From cfaba1dd6754d4238e1360247c198a64a313e96c Mon Sep 17 00:00:00 2001
From: Calcium-Ion
Date: Sat, 1 Aug 2026 09:35:51 +0800
Subject: [PATCH 01/99] fix(billing): harden tiered retry group-switch billing
(#6570)
Follow-up to #6518 (issue #6480) addressing three review findings:
- Document and lock in arrears semantics for the wallet Reserve top-up:
when an auto-group retry lands on a more expensive group, the full
reservation delta is deducted unconditionally (balance may go
negative), mirroring settlement, so the logged pre-consumed quota
always reconciles with the actual balance movement. Genuine DB
errors still fail the attempt with update_data_error. Subscription
funding keeps its insufficient-quota behavior: subscriptions enforce
a hard used<=total cap and do not support arrears.
- PriceData.FreeModel is cleared when a retry switches from a free
group to a paid one, keeping it consistent with the billing session
created at that point.
- getChannel refreshes GroupRatioInfo only after channel selection
succeeds, and the retry loop records the channel in use_channel
before PrepareTieredBillingForSelectedGroup can fail.
---
controller/relay.go | 7 +--
service/billing_session.go | 4 ++
service/tiered_settle.go | 12 +++-
service/tiered_settle_test.go | 109 ++++++++++++++++++++++++++++++++++
4 files changed, 127 insertions(+), 5 deletions(-)
diff --git a/controller/relay.go b/controller/relay.go
index 7e4270867463..8dccfe76dddd 100644
--- a/controller/relay.go
+++ b/controller/relay.go
@@ -199,12 +199,12 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
newAPIError = channelErr
break
}
+ addUsedChannel(c, channel.Id)
if billingErr := service.PrepareTieredBillingForSelectedGroup(c, relayInfo); billingErr != nil {
newAPIError = billingErr
break
}
- addUsedChannel(c, channel.Id)
bodyStorage, bodyErr := common.GetBodyStorage(c)
if bodyErr != nil {
// Ensure consistent 413 for oversized bodies even when error occurs later (e.g., retry path)
@@ -312,9 +312,6 @@ func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service
}, nil
}
channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(retryParam)
-
- info.PriceData.GroupRatioInfo = helper.HandleGroupRatio(c, info)
-
if err != nil {
return nil, types.NewError(fmt.Errorf("获取分组 %s 下模型 %s 的可用渠道失败(retry): %s", selectGroup, info.OriginModelName, err.Error()), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
}
@@ -322,6 +319,8 @@ func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service
return nil, types.NewError(fmt.Errorf("分组 %s 下模型 %s 的可用渠道不存在(retry)", selectGroup, info.OriginModelName), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
}
+ info.PriceData.GroupRatioInfo = helper.HandleGroupRatio(c, info)
+
newAPIError := middleware.SetupContextForSelectedChannel(c, channel, info.OriginModelName)
if newAPIError != nil {
return nil, newAPIError
diff --git a/service/billing_session.go b/service/billing_session.go
index 96afcf034e30..42b0ffbca8ec 100644
--- a/service/billing_session.go
+++ b/service/billing_session.go
@@ -232,6 +232,10 @@ func (s *BillingSession) preConsume(c *gin.Context, quota int) *types.NewAPIErro
func (s *BillingSession) reserveFunding(delta int) error {
switch funding := s.funding.(type) {
case *WalletFunding:
+ // 与结算补扣(SettleBilling 正差额 → WalletFunding.Settle)语义一致:
+ // 全额无条件扣减,余额不足的部分记为欠费(余额可为负),不中断请求,
+ // 保证日志记录的预扣额度与用户余额的实际变动始终对账一致。
+ // DecreaseUserQuota 仅在数据库错误时失败。
if err := model.DecreaseUserQuota(funding.userId, delta, false); err != nil {
return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
}
diff --git a/service/tiered_settle.go b/service/tiered_settle.go
index b31d7964f56c..1f3f58fe31c1 100644
--- a/service/tiered_settle.go
+++ b/service/tiered_settle.go
@@ -132,9 +132,19 @@ func PrepareTieredBillingForSelectedGroup(c *gin.Context, relayInfo *relaycommon
types.ErrOptionWithSkipRetry(),
)
}
- if snap == nil || snap.GroupRatio == 0 {
+ if snap == nil {
return nil
}
+ if snap.GroupRatio == 0 {
+ // Paid-to-free keeps FreeModel as-is: FreeModel means "pre-consume was
+ // skipped", which is not true once a session exists, and settlement
+ // already yields 0 for a zero group ratio.
+ return nil
+ }
+
+ // The selected group is paid; clear a FreeModel flag frozen when the
+ // initial group was free so downstream state stays consistent.
+ relayInfo.PriceData.FreeModel = false
if relayInfo.Billing == nil {
return PreConsumeBilling(c, snap.EstimatedQuotaAfterGroup, relayInfo)
diff --git a/service/tiered_settle_test.go b/service/tiered_settle_test.go
index fe3a30a08bf1..29a3bfb4fdab 100644
--- a/service/tiered_settle_test.go
+++ b/service/tiered_settle_test.go
@@ -389,6 +389,7 @@ func TestPrepareTieredBillingForSelectedGroupStartsBillingAfterFreeGroup(t *test
QuotaPerUnit: testQuotaPerUnit,
},
PriceData: types.PriceData{
+ FreeModel: true,
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 0.20},
},
}
@@ -396,6 +397,7 @@ func TestPrepareTieredBillingForSelectedGroupStartsBillingAfterFreeGroup(t *test
require.Nil(t, PrepareTieredBillingForSelectedGroup(ctx, relayInfo))
require.NotNil(t, relayInfo.Billing)
+ assert.False(t, relayInfo.PriceData.FreeModel, "FreeModel must be cleared after switching to a paid group")
assert.Equal(t, 100_000, relayInfo.FinalPreConsumedQuota)
assert.Equal(t, 0.20, relayInfo.TieredBillingSnapshot.GroupRatio)
assert.Equal(t, 100_000, relayInfo.TieredBillingSnapshot.EstimatedQuotaAfterGroup)
@@ -405,6 +407,113 @@ func TestPrepareTieredBillingForSelectedGroupStartsBillingAfterFreeGroup(t *test
assert.Equal(t, 400_000, userQuota)
}
+func TestPrepareTieredBillingForSelectedGroupPaidToFreeKeepsFreeModelFalse(t *testing.T) {
+ const expr = `tier("base", p)`
+ billing := &recordingBillingSettler{preConsumedQuota: 50_000}
+ relayInfo := &relaycommon.RelayInfo{
+ Billing: billing,
+ FinalPreConsumedQuota: 50_000,
+ TieredBillingSnapshot: &billingexpr.BillingSnapshot{
+ BillingMode: "tiered_expr",
+ ExprString: expr,
+ ExprHash: billingexpr.ExprHashString(expr),
+ GroupRatio: 0.10,
+ EstimatedQuotaBeforeGroup: 500_000,
+ EstimatedQuotaAfterGroup: 50_000,
+ QuotaPerUnit: testQuotaPerUnit,
+ },
+ PriceData: types.PriceData{
+ GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 0},
+ },
+ }
+
+ require.Nil(t, PrepareTieredBillingForSelectedGroup(nil, relayInfo))
+
+ // Pre-consume did happen under the paid group, so FreeModel stays false;
+ // settlement already yields 0 for GroupRatio == 0 and the session refunds.
+ assert.False(t, relayInfo.PriceData.FreeModel)
+ assert.Empty(t, billing.reserveTargets)
+ assert.Equal(t, 50_000, relayInfo.FinalPreConsumedQuota)
+}
+
+func TestPrepareTieredBillingForSelectedGroupTopUpArrearsAllowsNegativeBalance(t *testing.T) {
+ truncate(t)
+
+ const userID = 701
+ // Balance covers the initial 50k pre-consume (already deducted before this
+ // test's seed) but not the 50k top-up to the more expensive retry group.
+ // The top-up must NOT abort the request: the full delta is deducted, the
+ // uncovered 30k becomes arrears (negative balance), mirroring how
+ // settlement charges a positive delta unconditionally.
+ seedUser(t, userID, 20_000)
+
+ relayInfo := &relaycommon.RelayInfo{
+ UserId: userID,
+ IsPlayground: true,
+ FinalPreConsumedQuota: 50_000,
+ TieredBillingSnapshot: &billingexpr.BillingSnapshot{
+ BillingMode: "tiered_expr",
+ ExprString: `tier("base", p)`,
+ ExprHash: billingexpr.ExprHashString(`tier("base", p)`),
+ GroupRatio: 0.10,
+ EstimatedQuotaBeforeGroup: 500_000,
+ EstimatedQuotaAfterGroup: 50_000,
+ QuotaPerUnit: testQuotaPerUnit,
+ },
+ PriceData: types.PriceData{
+ GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 0.20},
+ },
+ }
+ session := &BillingSession{
+ relayInfo: relayInfo,
+ funding: &WalletFunding{userId: userID, consumed: 50_000},
+ preConsumedQuota: 50_000,
+ }
+ relayInfo.Billing = session
+
+ require.Nil(t, PrepareTieredBillingForSelectedGroup(nil, relayInfo))
+
+ // Full reservation recorded; wallet charged the full delta into arrears.
+ assert.Equal(t, 100_000, session.GetPreConsumedQuota())
+ assert.Equal(t, 100_000, relayInfo.FinalPreConsumedQuota)
+ assert.Equal(t, 100_000, relayInfo.TieredBillingSnapshot.EstimatedQuotaAfterGroup)
+ userQuota, err := model.GetUserQuota(userID, false)
+ require.NoError(t, err)
+ assert.Equal(t, -30_000, userQuota)
+
+ // Settlement still reconciles against the full reservation: actual 80k
+ // refunds the 20k over-reserve, landing at seed - (actual - initial) = -10k.
+ require.NoError(t, session.Settle(80_000))
+ userQuota, err = model.GetUserQuota(userID, false)
+ require.NoError(t, err)
+ assert.Equal(t, -10_000, userQuota)
+}
+
+func TestBillingSessionReserveWalletTopUpDecrementsBalance(t *testing.T) {
+ truncate(t)
+
+ const userID = 702
+ seedUser(t, userID, 500_000)
+
+ relayInfo := &relaycommon.RelayInfo{
+ UserId: userID,
+ IsPlayground: true,
+ }
+ session := &BillingSession{
+ relayInfo: relayInfo,
+ funding: &WalletFunding{userId: userID, consumed: 50_000},
+ preConsumedQuota: 50_000,
+ }
+
+ require.NoError(t, session.Reserve(100_000))
+
+ assert.Equal(t, 100_000, session.GetPreConsumedQuota())
+ assert.Equal(t, 100_000, relayInfo.FinalPreConsumedQuota)
+ userQuota, err := model.GetUserQuota(userID, false)
+ require.NoError(t, err)
+ assert.Equal(t, 450_000, userQuota)
+}
+
func TestTryTieredSettleUsesFinalGroupAfterRetry(t *testing.T) {
const expr = `tier("base", p)`
tests := []struct {
From bd585d78efd418aaf7baa7e34fa48c5536581868 Mon Sep 17 00:00:00 2001
From: Calcium-Ion
Date: Sat, 1 Aug 2026 22:39:54 +0800
Subject: [PATCH 02/99] fix(aws): cancel Bedrock requests on client disconnect
(#6589)
* fix(aws): cancel Bedrock requests on client disconnect
* fix(billing): log effective usage billing path
---
relay/channel/aws/relay-aws.go | 78 ++++--
relay/channel/aws/relay_aws_test.go | 403 ++++++++++++++++++++++++++++
service/billing_usage.go | 26 +-
service/text_quota_test.go | 15 +-
4 files changed, 479 insertions(+), 43 deletions(-)
diff --git a/relay/channel/aws/relay-aws.go b/relay/channel/aws/relay-aws.go
index c502364c6adf..c4751b5af855 100644
--- a/relay/channel/aws/relay-aws.go
+++ b/relay/channel/aws/relay-aws.go
@@ -40,11 +40,24 @@ func getAwsErrorStatusCode(err error) int {
return http.StatusInternalServerError
}
-func newAwsInvokeContext() (context.Context, context.CancelFunc) {
+func newAwsInvokeContext(parent context.Context) (context.Context, context.CancelFunc) {
if common.RelayTimeout <= 0 {
- return context.Background(), func() {}
+ return context.WithCancel(parent)
}
- return context.WithTimeout(context.Background(), time.Duration(common.RelayTimeout)*time.Second)
+ return context.WithTimeout(parent, time.Duration(common.RelayTimeout)*time.Second)
+}
+
+func newAwsInvokeError(requestContext context.Context, err error, operation string) *types.NewAPIError {
+ options := make([]types.NewAPIErrorOptions, 0, 1)
+ if requestContext.Err() != nil {
+ options = append(options, types.ErrOptionWithSkipRetry())
+ }
+ return types.NewOpenAIError(
+ errors.Wrap(err, operation),
+ types.ErrorCodeAwsInvokeError,
+ getAwsErrorStatusCode(err),
+ options...,
+ )
}
func newAwsClient(c *gin.Context, info *relaycommon.RelayInfo) (*bedrockruntime.Client, error) {
@@ -215,13 +228,13 @@ func getAwsModelID(requestModel string) string {
func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor) (*types.NewAPIError, *dto.Usage) {
- ctx, cancel := newAwsInvokeContext()
+ requestContext := c.Request.Context()
+ ctx, cancel := newAwsInvokeContext(requestContext)
defer cancel()
awsResp, err := a.AwsClient.InvokeModel(ctx, a.AwsReq.(*bedrockruntime.InvokeModelInput))
if err != nil {
- statusCode := getAwsErrorStatusCode(err)
- return types.NewOpenAIError(errors.Wrap(err, "InvokeModel"), types.ErrorCodeAwsInvokeError, statusCode), nil
+ return newAwsInvokeError(requestContext, err, "InvokeModel"), nil
}
claudeInfo := &claude.ClaudeResponseInfo{
@@ -245,13 +258,13 @@ func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor) (*types
}
func awsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor) (*types.NewAPIError, *dto.Usage) {
- ctx, cancel := newAwsInvokeContext()
+ requestContext := c.Request.Context()
+ ctx, cancel := newAwsInvokeContext(requestContext)
defer cancel()
awsResp, err := a.AwsClient.InvokeModelWithResponseStream(ctx, a.AwsReq.(*bedrockruntime.InvokeModelWithResponseStreamInput))
if err != nil {
- statusCode := getAwsErrorStatusCode(err)
- return types.NewOpenAIError(errors.Wrap(err, "InvokeModelWithResponseStream"), types.ErrorCodeAwsInvokeError, statusCode), nil
+ return newAwsInvokeError(requestContext, err, "InvokeModelWithResponseStream"), nil
}
stream := awsResp.GetStream()
defer stream.Close()
@@ -264,23 +277,38 @@ func awsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor) (
Usage: &dto.Usage{},
}
- for event := range stream.Events() {
- switch v := event.(type) {
- case *bedrockruntimeTypes.ResponseStreamMemberChunk:
- info.SetFirstResponseTime()
- respErr := claude.HandleStreamResponseData(c, info, claudeInfo, string(v.Value.Bytes))
- if respErr != nil {
- return respErr, nil
+ events := stream.Events()
+streamLoop:
+ for {
+ select {
+ case <-ctx.Done():
+ break streamLoop
+ case event, ok := <-events:
+ if !ok {
+ break streamLoop
+ }
+ if ctx.Err() != nil {
+ break streamLoop
+ }
+
+ switch v := event.(type) {
+ case *bedrockruntimeTypes.ResponseStreamMemberChunk:
+ info.SetFirstResponseTime()
+ respErr := claude.HandleStreamResponseData(c, info, claudeInfo, string(v.Value.Bytes))
+ if respErr != nil {
+ return respErr, nil
+ }
+ case *bedrockruntimeTypes.UnknownUnionMember:
+ fmt.Println("unknown tag:", v.Tag)
+ return types.NewError(errors.New("unknown response type"), types.ErrorCodeInvalidRequest), nil
+ default:
+ fmt.Println("union is nil or unknown type")
+ return types.NewError(errors.New("nil or unknown response type"), types.ErrorCodeInvalidRequest), nil
}
- case *bedrockruntimeTypes.UnknownUnionMember:
- fmt.Println("unknown tag:", v.Tag)
- return types.NewError(errors.New("unknown response type"), types.ErrorCodeInvalidRequest), nil
- default:
- fmt.Println("union is nil or unknown type")
- return types.NewError(errors.New("nil or unknown response type"), types.ErrorCodeInvalidRequest), nil
}
}
+ _ = stream.Close()
claude.HandleStreamFinalResponse(c, info, claudeInfo)
return nil, claudeInfo.Usage
}
@@ -288,13 +316,13 @@ func awsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor) (
// Nova模型处理函数
func handleNovaRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor) (*types.NewAPIError, *dto.Usage) {
- ctx, cancel := newAwsInvokeContext()
+ requestContext := c.Request.Context()
+ ctx, cancel := newAwsInvokeContext(requestContext)
defer cancel()
awsResp, err := a.AwsClient.InvokeModel(ctx, a.AwsReq.(*bedrockruntime.InvokeModelInput))
if err != nil {
- statusCode := getAwsErrorStatusCode(err)
- return types.NewOpenAIError(errors.Wrap(err, "InvokeModel"), types.ErrorCodeAwsInvokeError, statusCode), nil
+ return newAwsInvokeError(requestContext, err, "InvokeModel"), nil
}
// 解析Nova响应
diff --git a/relay/channel/aws/relay_aws_test.go b/relay/channel/aws/relay_aws_test.go
index 92745ff40929..22d8373873ed 100644
--- a/relay/channel/aws/relay_aws_test.go
+++ b/relay/channel/aws/relay_aws_test.go
@@ -2,17 +2,145 @@ package aws
import (
"bytes"
+ "context"
+ "errors"
+ "io"
"net/http"
"net/http/httptest"
+ "sync"
"testing"
+ "time"
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ relaytypes "github.com/QuantumNous/new-api/relaykit/types"
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream"
+ "github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream/eventstreamapi"
+ "github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime"
"github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+const awsTestModel = "anthropic.claude-3-5-sonnet-20240620-v1:0"
+
+type awsHTTPClientFunc func(*http.Request) (*http.Response, error)
+
+func (f awsHTTPClientFunc) Do(request *http.Request) (*http.Response, error) {
+ return f(request)
+}
+
+type awsNotifyingResponseWriter struct {
+ *httptest.ResponseRecorder
+ notifyOn []byte
+ notified chan int
+ once sync.Once
+}
+
+func newAwsNotifyingResponseWriter(notifyOn string) *awsNotifyingResponseWriter {
+ return &awsNotifyingResponseWriter{
+ ResponseRecorder: httptest.NewRecorder(),
+ notifyOn: []byte(notifyOn),
+ notified: make(chan int, 1),
+ }
+}
+
+func (w *awsNotifyingResponseWriter) Write(data []byte) (int, error) {
+ return w.ResponseRecorder.Write(data)
+}
+
+func (w *awsNotifyingResponseWriter) Flush() {
+ w.ResponseRecorder.Flush()
+ if bytes.Contains(w.Body.Bytes(), w.notifyOn) {
+ w.once.Do(func() {
+ w.notified <- w.Body.Len()
+ })
+ }
+}
+
+func newAwsTestClient(httpClient bedrockruntime.HTTPClient) *bedrockruntime.Client {
+ return bedrockruntime.New(bedrockruntime.Options{
+ Region: "us-east-1",
+ BaseEndpoint: aws.String("https://bedrock.test"),
+ Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(
+ "access-key", "secret-key", "",
+ )),
+ HTTPClient: httpClient,
+ Retryer: aws.NopRetryer{},
+ })
+}
+
+func newAwsTestContext(writer http.ResponseWriter, requestContext context.Context) *gin.Context {
+ c, _ := gin.CreateTestContext(writer)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(requestContext)
+ return c
+}
+
+func newAwsTestRelayInfo() *relaycommon.RelayInfo {
+ return &relaycommon.RelayInfo{
+ StartTime: time.Now(),
+ IsStream: true,
+ OriginModelName: awsTestModel,
+ RelayFormat: relaytypes.RelayFormatOpenAI,
+ ShouldIncludeUsage: true,
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: awsTestModel,
+ },
+ }
+}
+
+func newAwsInvokeModelInput() *bedrockruntime.InvokeModelInput {
+ return &bedrockruntime.InvokeModelInput{
+ ModelId: aws.String(awsTestModel),
+ Body: []byte(`{}`),
+ Accept: aws.String("application/json"),
+ ContentType: aws.String("application/json"),
+ }
+}
+
+func newAwsStreamInput() *bedrockruntime.InvokeModelWithResponseStreamInput {
+ return &bedrockruntime.InvokeModelWithResponseStreamInput{
+ ModelId: aws.String(awsTestModel),
+ Body: []byte(`{}`),
+ Accept: aws.String("application/json"),
+ ContentType: aws.String("application/json"),
+ }
+}
+
+func writeAwsStreamEvent(writer io.Writer, data string) error {
+ payload, err := common.Marshal(struct {
+ Bytes []byte `json:"bytes"`
+ }{Bytes: []byte(data)})
+ if err != nil {
+ return err
+ }
+
+ return eventstream.NewEncoder().Encode(writer, eventstream.Message{
+ Headers: eventstream.Headers{
+ {Name: eventstreamapi.MessageTypeHeader, Value: eventstream.StringValue(eventstreamapi.EventMessageType)},
+ {Name: eventstreamapi.EventTypeHeader, Value: eventstream.StringValue("chunk")},
+ {Name: eventstreamapi.ContentTypeHeader, Value: eventstream.StringValue("application/json")},
+ },
+ Payload: payload,
+ })
+}
+
+func newAwsStreamResponse(request *http.Request, body io.ReadCloser) *http.Response {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Status: "200 OK",
+ Header: http.Header{
+ "Content-Type": []string{"application/vnd.amazon.eventstream"},
+ "X-Amzn-Bedrock-Content-Type": []string{"application/json"},
+ },
+ Body: body,
+ Request: request,
+ }
+}
+
func TestDoAwsClientRequest_AppliesRuntimeHeaderOverrideToAnthropicBeta(t *testing.T) {
t.Parallel()
@@ -53,3 +181,278 @@ func TestDoAwsClientRequest_AppliesRuntimeHeaderOverrideToAnthropicBeta(t *testi
require.True(t, ok)
require.Equal(t, []any{"computer-use-2025-01-24"}, values)
}
+
+func TestNewAwsInvokeContextInheritsParent(t *testing.T) {
+ originalRelayTimeout := common.RelayTimeout
+ t.Cleanup(func() {
+ common.RelayTimeout = originalRelayTimeout
+ })
+
+ tests := []struct {
+ name string
+ relayTimeout int
+ wantDeadline bool
+ }{
+ {name: "without relay timeout", relayTimeout: 0, wantDeadline: false},
+ {name: "with relay timeout", relayTimeout: 30, wantDeadline: true},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ common.RelayTimeout = test.relayTimeout
+ parent, cancelParent := context.WithCancel(context.Background())
+ invokeContext, cancelInvoke := newAwsInvokeContext(parent)
+ defer cancelInvoke()
+
+ _, hasDeadline := invokeContext.Deadline()
+ assert.Equal(t, test.wantDeadline, hasDeadline)
+
+ cancelParent()
+ require.ErrorIs(t, invokeContext.Err(), context.Canceled)
+ })
+ }
+}
+
+func TestNewAwsInvokeErrorSkipsRetryOnlyForClientCancellation(t *testing.T) {
+ canceledContext, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ tests := []struct {
+ name string
+ requestContext context.Context
+ err error
+ wantSkipRetry bool
+ }{
+ {
+ name: "client context canceled",
+ requestContext: canceledContext,
+ err: context.Canceled,
+ wantSkipRetry: true,
+ },
+ {
+ name: "relay timeout with live client context",
+ requestContext: context.Background(),
+ err: context.DeadlineExceeded,
+ wantSkipRetry: false,
+ },
+ {
+ name: "upstream error with live client context",
+ requestContext: context.Background(),
+ err: errors.New("upstream failed"),
+ wantSkipRetry: false,
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ err := newAwsInvokeError(test.requestContext, test.err, "InvokeModel")
+ assert.Equal(t, test.wantSkipRetry, relaytypes.IsSkipRetryError(err))
+ })
+ }
+}
+
+func TestAwsHandlersCancelSdkRequestAndSkipRetry(t *testing.T) {
+ originalRelayTimeout := common.RelayTimeout
+ common.RelayTimeout = 0
+ t.Cleanup(func() {
+ common.RelayTimeout = originalRelayTimeout
+ })
+
+ tests := []struct {
+ name string
+ request any
+ handle func(*gin.Context, *relaycommon.RelayInfo, *Adaptor) (*relaytypes.NewAPIError, *dto.Usage)
+ }{
+ {name: "non-stream", request: newAwsInvokeModelInput(), handle: awsHandler},
+ {name: "stream", request: newAwsStreamInput(), handle: awsStreamHandler},
+ {name: "nova", request: newAwsInvokeModelInput(), handle: handleNovaRequest},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ requestContext, cancelRequest := context.WithCancel(context.Background())
+ t.Cleanup(cancelRequest)
+
+ upstreamContexts := make(chan context.Context, 1)
+ client := newAwsTestClient(awsHTTPClientFunc(func(request *http.Request) (*http.Response, error) {
+ upstreamContexts <- request.Context()
+ <-request.Context().Done()
+ return nil, request.Context().Err()
+ }))
+ adaptor := &Adaptor{AwsClient: client, AwsReq: test.request}
+ c := newAwsTestContext(httptest.NewRecorder(), requestContext)
+ info := newAwsTestRelayInfo()
+
+ type handlerResult struct {
+ err *relaytypes.NewAPIError
+ usage *dto.Usage
+ }
+ results := make(chan handlerResult, 1)
+ go func() {
+ err, usage := test.handle(c, info, adaptor)
+ results <- handlerResult{err: err, usage: usage}
+ }()
+
+ var upstreamContext context.Context
+ select {
+ case upstreamContext = <-upstreamContexts:
+ case result := <-results:
+ t.Fatalf("handler returned before issuing AWS request: %v", result.err)
+ case <-time.After(5 * time.Second):
+ t.Fatal("AWS request did not start")
+ }
+
+ cancelRequest()
+
+ var result handlerResult
+ select {
+ case result = <-results:
+ case <-time.After(5 * time.Second):
+ t.Fatal("handler did not stop after client cancellation")
+ }
+
+ require.ErrorIs(t, upstreamContext.Err(), context.Canceled)
+ require.NotNil(t, result.err)
+ assert.True(t, relaytypes.IsSkipRetryError(result.err))
+ assert.Nil(t, result.usage)
+ })
+ }
+}
+
+func TestAwsStreamHandlerUsesFinalUpstreamUsage(t *testing.T) {
+ originalRelayTimeout := common.RelayTimeout
+ common.RelayTimeout = 0
+ t.Cleanup(func() {
+ common.RelayTimeout = originalRelayTimeout
+ })
+
+ events := []string{
+ `{"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","model":"claude-test","content":[],"usage":{"input_tokens":100,"output_tokens":1}}}`,
+ `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
+ `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial"}}`,
+ `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":423}}`,
+ `{"type":"message_stop"}`,
+ }
+ client := newAwsTestClient(awsHTTPClientFunc(func(request *http.Request) (*http.Response, error) {
+ var body bytes.Buffer
+ for _, event := range events {
+ if err := writeAwsStreamEvent(&body, event); err != nil {
+ return nil, err
+ }
+ }
+ return newAwsStreamResponse(request, io.NopCloser(bytes.NewReader(body.Bytes()))), nil
+ }))
+ adaptor := &Adaptor{AwsClient: client, AwsReq: newAwsStreamInput()}
+ recorder := httptest.NewRecorder()
+ c := newAwsTestContext(recorder, context.Background())
+
+ handlerErr, usage := awsStreamHandler(c, newAwsTestRelayInfo(), adaptor)
+
+ require.Nil(t, handlerErr)
+ require.NotNil(t, usage)
+ require.NotNil(t, usage.BillingUsage)
+ require.NotNil(t, usage.BillingUsage.ClaudeUsage)
+ assert.Equal(t, 100, usage.BillingUsage.ClaudeUsage.InputTokens)
+ assert.Equal(t, 423, usage.BillingUsage.ClaudeUsage.OutputTokens)
+ assert.Contains(t, recorder.Body.String(), "[DONE]")
+}
+
+func TestAwsStreamHandlerStopsAtClientCancellationAndKeepsPartialBillingUsage(t *testing.T) {
+ originalRelayTimeout := common.RelayTimeout
+ common.RelayTimeout = 0
+ t.Cleanup(func() {
+ common.RelayTimeout = originalRelayTimeout
+ })
+
+ requestContext, cancelRequest := context.WithCancel(context.Background())
+ t.Cleanup(cancelRequest)
+ releaseFinal := make(chan struct{})
+ var releaseFinalOnce sync.Once
+ release := func() {
+ releaseFinalOnce.Do(func() {
+ close(releaseFinal)
+ })
+ }
+ t.Cleanup(release)
+
+ producerResults := make(chan error, 1)
+ upstreamContexts := make(chan context.Context, 1)
+ client := newAwsTestClient(awsHTTPClientFunc(func(request *http.Request) (*http.Response, error) {
+ upstreamContexts <- request.Context()
+ reader, writer := io.Pipe()
+ go func() {
+ defer writer.Close()
+ initialEvents := []string{
+ `{"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","model":"claude-test","content":[],"usage":{"input_tokens":100,"output_tokens":1}}}`,
+ `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
+ `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial"}}`,
+ }
+ for _, event := range initialEvents {
+ if err := writeAwsStreamEvent(writer, event); err != nil {
+ producerResults <- err
+ return
+ }
+ }
+
+ <-releaseFinal
+ producerResults <- writeAwsStreamEvent(writer, `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":423}}`)
+ }()
+ return newAwsStreamResponse(request, reader), nil
+ }))
+
+ responseWriter := newAwsNotifyingResponseWriter("partial")
+ c := newAwsTestContext(responseWriter, requestContext)
+ adaptor := &Adaptor{AwsClient: client, AwsReq: newAwsStreamInput()}
+
+ type handlerResult struct {
+ err *relaytypes.NewAPIError
+ usage *dto.Usage
+ }
+ results := make(chan handlerResult, 1)
+ go func() {
+ err, usage := awsStreamHandler(c, newAwsTestRelayInfo(), adaptor)
+ results <- handlerResult{err: err, usage: usage}
+ }()
+
+ var upstreamContext context.Context
+ select {
+ case upstreamContext = <-upstreamContexts:
+ case <-time.After(5 * time.Second):
+ t.Fatal("AWS stream request did not start")
+ }
+
+ var bodyLengthBeforeCancel int
+ select {
+ case bodyLengthBeforeCancel = <-responseWriter.notified:
+ case <-time.After(5 * time.Second):
+ t.Fatal("partial response was not written")
+ }
+ cancelRequest()
+
+ var result handlerResult
+ select {
+ case result = <-results:
+ case <-time.After(5 * time.Second):
+ t.Fatal("stream handler did not stop after client cancellation")
+ }
+
+ require.ErrorIs(t, upstreamContext.Err(), context.Canceled)
+ require.Nil(t, result.err)
+ require.NotNil(t, result.usage)
+ require.NotNil(t, result.usage.BillingUsage)
+ require.NotNil(t, result.usage.BillingUsage.ClaudeUsage)
+ assert.Equal(t, dto.BillingUsageSourceClaudeMessages, result.usage.BillingUsage.Source)
+ assert.Equal(t, dto.BillingUsageSemanticAnthropic, result.usage.BillingUsage.Semantic)
+ assert.Equal(t, 100, result.usage.BillingUsage.ClaudeUsage.InputTokens)
+ assert.Equal(t, 1, result.usage.BillingUsage.ClaudeUsage.OutputTokens)
+ assert.Equal(t, bodyLengthBeforeCancel, responseWriter.Body.Len())
+ assert.NotContains(t, responseWriter.Body.String(), "[DONE]")
+
+ release()
+ select {
+ case producerErr := <-producerResults:
+ require.Error(t, producerErr)
+ case <-time.After(5 * time.Second):
+ t.Fatal("upstream producer did not observe the closed stream")
+ }
+}
diff --git a/service/billing_usage.go b/service/billing_usage.go
index 2766178d68c4..12656e693708 100644
--- a/service/billing_usage.go
+++ b/service/billing_usage.go
@@ -25,36 +25,32 @@ func effectiveBillingUsage(usage *dto.Usage) *dto.Usage {
}
func usageBillingPathForLog(isLocalCountTokens bool, usage *dto.Usage) string {
- if isLocalCountTokens {
- return usageBillingPathLocal
- }
- if usage == nil || usage.BillingUsage == nil {
+ effectiveUsage, ok := usageFromBillingUsage(usage)
+ if !ok {
+ if isLocalCountTokens {
+ return usageBillingPathLocal
+ }
return usageBillingPathUpstream
}
- source := strings.TrimSpace(usage.BillingUsage.Source)
- semantic := strings.TrimSpace(usage.BillingUsage.Semantic)
- if strings.EqualFold(source, dto.BillingUsageSourceOAIChat) ||
- strings.EqualFold(source, dto.BillingUsageSourceOAIResponses) ||
- strings.EqualFold(semantic, dto.BillingUsageSemanticOpenAI) {
+
+ switch effectiveUsage.UsageSemantic {
+ case dto.BillingUsageSemanticOpenAI:
if usage.BillingUsage.Estimated {
return usageBillingPathOpenAIEstimated
}
return usageBillingPathOpenAI
- }
- if strings.EqualFold(source, dto.BillingUsageSourceClaudeMessages) ||
- strings.EqualFold(semantic, dto.BillingUsageSemanticAnthropic) {
+ case dto.BillingUsageSemanticAnthropic:
if usage.BillingUsage.Estimated {
return usageBillingPathAnthropicEstimated
}
return usageBillingPathAnthropic
- }
- if strings.EqualFold(source, dto.BillingUsageSourceGeminiChat) ||
- strings.EqualFold(semantic, dto.BillingUsageSemanticGemini) {
+ case dto.BillingUsageSemanticGemini:
if usage.BillingUsage.Estimated {
return usageBillingPathGeminiEstimated
}
return usageBillingPathGemini
}
+
return usageBillingPathUpstream
}
diff --git a/service/text_quota_test.go b/service/text_quota_test.go
index 5a908af2d485..c9e958e7bf2b 100644
--- a/service/text_quota_test.go
+++ b/service/text_quota_test.go
@@ -284,9 +284,18 @@ func TestCalculateTextQuotaSummaryUsesOpenAIBillingUsageBeforeTopLevelUsage(t *t
}
func TestUsageBillingPathForLog(t *testing.T) {
- require.Equal(t, usageBillingPathLocal, usageBillingPathForLog(true, &dto.Usage{
+ require.Equal(t, usageBillingPathAnthropic, usageBillingPathForLog(true, &dto.Usage{
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{InputTokens: 1}),
}))
+ invalidBillingUsage := &dto.Usage{
+ PromptTokens: 1,
+ BillingUsage: &dto.BillingUsage{
+ Source: dto.BillingUsageSourceClaudeMessages,
+ Semantic: dto.BillingUsageSemanticAnthropic,
+ },
+ }
+ require.Equal(t, usageBillingPathLocal, usageBillingPathForLog(true, invalidBillingUsage))
+ require.Equal(t, usageBillingPathUpstream, usageBillingPathForLog(false, invalidBillingUsage))
require.Equal(t, usageBillingPathUpstream, usageBillingPathForLog(false, &dto.Usage{}))
require.Equal(t, usageBillingPathOpenAI, usageBillingPathForLog(false, &dto.Usage{
BillingUsage: dto.NewOpenAIChatBillingUsage(&dto.Usage{PromptTokens: 1}),
@@ -297,7 +306,7 @@ func TestUsageBillingPathForLog(t *testing.T) {
require.Equal(t, usageBillingPathGemini, usageBillingPathForLog(false, &dto.Usage{
BillingUsage: dto.NewGeminiChatBillingUsage(&dto.GeminiUsageMetadata{PromptTokenCount: 1}),
}))
- require.Equal(t, usageBillingPathGeminiEstimated, usageBillingPathForLog(false, &dto.Usage{
+ require.Equal(t, usageBillingPathGeminiEstimated, usageBillingPathForLog(true, &dto.Usage{
BillingUsage: dto.NewEstimatedGeminiChatBillingUsage(&dto.Usage{PromptTokens: 1}),
}))
}
@@ -306,7 +315,7 @@ func TestAppendUsageBillingPathForLogWritesAdminInfo(t *testing.T) {
other := map[string]interface{}{
"admin_info": map[string]interface{}{},
}
- appendUsageBillingPathForLog(other, false, &dto.Usage{
+ appendUsageBillingPathForLog(other, true, &dto.Usage{
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{InputTokens: 1}),
})
From 0ab02020603d22e5613bc4cf46bfab06f8567769 Mon Sep 17 00:00:00 2001
From: Calcium-Ion
Date: Sat, 1 Aug 2026 23:19:01 +0800
Subject: [PATCH 03/99] Feat/auto group (#6590)
* feat(token): support custom auto group order
* feat(keys): enhance auto group presentation
* fix(keys): rework Auto flow border and compact inherited order
The Auto group highlight previously tinted the whole control surface
with a gradient and animated only a 1px top sweep, which read as a
background color rather than a flowing border. Replace it with a
border-only effect: an aria-hidden, pointer-events-none overlay whose
conic gradient is masked down to a thin ring hugging the rounded
perimeter, so the highlight travels around all four edges and corners
every 3.2s. The interior stays neutral with a restrained static
primary border and glow; prefers-reduced-motion hides the moving
layer while keeping the static emphasis.
The inherited global Auto order also rendered as spacious two-line
rows with circular sequence markers, wasting drawer space. Render it
as a compact wrapping strip of one-line chips (index, name, ratio
badge) with descriptions kept accessible via title and sr-only text,
scrolling only past a much smaller max height.
Custom add/remove/reorder editing, empty-array inheritance semantics,
and the submit payload are unchanged.
* fix(keys): preserve Auto inheritance and unify effects
* refactor(keys): temporarily disable AutoGroupBadge in api-key-group-cell
---
constant/context_key.go | 1 +
controller/model.go | 45 +-
controller/model_list_test.go | 70 ++-
controller/model_owned_by_test.go | 33 ++
controller/token.go | 132 ++++-
controller/token_auto_groups_test.go | 234 ++++++++
controller/token_test.go | 40 ++
i18n/keys.go | 3 +
i18n/locales/en.yaml | 3 +
i18n/locales/zh-CN.yaml | 3 +
i18n/locales/zh-TW.yaml | 3 +
middleware/auth.go | 10 +
middleware/distributor.go | 2 +-
middleware/token_auto_groups_context_test.go | 48 ++
model/option.go | 6 +
model/option_auto_group_test.go | 17 +
model/token.go | 45 +-
model/token_auto_groups_cache_test.go | 57 ++
router/api-router.go | 1 +
service/channel_select.go | 5 +-
service/channel_select_auto_groups_test.go | 129 +++++
service/group.go | 58 +-
service/group_auto_groups_test.go | 72 +++
setting/auto_group.go | 33 ++
setting/auto_group_test.go | 29 +
web/src/features/keys/api.ts | 9 +
.../__tests__/api-key-group-cell.test.tsx | 236 ++++++++
.../__tests__/api-key-group-combobox.test.tsx | 294 ++++++++++
.../__tests__/api-keys-mutate-drawer.test.tsx | 371 ++++++++++++
.../auto-group-order-editor.test.tsx | 540 ++++++++++++++++++
.../keys/components/api-key-group-cell.tsx | 90 +++
.../components/api-key-group-combobox.tsx | 146 ++---
.../keys/components/api-keys-columns.tsx | 53 +-
.../components/api-keys-mutate-drawer.tsx | 216 ++++++-
.../components/auto-group-order-editor.tsx | 338 +++++++++++
.../keys/components/auto-group-visuals.tsx | 140 +++++
.../lib/__tests__/auto-group-form.test.ts | 189 ++++++
web/src/features/keys/lib/api-key-form.ts | 63 +-
web/src/features/keys/types.ts | 7 +
.../drawers/model-mutate-drawer.tsx | 1 +
.../system-settings/billing/index.tsx | 1 +
.../billing/section-registry.tsx | 1 +
.../group-auto-limit-validation.test.ts | 40 ++
.../models/group-ratio-form.tsx | 56 ++
.../models/group-ratio-visual-editor.tsx | 12 +-
.../features/system-settings/models/index.tsx | 1 +
.../models/ratio-settings-card.tsx | 7 +
web/src/features/system-settings/types.ts | 2 +
.../system-settings/utils/numeric-field.ts | 5 +
web/src/i18n/locales/en.json | 27 +-
web/src/i18n/locales/fr.json | 27 +-
web/src/i18n/locales/ja.json | 27 +-
web/src/i18n/locales/ru.json | 27 +-
web/src/i18n/locales/vi.json | 27 +-
web/src/i18n/locales/zh-TW.json | 27 +-
web/src/i18n/locales/zh.json | 27 +-
web/src/styles/index.css | 46 ++
57 files changed, 3922 insertions(+), 210 deletions(-)
create mode 100644 controller/token_auto_groups_test.go
create mode 100644 middleware/token_auto_groups_context_test.go
create mode 100644 model/option_auto_group_test.go
create mode 100644 model/token_auto_groups_cache_test.go
create mode 100644 service/channel_select_auto_groups_test.go
create mode 100644 service/group_auto_groups_test.go
create mode 100644 setting/auto_group_test.go
create mode 100644 web/src/features/keys/components/__tests__/api-key-group-cell.test.tsx
create mode 100644 web/src/features/keys/components/__tests__/api-key-group-combobox.test.tsx
create mode 100644 web/src/features/keys/components/__tests__/api-keys-mutate-drawer.test.tsx
create mode 100644 web/src/features/keys/components/__tests__/auto-group-order-editor.test.tsx
create mode 100644 web/src/features/keys/components/api-key-group-cell.tsx
create mode 100644 web/src/features/keys/components/auto-group-order-editor.tsx
create mode 100644 web/src/features/keys/components/auto-group-visuals.tsx
create mode 100644 web/src/features/keys/lib/__tests__/auto-group-form.test.ts
create mode 100644 web/src/features/system-settings/models/__tests__/group-auto-limit-validation.test.ts
diff --git a/constant/context_key.go b/constant/context_key.go
index b856bc3dda14..ccb8010f9476 100644
--- a/constant/context_key.go
+++ b/constant/context_key.go
@@ -19,6 +19,7 @@ const (
ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled"
ContextKeyTokenModelLimit ContextKey = "token_model_limit"
ContextKeyTokenCrossGroupRetry ContextKey = "token_cross_group_retry"
+ ContextKeyTokenAutoGroups ContextKey = "token_auto_groups"
/* channel related keys */
ContextKeyChannelId ContextKey = "channel_id"
diff --git a/controller/model.go b/controller/model.go
index b32eebd7daac..1d759301bc7e 100644
--- a/controller/model.go
+++ b/controller/model.go
@@ -20,6 +20,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/operation_setting"
+ "github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
@@ -190,7 +191,7 @@ func getModelListGroups(c *gin.Context) (modelListGroups, error) {
return modelListGroups{
userGroup: userGroup,
tokenGroup: tokenGroup,
- ownerGroups: service.GetUserAutoGroup(userGroup),
+ ownerGroups: service.GetRequestAutoGroups(c, userGroup),
}, nil
}
@@ -228,32 +229,28 @@ func ListModels(c *gin.Context, modelType int) {
}
ownerGroups := groups.ownerGroups
modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled)
+ var tokenModelLimit map[string]bool
if modelLimitEnable {
s, ok := common.GetContextKey(c, constant.ContextKeyTokenModelLimit)
- var tokenModelLimit map[string]bool
if ok {
- tokenModelLimit = s.(map[string]bool)
- } else {
+ tokenModelLimit, _ = s.(map[string]bool)
+ }
+ if tokenModelLimit == nil {
tokenModelLimit = map[string]bool{}
}
- for allowModel, _ := range tokenModelLimit {
- if !acceptUnsetRatioModel {
- if !helper.HasModelBillingConfig(allowModel) {
- continue
- }
+ }
+ models := service.GetGroupsEnabledModels(ownerGroups)
+ for _, modelName := range models {
+ if modelLimitEnable {
+ matchingName := ratio_setting.FormatMatchingModelName(modelName)
+ if !tokenModelLimit[modelName] && !tokenModelLimit[matchingName] {
+ continue
}
- userModelNames = append(userModelNames, allowModel)
}
- } else {
- models := service.GetGroupsEnabledModels(ownerGroups)
- for _, modelName := range models {
- if !acceptUnsetRatioModel {
- if !helper.HasModelBillingConfig(modelName) {
- continue
- }
- }
- userModelNames = append(userModelNames, modelName)
+ if !acceptUnsetRatioModel && !helper.HasModelBillingConfig(modelName) {
+ continue
}
+ userModelNames = append(userModelNames, modelName)
}
ownerByModel := map[string]string{}
@@ -276,11 +273,17 @@ func ListModels(c *gin.Context, modelType int) {
Type: "model",
}
}
+ firstID := ""
+ lastID := ""
+ if len(useranthropicModels) > 0 {
+ firstID = useranthropicModels[0].ID
+ lastID = useranthropicModels[len(useranthropicModels)-1].ID
+ }
c.JSON(200, gin.H{
"data": useranthropicModels,
- "first_id": useranthropicModels[0].ID,
+ "first_id": firstID,
"has_more": false,
- "last_id": useranthropicModels[len(useranthropicModels)-1].ID,
+ "last_id": lastID,
})
case constant.ChannelTypeGemini:
userGeminiModels := make([]dto.GeminiModel, len(userOpenAiModels))
diff --git a/controller/model_list_test.go b/controller/model_list_test.go
index b1fa9b956ce3..812207b8fd44 100644
--- a/controller/model_list_test.go
+++ b/controller/model_list_test.go
@@ -402,7 +402,13 @@ func TestListModelsTokenLimitIncludesTieredBillingModel(t *testing.T) {
"zz-token-tiered-visible-model": `tier("base", p * 1 + c * 2)`,
"zz-token-tiered-empty-expr-model": "",
})
- setupModelListControllerTestDB(t)
+ db := setupModelListControllerTestDB(t)
+ require.NoError(t, db.Create(&[]model.Ability{
+ {Group: "default", Model: "zz-token-tiered-visible-model", ChannelId: 1, Enabled: true},
+ {Group: "default", Model: "zz-token-tiered-empty-expr-model", ChannelId: 1, Enabled: true},
+ {Group: "default", Model: "zz-token-tiered-missing-expr-model", ChannelId: 1, Enabled: true},
+ {Group: "default", Model: "zz-token-unpriced-model", ChannelId: 1, Enabled: true},
+ }).Error)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
@@ -425,6 +431,68 @@ func TestListModelsTokenLimitIncludesTieredBillingModel(t *testing.T) {
require.NotContains(t, ids, "zz-token-unpriced-model")
}
+func TestListModelsTokenLimitUsesResolvedCustomAutoGroups(t *testing.T) {
+ withSelfUseModeEnabled(t)
+ originalMax := setting.GetMaxTokenAutoGroups()
+ originalUsableGroups := setting.UserUsableGroups2JSONString()
+ originalRatios := ratio_setting.GroupRatio2JSONString()
+ require.NoError(t, setting.UpdateMaxTokenAutoGroups("5"))
+ require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP"}`))
+ require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":1}`))
+ t.Cleanup(func() {
+ require.NoError(t, setting.UpdateMaxTokenAutoGroups(fmt.Sprintf("%d", originalMax)))
+ require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(originalUsableGroups))
+ require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalRatios))
+ })
+
+ db := setupModelListControllerTestDB(t)
+ require.NoError(t, db.Create(&[]model.Ability{
+ {Group: "vip", Model: "zz-vip-allowed", ChannelId: 1, Enabled: true},
+ {Group: "vip", Model: "zz-vip-denied", ChannelId: 1, Enabled: true},
+ {Group: "default", Model: "zz-default-outside-snapshot", ChannelId: 1, Enabled: true},
+ }).Error)
+
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
+ common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
+ common.SetContextKey(ctx, constant.ContextKeyTokenGroup, "auto")
+ common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"vip"})
+ common.SetContextKey(ctx, constant.ContextKeyTokenModelLimitEnabled, true)
+ common.SetContextKey(ctx, constant.ContextKeyTokenModelLimit, map[string]bool{
+ "zz-vip-allowed": true,
+ "zz-default-outside-snapshot": true,
+ "zz-not-enabled": true,
+ })
+
+ ListModels(ctx, constant.ChannelTypeOpenAI)
+ ids := decodeListModelsResponse(t, recorder)
+ require.Equal(t, map[string]struct{}{"zz-vip-allowed": {}}, ids)
+
+ require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default"}`))
+ emptyRecorder := httptest.NewRecorder()
+ emptyCtx, _ := gin.CreateTestContext(emptyRecorder)
+ emptyCtx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
+ common.SetContextKey(emptyCtx, constant.ContextKeyUserGroup, "default")
+ common.SetContextKey(emptyCtx, constant.ContextKeyTokenGroup, "auto")
+ common.SetContextKey(emptyCtx, constant.ContextKeyTokenAutoGroups, []string{"vip"})
+ common.SetContextKey(emptyCtx, constant.ContextKeyTokenModelLimitEnabled, true)
+ common.SetContextKey(emptyCtx, constant.ContextKeyTokenModelLimit, map[string]bool{"zz-vip-allowed": true})
+
+ require.NotPanics(t, func() {
+ ListModels(emptyCtx, constant.ChannelTypeAnthropic)
+ })
+ var anthropicResponse struct {
+ Data []dto.AnthropicModel `json:"data"`
+ FirstID string `json:"first_id"`
+ LastID string `json:"last_id"`
+ }
+ require.NoError(t, common.Unmarshal(emptyRecorder.Body.Bytes(), &anthropicResponse))
+ require.Empty(t, anthropicResponse.Data)
+ require.Empty(t, anthropicResponse.FirstID)
+ require.Empty(t, anthropicResponse.LastID)
+}
+
func TestCheckUpdatePasswordRequiresCurrentPassword(t *testing.T) {
db := setupModelListControllerTestDB(t)
hashedPassword, err := common.Password2Hash("CurrentPassword123")
diff --git a/controller/model_owned_by_test.go b/controller/model_owned_by_test.go
index bc2ef32f135c..da9bb1a0ff0e 100644
--- a/controller/model_owned_by_test.go
+++ b/controller/model_owned_by_test.go
@@ -1,11 +1,14 @@
package controller
import (
+ "fmt"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/setting"
+ "github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
@@ -83,3 +86,33 @@ func TestGetModelListGroupsUsesExplicitTokenGroup(t *testing.T) {
require.Equal(t, "vip", groups.tokenGroup)
require.Equal(t, []string{"vip"}, groups.ownerGroups)
}
+
+func TestGetModelListGroupsUsesFilteredTokenAutoGroupsSnapshot(t *testing.T) {
+ originalMax := setting.GetMaxTokenAutoGroups()
+ originalUsableGroups := setting.UserUsableGroups2JSONString()
+ originalRatios := ratio_setting.GroupRatio2JSONString()
+ require.NoError(t, setting.UpdateMaxTokenAutoGroups("1"))
+ require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP"}`))
+ require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":1}`))
+ t.Cleanup(func() {
+ require.NoError(t, setting.UpdateMaxTokenAutoGroups(fmt.Sprintf("%d", originalMax)))
+ require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(originalUsableGroups))
+ require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalRatios))
+ })
+
+ gin.SetMode(gin.TestMode)
+ ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+ common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
+ common.SetContextKey(ctx, constant.ContextKeyTokenGroup, "auto")
+ common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"vip", "default"})
+
+ groups, err := getModelListGroups(ctx)
+ require.NoError(t, err)
+ require.Equal(t, []string{"vip"}, groups.ownerGroups)
+
+ common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"vip"})
+ require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default"}`))
+ groups, err = getModelListGroups(ctx)
+ require.NoError(t, err)
+ require.Empty(t, groups.ownerGroups)
+}
diff --git a/controller/token.go b/controller/token.go
index 836e9b2952ac..c26d82e3dee1 100644
--- a/controller/token.go
+++ b/controller/token.go
@@ -7,30 +7,115 @@ import (
"strings"
"github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
)
-func buildMaskedTokenResponse(token *model.Token) *model.Token {
+type tokenAutoGroupsInput struct {
+ Set bool
+ Groups []string
+}
+
+func (input *tokenAutoGroupsInput) UnmarshalJSON(data []byte) error {
+ input.Set = true
+ if strings.TrimSpace(string(data)) == "null" {
+ input.Groups = nil
+ return nil
+ }
+ return common.Unmarshal(data, &input.Groups)
+}
+
+type tokenRequest struct {
+ model.Token
+ AutoGroups tokenAutoGroupsInput `json:"auto_groups"`
+}
+
+type tokenResponse struct {
+ *model.Token
+ AutoGroups []string `json:"auto_groups"`
+}
+
+func buildMaskedTokenResponse(token *model.Token) *tokenResponse {
if token == nil {
return nil
}
maskedToken := *token
maskedToken.Key = token.GetMaskedKey()
- return &maskedToken
+ autoGroups, err := token.GetAutoGroups()
+ if err != nil {
+ common.SysError(fmt.Sprintf("failed to parse auto groups for token %d: %v", token.Id, err))
+ autoGroups = nil
+ }
+ if len(autoGroups) == 0 {
+ autoGroups = nil
+ }
+ return &tokenResponse{Token: &maskedToken, AutoGroups: autoGroups}
}
-func buildMaskedTokenResponses(tokens []*model.Token) []*model.Token {
- maskedTokens := make([]*model.Token, 0, len(tokens))
+func buildMaskedTokenResponses(tokens []*model.Token) []*tokenResponse {
+ maskedTokens := make([]*tokenResponse, 0, len(tokens))
for _, token := range tokens {
maskedTokens = append(maskedTokens, buildMaskedTokenResponse(token))
}
return maskedTokens
}
+func getTokenRequestUserGroup(c *gin.Context) (string, error) {
+ if userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup); userGroup != "" {
+ return userGroup, nil
+ }
+ if userGroup := c.GetString("group"); userGroup != "" {
+ return userGroup, nil
+ }
+ return model.GetUserGroup(c.GetInt("id"), false)
+}
+
+func setTokenAutoGroups(c *gin.Context, token *model.Token, groups []string) bool {
+ if len(groups) == 0 {
+ if err := token.SetAutoGroups(nil); err != nil {
+ common.ApiError(c, err)
+ return false
+ }
+ return true
+ }
+
+ maxCount := setting.GetMaxTokenAutoGroups()
+ if len(groups) > maxCount {
+ common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsTooMany, map[string]any{"Max": maxCount})
+ return false
+ }
+
+ userGroup, err := getTokenRequestUserGroup(c)
+ if err != nil {
+ common.ApiError(c, err)
+ return false
+ }
+ seen := make(map[string]struct{}, len(groups))
+ for _, group := range groups {
+ if _, ok := seen[group]; ok {
+ common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsDuplicate, map[string]any{"Group": group})
+ return false
+ }
+ seen[group] = struct{}{}
+ if !service.IsUserSelectableGroup(userGroup, group) {
+ common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsInvalid, map[string]any{"Group": group})
+ return false
+ }
+ }
+
+ if err := token.SetAutoGroups(groups); err != nil {
+ common.ApiError(c, err)
+ return false
+ }
+ return true
+}
+
func GetAllTokens(c *gin.Context) {
userId := c.GetInt("id")
pageInfo := common.GetPageQuery(c)
@@ -77,6 +162,18 @@ func GetToken(c *gin.Context) {
common.ApiSuccess(c, buildMaskedTokenResponse(token))
}
+func GetTokenAutoGroups(c *gin.Context) {
+ userGroup, err := getTokenRequestUserGroup(c)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, gin.H{
+ "groups": service.GetUserAutoGroup(userGroup),
+ "max_count": setting.GetMaxTokenAutoGroups(),
+ })
+}
+
func GetTokenKey(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
userId := c.GetInt("id")
@@ -165,12 +262,13 @@ func GetTokenUsage(c *gin.Context) {
}
func AddToken(c *gin.Context) {
- token := model.Token{}
- err := c.ShouldBindJSON(&token)
+ request := tokenRequest{}
+ err := c.ShouldBindJSON(&request)
if err != nil {
common.ApiError(c, err)
return
}
+ token := request.Token
if len(token.Name) > 50 {
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
return
@@ -201,6 +299,14 @@ func AddToken(c *gin.Context) {
})
return
}
+ if token.Group == "auto" {
+ if !setTokenAutoGroups(c, &token, request.AutoGroups.Groups) {
+ return
+ }
+ } else {
+ token.CrossGroupRetry = false
+ _ = token.SetAutoGroups(nil)
+ }
key, err := common.GenerateKey()
if err != nil {
common.ApiErrorI18n(c, i18n.MsgTokenGenerateFailed)
@@ -221,6 +327,7 @@ func AddToken(c *gin.Context) {
AllowIps: token.AllowIps,
Group: token.Group,
CrossGroupRetry: token.CrossGroupRetry,
+ AutoGroups: token.AutoGroups,
}
err = cleanToken.Insert()
if err != nil {
@@ -250,12 +357,13 @@ func DeleteToken(c *gin.Context) {
func UpdateToken(c *gin.Context) {
userId := c.GetInt("id")
statusOnly := c.Query("status_only")
- token := model.Token{}
- err := c.ShouldBindJSON(&token)
+ request := tokenRequest{}
+ err := c.ShouldBindJSON(&request)
if err != nil {
common.ApiError(c, err)
return
}
+ token := request.Token
if len(token.Name) > 50 {
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
return
@@ -299,6 +407,14 @@ func UpdateToken(c *gin.Context) {
cleanToken.AllowIps = token.AllowIps
cleanToken.Group = token.Group
cleanToken.CrossGroupRetry = token.CrossGroupRetry
+ if token.Group != "auto" {
+ cleanToken.CrossGroupRetry = false
+ _ = cleanToken.SetAutoGroups(nil)
+ } else if request.AutoGroups.Set {
+ if !setTokenAutoGroups(c, cleanToken, request.AutoGroups.Groups) {
+ return
+ }
+ }
}
err = cleanToken.Update()
if err != nil {
diff --git a/controller/token_auto_groups_test.go b/controller/token_auto_groups_test.go
new file mode 100644
index 000000000000..3da2969575c1
--- /dev/null
+++ b/controller/token_auto_groups_test.go
@@ -0,0 +1,234 @@
+package controller
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/setting"
+ "github.com/QuantumNous/new-api/setting/ratio_setting"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func configureTokenAutoGroupsTest(t *testing.T, maxCount string, autoGroups string) {
+ t.Helper()
+ originalMax := setting.GetMaxTokenAutoGroups()
+ originalAutoGroups := setting.AutoGroups2JsonString()
+ originalUsableGroups := setting.UserUsableGroups2JSONString()
+ originalRatios := ratio_setting.GroupRatio2JSONString()
+ require.NoError(t, setting.UpdateMaxTokenAutoGroups(maxCount))
+ require.NoError(t, setting.UpdateAutoGroupsByJsonString(autoGroups))
+ require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP"}`))
+ require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":1}`))
+ t.Cleanup(func() {
+ require.NoError(t, setting.UpdateMaxTokenAutoGroups(stringInt(originalMax)))
+ require.NoError(t, setting.UpdateAutoGroupsByJsonString(originalAutoGroups))
+ require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(originalUsableGroups))
+ require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalRatios))
+ })
+}
+
+func stringInt(value int) string {
+ return fmt.Sprintf("%d", value)
+}
+
+func setupTokenAutoGroupsControllerTest(t *testing.T) *model.User {
+ t.Helper()
+ db := setupTokenControllerTestDB(t)
+ require.NoError(t, db.AutoMigrate(&model.User{}))
+ user := &model.User{
+ Id: 101,
+ Username: "token-auto-user",
+ Password: "password",
+ Group: "default",
+ Status: common.UserStatusEnabled,
+ }
+ require.NoError(t, db.Create(user).Error)
+ return user
+}
+
+func baseAutoTokenRequest(name string) map[string]any {
+ return map[string]any{
+ "name": name,
+ "expired_time": -1,
+ "remain_quota": 0,
+ "unlimited_quota": true,
+ "group": "auto",
+ "cross_group_retry": true,
+ }
+}
+
+func newTokenAutoGroupsAuthenticatedContext(t *testing.T, method string, target string, body any, userID int) (*gin.Context, *httptest.ResponseRecorder) {
+ t.Helper()
+ ctx, recorder := newAuthenticatedContext(t, method, target, body, userID)
+ common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
+ return ctx, recorder
+}
+
+func TestAddTokenEmptyAutoGroupsInheritGlobalAuto(t *testing.T) {
+ tests := []struct {
+ name string
+ includeField bool
+ value any
+ }{
+ {name: "omitted"},
+ {name: "null", includeField: true, value: nil},
+ {name: "empty array", includeField: true, value: []string{}},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ configureTokenAutoGroupsTest(t, "5", `["default","vip"]`)
+ user := setupTokenAutoGroupsControllerTest(t)
+ request := baseAutoTokenRequest("create-" + test.name)
+ if test.includeField {
+ request["auto_groups"] = test.value
+ }
+
+ ctx, recorder := newTokenAutoGroupsAuthenticatedContext(t, http.MethodPost, "/api/token/", request, user.Id)
+ AddToken(ctx)
+
+ response := decodeAPIResponse(t, recorder)
+ require.True(t, response.Success, response.Message)
+ var token model.Token
+ require.NoError(t, model.DB.Where("name = ?", request["name"]).First(&token).Error)
+ assert.Empty(t, token.AutoGroups)
+ assert.True(t, token.CrossGroupRetry)
+ payload, err := common.Marshal(buildMaskedTokenResponse(&token))
+ require.NoError(t, err)
+ var responseData map[string]any
+ require.NoError(t, common.Unmarshal(payload, &responseData))
+ assert.Nil(t, responseData["auto_groups"])
+ })
+ }
+}
+
+func TestAddTokenPersistsOrderedAutoGroupsSnapshot(t *testing.T) {
+ configureTokenAutoGroupsTest(t, "5", `["default","vip"]`)
+ user := setupTokenAutoGroupsControllerTest(t)
+ request := baseAutoTokenRequest("ordered-snapshot")
+ request["auto_groups"] = []string{"vip", "default"}
+
+ ctx, recorder := newTokenAutoGroupsAuthenticatedContext(t, http.MethodPost, "/api/token/", request, user.Id)
+ AddToken(ctx)
+ require.True(t, decodeAPIResponse(t, recorder).Success)
+
+ var token model.Token
+ require.NoError(t, model.DB.Where("name = ?", "ordered-snapshot").First(&token).Error)
+ assert.JSONEq(t, `["vip","default"]`, token.AutoGroups)
+
+ getCtx, getRecorder := newTokenAutoGroupsAuthenticatedContext(t, http.MethodGet, "/api/token/"+stringInt(token.Id), nil, user.Id)
+ getCtx.Params = append(getCtx.Params, gin.Param{Key: "id", Value: stringInt(token.Id)})
+ GetToken(getCtx)
+ getResponse := decodeAPIResponse(t, getRecorder)
+ require.True(t, getResponse.Success)
+ var data struct {
+ AutoGroups []string `json:"auto_groups"`
+ }
+ require.NoError(t, common.Unmarshal(getResponse.Data, &data))
+ assert.Equal(t, []string{"vip", "default"}, data.AutoGroups)
+}
+
+func TestUpdateTokenAutoGroupsTriStateAndNonAutoCleanup(t *testing.T) {
+ tests := []struct {
+ name string
+ includeField bool
+ value any
+ group string
+ expectedAutoGroups string
+ expectedRetry bool
+ }{
+ {name: "omitted preserves", group: "auto", expectedAutoGroups: `["vip","default"]`, expectedRetry: true},
+ {name: "null inherits", includeField: true, value: nil, group: "auto", expectedRetry: true},
+ {name: "empty inherits", includeField: true, value: []string{}, group: "auto", expectedRetry: true},
+ {name: "non auto clears and disables retry", includeField: true, value: []string{"vip"}, group: "default"},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ configureTokenAutoGroupsTest(t, "5", `["default","vip"]`)
+ user := setupTokenAutoGroupsControllerTest(t)
+ token := seedToken(t, model.DB, user.Id, "update-auto", "update-auto-key")
+ token.Group = "auto"
+ token.CrossGroupRetry = true
+ require.NoError(t, token.SetAutoGroups([]string{"vip", "default"}))
+ require.NoError(t, model.DB.Save(token).Error)
+
+ request := baseAutoTokenRequest("updated-auto")
+ request["id"] = token.Id
+ request["status"] = common.TokenStatusEnabled
+ request["group"] = test.group
+ if test.includeField {
+ request["auto_groups"] = test.value
+ }
+ ctx, recorder := newTokenAutoGroupsAuthenticatedContext(t, http.MethodPut, "/api/token/", request, user.Id)
+ UpdateToken(ctx)
+ response := decodeAPIResponse(t, recorder)
+ require.True(t, response.Success, response.Message)
+
+ var updated model.Token
+ require.NoError(t, model.DB.First(&updated, token.Id).Error)
+ if test.expectedAutoGroups == "" {
+ assert.Empty(t, updated.AutoGroups)
+ } else {
+ assert.JSONEq(t, test.expectedAutoGroups, updated.AutoGroups)
+ }
+ assert.Equal(t, test.expectedRetry, updated.CrossGroupRetry)
+ })
+ }
+}
+
+func TestAddTokenRejectsInvalidAutoGroups(t *testing.T) {
+ tests := []struct {
+ name string
+ maxCount string
+ groups []string
+ }{
+ {name: "over limit", maxCount: "1", groups: []string{"default", "vip"}},
+ {name: "duplicate", maxCount: "5", groups: []string{"default", "default"}},
+ {name: "auto pseudo group", maxCount: "5", groups: []string{"auto"}},
+ {name: "unavailable", maxCount: "5", groups: []string{"missing"}},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ configureTokenAutoGroupsTest(t, test.maxCount, `["default","vip"]`)
+ user := setupTokenAutoGroupsControllerTest(t)
+ request := baseAutoTokenRequest("invalid-" + test.name)
+ request["auto_groups"] = test.groups
+
+ ctx, recorder := newTokenAutoGroupsAuthenticatedContext(t, http.MethodPost, "/api/token/", request, user.Id)
+ AddToken(ctx)
+
+ response := decodeAPIResponse(t, recorder)
+ assert.False(t, response.Success)
+ var count int64
+ require.NoError(t, model.DB.Model(&model.Token{}).Count(&count).Error)
+ assert.Zero(t, count)
+ })
+ }
+}
+
+func TestGetTokenAutoGroupsReturnsFullFilteredGlobalOrderAndLimit(t *testing.T) {
+ configureTokenAutoGroupsTest(t, "1", `["vip","missing","default"]`)
+ user := setupTokenAutoGroupsControllerTest(t)
+
+ ctx, recorder := newTokenAutoGroupsAuthenticatedContext(t, http.MethodGet, "/api/token/auto-groups", nil, user.Id)
+ GetTokenAutoGroups(ctx)
+
+ response := decodeAPIResponse(t, recorder)
+ require.True(t, response.Success, response.Message)
+ var data struct {
+ Groups []string `json:"groups"`
+ MaxCount int `json:"max_count"`
+ }
+ require.NoError(t, common.Unmarshal(response.Data, &data))
+ assert.Equal(t, []string{"vip", "default"}, data.Groups)
+ assert.Equal(t, 1, data.MaxCount)
+}
diff --git a/controller/token_test.go b/controller/token_test.go
index 12b1cbdd84fb..9cca168ab100 100644
--- a/controller/token_test.go
+++ b/controller/token_test.go
@@ -273,6 +273,34 @@ func getTokenKeyColumnType(t *testing.T, db *gorm.DB, dialect string) string {
}
}
+func getTokenAutoGroupsColumnType(t *testing.T, db *gorm.DB, dialect string) string {
+ t.Helper()
+
+ switch dialect {
+ case "sqlite":
+ return getSQLiteColumnType(t, db, "tokens", "auto_groups")
+ case "mysql":
+ var columnType string
+ if err := db.Raw(`SELECT DATA_TYPE FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?`,
+ "tokens", "auto_groups").Scan(&columnType).Error; err != nil {
+ t.Fatalf("failed to inspect mysql token auto_groups column: %v", err)
+ }
+ return strings.ToLower(columnType)
+ case "postgres":
+ var dataType string
+ if err := db.Raw(`SELECT data_type FROM information_schema.columns
+ WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?`,
+ "tokens", "auto_groups").Scan(&dataType).Error; err != nil {
+ t.Fatalf("failed to inspect postgres token auto_groups column: %v", err)
+ }
+ return strings.ToLower(dataType)
+ default:
+ t.Fatalf("unsupported dialect %q", dialect)
+ return ""
+ }
+}
+
func runTokenMigrationCompatibilityTest(t *testing.T, db *gorm.DB, dialect string, managedTokensTable *bool) {
t.Helper()
@@ -314,6 +342,12 @@ func runTokenMigrationCompatibilityTest(t *testing.T, db *gorm.DB, dialect strin
if got := getTokenKeyColumnType(t, db, dialect); got != "varchar(128)" {
t.Fatalf("expected migrated key column type varchar(128), got %q", got)
}
+ if !db.Migrator().HasColumn(&model.Token{}, "auto_groups") {
+ t.Fatal("expected migration to add auto_groups column")
+ }
+ if got := getTokenAutoGroupsColumnType(t, db, dialect); got != "text" {
+ t.Fatalf("expected migrated auto_groups column type text, got %q", got)
+ }
var migratedToken model.Token
if err := db.First(&migratedToken, "name = ?", "legacy-token").Error; err != nil {
@@ -325,6 +359,9 @@ func runTokenMigrationCompatibilityTest(t *testing.T, db *gorm.DB, dialect strin
if migratedToken.Name != "legacy-token" {
t.Fatalf("expected migrated token name to be preserved, got %q", migratedToken.Name)
}
+ if migratedToken.AutoGroups != "" {
+ t.Fatalf("expected legacy token to inherit global Auto groups, got %q", migratedToken.AutoGroups)
+ }
inserted := model.Token{
UserId: 8,
@@ -362,6 +399,9 @@ func TestTokenAutoMigrateUsesVarchar128KeyColumn(t *testing.T) {
if got := getTokenKeyColumnType(t, db, "sqlite"); got != "varchar(128)" {
t.Fatalf("expected key column type varchar(128), got %q", got)
}
+ if got := getSQLiteColumnType(t, db, "tokens", "auto_groups"); got != "text" {
+ t.Fatalf("expected auto_groups column type text, got %q", got)
+ }
}
func TestTokenMigrationFromChar48ToVarchar128(t *testing.T) {
diff --git a/i18n/keys.go b/i18n/keys.go
index 8e9a4b5694d4..64a835e1a942 100644
--- a/i18n/keys.go
+++ b/i18n/keys.go
@@ -55,6 +55,9 @@ const (
MsgTokenExhausted = "token.exhausted"
MsgTokenStatusUnavailable = "token.status_unavailable"
MsgTokenDbError = "token.db_error"
+ MsgTokenAutoGroupsTooMany = "token.auto_groups_too_many"
+ MsgTokenAutoGroupsDuplicate = "token.auto_groups_duplicate"
+ MsgTokenAutoGroupsInvalid = "token.auto_groups_invalid"
)
// Redemption related messages
diff --git a/i18n/locales/en.yaml b/i18n/locales/en.yaml
index 3f1fd03cb090..c533daecc32d 100644
--- a/i18n/locales/en.yaml
+++ b/i18n/locales/en.yaml
@@ -47,6 +47,9 @@ token.expired: "This token has expired"
token.exhausted: "This token quota is exhausted TokenStatusExhausted[sk-{{.Prefix}}***{{.Suffix}}]"
token.status_unavailable: "This token status is unavailable"
token.db_error: "Invalid token, database query error, please contact administrator"
+token.auto_groups_too_many: "A token can select at most {{.Max}} Auto groups"
+token.auto_groups_duplicate: "Auto group {{.Group}} is duplicated"
+token.auto_groups_invalid: "Auto group {{.Group}} is unavailable or unauthorized"
# Redemption messages
redemption.name_length: "Redemption code name length must be between 1-20"
diff --git a/i18n/locales/zh-CN.yaml b/i18n/locales/zh-CN.yaml
index fe982e59a0f3..a2f5275be9a8 100644
--- a/i18n/locales/zh-CN.yaml
+++ b/i18n/locales/zh-CN.yaml
@@ -48,6 +48,9 @@ token.expired: "该令牌已过期"
token.exhausted: "该令牌额度已用尽 TokenStatusExhausted[sk-{{.Prefix}}***{{.Suffix}}]"
token.status_unavailable: "该令牌状态不可用"
token.db_error: "无效的令牌,数据库查询出错,请联系管理员"
+token.auto_groups_too_many: "每个令牌最多可选择 {{.Max}} 个 Auto 分组"
+token.auto_groups_duplicate: "Auto 分组 {{.Group}} 重复"
+token.auto_groups_invalid: "Auto 分组 {{.Group}} 不可用或无权访问"
# Redemption messages
redemption.name_length: "兑换码名称长度必须在1-20之间"
diff --git a/i18n/locales/zh-TW.yaml b/i18n/locales/zh-TW.yaml
index 27759d07f378..84ebd57ed587 100644
--- a/i18n/locales/zh-TW.yaml
+++ b/i18n/locales/zh-TW.yaml
@@ -48,6 +48,9 @@ token.expired: "該令牌已過期"
token.exhausted: "該令牌額度已用盡 TokenStatusExhausted[sk-{{.Prefix}}***{{.Suffix}}]"
token.status_unavailable: "該令牌狀態不可用"
token.db_error: "無效的令牌,資料庫查詢出錯,請聯繫管理員"
+token.auto_groups_too_many: "每個令牌最多可選擇 {{.Max}} 個 Auto 分組"
+token.auto_groups_duplicate: "Auto 分組 {{.Group}} 重複"
+token.auto_groups_invalid: "Auto 分組 {{.Group}} 不可用或無權存取"
# Redemption messages
redemption.name_length: "兌換碼名稱長度必須在1-20之間"
diff --git a/middleware/auth.go b/middleware/auth.go
index 2ad09a7a8d2d..4e1436f38466 100644
--- a/middleware/auth.go
+++ b/middleware/auth.go
@@ -502,6 +502,16 @@ func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) e
}
common.SetContextKey(c, constant.ContextKeyTokenGroup, token.Group)
common.SetContextKey(c, constant.ContextKeyTokenCrossGroupRetry, token.CrossGroupRetry)
+ if token.AutoGroups != "" {
+ autoGroups, err := token.GetAutoGroups()
+ if err != nil {
+ common.SysError(fmt.Sprintf("failed to parse auto groups for token %d: %v", token.Id, err))
+ autoGroups = []string{}
+ common.SetContextKey(c, constant.ContextKeyTokenAutoGroups, autoGroups)
+ } else if len(autoGroups) > 0 {
+ common.SetContextKey(c, constant.ContextKeyTokenAutoGroups, autoGroups)
+ }
+ }
if len(parts) > 1 {
if model.IsAdmin(token.UserId) {
c.Set("specific_channel_id", parts[1])
diff --git a/middleware/distributor.go b/middleware/distributor.go
index bde639ddee5b..7decf0e28728 100644
--- a/middleware/distributor.go
+++ b/middleware/distributor.go
@@ -109,7 +109,7 @@ func Distribute() func(c *gin.Context) {
channelSupportsRequestPath(preferred, c.Request.URL.Path, modelRequest.Model) {
if usingGroup == "auto" {
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
- autoGroups := service.GetUserAutoGroup(userGroup)
+ autoGroups := service.GetRequestAutoGroups(c, userGroup)
for _, g := range autoGroups {
if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) {
selectGroup = g
diff --git a/middleware/token_auto_groups_context_test.go b/middleware/token_auto_groups_context_test.go
new file mode 100644
index 000000000000..ac507d5b3782
--- /dev/null
+++ b/middleware/token_auto_groups_context_test.go
@@ -0,0 +1,48 @@
+package middleware
+
+import (
+ "net/http/httptest"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newTokenAutoGroupsContext() *gin.Context {
+ gin.SetMode(gin.TestMode)
+ ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+ return ctx
+}
+
+func TestSetupContextForTokenPreservesCustomAutoGroupsOrder(t *testing.T) {
+ ctx := newTokenAutoGroupsContext()
+ token := &model.Token{Id: 1, UserId: 2, AutoGroups: `["vip","default"]`}
+
+ require.NoError(t, SetupContextForToken(ctx, token))
+ value, ok := common.GetContextKey(ctx, constant.ContextKeyTokenAutoGroups)
+ require.True(t, ok)
+ assert.Equal(t, []string{"vip", "default"}, value)
+}
+
+func TestSetupContextForTokenTreatsStoredEmptyArrayAsInheritance(t *testing.T) {
+ ctx := newTokenAutoGroupsContext()
+ token := &model.Token{Id: 1, UserId: 2, AutoGroups: `[]`}
+
+ require.NoError(t, SetupContextForToken(ctx, token))
+ _, ok := common.GetContextKey(ctx, constant.ContextKeyTokenAutoGroups)
+ assert.False(t, ok)
+}
+
+func TestSetupContextForTokenMalformedAutoGroupsFailsClosed(t *testing.T) {
+ ctx := newTokenAutoGroupsContext()
+ token := &model.Token{Id: 1, UserId: 2, AutoGroups: `not-json`}
+
+ require.NoError(t, SetupContextForToken(ctx, token))
+ value, ok := common.GetContextKey(ctx, constant.ContextKeyTokenAutoGroups)
+ require.True(t, ok)
+ assert.Equal(t, []string{}, value)
+}
diff --git a/model/option.go b/model/option.go
index 7ab64e0ded80..e7fda5231be7 100644
--- a/model/option.go
+++ b/model/option.go
@@ -120,6 +120,7 @@ func InitOptionMap() {
common.OptionMap["Chats"] = setting.Chats2JsonString()
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
common.OptionMap["DefaultUseAutoGroup"] = strconv.FormatBool(setting.DefaultUseAutoGroup)
+ common.OptionMap["MaxTokenAutoGroups"] = strconv.Itoa(setting.GetMaxTokenAutoGroups())
common.OptionMap["PayMethods"] = operation_setting.PayMethods2JsonString()
common.OptionMap["GitHubClientId"] = ""
common.OptionMap["GitHubClientSecret"] = ""
@@ -208,6 +209,9 @@ func validateOptionValue(key string, value string) error {
if key == operation_setting.ToolPriceOptionKey {
return operation_setting.ValidateToolPricesJSON(value)
}
+ if key == "MaxTokenAutoGroups" {
+ return setting.ValidateMaxTokenAutoGroups(value)
+ }
return nil
}
@@ -413,6 +417,8 @@ func updateOptionMap(key string, value string) (err error) {
err = setting.UpdateChatsByJsonString(value)
case "AutoGroups":
err = setting.UpdateAutoGroupsByJsonString(value)
+ case "MaxTokenAutoGroups":
+ err = setting.UpdateMaxTokenAutoGroups(value)
case "CustomCallbackAddress":
operation_setting.CustomCallbackAddress = value
case "EpayId":
diff --git a/model/option_auto_group_test.go b/model/option_auto_group_test.go
new file mode 100644
index 000000000000..c1a8f168343c
--- /dev/null
+++ b/model/option_auto_group_test.go
@@ -0,0 +1,17 @@
+package model
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestValidateOptionValueRejectsInvalidMaxTokenAutoGroups(t *testing.T) {
+ for _, value := range []string{"", "0", "-1", "1.5", "invalid"} {
+ t.Run(value, func(t *testing.T) {
+ assert.Error(t, validateOptionValue("MaxTokenAutoGroups", value))
+ })
+ }
+ require.NoError(t, validateOptionValue("MaxTokenAutoGroups", "999999"))
+}
diff --git a/model/token.go b/model/token.go
index 5d62258e7920..5aa8b3d56e0e 100644
--- a/model/token.go
+++ b/model/token.go
@@ -28,9 +28,34 @@ type Token struct {
UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota
Group string `json:"group" gorm:"default:''"`
CrossGroupRetry bool `json:"cross_group_retry"` // 跨分组重试,仅auto分组有效
+ AutoGroups string `json:"-" gorm:"type:text"`
DeletedAt gorm.DeletedAt `gorm:"index"`
}
+func (token *Token) GetAutoGroups() ([]string, error) {
+ if token.AutoGroups == "" {
+ return nil, nil
+ }
+ var groups []string
+ if err := common.UnmarshalJsonStr(token.AutoGroups, &groups); err != nil {
+ return nil, err
+ }
+ return groups, nil
+}
+
+func (token *Token) SetAutoGroups(groups []string) error {
+ if len(groups) == 0 {
+ token.AutoGroups = ""
+ return nil
+ }
+ data, err := common.Marshal(groups)
+ if err != nil {
+ return err
+ }
+ token.AutoGroups = string(data)
+ return nil
+}
+
func (token *Token) Clean() {
token.Key = ""
}
@@ -291,18 +316,16 @@ func (token *Token) Insert() error {
// Update Make sure your token's fields is completed, because this will update non-zero values
func (token *Token) Update() (err error) {
- defer func() {
- if shouldUpdateRedis(true, err) {
- gopool.Go(func() {
- err := cacheSetToken(*token)
- if err != nil {
- common.SysLog("failed to update token cache: " + err.Error())
- }
- })
- }
- }()
err = DB.Model(token).Select("name", "status", "expired_time", "remain_quota", "unlimited_quota",
- "model_limits_enabled", "model_limits", "allow_ips", "group", "cross_group_retry").Updates(token).Error
+ "model_limits_enabled", "model_limits", "allow_ips", "group", "cross_group_retry", "auto_groups").Updates(token).Error
+ if shouldUpdateRedis(true, err) {
+ if cacheErr := cacheSetToken(*token); cacheErr != nil {
+ common.SysLog("failed to update token cache: " + cacheErr.Error())
+ if deleteErr := cacheDeleteToken(token.Key); deleteErr != nil {
+ common.SysLog("failed to invalidate token cache after update: " + deleteErr.Error())
+ }
+ }
+ }
return err
}
diff --git a/model/token_auto_groups_cache_test.go b/model/token_auto_groups_cache_test.go
new file mode 100644
index 000000000000..2018502d6200
--- /dev/null
+++ b/model/token_auto_groups_cache_test.go
@@ -0,0 +1,57 @@
+package model
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTokenAutoGroupsRoundTripThroughRedisHashCache(t *testing.T) {
+ useUserCacheMiniRedis(t)
+ token := Token{
+ Id: 42,
+ UserId: 7,
+ Key: "token-auto-groups-cache-key",
+ Name: "auto-cache",
+ Group: "auto",
+ AutoGroups: `["vip","default"]`,
+ }
+
+ require.NoError(t, cacheSetToken(token))
+ cached, err := cacheGetTokenByKey(token.Key)
+ require.NoError(t, err)
+ assert.Equal(t, token.AutoGroups, cached.AutoGroups)
+ groups, err := cached.GetAutoGroups()
+ require.NoError(t, err)
+ assert.Equal(t, []string{"vip", "default"}, groups)
+}
+
+func TestTokenUpdateSynchronouslyNarrowsPreheatedAutoGroupsCache(t *testing.T) {
+ truncateTables(t)
+ useUserCacheMiniRedis(t)
+ token := Token{
+ UserId: 7,
+ Key: "token-auto-groups-update-cache-key",
+ Name: "auto-cache-update",
+ Status: common.TokenStatusEnabled,
+ ExpiredTime: -1,
+ UnlimitedQuota: true,
+ Group: "auto",
+ CrossGroupRetry: true,
+ AutoGroups: `["default","vip"]`,
+ }
+ require.NoError(t, token.Insert())
+ require.NoError(t, cacheSetToken(token))
+
+ preheated, err := cacheGetTokenByKey(token.Key)
+ require.NoError(t, err)
+ assert.JSONEq(t, `["default","vip"]`, preheated.AutoGroups)
+
+ require.NoError(t, token.SetAutoGroups([]string{"vip"}))
+ require.NoError(t, token.Update())
+ immediate, err := cacheGetTokenByKey(token.Key)
+ require.NoError(t, err)
+ assert.JSONEq(t, `["vip"]`, immediate.AutoGroups)
+}
diff --git a/router/api-router.go b/router/api-router.go
index 80fd65178c44..907cf1ed2885 100644
--- a/router/api-router.go
+++ b/router/api-router.go
@@ -238,6 +238,7 @@ func SetApiRouter(router *gin.Engine) {
{
tokenRoute.GET("/", controller.GetAllTokens)
tokenRoute.GET("/search", middleware.SearchRateLimit(), controller.SearchTokens)
+ tokenRoute.GET("/auto-groups", controller.GetTokenAutoGroups)
tokenRoute.GET("/:id", controller.GetToken)
tokenRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetTokenKey)
tokenRoute.POST("/", controller.AddToken)
diff --git a/service/channel_select.go b/service/channel_select.go
index 24c4e252bfb3..0ab88dc84ff2 100644
--- a/service/channel_select.go
+++ b/service/channel_select.go
@@ -7,7 +7,6 @@ import (
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
- "github.com/QuantumNous/new-api/setting"
"github.com/gin-gonic/gin"
)
@@ -88,10 +87,10 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
userGroup := common.GetContextKeyString(param.Ctx, constant.ContextKeyUserGroup)
if param.TokenGroup == "auto" {
- if len(setting.GetAutoGroups()) == 0 {
+ autoGroups := GetRequestAutoGroups(param.Ctx, userGroup)
+ if len(autoGroups) == 0 {
return nil, selectGroup, errors.New("auto groups is not enabled")
}
- autoGroups := GetUserAutoGroup(userGroup)
// startGroupIndex: the group index to start searching from
// startGroupIndex: 开始搜索的分组索引
diff --git a/service/channel_select_auto_groups_test.go b/service/channel_select_auto_groups_test.go
new file mode 100644
index 000000000000..e8454b389471
--- /dev/null
+++ b/service/channel_select_auto_groups_test.go
@@ -0,0 +1,129 @@
+package service
+
+import (
+ "fmt"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/setting"
+ "github.com/QuantumNous/new-api/setting/ratio_setting"
+ "github.com/gin-gonic/gin"
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+func setupChannelSelectAutoGroupsTest(t *testing.T) *gorm.DB {
+ t.Helper()
+
+ originalDB := model.DB
+ originalMemoryCacheEnabled := common.MemoryCacheEnabled
+ originalRetryTimes := common.RetryTimes
+ originalAutoGroups := setting.AutoGroups2JsonString()
+ originalUsableGroups := setting.UserUsableGroups2JSONString()
+ originalGroupRatios := ratio_setting.GroupRatio2JSONString()
+ originalMaxTokenAutoGroups := setting.GetMaxTokenAutoGroups()
+
+ dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
+ db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.Ability{}))
+ model.DB = db
+ common.MemoryCacheEnabled = true
+ common.RetryTimes = 0
+
+ require.NoError(t, setting.UpdateAutoGroupsByJsonString(`[]`))
+ require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP"}`))
+ require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":2}`))
+ require.NoError(t, setting.UpdateMaxTokenAutoGroups("2"))
+
+ t.Cleanup(func() {
+ model.DB = originalDB
+ common.MemoryCacheEnabled = originalMemoryCacheEnabled
+ common.RetryTimes = originalRetryTimes
+ require.NoError(t, setting.UpdateAutoGroupsByJsonString(originalAutoGroups))
+ require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(originalUsableGroups))
+ require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalGroupRatios))
+ require.NoError(t, setting.UpdateMaxTokenAutoGroups(fmt.Sprintf("%d", originalMaxTokenAutoGroups)))
+
+ if originalMemoryCacheEnabled && originalDB != nil &&
+ originalDB.Migrator().HasTable(&model.Channel{}) && originalDB.Migrator().HasTable(&model.Ability{}) {
+ model.InitChannelCache()
+ }
+ sqlDB, err := db.DB()
+ if err == nil {
+ require.NoError(t, sqlDB.Close())
+ }
+ })
+
+ return db
+}
+
+func createChannelSelectAutoGroupsChannel(t *testing.T, db *gorm.DB, id int, group, modelName string) {
+ t.Helper()
+ priority := int64(0)
+ weight := uint(100)
+ require.NoError(t, db.Create(&model.Channel{
+ Id: id,
+ Type: constant.ChannelTypeOpenAI,
+ Key: fmt.Sprintf("key-%d", id),
+ Status: common.ChannelStatusEnabled,
+ Name: fmt.Sprintf("channel-%d", id),
+ Weight: &weight,
+ Models: modelName,
+ Group: group,
+ Priority: &priority,
+ }).Error)
+ require.NoError(t, db.Create(&model.Ability{
+ Group: group,
+ Model: modelName,
+ ChannelId: id,
+ Enabled: true,
+ Priority: &priority,
+ Weight: weight,
+ }).Error)
+}
+
+func TestCacheGetRandomSatisfiedChannelUsesTokenAutoGroupsWhenGlobalAutoIsEmpty(t *testing.T) {
+ db := setupChannelSelectAutoGroupsTest(t)
+ const modelName = "auto-groups-runtime-model"
+ createChannelSelectAutoGroupsChannel(t, db, 2101, "vip", modelName)
+ createChannelSelectAutoGroupsChannel(t, db, 2102, "default", modelName)
+ model.InitChannelCache()
+
+ gin.SetMode(gin.TestMode)
+ ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+ common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
+ common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"vip", "default"})
+ common.SetContextKey(ctx, constant.ContextKeyTokenCrossGroupRetry, true)
+
+ retry := 0
+ param := &RetryParam{
+ Ctx: ctx,
+ TokenGroup: "auto",
+ ModelName: modelName,
+ RequestPath: "/v1/chat/completions",
+ Retry: &retry,
+ }
+
+ first, selectedGroup, err := CacheGetRandomSatisfiedChannel(param)
+ require.NoError(t, err)
+ require.NotNil(t, first)
+ assert.Equal(t, 2101, first.Id)
+ assert.Equal(t, "vip", selectedGroup)
+ assert.Equal(t, "vip", common.GetContextKeyString(ctx, constant.ContextKeyAutoGroup))
+ assert.Empty(t, setting.GetAutoGroups(), "the selection must not depend on the global Auto list")
+
+ param.IncreaseRetry()
+ second, selectedGroup, err := CacheGetRandomSatisfiedChannel(param)
+ require.NoError(t, err)
+ require.NotNil(t, second)
+ assert.Equal(t, 2102, second.Id)
+ assert.Equal(t, "default", selectedGroup)
+ assert.Equal(t, "default", common.GetContextKeyString(ctx, constant.ContextKeyAutoGroup))
+}
diff --git a/service/group.go b/service/group.go
index 8cb359bcff63..e792083e4a09 100644
--- a/service/group.go
+++ b/service/group.go
@@ -3,9 +3,12 @@ package service
import (
"strings"
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
+ "github.com/gin-gonic/gin"
)
func GetUserUsableGroups(userGroup string) map[string]string {
@@ -42,18 +45,67 @@ func GroupInUserUsableGroups(userGroup, groupName string) bool {
return ok
}
+func IsUserSelectableGroup(userGroup, groupName string) bool {
+ if groupName == "" || groupName == "auto" {
+ return false
+ }
+ return GroupInUserUsableGroups(userGroup, groupName) && ratio_setting.ContainsGroupRatio(groupName)
+}
+
// GetUserAutoGroup 根据用户分组获取自动分组设置
func GetUserAutoGroup(userGroup string) []string {
- groups := GetUserUsableGroups(userGroup)
autoGroups := make([]string, 0)
+ seen := make(map[string]struct{})
for _, group := range setting.GetAutoGroups() {
- if _, ok := groups[group]; ok {
- autoGroups = append(autoGroups, group)
+ if !IsUserSelectableGroup(userGroup, group) {
+ continue
+ }
+ if _, ok := seen[group]; ok {
+ continue
}
+ seen[group] = struct{}{}
+ autoGroups = append(autoGroups, group)
}
return autoGroups
}
+// FilterUserTokenAutoGroups applies current permissions before the current
+// per-token limit. It intentionally does not fall back to the global Auto list.
+func FilterUserTokenAutoGroups(userGroup string, groups []string) []string {
+ maxCount := setting.GetMaxTokenAutoGroups()
+ filtered := make([]string, 0, min(len(groups), maxCount))
+ seen := make(map[string]struct{})
+ for _, group := range groups {
+ if !IsUserSelectableGroup(userGroup, group) {
+ continue
+ }
+ if _, ok := seen[group]; ok {
+ continue
+ }
+ seen[group] = struct{}{}
+ filtered = append(filtered, group)
+ if len(filtered) == maxCount {
+ break
+ }
+ }
+ return filtered
+}
+
+// GetRequestAutoGroups resolves the ordered Auto groups for the current token.
+// The absence of the context value means that the token inherits the complete
+// global Auto list; a present (even empty) value is an explicit token snapshot.
+func GetRequestAutoGroups(c *gin.Context, userGroup string) []string {
+ value, ok := common.GetContextKey(c, constant.ContextKeyTokenAutoGroups)
+ if !ok {
+ return GetUserAutoGroup(userGroup)
+ }
+ groups, ok := value.([]string)
+ if !ok {
+ return []string{}
+ }
+ return FilterUserTokenAutoGroups(userGroup, groups)
+}
+
// GetGroupsEnabledModels 按 groups 顺序获取各分组启用的模型并去重
func GetGroupsEnabledModels(groups []string) []string {
seen := make(map[string]struct{})
diff --git a/service/group_auto_groups_test.go b/service/group_auto_groups_test.go
new file mode 100644
index 000000000000..1f138ad4bf1d
--- /dev/null
+++ b/service/group_auto_groups_test.go
@@ -0,0 +1,72 @@
+package service
+
+import (
+ "fmt"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/setting"
+ "github.com/QuantumNous/new-api/setting/ratio_setting"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func configureRequestAutoGroupsTest(t *testing.T) {
+ t.Helper()
+ originalMax := setting.GetMaxTokenAutoGroups()
+ originalAutoGroups := setting.AutoGroups2JsonString()
+ originalUsableGroups := setting.UserUsableGroups2JSONString()
+ originalRatios := ratio_setting.GroupRatio2JSONString()
+ require.NoError(t, setting.UpdateMaxTokenAutoGroups("2"))
+ require.NoError(t, setting.UpdateAutoGroupsByJsonString(`["vip","default","svip"]`))
+ require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP","svip":"SVIP"}`))
+ require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":1,"svip":1}`))
+ t.Cleanup(func() {
+ require.NoError(t, setting.UpdateMaxTokenAutoGroups(fmt.Sprintf("%d", originalMax)))
+ require.NoError(t, setting.UpdateAutoGroupsByJsonString(originalAutoGroups))
+ require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(originalUsableGroups))
+ require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalRatios))
+ })
+}
+
+func newRequestAutoGroupsContext() *gin.Context {
+ gin.SetMode(gin.TestMode)
+ ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+ return ctx
+}
+
+func TestGetRequestAutoGroupsInheritedListIsNotLimited(t *testing.T) {
+ configureRequestAutoGroupsTest(t)
+ ctx := newRequestAutoGroupsContext()
+
+ groups := GetRequestAutoGroups(ctx, "default")
+
+ assert.Equal(t, []string{"vip", "default", "svip"}, groups)
+}
+
+func TestGetRequestAutoGroupsFiltersBeforeApplyingCurrentLimit(t *testing.T) {
+ configureRequestAutoGroupsTest(t)
+ ctx := newRequestAutoGroupsContext()
+ common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"revoked", "vip", "default", "svip"})
+ require.NoError(t, setting.UpdateAutoGroupsByJsonString(`[]`))
+
+ groups := GetRequestAutoGroups(ctx, "default")
+
+ assert.Equal(t, []string{"vip", "default"}, groups)
+ require.NoError(t, setting.UpdateMaxTokenAutoGroups("1"))
+ assert.Equal(t, []string{"vip"}, GetRequestAutoGroups(ctx, "default"))
+}
+
+func TestGetRequestAutoGroupsDoesNotFallBackAfterPermissionChange(t *testing.T) {
+ configureRequestAutoGroupsTest(t)
+ ctx := newRequestAutoGroupsContext()
+ common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"vip"})
+ require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default"}`))
+
+ groups := GetRequestAutoGroups(ctx, "default")
+
+ assert.Empty(t, groups)
+}
diff --git a/setting/auto_group.go b/setting/auto_group.go
index 9261286bca93..3b509b6026e6 100644
--- a/setting/auto_group.go
+++ b/setting/auto_group.go
@@ -1,15 +1,27 @@
package setting
import (
+ "fmt"
+ "strconv"
+ "sync/atomic"
+
"github.com/QuantumNous/new-api/common"
)
+const DefaultMaxTokenAutoGroups = 5
+
var autoGroups = []string{
"default",
}
var DefaultUseAutoGroup = false
+var maxTokenAutoGroups atomic.Int64
+
+func init() {
+ maxTokenAutoGroups.Store(DefaultMaxTokenAutoGroups)
+}
+
func ContainsAutoGroup(group string) bool {
for _, autoGroup := range autoGroups {
if autoGroup == group {
@@ -35,3 +47,24 @@ func AutoGroups2JsonString() string {
func GetAutoGroups() []string {
return autoGroups
}
+
+func GetMaxTokenAutoGroups() int {
+ return int(maxTokenAutoGroups.Load())
+}
+
+func ValidateMaxTokenAutoGroups(value string) error {
+ maxCount, err := strconv.Atoi(value)
+ if err != nil || maxCount <= 0 {
+ return fmt.Errorf("MaxTokenAutoGroups must be a positive integer")
+ }
+ return nil
+}
+
+func UpdateMaxTokenAutoGroups(value string) error {
+ if err := ValidateMaxTokenAutoGroups(value); err != nil {
+ return err
+ }
+ maxCount, _ := strconv.Atoi(value)
+ maxTokenAutoGroups.Store(int64(maxCount))
+ return nil
+}
diff --git a/setting/auto_group_test.go b/setting/auto_group_test.go
new file mode 100644
index 000000000000..414c169ed706
--- /dev/null
+++ b/setting/auto_group_test.go
@@ -0,0 +1,29 @@
+package setting
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUpdateMaxTokenAutoGroupsAcceptsAnyPositiveInteger(t *testing.T) {
+ original := GetMaxTokenAutoGroups()
+ t.Cleanup(func() {
+ require.NoError(t, UpdateMaxTokenAutoGroups(fmt.Sprintf("%d", original)))
+ })
+
+ require.NoError(t, UpdateMaxTokenAutoGroups("123456"))
+ assert.Equal(t, 123456, GetMaxTokenAutoGroups())
+}
+
+func TestUpdateMaxTokenAutoGroupsRejectsInvalidValuesWithoutChangingState(t *testing.T) {
+ original := GetMaxTokenAutoGroups()
+ for _, value := range []string{"", "0", "-1", "1.5", "not-a-number"} {
+ t.Run(value, func(t *testing.T) {
+ assert.Error(t, UpdateMaxTokenAutoGroups(value))
+ assert.Equal(t, original, GetMaxTokenAutoGroups())
+ })
+ }
+}
diff --git a/web/src/features/keys/api.ts b/web/src/features/keys/api.ts
index df3cc5ff74bc..0f90490c7852 100644
--- a/web/src/features/keys/api.ts
+++ b/web/src/features/keys/api.ts
@@ -25,6 +25,7 @@ import type {
GetApiKeysResponse,
SearchApiKeysParams,
ApiKeyFormData,
+ TokenAutoGroupsConfig,
} from './types'
// ============================================================================
@@ -60,6 +61,14 @@ export async function getApiKey(id: number): Promise> {
return res.data
}
+// Get the current user's global Auto order and the per-token selection limit.
+export async function getTokenAutoGroups(): Promise<
+ ApiResponse
+> {
+ const res = await api.get('/api/token/auto-groups')
+ return res.data
+}
+
// Create a new API key
export async function createApiKey(
data: ApiKeyFormData
diff --git a/web/src/features/keys/components/__tests__/api-key-group-cell.test.tsx b/web/src/features/keys/components/__tests__/api-key-group-cell.test.tsx
new file mode 100644
index 000000000000..5cb64ae57f7b
--- /dev/null
+++ b/web/src/features/keys/components/__tests__/api-key-group-cell.test.tsx
@@ -0,0 +1,236 @@
+/*
+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 { after, describe, test } from 'node:test'
+
+import { Window } from 'happy-dom'
+
+const domWindow = new Window()
+const domGlobals = [
+ 'window',
+ 'document',
+ 'navigator',
+ 'HTMLElement',
+ 'HTMLButtonElement',
+ 'SVGElement',
+ 'Node',
+ 'Element',
+ 'Event',
+ '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 { createInstance } = await import('i18next')
+const { I18nextProvider, initReactI18next } = await import('react-i18next')
+const { TooltipProvider } = await import('@/components/ui/tooltip')
+const { ApiKeyGroupCell } = await import('../api-key-group-cell')
+
+const i18n = createInstance()
+await i18n.use(initReactI18next).init({
+ lng: 'en',
+ resources: {
+ en: {
+ translation: {
+ Auto: 'Auto',
+ 'Cross-group': 'Cross-group',
+ Ratio: 'Ratio',
+ 'Automatically selects the best available group with circuit breaker mechanism':
+ 'Automatically selects the best available group with circuit breaker mechanism',
+ },
+ },
+ },
+})
+
+const reactTestGlobals = globalThis as typeof globalThis & {
+ IS_REACT_ACT_ENVIRONMENT?: boolean
+}
+reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true
+
+function CellHarness(props: {
+ group: string
+ ratio?: number | string
+ crossGroupRetry?: boolean
+ shouldReduceMotion?: boolean
+}) {
+ return (
+
+
+
+
+
+ )
+}
+
+describe('API key group table cell', () => {
+ after(() => {
+ domWindow.close()
+ })
+
+ test('renders two unclipped rings and a localized Auto ratio when API data uses a nonlocalized string', async () => {
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+
+ await act(async () =>
+ root.render(
+
+ )
+ )
+
+ const badgeCell = container.querySelector(
+ '[data-api-key-group-cell="auto"]'
+ )
+ assert.ok(badgeCell)
+ assert.equal(badgeCell.classList.contains('overflow-visible'), true)
+ assert.equal(badgeCell.classList.contains('overflow-hidden'), false)
+
+ const frames = container.querySelectorAll('[data-auto-group-frame]')
+ const movingRings = container.querySelectorAll(
+ '[data-auto-group-flow-border]'
+ )
+ assert.equal(frames.length, 2)
+ assert.equal(movingRings.length, 2)
+ for (const frame of frames) {
+ assert.equal(frame.classList.contains('relative'), true)
+ assert.equal(frame.classList.contains('overflow-visible'), true)
+ assert.equal(frame.classList.contains('rounded-4xl'), true)
+ assert.equal(frame.classList.contains('p-px'), true)
+ }
+
+ const ratio = container.querySelector(
+ '[data-auto-group-effect="ratio"]'
+ )
+ assert.ok(ratio)
+ assert.equal(ratio.textContent, 'Auto Ratio')
+ assert.equal(ratio.textContent?.includes('x'), false)
+ assert.equal(container.textContent?.includes('自动'), false)
+ assert.equal(container.textContent?.includes('Cross-group'), true)
+
+ const crossGroupBadge = [
+ ...container.querySelectorAll('[data-slot="status-badge"]'),
+ ].find((badge) => badge.textContent === 'Cross-group')
+ assert.ok(crossGroupBadge)
+ assert.equal(crossGroupBadge.closest('[data-auto-group-frame]'), null)
+
+ await act(async () => root.unmount())
+ container.remove()
+ })
+
+ test('keeps static Auto frames but omits both moving layers for reduced motion', async () => {
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+
+ await act(async () =>
+ root.render( )
+ )
+
+ assert.equal(
+ container.querySelectorAll('[data-auto-group-frame]').length,
+ 2
+ )
+ assert.equal(
+ container.querySelectorAll('[data-auto-group-flow-border]').length,
+ 0
+ )
+
+ await act(async () => root.unmount())
+ container.remove()
+ })
+
+ test('shows only the Auto badge when ratio data is unavailable', async () => {
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+
+ await act(async () =>
+ root.render( )
+ )
+
+ assert.equal(
+ container.querySelectorAll('[data-auto-group-frame]').length,
+ 1
+ )
+ assert.equal(
+ container.querySelectorAll('[data-auto-group-flow-border]').length,
+ 1
+ )
+ assert.equal(
+ container.querySelector('[data-auto-group-effect="ratio"]'),
+ null
+ )
+ assert.equal(container.textContent?.includes('Auto'), true)
+ assert.equal(container.textContent?.includes('Ratio'), false)
+
+ await act(async () => root.unmount())
+ container.remove()
+ })
+
+ test('narrows normal group ratios to numbers and never applies Auto rings', async () => {
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+
+ await act(async () =>
+ root.render(
+
+ )
+ )
+
+ assert.equal(container.textContent?.includes('vip'), true)
+ assert.equal(container.textContent?.includes('自动'), false)
+ assert.equal(container.querySelector('[data-auto-group-frame]'), null)
+ assert.equal(container.querySelector('[data-auto-group-flow-border]'), null)
+
+ await act(async () =>
+ root.render(
+
+ )
+ )
+
+ assert.equal(container.textContent?.includes('3x'), true)
+ assert.equal(container.querySelector('[data-auto-group-frame]'), null)
+
+ await act(async () => root.unmount())
+ container.remove()
+ })
+})
diff --git a/web/src/features/keys/components/__tests__/api-key-group-combobox.test.tsx b/web/src/features/keys/components/__tests__/api-key-group-combobox.test.tsx
new file mode 100644
index 000000000000..5c3b6525e481
--- /dev/null
+++ b/web/src/features/keys/components/__tests__/api-key-group-combobox.test.tsx
@@ -0,0 +1,294 @@
+/*
+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 { after, describe, test } from 'node:test'
+
+import { Window } from 'happy-dom'
+
+const domWindow = new Window()
+const domGlobals = [
+ 'window',
+ 'document',
+ 'navigator',
+ 'HTMLElement',
+ 'HTMLButtonElement',
+ 'HTMLInputElement',
+ 'SVGElement',
+ 'Node',
+ 'Element',
+ 'Event',
+ 'KeyboardEvent',
+ 'PointerEvent',
+ 'CustomEvent',
+ 'MutationObserver',
+ 'ResizeObserver',
+ 'requestAnimationFrame',
+ 'cancelAnimationFrame',
+ 'getComputedStyle',
+] as const
+
+for (const key of domGlobals) {
+ Object.defineProperty(globalThis, key, {
+ configurable: true,
+ value: domWindow[key],
+ })
+}
+
+let shouldReduceMotion = false
+const reducedMotionMediaQuery = domWindow.matchMedia('(prefers-reduced-motion)')
+Object.defineProperty(reducedMotionMediaQuery, 'matches', {
+ configurable: true,
+ get: () => shouldReduceMotion,
+})
+Object.defineProperty(domWindow, 'matchMedia', {
+ configurable: true,
+ value: () => reducedMotionMediaQuery,
+})
+
+function setReducedMotion(value: boolean) {
+ shouldReduceMotion = value
+ reducedMotionMediaQuery.dispatchEvent(new domWindow.Event('change'))
+}
+
+const { act, useState } = await import('react')
+const { createRoot } = await import('react-dom/client')
+const { createInstance } = await import('i18next')
+const { I18nextProvider, initReactI18next } = await import('react-i18next')
+const { ApiKeyGroupCombobox } = await import('../api-key-group-combobox')
+
+const i18n = createInstance()
+await i18n.use(initReactI18next).init({
+ lng: 'en',
+ resources: {
+ en: {
+ translation: {
+ Auto: 'Auto',
+ Ratio: 'Ratio',
+ 'Search...': 'Search...',
+ 'No group found.': 'No group found.',
+ 'Select a group': 'Select a group',
+ },
+ },
+ },
+})
+
+const reactTestGlobals = globalThis as typeof globalThis & {
+ IS_REACT_ACT_ENVIRONMENT?: boolean
+}
+reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true
+
+const options = [
+ {
+ value: 'auto',
+ label: 'auto',
+ desc: 'Global automatic routing',
+ ratio: '自动',
+ },
+ { value: 'default', label: 'default', desc: 'User group', ratio: 1 },
+ { value: 'vip', label: 'vip', desc: 'Priority group', ratio: 3 },
+]
+
+function Harness(props: { initialValue: string }) {
+ const [value, setValue] = useState(props.initialValue)
+
+ return (
+
+
+ {value}
+
+ )
+}
+
+function getTrigger(container: ParentNode): HTMLButtonElement {
+ const trigger = container.querySelector(
+ 'button[role="combobox"]'
+ )
+ assert.ok(trigger)
+ return trigger
+}
+
+function getCommandItem(label: string): HTMLElement {
+ const item = [
+ ...document.querySelectorAll('[data-slot="command-item"]'),
+ ].find((candidate) => candidate.textContent?.includes(label))
+ assert.ok(item)
+ return item
+}
+
+describe('API key group combobox Auto effect', () => {
+ after(() => {
+ domWindow.close()
+ })
+
+ test('rings the selected Auto trigger and its localized ratio without rendering the API ratio text', async () => {
+ setReducedMotion(false)
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+
+ await act(async () => root.render( ))
+
+ const trigger = getTrigger(container)
+ assert.equal(trigger.getAttribute('aria-expanded'), 'false')
+ assert.equal(trigger.dataset.autoGroupEffect, 'trigger')
+ assert.equal(trigger.classList.contains('bg-linear-to-r'), false)
+ assert.equal(trigger.classList.contains('overflow-hidden'), false)
+ assert.equal(trigger.classList.contains('overflow-visible'), true)
+
+ const triggerFlowBorder = trigger.querySelector(
+ '[data-auto-group-flow-border]'
+ )
+ assert.ok(triggerFlowBorder)
+ assert.equal(triggerFlowBorder.getAttribute('aria-hidden'), 'true')
+ assert.equal(
+ triggerFlowBorder.classList.contains('pointer-events-none'),
+ true
+ )
+ assert.equal(
+ triggerFlowBorder.classList.contains('auto-group-flow-border'),
+ true
+ )
+
+ const triggerRatio = trigger.querySelector(
+ '[data-auto-group-effect="ratio"]'
+ )
+ assert.ok(triggerRatio)
+ assert.equal(triggerRatio.textContent, 'Auto Ratio')
+ assert.equal(triggerRatio.textContent?.includes('Auto'), true)
+ assert.equal(triggerRatio.textContent?.includes('x'), false)
+ assert.equal(trigger.textContent?.includes('自动'), false)
+ assert.equal(triggerRatio.classList.contains('relative'), true)
+ assert.equal(triggerRatio.classList.contains('overflow-visible'), true)
+ assert.equal(triggerRatio.classList.contains('rounded-4xl'), true)
+ assert.ok(triggerRatio.querySelector('[data-auto-group-flow-border]'))
+
+ await act(async () => trigger.click())
+ assert.equal(trigger.getAttribute('aria-expanded'), 'true')
+
+ const autoOption = getCommandItem('Global automatic routing')
+ assert.equal(autoOption.dataset.autoGroupEffect, 'option')
+ assert.equal(autoOption.getAttribute('aria-selected'), 'true')
+ assert.equal(autoOption.classList.contains('bg-linear-to-r'), false)
+ assert.equal(autoOption.classList.contains('overflow-visible'), true)
+ assert.ok(autoOption.querySelector('[data-auto-group-flow-border]'))
+ const optionRatio = autoOption.querySelector(
+ '[data-auto-group-effect="ratio"]'
+ )
+ assert.ok(optionRatio)
+ assert.equal(optionRatio.textContent, 'Auto Ratio')
+ assert.ok(optionRatio.querySelector('[data-auto-group-flow-border]'))
+
+ const defaultOption = getCommandItem('User group')
+ assert.equal(defaultOption.hasAttribute('data-auto-group-effect'), false)
+ assert.equal(
+ defaultOption.querySelector('[data-auto-group-flow-border]'),
+ null
+ )
+ assert.equal(defaultOption.textContent?.includes('1x Ratio'), true)
+ assert.equal(
+ defaultOption.querySelector('[data-auto-group-effect="ratio"]'),
+ null
+ )
+
+ await act(async () => root.unmount())
+ container.remove()
+ })
+
+ test('keeps search and selection behavior while leaving normal groups unstyled', async () => {
+ setReducedMotion(false)
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+
+ await act(async () => root.render( ))
+
+ const trigger = getTrigger(container)
+ await act(async () => trigger.click())
+
+ const searchInput = document.querySelector(
+ 'input[placeholder="Search..."]'
+ )
+ assert.ok(searchInput)
+ await act(async () => {
+ const valueSetter = Object.getOwnPropertyDescriptor(
+ domWindow.HTMLInputElement.prototype,
+ 'value'
+ )?.set
+ assert.ok(valueSetter)
+ valueSetter.call(searchInput, 'vip')
+ searchInput.dispatchEvent(
+ new domWindow.Event('input', { bubbles: true }) as unknown as Event
+ )
+ })
+
+ const visibleOptions = [
+ ...document.querySelectorAll('[data-slot="command-item"]'),
+ ]
+ assert.equal(
+ visibleOptions.some((option) =>
+ option.textContent?.includes('Global automatic routing')
+ ),
+ false
+ )
+ const vipOption = getCommandItem('Priority group')
+ await act(async () => vipOption.click())
+
+ assert.equal(
+ container.querySelector('[data-testid="selected-group"]')?.textContent,
+ 'vip'
+ )
+ assert.equal(trigger.getAttribute('aria-expanded'), 'false')
+ assert.equal(trigger.hasAttribute('data-auto-group-effect'), false)
+ assert.equal(trigger.querySelector('[data-auto-group-flow-border]'), null)
+
+ await act(async () => root.unmount())
+ container.remove()
+ })
+
+ test('preserves the static Auto treatment but omits moving layers for reduced motion', async () => {
+ setReducedMotion(true)
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+
+ await act(async () => root.render( ))
+
+ const trigger = getTrigger(container)
+ assert.equal(trigger.dataset.autoGroupEffect, 'trigger')
+ assert.equal(trigger.querySelector('[data-auto-group-flow-border]'), null)
+ assert.ok(trigger.querySelector('[data-auto-group-effect="ratio"]'))
+
+ await act(async () => trigger.click())
+ const autoOption = getCommandItem('Global automatic routing')
+ assert.equal(autoOption.dataset.autoGroupEffect, 'option')
+ assert.equal(
+ autoOption.querySelector('[data-auto-group-flow-border]'),
+ null
+ )
+ assert.ok(autoOption.querySelector('[data-auto-group-effect="ratio"]'))
+
+ await act(async () => root.unmount())
+ container.remove()
+ setReducedMotion(false)
+ })
+})
diff --git a/web/src/features/keys/components/__tests__/api-keys-mutate-drawer.test.tsx b/web/src/features/keys/components/__tests__/api-keys-mutate-drawer.test.tsx
new file mode 100644
index 000000000000..238d0b21d4d0
--- /dev/null
+++ b/web/src/features/keys/components/__tests__/api-keys-mutate-drawer.test.tsx
@@ -0,0 +1,371 @@
+/*
+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 { after, afterEach, describe, test } from 'node:test'
+
+import { Window } from 'happy-dom'
+
+const domWindow = new Window()
+const domGlobals = [
+ 'window',
+ 'document',
+ 'navigator',
+ 'HTMLElement',
+ 'HTMLButtonElement',
+ 'HTMLInputElement',
+ 'HTMLFormElement',
+ '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 { createInstance } = await import('i18next')
+const { I18nextProvider, initReactI18next } = await import('react-i18next')
+const { QueryClient, QueryClientProvider } =
+ await import('@tanstack/react-query')
+const { api } = await import('@/lib/api')
+const { ApiKeysProvider } = await import('../api-keys-provider')
+const { ApiKeysMutateDrawer } = await import('../api-keys-mutate-drawer')
+
+const i18n = createInstance()
+await i18n.use(initReactI18next).init({
+ lng: 'en',
+ resources: { en: { translation: {} } },
+})
+
+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
+ post: ApiMethod
+}
+type RenderedDrawer = {
+ host: HTMLDivElement
+ queryClient: InstanceType
+ root: ReturnType
+}
+
+const apiClient = api as unknown as MockableApi
+const originalGet = apiClient.get
+const originalPost = apiClient.post
+let renderedDrawer: RenderedDrawer | null = null
+
+function installApiFixtures(createdPayloads: Array>) {
+ apiClient.get = async (url) => {
+ switch (url) {
+ case '/api/status':
+ return { data: { data: { default_use_auto_group: true } } }
+ case '/api/user/models':
+ return { data: { success: true, data: [] } }
+ case '/api/user/self/groups':
+ return {
+ data: {
+ success: true,
+ data: {
+ auto: { desc: 'Automatic routing', ratio: 'auto' },
+ default: { desc: 'Standard access', ratio: 1 },
+ vip: { desc: 'Priority access', ratio: 2 },
+ },
+ },
+ }
+ case '/api/token/auto-groups':
+ return {
+ data: {
+ success: true,
+ data: { groups: ['vip', 'default'], max_count: 3 },
+ },
+ }
+ default:
+ throw new Error(`Unexpected GET ${url}`)
+ }
+ }
+ apiClient.post = async (url, data) => {
+ assert.equal(url, '/api/token/')
+ assert.ok(data && typeof data === 'object')
+ createdPayloads.push(data as Record)
+ return { data: { success: true, data: {} } }
+ }
+}
+
+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,
+ })
+ })
+}
+
+async function renderCreateDrawer(): Promise {
+ const host = document.createElement('div')
+ document.body.append(host)
+ const root = createRoot(host)
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ })
+ const freshAt = Date.now() + 60_000
+ queryClient.setQueryData(
+ ['status'],
+ { default_use_auto_group: true },
+ { updatedAt: freshAt }
+ )
+ queryClient.setQueryData(
+ ['user-models'],
+ { success: true, data: [] },
+ { updatedAt: freshAt }
+ )
+ queryClient.setQueryData(
+ ['user-groups'],
+ {
+ success: true,
+ data: {
+ auto: { desc: 'Automatic routing', ratio: 'auto' },
+ default: { desc: 'Standard access', ratio: 1 },
+ vip: { desc: 'Priority access', ratio: 2 },
+ },
+ },
+ { updatedAt: freshAt }
+ )
+ queryClient.setQueryData(
+ ['token-auto-groups'],
+ {
+ success: true,
+ data: { groups: ['vip', 'default'], max_count: 3 },
+ },
+ { updatedAt: freshAt }
+ )
+ renderedDrawer = { host, queryClient, root }
+
+ await act(async () =>
+ root.render(
+
+
+
+ undefined} />
+
+
+
+ )
+ )
+ await act(async () =>
+ waitForCondition(() => {
+ const saveButton = findButton('Save changes', false)
+ return saveButton !== null && !saveButton.disabled
+ }, 'API key drawer did not finish initializing')
+ )
+}
+
+function findButton(text: string, required: true): HTMLButtonElement
+function findButton(text: string, required: false): HTMLButtonElement | null
+function findButton(text: string, required = true): HTMLButtonElement | null {
+ const button = [
+ ...document.querySelectorAll('button'),
+ ].find((candidate) => candidate.textContent?.includes(text))
+ if (required) assert.ok(button, `Expected button containing "${text}"`)
+ return button ?? null
+}
+
+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, textarea, button[role="combobox"], [role="group"]'
+ )
+ 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 selectComboboxOption(
+ trigger: HTMLButtonElement,
+ optionDescription: string
+) {
+ await act(async () => trigger.click())
+ const option = [
+ ...document.querySelectorAll('[data-slot="command-item"]'),
+ ].find((candidate) => candidate.textContent?.includes(optionDescription))
+ assert.ok(option, `Expected option containing "${optionDescription}"`)
+ await act(async () => option.click())
+}
+
+afterEach(async () => {
+ apiClient.get = originalGet
+ apiClient.post = originalPost
+ domWindow.localStorage.clear()
+ if (renderedDrawer) {
+ await act(async () => renderedDrawer?.root.unmount())
+ renderedDrawer.queryClient.clear()
+ renderedDrawer.host.remove()
+ renderedDrawer = null
+ }
+ document.body.replaceChildren()
+})
+
+after(() => {
+ domWindow.close()
+})
+
+describe('API keys mutate drawer Auto group integration', () => {
+ test('inherits the root Auto order and sends an empty override for every batch-created key', async () => {
+ const createdPayloads: Array> = []
+ installApiFixtures(createdPayloads)
+ await renderCreateDrawer()
+
+ const groupTrigger = getControlByLabel('Group')
+ assert.equal(groupTrigger.textContent?.includes('auto'), true)
+ assert.equal(
+ document.body.textContent?.includes(
+ 'Using the complete global Auto order (2 groups)'
+ ),
+ true
+ )
+ assert.deepEqual(
+ [
+ ...document.querySelectorAll('[data-slot="global-auto-order-name"]'),
+ ].map((item) => item.textContent),
+ ['vip', 'default']
+ )
+ assert.equal(findButton('Restore global Auto', true).disabled, true)
+
+ await changeInput(getControlByLabel('Name'), 'batch')
+ await changeInput(getControlByLabel('Quantity'), '2')
+ await act(async () => findButton('Save changes', true).click())
+ await act(async () =>
+ waitForCondition(
+ () => createdPayloads.length === 2,
+ 'batch API keys were not created'
+ )
+ )
+
+ assert.equal(createdPayloads.length, 2)
+ assert.equal(createdPayloads[0]?.name, 'batch')
+ for (const payload of createdPayloads) {
+ assert.equal(payload.group, 'auto')
+ assert.deepEqual(payload.auto_groups, [])
+ assert.equal(payload.cross_group_retry, true)
+ }
+ })
+
+ test('preserves an unsaved custom order and mode after Auto to ordinary to Auto changes', async () => {
+ const createdPayloads: Array> = []
+ installApiFixtures(createdPayloads)
+ await renderCreateDrawer()
+
+ const autoOrderControl = getControlByLabel('Auto group order')
+ const addGroupTrigger = autoOrderControl.querySelector(
+ 'button[role="combobox"]'
+ )
+ assert.ok(addGroupTrigger)
+ await selectComboboxOption(addGroupTrigger, 'Priority access')
+
+ assert.ok(document.querySelector('button[aria-label="Remove vip"]'))
+ assert.equal(
+ document.body.textContent?.includes('1 / 3 groups selected'),
+ true
+ )
+ assert.equal(findButton('Restore global Auto', true).disabled, false)
+
+ const groupTrigger = getControlByLabel('Group')
+ await selectComboboxOption(groupTrigger, 'Standard access')
+ assert.equal(
+ document.querySelector('button[aria-label="Remove vip"]'),
+ null
+ )
+ await selectComboboxOption(groupTrigger, 'Automatic routing')
+
+ assert.ok(document.querySelector('button[aria-label="Remove vip"]'))
+ assert.equal(
+ document.body.textContent?.includes('1 / 3 groups selected'),
+ true
+ )
+ assert.equal(findButton('Restore global Auto', true).disabled, false)
+
+ await changeInput(getControlByLabel('Name'), 'custom')
+ await act(async () => findButton('Save changes', true).click())
+ await act(async () =>
+ waitForCondition(
+ () => createdPayloads.length === 1,
+ 'custom-order API key was not created'
+ )
+ )
+ assert.deepEqual(createdPayloads[0]?.auto_groups, ['vip'])
+ })
+})
diff --git a/web/src/features/keys/components/__tests__/auto-group-order-editor.test.tsx b/web/src/features/keys/components/__tests__/auto-group-order-editor.test.tsx
new file mode 100644
index 000000000000..f37f50755f2d
--- /dev/null
+++ b/web/src/features/keys/components/__tests__/auto-group-order-editor.test.tsx
@@ -0,0 +1,540 @@
+/*
+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 { after, describe, test } from 'node:test'
+
+import { Window } from 'happy-dom'
+
+const domWindow = new Window()
+const domGlobals = [
+ 'window',
+ 'document',
+ 'navigator',
+ 'HTMLElement',
+ 'HTMLButtonElement',
+ 'HTMLInputElement',
+ 'SVGElement',
+ 'Node',
+ 'Element',
+ 'Event',
+ 'KeyboardEvent',
+ 'PointerEvent',
+ 'CustomEvent',
+ 'MutationObserver',
+ 'ResizeObserver',
+ 'requestAnimationFrame',
+ 'cancelAnimationFrame',
+ 'getComputedStyle',
+] as const
+
+for (const key of domGlobals) {
+ Object.defineProperty(globalThis, key, {
+ configurable: true,
+ value: domWindow[key],
+ })
+}
+
+const { act, useState } = await import('react')
+const { createRoot } = await import('react-dom/client')
+const { createInstance } = await import('i18next')
+const { I18nextProvider, initReactI18next } = await import('react-i18next')
+const { AutoGroupOrderEditor } = await import('../auto-group-order-editor')
+
+const i18n = createInstance()
+await i18n.use(initReactI18next).init({
+ lng: 'en',
+ resources: {
+ en: {
+ translation: {
+ '{{count}} / {{max}} groups selected':
+ '{{count}} / {{max}} groups selected',
+ 'Add Auto group': 'Add Auto group',
+ 'Auto group order': 'Auto group order',
+ 'Drag {{group}} to reorder': 'Drag {{group}} to reorder',
+ 'Inherit global Auto order': 'Inherit global Auto order',
+ 'Maximum {{max}} groups selected': 'Maximum {{max}} groups selected',
+ 'Move {{group}} down': 'Move {{group}} down',
+ 'Move {{group}} up': 'Move {{group}} up',
+ 'No available groups in the global Auto order.':
+ 'No available groups in the global Auto order.',
+ 'No valid custom Auto groups remain. Add a group or restore global Auto.':
+ 'No valid custom Auto groups remain. Add a group or restore global Auto.',
+ 'No custom groups. Saving will inherit the complete global Auto order.':
+ 'No custom groups. Saving will inherit the complete global Auto order.',
+ 'Remove {{group}}': 'Remove {{group}}',
+ 'Restore global Auto': 'Restore global Auto',
+ Ratio: 'Ratio',
+ 'Search...': 'Search...',
+ 'No group found.': 'No group found.',
+ 'Select a group': 'Select a group',
+ 'Using the complete global Auto order ({{count}} groups)':
+ 'Using the complete global Auto order ({{count}} groups)',
+ },
+ },
+ },
+})
+
+const reactTestGlobals = globalThis as typeof globalThis & {
+ IS_REACT_ACT_ENVIRONMENT?: boolean
+}
+reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true
+
+const globalOptions = [
+ { value: 'vip', label: 'VIP', desc: 'Priority access', ratio: 3 },
+ { value: 'default', label: 'Default', desc: 'Standard access', ratio: 1 },
+ { value: 'team', label: 'Team', desc: 'Shared access', ratio: 2 },
+]
+
+function Harness(props: { initialGroups?: string[] }) {
+ const [groups, setGroups] = useState(
+ props.initialGroups ?? ['default', 'vip']
+ )
+ const [mode, setMode] = useState<'inherit' | 'custom'>('custom')
+ return (
+
+ {
+ setGroups(value.groups)
+ setMode(value.mode)
+ }}
+ />
+ {groups.join(',')}
+ {mode}
+
+ )
+}
+
+function InheritanceHarness(props: { globalOptions?: typeof globalOptions }) {
+ const [groups, setGroups] = useState([])
+ const [mode, setMode] = useState<'inherit' | 'custom'>('inherit')
+
+ return (
+
+ {
+ setGroups(value.groups)
+ setMode(value.mode)
+ }}
+ />
+ {groups.join(',')}
+ {mode}
+
+ )
+}
+
+function CustomEmptyHarness() {
+ const [groups, setGroups] = useState([])
+ const [mode, setMode] = useState<'inherit' | 'custom'>('custom')
+
+ return (
+
+ {
+ setGroups(value.groups)
+ setMode(value.mode)
+ }}
+ />
+ {groups.join(',')}
+ {mode}
+
+ )
+}
+
+function findButton(container: ParentNode, label: string): HTMLButtonElement {
+ const button = container.querySelector(
+ `button[aria-label="${label}"]`
+ )
+ assert.ok(button)
+ return button
+}
+
+describe('Auto group order editor', () => {
+ after(() => {
+ domWindow.close()
+ })
+
+ test('enforces the limit and exposes accessible reorder controls', async () => {
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+
+ await act(async () => root.render( ))
+
+ const addButton = container.querySelector(
+ 'button[role="combobox"]'
+ )
+ assert.ok(addButton)
+ assert.equal(addButton.disabled, true)
+ assert.equal(container.textContent?.includes('2 / 2 groups selected'), true)
+ assert.ok(
+ container.querySelector('[role="group"][aria-label="Auto group order"]')
+ )
+ assert.equal(
+ findButton(container, 'Drag default to reorder').type,
+ 'button'
+ )
+
+ await act(async () => findButton(container, 'Move default down').click())
+ assert.equal(
+ container.querySelector('[data-testid="order"]')?.textContent,
+ 'vip,default'
+ )
+
+ await act(async () => {
+ findButton(container, 'Drag vip to reorder').dispatchEvent(
+ new domWindow.KeyboardEvent('keydown', {
+ key: 'ArrowDown',
+ bubbles: true,
+ }) as unknown as KeyboardEvent
+ )
+ })
+ assert.equal(
+ container.querySelector('[data-testid="order"]')?.textContent,
+ 'default,vip'
+ )
+
+ await act(async () => root.unmount())
+ container.remove()
+ })
+
+ test('adds and removes groups, then restores inheritance as an empty value', async () => {
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+
+ await act(async () => root.render( ))
+ await act(async () => findButton(container, 'Remove vip').click())
+
+ assert.equal(
+ container.querySelector('[data-testid="order"]')?.textContent,
+ 'default'
+ )
+ const addButton = container.querySelector(
+ 'button[role="combobox"]'
+ )
+ assert.ok(addButton)
+ assert.equal(addButton.disabled, false)
+
+ await act(async () => addButton.click())
+ const teamOption = [
+ ...document.querySelectorAll('[data-slot="command-item"]'),
+ ].find((option) => option.textContent?.includes('team'))
+ assert.ok(teamOption)
+ await act(async () => teamOption.click())
+ assert.equal(
+ container.querySelector('[data-testid="order"]')?.textContent,
+ 'default,team'
+ )
+ assert.equal(addButton.disabled, true)
+
+ const restoreButton = [...container.querySelectorAll('button')].find(
+ (button) => button.textContent?.includes('Restore global Auto')
+ )
+ assert.ok(restoreButton)
+ await act(async () => restoreButton.click())
+
+ assert.equal(
+ container.querySelector('[data-testid="order"]')?.textContent,
+ ''
+ )
+ assert.equal(
+ container.querySelector('[data-testid="mode"]')?.textContent,
+ 'inherit'
+ )
+ assert.equal(
+ container.textContent?.includes(
+ 'Using the complete global Auto order (3 groups)'
+ ),
+ true
+ )
+
+ const inheritedItems = container.querySelectorAll(
+ '[data-slot="global-auto-order"] > li'
+ )
+ assert.deepEqual(
+ [...inheritedItems].map(
+ (item) =>
+ item.querySelector('[data-slot="global-auto-order-name"]')
+ ?.textContent
+ ),
+ ['VIP', 'Default', 'Team']
+ )
+
+ await act(async () => root.unmount())
+ container.remove()
+ })
+
+ test('shows the complete inherited order with metadata beyond the custom limit', async () => {
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+
+ await act(async () => root.render( ))
+
+ assert.equal(
+ container.textContent?.includes(
+ 'Using the complete global Auto order (3 groups)'
+ ),
+ true
+ )
+ assert.equal(
+ container.textContent?.includes('0 / 2 groups selected'),
+ false
+ )
+
+ const order = container.querySelector(
+ '[data-slot="global-auto-order"]'
+ )
+ assert.ok(order)
+ assert.equal(order.classList.contains('overflow-y-auto'), true)
+ assert.equal(order.classList.contains('flex-wrap'), true)
+
+ const items = [...order.querySelectorAll('li')]
+ assert.equal(items.length, 3)
+ assert.equal(
+ order.querySelectorAll('[data-slot="global-auto-order-connector"]')
+ .length,
+ 2
+ )
+ assert.deepEqual(
+ items.map((item) => ({
+ index: item.querySelector('[data-slot="global-auto-order-index"]')
+ ?.textContent,
+ name: item.querySelector('[data-slot="global-auto-order-name"]')
+ ?.textContent,
+ title: item
+ .querySelector('[data-slot="global-auto-order-chip"]')
+ ?.getAttribute('title'),
+ description: item.querySelector(
+ '[data-slot="global-auto-order-description"]'
+ )?.textContent,
+ ratio: item.querySelector('[data-slot="badge"]')?.textContent,
+ })),
+ [
+ {
+ index: '1',
+ name: 'VIP',
+ title: 'Priority access',
+ description: 'Priority access',
+ ratio: '3x Ratio',
+ },
+ {
+ index: '2',
+ name: 'Default',
+ title: 'Standard access',
+ description: 'Standard access',
+ ratio: '1x Ratio',
+ },
+ {
+ index: '3',
+ name: 'Team',
+ title: 'Shared access',
+ description: 'Shared access',
+ ratio: '2x Ratio',
+ },
+ ]
+ )
+
+ for (const item of items) {
+ const chip = item.querySelector('[data-slot="global-auto-order-chip"]')
+ assert.ok(chip)
+ const description = item.querySelector(
+ '[data-slot="global-auto-order-description"]'
+ )
+ assert.ok(description)
+ assert.equal(description.classList.contains('sr-only'), true)
+ }
+
+ assert.equal(
+ items[0]?.querySelector('[data-slot="global-auto-order-connector"]'),
+ null
+ )
+ for (const item of items.slice(1)) {
+ const connector = item.querySelector(
+ '[data-slot="global-auto-order-connector"]'
+ )
+ assert.ok(connector)
+ assert.equal(connector.getAttribute('aria-hidden'), 'true')
+ }
+
+ assert.equal(container.querySelector('[aria-label^="Drag "]'), null)
+ assert.equal(container.querySelector('[aria-label^="Move "]'), null)
+ assert.equal(container.querySelector('[aria-label^="Remove "]'), null)
+
+ const restoreButton = [...container.querySelectorAll('button')].find(
+ (button) => button.textContent?.includes('Restore global Auto')
+ )
+ assert.ok(restoreButton)
+ assert.equal(restoreButton.disabled, true)
+
+ await act(async () => root.unmount())
+ container.remove()
+ })
+
+ test('shows an explicit empty state when the global Auto order has no groups', async () => {
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+
+ await act(async () =>
+ root.render( )
+ )
+
+ assert.equal(
+ container.textContent?.includes(
+ 'Using the complete global Auto order (0 groups)'
+ ),
+ true
+ )
+ assert.equal(
+ container.textContent?.includes(
+ 'No available groups in the global Auto order.'
+ ),
+ true
+ )
+ assert.equal(
+ container.querySelector('[data-slot="global-auto-order"]'),
+ null
+ )
+
+ await act(async () => root.unmount())
+ container.remove()
+ })
+
+ test('keeps an empty custom order distinct from global inheritance', async () => {
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+
+ await act(async () => root.render( ))
+
+ assert.equal(
+ container.querySelector('[data-testid="mode"]')?.textContent,
+ 'custom'
+ )
+ assert.equal(
+ container.textContent?.includes(
+ 'No valid custom Auto groups remain. Add a group or restore global Auto.'
+ ),
+ true
+ )
+ assert.equal(
+ container.querySelector('[data-slot="global-auto-order"]'),
+ null
+ )
+
+ const restoreButton = [...container.querySelectorAll('button')].find(
+ (button) => button.textContent?.includes('Restore global Auto')
+ )
+ assert.ok(restoreButton)
+ assert.equal(restoreButton.disabled, false)
+ await act(async () => restoreButton.click())
+
+ assert.equal(
+ container.querySelector('[data-testid="mode"]')?.textContent,
+ 'inherit'
+ )
+ assert.ok(container.querySelector('[data-slot="global-auto-order"]'))
+
+ await act(async () => root.unmount())
+ container.remove()
+ })
+
+ test('adding a group from inheritance explicitly creates a custom order', async () => {
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+
+ await act(async () => root.render( ))
+
+ const addButton = container.querySelector(
+ 'button[role="combobox"]'
+ )
+ assert.ok(addButton)
+ await act(async () => addButton.click())
+ const vipOption = [
+ ...document.querySelectorAll('[data-slot="command-item"]'),
+ ].find((option) => option.textContent?.includes('VIP'))
+ assert.ok(vipOption)
+ await act(async () => vipOption.click())
+
+ assert.equal(
+ container.querySelector('[data-testid="mode"]')?.textContent,
+ 'custom'
+ )
+ assert.equal(
+ container.querySelector('[data-testid="order"]')?.textContent,
+ 'vip'
+ )
+ assert.equal(
+ container.querySelector('[data-slot="global-auto-order"]'),
+ null
+ )
+
+ await act(async () => root.unmount())
+ container.remove()
+ })
+
+ test('removing the last custom group does not silently enable inheritance', async () => {
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+
+ await act(async () => root.render( ))
+ await act(async () => findButton(container, 'Remove default').click())
+
+ assert.equal(
+ container.querySelector('[data-testid="order"]')?.textContent,
+ ''
+ )
+ assert.equal(
+ container.querySelector('[data-testid="mode"]')?.textContent,
+ 'custom'
+ )
+ assert.equal(
+ container.textContent?.includes(
+ 'No valid custom Auto groups remain. Add a group or restore global Auto.'
+ ),
+ true
+ )
+
+ await act(async () => root.unmount())
+ container.remove()
+ })
+})
diff --git a/web/src/features/keys/components/api-key-group-cell.tsx b/web/src/features/keys/components/api-key-group-cell.tsx
new file mode 100644
index 000000000000..21a1bc7d9a04
--- /dev/null
+++ b/web/src/features/keys/components/api-key-group-cell.tsx
@@ -0,0 +1,90 @@
+/*
+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 { useTranslation } from 'react-i18next'
+
+import { BadgeCell, TruncatedCell } from '@/components/data-table'
+import { GroupBadge } from '@/components/group-badge'
+import { StatusBadge } from '@/components/status-badge'
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/components/ui/tooltip'
+
+import {
+ // AutoGroupBadge,
+ GroupRatioBadge,
+ type GroupRatio,
+} from './auto-group-visuals'
+
+type ApiKeyGroupCellProps = {
+ crossGroupRetry: boolean
+ group: string
+ ratio?: GroupRatio
+ shouldReduceMotion: boolean
+}
+
+export function ApiKeyGroupCell(props: ApiKeyGroupCellProps) {
+ const { t } = useTranslation()
+
+ if (props.group !== 'auto') {
+ const ratio = typeof props.ratio === 'number' ? props.ratio : undefined
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+ }
+ >
+
+ {/* */}
+
+
+
+
+ {t(
+ 'Automatically selects the best available group with circuit breaker mechanism'
+ )}
+
+
+
+ )
+}
diff --git a/web/src/features/keys/components/api-key-group-combobox.tsx b/web/src/features/keys/components/api-key-group-combobox.tsx
index 2593eff016eb..a5dada76d147 100644
--- a/web/src/features/keys/components/api-key-group-combobox.tsx
+++ b/web/src/features/keys/components/api-key-group-combobox.tsx
@@ -20,7 +20,6 @@ import { Check, ChevronsUpDown } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Command,
@@ -35,8 +34,15 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
+import { useMediaQuery } from '@/hooks'
import { cn } from '@/lib/utils'
+import {
+ AUTO_GROUP_FRAME_CLASS_NAME,
+ AutoGroupFlowBorder,
+ GroupRatioBadge,
+} from './auto-group-visuals'
+
export type ApiKeyGroupOption = {
value: string
label: string
@@ -52,50 +58,6 @@ type ApiKeyGroupComboboxProps = {
disabled?: boolean
}
-function formatGroupRatio(
- ratio: ApiKeyGroupOption['ratio'],
- ratioLabel: string
-) {
- if (ratio === undefined || ratio === null || ratio === '') return null
- return `${ratio}x ${ratioLabel}`
-}
-
-function getRatioBadgeClassName(ratio: ApiKeyGroupOption['ratio']) {
- if (typeof ratio !== 'number') {
- return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
- }
-
- if (ratio > 5) {
- return 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300'
- }
- if (ratio > 3) {
- return 'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/60 dark:bg-orange-950/40 dark:text-orange-300'
- }
- if (ratio > 1) {
- return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-300'
- }
- return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
-}
-
-function GroupRatioBadge({ ratio }: { ratio: ApiKeyGroupOption['ratio'] }) {
- const { t } = useTranslation()
- const label = formatGroupRatio(ratio, t('Ratio'))
-
- if (!label) return null
-
- return (
-
- {label}
-
- )
-}
-
export function ApiKeyGroupCombobox({
options,
value,
@@ -106,7 +68,9 @@ export function ApiKeyGroupCombobox({
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [searchValue, setSearchValue] = useState('')
+ const shouldReduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
const selectedOption = options.find((option) => option.value === value)
+ const isAutoSelected = selectedOption?.value === 'auto'
const filteredOptions = useMemo(() => {
const search = searchValue.trim().toLowerCase()
@@ -138,11 +102,22 @@ export function ApiKeyGroupCombobox({
variant='outline'
role='combobox'
aria-expanded={open}
+ data-auto-group-effect={isAutoSelected ? 'trigger' : undefined}
disabled={disabled}
- className='border-input bg-muted/40 hover:bg-muted/55 hover:text-foreground active:bg-background data-popup-open:border-ring data-popup-open:bg-background data-popup-open:ring-ring/20 h-auto min-h-14 w-full justify-between gap-2 rounded-lg px-3 py-2 text-start shadow-none transition-[background-color,border-color,box-shadow] duration-150 data-popup-open:ring-[3px] sm:min-h-20 sm:gap-3 sm:px-4 sm:py-3'
+ className={cn(
+ 'border-input bg-muted/40 hover:bg-muted/55 hover:text-foreground active:bg-background data-popup-open:border-ring data-popup-open:bg-background data-popup-open:ring-ring/20 relative h-auto min-h-14 w-full justify-between gap-2 rounded-lg px-3 py-2 text-start shadow-none transition-[background-color,border-color,box-shadow] duration-150 data-popup-open:ring-[3px] sm:min-h-20 sm:gap-3 sm:px-4 sm:py-3',
+ isAutoSelected &&
+ cn(
+ AUTO_GROUP_FRAME_CLASS_NAME,
+ 'hover:border-primary/55 data-popup-open:border-primary/55 data-popup-open:ring-primary/20'
+ )
+ )}
/>
}
>
+ {isAutoSelected && (
+
+ )}
@@ -155,10 +130,17 @@ export function ApiKeyGroupCombobox({
)}
-
+
-
+
{t('No group found.')}
- {filteredOptions.map((option) => (
- handleSelect(option.value)}
- className='data-[selected=true]:bg-muted items-start gap-3 rounded-lg px-3 py-3 transition-colors'
- >
- {
+ const isAutoOption = option.value === 'auto'
+
+ return (
+ handleSelect(option.value)}
className={cn(
- 'mt-0.5 h-4 w-4',
- value === option.value ? 'opacity-100' : 'opacity-0'
+ 'data-[selected=true]:bg-muted items-start gap-3 rounded-lg px-3 py-3 transition-colors',
+ isAutoOption &&
+ cn(
+ AUTO_GROUP_FRAME_CLASS_NAME,
+ 'border-primary/35 data-[selected=true]:border-primary/55'
+ )
)}
- />
-
-
- {option.label}
-
- {option.desc && (
-
- {option.desc}
-
+ >
+ {isAutoOption && (
+
)}
-
-
-
- ))}
+
+
+
+ {option.label}
+
+ {option.desc && (
+
+ {option.desc}
+
+ )}
+
+
+
+ )
+ })}
diff --git a/web/src/features/keys/components/api-keys-columns.tsx b/web/src/features/keys/components/api-keys-columns.tsx
index 645d051388c6..2880783d931c 100644
--- a/web/src/features/keys/components/api-keys-columns.tsx
+++ b/web/src/features/keys/components/api-keys-columns.tsx
@@ -20,8 +20,6 @@ import { useQuery } from '@tanstack/react-query'
import type { ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
-import { BadgeCell, TruncatedCell } from '@/components/data-table'
-import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Progress } from '@/components/ui/progress'
@@ -30,6 +28,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
+import { useMediaQuery } from '@/hooks'
import { toIntlLocale } from '@/i18n/languages'
import { getUserGroups } from '@/lib/api'
import dayjs from '@/lib/dayjs'
@@ -38,6 +37,7 @@ import { cn } from '@/lib/utils'
import { API_KEY_STATUSES } from '../constants'
import type { ApiKey } from '../types'
+import { ApiKeyGroupCell } from './api-key-group-cell'
import { ApiKeyTimestampCell } from './api-key-timestamp-cell'
import {
ApiKeyCell,
@@ -53,16 +53,16 @@ function getQuotaProgressColor(percentage: number): string {
return '[&_[data-slot=progress-indicator]]:bg-emerald-500'
}
-function useGroupRatios(): Record {
+function useGroupRatios(): Record {
const { data } = useQuery({
queryKey: ['user-groups'],
queryFn: getUserGroups,
staleTime: 0,
select: (res) => {
if (!res.success || !res.data) return {}
- const ratios: Record = {}
+ const ratios: Record = {}
for (const [group, info] of Object.entries(res.data)) {
- if (typeof info.ratio === 'number') {
+ if (typeof info.ratio === 'number' || typeof info.ratio === 'string') {
ratios[group] = info.ratio
}
}
@@ -76,6 +76,7 @@ function useGroupRatios(): Record {
export function useApiKeysColumns(now: number): ColumnDef[] {
const { t, i18n } = useTranslation()
const groupRatios = useGroupRatios()
+ const shouldReduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
const justNowLabel = t('Just now')
const staleAccessThreshold = dayjs(now).subtract(3, 'month').valueOf()
@@ -195,44 +196,16 @@ export function useApiKeysColumns(now: number): ColumnDef[] {
cell: ({ row }) => {
const apiKey = row.original
const group = row.getValue('group') as string
- const ratio = group && group !== 'auto' ? groupRatios[group] : undefined
-
- if (group === 'auto') {
- return (
-
- }
- >
-
- {apiKey.cross_group_retry && (
-
- )}
-
-
-
- {t(
- 'Automatically selects the best available group with circuit breaker mechanism'
- )}
-
-
-
- )
- }
return (
-
-
-
+
)
},
- size: 160,
+ size: 220,
meta: { mobileHidden: true },
},
{
diff --git a/web/src/features/keys/components/api-keys-mutate-drawer.tsx b/web/src/features/keys/components/api-keys-mutate-drawer.tsx
index 9fa64e6f3937..ed393c53e6cf 100644
--- a/web/src/features/keys/components/api-keys-mutate-drawer.tsx
+++ b/web/src/features/keys/components/api-keys-mutate-drawer.tsx
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { zodResolver } from '@hookform/resolvers/zod'
import { useQuery } from '@tanstack/react-query'
import { ChevronDown, KeyRound, Settings2, WalletCards } from 'lucide-react'
-import { useEffect, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
import { useForm, type SubmitErrorHandler } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -67,7 +67,12 @@ import { getUserModels, getUserGroups } from '@/lib/api'
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
import { cn } from '@/lib/utils'
-import { createApiKey, updateApiKey, getApiKey } from '../api'
+import {
+ createApiKey,
+ updateApiKey,
+ getApiKey,
+ getTokenAutoGroups,
+} from '../api'
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
import {
getApiKeyFormSchema,
@@ -82,6 +87,7 @@ import {
type ApiKeyGroupOption,
} from './api-key-group-combobox'
import { useApiKeys } from './api-keys-provider'
+import { AutoGroupOrderEditor } from './auto-group-order-editor'
type ApiKeyMutateDrawerProps = {
open: boolean
@@ -96,10 +102,14 @@ export function ApiKeysMutateDrawer({
}: ApiKeyMutateDrawerProps) {
const { t } = useTranslation()
const isUpdate = !!currentRow
+ const currentRowId = currentRow?.id
const { triggerRefresh } = useApiKeys()
- const { status } = useStatus()
+ const { status, loading: statusLoading } = useStatus()
const [isSubmitting, setIsSubmitting] = useState(false)
const [advancedOpen, setAdvancedOpen] = useState(false)
+ const [initializedTarget, setInitializedTarget] = useState(
+ null
+ )
const defaultUseAutoGroup = status?.default_use_auto_group === true
// Fetch models
@@ -111,25 +121,77 @@ export function ApiKeysMutateDrawer({
})
// Fetch groups
- const { data: groupsData } = useQuery({
+ const {
+ data: groupsData,
+ isFetched: groupsFetched,
+ isFetching: groupsFetching,
+ } = useQuery({
queryKey: ['user-groups'],
queryFn: getUserGroups,
enabled: open,
staleTime: 0,
})
+ const {
+ data: apiKeyData,
+ isFetched: apiKeyFetched,
+ isFetching: apiKeyFetching,
+ } = useQuery({
+ queryKey: ['api-key', currentRowId],
+ queryFn: () => getApiKey(currentRowId ?? 0),
+ enabled: open && isUpdate && currentRowId !== undefined,
+ staleTime: 0,
+ })
+
+ const {
+ data: autoGroupsData,
+ isFetched: autoGroupsFetched,
+ isFetching: autoGroupsFetching,
+ } = useQuery({
+ queryKey: ['token-auto-groups'],
+ queryFn: getTokenAutoGroups,
+ enabled: open,
+ staleTime: 0,
+ })
+
const models = modelsData?.data || []
- const groupsRaw = groupsData?.data || {}
- const groups: ApiKeyGroupOption[] = Object.entries(groupsRaw).map(
- ([key, info]) => ({
- value: key,
- label: key,
- desc: info.desc || key,
- ratio: info.ratio,
- })
+ const groups = useMemo(
+ () =>
+ Object.entries(groupsData?.data || {}).map(([key, info]) => ({
+ value: key,
+ label: key,
+ desc: info.desc || key,
+ ratio: info.ratio,
+ })),
+ [groupsData]
)
const backendHasAuto = groups.some((g) => g.value === 'auto')
- const schema = getApiKeyFormSchema(t)
+ const availableAutoGroupNames = useMemo(
+ () => groups.filter((group) => group.value !== 'auto').map((g) => g.value),
+ [groups]
+ )
+ const globalAutoGroups = useMemo(() => {
+ const available = new Set(availableAutoGroupNames)
+ return (autoGroupsData?.data?.groups || []).filter((group) =>
+ available.has(group)
+ )
+ }, [autoGroupsData, availableAutoGroupNames])
+ const globalAutoGroupOptions = useMemo(() => {
+ const groupsByValue = new Map(groups.map((group) => [group.value, group]))
+ return globalAutoGroups.flatMap((group) => {
+ const option = groupsByValue.get(group)
+ return option ? [option] : []
+ })
+ }, [globalAutoGroups, groups])
+ const maxAutoGroups =
+ Number.isInteger(autoGroupsData?.data?.max_count) &&
+ Number(autoGroupsData?.data?.max_count) > 0
+ ? Number(autoGroupsData?.data?.max_count)
+ : 5
+ const schema = useMemo(
+ () => getApiKeyFormSchema(t, maxAutoGroups),
+ [t, maxAutoGroups]
+ )
const form = useForm({
resolver: zodResolver(schema),
@@ -138,23 +200,69 @@ export function ApiKeysMutateDrawer({
// Load existing data when updating
useEffect(() => {
- if (open && isUpdate && currentRow) {
- void getApiKey(currentRow.id).then((result) => {
- if (result.success && result.data) {
- form.reset(transformApiKeyToFormDefaults(result.data))
- }
- })
- } else if (open && !isUpdate) {
+ if (!open) {
+ setInitializedTarget(null)
+ return
+ }
+ if (
+ !groupsFetched ||
+ groupsFetching ||
+ !autoGroupsFetched ||
+ autoGroupsFetching
+ ) {
+ return
+ }
+ if (isUpdate && (!apiKeyFetched || apiKeyFetching)) return
+ if (!isUpdate && statusLoading) return
+
+ const target = isUpdate && currentRow ? `update:${currentRow.id}` : 'create'
+ if (initializedTarget === target) return
+ if (isUpdate && currentRow) {
+ if (apiKeyData?.success && apiKeyData.data) {
+ form.reset(
+ transformApiKeyToFormDefaults(
+ apiKeyData.data,
+ availableAutoGroupNames,
+ maxAutoGroups
+ )
+ )
+ setInitializedTarget(target)
+ }
+ } else {
form.reset(
getApiKeyFormDefaultValues(defaultUseAutoGroup && backendHasAuto)
)
+ setInitializedTarget(target)
}
- }, [open, isUpdate, currentRow, form, defaultUseAutoGroup, backendHasAuto])
+ }, [
+ open,
+ isUpdate,
+ currentRow,
+ form,
+ defaultUseAutoGroup,
+ statusLoading,
+ backendHasAuto,
+ groupsFetched,
+ groupsFetching,
+ autoGroupsFetched,
+ autoGroupsFetching,
+ apiKeyData,
+ apiKeyFetched,
+ apiKeyFetching,
+ availableAutoGroupNames,
+ maxAutoGroups,
+ initializedTarget,
+ ])
+
+ const formTarget =
+ isUpdate && currentRow ? `update:${currentRow.id}` : 'create'
+ const isFormInitialized = initializedTarget === formTarget
+ const selectedGroup = form.watch('group')
// Correct group after groups load: if the form value is not in available groups, fall back
useEffect(() => {
if (groups.length === 0) return
- const currentGroup = form.getValues('group')
+ const currentGroup = selectedGroup
if (currentGroup && !groups.some((g) => g.value === currentGroup)) {
const fallback =
groups.find((g) => g.value === 'default')?.value ??
@@ -162,10 +270,12 @@ export function ApiKeysMutateDrawer({
''
form.setValue('group', fallback)
if (currentGroup === 'auto') {
+ form.setValue('auto_groups', [])
+ form.setValue('auto_groups_mode', 'inherit')
form.setValue('cross_group_retry', false)
}
}
- }, [groups, form])
+ }, [groups, form, selectedGroup])
const onSubmit = async (data: ApiKeyFormValues) => {
setIsSubmitting(true)
@@ -247,7 +357,7 @@ export function ApiKeysMutateDrawer({
const quotaPlaceholder = tokensOnly
? t('Enter quota in tokens')
: t('Enter quota in {{currency}}', { currency: currencyLabel })
- const selectedGroup = form.watch('group')
+ const autoGroupsMode = form.watch('auto_groups_mode')
const unlimitedQuota = form.watch('unlimited_quota')
return (
@@ -277,6 +387,8 @@ export function ApiKeysMutateDrawer({
{customProviders.map((provider) => {
- const binding = customBindings.find(
- (b) => b.provider_id === String(provider.id)
- )
+ const binding = customBindingsByProviderId.get(provider.id)
const isBound = !!binding
return (
{isBound
- ? binding?.external_id || t('Bound')
+ ? binding?.provider_user_id || t('Bound')
: t('Not bound')}
diff --git a/web/src/features/system-settings/auth/custom-oauth/components/access-policy-templates.ts b/web/src/features/system-settings/auth/custom-oauth/components/access-policy-templates.ts
new file mode 100644
index 000000000000..0f19f63b7736
--- /dev/null
+++ b/web/src/features/system-settings/auth/custom-oauth/components/access-policy-templates.ts
@@ -0,0 +1,40 @@
+/*
+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
+*/
+export const ACCESS_POLICY_TEMPLATES = {
+ levelAndActive: `{
+ "logic": "and",
+ "conditions": [
+ { "field": "trust_level", "op": "gte", "value": 2 },
+ { "field": "active", "op": "eq", "value": true }
+ ]
+}`,
+ orgOrRole: `{
+ "logic": "or",
+ "conditions": [
+ { "field": "org", "op": "eq", "value": "core" },
+ { "field": "roles", "op": "contains", "value": "admin" }
+ ]
+}`,
+} as const
+
+export const ACCESS_DENIED_MESSAGE_TEMPLATES = {
+ level:
+ 'Requires level {{required}}; your current level is {{current}} (field: {{field}}).',
+ org: 'Access is limited to approved organizations or roles. Organization: {{current.org}}; roles: {{current.roles}}.',
+} as const
diff --git a/web/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx b/web/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx
index c83266f4aa1e..acafc1b83166 100644
--- a/web/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx
+++ b/web/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx
@@ -63,6 +63,10 @@ import {
type CustomOAuthProvider,
type CustomOAuthFormValues,
} from '../types'
+import {
+ ACCESS_DENIED_MESSAGE_TEMPLATES,
+ ACCESS_POLICY_TEMPLATES,
+} from './access-policy-templates'
import { DiscoveryButton } from './discovery-button'
import { PresetSelector } from './preset-selector'
@@ -603,6 +607,11 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
render={({ field }) => (
{t('Access Policy (JSON)')}
+
+ {t(
+ 'Evaluate fields from the provider user info response. Conditions and nested groups use and/or logic.'
+ )}
+
{t(
- 'JSON-based access control rules. Leave empty to allow all users.'
+ 'Supported operators: eq, ne, gt, gte, lt, lte, in, not_in, contains, not_contains, exists, not_exists. Leave empty to allow all users.'
)}
+
+
+ form.setValue(
+ 'access_policy',
+ ACCESS_POLICY_TEMPLATES.levelAndActive,
+ { shouldDirty: true, shouldValidate: true }
+ )
+ }
+ >
+ {t('Fill template: level and active')}
+
+
+ form.setValue(
+ 'access_policy',
+ ACCESS_POLICY_TEMPLATES.orgOrRole,
+ { shouldDirty: true, shouldValidate: true }
+ )
+ }
+ >
+ {t('Fill template: organization or role')}
+
+
)}
@@ -635,11 +674,46 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
+
+ {t(
+ 'Available variables: {{provider}}, {{field}}, {{op}}, {{required}}, {{current}}, and paths such as {{current.roles}}.'
+ )}
+
+
+
+ form.setValue(
+ 'access_denied_message',
+ ACCESS_DENIED_MESSAGE_TEMPLATES.level,
+ { shouldDirty: true }
+ )
+ }
+ >
+ {t('Fill template: level message')}
+
+
+ form.setValue(
+ 'access_denied_message',
+ ACCESS_DENIED_MESSAGE_TEMPLATES.org,
+ { shouldDirty: true }
+ )
+ }
+ >
+ {t('Fill template: organization message')}
+
+
)}
diff --git a/web/src/features/users/api.ts b/web/src/features/users/api.ts
index f3f2ba91a9ef..1d1d16ad5f71 100644
--- a/web/src/features/users/api.ts
+++ b/web/src/features/users/api.ts
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import type { PermissionCatalog } from '@/lib/admin-permissions'
import { api } from '@/lib/api'
+import type { CustomOAuthBinding } from '@/lib/oauth'
import type {
User,
@@ -178,19 +179,12 @@ export async function getPermissionCatalog(): Promise
{
// Admin Binding Management APIs
// ============================================================================
-export interface OAuthBinding {
- provider_id: string
- provider_name: string
- user_id?: number
- external_id?: string
-}
-
/**
* Get user's custom OAuth bindings (admin)
*/
export async function getUserOAuthBindings(
userId: number
-): Promise> {
+): Promise> {
const res = await api.get(`/api/user/${userId}/oauth/bindings`)
return res.data
}
@@ -211,7 +205,7 @@ export async function adminClearUserBinding(
*/
export async function adminUnbindCustomOAuth(
userId: number,
- providerId: string
+ providerId: number
): Promise {
const res = await api.delete(
`/api/user/${userId}/oauth/bindings/${providerId}`
diff --git a/web/src/features/users/components/dialogs/user-binding-dialog.tsx b/web/src/features/users/components/dialogs/user-binding-dialog.tsx
index 6db44645aff7..c4a56c229665 100644
--- a/web/src/features/users/components/dialogs/user-binding-dialog.tsx
+++ b/web/src/features/users/components/dialogs/user-binding-dialog.tsx
@@ -45,13 +45,13 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { api } from '@/lib/api'
+import { indexCustomOAuthBindings, type CustomOAuthBinding } from '@/lib/oauth'
import {
getUser,
getUserOAuthBindings,
adminClearUserBinding,
adminUnbindCustomOAuth,
- type OAuthBinding,
} from '../../api'
import type { User } from '../../types'
@@ -68,7 +68,7 @@ interface BindingItem {
icon: React.ReactNode
value: string
type: 'builtin' | 'custom'
- providerId?: string
+ providerId?: number
isBound: boolean
isEnabled: boolean
}
@@ -81,7 +81,7 @@ interface StatusInfo {
telegram_oauth?: boolean
linuxdo_oauth?: boolean
custom_oauth_providers?: Array<{
- id: string
+ id: number
name: string
icon?: string
}>
@@ -162,7 +162,7 @@ function CustomProviderIcon(props: { iconUrl?: string }) {
export function UserBindingDialog(props: Props) {
const { t } = useTranslation()
const [user, setUser] = useState(null)
- const [oauthBindings, setOauthBindings] = useState([])
+ const [oauthBindings, setOauthBindings] = useState([])
const [statusInfo, setStatusInfo] = useState({})
const [loading, setLoading] = useState(false)
const [showBoundOnly, setShowBoundOnly] = useState(true)
@@ -191,7 +191,7 @@ export function UserBindingDialog(props: Props) {
setUser(userRes.data)
}
if (oauthRes.success && oauthRes.data) {
- setOauthBindings(oauthRes.data as OAuthBinding[])
+ setOauthBindings(oauthRes.data)
}
if (statusRes.success && statusRes.data) {
setStatusInfo(statusRes.data as StatusInfo)
@@ -236,37 +236,35 @@ export function UserBindingDialog(props: Props) {
})
}
- const oauthBindingMap = new Map(
- oauthBindings.map((b) => [String(b.provider_id), b])
- )
+ const oauthBindingMap = indexCustomOAuthBindings(oauthBindings)
const customProviders = statusInfo.custom_oauth_providers || []
- const seenProviderIds = new Set()
+ const seenProviderIds = new Set()
for (const provider of customProviders) {
- seenProviderIds.add(String(provider.id))
- const binding = oauthBindingMap.get(String(provider.id))
+ seenProviderIds.add(provider.id)
+ const binding = oauthBindingMap.get(provider.id)
items.push({
key: `oauth_${provider.id}`,
- label: provider.name || provider.id,
+ label: provider.name || String(provider.id),
icon: ,
- value: binding?.external_id || '',
+ value: binding?.provider_user_id || '',
type: 'custom',
- providerId: String(provider.id),
+ providerId: provider.id,
isBound: !!binding,
isEnabled: true,
})
}
for (const binding of oauthBindings) {
- if (!seenProviderIds.has(String(binding.provider_id))) {
+ if (!seenProviderIds.has(binding.provider_id)) {
items.push({
key: `oauth_${binding.provider_id}`,
- label: binding.provider_name || binding.provider_id,
+ label: binding.provider_name || String(binding.provider_id),
icon: ,
- value: binding.external_id || '-',
+ value: binding.provider_user_id || '-',
type: 'custom',
- providerId: String(binding.provider_id),
+ providerId: binding.provider_id,
isBound: true,
isEnabled: false,
})
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index cbac6b6119d7..b3fec13839ec 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -536,6 +536,7 @@
"Available Models": "Available Models",
"Available reset credits": "Available reset credits",
"Available Rewards": "Available Rewards",
+ "Available variables: {{provider}}, {{field}}, {{op}}, {{required}}, {{current}}, and paths such as {{current.roles}}.": "Available variables: {{provider}}, {{field}}, {{op}}, {{required}}, {{current}}, and paths such as {{current.roles}}.",
"Average latency": "Average latency",
"Average latency, TTFT, and success rate by group": "Average latency, TTFT, and success rate by group",
"Average latency, TTFT, TPS, and success rate": "Average latency, TTFT, TPS, and success rate",
@@ -1487,6 +1488,7 @@
"e.g. my-gitlab": "e.g. my-gitlab",
"e.g. New API Console": "e.g. New API Console",
"e.g. openid profile email": "e.g. openid profile email",
+ "e.g. Requires level {{required}}; your current level is {{current}}": "e.g. Requires level {{required}}; your current level is {{current}}",
"e.g. Suitable for light usage": "e.g. Suitable for light usage",
"e.g. This request does not meet access policy": "e.g. This request does not meet access policy",
"e.g., 0.95": "e.g., 0.95",
@@ -1736,6 +1738,7 @@
"Error Type (optional)": "Error Type (optional)",
"Estimated cost": "Estimated cost",
"Estimated quota cost": "Estimated quota cost",
+ "Evaluate fields from the provider user info response. Conditions and nested groups use and/or logic.": "Evaluate fields from the provider user info response. Conditions and nested groups use and/or logic.",
"Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.",
"Every other device will lose access immediately. This device will remain signed in.": "Every other device will lose access immediately. This device will remain signed in.",
"Everything configured for this group, in one place.": "Everything configured for this group, in one place.",
@@ -1975,6 +1978,10 @@
"Fill in the following info to create a new subscription plan": "Fill in the following info to create a new subscription plan",
"Fill Related Models": "Fill Related Models",
"Fill Template": "Fill Template",
+ "Fill template: level and active": "Fill template: level and active",
+ "Fill template: level message": "Fill template: level message",
+ "Fill template: organization message": "Fill template: organization message",
+ "Fill template: organization or role": "Fill template: organization or role",
"Fill Templates": "Fill Templates",
"Fill the complete model value from the client request body, for example gpt-4o or gemini-2.5-flash. Separate multiple models with commas.": "Fill the complete model value from the client request body, for example gpt-4o or gemini-2.5-flash. Separate multiple models with commas.",
"Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format": "Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format",
@@ -4396,6 +4403,7 @@
"Supported Applications": "Supported Applications",
"Supported Imagine Models": "Supported Imagine Models",
"Supported modalities": "Supported modalities",
+ "Supported operators: eq, ne, gt, gte, lt, lte, in, not_in, contains, not_contains, exists, not_exists. Leave empty to allow all users.": "Supported operators: eq, ne, gt, gte, lt, lte, in, not_in, contains, not_contains, exists, not_exists. Leave empty to allow all users.",
"Supported parameters": "Supported parameters",
"Supported variables": "Supported variables",
"Supports `-thinking`, `-thinking-": "Supports `-thinking`, `-thinking-",
diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json
index fefe91d5cee3..e5528b779cc0 100644
--- a/web/src/i18n/locales/fr.json
+++ b/web/src/i18n/locales/fr.json
@@ -536,6 +536,7 @@
"Available Models": "Modèles disponibles",
"Available reset credits": "Crédits de réinitialisation disponibles",
"Available Rewards": "Récompenses disponibles",
+ "Available variables: {{provider}}, {{field}}, {{op}}, {{required}}, {{current}}, and paths such as {{current.roles}}.": "Variables disponibles : {{provider}}, {{field}}, {{op}}, {{required}}, {{current}}, ainsi que des chemins comme {{current.roles}}.",
"Average latency": "Latence moyenne",
"Average latency, TTFT, and success rate by group": "Latence moyenne, TTFT et taux de réussite par groupe",
"Average latency, TTFT, TPS, and success rate": "Latence moyenne, TTFT, TPS et taux de réussite",
@@ -1487,6 +1488,7 @@
"e.g. my-gitlab": "par ex. mon-gitlab",
"e.g. New API Console": "par ex. console New API",
"e.g. openid profile email": "par ex. openid profile email",
+ "e.g. Requires level {{required}}; your current level is {{current}}": "ex. Niveau {{required}} requis ; votre niveau actuel est {{current}}",
"e.g. Suitable for light usage": "ex. Adapté à une utilisation légère",
"e.g. This request does not meet access policy": "ex. Cette requête ne satisfait pas la politique d'accès",
"e.g., 0.95": "par ex., 0.95",
@@ -1736,6 +1738,7 @@
"Error Type (optional)": "Type d'erreur (optionnel)",
"Estimated cost": "Coût estimé",
"Estimated quota cost": "Coût de quota estimé",
+ "Evaluate fields from the provider user info response. Conditions and nested groups use and/or logic.": "Évalue les champs de la réponse d'informations utilisateur du fournisseur. Les conditions et groupes imbriqués utilisent la logique and/or.",
"Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "Chaque nom de groupe du tableau tarifaire peut être utilisé à deux endroits : sur un utilisateur (groupe d’utilisateurs, attribué par les admins) et sur un jeton (groupe de jetons, choisi à la création du jeton). Même ensemble de noms, deux rôles différents.",
"Every other device will lose access immediately. This device will remain signed in.": "Tous les autres appareils perdront immédiatement l’accès. Cet appareil restera connecté.",
"Everything configured for this group, in one place.": "Toute la configuration de ce groupe, au même endroit.",
@@ -1975,6 +1978,10 @@
"Fill in the following info to create a new subscription plan": "Remplissez les informations suivantes pour créer un nouveau plan d'abonnement",
"Fill Related Models": "Remplir les modèles associés",
"Fill Template": "Remplir le modèle",
+ "Fill template: level and active": "Insérer le modèle : niveau et état actif",
+ "Fill template: level message": "Insérer le modèle : message de niveau",
+ "Fill template: organization message": "Insérer le modèle : message d'organisation",
+ "Fill template: organization or role": "Insérer le modèle : organisation ou rôle",
"Fill Templates": "Remplir les modèles",
"Fill the complete model value from the client request body, for example gpt-4o or gemini-2.5-flash. Separate multiple models with commas.": "Saisissez la valeur model complète du corps de requête client, par exemple gpt-4o ou gemini-2.5-flash. Séparez plusieurs modèles par des virgules.",
"Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format": "Remplit thoughtSignature uniquement pour les canaux Gemini/Vertex utilisant le format OpenAI",
@@ -4396,6 +4403,7 @@
"Supported Applications": "Applications prises en charge",
"Supported Imagine Models": "Modèles Imagine pris en charge",
"Supported modalities": "Modalités prises en charge",
+ "Supported operators: eq, ne, gt, gte, lt, lte, in, not_in, contains, not_contains, exists, not_exists. Leave empty to allow all users.": "Opérateurs pris en charge : eq, ne, gt, gte, lt, lte, in, not_in, contains, not_contains, exists, not_exists. Laissez vide pour autoriser tous les utilisateurs.",
"Supported parameters": "Paramètres pris en charge",
"Supported variables": "Variables supportées",
"Supports `-thinking`, `-thinking-": "Prend en charge `-thinking`, `-thinking-",
diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json
index d1b56e3e77bb..dc72689c0bdc 100644
--- a/web/src/i18n/locales/ja.json
+++ b/web/src/i18n/locales/ja.json
@@ -536,6 +536,7 @@
"Available Models": "利用可能なモデル",
"Available reset credits": "利用可能なリセット回数",
"Available Rewards": "利用可能な報酬",
+ "Available variables: {{provider}}, {{field}}, {{op}}, {{required}}, {{current}}, and paths such as {{current.roles}}.": "使用可能な変数:{{provider}}、{{field}}、{{op}}、{{required}}、{{current}}、および {{current.roles}} のようなパス。",
"Average latency": "平均レイテンシ",
"Average latency, TTFT, and success rate by group": "グループ別の平均レイテンシ、TTFT、成功率",
"Average latency, TTFT, TPS, and success rate": "平均レイテンシ、TTFT、TPS、成功率",
@@ -1487,6 +1488,7 @@
"e.g. my-gitlab": "例: my-gitlab",
"e.g. New API Console": "例: New API コンソール",
"e.g. openid profile email": "例: openid profile email",
+ "e.g. Requires level {{required}}; your current level is {{current}}": "例:レベル {{required}} が必要です。現在のレベルは {{current}} です",
"e.g. Suitable for light usage": "例:ライトユーザー向け",
"e.g. This request does not meet access policy": "例:このリクエストはアクセスポリシーを満たしていません",
"e.g., 0.95": "例: 0.95",
@@ -1736,6 +1738,7 @@
"Error Type (optional)": "エラータイプ(任意)",
"Estimated cost": "推定コスト",
"Estimated quota cost": "想定クォートコスト",
+ "Evaluate fields from the provider user info response. Conditions and nested groups use and/or logic.": "プロバイダーのユーザー情報レスポンスのフィールドを評価します。条件とネストしたグループでは and/or ロジックを使用します。",
"Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "料金表の各グループ名は2つの場所で使えます。ユーザー側(ユーザーグループ、管理者が割り当て)とトークン側(トークングループ、トークン作成時に選択)です。同じ名前プールで、役割は2つです。",
"Every other device will lose access immediately. This device will remain signed in.": "他のすべてのデバイスは直ちにアクセスできなくなります。このデバイスはログイン状態を維持します。",
"Everything configured for this group, in one place.": "このグループのすべての設定を一か所で確認できます。",
@@ -1975,6 +1978,10 @@
"Fill in the following info to create a new subscription plan": "以下の情報を入力して新しいサブスクリプションプランを作成",
"Fill Related Models": "関連モデルを入力",
"Fill Template": "テンプレートを入力",
+ "Fill template: level and active": "テンプレートを入力:レベルと有効状態",
+ "Fill template: level message": "テンプレートを入力:レベルメッセージ",
+ "Fill template: organization message": "テンプレートを入力:組織メッセージ",
+ "Fill template: organization or role": "テンプレートを入力:組織またはロール",
"Fill Templates": "テンプレートを入力",
"Fill the complete model value from the client request body, for example gpt-4o or gemini-2.5-flash. Separate multiple models with commas.": "クライアントリクエスト本文の完全な model 値を入力します。例: gpt-4o または gemini-2.5-flash。複数のモデルはカンマで区切ります。",
"Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format": "OpenAI形式を利用するGemini/VertexチャネルにのみthoughtSignatureを付与します",
@@ -4396,6 +4403,7 @@
"Supported Applications": "サポートされているアプリケーション",
"Supported Imagine Models": "対応Imagineモデル",
"Supported modalities": "サポートされるモダリティ",
+ "Supported operators: eq, ne, gt, gte, lt, lte, in, not_in, contains, not_contains, exists, not_exists. Leave empty to allow all users.": "対応演算子:eq、ne、gt、gte、lt、lte、in、not_in、contains、not_contains、exists、not_exists。すべてのユーザーを許可する場合は空のままにしてください。",
"Supported parameters": "対応パラメータ",
"Supported variables": "サポートされる変数",
"Supports `-thinking`, `-thinking-": "「-thinking」、「-thinking-」をサポートします",
diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json
index 895627bb1d29..4ea860704561 100644
--- a/web/src/i18n/locales/ru.json
+++ b/web/src/i18n/locales/ru.json
@@ -536,6 +536,7 @@
"Available Models": "Доступные модели",
"Available reset credits": "Доступные сбросы лимита",
"Available Rewards": "Доступные награды",
+ "Available variables: {{provider}}, {{field}}, {{op}}, {{required}}, {{current}}, and paths such as {{current.roles}}.": "Доступные переменные: {{provider}}, {{field}}, {{op}}, {{required}}, {{current}}, а также пути вида {{current.roles}}.",
"Average latency": "Средняя задержка",
"Average latency, TTFT, and success rate by group": "Средняя задержка, TTFT и доля успешных запросов по группам",
"Average latency, TTFT, TPS, and success rate": "Средняя задержка, TTFT, TPS и доля успешных запросов",
@@ -1487,6 +1488,7 @@
"e.g. my-gitlab": "например, my-gitlab",
"e.g. New API Console": "напр. консоль New API",
"e.g. openid profile email": "например, openid profile email",
+ "e.g. Requires level {{required}}; your current level is {{current}}": "напр. Требуется уровень {{required}}; ваш текущий уровень — {{current}}",
"e.g. Suitable for light usage": "напр. Подходит для лёгкого использования",
"e.g. This request does not meet access policy": "напр. Этот запрос не соответствует политике доступа",
"e.g., 0.95": "напр., 0.95",
@@ -1736,6 +1738,7 @@
"Error Type (optional)": "Тип ошибки (необязательно)",
"Estimated cost": "Примерная стоимость",
"Estimated quota cost": "Ориентир стоимости квоты",
+ "Evaluate fields from the provider user info response. Conditions and nested groups use and/or logic.": "Проверяет поля ответа с данными пользователя от провайдера. Условия и вложенные группы используют логику and/or.",
"Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "Каждое имя группы из таблицы тарифов используется в двух местах: у пользователя (группа пользователя, назначается администратором) и у токена (группа токена, выбирается при создании). Один набор имён — две разные роли.",
"Every other device will lose access immediately. This device will remain signed in.": "Все остальные устройства немедленно потеряют доступ. Это устройство останется в системе.",
"Everything configured for this group, in one place.": "Все настройки этой группы в одном месте.",
@@ -1975,6 +1978,10 @@
"Fill in the following info to create a new subscription plan": "Заполните следующую информацию для создания нового плана подписки",
"Fill Related Models": "Заполнить связанные модели",
"Fill Template": "Заполнить шаблон",
+ "Fill template: level and active": "Заполнить шаблон: уровень и активность",
+ "Fill template: level message": "Заполнить шаблон: сообщение об уровне",
+ "Fill template: organization message": "Заполнить шаблон: сообщение об организации",
+ "Fill template: organization or role": "Заполнить шаблон: организация или роль",
"Fill Templates": "Заполнить шаблоны",
"Fill the complete model value from the client request body, for example gpt-4o or gemini-2.5-flash. Separate multiple models with commas.": "Укажите полное значение model из тела запроса клиента, например gpt-4o или gemini-2.5-flash. Несколько моделей разделяйте запятыми.",
"Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format": "Заполнять thoughtSignature только для каналов Gemini/Vertex, использующих формат OpenAI",
@@ -4396,6 +4403,7 @@
"Supported Applications": "Поддерживаемые приложения",
"Supported Imagine Models": "Поддерживаемые модели Imagine",
"Supported modalities": "Поддерживаемые модальности",
+ "Supported operators: eq, ne, gt, gte, lt, lte, in, not_in, contains, not_contains, exists, not_exists. Leave empty to allow all users.": "Поддерживаемые операторы: eq, ne, gt, gte, lt, lte, in, not_in, contains, not_contains, exists, not_exists. Оставьте поле пустым, чтобы разрешить доступ всем пользователям.",
"Supported parameters": "Поддерживаемые параметры",
"Supported variables": "Поддерживаемые переменные",
"Supports `-thinking`, `-thinking-": "Поддерживает `-thinking`, `-thinking-",
diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json
index 73b1b6c7c59a..4f42938a7e95 100644
--- a/web/src/i18n/locales/vi.json
+++ b/web/src/i18n/locales/vi.json
@@ -536,6 +536,7 @@
"Available Models": "Mô hình khả dụng",
"Available reset credits": "Lượt đặt lại khả dụng",
"Available Rewards": "Phần thưởng hiện có",
+ "Available variables: {{provider}}, {{field}}, {{op}}, {{required}}, {{current}}, and paths such as {{current.roles}}.": "Các biến khả dụng: {{provider}}, {{field}}, {{op}}, {{required}}, {{current}} và các đường dẫn như {{current.roles}}.",
"Average latency": "Độ trễ trung bình",
"Average latency, TTFT, and success rate by group": "Độ trễ trung bình, TTFT và tỷ lệ thành công theo nhóm",
"Average latency, TTFT, TPS, and success rate": "Độ trễ trung bình, TTFT, TPS và tỷ lệ thành công",
@@ -1487,6 +1488,7 @@
"e.g. my-gitlab": "ví dụ: my-gitlab",
"e.g. New API Console": "Ví dụ: Bảng điều khiển API mới",
"e.g. openid profile email": "ví dụ: openid profile email",
+ "e.g. Requires level {{required}}; your current level is {{current}}": "ví dụ: Yêu cầu cấp độ {{required}}; cấp độ hiện tại của bạn là {{current}}",
"e.g. Suitable for light usage": "ví dụ: Phù hợp cho sử dụng nhẹ",
"e.g. This request does not meet access policy": "ví dụ: Yêu cầu này không đáp ứng chính sách truy cập",
"e.g., 0.95": "e.g., 0.95",
@@ -1736,6 +1738,7 @@
"Error Type (optional)": "Loại lỗi (tùy chọn)",
"Estimated cost": "Chi phí ước tính",
"Estimated quota cost": "Ước tính chi phí hạn mức",
+ "Evaluate fields from the provider user info response. Conditions and nested groups use and/or logic.": "Đánh giá các trường trong phản hồi thông tin người dùng của nhà cung cấp. Điều kiện và nhóm lồng nhau sử dụng logic and/or.",
"Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "Mỗi tên nhóm trong bảng định giá có thể dùng ở hai nơi: trên người dùng (nhóm người dùng, do quản trị viên gán) và trên token (nhóm token, chọn khi tạo token). Cùng một bộ tên, hai vai trò khác nhau.",
"Every other device will lose access immediately. This device will remain signed in.": "Mọi thiết bị khác sẽ mất quyền truy cập ngay lập tức. Thiết bị này vẫn duy trì đăng nhập.",
"Everything configured for this group, in one place.": "Toàn bộ cấu hình của nhóm này, tại một nơi.",
@@ -1975,6 +1978,10 @@
"Fill in the following info to create a new subscription plan": "Điền thông tin sau để tạo gói đăng ký mới",
"Fill Related Models": "Điền Mô hình Liên quan",
"Fill Template": "Điền Mẫu",
+ "Fill template: level and active": "Điền mẫu: cấp độ và trạng thái hoạt động",
+ "Fill template: level message": "Điền mẫu: thông báo cấp độ",
+ "Fill template: organization message": "Điền mẫu: thông báo tổ chức",
+ "Fill template: organization or role": "Điền mẫu: tổ chức hoặc vai trò",
"Fill Templates": "Điền mẫu",
"Fill the complete model value from the client request body, for example gpt-4o or gemini-2.5-flash. Separate multiple models with commas.": "Nhập đầy đủ giá trị model trong body yêu cầu của client, ví dụ gpt-4o hoặc gemini-2.5-flash. Ngăn cách nhiều model bằng dấu phẩy.",
"Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format": "Điền thoughtSignature chỉ dành cho các kênh Gemini/Vertex sử dụng định dạng OpenAI",
@@ -4396,6 +4403,7 @@
"Supported Applications": "Ứng dụng được hỗ trợ",
"Supported Imagine Models": "Mô hình Imagine được hỗ trợ",
"Supported modalities": "Phương thức hỗ trợ",
+ "Supported operators: eq, ne, gt, gte, lt, lte, in, not_in, contains, not_contains, exists, not_exists. Leave empty to allow all users.": "Toán tử được hỗ trợ: eq, ne, gt, gte, lt, lte, in, not_in, contains, not_contains, exists, not_exists. Để trống để cho phép tất cả người dùng.",
"Supported parameters": "Tham số hỗ trợ",
"Supported variables": "Biến được hỗ trợ",
"Supports `-thinking`, `-thinking-": "Hỗ trợ `-thinking`, `-thinking-",
diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json
index a0fb9d1f3a31..ab6acc0de9a3 100644
--- a/web/src/i18n/locales/zh-TW.json
+++ b/web/src/i18n/locales/zh-TW.json
@@ -536,6 +536,7 @@
"Available Models": "可用模型",
"Available reset credits": "可用重置次數",
"Available Rewards": "可用獎勵",
+ "Available variables: {{provider}}, {{field}}, {{op}}, {{required}}, {{current}}, and paths such as {{current.roles}}.": "可用變數:{{provider}}、{{field}}、{{op}}、{{required}}、{{current}},以及 {{current.roles}} 等路徑變數。",
"Average latency": "平均延遲",
"Average latency, TTFT, and success rate by group": "各分組的平均延遲、首 Token 延遲和成功率",
"Average latency, TTFT, TPS, and success rate": "平均延遲、TTFT、TPS 和成功率",
@@ -1487,6 +1488,7 @@
"e.g. my-gitlab": "例如:my-gitlab",
"e.g. New API Console": "例如,New API 控制台",
"e.g. openid profile email": "例如:openid profile email",
+ "e.g. Requires level {{required}}; your current level is {{current}}": "例如:需要等級 {{required}};你目前的等級是 {{current}}",
"e.g. Suitable for light usage": "例如:適合輕度使用",
"e.g. This request does not meet access policy": "例如:該請求不滿足准入策略",
"e.g., 0.95": "例如,0.95",
@@ -1736,6 +1738,7 @@
"Error Type (optional)": "錯誤類型(可選)",
"Estimated cost": "預計成本",
"Estimated quota cost": "估算配額費用",
+ "Evaluate fields from the provider user info response. Conditions and nested groups use and/or logic.": "根據提供商回傳的用戶資訊欄位執行政策判斷。條件和巢狀分組支援 and/or 邏輯。",
"Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "定價表中的每個分組名可用在兩個地方:用戶身上(用戶分組,由管理員分配)和令牌身上(令牌分組,建立令牌時選擇)。同一批名字,兩種不同職責。",
"Every other device will lose access immediately. This device will remain signed in.": "其他所有裝置將立即失去存取權限,目前裝置將保持登入。",
"Everything configured for this group, in one place.": "該分組的全部設定,一處看全。",
@@ -1975,6 +1978,10 @@
"Fill in the following info to create a new subscription plan": "填寫以下資訊建立新的訂閱套餐",
"Fill Related Models": "填入相關模型",
"Fill Template": "填入模板",
+ "Fill template: level and active": "填入模板:等級和啟用狀態",
+ "Fill template: level message": "填入模板:等級提示",
+ "Fill template: organization message": "填入模板:組織提示",
+ "Fill template: organization or role": "填入模板:組織或角色",
"Fill Templates": "填充模板",
"Fill the complete model value from the client request body, for example gpt-4o or gemini-2.5-flash. Separate multiple models with commas.": "填寫客戶端請求體裡的完整 model 值,例如 gpt-4o 或 gemini-2.5-flash。多個模型用英文逗號分隔。",
"Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format": "僅為使用 OpenAI 格式的 Gemini/Vertex 渠道填充 thoughtSignature",
@@ -4396,6 +4403,7 @@
"Supported Applications": "常用套用支援",
"Supported Imagine Models": "支援的 Imagine 模型",
"Supported modalities": "支援的模態",
+ "Supported operators: eq, ne, gt, gte, lt, lte, in, not_in, contains, not_contains, exists, not_exists. Leave empty to allow all users.": "支援的操作符:eq、ne、gt、gte、lt、lte、in、not_in、contains、not_contains、exists、not_exists。留空則允許所有用戶。",
"Supported parameters": "支援的參數",
"Supported variables": "支援變數",
"Supports `-thinking`, `-thinking-": "支援 `-thinking`、`-thinking-`",
diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json
index 9366feb653d3..848b5b9db446 100644
--- a/web/src/i18n/locales/zh.json
+++ b/web/src/i18n/locales/zh.json
@@ -536,6 +536,7 @@
"Available Models": "可用模型",
"Available reset credits": "可用重置次数",
"Available Rewards": "可用奖励",
+ "Available variables: {{provider}}, {{field}}, {{op}}, {{required}}, {{current}}, and paths such as {{current.roles}}.": "可用变量:{{provider}}、{{field}}、{{op}}、{{required}}、{{current}},以及 {{current.roles}} 等路径变量。",
"Average latency": "平均延迟",
"Average latency, TTFT, and success rate by group": "各分组的平均延迟、首 Token 延迟和成功率",
"Average latency, TTFT, TPS, and success rate": "平均延迟、TTFT、TPS 和成功率",
@@ -1487,6 +1488,7 @@
"e.g. my-gitlab": "例如:my-gitlab",
"e.g. New API Console": "例如,New API 控制台",
"e.g. openid profile email": "例如:openid profile email",
+ "e.g. Requires level {{required}}; your current level is {{current}}": "例如:需要等级 {{required}};你当前的等级是 {{current}}",
"e.g. Suitable for light usage": "例如:适合轻度使用",
"e.g. This request does not meet access policy": "例如:该请求不满足准入策略",
"e.g., 0.95": "例如,0.95",
@@ -1736,6 +1738,7 @@
"Error Type (optional)": "错误类型(可选)",
"Estimated cost": "预计成本",
"Estimated quota cost": "估算配额费用",
+ "Evaluate fields from the provider user info response. Conditions and nested groups use and/or logic.": "根据提供商返回的用户信息字段执行策略判断。条件和嵌套分组支持 and/or 逻辑。",
"Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "定价表中的每个分组名可用在两个地方:用户身上(用户分组,由管理员分配)和令牌身上(令牌分组,创建令牌时选择)。同一批名字,两种不同职责。",
"Every other device will lose access immediately. This device will remain signed in.": "其他所有设备将立即失去访问权限,当前设备将保持登录。",
"Everything configured for this group, in one place.": "该分组的全部配置,一处看全。",
@@ -1975,6 +1978,10 @@
"Fill in the following info to create a new subscription plan": "填写以下信息创建新的订阅套餐",
"Fill Related Models": "填入相关模型",
"Fill Template": "填入模板",
+ "Fill template: level and active": "填充模板:等级和激活状态",
+ "Fill template: level message": "填充模板:等级提示",
+ "Fill template: organization message": "填充模板:组织提示",
+ "Fill template: organization or role": "填充模板:组织或角色",
"Fill Templates": "填充模板",
"Fill the complete model value from the client request body, for example gpt-4o or gemini-2.5-flash. Separate multiple models with commas.": "填写客户端请求体里的完整 model 值,例如 gpt-4o 或 gemini-2.5-flash。多个模型用英文逗号分隔。",
"Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format": "仅为使用 OpenAI 格式的 Gemini/Vertex 渠道填充 thoughtSignature",
@@ -4396,6 +4403,7 @@
"Supported Applications": "常用应用支持",
"Supported Imagine Models": "支持的 Imagine 模型",
"Supported modalities": "支持的模态",
+ "Supported operators: eq, ne, gt, gte, lt, lte, in, not_in, contains, not_contains, exists, not_exists. Leave empty to allow all users.": "支持的操作符:eq、ne、gt、gte、lt、lte、in、not_in、contains、not_contains、exists、not_exists。留空则允许所有用户。",
"Supported parameters": "支持的参数",
"Supported variables": "支持变量",
"Supports `-thinking`, `-thinking-": "支持 `-thinking`、`-thinking-`",
diff --git a/web/src/lib/oauth.ts b/web/src/lib/oauth.ts
index 3432d13a788f..d5f039421452 100644
--- a/web/src/lib/oauth.ts
+++ b/web/src/lib/oauth.ts
@@ -20,6 +20,20 @@ For commercial licensing, please contact support@quantumnous.com
// OAuth URL Builders
// ============================================================================
+export interface CustomOAuthBinding {
+ provider_id: number
+ provider_name: string
+ provider_slug: string
+ provider_icon: string
+ provider_user_id: string
+}
+
+export function indexCustomOAuthBindings(
+ bindings: CustomOAuthBinding[]
+): Map {
+ return new Map(bindings.map((binding) => [binding.provider_id, binding]))
+}
+
/**
* Build GitHub OAuth URL
*/
From e2c7aa7b102c2075eae2377df3508658d45e88dc Mon Sep 17 00:00:00 2001
From: QuentinHsu
Date: Sat, 15 Aug 2026 14:18:10 +0800
Subject: [PATCH 45/99] test(web): standardize frontend tests on Vitest (#6569)
* test(web): standardize frontend tests on Vitest
- configure Vitest, jsdom, and React Testing Library with shared test scripts.
- migrate existing node:test suites to the Vitest runner.
- rewrite JsonCodeEditor component tests with RTL and remove the direct happy-dom dependency.
* fix(ci): run frontend tests with Vitest
- invoke the configured Vitest script so browser test setup loads in CI.
- migrate remaining node:test suites to Vitest lifecycle APIs.
* test(web): use shared jsdom environment for component tests
- migrate usage cost and tool price tests to React Testing Library.
- remove duplicate happy-dom globals and rely on the configured Vitest setup.
* test(web): verify behavior with shared vitest setup
- replace Node test assertions with Vitest expect across frontend suites.
- migrate Keys component tests to React Testing Library interactions.
- centralize jsdom browser mocks for consistent component execution.
* fix(web): unblock frozen installs and Vitest CI
- sync dompurify 3.4.13 metadata into the Bun lockfile.
- replace the bun:test and happy-dom redemption harness with Vitest and RTL.
- preserve quota conversion, error feedback, and stale-response coverage in jsdom.
---
.github/workflows/ci.yml | 2 +-
web/bun.lock | 226 +++++++-
web/package.json | 10 +-
.../__tests__/json-code-editor-utils.test.ts | 21 +-
.../__tests__/json-code-editor.test.tsx | 225 +++-----
.../__tests__/layout.test.ts | 11 +-
web/src/components/ui/dropdown-menu.test.tsx | 11 +-
web/src/features/auth/api.test.ts | 20 +-
.../lib/__tests__/oauth-callback-mode.test.ts | 74 ++-
.../features/auth/lib/auth-redirect.test.ts | 37 +-
.../auth/lib/oauth-bind-window.test.ts | 99 ++--
.../features/auth/lib/telegram-login.test.ts | 45 +-
.../__tests__/channel-field-update.test.ts | 21 +-
.../__tests__/channel-table-row-id.test.ts | 15 +-
.../lib/__tests__/new-api-channel.test.ts | 40 +-
.../dashboard/lib/flow-selection.test.ts | 83 ++-
web/src/features/dashboard/lib/flow.test.ts | 525 ++++++++----------
.../__tests__/api-key-group-cell.test.tsx | 191 ++-----
.../__tests__/api-key-group-combobox.test.tsx | 236 +++-----
.../__tests__/api-keys-mutate-drawer.test.tsx | 269 +++------
.../auto-group-order-editor.test.tsx | 447 +++++----------
.../lib/__tests__/auto-group-form.test.ts | 55 +-
.../hooks/use-stream-request.test.ts | 19 +-
.../__tests__/login-session-utils.test.ts | 36 +-
.../redemptions-mutate-drawer.test.tsx | 413 +++++---------
.../group-auto-limit-validation.test.ts | 9 +-
.../__tests__/tool-price-validation.test.tsx | 133 +----
.../__tests__/cost-display.test.tsx | 134 +----
.../lib/__tests__/tool-surcharge.test.ts | 14 +-
.../features/wallet/hooks/use-payment.test.ts | 7 +-
web/src/features/wallet/lib/payment.test.ts | 21 +-
web/src/lib/auth-session.test.ts | 127 ++---
web/src/lib/legacy-route.test.ts | 28 +-
web/src/lib/server-error-message.test.ts | 20 +-
web/src/test-setup.ts | 73 +++
web/tsconfig.node.json | 2 +-
web/vitest.config.ts | 39 ++
37 files changed, 1567 insertions(+), 2171 deletions(-)
create mode 100644 web/src/test-setup.ts
create mode 100644 web/vitest.config.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 98e2661d1925..fc45846daae0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -85,4 +85,4 @@ jobs:
run: bun run typecheck
- name: Test
- run: bun test
+ run: bun run test
diff --git a/web/bun.lock b/web/bun.lock
index 42ab7a235086..8e3d098022a3 100644
--- a/web/bun.lock
+++ b/web/bun.lock
@@ -30,7 +30,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dayjs": "^1.11.21",
- "dompurify": "3.4.11",
+ "dompurify": "3.4.13",
"i18next": "^26.3.4",
"i18next-browser-languagedetector": "^8.2.1",
"input-otp": "^1.4.2",
@@ -71,23 +71,27 @@
"@tanstack/react-query-devtools": "^5.101.2",
"@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/router-plugin": "^1.168.19",
+ "@testing-library/jest-dom": "^7.0.0",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^26.1.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "^7.0.0-dev.20260702.3",
"@xyflow/react": "^12.11.1",
"embla-carousel-react": "^8.6.0",
- "happy-dom": "^20.11.1",
+ "jsdom": "^29.1.1",
"knip": "^6.24.0",
"oxfmt": "^0.57.0",
"oxlint": "^1.72.0",
"shadcn": "^4.12.0",
+ "vitest": "^4.1.10",
},
},
},
"overrides": {
"brace-expansion": "2.1.1",
- "dompurify": "3.4.11",
+ "dompurify": "3.4.13",
"fast-uri": "3.1.2",
"hono": "4.12.22",
"ip-address": "10.2.0",
@@ -99,6 +103,8 @@
"uuid": "14.0.0",
},
"packages": {
+ "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
+
"@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "4.0.3", "@ai-sdk/provider-utils": "5.0.11", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-f85diFdPMXYJpxCjOYZchMQkRH8h3r6lhK4Q2xmzJ7UA2OQ80L3W7tFu61742xGQK7zHWm5AhxYhNuc50H9SGQ=="],
"@ai-sdk/provider": ["@ai-sdk/provider@4.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw=="],
@@ -123,6 +129,14 @@
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
+ "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="],
+
+ "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="],
+
+ "@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="],
+
+ "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
+
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
@@ -187,6 +201,8 @@
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
+ "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
+
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g=="],
@@ -207,6 +223,18 @@
"@codemirror/view": ["@codemirror/view@6.43.6", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA=="],
+ "@csstools/color-helpers": ["@csstools/color-helpers@6.1.0", "", {}, "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg=="],
+
+ "@csstools/css-calc": ["@csstools/css-calc@3.3.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ=="],
+
+ "@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.10", "", { "dependencies": { "@csstools/color-helpers": "^6.1.0", "@csstools/css-calc": "^3.3.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw=="],
+
+ "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
+
+ "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.7", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig=="],
+
+ "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
+
"@date-fns/tz": ["@date-fns/tz@1.5.0", "", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="],
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
@@ -259,6 +287,8 @@
"@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="],
+ "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="],
+
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
@@ -663,6 +693,38 @@
"@resvg/resvg-js-win32-x64-msvc": ["@resvg/resvg-js-win32-x64-msvc@2.4.1", "", { "os": "win32", "cpu": "x64" }, "sha512-vY4kTLH2S3bP+puU5x7hlAxHv+ulFgcK6Zn3efKSr0M0KnZ9A3qeAjZteIpkowEFfUeMPNg2dvvoFRJA9zqxSw=="],
+ "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="],
+
+ "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="],
+
+ "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="],
+
+ "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="],
+
+ "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="],
+
+ "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="],
+
+ "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="],
+
+ "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="],
+
+ "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="],
+
+ "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="],
+
+ "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="],
+
+ "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="],
+
+ "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="],
+
+ "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="],
+
+ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="],
+
+ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
+
"@rsbuild/core": ["@rsbuild/core@2.1.6", "", { "dependencies": { "@rspack/core": "~2.1.4", "@swc/helpers": "^0.5.23" }, "peerDependencies": { "core-js": ">= 3.0.0" }, "optionalPeers": ["core-js"], "bin": { "rsbuild": "./bin/rsbuild.js" } }, "sha512-w2WxblstOgHnDElkqJZVO/jM/EqPaEhg7zqQhON3Xu3Mj9FlVOJx+SgimOtghFzxLt8x7atX4oMUtMTNSZGf0Q=="],
"@rsbuild/plugin-react": ["@rsbuild/plugin-react@2.1.0", "", { "dependencies": { "@rspack/plugin-react-refresh": "^2.0.2", "react-refresh": "^0.18.0" }, "peerDependencies": { "@rsbuild/core": "^2.0.0" }, "optionalPeers": ["@rsbuild/core"] }, "sha512-RQTIAWB/CwPjoWt9iAl+8HixeQVgZ7kEIBrWPCixfITyHdiD84h0YpUTpEUuz6kGHw1KXT9mHZ3Rwy6WG7aRDA=="],
@@ -801,6 +863,14 @@
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="],
+ "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
+
+ "@testing-library/jest-dom": ["@testing-library/jest-dom@7.0.0", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" }, "peerDependencies": { "@testing-library/dom": ">=10 <11" } }, "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg=="],
+
+ "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
+
+ "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="],
+
"@tokenlens/core": ["@tokenlens/core@1.3.0", "", {}, "sha512-d8YNHNC+q10bVpi95fELJwJyPVf1HfvBEI18eFQxRSZTdByXrP+f/ZtlhSzkx0Jl0aEmYVeBA5tPeeYRioLViQ=="],
"@tokenlens/fetch": ["@tokenlens/fetch@1.3.0", "", { "dependencies": { "@tokenlens/core": "1.3.0" } }, "sha512-RONDRmETYly9xO8XMKblmrZjKSwCva4s5ebJwQNfNlChZoA5kplPoCgnWceHnn1J1iRjLVlrCNB43ichfmGBKQ=="],
@@ -827,6 +897,10 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
+ "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
+
+ "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
+
"@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
@@ -891,6 +965,8 @@
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
+ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
+
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
@@ -985,6 +1061,20 @@
"@visactor/vutils-extension": ["@visactor/vutils-extension@2.1.4", "", { "dependencies": { "@visactor/vdataset": "~1.0.23", "@visactor/vutils": "~1.0.23" } }, "sha512-gyTxhrTN0Ybzis4FsHniTNQSKo9GWLT2f+0du3E6wEDQ/PijNRS6onkY3HyBXZbsnJlvk/sMZUqrtVJi3cTvzg=="],
+ "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="],
+
+ "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="],
+
+ "@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="],
+
+ "@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="],
+
+ "@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="],
+
+ "@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
+
+ "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="],
+
"@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="],
"@xyflow/react": ["@xyflow/react@12.11.2", "", { "dependencies": { "@xyflow/system": "0.0.79", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "@types/react": ">=17", "@types/react-dom": ">=17", "react": ">=17", "react-dom": ">=17" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA=="],
@@ -1011,7 +1101,9 @@
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
- "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
+ "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+
+ "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="],
@@ -1023,8 +1115,12 @@
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
+ "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
+
"array-source": ["array-source@0.0.4", "", {}, "sha512-frNdc+zBn80vipY+GdcJkLEbMWj3xmzArYApmUGxoiV8uAu/ygcs9icPdsGdA26h0MkHUMW6EN2piIvVx+M5Mw=="],
+ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
+
"assign-symbols": ["assign-symbols@1.0.0", "", {}, "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw=="],
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
@@ -1051,6 +1147,8 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.43", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ=="],
+ "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
+
"body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
"brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
@@ -1077,6 +1175,8 @@
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
+ "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
+
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
@@ -1151,6 +1251,10 @@
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
+ "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
+
+ "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
+
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
@@ -1229,6 +1333,8 @@
"dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="],
+ "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
+
"date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="],
"dayjs": ["dayjs@1.11.21", "", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="],
@@ -1237,6 +1343,8 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+ "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
+
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
@@ -1269,7 +1377,9 @@
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
- "dompurify": ["dompurify@3.4.11", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="],
+ "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
+
+ "dompurify": ["dompurify@3.4.13", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ=="],
"dot-prop": ["dot-prop@6.0.1", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="],
@@ -1297,7 +1407,7 @@
"enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
- "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
+ "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
@@ -1307,6 +1417,8 @@
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
+ "es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
+
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
@@ -1349,6 +1461,8 @@
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
+ "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
+
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@8.6.0", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA=="],
@@ -1401,6 +1515,8 @@
"fs-extra": ["fs-extra@11.3.6", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA=="],
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="],
@@ -1487,6 +1603,8 @@
"hono": ["hono@4.12.22", "", {}, "sha512-7fvVPbB92zNRsQke+uiRGwtTuef0tB2Dg4hWxYfFNvkQhIltWoyi0ONReM5LWA+jJWS3nfT5lTq+qbsIpX0IQw=="],
+ "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
+
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
@@ -1515,6 +1633,8 @@
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
+ "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
+
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
@@ -1565,6 +1685,8 @@
"is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="],
+ "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
+
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="],
@@ -1595,6 +1717,8 @@
"js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
+ "jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="],
+
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
@@ -1671,12 +1795,14 @@
"lottie-web": ["lottie-web@5.13.0", "", {}, "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ=="],
- "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
+ "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
"lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="],
"lucide-react": ["lucide-react@1.25.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw=="],
+ "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
+
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="],
@@ -1741,6 +1867,8 @@
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
+ "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
+
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
@@ -1841,6 +1969,8 @@
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
+ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
+
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -1873,6 +2003,8 @@
"object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="],
+ "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="],
+
"on-change": ["on-change@4.0.0", "", {}, "sha512-PTu7C9Jsz4b+sNMDpH0eZFTr7uxdOtoDWRnhaVNK50bgrrnW5nvbWI0jm5DG9qOoTnIhBzE9xoKVFPD9xgtbdg=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
@@ -1915,7 +2047,7 @@
"parse-svg-path": ["parse-svg-path@0.1.2", "", {}, "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ=="],
- "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
+ "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
@@ -1963,6 +2095,8 @@
"prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="],
+ "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
+
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
@@ -1977,6 +2111,8 @@
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
+ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
+
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
"qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="],
@@ -2081,6 +2217,8 @@
"recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="],
+ "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
+
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
@@ -2139,6 +2277,8 @@
"robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
+ "rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="],
+
"roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
@@ -2153,6 +2293,8 @@
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
+ "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
+
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"screenfull": ["screenfull@5.2.0", "", {}, "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA=="],
@@ -2193,6 +2335,8 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
+ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
+
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"simple-statistics": ["simple-statistics@7.9.3", "", {}, "sha512-WXpxUfo7BJCRpyl4besiuMV7wNj9xiPIq7IKmUQO4upIaF8pK2AXwhjttHN5L8KXZrLkGMCHGHq4p+pJXiIahQ=="],
@@ -2221,8 +2365,12 @@
"sse.js": ["sse.js@2.8.0", "", {}, "sha512-35RyyFYpzzHZgMw9D5GxwADbL6gnntSwW/rKXcuIy1KkYCPjW6oia0moNdNRhs34oVHU1Sjgovj3l7uIEZjrKA=="],
+ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
+
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
+ "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
+
"stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
"stream-markdown-parser": ["stream-markdown-parser@1.1.3", "", { "dependencies": { "markdown-it-container": "^4.0.0", "markdown-it-footnote": "^4.0.0", "markdown-it-ins": "^4.0.0", "markdown-it-mark": "^4.0.0", "markdown-it-sub": "^2.0.0", "markdown-it-sup": "^2.0.0", "markdown-it-task-checkbox": "^1.0.6", "markdown-it-ts": "^1.0.4" } }, "sha512-tge6aKbOGU36vBLeeroHGBAJ7qiNcsTYyvmgqevVN0+5kMiLOjAyeDmv3PQkniTMbgOsswVHN9izmgmSw5bsBQ=="],
@@ -2245,6 +2393,8 @@
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
+ "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
+
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
"style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
@@ -2259,6 +2409,8 @@
"swr": ["swr@2.4.2", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ej644Y2bvkIajfR32KGeSSdBXQW+ScjGjkybZgSE7kFpk9eGnV44XY9FJylXi+W75pavSX1PVNB57W5EbhGIYw=="],
+ "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
+
"systeminformation": ["systeminformation@5.33.0", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-0LYSL01CCbjVeJG7iXI8fUCFU76zMjzbHd/EU3or4QpSFYCLMgslR11prwHuA3siz5jmOkqoLhjgOyDRmXBKmA=="],
"tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="],
@@ -2275,12 +2427,20 @@
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
+ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
+
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
+ "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
+
+ "tldts": ["tldts@7.4.9", "", { "dependencies": { "tldts-core": "^7.4.9" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA=="],
+
+ "tldts-core": ["tldts-core@7.4.9", "", {}, "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg=="],
+
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"to-vfile": ["to-vfile@8.0.0", "", { "dependencies": { "vfile": "^6.0.0" } }, "sha512-IcmH1xB5576MJc9qcfEC/m/nQCFt3fzMHz45sSlgJyTWjRbKW1HAkJpuf3DgE57YzIlZcwcBZA5ENQbBo4aLkg=="],
@@ -2293,6 +2453,10 @@
"topojson-server": ["topojson-server@3.0.1", "", { "dependencies": { "commander": "2" }, "bin": { "geo2topo": "bin/geo2topo" } }, "sha512-/VS9j/ffKr2XAOjlZ9CgyyeLmgJ9dMwq6Y0YEON8O7p/tGGk+dCWnrE03zEdu7i4L7YsFZLEPZPzCvcB7lEEXw=="],
+ "tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="],
+
+ "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
+
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
@@ -2383,26 +2547,42 @@
"virtua": ["virtua@0.49.3", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-k1Yn988Vz/L40uDtEWPjfdVo15Suumh4tU4/z5Srs0elNcU9DgBskqdh3llpHyAlXrCeHWTfcclYvY1uU4MmIg=="],
+ "vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="],
+
+ "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="],
+
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
+ "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
+
"walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
+ "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
+
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
- "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
+ "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
+
+ "whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
+ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
+
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="],
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
+ "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
+
+ "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
+
"yace": ["yace@1.1.0", "", {}, "sha512-jB29trAxPBvTIR/lXsgh97q22n/Rq5ZHbDT4DT6WUvXVlJLR7jdwiBKD+0zi3XZAD5N+YKa3GrdIrelqIXj2oQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
@@ -2421,6 +2601,8 @@
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
+ "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
+
"@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
@@ -2483,6 +2665,10 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+ "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
+
+ "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
+
"@visactor/vdataset/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
"@visactor/vlayouts/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
@@ -2535,6 +2721,14 @@
"geojson-dissolve/@turf/meta": ["@turf/meta@3.14.0", "", {}, "sha512-OtXqLQuR9hlQ/HkAF/OdzRea7E0eZK1ay8y8CBXkoO2R6v34CsDrWYLMSo0ZzMsaQDpKo76NPP2GGo+PyG1cSg=="],
+ "happy-dom/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
+
+ "happy-dom/whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
+
+ "hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
+
+ "hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
+
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
@@ -2563,10 +2757,10 @@
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
- "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
-
"postcss/nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
+ "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
+
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
@@ -2579,6 +2773,8 @@
"restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
+ "rolldown/@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="],
+
"send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"set-value/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
@@ -2591,6 +2787,8 @@
"split-string/extend-shallow": ["extend-shallow@3.0.2", "", { "dependencies": { "assign-symbols": "^1.0.0", "is-extendable": "^1.0.1" } }, "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q=="],
+ "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
+
"topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"topojson-server/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
@@ -2633,10 +2831,12 @@
"d3/d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
- "enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
-
"express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
+ "hast-util-from-html/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
+
+ "hast-util-raw/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
+
"mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"micromark-extension-math/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
diff --git a/web/package.json b/web/package.json
index d96a6c4bf2ea..2893f36b0d0f 100644
--- a/web/package.json
+++ b/web/package.json
@@ -11,6 +11,8 @@
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint -c .oxlintrc.json . --fix",
"preview": "rsbuild preview",
+ "test": "vitest run",
+ "test:watch": "vitest",
"format:check": "node scripts/format-with-protected-headers.mjs --check",
"format": "node scripts/format-with-protected-headers.mjs --write",
"copyright:check": "node scripts/add-copyright.mjs --check",
@@ -85,17 +87,21 @@
"@tanstack/react-query-devtools": "^5.101.2",
"@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/router-plugin": "^1.168.19",
+ "@testing-library/jest-dom": "^7.0.0",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^26.1.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "^7.0.0-dev.20260702.3",
"@xyflow/react": "^12.11.1",
"embla-carousel-react": "^8.6.0",
- "happy-dom": "^20.11.1",
+ "jsdom": "^29.1.1",
"knip": "^6.24.0",
"oxfmt": "^0.57.0",
"oxlint": "^1.72.0",
- "shadcn": "^4.12.0"
+ "shadcn": "^4.12.0",
+ "vitest": "^4.1.10"
},
"overrides": {
"brace-expansion": "2.1.1",
diff --git a/web/src/components/json-code-editor/__tests__/json-code-editor-utils.test.ts b/web/src/components/json-code-editor/__tests__/json-code-editor-utils.test.ts
index bbac95fd4f70..00651f908360 100644
--- a/web/src/components/json-code-editor/__tests__/json-code-editor-utils.test.ts
+++ b/web/src/components/json-code-editor/__tests__/json-code-editor-utils.test.ts
@@ -16,8 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { describe, test } from 'node:test'
+import { describe, expect, test } from 'vitest'
import {
applyJsonSmartEnter,
@@ -29,42 +28,42 @@ import {
describe('json code editor utils', () => {
test('treats empty drafts as valid editable JSON drafts', () => {
- assert.deepEqual(getJsonValidationState(' \n'), {
+ expect(getJsonValidationState(' \n')).toEqual({
isValid: true,
messageKey: 'JSON',
})
})
test('reports invalid JSON without throwing away the draft', () => {
- assert.deepEqual(getJsonValidationState('{"model": }'), {
+ expect(getJsonValidationState('{"model": }')).toEqual({
isValid: false,
messageKey: 'Invalid JSON',
})
})
test('formats valid JSON with stable two-space indentation', () => {
- assert.deepEqual(formatJsonDraft('{"model":{"ratio":2}}'), {
+ expect(formatJsonDraft('{"model":{"ratio":2}}')).toEqual({
didFormat: true,
value: '{\n "model": {\n "ratio": 2\n }\n}',
})
})
test('keeps invalid JSON drafts unchanged when formatting is requested', () => {
- assert.deepEqual(formatJsonDraft('{"model": }'), {
+ expect(formatJsonDraft('{"model": }')).toEqual({
didFormat: false,
value: '{"model": }',
})
})
test('derives the one-based cursor line and column from text offsets', () => {
- assert.deepEqual(getCursorLocation('{\n "model": 1\n}', 5), {
+ expect(getCursorLocation('{\n "model": 1\n}', 5)).toEqual({
line: 2,
column: 4,
})
})
test('expands paired JSON brackets with a nested indentation line', () => {
- assert.deepEqual(applyJsonSmartEnter('{}', 1, 1), {
+ expect(applyJsonSmartEnter('{}', 1, 1)).toEqual({
value: '{\n \n}',
selectionStart: 4,
selectionEnd: 4,
@@ -90,11 +89,11 @@ describe('json code editor utils', () => {
source.scrollTop = 80
synchronizer.sync()
- assert.equal(queuedFrames.length, 1)
+ expect(queuedFrames.length).toBe(1)
queuedFrames[0]()
- assert.equal(contentLayer.style.transform, 'translate3d(-24px, -80px, 0)')
- assert.equal(lineNumberLayer.style.transform, 'translate3d(0, -80px, 0)')
+ expect(contentLayer.style.transform).toBe('translate3d(-24px, -80px, 0)')
+ expect(lineNumberLayer.style.transform).toBe('translate3d(0, -80px, 0)')
})
})
diff --git a/web/src/components/json-code-editor/__tests__/json-code-editor.test.tsx b/web/src/components/json-code-editor/__tests__/json-code-editor.test.tsx
index 24959b2cdd5a..9d4b5379adaf 100644
--- a/web/src/components/json-code-editor/__tests__/json-code-editor.test.tsx
+++ b/web/src/components/json-code-editor/__tests__/json-code-editor.test.tsx
@@ -16,165 +16,98 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { after, describe, test } from 'node:test'
-
-import { Window } from 'happy-dom'
-
-const domWindow = new Window()
-const domGlobals = [
- 'window',
- 'document',
- 'navigator',
- 'HTMLElement',
- 'HTMLTextAreaElement',
- 'Node',
- 'Element',
- 'Event',
- 'CustomEvent',
- 'MutationObserver',
- '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 i18next = (await import('i18next')).default
-const { initReactI18next } = await import('react-i18next')
-await i18next.use(initReactI18next).init({
- lng: 'en',
- resources: {
- en: {
- translation: {
- JSON: 'JSON',
- 'Invalid JSON': 'Invalid JSON',
- 'Copied to clipboard': 'Copied to clipboard',
- 'Failed to copy': 'Failed to copy',
- 'Format JSON': 'Format JSON',
- },
- },
- },
-})
-const { JsonCodeEditor } = await import('../../json-code-editor')
-const reactTestGlobals = globalThis as typeof globalThis & {
- IS_REACT_ACT_ENVIRONMENT?: boolean
-}
-reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true
-
-type RenderedEditor = {
- container: HTMLDivElement
- root: ReturnType
-}
-
-async function renderEditor(
- props: React.ComponentProps
-): Promise {
- const container = document.createElement('div')
- document.body.append(container)
- const root = createRoot(container)
-
- await act(async () => {
- root.render( )
- })
-
- return { container, root }
-}
+import { fireEvent, render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, test, vi } from 'vitest'
-async function unmountEditor(rendered: RenderedEditor) {
- await act(async () => rendered.root.unmount())
- rendered.container.remove()
-}
+import { JsonCodeEditor } from '../../json-code-editor'
describe('JsonCodeEditor component', () => {
- after(() => {
- domWindow.close()
- })
-
- test('forwards form attributes and lifecycle callbacks to the textarea', async () => {
- const blurCalls: number[] = []
- const refValues: Array = []
- const rendered = await renderEditor({
- value: '{"model":"gpt"}',
- onChange: () => undefined,
- id: 'json-input',
- name: 'model_config',
- placeholder: '{"model":"gpt"}',
- disabled: true,
- 'aria-describedby': 'model-help',
- 'aria-invalid': true,
- 'data-form-root': 'settings-form',
- onBlur: () => blurCalls.push(1),
- textareaRef: (element) => refValues.push(element),
+ test('forwards form attributes and the textarea ref', () => {
+ const textareaRef = vi.fn()
+ const rendered = render(
+ undefined}
+ id='json-input'
+ name='model_config'
+ placeholder='{"model":"gpt"}'
+ disabled
+ ariaLabel='Model configuration'
+ aria-describedby='model-help'
+ aria-invalid
+ data-form-root='settings-form'
+ textareaRef={textareaRef}
+ />
+ )
+ const textarea = screen.getByRole('textbox', {
+ name: 'Model configuration',
})
- const textarea = rendered.container.querySelector('textarea')
-
- assert.ok(textarea)
- assert.equal(textarea.id, 'json-input')
- assert.equal(textarea.name, 'model_config')
- assert.equal(textarea.placeholder, '{"model":"gpt"}')
- assert.equal(textarea.disabled, true)
- assert.equal(textarea.getAttribute('aria-describedby'), 'model-help')
- assert.equal(textarea.getAttribute('aria-invalid'), 'true')
- assert.equal(textarea.getAttribute('data-form-root'), 'settings-form')
-
- await act(async () => textarea.dispatchEvent(new Event('blur')))
- assert.deepEqual(blurCalls, [1])
- assert.equal(refValues[0], textarea)
-
- await unmountEditor(rendered)
- assert.equal(refValues.at(-1), null)
+
+ expect(textarea).toHaveAttribute('id', 'json-input')
+ expect(textarea).toHaveAttribute('name', 'model_config')
+ expect(textarea).toHaveAttribute('placeholder', '{"model":"gpt"}')
+ expect(textarea).toBeDisabled()
+ expect(textarea).toHaveAttribute('aria-describedby', 'model-help')
+ expect(textarea).toHaveAttribute('aria-invalid', 'true')
+ expect(textarea).toHaveAttribute('data-form-root', 'settings-form')
+ expect(textareaRef).toHaveBeenCalledWith(textarea)
+
+ rendered.unmount()
+ expect(textareaRef).toHaveBeenLastCalledWith(null)
})
- test('emits user edits and synchronizes a controlled value', async () => {
- const changes: string[] = []
- const rendered = await renderEditor({
- value: '{"count":1}',
- onChange: (value) => changes.push(value),
- })
- const textarea = rendered.container.querySelector('textarea')
+ test('calls onBlur when focus leaves the editor', () => {
+ const onBlur = vi.fn()
+ render(
+ undefined}
+ onBlur={onBlur}
+ ariaLabel='Model configuration'
+ />
+ )
- assert.ok(textarea)
- await act(async () => {
- textarea.value = '{"count":2}'
- textarea.dispatchEvent(new Event('input', { bubbles: true }))
- })
- assert.deepEqual(changes, ['{"count":2}'])
-
- await act(async () => {
- rendered.root.render(
- changes.push(value)}
- />
- )
+ fireEvent.blur(screen.getByRole('textbox', { name: 'Model configuration' }))
+
+ expect(onBlur).toHaveBeenCalledOnce()
+ })
+
+ test('emits user edits and synchronizes a controlled value', () => {
+ const onChange = vi.fn()
+ const rendered = render(
+
+ )
+ const textarea = screen.getByRole('textbox', {
+ name: 'Model configuration',
})
- assert.equal(textarea.value, '{"count":3}')
- await unmountEditor(rendered)
+ fireEvent.input(textarea, { target: { value: '{"count":2}' } })
+ expect(onChange).toHaveBeenCalledWith('{"count":2}')
+
+ rendered.rerender(
+
+ )
+ expect(textarea).toHaveValue('{"count":3}')
})
test('formats valid JSON through the public toolbar action', async () => {
- const changes: string[] = []
- const rendered = await renderEditor({
- value: '{"model":{"ratio":2}}',
- onChange: (value) => changes.push(value),
- })
- const formatButton = [
- ...rendered.container.querySelectorAll('button'),
- ].find((button) => button.textContent?.includes('Format JSON'))
+ const user = userEvent.setup()
+ const onChange = vi.fn()
+ render( )
- assert.ok(formatButton)
- await act(async () => formatButton.click())
- assert.deepEqual(changes, ['{\n "model": {\n "ratio": 2\n }\n}'])
+ await user.click(screen.getByRole('button', { name: 'Format JSON' }))
- await unmountEditor(rendered)
+ expect(onChange).toHaveBeenCalledWith(
+ '{\n "model": {\n "ratio": 2\n }\n}'
+ )
})
})
diff --git a/web/src/components/model-group-selector/__tests__/layout.test.ts b/web/src/components/model-group-selector/__tests__/layout.test.ts
index ef5b7e6fe3f4..db60dbf6c0f6 100644
--- a/web/src/components/model-group-selector/__tests__/layout.test.ts
+++ b/web/src/components/model-group-selector/__tests__/layout.test.ts
@@ -16,8 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { describe, test } from 'node:test'
+import { describe, expect, test } from 'vitest'
import {
modelGroupSelectorLayoutClasses,
@@ -29,8 +28,8 @@ describe('model group selector layout', () => {
const groupScrollClasses =
modelGroupSelectorLayoutClasses.groupScroll.split(' ')
- assert.ok(groupScrollClasses.includes('auto-rows-[2rem]'))
- assert.ok(groupScrollClasses.includes('content-start'))
+ expect(groupScrollClasses.includes('auto-rows-[2rem]')).toBeTruthy()
+ expect(groupScrollClasses.includes('content-start')).toBeTruthy()
})
test('centers the selected group inside its own scroll container', () => {
@@ -50,7 +49,7 @@ describe('model group selector layout', () => {
scrollSelectedOptionIntoView(selectedOption, scrollContainer)
- assert.deepEqual(scrollCalls, [{ top: 76, behavior: 'auto' }])
+ expect(scrollCalls).toEqual([{ top: 76, behavior: 'auto' }])
})
test('falls back to scrollIntoView when no group container is provided', () => {
@@ -63,6 +62,6 @@ describe('model group selector layout', () => {
scrollSelectedOptionIntoView(selectedOption)
- assert.deepEqual(scrollCalls, [{ block: 'center', inline: 'nearest' }])
+ expect(scrollCalls).toEqual([{ block: 'center', inline: 'nearest' }])
})
})
diff --git a/web/src/components/ui/dropdown-menu.test.tsx b/web/src/components/ui/dropdown-menu.test.tsx
index 71d6afe97508..8a3e2d9d427f 100644
--- a/web/src/components/ui/dropdown-menu.test.tsx
+++ b/web/src/components/ui/dropdown-menu.test.tsx
@@ -16,8 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { describe, test } from 'node:test'
+import { describe, expect, test } from 'vitest'
import { handleDropdownMenuItemSelect } from './dropdown-menu-events'
@@ -52,8 +51,8 @@ describe('DropdownMenuItem onSelect compatibility', () => {
selected = true
})
- assert.equal(selected, true)
- assert.equal(event.baseUIHandlerPrevented, false)
+ expect(selected).toBe(true)
+ expect(event.baseUIHandlerPrevented).toBe(false)
})
test('keeps the Base UI menu open when onSelect prevents default', () => {
@@ -63,7 +62,7 @@ describe('DropdownMenuItem onSelect compatibility', () => {
selectEvent.preventDefault()
})
- assert.equal(event.defaultPrevented, true)
- assert.equal(event.baseUIHandlerPrevented, true)
+ expect(event.defaultPrevented).toBe(true)
+ expect(event.baseUIHandlerPrevented).toBe(true)
})
})
diff --git a/web/src/features/auth/api.test.ts b/web/src/features/auth/api.test.ts
index 455b68866c34..c3fcb8943ea1 100644
--- a/web/src/features/auth/api.test.ts
+++ b/web/src/features/auth/api.test.ts
@@ -16,8 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { describe, test } from 'node:test'
+import { describe, expect, test } from 'vitest'
import type { RefreshOutcome } from '@/lib/api'
import type { AuthBundle } from '@/stores/auth-store'
@@ -63,8 +62,8 @@ describe('logout coordination', () => {
},
})
- assert.deepEqual(result, { success: false, message: 'not revoked' })
- assert.equal(refreshCount, 0)
+ expect(result).toEqual({ success: false, message: 'not revoked' })
+ expect(refreshCount).toBe(0)
})
test('recovers a cookie mismatch and retries with the refreshed SID', async () => {
@@ -83,8 +82,8 @@ describe('logout coordination', () => {
},
})
- assert.deepEqual(result, { success: true, message: '' })
- assert.deepEqual(requestedSIDs, ['session-a', 'session-b'])
+ expect(result).toEqual({ success: true, message: '' })
+ expect(requestedSIDs).toEqual(['session-a', 'session-b'])
})
test('treats a mismatch that refresh confirms anonymous as signed out', async () => {
@@ -96,7 +95,7 @@ describe('logout coordination', () => {
refresh: async () => ({ kind: 'anonymous' }),
})
- assert.deepEqual(result, { success: true, message: '' })
+ expect(result).toEqual({ success: true, message: '' })
})
test('preserves the active session when mismatch recovery is temporary', async () => {
@@ -106,15 +105,14 @@ describe('logout coordination', () => {
error: new Error('offline'),
}
- await assert.rejects(
+ await expect(
executeLogout({
getExpectedSID: () => 'session-a',
request: async () => {
throw originalError
},
refresh: async () => transient,
- }),
- (error) => error === originalError
- )
+ })
+ ).rejects.toBe(originalError)
})
})
diff --git a/web/src/features/auth/lib/__tests__/oauth-callback-mode.test.ts b/web/src/features/auth/lib/__tests__/oauth-callback-mode.test.ts
index 180e7bf034c4..75b87c79e136 100644
--- a/web/src/features/auth/lib/__tests__/oauth-callback-mode.test.ts
+++ b/web/src/features/auth/lib/__tests__/oauth-callback-mode.test.ts
@@ -16,8 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { describe, test } from 'node:test'
+import { describe, expect, test } from 'vitest'
import {
getOAuthSessionStorage,
@@ -40,15 +39,14 @@ const bindState = 'bind-state'
describe('resolveOAuthCallbackMode', () => {
test('matching provider and state mark is treated as a bind flow', () => {
const storage = fakeStorage()
- assert.equal(markOAuthBindPopup(storage, 'oidc', bindState), true)
+ expect(markOAuthBindPopup(storage, 'oidc', bindState)).toBe(true)
- assert.equal(
+ expect(
resolveOAuthCallbackMode('oidc', bindState, {
opener: openOpener,
storage,
- }),
- 'bind'
- )
+ })
+ ).toBe('bind')
})
// Regression: a tab opened from an external link (Slack, e-mail, another
@@ -58,75 +56,69 @@ describe('resolveOAuthCallbackMode', () => {
test('login redirect in a tab with a foreign opener stays a login flow', () => {
const storage = fakeStorage()
- assert.equal(
+ expect(
resolveOAuthCallbackMode('oidc', bindState, {
opener: openOpener,
storage,
- }),
- 'login'
- )
+ })
+ ).toBe('login')
})
test('bind marker for another provider does not hijack this callback', () => {
const storage = fakeStorage()
markOAuthBindPopup(storage, 'github', bindState)
- assert.equal(
+ expect(
resolveOAuthCallbackMode('oidc', bindState, {
opener: openOpener,
storage,
- }),
- 'login'
- )
+ })
+ ).toBe('login')
})
test('stale bind marker does not hijack a later callback', () => {
const storage = fakeStorage()
markOAuthBindPopup(storage, 'oidc', 'previous-state')
- assert.equal(
+ expect(
resolveOAuthCallbackMode('oidc', bindState, {
opener: openOpener,
storage,
- }),
- 'login'
- )
+ })
+ ).toBe('login')
})
test('bind marker without an opener falls back to login', () => {
const storage = fakeStorage()
markOAuthBindPopup(storage, 'oidc', bindState)
- assert.equal(
+ expect(
resolveOAuthCallbackMode('oidc', bindState, {
opener: null,
storage,
- }),
- 'login'
- )
+ })
+ ).toBe('login')
})
test('closed opener falls back to login', () => {
const storage = fakeStorage()
markOAuthBindPopup(storage, 'oidc', bindState)
- assert.equal(
+ expect(
resolveOAuthCallbackMode('oidc', bindState, {
opener: { closed: true },
storage,
- }),
- 'login'
- )
+ })
+ ).toBe('login')
})
test('missing storage degrades to login instead of throwing', () => {
- assert.equal(
+ expect(
resolveOAuthCallbackMode('oidc', bindState, {
opener: openOpener,
storage: null,
- }),
- 'login'
- )
+ })
+ ).toBe('login')
})
test('storage read failure degrades to login instead of throwing', () => {
@@ -137,13 +129,12 @@ describe('resolveOAuthCallbackMode', () => {
setItem: () => undefined,
}
- assert.equal(
+ expect(
resolveOAuthCallbackMode('oidc', bindState, {
opener: openOpener,
storage,
- }),
- 'login'
- )
+ })
+ ).toBe('login')
})
})
@@ -155,7 +146,7 @@ describe('OAuth bind popup storage', () => {
},
}
- assert.equal(getOAuthSessionStorage(owner), null)
+ expect(getOAuthSessionStorage(owner)).toBe(null)
})
test('marking reports unavailable or unwritable storage', () => {
@@ -166,9 +157,9 @@ describe('OAuth bind popup storage', () => {
},
}
- assert.equal(markOAuthBindPopup(null, 'oidc', bindState), false)
- assert.equal(markOAuthBindPopup(storage, 'oidc', bindState), false)
- assert.equal(
+ expect(markOAuthBindPopup(null, 'oidc', bindState)).toBe(false)
+ expect(markOAuthBindPopup(storage, 'oidc', bindState)).toBe(false)
+ expect(
markOAuthBindPopup(
{
getItem: () => null,
@@ -176,8 +167,7 @@ describe('OAuth bind popup storage', () => {
},
'oidc',
bindState
- ),
- false
- )
+ )
+ ).toBe(false)
})
})
diff --git a/web/src/features/auth/lib/auth-redirect.test.ts b/web/src/features/auth/lib/auth-redirect.test.ts
index f043c26bc0a0..550223db7f56 100644
--- a/web/src/features/auth/lib/auth-redirect.test.ts
+++ b/web/src/features/auth/lib/auth-redirect.test.ts
@@ -16,8 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { describe, test } from 'node:test'
+import { describe, expect, test } from 'vitest'
import type { AuthUser } from '@/stores/auth-store'
@@ -27,17 +26,15 @@ const origin = 'https://dashboard.example.com'
describe('authentication redirect validation', () => {
test('preserves safe internal paths, search parameters, and fragments', () => {
- assert.equal(
- sanitizeAuthRedirect('/console?tab=usage#recent', origin),
+ expect(sanitizeAuthRedirect('/console?tab=usage#recent', origin)).toBe(
'/console?tab=usage#recent'
)
- assert.equal(
+ expect(
sanitizeAuthRedirect(
'https://dashboard.example.com/dashboard?tab=quota#daily',
origin
- ),
- '/dashboard?tab=quota#daily'
- )
+ )
+ ).toBe('/dashboard?tab=quota#daily')
})
test('rejects external and ambiguously parsed redirect targets', () => {
@@ -53,13 +50,13 @@ describe('authentication redirect validation', () => {
]
for (const target of unsafeTargets) {
- assert.equal(sanitizeAuthRedirect(target, origin), null)
+ expect(sanitizeAuthRedirect(target, origin)).toBe(null)
}
})
test('rejects invalid or non-HTTP application origins', () => {
- assert.equal(sanitizeAuthRedirect('/dashboard', 'not-an-origin'), null)
- assert.equal(sanitizeAuthRedirect('/dashboard', 'file:///tmp/app'), null)
+ expect(sanitizeAuthRedirect('/dashboard', 'not-an-origin')).toBe(null)
+ expect(sanitizeAuthRedirect('/dashboard', 'file:///tmp/app')).toBe(null)
})
})
@@ -67,31 +64,27 @@ describe('saved authentication language', () => {
const user: AuthUser = { id: 1, username: 'user', role: 1 }
test('prefers the explicit user language', () => {
- assert.equal(
+ expect(
getSavedLanguage({
...user,
language: 'ja',
setting: { language: 'fr' },
- }),
- 'ja'
- )
+ })
+ ).toBe('ja')
})
test('reads object and JSON string settings', () => {
- assert.equal(
- getSavedLanguage({ ...user, setting: { language: 'fr' } }),
+ expect(getSavedLanguage({ ...user, setting: { language: 'fr' } })).toBe(
'fr'
)
- assert.equal(
- getSavedLanguage({ ...user, setting: '{"language":"ru"}' }),
+ expect(getSavedLanguage({ ...user, setting: '{"language":"ru"}' })).toBe(
'ru'
)
})
test('ignores malformed and non-string setting languages', () => {
- assert.equal(getSavedLanguage({ ...user, setting: '{' }), undefined)
- assert.equal(
- getSavedLanguage({ ...user, setting: { language: 123 } }),
+ expect(getSavedLanguage({ ...user, setting: '{' })).toBe(undefined)
+ expect(getSavedLanguage({ ...user, setting: { language: 123 } })).toBe(
undefined
)
})
diff --git a/web/src/features/auth/lib/oauth-bind-window.test.ts b/web/src/features/auth/lib/oauth-bind-window.test.ts
index 3ef4ba432262..54a33385fba7 100644
--- a/web/src/features/auth/lib/oauth-bind-window.test.ts
+++ b/web/src/features/auth/lib/oauth-bind-window.test.ts
@@ -16,8 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { describe, test } from 'node:test'
+import { describe, expect, test } from 'vitest'
import {
parseTelegramBindCallback,
@@ -51,51 +50,48 @@ function fakeTimerRuntime() {
describe('OAuth bind popup lifecycle', () => {
test('parses Telegram success and stable error callbacks', () => {
- assert.deepEqual(
+ expect(
parseTelegramBindCallback({
telegram_bind: 'success',
flow_token: 'flow-success',
- }),
- {
- kind: 'result',
- flowToken: 'flow-success',
- success: true,
- }
- )
- assert.deepEqual(
+ })
+ ).toEqual({
+ kind: 'result',
+ flowToken: 'flow-success',
+ success: true,
+ })
+ expect(
parseTelegramBindCallback({
telegram_bind: 'error',
flow_token: 'flow-error',
error_code: 'TELEGRAM_BIND_ALREADY_BOUND',
- }),
- {
- kind: 'result',
- flowToken: 'flow-error',
- success: false,
- code: 'TELEGRAM_BIND_ALREADY_BOUND',
- }
- )
+ })
+ ).toEqual({
+ kind: 'result',
+ flowToken: 'flow-error',
+ success: false,
+ code: 'TELEGRAM_BIND_ALREADY_BOUND',
+ })
})
test('rejects Telegram callbacks without a flow token and ignores descriptions', () => {
- assert.deepEqual(parseTelegramBindCallback({ telegram_bind: 'error' }), {
+ expect(parseTelegramBindCallback({ telegram_bind: 'error' })).toEqual({
kind: 'invalid',
})
- assert.deepEqual(
+ expect(
parseTelegramBindCallback({
telegram_bind: 'error',
flow_token: 'flow-error',
error_code: 'UNKNOWN_CODE',
error_description: 'untrusted message',
- } as Parameters[0]),
- {
- kind: 'result',
- flowToken: 'flow-error',
- success: false,
- code: 'UNKNOWN_CODE',
- }
- )
- assert.equal(parseTelegramBindCallback({}), null)
+ } as Parameters[0])
+ ).toEqual({
+ kind: 'result',
+ flowToken: 'flow-error',
+ success: false,
+ code: 'UNKNOWN_CODE',
+ })
+ expect(parseTelegramBindCallback({})).toBe(null)
})
test('posts only complete Telegram bind results to an available opener', () => {
@@ -112,11 +108,10 @@ describe('OAuth bind popup lifecycle', () => {
error_code: 'UNKNOWN_CODE',
})
- assert.equal(
- postTelegramBindResult(callback, opener, 'https://dashboard.example.com'),
- true
- )
- assert.deepEqual(messages, [
+ expect(
+ postTelegramBindResult(callback, opener, 'https://dashboard.example.com')
+ ).toBe(true)
+ expect(messages).toEqual([
{
message: {
type: 'telegram:binding:result',
@@ -128,23 +123,17 @@ describe('OAuth bind popup lifecycle', () => {
},
])
- assert.equal(
- postTelegramBindResult(
- { kind: 'invalid' },
- opener,
- 'https://example.com'
- ),
- false
- )
- assert.equal(
+ expect(
+ postTelegramBindResult({ kind: 'invalid' }, opener, 'https://example.com')
+ ).toBe(false)
+ expect(
postTelegramBindResult(
callback,
{ ...opener, closed: true },
'https://example.com'
- ),
- false
- )
- assert.equal(messages.length, 1)
+ )
+ ).toBe(false)
+ expect(messages.length).toBe(1)
})
test('waits 30 seconds for the opener response and can be cancelled', () => {
@@ -158,11 +147,11 @@ describe('OAuth bind popup lifecycle', () => {
timer.runtime
)
- assert.equal(timer.delay, 30_000)
+ expect(timer.delay).toBe(30_000)
cancel()
timer.fire()
- assert.equal(timedOut, false)
- assert.deepEqual(timer.cancelled, [timer.handle])
+ expect(timedOut).toBe(false)
+ expect(timer.cancelled).toEqual([timer.handle])
})
test('reports a closed popup once and clears its poller', () => {
@@ -178,13 +167,13 @@ describe('OAuth bind popup lifecycle', () => {
timer.runtime
)
- assert.equal(timer.delay, 500)
+ expect(timer.delay).toBe(500)
timer.fire()
- assert.equal(closedCount, 0)
+ expect(closedCount).toBe(0)
popup.closed = true
timer.fire()
timer.fire()
- assert.equal(closedCount, 1)
- assert.deepEqual(timer.cancelled, [timer.handle])
+ expect(closedCount).toBe(1)
+ expect(timer.cancelled).toEqual([timer.handle])
})
})
diff --git a/web/src/features/auth/lib/telegram-login.test.ts b/web/src/features/auth/lib/telegram-login.test.ts
index 95ee310c5d8c..ad64e9678649 100644
--- a/web/src/features/auth/lib/telegram-login.test.ts
+++ b/web/src/features/auth/lib/telegram-login.test.ts
@@ -16,14 +16,13 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { describe, test } from 'node:test'
+import { describe, expect, test } from 'vitest'
import { pickTelegramAuthorization } from './telegram-login'
describe('Telegram login authorization', () => {
test('keeps only fields signed by the Telegram login contract', () => {
- assert.deepEqual(
+ expect(
pickTelegramAuthorization({
id: 12345,
first_name: 'Test',
@@ -35,33 +34,27 @@ describe('Telegram login authorization', () => {
lang: 'en',
admin: true,
redirect: 'https://attacker.example',
- }),
- {
- id: 12345,
- first_name: 'Test',
- last_name: 'User',
- username: 'test_user',
- photo_url: 'https://t.me/i/userpic/320/test.jpg',
- auth_date: 1_900_000_000,
- hash: 'signed-hash',
- lang: 'en',
- }
- )
+ })
+ ).toEqual({
+ id: 12345,
+ first_name: 'Test',
+ last_name: 'User',
+ username: 'test_user',
+ photo_url: 'https://t.me/i/userpic/320/test.jpg',
+ auth_date: 1_900_000_000,
+ hash: 'signed-hash',
+ lang: 'en',
+ })
})
test('rejects incomplete or structurally invalid callbacks', () => {
- assert.equal(pickTelegramAuthorization(null), null)
- assert.equal(
- pickTelegramAuthorization({ auth_date: 1, hash: 'hash' }),
- null
- )
- assert.equal(
- pickTelegramAuthorization({ id: 1, auth_date: 1, hash: '' }),
- null
- )
- assert.equal(
- pickTelegramAuthorization({ id: {}, auth_date: 1, hash: 'hash' }),
+ expect(pickTelegramAuthorization(null)).toBe(null)
+ expect(pickTelegramAuthorization({ auth_date: 1, hash: 'hash' })).toBe(null)
+ expect(pickTelegramAuthorization({ id: 1, auth_date: 1, hash: '' })).toBe(
null
)
+ expect(
+ pickTelegramAuthorization({ id: {}, auth_date: 1, hash: 'hash' })
+ ).toBe(null)
})
})
diff --git a/web/src/features/channels/lib/__tests__/channel-field-update.test.ts b/web/src/features/channels/lib/__tests__/channel-field-update.test.ts
index d984f656d70f..cfd463ba951c 100644
--- a/web/src/features/channels/lib/__tests__/channel-field-update.test.ts
+++ b/web/src/features/channels/lib/__tests__/channel-field-update.test.ts
@@ -16,8 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { describe, test } from 'node:test'
+import { describe, expect, test } from 'vitest'
import {
CHANNEL_FIELD_UPDATE_DELAY_MS,
@@ -31,7 +30,7 @@ function createFakeTimers() {
return {
timers: {
setTimeout: (callback: () => void, delay: number) => {
- assert.equal(delay, CHANNEL_FIELD_UPDATE_DELAY_MS)
+ expect(delay).toBe(CHANNEL_FIELD_UPDATE_DELAY_MS)
const id = nextId++
pending.set(id, callback)
return id
@@ -63,11 +62,11 @@ describe('channel field update scheduler', () => {
scheduler.schedule(1)
scheduler.schedule(2)
scheduler.schedule(3)
- assert.deepEqual(updates, [])
- assert.equal(fake.pendingCount, 1)
+ expect(updates).toEqual([])
+ expect(fake.pendingCount).toBe(1)
fake.fireAll()
- assert.deepEqual(updates, [3])
+ expect(updates).toEqual([3])
})
test('flush commits the pending value immediately and cancels the timer', () => {
@@ -80,11 +79,11 @@ describe('channel field update scheduler', () => {
scheduler.schedule(7)
scheduler.flush()
- assert.deepEqual(updates, [7])
- assert.equal(fake.pendingCount, 0)
+ expect(updates).toEqual([7])
+ expect(fake.pendingCount).toBe(0)
fake.fireAll()
- assert.deepEqual(updates, [7])
+ expect(updates).toEqual([7])
})
test('flush without a pending value does nothing', () => {
@@ -99,7 +98,7 @@ describe('channel field update scheduler', () => {
scheduler.schedule(5)
scheduler.flush()
scheduler.flush()
- assert.deepEqual(updates, [5])
+ expect(updates).toEqual([5])
})
test('preserves a pending value of 0', () => {
@@ -112,6 +111,6 @@ describe('channel field update scheduler', () => {
scheduler.schedule(0)
scheduler.flush()
- assert.deepEqual(updates, [0])
+ expect(updates).toEqual([0])
})
})
diff --git a/web/src/features/channels/lib/__tests__/channel-table-row-id.test.ts b/web/src/features/channels/lib/__tests__/channel-table-row-id.test.ts
index d0e19bafb776..2d6906279e3a 100644
--- a/web/src/features/channels/lib/__tests__/channel-table-row-id.test.ts
+++ b/web/src/features/channels/lib/__tests__/channel-table-row-id.test.ts
@@ -16,8 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { describe, test } from 'node:test'
+import { describe, expect, test } from 'vitest'
import type { Channel } from '../../types'
import { getChannelTableRowId, type TagRow } from '../channel-utils'
@@ -35,12 +34,8 @@ describe('channel table row identity', () => {
const beforeUpdate = [first, updated, third].map(getChannelTableRowId)
const afterUpdate = [updated, first, third].map(getChannelTableRowId)
- assert.deepEqual(beforeUpdate, [
- 'channel:101',
- 'channel:202',
- 'channel:303',
- ])
- assert.deepEqual(afterUpdate, ['channel:202', 'channel:101', 'channel:303'])
+ expect(beforeUpdate).toEqual(['channel:101', 'channel:202', 'channel:303'])
+ expect(afterUpdate).toEqual(['channel:202', 'channel:101', 'channel:303'])
})
test('uses separate namespaces for tag and channel rows', () => {
@@ -50,7 +45,7 @@ describe('channel table row identity', () => {
children: [channel(202)],
} as TagRow
- assert.equal(getChannelTableRowId(tagRow), 'tag:202')
- assert.equal(getChannelTableRowId(channel(202)), 'channel:202')
+ expect(getChannelTableRowId(tagRow)).toBe('tag:202')
+ expect(getChannelTableRowId(channel(202))).toBe('channel:202')
})
})
diff --git a/web/src/features/channels/lib/__tests__/new-api-channel.test.ts b/web/src/features/channels/lib/__tests__/new-api-channel.test.ts
index 5e4f6c9b4608..f24fb3b8466d 100644
--- a/web/src/features/channels/lib/__tests__/new-api-channel.test.ts
+++ b/web/src/features/channels/lib/__tests__/new-api-channel.test.ts
@@ -16,8 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { describe, test } from 'node:test'
+import { describe, expect, test } from 'vitest'
import {
CHANNEL_TYPE_NEW_API,
@@ -45,45 +44,40 @@ describe('New API channel', () => {
(item) => item.value === CHANNEL_TYPE_NEW_API
)
- assert.deepEqual(option, {
+ expect(option).toEqual({
value: CHANNEL_TYPE_NEW_API,
label: 'New API',
})
- assert.equal(
+ expect(
CHANNEL_TYPE_OPTIONS.findIndex(
(item) => item.value === CHANNEL_TYPE_NEW_API
- ) + 1,
- CHANNEL_TYPE_OPTIONS.findIndex((item) => item.value === 58)
- )
- assert.equal(MODEL_FETCHABLE_TYPES.has(CHANNEL_TYPE_NEW_API), true)
- assert.equal(getChannelTypeIcon(CHANNEL_TYPE_NEW_API), 'NewAPI')
- assert.equal(
- getKeyPromptForType(CHANNEL_TYPE_NEW_API),
+ ) + 1
+ ).toBe(CHANNEL_TYPE_OPTIONS.findIndex((item) => item.value === 58))
+ expect(MODEL_FETCHABLE_TYPES.has(CHANNEL_TYPE_NEW_API)).toBe(true)
+ expect(getChannelTypeIcon(CHANNEL_TYPE_NEW_API)).toBe('NewAPI')
+ expect(getKeyPromptForType(CHANNEL_TYPE_NEW_API)).toBe(
'Enter API key for this channel'
)
- assert.equal(getChannelTypeConfig(CHANNEL_TYPE_NEW_API).icon, 'NewAPI')
+ expect(getChannelTypeConfig(CHANNEL_TYPE_NEW_API).icon).toBe('NewAPI')
})
test('requires a non-blank Base URL', () => {
const blankResult = channelFormSchema.safeParse(newAPIForm(' '))
- assert.equal(blankResult.success, false)
+ expect(blankResult.success).toBe(false)
if (!blankResult.success) {
- assert.equal(
+ expect(
blankResult.error.issues.some(
(issue) =>
issue.path[0] === 'base_url' &&
issue.message === 'Base URL is required for this channel type'
- ),
- true
- )
+ )
+ ).toBe(true)
}
- assert.equal(
- channelFormSchema.safeParse(newAPIForm('https://new-api.example'))
- .success,
- true
- )
+ expect(
+ channelFormSchema.safeParse(newAPIForm('https://new-api.example')).success
+ ).toBe(true)
})
test('keeps Sub2API Base URL validation unchanged', () => {
@@ -92,6 +86,6 @@ describe('New API channel', () => {
type: 59,
})
- assert.equal(result.success, true)
+ expect(result.success).toBe(true)
})
})
diff --git a/web/src/features/dashboard/lib/flow-selection.test.ts b/web/src/features/dashboard/lib/flow-selection.test.ts
index 4317c7175fe4..43bbccd7d673 100644
--- a/web/src/features/dashboard/lib/flow-selection.test.ts
+++ b/web/src/features/dashboard/lib/flow-selection.test.ts
@@ -16,8 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { describe, test } from 'node:test'
+import { describe, expect, test } from 'vitest'
import type { FlowUserFilterOption } from '../types'
import {
@@ -46,89 +45,75 @@ const users: FlowUserFilterOption[] = [
describe('dashboard flow selection helpers', () => {
test('limits user chips to currently visible users', () => {
- assert.deepEqual(
- visibleFlowUsers(users, []).map((user) => user.value),
- ['user:1', 'user:2']
- )
- assert.deepEqual(
- visibleFlowUsers(users, ['user:2']).map((user) => user.value),
- ['user:2']
- )
+ expect(visibleFlowUsers(users, []).map((user) => user.value)).toEqual([
+ 'user:1',
+ 'user:2',
+ ])
+ expect(
+ visibleFlowUsers(users, ['user:2']).map((user) => user.value)
+ ).toEqual(['user:2'])
})
test('filters visible users without mutating the source options', () => {
const visible = visibleFlowUsers(users, ['user:1'])
- assert.deepEqual(
- visible.map((user) => user.value),
- ['user:1']
- )
- assert.deepEqual(
- users.map((user) => user.value),
- ['user:1', 'user:2']
- )
+ expect(visible.map((user) => user.value)).toEqual(['user:1'])
+ expect(users.map((user) => user.value)).toEqual(['user:1', 'user:2'])
})
test('formats compact selected counts for flow multiselect summaries', () => {
- assert.equal(compactFlowSelectionLabel(0), '*')
- assert.equal(compactFlowSelectionLabel(1), '1')
- assert.equal(compactFlowSelectionLabel(23), '23')
+ expect(compactFlowSelectionLabel(0)).toBe('*')
+ expect(compactFlowSelectionLabel(1)).toBe('1')
+ expect(compactFlowSelectionLabel(23)).toBe('23')
})
test('prioritizes loading and error states before empty flow data', () => {
- assert.equal(
+ expect(
flowDisplayState({
isLoading: true,
isError: true,
linkCount: 0,
themeReady: true,
- }),
- 'loading'
- )
- assert.equal(
+ })
+ ).toBe('loading')
+ expect(
flowDisplayState({
isLoading: false,
isError: true,
linkCount: 0,
themeReady: true,
- }),
- 'error'
- )
- assert.equal(
+ })
+ ).toBe('error')
+ expect(
flowDisplayState({
isLoading: false,
isError: false,
linkCount: 0,
themeReady: true,
- }),
- 'empty'
- )
- assert.equal(
+ })
+ ).toBe('empty')
+ expect(
flowDisplayState({
isLoading: false,
isError: false,
linkCount: 1,
themeReady: false,
- }),
- 'loading'
- )
+ })
+ ).toBe('loading')
})
test('throws unsuccessful flow responses instead of treating them as empty data', () => {
- assert.throws(
- () =>
- requireSuccessfulFlowRows(
- { success: false, data: [], message: 'database unavailable' },
- 'Failed to load'
- ),
- /database unavailable/
- )
- assert.deepEqual(
+ expect(() =>
+ requireSuccessfulFlowRows(
+ { success: false, data: [], message: 'database unavailable' },
+ 'Failed to load'
+ )
+ ).toThrow(/database unavailable/)
+ expect(
requireSuccessfulFlowRows(
{ success: true, data: [{ user_id: 1, quota: 10 }] },
'Failed to load'
- ),
- [{ user_id: 1, quota: 10 }]
- )
+ )
+ ).toEqual([{ user_id: 1, quota: 10 }])
})
})
diff --git a/web/src/features/dashboard/lib/flow.test.ts b/web/src/features/dashboard/lib/flow.test.ts
index 1645d2f01e66..724eb3677c08 100644
--- a/web/src/features/dashboard/lib/flow.test.ts
+++ b/web/src/features/dashboard/lib/flow.test.ts
@@ -16,8 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { describe, test } from 'node:test'
+import { describe, expect, test } from 'vitest'
import type { FlowQuotaDataItem } from '../types'
import {
@@ -113,18 +112,16 @@ describe('dashboard flow data', () => {
role: 'user',
})
- assert.equal(result.summary.quota, 150)
- assert.equal(result.summary.tokens, 60)
- assert.equal(result.summary.requests, 3)
- assert.deepEqual(
- result.flow.links.map((link) => [link.source, link.target, link.value]),
- [
- ['group:vip', 'model:gpt-4.1', 150],
- ['token:11', 'group:vip', 150],
- ]
- )
- assert.equal(
- result.flow.nodes.some((node) => node.kind === 'channel'),
+ expect(result.summary.quota).toBe(150)
+ expect(result.summary.tokens).toBe(60)
+ expect(result.summary.requests).toBe(3)
+ expect(
+ result.flow.links.map((link) => [link.source, link.target, link.value])
+ ).toEqual([
+ ['group:vip', 'model:gpt-4.1', 150],
+ ['token:11', 'group:vip', 150],
+ ])
+ expect(result.flow.nodes.some((node) => node.kind === 'channel')).toBe(
false
)
})
@@ -134,18 +131,17 @@ describe('dashboard flow data', () => {
role: 'admin',
})
- assert.deepEqual(
- result.flow.links.map((link) => [link.source, link.target, link.value]),
- [
- ['group:default', 'model:claude-4-sonnet', 70],
- ['group:vip', 'model:gpt-4.1', 150],
- ['model:claude-4-sonnet', 'channel:101', 70],
- ['model:gpt-4.1', 'channel:101', 100],
- ['model:gpt-4.1', 'channel:102', 50],
- ['user:1', 'group:vip', 150],
- ['user:2', 'group:default', 70],
- ]
- )
+ expect(
+ result.flow.links.map((link) => [link.source, link.target, link.value])
+ ).toEqual([
+ ['group:default', 'model:claude-4-sonnet', 70],
+ ['group:vip', 'model:gpt-4.1', 150],
+ ['model:claude-4-sonnet', 'channel:101', 70],
+ ['model:gpt-4.1', 'channel:101', 100],
+ ['model:gpt-4.1', 'channel:102', 50],
+ ['user:1', 'group:vip', 150],
+ ['user:2', 'group:default', 70],
+ ])
})
test('builds root user-node-token-group-model-channel flow', () => {
@@ -153,22 +149,21 @@ describe('dashboard flow data', () => {
role: 'root',
})
- assert.deepEqual(
- result.flow.links.map((link) => [link.source, link.target, link.value]),
- [
- ['group:default', 'model:claude-4-sonnet', 3],
- ['group:vip', 'model:gpt-4.1', 3],
- ['model:claude-4-sonnet', 'channel:101', 3],
- ['model:gpt-4.1', 'channel:101', 2],
- ['model:gpt-4.1', 'channel:102', 1],
- ['node:node-a', 'token:11', 3],
- ['node:node-b', 'token:22', 3],
- ['token:11', 'group:vip', 3],
- ['token:22', 'group:default', 3],
- ['user:1', 'node:node-a', 3],
- ['user:2', 'node:node-b', 3],
- ]
- )
+ expect(
+ result.flow.links.map((link) => [link.source, link.target, link.value])
+ ).toEqual([
+ ['group:default', 'model:claude-4-sonnet', 3],
+ ['group:vip', 'model:gpt-4.1', 3],
+ ['model:claude-4-sonnet', 'channel:101', 3],
+ ['model:gpt-4.1', 'channel:101', 2],
+ ['model:gpt-4.1', 'channel:102', 1],
+ ['node:node-a', 'token:11', 3],
+ ['node:node-b', 'token:22', 3],
+ ['token:11', 'group:vip', 3],
+ ['token:22', 'group:default', 3],
+ ['user:1', 'node:node-a', 3],
+ ['user:2', 'node:node-b', 3],
+ ])
})
test('filters by selected users', () => {
@@ -177,15 +172,14 @@ describe('dashboard flow data', () => {
selectedUsers: ['user:2'],
})
- assert.equal(result.summary.quota, 70)
- assert.deepEqual(
- result.flow.links.map((link) => [link.source, link.target, link.value]),
- [
- ['group:default', 'model:claude-4-sonnet', 70],
- ['model:claude-4-sonnet', 'channel:101', 70],
- ['user:2', 'group:default', 70],
- ]
- )
+ expect(result.summary.quota).toBe(70)
+ expect(
+ result.flow.links.map((link) => [link.source, link.target, link.value])
+ ).toEqual([
+ ['group:default', 'model:claude-4-sonnet', 70],
+ ['model:claude-4-sonnet', 'channel:101', 70],
+ ['user:2', 'group:default', 70],
+ ])
})
test('filters rows by selected flow nodes', () => {
@@ -194,16 +188,15 @@ describe('dashboard flow data', () => {
selectedNodes: [{ kind: 'model', id: 'model:gpt-4.1' }],
})
- assert.equal(result.summary.quota, 150)
- assert.deepEqual(
- result.flow.links.map((link) => [link.source, link.target, link.value]),
- [
- ['group:vip', 'model:gpt-4.1', 150],
- ['model:gpt-4.1', 'channel:101', 100],
- ['model:gpt-4.1', 'channel:102', 50],
- ['user:1', 'group:vip', 150],
- ]
- )
+ expect(result.summary.quota).toBe(150)
+ expect(
+ result.flow.links.map((link) => [link.source, link.target, link.value])
+ ).toEqual([
+ ['group:vip', 'model:gpt-4.1', 150],
+ ['model:gpt-4.1', 'channel:101', 100],
+ ['model:gpt-4.1', 'channel:102', 50],
+ ['user:1', 'group:vip', 150],
+ ])
})
test('combines node filters with OR inside a column and AND across columns', () => {
@@ -222,20 +215,19 @@ describe('dashboard flow data', () => {
],
})
- assert.equal(sameColumn.summary.quota, 220)
- assert.equal(crossColumn.summary.quota, 100)
- assert.deepEqual(
+ expect(sameColumn.summary.quota).toBe(220)
+ expect(crossColumn.summary.quota).toBe(100)
+ expect(
crossColumn.flow.links.map((link) => [
link.source,
link.target,
link.value,
- ]),
- [
- ['group:vip', 'model:gpt-4.1', 100],
- ['model:gpt-4.1', 'channel:101', 100],
- ['user:1', 'group:vip', 100],
- ]
- )
+ ])
+ ).toEqual([
+ ['group:vip', 'model:gpt-4.1', 100],
+ ['model:gpt-4.1', 'channel:101', 100],
+ ['user:1', 'group:vip', 100],
+ ])
})
test('combines user and node filters', () => {
@@ -245,15 +237,14 @@ describe('dashboard flow data', () => {
selectedNodes: [{ kind: 'channel', id: 'channel:101' }],
})
- assert.equal(result.summary.quota, 100)
- assert.deepEqual(
- result.flow.links.map((link) => [link.source, link.target, link.value]),
- [
- ['group:vip', 'model:gpt-4.1', 100],
- ['model:gpt-4.1', 'channel:101', 100],
- ['user:1', 'group:vip', 100],
- ]
- )
+ expect(result.summary.quota).toBe(100)
+ expect(
+ result.flow.links.map((link) => [link.source, link.target, link.value])
+ ).toEqual([
+ ['group:vip', 'model:gpt-4.1', 100],
+ ['model:gpt-4.1', 'channel:101', 100],
+ ['user:1', 'group:vip', 100],
+ ])
})
test('reconnects links when a middle stage is hidden', () => {
@@ -262,20 +253,16 @@ describe('dashboard flow data', () => {
visibleStages: ['user', 'model', 'channel'],
})
- assert.deepEqual(
- result.flow.links.map((link) => [link.source, link.target, link.value]),
- [
- ['model:claude-4-sonnet', 'channel:101', 70],
- ['model:gpt-4.1', 'channel:101', 100],
- ['model:gpt-4.1', 'channel:102', 50],
- ['user:1', 'model:gpt-4.1', 150],
- ['user:2', 'model:claude-4-sonnet', 70],
- ]
- )
- assert.equal(
- result.flow.nodes.some((node) => node.kind === 'group'),
- false
- )
+ expect(
+ result.flow.links.map((link) => [link.source, link.target, link.value])
+ ).toEqual([
+ ['model:claude-4-sonnet', 'channel:101', 70],
+ ['model:gpt-4.1', 'channel:101', 100],
+ ['model:gpt-4.1', 'channel:102', 50],
+ ['user:1', 'model:gpt-4.1', 150],
+ ['user:2', 'model:claude-4-sonnet', 70],
+ ])
+ expect(result.flow.nodes.some((node) => node.kind === 'group')).toBe(false)
})
test('ignores stage filters that would leave fewer than two columns', () => {
@@ -284,26 +271,24 @@ describe('dashboard flow data', () => {
visibleStages: ['model'],
})
- assert.deepEqual(
- result.flow.links.map((link) => [link.source, link.target, link.value]),
- [
- ['group:vip', 'model:gpt-4.1', 150],
- ['token:11', 'group:vip', 150],
- ]
- )
+ expect(
+ result.flow.links.map((link) => [link.source, link.target, link.value])
+ ).toEqual([
+ ['group:vip', 'model:gpt-4.1', 150],
+ ['token:11', 'group:vip', 150],
+ ])
})
test('builds user filter options with stable values', () => {
const options = buildFlowFilterOptions(rows, 'quota')
- assert.deepEqual(
- options.users.map((user) => [user.value, user.label, user.valueLabel]),
- [
- ['user:1', 'alice', '150'],
- ['user:2', 'bob', '70'],
- ]
- )
- assert.notEqual(options.users[0].color, options.users[1].color)
+ expect(
+ options.users.map((user) => [user.value, user.label, user.valueLabel])
+ ).toEqual([
+ ['user:1', 'alice', '150'],
+ ['user:2', 'bob', '70'],
+ ])
+ expect(options.users[0].color).not.toBe(options.users[1].color)
})
test('builds node filter options without applying top limits', () => {
@@ -313,22 +298,20 @@ describe('dashboard flow data', () => {
overflowMode: 'aggregate',
})
- assert.equal(
+ expect(
result.filterOptions.nodes.some(
(option) => option.kind === 'model' && option.value === 'model:model-c'
- ),
- true
- )
- assert.deepEqual(
+ )
+ ).toBe(true)
+ expect(
result.filterOptions.nodes
.filter((option) => option.kind === 'model')
- .map((option) => [option.value, option.valueLabel]),
- [
- ['model:model-a', '100'],
- ['model:model-b', '80'],
- ['model:model-c', '10'],
- ]
- )
+ .map((option) => [option.value, option.valueLabel])
+ ).toEqual([
+ ['model:model-a', '100'],
+ ['model:model-b', '80'],
+ ['model:model-c', '10'],
+ ])
})
test('facets node filter options by selected nodes from other columns', () => {
@@ -338,30 +321,27 @@ describe('dashboard flow data', () => {
})
const nodeOptions = result.filterOptions.nodes
- assert.deepEqual(
+ expect(
nodeOptions
.filter((option) => option.kind === 'node')
- .map((option) => [option.value, option.valueLabel]),
- [
- ['node:node-a', '150'],
- ['node:node-b', '70'],
- ]
- )
- assert.deepEqual(
+ .map((option) => [option.value, option.valueLabel])
+ ).toEqual([
+ ['node:node-a', '150'],
+ ['node:node-b', '70'],
+ ])
+ expect(
nodeOptions
.filter((option) => option.kind === 'token')
- .map((option) => [option.value, option.valueLabel]),
- [['token:11', '150']]
- )
- assert.deepEqual(
+ .map((option) => [option.value, option.valueLabel])
+ ).toEqual([['token:11', '150']])
+ expect(
nodeOptions
.filter((option) => option.kind === 'channel')
- .map((option) => [option.value, option.valueLabel]),
- [
- ['channel:101', '100'],
- ['channel:102', '50'],
- ]
- )
+ .map((option) => [option.value, option.valueLabel])
+ ).toEqual([
+ ['channel:101', '100'],
+ ['channel:102', '50'],
+ ])
})
test('keeps same-column node options available for OR filtering', () => {
@@ -370,24 +350,22 @@ describe('dashboard flow data', () => {
selectedNodes: [{ kind: 'model', id: 'model:gpt-4.1' }],
})
- assert.deepEqual(
+ expect(
result.filterOptions.nodes
.filter((option) => option.kind === 'model')
- .map((option) => [option.value, option.valueLabel]),
- [
- ['model:gpt-4.1', '150'],
- ['model:claude-4-sonnet', '70'],
- ]
- )
- assert.deepEqual(
+ .map((option) => [option.value, option.valueLabel])
+ ).toEqual([
+ ['model:gpt-4.1', '150'],
+ ['model:claude-4-sonnet', '70'],
+ ])
+ expect(
result.filterOptions.nodes
.filter((option) => option.kind === 'channel')
- .map((option) => [option.value, option.valueLabel]),
- [
- ['channel:101', '100'],
- ['channel:102', '50'],
- ]
- )
+ .map((option) => [option.value, option.valueLabel])
+ ).toEqual([
+ ['channel:101', '100'],
+ ['channel:102', '50'],
+ ])
})
test('combines user filters with faceted node filter options', () => {
@@ -397,22 +375,20 @@ describe('dashboard flow data', () => {
selectedNodes: [{ kind: 'channel', id: 'channel:101' }],
})
- assert.equal(result.summary.quota, 100)
- assert.deepEqual(
+ expect(result.summary.quota).toBe(100)
+ expect(
result.filterOptions.nodes
.filter((option) => option.kind === 'model')
- .map((option) => [option.value, option.valueLabel]),
- [['model:gpt-4.1', '100']]
- )
- assert.deepEqual(
+ .map((option) => [option.value, option.valueLabel])
+ ).toEqual([['model:gpt-4.1', '100']])
+ expect(
result.filterOptions.nodes
.filter((option) => option.kind === 'channel')
- .map((option) => [option.value, option.valueLabel]),
- [
- ['channel:101', '100'],
- ['channel:102', '50'],
- ]
- )
+ .map((option) => [option.value, option.valueLabel])
+ ).toEqual([
+ ['channel:101', '100'],
+ ['channel:102', '50'],
+ ])
})
test('aggregates overflow nodes into per-column Other buckets', () => {
@@ -434,18 +410,18 @@ describe('dashboard flow data', () => {
.filter((link) => link.source.startsWith('user:'))
.reduce((sum, link) => sum + link.value, 0)
- assert.equal(result.summary.quota, 190)
- assert.equal(firstStepTotal, 190)
- assert.equal(otherUser?.label, 'Other user')
- assert.equal(otherFirstStepLink?.value, 10)
- assert.equal(nodeIds.has('user:3'), false)
- assert.equal(nodeIds.has('group:free'), false)
- assert.equal(nodeIds.has('model:model-c'), false)
- assert.equal(nodeIds.has('channel:203'), false)
- assert.equal(nodeIds.has('user:__other__'), true)
- assert.equal(nodeIds.has('group:__other__'), true)
- assert.equal(nodeIds.has('model:__other__'), true)
- assert.equal(nodeIds.has('channel:__other__'), true)
+ expect(result.summary.quota).toBe(190)
+ expect(firstStepTotal).toBe(190)
+ expect(otherUser?.label).toBe('Other user')
+ expect(otherFirstStepLink?.value).toBe(10)
+ expect(nodeIds.has('user:3')).toBe(false)
+ expect(nodeIds.has('group:free')).toBe(false)
+ expect(nodeIds.has('model:model-c')).toBe(false)
+ expect(nodeIds.has('channel:203')).toBe(false)
+ expect(nodeIds.has('user:__other__')).toBe(true)
+ expect(nodeIds.has('group:__other__')).toBe(true)
+ expect(nodeIds.has('model:__other__')).toBe(true)
+ expect(nodeIds.has('channel:__other__')).toBe(true)
})
test('hides overflow paths when overflow mode is hide', () => {
@@ -460,11 +436,11 @@ describe('dashboard flow data', () => {
.filter((link) => link.source.startsWith('user:'))
.reduce((sum, link) => sum + link.value, 0)
- assert.equal(result.summary.quota, 190)
- assert.equal(firstStepTotal, 180)
- assert.equal(nodeIds.has('user:3'), false)
- assert.equal(nodeIds.has('user:__other__'), false)
- assert.equal(nodeIds.has('model:__other__'), false)
+ expect(result.summary.quota).toBe(190)
+ expect(firstStepTotal).toBe(180)
+ expect(nodeIds.has('user:3')).toBe(false)
+ expect(nodeIds.has('user:__other__')).toBe(false)
+ expect(nodeIds.has('model:__other__')).toBe(false)
})
test('ranks top nodes using the selected flow metric', () => {
@@ -484,18 +460,11 @@ describe('dashboard flow data', () => {
overflowMode: 'aggregate',
})
- assert.equal(
- byQuota.flow.nodes.some((node) => node.id === 'user:1'),
- true
- )
- assert.equal(
- byRequests.flow.nodes.some((node) => node.id === 'user:2'),
- true
- )
- assert.equal(
- byTokens.flow.nodes.some((node) => node.id === 'user:3'),
+ expect(byQuota.flow.nodes.some((node) => node.id === 'user:1')).toBe(true)
+ expect(byRequests.flow.nodes.some((node) => node.id === 'user:2')).toBe(
true
)
+ expect(byTokens.flow.nodes.some((node) => node.id === 'user:3')).toBe(true)
})
test('applies top limits only to visible stages', () => {
@@ -507,19 +476,18 @@ describe('dashboard flow data', () => {
})
const nodeIds = new Set(result.flow.nodes.map((node) => node.id))
- assert.equal(nodeIds.has('user:1'), true)
- assert.equal(nodeIds.has('user:__other__'), true)
- assert.equal(nodeIds.has('model:model-a'), true)
- assert.equal(nodeIds.has('model:__other__'), true)
- assert.equal(nodeIds.has('group:__other__'), false)
- assert.equal(nodeIds.has('channel:__other__'), false)
- assert.deepEqual(
- result.flow.links.map((link) => [link.source, link.target, link.value]),
- [
- ['user:__other__', 'model:__other__', 90],
- ['user:1', 'model:model-a', 100],
- ]
- )
+ expect(nodeIds.has('user:1')).toBe(true)
+ expect(nodeIds.has('user:__other__')).toBe(true)
+ expect(nodeIds.has('model:model-a')).toBe(true)
+ expect(nodeIds.has('model:__other__')).toBe(true)
+ expect(nodeIds.has('group:__other__')).toBe(false)
+ expect(nodeIds.has('channel:__other__')).toBe(false)
+ expect(
+ result.flow.links.map((link) => [link.source, link.target, link.value])
+ ).toEqual([
+ ['user:__other__', 'model:__other__', 90],
+ ['user:1', 'model:model-a', 100],
+ ])
})
test('applies top limits after node filters', () => {
@@ -531,17 +499,16 @@ describe('dashboard flow data', () => {
})
const nodeIds = new Set(result.flow.nodes.map((node) => node.id))
- assert.equal(result.summary.quota, 10)
- assert.equal(nodeIds.has('model:model-c'), true)
- assert.equal(nodeIds.has('model:__other__'), false)
- assert.deepEqual(
- result.flow.links.map((link) => [link.source, link.target, link.value]),
- [
- ['group:free', 'model:model-c', 10],
- ['model:model-c', 'channel:203', 10],
- ['user:3', 'group:free', 10],
- ]
- )
+ expect(result.summary.quota).toBe(10)
+ expect(nodeIds.has('model:model-c')).toBe(true)
+ expect(nodeIds.has('model:__other__')).toBe(false)
+ expect(
+ result.flow.links.map((link) => [link.source, link.target, link.value])
+ ).toEqual([
+ ['group:free', 'model:model-c', 10],
+ ['model:model-c', 'channel:203', 10],
+ ['user:3', 'group:free', 10],
+ ])
})
test('ignores selected node filters for hidden stages', () => {
@@ -551,9 +518,8 @@ describe('dashboard flow data', () => {
selectedNodes: [{ kind: 'group', id: 'group:vip' }],
})
- assert.equal(result.summary.quota, 220)
- assert.equal(
- result.flow.nodes.some((node) => node.id === 'group:vip'),
+ expect(result.summary.quota).toBe(220)
+ expect(result.flow.nodes.some((node) => node.id === 'group:vip')).toBe(
false
)
})
@@ -576,35 +542,35 @@ describe('dashboard flow data', () => {
])
)
- assert.deepEqual(nodeState.get('user:1'), {
+ expect(nodeState.get('user:1')).toEqual({
highlighted: true,
dimmed: false,
})
- assert.deepEqual(nodeState.get('node:node-a'), {
+ expect(nodeState.get('node:node-a')).toEqual({
highlighted: true,
dimmed: false,
})
- assert.deepEqual(nodeState.get('model:gpt-4.1'), {
+ expect(nodeState.get('model:gpt-4.1')).toEqual({
highlighted: true,
dimmed: false,
})
- assert.deepEqual(nodeState.get('channel:101'), {
+ expect(nodeState.get('channel:101')).toEqual({
highlighted: true,
dimmed: false,
})
- assert.deepEqual(nodeState.get('user:2'), {
+ expect(nodeState.get('user:2')).toEqual({
highlighted: false,
dimmed: true,
})
- assert.deepEqual(linkState.get('user:1->node:node-a'), {
+ expect(linkState.get('user:1->node:node-a')).toEqual({
highlighted: true,
dimmed: false,
})
- assert.deepEqual(linkState.get('model:gpt-4.1->channel:101'), {
+ expect(linkState.get('model:gpt-4.1->channel:101')).toEqual({
highlighted: true,
dimmed: false,
})
- assert.deepEqual(linkState.get('model:claude-4-sonnet->channel:101'), {
+ expect(linkState.get('model:claude-4-sonnet->channel:101')).toEqual({
highlighted: false,
dimmed: true,
})
@@ -628,23 +594,23 @@ describe('dashboard flow data', () => {
])
)
- assert.deepEqual(linkState.get('model:gpt-4.1->channel:101'), {
+ expect(linkState.get('model:gpt-4.1->channel:101')).toEqual({
highlighted: true,
dimmed: false,
})
- assert.deepEqual(linkState.get('model:gpt-4.1->channel:102'), {
+ expect(linkState.get('model:gpt-4.1->channel:102')).toEqual({
highlighted: false,
dimmed: true,
})
- assert.deepEqual(nodeState.get('user:1'), {
+ expect(nodeState.get('user:1')).toEqual({
highlighted: true,
dimmed: false,
})
- assert.deepEqual(nodeState.get('node:node-a'), {
+ expect(nodeState.get('node:node-a')).toEqual({
highlighted: true,
dimmed: false,
})
- assert.deepEqual(nodeState.get('user:2'), {
+ expect(nodeState.get('user:2')).toEqual({
highlighted: false,
dimmed: true,
})
@@ -686,11 +652,11 @@ describe('dashboard flow data', () => {
(link) => link.source === 'user:2' && link.target === 'group:vip'
)
- assert.equal(sharedLink?.value, 150)
- assert.equal(sharedLink?.highlighted, true)
- assert.equal(sharedLink?.dimmed, false)
- assert.equal(inactiveUserLink?.highlighted, false)
- assert.equal(inactiveUserLink?.dimmed, true)
+ expect(sharedLink?.value).toBe(150)
+ expect(sharedLink?.highlighted).toBe(true)
+ expect(sharedLink?.dimmed).toBe(false)
+ expect(inactiveUserLink?.highlighted).toBe(false)
+ expect(inactiveUserLink?.dimmed).toBe(true)
})
test('does not emit highlight states without a visible active node', () => {
@@ -703,30 +669,26 @@ describe('dashboard flow data', () => {
activeNode: { kind: 'user', id: 'user:1' },
})
- assert.equal(
+ expect(
withoutActive.flow.nodes.every(
(node) => node.highlighted === undefined && node.dimmed === undefined
- ),
- true
- )
- assert.equal(
+ )
+ ).toBe(true)
+ expect(
withoutActive.flow.links.every(
(link) => link.highlighted === undefined && link.dimmed === undefined
- ),
- true
- )
- assert.equal(
+ )
+ ).toBe(true)
+ expect(
hiddenActive.flow.nodes.every(
(node) => node.highlighted === undefined && node.dimmed === undefined
- ),
- true
- )
- assert.equal(
+ )
+ ).toBe(true)
+ expect(
hiddenActive.flow.links.every(
(link) => link.highlighted === undefined && link.dimmed === undefined
- ),
- true
- )
+ )
+ ).toBe(true)
})
test('builds Sankey spec with quota token request tooltips', () => {
@@ -743,19 +705,19 @@ describe('dashboard flow data', () => {
link.source === 'user:1' && link.target === 'node:node-a'
)
- assert.equal(flowSpec.type, 'sankey')
- assert.equal(flowSpec.title.text, 'Flow')
- assert.deepEqual(flowSpec.emphasis, { enable: false })
- assert.equal(flowSpec.tooltip.mark.visible({ datum: aliceNode }), true)
- assert.equal(flowSpec.tooltip.mark.visible({ datum: userNodeLink }), true)
- assert.equal(flowSpec.animation, false)
- assert.equal(values.nodes.length, 6)
- assert.equal(values.links.length, 5)
- assert.equal(aliceNode.name, 'alice')
- assert.match(userNodeLink.linkColor, /^rgba\(/)
+ expect(flowSpec.type).toBe('sankey')
+ expect(flowSpec.title.text).toBe('Flow')
+ expect(flowSpec.emphasis).toEqual({ enable: false })
+ expect(flowSpec.tooltip.mark.visible({ datum: aliceNode })).toBe(true)
+ expect(flowSpec.tooltip.mark.visible({ datum: userNodeLink })).toBe(true)
+ expect(flowSpec.animation).toBe(false)
+ expect(values.nodes.length).toBe(6)
+ expect(values.links.length).toBe(5)
+ expect(aliceNode.name).toBe('alice')
+ expect(userNodeLink.linkColor).toMatch(/^rgba\(/)
const tooltipRows = flowSpec.tooltip.mark.content
- assert.deepEqual(
+ expect(
tooltipRows
.filter((row: Record) =>
typeof row.visible === 'function'
@@ -767,14 +729,13 @@ describe('dashboard flow data', () => {
typeof row.value === 'function'
? row.value({ datum: userNodeLink })
: row.value,
- ]),
- [
- ['Quota', '100'],
- ['Tokens', '40'],
- ['Requests', '2'],
- ['Share', '100.0%'],
- ]
- )
+ ])
+ ).toEqual([
+ ['Quota', '100'],
+ ['Tokens', '40'],
+ ['Requests', '2'],
+ ['Share', '100.0%'],
+ ])
})
test('maps active flow highlight states into the Sankey spec', () => {
@@ -801,15 +762,15 @@ describe('dashboard flow data', () => {
const nodeOpacity = flowSpec.node.style.fillOpacity
const linkOpacity = flowSpec.link.style.fillOpacity
- assert.deepEqual(flowSpec.emphasis, { enable: false })
- assert.equal(aliceNode.highlighted, true)
- assert.equal(bobNode.dimmed, true)
- assert.equal(highlightedLink.highlighted, true)
- assert.equal(dimmedLink.dimmed, true)
- assert.equal(nodeOpacity(aliceNode), 1)
- assert.equal(nodeOpacity(bobNode), 0.18)
- assert.equal(linkOpacity(highlightedLink), 0.86)
- assert.equal(linkOpacity(dimmedLink), 0.08)
- assert.equal(highlightedLink.zIndex > dimmedLink.zIndex, true)
+ expect(flowSpec.emphasis).toEqual({ enable: false })
+ expect(aliceNode.highlighted).toBe(true)
+ expect(bobNode.dimmed).toBe(true)
+ expect(highlightedLink.highlighted).toBe(true)
+ expect(dimmedLink.dimmed).toBe(true)
+ expect(nodeOpacity(aliceNode)).toBe(1)
+ expect(nodeOpacity(bobNode)).toBe(0.18)
+ expect(linkOpacity(highlightedLink)).toBe(0.86)
+ expect(linkOpacity(dimmedLink)).toBe(0.08)
+ expect(highlightedLink.zIndex > dimmedLink.zIndex).toBe(true)
})
})
diff --git a/web/src/features/keys/components/__tests__/api-key-group-cell.test.tsx b/web/src/features/keys/components/__tests__/api-key-group-cell.test.tsx
index 5cb64ae57f7b..2f07baa2fd75 100644
--- a/web/src/features/keys/components/__tests__/api-key-group-cell.test.tsx
+++ b/web/src/features/keys/components/__tests__/api-key-group-cell.test.tsx
@@ -16,39 +16,9 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { after, describe, test } from 'node:test'
-
-import { Window } from 'happy-dom'
-
-const domWindow = new Window()
-const domGlobals = [
- 'window',
- 'document',
- 'navigator',
- 'HTMLElement',
- 'HTMLButtonElement',
- 'SVGElement',
- 'Node',
- 'Element',
- 'Event',
- 'CustomEvent',
- 'MutationObserver',
- 'ResizeObserver',
- 'requestAnimationFrame',
- 'cancelAnimationFrame',
- 'getComputedStyle',
-] as const
-
-for (const key of domGlobals) {
- Object.defineProperty(globalThis, key, {
- configurable: true,
- value: domWindow[key],
- })
-}
+import { render } from '@testing-library/react'
+import { describe, expect, test } from 'vitest'
-const { act } = await import('react')
-const { createRoot } = await import('react-dom/client')
const { createInstance } = await import('i18next')
const { I18nextProvider, initReactI18next } = await import('react-i18next')
const { TooltipProvider } = await import('@/components/ui/tooltip')
@@ -70,11 +40,6 @@ await i18n.use(initReactI18next).init({
},
})
-const reactTestGlobals = globalThis as typeof globalThis & {
- IS_REACT_ACT_ENVIRONMENT?: boolean
-}
-reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true
-
function CellHarness(props: {
group: string
ratio?: number | string
@@ -96,141 +61,93 @@ function CellHarness(props: {
}
describe('API key group table cell', () => {
- after(() => {
- domWindow.close()
- })
-
- test('renders two unclipped rings and a localized Auto ratio when API data uses a nonlocalized string', async () => {
- const container = document.createElement('div')
- document.body.append(container)
- const root = createRoot(container)
-
- await act(async () =>
- root.render(
-
- )
+ test('renders an unclipped ring and a localized Auto ratio when API data uses a nonlocalized string', () => {
+ const { container } = render(
+
)
const badgeCell = container.querySelector(
'[data-api-key-group-cell="auto"]'
)
- assert.ok(badgeCell)
- assert.equal(badgeCell.classList.contains('overflow-visible'), true)
- assert.equal(badgeCell.classList.contains('overflow-hidden'), false)
+ expect(badgeCell).toHaveClass('overflow-visible')
+ expect(badgeCell).not.toHaveClass('overflow-hidden')
const frames = container.querySelectorAll('[data-auto-group-frame]')
const movingRings = container.querySelectorAll(
'[data-auto-group-flow-border]'
)
- assert.equal(frames.length, 2)
- assert.equal(movingRings.length, 2)
+ expect(frames.length).toBe(1)
+ expect(movingRings.length).toBe(1)
for (const frame of frames) {
- assert.equal(frame.classList.contains('relative'), true)
- assert.equal(frame.classList.contains('overflow-visible'), true)
- assert.equal(frame.classList.contains('rounded-4xl'), true)
- assert.equal(frame.classList.contains('p-px'), true)
+ expect(frame).toHaveClass(
+ 'relative',
+ 'overflow-visible',
+ 'rounded-4xl',
+ 'p-px'
+ )
}
const ratio = container.querySelector(
'[data-auto-group-effect="ratio"]'
)
- assert.ok(ratio)
- assert.equal(ratio.textContent, 'Auto Ratio')
- assert.equal(ratio.textContent?.includes('x'), false)
- assert.equal(container.textContent?.includes('自动'), false)
- assert.equal(container.textContent?.includes('Cross-group'), true)
+ expect(ratio).toHaveTextContent('Auto Ratio')
+ expect(ratio).not.toHaveTextContent('x')
+ expect(container).not.toHaveTextContent('自动')
+ expect(container).toHaveTextContent('Cross-group')
const crossGroupBadge = [
...container.querySelectorAll('[data-slot="status-badge"]'),
].find((badge) => badge.textContent === 'Cross-group')
- assert.ok(crossGroupBadge)
- assert.equal(crossGroupBadge.closest('[data-auto-group-frame]'), null)
-
- await act(async () => root.unmount())
- container.remove()
+ expect(crossGroupBadge).not.toBeUndefined()
+ expect(crossGroupBadge?.closest('[data-auto-group-frame]')).toBeNull()
})
- test('keeps static Auto frames but omits both moving layers for reduced motion', async () => {
- const container = document.createElement('div')
- document.body.append(container)
- const root = createRoot(container)
-
- await act(async () =>
- root.render( )
+ test('keeps the static Auto ratio frame but omits its moving layer for reduced motion', () => {
+ const { container } = render(
+
)
- assert.equal(
- container.querySelectorAll('[data-auto-group-frame]').length,
- 2
- )
- assert.equal(
- container.querySelectorAll('[data-auto-group-flow-border]').length,
- 0
- )
-
- await act(async () => root.unmount())
- container.remove()
+ expect(container.querySelectorAll('[data-auto-group-frame]').length).toBe(1)
+ expect(
+ container.querySelectorAll('[data-auto-group-flow-border]').length
+ ).toBe(0)
})
- test('shows only the Auto badge when ratio data is unavailable', async () => {
- const container = document.createElement('div')
- document.body.append(container)
- const root = createRoot(container)
-
- await act(async () =>
- root.render( )
+ test('shows only the cross-group badge when ratio data is unavailable', () => {
+ const { container } = render(
+
)
- assert.equal(
- container.querySelectorAll('[data-auto-group-frame]').length,
- 1
- )
- assert.equal(
- container.querySelectorAll('[data-auto-group-flow-border]').length,
- 1
- )
- assert.equal(
- container.querySelector('[data-auto-group-effect="ratio"]'),
+ expect(container.querySelectorAll('[data-auto-group-frame]').length).toBe(0)
+ expect(
+ container.querySelectorAll('[data-auto-group-flow-border]').length
+ ).toBe(0)
+ expect(container.querySelector('[data-auto-group-effect="ratio"]')).toBe(
null
)
- assert.equal(container.textContent?.includes('Auto'), true)
- assert.equal(container.textContent?.includes('Ratio'), false)
-
- await act(async () => root.unmount())
- container.remove()
+ expect(container).toHaveTextContent('Cross-group')
+ expect(container).not.toHaveTextContent('Auto')
+ expect(container).not.toHaveTextContent('Ratio')
})
- test('narrows normal group ratios to numbers and never applies Auto rings', async () => {
- const container = document.createElement('div')
- document.body.append(container)
- const root = createRoot(container)
-
- await act(async () =>
- root.render(
-
- )
+ test('narrows normal group ratios to numbers and never applies Auto rings', () => {
+ const { container, rerender } = render(
+
)
- assert.equal(container.textContent?.includes('vip'), true)
- assert.equal(container.textContent?.includes('自动'), false)
- assert.equal(container.querySelector('[data-auto-group-frame]'), null)
- assert.equal(container.querySelector('[data-auto-group-flow-border]'), null)
-
- await act(async () =>
- root.render(
-
- )
- )
+ expect(container).toHaveTextContent('vip')
+ expect(container).not.toHaveTextContent('自动')
+ expect(container.querySelector('[data-auto-group-frame]')).toBe(null)
+ expect(container.querySelector('[data-auto-group-flow-border]')).toBe(null)
- assert.equal(container.textContent?.includes('3x'), true)
- assert.equal(container.querySelector('[data-auto-group-frame]'), null)
+ rerender( )
- await act(async () => root.unmount())
- container.remove()
+ expect(container).toHaveTextContent('3x')
+ expect(container.querySelector('[data-auto-group-frame]')).toBe(null)
})
})
diff --git a/web/src/features/keys/components/__tests__/api-key-group-combobox.test.tsx b/web/src/features/keys/components/__tests__/api-key-group-combobox.test.tsx
index 5c3b6525e481..7833c52c2c12 100644
--- a/web/src/features/keys/components/__tests__/api-key-group-combobox.test.tsx
+++ b/web/src/features/keys/components/__tests__/api-key-group-combobox.test.tsx
@@ -16,58 +16,26 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { after, describe, test } from 'node:test'
-
-import { Window } from 'happy-dom'
-
-const domWindow = new Window()
-const domGlobals = [
- 'window',
- 'document',
- 'navigator',
- 'HTMLElement',
- 'HTMLButtonElement',
- 'HTMLInputElement',
- 'SVGElement',
- 'Node',
- 'Element',
- 'Event',
- 'KeyboardEvent',
- 'PointerEvent',
- 'CustomEvent',
- 'MutationObserver',
- 'ResizeObserver',
- 'requestAnimationFrame',
- 'cancelAnimationFrame',
- 'getComputedStyle',
-] as const
-
-for (const key of domGlobals) {
- Object.defineProperty(globalThis, key, {
- configurable: true,
- value: domWindow[key],
- })
-}
+import { fireEvent, render, screen, within } from '@testing-library/react'
+import { describe, expect, test } from 'vitest'
let shouldReduceMotion = false
-const reducedMotionMediaQuery = domWindow.matchMedia('(prefers-reduced-motion)')
+const reducedMotionMediaQuery = window.matchMedia('(prefers-reduced-motion)')
Object.defineProperty(reducedMotionMediaQuery, 'matches', {
configurable: true,
get: () => shouldReduceMotion,
})
-Object.defineProperty(domWindow, 'matchMedia', {
+Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: () => reducedMotionMediaQuery,
})
function setReducedMotion(value: boolean) {
shouldReduceMotion = value
- reducedMotionMediaQuery.dispatchEvent(new domWindow.Event('change'))
+ reducedMotionMediaQuery.dispatchEvent(new Event('change'))
}
-const { act, useState } = await import('react')
-const { createRoot } = await import('react-dom/client')
+const { useState } = await import('react')
const { createInstance } = await import('i18next')
const { I18nextProvider, initReactI18next } = await import('react-i18next')
const { ApiKeyGroupCombobox } = await import('../api-key-group-combobox')
@@ -88,11 +56,6 @@ await i18n.use(initReactI18next).init({
},
})
-const reactTestGlobals = globalThis as typeof globalThis & {
- IS_REACT_ACT_ENVIRONMENT?: boolean
-}
-reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true
-
const options = [
{
value: 'auto',
@@ -119,176 +82,133 @@ function Harness(props: { initialValue: string }) {
)
}
-function getTrigger(container: ParentNode): HTMLButtonElement {
- const trigger = container.querySelector(
- 'button[role="combobox"]'
- )
- assert.ok(trigger)
- return trigger
+function getTrigger(): HTMLButtonElement {
+ return screen.getByRole('combobox')
}
function getCommandItem(label: string): HTMLElement {
const item = [
...document.querySelectorAll('[data-slot="command-item"]'),
].find((candidate) => candidate.textContent?.includes(label))
- assert.ok(item)
+ if (!item) {
+ throw new Error(`Expected command item containing "${label}"`)
+ }
return item
}
describe('API key group combobox Auto effect', () => {
- after(() => {
- domWindow.close()
- })
-
- test('rings the selected Auto trigger and its localized ratio without rendering the API ratio text', async () => {
+ test('rings the selected Auto trigger and its localized ratio without rendering the API ratio text', () => {
setReducedMotion(false)
- const container = document.createElement('div')
- document.body.append(container)
- const root = createRoot(container)
+ render( )
- await act(async () => root.render( ))
-
- const trigger = getTrigger(container)
- assert.equal(trigger.getAttribute('aria-expanded'), 'false')
- assert.equal(trigger.dataset.autoGroupEffect, 'trigger')
- assert.equal(trigger.classList.contains('bg-linear-to-r'), false)
- assert.equal(trigger.classList.contains('overflow-hidden'), false)
- assert.equal(trigger.classList.contains('overflow-visible'), true)
+ const trigger = getTrigger()
+ expect(trigger).toHaveAttribute('aria-expanded', 'false')
+ expect(trigger).toHaveAttribute('data-auto-group-effect', 'trigger')
+ expect(trigger).not.toHaveClass('bg-linear-to-r', 'overflow-hidden')
+ expect(trigger).toHaveClass('overflow-visible')
const triggerFlowBorder = trigger.querySelector(
'[data-auto-group-flow-border]'
)
- assert.ok(triggerFlowBorder)
- assert.equal(triggerFlowBorder.getAttribute('aria-hidden'), 'true')
- assert.equal(
- triggerFlowBorder.classList.contains('pointer-events-none'),
- true
- )
- assert.equal(
- triggerFlowBorder.classList.contains('auto-group-flow-border'),
- true
+ expect(triggerFlowBorder).toHaveAttribute('aria-hidden', 'true')
+ expect(triggerFlowBorder).toHaveClass(
+ 'pointer-events-none',
+ 'auto-group-flow-border'
)
const triggerRatio = trigger.querySelector(
'[data-auto-group-effect="ratio"]'
)
- assert.ok(triggerRatio)
- assert.equal(triggerRatio.textContent, 'Auto Ratio')
- assert.equal(triggerRatio.textContent?.includes('Auto'), true)
- assert.equal(triggerRatio.textContent?.includes('x'), false)
- assert.equal(trigger.textContent?.includes('自动'), false)
- assert.equal(triggerRatio.classList.contains('relative'), true)
- assert.equal(triggerRatio.classList.contains('overflow-visible'), true)
- assert.equal(triggerRatio.classList.contains('rounded-4xl'), true)
- assert.ok(triggerRatio.querySelector('[data-auto-group-flow-border]'))
+ expect(triggerRatio).toHaveTextContent('Auto Ratio')
+ expect(triggerRatio).not.toHaveTextContent('x')
+ expect(trigger).not.toHaveTextContent('自动')
+ expect(triggerRatio).toHaveClass(
+ 'relative',
+ 'overflow-visible',
+ 'rounded-4xl'
+ )
+ expect(
+ triggerRatio?.querySelector('[data-auto-group-flow-border]')
+ ).toBeInTheDocument()
- await act(async () => trigger.click())
- assert.equal(trigger.getAttribute('aria-expanded'), 'true')
+ fireEvent.click(trigger)
+ expect(trigger).toHaveAttribute('aria-expanded', 'true')
const autoOption = getCommandItem('Global automatic routing')
- assert.equal(autoOption.dataset.autoGroupEffect, 'option')
- assert.equal(autoOption.getAttribute('aria-selected'), 'true')
- assert.equal(autoOption.classList.contains('bg-linear-to-r'), false)
- assert.equal(autoOption.classList.contains('overflow-visible'), true)
- assert.ok(autoOption.querySelector('[data-auto-group-flow-border]'))
+ expect(autoOption).toHaveAttribute('data-auto-group-effect', 'option')
+ expect(autoOption).toHaveAttribute('aria-selected', 'true')
+ expect(autoOption).not.toHaveClass('bg-linear-to-r')
+ expect(autoOption).toHaveClass('overflow-visible')
+ expect(
+ autoOption.querySelector('[data-auto-group-flow-border]')
+ ).toBeInTheDocument()
const optionRatio = autoOption.querySelector(
'[data-auto-group-effect="ratio"]'
)
- assert.ok(optionRatio)
- assert.equal(optionRatio.textContent, 'Auto Ratio')
- assert.ok(optionRatio.querySelector('[data-auto-group-flow-border]'))
+ expect(optionRatio).toHaveTextContent('Auto Ratio')
+ expect(
+ optionRatio?.querySelector('[data-auto-group-flow-border]')
+ ).toBeInTheDocument()
const defaultOption = getCommandItem('User group')
- assert.equal(defaultOption.hasAttribute('data-auto-group-effect'), false)
- assert.equal(
- defaultOption.querySelector('[data-auto-group-flow-border]'),
+ expect(defaultOption).not.toHaveAttribute('data-auto-group-effect')
+ expect(defaultOption.querySelector('[data-auto-group-flow-border]')).toBe(
null
)
- assert.equal(defaultOption.textContent?.includes('1x Ratio'), true)
- assert.equal(
- defaultOption.querySelector('[data-auto-group-effect="ratio"]'),
- null
- )
-
- await act(async () => root.unmount())
- container.remove()
+ expect(defaultOption).toHaveTextContent('1x Ratio')
+ expect(
+ defaultOption.querySelector('[data-auto-group-effect="ratio"]')
+ ).toBe(null)
})
test('keeps search and selection behavior while leaving normal groups unstyled', async () => {
setReducedMotion(false)
- const container = document.createElement('div')
- document.body.append(container)
- const root = createRoot(container)
-
- await act(async () => root.render( ))
+ const { container } = render( )
- const trigger = getTrigger(container)
- await act(async () => trigger.click())
+ const trigger = getTrigger()
+ fireEvent.click(trigger)
- const searchInput = document.querySelector(
- 'input[placeholder="Search..."]'
- )
- assert.ok(searchInput)
- await act(async () => {
- const valueSetter = Object.getOwnPropertyDescriptor(
- domWindow.HTMLInputElement.prototype,
- 'value'
- )?.set
- assert.ok(valueSetter)
- valueSetter.call(searchInput, 'vip')
- searchInput.dispatchEvent(
- new domWindow.Event('input', { bubbles: true }) as unknown as Event
- )
+ fireEvent.input(screen.getByPlaceholderText('Search...'), {
+ target: { value: 'vip' },
})
const visibleOptions = [
...document.querySelectorAll('[data-slot="command-item"]'),
]
- assert.equal(
+ expect(
visibleOptions.some((option) =>
option.textContent?.includes('Global automatic routing')
- ),
- false
- )
+ )
+ ).toBe(false)
const vipOption = getCommandItem('Priority group')
- await act(async () => vipOption.click())
+ fireEvent.click(vipOption)
- assert.equal(
- container.querySelector('[data-testid="selected-group"]')?.textContent,
+ expect(within(container).getByTestId('selected-group')).toHaveTextContent(
'vip'
)
- assert.equal(trigger.getAttribute('aria-expanded'), 'false')
- assert.equal(trigger.hasAttribute('data-auto-group-effect'), false)
- assert.equal(trigger.querySelector('[data-auto-group-flow-border]'), null)
-
- await act(async () => root.unmount())
- container.remove()
+ expect(trigger).toHaveAttribute('aria-expanded', 'false')
+ expect(trigger).not.toHaveAttribute('data-auto-group-effect')
+ expect(trigger.querySelector('[data-auto-group-flow-border]')).toBe(null)
})
test('preserves the static Auto treatment but omits moving layers for reduced motion', async () => {
setReducedMotion(true)
- const container = document.createElement('div')
- document.body.append(container)
- const root = createRoot(container)
-
- await act(async () => root.render( ))
+ render( )
- const trigger = getTrigger(container)
- assert.equal(trigger.dataset.autoGroupEffect, 'trigger')
- assert.equal(trigger.querySelector('[data-auto-group-flow-border]'), null)
- assert.ok(trigger.querySelector('[data-auto-group-effect="ratio"]'))
+ const trigger = getTrigger()
+ expect(trigger).toHaveAttribute('data-auto-group-effect', 'trigger')
+ expect(trigger.querySelector('[data-auto-group-flow-border]')).toBe(null)
+ expect(
+ trigger.querySelector('[data-auto-group-effect="ratio"]')
+ ).toBeInTheDocument()
- await act(async () => trigger.click())
+ fireEvent.click(trigger)
const autoOption = getCommandItem('Global automatic routing')
- assert.equal(autoOption.dataset.autoGroupEffect, 'option')
- assert.equal(
- autoOption.querySelector('[data-auto-group-flow-border]'),
- null
- )
- assert.ok(autoOption.querySelector('[data-auto-group-effect="ratio"]'))
-
- await act(async () => root.unmount())
- container.remove()
+ expect(autoOption).toHaveAttribute('data-auto-group-effect', 'option')
+ expect(autoOption.querySelector('[data-auto-group-flow-border]')).toBe(null)
+ expect(
+ autoOption.querySelector('[data-auto-group-effect="ratio"]')
+ ).toBeInTheDocument()
setReducedMotion(false)
})
})
diff --git a/web/src/features/keys/components/__tests__/api-keys-mutate-drawer.test.tsx b/web/src/features/keys/components/__tests__/api-keys-mutate-drawer.test.tsx
index 238d0b21d4d0..e57fcbfa1222 100644
--- a/web/src/features/keys/components/__tests__/api-keys-mutate-drawer.test.tsx
+++ b/web/src/features/keys/components/__tests__/api-keys-mutate-drawer.test.tsx
@@ -16,45 +16,9 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { after, afterEach, describe, test } from 'node:test'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, test } from 'vitest'
-import { Window } from 'happy-dom'
-
-const domWindow = new Window()
-const domGlobals = [
- 'window',
- 'document',
- 'navigator',
- 'HTMLElement',
- 'HTMLButtonElement',
- 'HTMLInputElement',
- 'HTMLFormElement',
- '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 { createInstance } = await import('i18next')
const { I18nextProvider, initReactI18next } = await import('react-i18next')
const { QueryClient, QueryClientProvider } =
@@ -69,20 +33,13 @@ await i18n.use(initReactI18next).init({
resources: { en: { translation: {} } },
})
-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
post: ApiMethod
}
type RenderedDrawer = {
- host: HTMLDivElement
queryClient: InstanceType
- root: ReturnType
}
const apiClient = api as unknown as MockableApi
@@ -120,44 +77,14 @@ function installApiFixtures(createdPayloads: Array>) {
}
}
apiClient.post = async (url, data) => {
- assert.equal(url, '/api/token/')
- assert.ok(data && typeof data === 'object')
+ expect(url).toBe('/api/token/')
+ expect(data && typeof data === 'object').toBeTruthy()
createdPayloads.push(data as Record)
return { data: { success: true, data: {} } }
}
}
-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,
- })
- })
-}
-
async function renderCreateDrawer(): Promise {
- const host = document.createElement('div')
- document.body.append(host)
- const root = createRoot(host)
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
@@ -192,43 +119,49 @@ async function renderCreateDrawer(): Promise {
},
{ updatedAt: freshAt }
)
- renderedDrawer = { host, queryClient, root }
-
- await act(async () =>
- root.render(
-
-
-
- undefined} />
-
-
-
- )
+ renderedDrawer = { queryClient }
+
+ render(
+
+
+
+ undefined} />
+
+
+
)
- await act(async () =>
- waitForCondition(() => {
+ await waitFor(
+ () => {
const saveButton = findButton('Save changes', false)
- return saveButton !== null && !saveButton.disabled
- }, 'API key drawer did not finish initializing')
+ expect(saveButton).toBeEnabled()
+ },
+ { timeout: 1500 }
)
}
function findButton(text: string, required: true): HTMLButtonElement
function findButton(text: string, required: false): HTMLButtonElement | null
function findButton(text: string, required = true): HTMLButtonElement | null {
- const button = [
- ...document.querySelectorAll('button'),
- ].find((candidate) => candidate.textContent?.includes(text))
- if (required) assert.ok(button, `Expected button containing "${text}"`)
+ const button = screen
+ .queryAllByRole('button')
+ .find((candidate) => candidate.textContent?.includes(text))
+ if (required && !button) {
+ throw new Error(`Expected button containing "${text}"`)
+ }
return button ?? null
}
-function getControlByLabel(labelText: string): T {
+function getControlByLabel(labelText: 'Name' | 'Quantity'): HTMLInputElement
+function getControlByLabel(labelText: 'Group'): HTMLButtonElement
+function getControlByLabel(labelText: 'Auto group order'): HTMLElement
+function getControlByLabel(labelText: string): HTMLElement {
const label = [...document.querySelectorAll('label')].find(
(candidate) => candidate.textContent?.trim() === labelText
)
- assert.ok(label, `Expected label "${labelText}"`)
- assert.ok(label.htmlFor)
+ if (!label) {
+ throw new Error(`Expected label "${labelText}"`)
+ }
+
const control =
label.control ??
label
@@ -236,51 +169,38 @@ function getControlByLabel(labelText: string): T {
?.querySelector(
'[data-slot="form-control"], input, textarea, button[role="combobox"], [role="group"]'
)
- assert.ok(control)
- return control as T
+ if (!control) {
+ throw new Error(`Expected control for label "${labelText}"`)
+ }
+ return control
}
-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
- )
- })
+function changeInput(input: HTMLInputElement, value: string): void {
+ fireEvent.input(input, { target: { value } })
}
-async function selectComboboxOption(
+function selectComboboxOption(
trigger: HTMLButtonElement,
optionDescription: string
-) {
- await act(async () => trigger.click())
+): void {
+ fireEvent.click(trigger)
const option = [
...document.querySelectorAll('[data-slot="command-item"]'),
].find((candidate) => candidate.textContent?.includes(optionDescription))
- assert.ok(option, `Expected option containing "${optionDescription}"`)
- await act(async () => option.click())
+ if (!option) {
+ throw new Error(`Expected option containing "${optionDescription}"`)
+ }
+ fireEvent.click(option)
}
-afterEach(async () => {
+afterEach(() => {
apiClient.get = originalGet
apiClient.post = originalPost
- domWindow.localStorage.clear()
+ localStorage.clear()
if (renderedDrawer) {
- await act(async () => renderedDrawer?.root.unmount())
renderedDrawer.queryClient.clear()
- renderedDrawer.host.remove()
renderedDrawer = null
}
- document.body.replaceChildren()
-})
-
-after(() => {
- domWindow.close()
})
describe('API keys mutate drawer Auto group integration', () => {
@@ -289,38 +209,31 @@ describe('API keys mutate drawer Auto group integration', () => {
installApiFixtures(createdPayloads)
await renderCreateDrawer()
- const groupTrigger = getControlByLabel('Group')
- assert.equal(groupTrigger.textContent?.includes('auto'), true)
- assert.equal(
+ const groupTrigger = getControlByLabel('Group')
+ expect(groupTrigger.textContent?.includes('auto')).toBe(true)
+ expect(
document.body.textContent?.includes(
'Using the complete global Auto order (2 groups)'
- ),
- true
- )
- assert.deepEqual(
+ )
+ ).toBe(true)
+ expect(
[
...document.querySelectorAll('[data-slot="global-auto-order-name"]'),
- ].map((item) => item.textContent),
- ['vip', 'default']
- )
- assert.equal(findButton('Restore global Auto', true).disabled, true)
+ ].map((item) => item.textContent)
+ ).toEqual(['vip', 'default'])
+ expect(findButton('Restore global Auto', true).disabled).toBe(true)
- await changeInput(getControlByLabel('Name'), 'batch')
- await changeInput(getControlByLabel('Quantity'), '2')
- await act(async () => findButton('Save changes', true).click())
- await act(async () =>
- waitForCondition(
- () => createdPayloads.length === 2,
- 'batch API keys were not created'
- )
- )
+ changeInput(getControlByLabel('Name'), 'batch')
+ changeInput(getControlByLabel('Quantity'), '2')
+ fireEvent.click(findButton('Save changes', true))
+ await waitFor(() => expect(createdPayloads).toHaveLength(2))
- assert.equal(createdPayloads.length, 2)
- assert.equal(createdPayloads[0]?.name, 'batch')
+ expect(createdPayloads.length).toBe(2)
+ expect(createdPayloads[0]?.name).toBe('batch')
for (const payload of createdPayloads) {
- assert.equal(payload.group, 'auto')
- assert.deepEqual(payload.auto_groups, [])
- assert.equal(payload.cross_group_retry, true)
+ expect(payload.group).toBe('auto')
+ expect(payload.auto_groups).toEqual([])
+ expect(payload.cross_group_retry).toBe(true)
}
})
@@ -329,43 +242,39 @@ describe('API keys mutate drawer Auto group integration', () => {
installApiFixtures(createdPayloads)
await renderCreateDrawer()
- const autoOrderControl = getControlByLabel('Auto group order')
+ const autoOrderControl = getControlByLabel('Auto group order')
const addGroupTrigger = autoOrderControl.querySelector(
'button[role="combobox"]'
)
- assert.ok(addGroupTrigger)
- await selectComboboxOption(addGroupTrigger, 'Priority access')
+ if (!addGroupTrigger) {
+ throw new Error('Expected Auto group order combobox')
+ }
+ selectComboboxOption(addGroupTrigger, 'Priority access')
- assert.ok(document.querySelector('button[aria-label="Remove vip"]'))
- assert.equal(
- document.body.textContent?.includes('1 / 3 groups selected'),
+ expect(
+ document.querySelector('button[aria-label="Remove vip"]')
+ ).toBeTruthy()
+ expect(document.body.textContent?.includes('1 / 3 groups selected')).toBe(
true
)
- assert.equal(findButton('Restore global Auto', true).disabled, false)
+ expect(findButton('Restore global Auto', true).disabled).toBe(false)
- const groupTrigger = getControlByLabel('Group')
- await selectComboboxOption(groupTrigger, 'Standard access')
- assert.equal(
- document.querySelector('button[aria-label="Remove vip"]'),
- null
- )
- await selectComboboxOption(groupTrigger, 'Automatic routing')
+ const groupTrigger = getControlByLabel('Group')
+ selectComboboxOption(groupTrigger, 'Standard access')
+ expect(document.querySelector('button[aria-label="Remove vip"]')).toBe(null)
+ selectComboboxOption(groupTrigger, 'Automatic routing')
- assert.ok(document.querySelector('button[aria-label="Remove vip"]'))
- assert.equal(
- document.body.textContent?.includes('1 / 3 groups selected'),
+ expect(
+ document.querySelector('button[aria-label="Remove vip"]')
+ ).toBeTruthy()
+ expect(document.body.textContent?.includes('1 / 3 groups selected')).toBe(
true
)
- assert.equal(findButton('Restore global Auto', true).disabled, false)
+ expect(findButton('Restore global Auto', true).disabled).toBe(false)
- await changeInput(getControlByLabel('Name'), 'custom')
- await act(async () => findButton('Save changes', true).click())
- await act(async () =>
- waitForCondition(
- () => createdPayloads.length === 1,
- 'custom-order API key was not created'
- )
- )
- assert.deepEqual(createdPayloads[0]?.auto_groups, ['vip'])
+ changeInput(getControlByLabel('Name'), 'custom')
+ fireEvent.click(findButton('Save changes', true))
+ await waitFor(() => expect(createdPayloads).toHaveLength(1))
+ expect(createdPayloads[0]?.auto_groups).toEqual(['vip'])
})
})
diff --git a/web/src/features/keys/components/__tests__/auto-group-order-editor.test.tsx b/web/src/features/keys/components/__tests__/auto-group-order-editor.test.tsx
index f37f50755f2d..3df0d191b74e 100644
--- a/web/src/features/keys/components/__tests__/auto-group-order-editor.test.tsx
+++ b/web/src/features/keys/components/__tests__/auto-group-order-editor.test.tsx
@@ -16,42 +16,10 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { after, describe, test } from 'node:test'
-
-import { Window } from 'happy-dom'
-
-const domWindow = new Window()
-const domGlobals = [
- 'window',
- 'document',
- 'navigator',
- 'HTMLElement',
- 'HTMLButtonElement',
- 'HTMLInputElement',
- 'SVGElement',
- 'Node',
- 'Element',
- 'Event',
- 'KeyboardEvent',
- 'PointerEvent',
- 'CustomEvent',
- 'MutationObserver',
- 'ResizeObserver',
- 'requestAnimationFrame',
- 'cancelAnimationFrame',
- 'getComputedStyle',
-] as const
-
-for (const key of domGlobals) {
- Object.defineProperty(globalThis, key, {
- configurable: true,
- value: domWindow[key],
- })
-}
+import { fireEvent, render, within } from '@testing-library/react'
+import { describe, expect, test } from 'vitest'
-const { act, useState } = await import('react')
-const { createRoot } = await import('react-dom/client')
+const { useState } = await import('react')
const { createInstance } = await import('i18next')
const { I18nextProvider, initReactI18next } = await import('react-i18next')
const { AutoGroupOrderEditor } = await import('../auto-group-order-editor')
@@ -90,11 +58,6 @@ await i18n.use(initReactI18next).init({
},
})
-const reactTestGlobals = globalThis as typeof globalThis & {
- IS_REACT_ACT_ENVIRONMENT?: boolean
-}
-reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true
-
const globalOptions = [
{ value: 'vip', label: 'VIP', desc: 'Priority access', ratio: 3 },
{ value: 'default', label: 'Default', desc: 'Standard access', ratio: 1 },
@@ -176,163 +139,104 @@ function CustomEmptyHarness() {
)
}
-function findButton(container: ParentNode, label: string): HTMLButtonElement {
- const button = container.querySelector(
- `button[aria-label="${label}"]`
- )
- assert.ok(button)
- return button
+function findButton(container: HTMLElement, label: string): HTMLButtonElement {
+ return within(container).getByRole('button', { name: label })
}
-describe('Auto group order editor', () => {
- after(() => {
- domWindow.close()
- })
-
- test('enforces the limit and exposes accessible reorder controls', async () => {
- const container = document.createElement('div')
- document.body.append(container)
- const root = createRoot(container)
-
- await act(async () => root.render( ))
-
- const addButton = container.querySelector(
- 'button[role="combobox"]'
- )
- assert.ok(addButton)
- assert.equal(addButton.disabled, true)
- assert.equal(container.textContent?.includes('2 / 2 groups selected'), true)
- assert.ok(
- container.querySelector('[role="group"][aria-label="Auto group order"]')
- )
- assert.equal(
- findButton(container, 'Drag default to reorder').type,
- 'button'
- )
+function getCommandItem(label: string): HTMLElement {
+ const item = [
+ ...document.querySelectorAll('[data-slot="command-item"]'),
+ ].find((candidate) => candidate.textContent?.includes(label))
+ if (!item) {
+ throw new Error(`Expected command item containing "${label}"`)
+ }
+ return item
+}
- await act(async () => findButton(container, 'Move default down').click())
- assert.equal(
- container.querySelector('[data-testid="order"]')?.textContent,
+describe('Auto group order editor', () => {
+ test('enforces the limit and exposes accessible reorder controls', () => {
+ const { container } = render( )
+
+ const addButton = within(container).getByRole('combobox')
+ expect(addButton).toBeDisabled()
+ expect(container).toHaveTextContent('2 / 2 groups selected')
+ expect(
+ within(container).getByRole('group', { name: 'Auto group order' })
+ ).toBeInTheDocument()
+ expect(findButton(container, 'Drag default to reorder').type).toBe('button')
+
+ fireEvent.click(findButton(container, 'Move default down'))
+ expect(within(container).getByTestId('order')).toHaveTextContent(
'vip,default'
)
- await act(async () => {
- findButton(container, 'Drag vip to reorder').dispatchEvent(
- new domWindow.KeyboardEvent('keydown', {
- key: 'ArrowDown',
- bubbles: true,
- }) as unknown as KeyboardEvent
- )
+ fireEvent.keyDown(findButton(container, 'Drag vip to reorder'), {
+ key: 'ArrowDown',
})
- assert.equal(
- container.querySelector('[data-testid="order"]')?.textContent,
+ expect(within(container).getByTestId('order')).toHaveTextContent(
'default,vip'
)
-
- await act(async () => root.unmount())
- container.remove()
})
- test('adds and removes groups, then restores inheritance as an empty value', async () => {
- const container = document.createElement('div')
- document.body.append(container)
- const root = createRoot(container)
+ test('adds and removes groups, then restores inheritance as an empty value', () => {
+ const { container } = render( )
+ fireEvent.click(findButton(container, 'Remove vip'))
- await act(async () => root.render( ))
- await act(async () => findButton(container, 'Remove vip').click())
+ expect(within(container).getByTestId('order')).toHaveTextContent('default')
+ const addButton = within(container).getByRole('combobox')
+ expect(addButton).toBeEnabled()
- assert.equal(
- container.querySelector('[data-testid="order"]')?.textContent,
- 'default'
- )
- const addButton = container.querySelector(
- 'button[role="combobox"]'
- )
- assert.ok(addButton)
- assert.equal(addButton.disabled, false)
-
- await act(async () => addButton.click())
- const teamOption = [
- ...document.querySelectorAll('[data-slot="command-item"]'),
- ].find((option) => option.textContent?.includes('team'))
- assert.ok(teamOption)
- await act(async () => teamOption.click())
- assert.equal(
- container.querySelector('[data-testid="order"]')?.textContent,
+ fireEvent.click(addButton)
+ fireEvent.click(getCommandItem('team'))
+ expect(within(container).getByTestId('order')).toHaveTextContent(
'default,team'
)
- assert.equal(addButton.disabled, true)
+ expect(addButton).toBeDisabled()
- const restoreButton = [...container.querySelectorAll('button')].find(
- (button) => button.textContent?.includes('Restore global Auto')
+ fireEvent.click(
+ within(container).getByRole('button', { name: 'Restore global Auto' })
)
- assert.ok(restoreButton)
- await act(async () => restoreButton.click())
- assert.equal(
- container.querySelector('[data-testid="order"]')?.textContent,
- ''
- )
- assert.equal(
- container.querySelector('[data-testid="mode"]')?.textContent,
- 'inherit'
- )
- assert.equal(
- container.textContent?.includes(
- 'Using the complete global Auto order (3 groups)'
- ),
- true
+ expect(within(container).getByTestId('order')).toBeEmptyDOMElement()
+ expect(within(container).getByTestId('mode')).toHaveTextContent('inherit')
+ expect(container).toHaveTextContent(
+ 'Using the complete global Auto order (3 groups)'
)
const inheritedItems = container.querySelectorAll(
'[data-slot="global-auto-order"] > li'
)
- assert.deepEqual(
+ expect(
[...inheritedItems].map(
(item) =>
item.querySelector('[data-slot="global-auto-order-name"]')
?.textContent
- ),
- ['VIP', 'Default', 'Team']
- )
-
- await act(async () => root.unmount())
- container.remove()
+ )
+ ).toEqual(['VIP', 'Default', 'Team'])
})
- test('shows the complete inherited order with metadata beyond the custom limit', async () => {
- const container = document.createElement('div')
- document.body.append(container)
- const root = createRoot(container)
+ test('shows the complete inherited order with metadata beyond the custom limit', () => {
+ const { container } = render( )
- await act(async () => root.render( ))
-
- assert.equal(
- container.textContent?.includes(
- 'Using the complete global Auto order (3 groups)'
- ),
- true
- )
- assert.equal(
- container.textContent?.includes('0 / 2 groups selected'),
- false
+ expect(container).toHaveTextContent(
+ 'Using the complete global Auto order (3 groups)'
)
+ expect(container).not.toHaveTextContent('0 / 2 groups selected')
const order = container.querySelector(
'[data-slot="global-auto-order"]'
)
- assert.ok(order)
- assert.equal(order.classList.contains('overflow-y-auto'), true)
- assert.equal(order.classList.contains('flex-wrap'), true)
+ if (!order) {
+ throw new Error('Expected inherited Auto group order')
+ }
+ expect(order).toHaveClass('overflow-y-auto', 'flex-wrap')
const items = [...order.querySelectorAll('li')]
- assert.equal(items.length, 3)
- assert.equal(
- order.querySelectorAll('[data-slot="global-auto-order-connector"]')
- .length,
- 2
- )
- assert.deepEqual(
+ expect(items.length).toBe(3)
+ expect(
+ order.querySelectorAll('[data-slot="global-auto-order-connector"]').length
+ ).toBe(2)
+ expect(
items.map((item) => ({
index: item.querySelector('[data-slot="global-auto-order-index"]')
?.textContent,
@@ -345,196 +249,117 @@ describe('Auto group order editor', () => {
'[data-slot="global-auto-order-description"]'
)?.textContent,
ratio: item.querySelector('[data-slot="badge"]')?.textContent,
- })),
- [
- {
- index: '1',
- name: 'VIP',
- title: 'Priority access',
- description: 'Priority access',
- ratio: '3x Ratio',
- },
- {
- index: '2',
- name: 'Default',
- title: 'Standard access',
- description: 'Standard access',
- ratio: '1x Ratio',
- },
- {
- index: '3',
- name: 'Team',
- title: 'Shared access',
- description: 'Shared access',
- ratio: '2x Ratio',
- },
- ]
- )
+ }))
+ ).toEqual([
+ {
+ index: '1',
+ name: 'VIP',
+ title: 'Priority access',
+ description: 'Priority access',
+ ratio: '3x Ratio',
+ },
+ {
+ index: '2',
+ name: 'Default',
+ title: 'Standard access',
+ description: 'Standard access',
+ ratio: '1x Ratio',
+ },
+ {
+ index: '3',
+ name: 'Team',
+ title: 'Shared access',
+ description: 'Shared access',
+ ratio: '2x Ratio',
+ },
+ ])
for (const item of items) {
const chip = item.querySelector('[data-slot="global-auto-order-chip"]')
- assert.ok(chip)
+ expect(chip).toBeInTheDocument()
const description = item.querySelector(
'[data-slot="global-auto-order-description"]'
)
- assert.ok(description)
- assert.equal(description.classList.contains('sr-only'), true)
+ expect(description).toHaveClass('sr-only')
}
- assert.equal(
- items[0]?.querySelector('[data-slot="global-auto-order-connector"]'),
- null
- )
+ expect(
+ items[0]?.querySelector('[data-slot="global-auto-order-connector"]')
+ ).toBe(null)
for (const item of items.slice(1)) {
const connector = item.querySelector(
'[data-slot="global-auto-order-connector"]'
)
- assert.ok(connector)
- assert.equal(connector.getAttribute('aria-hidden'), 'true')
+ expect(connector).toHaveAttribute('aria-hidden', 'true')
}
- assert.equal(container.querySelector('[aria-label^="Drag "]'), null)
- assert.equal(container.querySelector('[aria-label^="Move "]'), null)
- assert.equal(container.querySelector('[aria-label^="Remove "]'), null)
+ expect(container.querySelector('[aria-label^="Drag "]')).toBe(null)
+ expect(container.querySelector('[aria-label^="Move "]')).toBe(null)
+ expect(container.querySelector('[aria-label^="Remove "]')).toBe(null)
- const restoreButton = [...container.querySelectorAll('button')].find(
- (button) => button.textContent?.includes('Restore global Auto')
- )
- assert.ok(restoreButton)
- assert.equal(restoreButton.disabled, true)
-
- await act(async () => root.unmount())
- container.remove()
+ expect(
+ within(container).getByRole('button', { name: 'Restore global Auto' })
+ ).toBeDisabled()
})
- test('shows an explicit empty state when the global Auto order has no groups', async () => {
- const container = document.createElement('div')
- document.body.append(container)
- const root = createRoot(container)
-
- await act(async () =>
- root.render( )
- )
+ test('shows an explicit empty state when the global Auto order has no groups', () => {
+ const { container } = render( )
- assert.equal(
- container.textContent?.includes(
- 'Using the complete global Auto order (0 groups)'
- ),
- true
+ expect(container).toHaveTextContent(
+ 'Using the complete global Auto order (0 groups)'
)
- assert.equal(
- container.textContent?.includes(
- 'No available groups in the global Auto order.'
- ),
- true
+ expect(container).toHaveTextContent(
+ 'No available groups in the global Auto order.'
)
- assert.equal(
- container.querySelector('[data-slot="global-auto-order"]'),
+ expect(container.querySelector('[data-slot="global-auto-order"]')).toBe(
null
)
-
- await act(async () => root.unmount())
- container.remove()
})
- test('keeps an empty custom order distinct from global inheritance', async () => {
- const container = document.createElement('div')
- document.body.append(container)
- const root = createRoot(container)
-
- await act(async () => root.render( ))
+ test('keeps an empty custom order distinct from global inheritance', () => {
+ const { container } = render( )
- assert.equal(
- container.querySelector('[data-testid="mode"]')?.textContent,
- 'custom'
+ expect(within(container).getByTestId('mode')).toHaveTextContent('custom')
+ expect(container).toHaveTextContent(
+ 'No valid custom Auto groups remain. Add a group or restore global Auto.'
)
- assert.equal(
- container.textContent?.includes(
- 'No valid custom Auto groups remain. Add a group or restore global Auto.'
- ),
- true
- )
- assert.equal(
- container.querySelector('[data-slot="global-auto-order"]'),
+ expect(container.querySelector('[data-slot="global-auto-order"]')).toBe(
null
)
- const restoreButton = [...container.querySelectorAll('button')].find(
- (button) => button.textContent?.includes('Restore global Auto')
- )
- assert.ok(restoreButton)
- assert.equal(restoreButton.disabled, false)
- await act(async () => restoreButton.click())
-
- assert.equal(
- container.querySelector('[data-testid="mode"]')?.textContent,
- 'inherit'
- )
- assert.ok(container.querySelector('[data-slot="global-auto-order"]'))
+ const restoreButton = within(container).getByRole('button', {
+ name: 'Restore global Auto',
+ })
+ expect(restoreButton).toBeEnabled()
+ fireEvent.click(restoreButton)
- await act(async () => root.unmount())
- container.remove()
+ expect(within(container).getByTestId('mode')).toHaveTextContent('inherit')
+ expect(
+ container.querySelector('[data-slot="global-auto-order"]')
+ ).toBeInTheDocument()
})
- test('adding a group from inheritance explicitly creates a custom order', async () => {
- const container = document.createElement('div')
- document.body.append(container)
- const root = createRoot(container)
+ test('adding a group from inheritance explicitly creates a custom order', () => {
+ const { container } = render( )
- await act(async () => root.render( ))
+ fireEvent.click(within(container).getByRole('combobox'))
+ fireEvent.click(getCommandItem('VIP'))
- const addButton = container.querySelector(
- 'button[role="combobox"]'
- )
- assert.ok(addButton)
- await act(async () => addButton.click())
- const vipOption = [
- ...document.querySelectorAll('[data-slot="command-item"]'),
- ].find((option) => option.textContent?.includes('VIP'))
- assert.ok(vipOption)
- await act(async () => vipOption.click())
-
- assert.equal(
- container.querySelector('[data-testid="mode"]')?.textContent,
- 'custom'
- )
- assert.equal(
- container.querySelector('[data-testid="order"]')?.textContent,
- 'vip'
- )
- assert.equal(
- container.querySelector('[data-slot="global-auto-order"]'),
+ expect(within(container).getByTestId('mode')).toHaveTextContent('custom')
+ expect(within(container).getByTestId('order')).toHaveTextContent('vip')
+ expect(container.querySelector('[data-slot="global-auto-order"]')).toBe(
null
)
-
- await act(async () => root.unmount())
- container.remove()
})
- test('removing the last custom group does not silently enable inheritance', async () => {
- const container = document.createElement('div')
- document.body.append(container)
- const root = createRoot(container)
-
- await act(async () => root.render( ))
- await act(async () => findButton(container, 'Remove default').click())
+ test('removing the last custom group does not silently enable inheritance', () => {
+ const { container } = render( )
+ fireEvent.click(findButton(container, 'Remove default'))
- assert.equal(
- container.querySelector('[data-testid="order"]')?.textContent,
- ''
+ expect(within(container).getByTestId('order')).toBeEmptyDOMElement()
+ expect(within(container).getByTestId('mode')).toHaveTextContent('custom')
+ expect(container).toHaveTextContent(
+ 'No valid custom Auto groups remain. Add a group or restore global Auto.'
)
- assert.equal(
- container.querySelector('[data-testid="mode"]')?.textContent,
- 'custom'
- )
- assert.equal(
- container.textContent?.includes(
- 'No valid custom Auto groups remain. Add a group or restore global Auto.'
- ),
- true
- )
-
- await act(async () => root.unmount())
- container.remove()
})
})
diff --git a/web/src/features/keys/lib/__tests__/auto-group-form.test.ts b/web/src/features/keys/lib/__tests__/auto-group-form.test.ts
index 296023b6ae00..479ff59431d8 100644
--- a/web/src/features/keys/lib/__tests__/auto-group-form.test.ts
+++ b/web/src/features/keys/lib/__tests__/auto-group-form.test.ts
@@ -16,10 +16,8 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { describe, test } from 'node:test'
-
import type { TFunction } from 'i18next'
+import { describe, expect, test } from 'vitest'
import { apiKeySchema, type ApiKey } from '../../types'
import {
@@ -60,16 +58,16 @@ describe('API key Auto group form mapping', () => {
const legacyApiKey: Record = { ...baseApiKey }
delete legacyApiKey.auto_groups
- assert.equal(apiKeySchema.parse(legacyApiKey).auto_groups, null)
+ expect(apiKeySchema.parse(legacyApiKey).auto_groups).toBe(null)
})
test('creates an Auto token that inherits the global order', () => {
const defaults = getApiKeyFormDefaultValues(true)
- assert.equal(defaults.group, 'auto')
- assert.equal(defaults.auto_groups_mode, 'inherit')
- assert.deepEqual(defaults.auto_groups, [])
- assert.deepEqual(transformFormDataToPayload(defaults).auto_groups, [])
+ expect(defaults.group).toBe('auto')
+ expect(defaults.auto_groups_mode).toBe('inherit')
+ expect(defaults.auto_groups).toEqual([])
+ expect(transformFormDataToPayload(defaults).auto_groups).toEqual([])
})
test('maps omitted, null, and empty snapshots to inheritance on edit', () => {
@@ -88,8 +86,8 @@ describe('API key Auto group form mapping', () => {
2
)
- assert.equal(defaults.auto_groups_mode, 'inherit')
- assert.deepEqual(defaults.auto_groups, [])
+ expect(defaults.auto_groups_mode).toBe('inherit')
+ expect(defaults.auto_groups).toEqual([])
}
})
@@ -103,8 +101,8 @@ describe('API key Auto group form mapping', () => {
2
)
- assert.equal(defaults.auto_groups_mode, 'custom')
- assert.deepEqual(defaults.auto_groups, ['vip', 'default'])
+ expect(defaults.auto_groups_mode).toBe('custom')
+ expect(defaults.auto_groups).toEqual(['vip', 'default'])
})
test('keeps a fully filtered snapshot custom and rejects it until resolved', () => {
@@ -114,15 +112,14 @@ describe('API key Auto group form mapping', () => {
2
)
- assert.equal(defaults.auto_groups_mode, 'custom')
- assert.deepEqual(defaults.auto_groups, [])
+ expect(defaults.auto_groups_mode).toBe('custom')
+ expect(defaults.auto_groups).toEqual([])
const result = getApiKeyFormSchema(t, 2).safeParse(defaults)
- assert.equal(result.success, false)
+ expect(result.success).toBe(false)
if (result.success) return
- assert.deepEqual(result.error.issues[0]?.path, ['auto_groups'])
- assert.equal(
- result.error.issues[0]?.message,
+ expect(result.error.issues[0]?.path).toEqual(['auto_groups'])
+ expect(result.error.issues[0]?.message).toBe(
'Select at least one Auto group or restore global Auto.'
)
})
@@ -134,7 +131,7 @@ describe('API key Auto group form mapping', () => {
auto_groups: ['vip', 'default'],
}
- assert.deepEqual(transformFormDataToPayload(custom).auto_groups, [
+ expect(transformFormDataToPayload(custom).auto_groups).toEqual([
'vip',
'default',
])
@@ -142,7 +139,7 @@ describe('API key Auto group form mapping', () => {
test('submits an empty array for inheritance and for non-Auto groups', () => {
const inherited = getApiKeyFormDefaultValues(true)
- assert.deepEqual(transformFormDataToPayload(inherited).auto_groups, [])
+ expect(transformFormDataToPayload(inherited).auto_groups).toEqual([])
const nonAuto = {
...inherited,
@@ -150,8 +147,8 @@ describe('API key Auto group form mapping', () => {
auto_groups_mode: 'custom' as const,
auto_groups: ['vip'],
}
- assert.deepEqual(transformFormDataToPayload(nonAuto).auto_groups, [])
- assert.equal(transformFormDataToPayload(nonAuto).cross_group_retry, false)
+ expect(transformFormDataToPayload(nonAuto).auto_groups).toEqual([])
+ expect(transformFormDataToPayload(nonAuto).cross_group_retry).toBe(false)
})
test('rejects snapshots over the configured limit', () => {
@@ -162,13 +159,10 @@ describe('API key Auto group form mapping', () => {
auto_groups: ['default', 'vip'],
})
- assert.equal(result.success, false)
+ expect(result.success).toBe(false)
if (result.success) return
- assert.equal(result.error.issues[0]?.path[0], 'auto_groups')
- assert.equal(
- result.error.issues[0]?.message,
- 'Select at most 1 Auto groups'
- )
+ expect(result.error.issues[0]?.path[0]).toBe('auto_groups')
+ expect(result.error.issues[0]?.message).toBe('Select at most 1 Auto groups')
})
test('rejects duplicate custom groups', () => {
@@ -179,10 +173,9 @@ describe('API key Auto group form mapping', () => {
auto_groups: ['vip', 'vip'],
})
- assert.equal(result.success, false)
+ expect(result.success).toBe(false)
if (result.success) return
- assert.equal(
- result.error.issues[0]?.message,
+ expect(result.error.issues[0]?.message).toBe(
'Auto groups must not contain duplicates'
)
})
diff --git a/web/src/features/playground/hooks/use-stream-request.test.ts b/web/src/features/playground/hooks/use-stream-request.test.ts
index a913fec3fe11..29bcde76c1c4 100644
--- a/web/src/features/playground/hooks/use-stream-request.test.ts
+++ b/web/src/features/playground/hooks/use-stream-request.test.ts
@@ -16,8 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { describe, test } from 'node:test'
+import { describe, expect, test } from 'vitest'
import type { ChatCompletionRequest } from '../types'
import { createStreamRequestController } from './use-stream-request'
@@ -103,12 +102,12 @@ describe('latest-wins stream request coordination', () => {
const second = controller.send(payload, noopCallbacks)
firstHeaders.resolve({ Authorization: 'Bearer stale' })
await first
- assert.equal(sources.length, 0)
+ expect(sources.length).toBe(0)
secondHeaders.resolve({ Authorization: 'Bearer current' })
await second
- assert.equal(sources.length, 1)
- assert.equal(sources[0]?.streamed, true)
+ expect(sources.length).toBe(1)
+ expect(sources[0]?.streamed).toBe(true)
})
test('stop cancels a request that is still waiting for headers', async () => {
@@ -128,7 +127,7 @@ describe('latest-wins stream request coordination', () => {
headers.resolve({ Authorization: 'Bearer ignored' })
await request
- assert.equal(sourceCount, 0)
+ expect(sourceCount).toBe(0)
})
test('dispose cancels a pending header request without a state update', async () => {
@@ -149,8 +148,8 @@ describe('latest-wins stream request coordination', () => {
headers.resolve({ Authorization: 'Bearer ignored' })
await request
- assert.equal(sourceCount, 0)
- assert.deepEqual(streamingStates, [false])
+ expect(sourceCount).toBe(0)
+ expect(streamingStates).toEqual([false])
})
test('closes the previous source and ignores all of its later events', async () => {
@@ -182,7 +181,7 @@ describe('latest-wins stream request coordination', () => {
await controller.send(payload, callbacks)
const second = controller.send(payload, callbacks)
- assert.equal(sources[0]?.closed, true)
+ expect(sources[0]?.closed).toBe(true)
sources[0]?.emit(
'message',
JSON.stringify({ choices: [{ delta: { content: 'stale' } }] })
@@ -195,6 +194,6 @@ describe('latest-wins stream request coordination', () => {
JSON.stringify({ choices: [{ delta: { content: 'current' } }] })
)
- assert.deepEqual(updates, ['current'])
+ expect(updates).toEqual(['current'])
})
})
diff --git a/web/src/features/profile/components/__tests__/login-session-utils.test.ts b/web/src/features/profile/components/__tests__/login-session-utils.test.ts
index c75df750f489..f97c0b3ef9ae 100644
--- a/web/src/features/profile/components/__tests__/login-session-utils.test.ts
+++ b/web/src/features/profile/components/__tests__/login-session-utils.test.ts
@@ -1,3 +1,4 @@
+import type { TFunction } from 'i18next'
/*
Copyright (C) 2023-2026 QuantumNous
@@ -16,10 +17,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { describe, test } from 'node:test'
-
-import type { TFunction } from 'i18next'
+import { describe, expect, test } from 'vitest'
import { loginMethodLabel, sessionDevice } from '../login-session-utils'
@@ -27,14 +25,10 @@ const translate = ((key: string) => key) as TFunction
describe('login session presentation', () => {
test('labels built-in and provider OAuth login methods', () => {
- assert.equal(loginMethodLabel('password', translate), 'Password')
- assert.equal(
- loginMethodLabel('2fa', translate),
- 'Two-factor Authentication'
- )
- assert.equal(loginMethodLabel('oauth:github', translate), 'OAuth · GitHub')
- assert.equal(
- loginMethodLabel('oauth:custom-provider', translate),
+ expect(loginMethodLabel('password', translate)).toBe('Password')
+ expect(loginMethodLabel('2fa', translate)).toBe('Two-factor Authentication')
+ expect(loginMethodLabel('oauth:github', translate)).toBe('OAuth · GitHub')
+ expect(loginMethodLabel('oauth:custom-provider', translate)).toBe(
'OAuth · custom-provider'
)
})
@@ -43,8 +37,7 @@ describe('login session presentation', () => {
const userAgent =
'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
- assert.equal(
- sessionDevice(userAgent, 'Unknown device', 'Browser'),
+ expect(sessionDevice(userAgent, 'Unknown device', 'Browser')).toBe(
'Safari · iOS'
)
})
@@ -53,8 +46,7 @@ describe('login session presentation', () => {
const userAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15'
- assert.equal(
- sessionDevice(userAgent, 'Unknown device', 'Browser', 5),
+ expect(sessionDevice(userAgent, 'Unknown device', 'Browser', 5)).toBe(
'Safari · iOS'
)
})
@@ -63,8 +55,7 @@ describe('login session presentation', () => {
const userAgent =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
- assert.equal(
- sessionDevice(userAgent, 'Unknown device', 'Browser', 10),
+ expect(sessionDevice(userAgent, 'Unknown device', 'Browser', 10)).toBe(
'Chrome · Windows'
)
})
@@ -73,8 +64,7 @@ describe('login session presentation', () => {
const userAgent =
'Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36'
- assert.equal(
- sessionDevice(userAgent, 'Unknown device', 'Browser', 5),
+ expect(sessionDevice(userAgent, 'Unknown device', 'Browser', 5)).toBe(
'Chrome · Android'
)
})
@@ -83,15 +73,13 @@ describe('login session presentation', () => {
const userAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15'
- assert.equal(
- sessionDevice(userAgent, 'Unknown device', 'Browser'),
+ expect(sessionDevice(userAgent, 'Unknown device', 'Browser')).toBe(
'Safari · macOS'
)
})
test('falls back to the unknown-device label for an empty user agent', () => {
- assert.equal(
- sessionDevice('', 'Unknown device', 'Browser'),
+ expect(sessionDevice('', 'Unknown device', 'Browser')).toBe(
'Unknown device'
)
})
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
index 46008e102aa4..b100c55cf2d3 100644
--- 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
@@ -16,56 +16,17 @@ 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 {
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ type RenderResult,
+} from '@testing-library/react'
+import { afterEach, describe, expect, test } from 'vitest'
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')
@@ -88,19 +49,13 @@ await i18n.use(initReactI18next).init({
},
})
-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
+ result: RenderResult
}
type CurrencyFixture = {
quotaDisplayType: 'USD' | 'CNY'
@@ -171,269 +126,189 @@ async function renderDrawer(
},
})
- const host = document.createElement('div')
- document.body.append(host)
- const root = createRoot(host)
- renderedDrawer = { host, root }
-
- await act(async () => root.render(drawerTree(currentRow)))
+ renderedDrawer = { result: 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,
- })
- })
+ if (!renderedDrawer) {
+ throw new Error('Expected a rendered redemption drawer')
+ }
+ renderedDrawer.result.rerender(drawerTree(currentRow))
}
function getSaveButton(): HTMLButtonElement {
- const button = document.querySelector(
- 'button[form="redemption-form"][type="submit"]'
- )
- assert.ok(button)
- return button
+ return screen.getByRole('button', { name: 'Save changes' })
}
-function getControlByLabel(labelText: string): T {
+function getControlByLabel(labelText: 'Name'): HTMLInputElement
+function getControlByLabel(labelText: 'Quota (CNY)'): HTMLInputElement
+function getControlByLabel(labelText: 'Quota (USD)'): HTMLInputElement
+function getControlByLabel(labelText: string): HTMLElement {
const label = [...document.querySelectorAll('label')].find(
(candidate) => candidate.textContent?.trim() === labelText
)
- assert.ok(label, `Expected label "${labelText}"`)
- assert.ok(label.htmlFor)
+ if (!label) {
+ throw new Error(`Expected label "${labelText}"`)
+ }
const control =
label.control ??
label
.closest('[data-slot="form-item"]')
?.querySelector('[data-slot="form-control"], input')
- assert.ok(control)
- return control as T
+ if (!control) {
+ throw new Error(`Expected control for label "${labelText}"`)
+ }
+ return control
}
-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
- )
- })
+function changeInput(input: HTMLInputElement, value: string): void {
+ fireEvent.input(input, { target: { value } })
}
-async function submitForm(): Promise {
+function submitForm(): void {
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
- )
- )
+ if (!form) {
+ throw new Error('Expected redemption form')
+ }
+ fireEvent.submit(form)
}
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')
- )
+ await waitFor(() => expect(getSaveButton()).toBeEnabled())
}
-afterEach(async () => {
+afterEach(() => {
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()
+ localStorage.clear()
+ renderedDrawer = null
})
-afterAll(() => {
- domWindow.close()
-})
+describe('redemption drawer', () => {
+ test('shows the reported CNY quota without floating-point noise', async () => {
+ const original = redemption(1, 13888889)
+ apiClient.get = async () => ({ data: { success: true, data: original } })
-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()
- await renderDrawer(original, {
- quotaDisplayType: 'CNY',
- usdExchangeRate: 7.2,
+ expect(getControlByLabel('Quota (CNY)').value).toBe('200')
})
- 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'
+ test('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 waitFor(() =>
+ expect(document.body).toHaveTextContent('Something went wrong!')
)
- )
-
- 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' },
+ expect(getSaveButton()).toBeDisabled()
+ submitForm()
+ expect(updates).toEqual([])
})
- 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('blocks updates and uses localized feedback for unsuccessful responses', async () => {
+ apiClient.get = async () => ({
+ data: { success: false, message: 'raw server message' },
+ })
-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(redemption(1))
+ await waitFor(() =>
+ expect(document.body).toHaveTextContent('Failed to load')
+ )
- await renderDrawer(original)
- await waitForLoadedForm()
- await changeInput(getControlByLabel('Quota (USD)'), '2')
- await submitForm()
- await act(async () =>
- waitForCondition(() => updates.length === 1, 'update was not submitted')
- )
+ expect(getSaveButton()).toBeDisabled()
+ expect(document.body).not.toHaveTextContent('raw server message')
+ })
- assert.equal(updates[0]?.quota, 1000000)
-})
+ test('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) => {
+ expect(data && typeof data === 'object').toBeTruthy()
+ updates.push(data as Record)
+ return { data: { success: true, data: original } }
+ }
+
+ await renderDrawer(original)
+ await waitForLoadedForm()
+ expect(getControlByLabel('Quota (USD)').value).toBe('1')
+
+ changeInput(getControlByLabel('Name'), 'renamed')
+ submitForm()
+ await waitFor(() => expect(updates).toHaveLength(1))
+
+ expect(updates[0]?.name).toBe('renamed')
+ expect(updates[0]?.quota).toBe(500001)
+ })
-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 } }
- }
+ test('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) => {
+ expect(data && typeof data === 'object').toBeTruthy()
+ updates.push(data as Record)
+ return { data: { success: true, data: original } }
+ }
+
+ await renderDrawer(original)
+ await waitForLoadedForm()
+ changeInput(getControlByLabel('Quota (USD)'), '2')
+ submitForm()
+ await waitFor(() => expect(updates).toHaveLength(1))
+
+ expect(updates[0]?.quota).toBe(1000000)
+ })
- await renderDrawer(first)
- await rerenderDrawer(second)
- await act(async () =>
- waitForCondition(
- () => requestedUrls.includes('/api/redemption/2'),
- 'second redemption was not requested'
- )
- )
- await act(async () =>
+ test('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) => {
+ expect(data && typeof data === 'object').toBeTruthy()
+ updates.push(data as Record)
+ return { data: { success: true, data: second } }
+ }
+
+ await renderDrawer(first)
+ await rerenderDrawer(second)
+ await waitFor(() => expect(requestedUrls).toContain('/api/redemption/2'))
secondRequest.resolve({ data: { success: true, data: second } })
- )
- await waitForLoadedForm()
+ await waitForLoadedForm()
- await act(async () =>
firstRequest.resolve({ data: { success: true, data: first } })
- )
- assert.equal(getControlByLabel('Name').value, 'code-2')
+ expect(getControlByLabel('Name').value).toBe('code-2')
- await changeInput(getControlByLabel('Name'), 'second')
- await submitForm()
- await act(async () =>
- waitForCondition(() => updates.length === 1, 'update was not submitted')
- )
+ changeInput(getControlByLabel('Name'), 'second')
+ submitForm()
+ await waitFor(() => expect(updates).toHaveLength(1))
- assert.equal(updates[0]?.id, 2)
- assert.equal(updates[0]?.quota, 1000001)
+ expect(updates[0]?.id).toBe(2)
+ expect(updates[0]?.quota).toBe(1000001)
+ })
})
diff --git a/web/src/features/system-settings/models/__tests__/group-auto-limit-validation.test.ts b/web/src/features/system-settings/models/__tests__/group-auto-limit-validation.test.ts
index 4b2a5b6eae47..62e60e9f8044 100644
--- a/web/src/features/system-settings/models/__tests__/group-auto-limit-validation.test.ts
+++ b/web/src/features/system-settings/models/__tests__/group-auto-limit-validation.test.ts
@@ -16,8 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { describe, test } from 'node:test'
+import { describe, expect, test } from 'vitest'
import { positiveIntegerSchema } from '../../utils/numeric-field'
@@ -26,15 +25,15 @@ const schema = positiveIntegerSchema(t('Enter a positive integer'))
describe('per-token Auto group limit validation', () => {
test('accepts any positive integer without a product upper bound', () => {
- assert.equal(schema.safeParse(1000).success, true)
+ expect(schema.safeParse(1000).success).toBe(true)
})
test('rejects zero, negative, and fractional limits', () => {
for (const maxTokenAutoGroups of [0, -1, 1.5]) {
const result = schema.safeParse(maxTokenAutoGroups)
- assert.equal(result.success, false)
+ expect(result.success).toBe(false)
if (result.success) continue
- assert.equal(result.error.issues[0]?.message, 'Enter a positive integer')
+ expect(result.error.issues[0]?.message).toBe('Enter a positive integer')
}
})
})
diff --git a/web/src/features/system-settings/models/__tests__/tool-price-validation.test.tsx b/web/src/features/system-settings/models/__tests__/tool-price-validation.test.tsx
index fc6b8a88aa8b..5544d4ff7d79 100644
--- a/web/src/features/system-settings/models/__tests__/tool-price-validation.test.tsx
+++ b/web/src/features/system-settings/models/__tests__/tool-price-validation.test.tsx
@@ -16,128 +16,49 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { after, describe, test } from 'node:test'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { fireEvent, render, screen } from '@testing-library/react'
+import i18next from 'i18next'
+import { beforeAll, describe, expect, test } from 'vitest'
-import { Window } from 'happy-dom'
-
-const domWindow = new Window()
-const domGlobals = [
- 'window',
- 'document',
- 'navigator',
- 'HTMLElement',
- 'HTMLInputElement',
- 'SVGElement',
- 'Node',
- 'Element',
- 'Event',
- 'CustomEvent',
- 'MutationObserver',
- '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 { QueryClient, QueryClientProvider } =
- await import('@tanstack/react-query')
-const { createInstance } = await import('i18next')
-const { I18nextProvider, initReactI18next } = await import('react-i18next')
-const { ToolPriceSettings } = await import('../tool-price-settings')
-
-const i18n = createInstance()
-await i18n.use(initReactI18next).init({
- lng: 'en',
- resources: {
- en: {
- translation: {
- 'Price ($/1K calls)': 'Price ($/1K calls)',
- 'Please enter a valid number': 'Please enter a valid number',
- 'Tool identifier': 'Tool identifier',
- },
- },
- },
-})
-
-const reactTestGlobals = globalThis as typeof globalThis & {
- IS_REACT_ACT_ENVIRONMENT?: boolean
-}
-reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true
-
-function changeInputValue(input: HTMLInputElement, value: string) {
- 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
- )
-}
+import { ToolPriceSettings } from '../tool-price-settings'
describe('tool price validation', () => {
- after(() => {
- domWindow.close()
+ beforeAll(() => {
+ i18next.addResourceBundle('en', 'translation', {
+ 'Price ($/1K calls)': 'Price ($/1K calls)',
+ 'Please enter a valid number': 'Please enter a valid number',
+ 'Tool identifier': 'Tool identifier',
+ })
})
- test('blocks an empty price without converting it to an explicit zero', async () => {
- const container = document.createElement('div')
- document.body.append(container)
- const root = createRoot(container)
+ test('blocks an empty price without converting it to an explicit zero', () => {
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
})
- await act(async () => {
- root.render(
-
-
-
-
-
- )
- })
-
- const priceInput = container.querySelector(
- 'input[aria-label="Price ($/1K calls): web_search"]'
+ render(
+
+
+
)
- assert.ok(priceInput)
- await act(async () => {
- changeInputValue(priceInput, '')
+ const priceInput = screen.getByRole('spinbutton', {
+ name: 'Price ($/1K calls): web_search',
})
+ const saveButton = screen.getByRole('button', { name: 'Save tool prices' })
- assert.equal(priceInput.getAttribute('aria-invalid'), 'true')
- assert.equal(
- priceInput.closest('[data-slot="field"]')?.querySelector('[role="alert"]')
- ?.textContent,
- 'Please enter a valid number'
- )
- const saveButton = [...container.querySelectorAll('button')].find(
- (button) => button.textContent === 'Save tool prices'
- )
- assert.ok(saveButton)
- assert.equal(saveButton.disabled, true)
+ fireEvent.change(priceInput, { target: { value: '' } })
- await act(async () => {
- changeInputValue(priceInput, '0')
- })
+ expect(priceInput).toHaveAttribute('aria-invalid', 'true')
+ expect(screen.getByText('Please enter a valid number')).toBeInTheDocument()
+ expect(saveButton).toBeDisabled()
+
+ fireEvent.change(priceInput, { target: { value: '0' } })
- assert.equal(priceInput.getAttribute('aria-invalid'), 'false')
- assert.equal(saveButton.disabled, false)
+ expect(priceInput).toHaveAttribute('aria-invalid', 'false')
+ expect(saveButton).toBeEnabled()
- await act(async () => root.unmount())
- container.remove()
queryClient.clear()
})
})
diff --git a/web/src/features/usage-logs/components/__tests__/cost-display.test.tsx b/web/src/features/usage-logs/components/__tests__/cost-display.test.tsx
index fc6294f6ce4f..457a48daa0b2 100644
--- a/web/src/features/usage-logs/components/__tests__/cost-display.test.tsx
+++ b/web/src/features/usage-logs/components/__tests__/cost-display.test.tsx
@@ -16,88 +16,19 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { after, describe, test } from 'node:test'
-
-import { Window } from 'happy-dom'
+import { render, screen } from '@testing-library/react'
+import i18next from 'i18next'
import type React from 'react'
+import { beforeAll, describe, expect, test } from 'vitest'
-const domWindow = new Window()
-const domGlobals = [
- 'window',
- 'document',
- 'navigator',
- 'HTMLElement',
- 'SVGElement',
- 'Node',
- 'Element',
- 'Event',
- 'CustomEvent',
- 'MutationObserver',
- '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 { createInstance } = await import('i18next')
-const { I18nextProvider, initReactI18next } = await import('react-i18next')
-
-const i18n = createInstance()
-await i18n.use(initReactI18next).init({
- lng: 'en',
- resources: {
- en: {
- translation: {
- Subscription: 'Subscription',
- 'Deducted by subscription': 'Deducted by subscription',
- 'Includes tool-call surcharge': 'Includes tool-call surcharge',
- },
- },
- },
-})
-
-const { LogCostDisplay } = await import('../log-cost-display')
-const { formatLogQuota } = await import('@/lib/format')
-const reactTestGlobals = globalThis as typeof globalThis & {
- IS_REACT_ACT_ENVIRONMENT?: boolean
-}
-reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true
+import { formatLogQuota } from '@/lib/format'
-type RenderedCost = {
- container: HTMLDivElement
- root: ReturnType
-}
+import { LogCostDisplay } from '../log-cost-display'
-async function renderCost(
+function renderCost(
props: React.ComponentProps
-): Promise {
- const container = document.createElement('div')
- document.body.append(container)
- const root = createRoot(container)
-
- await act(async () => {
- root.render(
-
-
-
- )
- })
-
- return { container, root }
-}
-
-async function unmountCost(rendered: RenderedCost) {
- await act(async () => rendered.root.unmount())
- rendered.container.remove()
+): ReturnType {
+ return render( )
}
function normalizedText(value: string | null): string {
@@ -105,39 +36,36 @@ function normalizedText(value: string | null): string {
}
describe('log cost display', () => {
- after(() => {
- domWindow.close()
+ beforeAll(() => {
+ i18next.addResourceBundle('en', 'translation', {
+ Subscription: 'Subscription',
+ 'Deducted by subscription': 'Deducted by subscription',
+ 'Includes tool-call surcharge': 'Includes tool-call surcharge',
+ })
})
- test('keeps the regular cost visible and adds an accessible surcharge marker', async () => {
- const rendered = await renderCost({
+ test('keeps the regular cost visible and adds an accessible surcharge marker', () => {
+ const rendered = renderCost({
quota: 12500,
other: {
tool_surcharges: [{ name: 'lookup_customer', count: 1, price: 5 }],
},
})
- assert.equal(
+ expect(
normalizedText(rendered.container.textContent).includes(
normalizedText(formatLogQuota(12500))
- ),
- true
- )
- const marker = rendered.container.querySelector(
- '[data-tool-surcharge-indicator="true"]'
- )
- assert.ok(marker)
- assert.equal(
- marker.getAttribute('aria-label'),
- 'Includes tool-call surcharge'
- )
- assert.equal(marker.getAttribute('tabindex'), '0')
-
- await unmountCost(rendered)
+ )
+ ).toBe(true)
+ const marker = screen.getByRole('img', {
+ name: 'Includes tool-call surcharge',
+ })
+ expect(marker).toHaveAttribute('data-tool-surcharge-indicator', 'true')
+ expect(marker).toHaveAttribute('tabindex', '0')
})
- test('preserves the subscription badge and adds the same legacy surcharge marker', async () => {
- const rendered = await renderCost({
+ test('preserves the subscription badge and adds the same legacy surcharge marker', () => {
+ renderCost({
quota: 5000,
other: {
billing_source: 'subscription',
@@ -147,11 +75,9 @@ describe('log cost display', () => {
},
})
- assert.equal(rendered.container.textContent?.includes('Subscription'), true)
- assert.ok(
- rendered.container.querySelector('[data-tool-surcharge-indicator="true"]')
- )
-
- await unmountCost(rendered)
+ expect(screen.getByText('Subscription')).toBeInTheDocument()
+ expect(
+ screen.getByRole('img', { name: 'Includes tool-call surcharge' })
+ ).toHaveAttribute('data-tool-surcharge-indicator', 'true')
})
})
diff --git a/web/src/features/usage-logs/lib/__tests__/tool-surcharge.test.ts b/web/src/features/usage-logs/lib/__tests__/tool-surcharge.test.ts
index fb3ea348dbb0..f5b809494ce8 100644
--- a/web/src/features/usage-logs/lib/__tests__/tool-surcharge.test.ts
+++ b/web/src/features/usage-logs/lib/__tests__/tool-surcharge.test.ts
@@ -16,20 +16,18 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { describe, test } from 'node:test'
+import { describe, expect, test } from 'vitest'
import type { LogOtherData } from '../../types'
import { hasToolSurcharge } from '../format'
describe('tool surcharge detection', () => {
test('shows the marker for a charged structured tool surcharge', () => {
- assert.equal(
+ expect(
hasToolSurcharge({
tool_surcharges: [{ name: 'lookup_customer', count: 2, price: 5 }],
- }),
- true
- )
+ })
+ ).toBe(true)
})
const legacyCases: Array<{
@@ -63,7 +61,7 @@ describe('tool surcharge detection', () => {
for (const scenario of legacyCases) {
test(`keeps the marker visible for legacy ${scenario.name} charges`, () => {
- assert.equal(hasToolSurcharge(scenario.other), true)
+ expect(hasToolSurcharge(scenario.other)).toBe(true)
})
}
@@ -93,7 +91,7 @@ describe('tool surcharge detection', () => {
]
for (const other of invalidCases) {
- assert.equal(hasToolSurcharge(other), false)
+ expect(hasToolSurcharge(other)).toBe(false)
}
})
})
diff --git a/web/src/features/wallet/hooks/use-payment.test.ts b/web/src/features/wallet/hooks/use-payment.test.ts
index ebd90a144cd0..210e873f14e5 100644
--- a/web/src/features/wallet/hooks/use-payment.test.ts
+++ b/web/src/features/wallet/hooks/use-payment.test.ts
@@ -16,8 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { describe, test } from 'node:test'
+import { describe, expect, test } from 'vitest'
import { PAYMENT_TYPES } from '../constants'
import { requestPaymentAmount } from './use-payment'
@@ -44,7 +43,7 @@ describe('payment amount routing', () => {
},
})
- assert.equal(amount, 18.75)
- assert.deepEqual(calls, ['waffo:120'])
+ expect(amount).toBe(18.75)
+ expect(calls).toEqual(['waffo:120'])
})
})
diff --git a/web/src/features/wallet/lib/payment.test.ts b/web/src/features/wallet/lib/payment.test.ts
index 10e8eabaddf8..99f22ce3800c 100644
--- a/web/src/features/wallet/lib/payment.test.ts
+++ b/web/src/features/wallet/lib/payment.test.ts
@@ -16,8 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { describe, test } from 'node:test'
+import { describe, expect, test } from 'vitest'
import { PAYMENT_TYPES } from '../constants'
import {
@@ -29,11 +28,11 @@ import {
describe('payment type classification', () => {
test('keeps Waffo and Waffo Pancake on their dedicated flows', () => {
- assert.equal(isWaffoPayment(PAYMENT_TYPES.WAFFO), true)
- assert.equal(isWaffoPayment(PAYMENT_TYPES.WAFFO_PANCAKE), false)
- assert.equal(isWaffoPancakePayment(PAYMENT_TYPES.WAFFO_PANCAKE), true)
- assert.equal(isWaffoPancakePayment(PAYMENT_TYPES.WAFFO), false)
- assert.equal(isStripePayment(PAYMENT_TYPES.STRIPE), true)
+ expect(isWaffoPayment(PAYMENT_TYPES.WAFFO)).toBe(true)
+ expect(isWaffoPayment(PAYMENT_TYPES.WAFFO_PANCAKE)).toBe(false)
+ expect(isWaffoPancakePayment(PAYMENT_TYPES.WAFFO_PANCAKE)).toBe(true)
+ expect(isWaffoPancakePayment(PAYMENT_TYPES.WAFFO)).toBe(false)
+ expect(isStripePayment(PAYMENT_TYPES.STRIPE)).toBe(true)
})
})
@@ -60,8 +59,8 @@ describe('payment dispatch', () => {
}
)
- assert.equal(success, true)
- assert.deepEqual(calls, ['waffo:120:3'])
+ expect(success).toBe(true)
+ expect(calls).toEqual(['waffo:120:3'])
})
test('does not create a Waffo order without a selected method index', async () => {
@@ -80,7 +79,7 @@ describe('payment dispatch', () => {
}
)
- assert.equal(success, false)
- assert.equal(called, false)
+ expect(success).toBe(false)
+ expect(called).toBe(false)
})
})
diff --git a/web/src/lib/auth-session.test.ts b/web/src/lib/auth-session.test.ts
index 4a97d3c2737c..17055ee45d21 100644
--- a/web/src/lib/auth-session.test.ts
+++ b/web/src/lib/auth-session.test.ts
@@ -16,10 +16,8 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { afterEach, describe, test } from 'node:test'
-
import { QueryClient } from '@tanstack/react-query'
+import { afterEach, describe, expect, test } from 'vitest'
import { useAuthStore, type AuthBundle } from '../stores/auth-store'
import {
@@ -59,10 +57,10 @@ afterEach(() => {
describe('authentication session coordination', () => {
test('bootstrap distinguishes a completed anonymous check from an active session', async () => {
useAuthStore.getState().auth.reset('complete')
- assert.deepEqual(await bootstrapAuthentication(), { kind: 'anonymous' })
+ expect(await bootstrapAuthentication()).toEqual({ kind: 'anonymous' })
useAuthStore.getState().auth.setBundle(bundle)
- assert.deepEqual(await bootstrapAuthentication(), {
+ expect(await bootstrapAuthentication()).toEqual({
kind: 'authenticated',
bundle,
})
@@ -97,10 +95,10 @@ describe('authentication session coordination', () => {
const outcome = await createRefreshRunner(runtime)()
- assert.equal(outcome.kind, 'authenticated')
- assert.deepEqual(requestedSIDs, [bundle.session.sid, undefined])
- assert.deepEqual(clears, [[false, 'idle']])
- assert.deepEqual(accepted, [bundle])
+ expect(outcome.kind).toBe('authenticated')
+ expect(requestedSIDs).toEqual([bundle.session.sid, undefined])
+ expect(clears).toEqual([[false, 'idle']])
+ expect(accepted).toEqual([bundle])
})
test('a rejected refresh confirms anonymous state and synchronizes sign-out', async () => {
@@ -117,10 +115,10 @@ describe('authentication session coordination', () => {
wait: async () => undefined,
}
- assert.deepEqual(await createRefreshRunner(runtime)(), {
+ expect(await createRefreshRunner(runtime)()).toEqual({
kind: 'anonymous',
})
- assert.deepEqual(clears, [[true, undefined]])
+ expect(clears).toEqual([[true, undefined]])
})
test('a temporary refresh failure remains retryable without clearing the session', async () => {
@@ -142,9 +140,9 @@ describe('authentication session coordination', () => {
const outcome = await createRefreshRunner(runtime)()
- assert.equal(outcome.kind, 'transient_error')
- assert.equal(clearCount, 0)
- assert.equal(transientCount, 1)
+ expect(outcome.kind).toBe('transient_error')
+ expect(clearCount).toBe(0)
+ expect(transientCount).toBe(1)
})
test('a rate limited refresh remains retryable without clearing the session', async () => {
@@ -166,9 +164,9 @@ describe('authentication session coordination', () => {
const outcome = await createRefreshRunner(runtime)()
- assert.equal(outcome.kind, 'transient_error')
- assert.equal(clearCount, 0)
- assert.equal(transientCount, 1)
+ expect(outcome.kind).toBe('transient_error')
+ expect(clearCount).toBe(0)
+ expect(transientCount).toBe(1)
})
test('an exhausted refresh race clears the unusable local session', async () => {
@@ -191,12 +189,12 @@ describe('authentication session coordination', () => {
},
}
- assert.deepEqual(await createRefreshRunner(runtime)(), {
+ expect(await createRefreshRunner(runtime)()).toEqual({
kind: 'out_of_sync',
code: 'AUTH_REFRESH_RACE',
})
- assert.deepEqual(requestedDelays, [80, 200, 500])
- assert.deepEqual(clears, [[false, undefined]])
+ expect(requestedDelays).toEqual([80, 200, 500])
+ expect(clears).toEqual([[false, undefined]])
})
test('an unexpected successful response is treated as out of sync', async () => {
@@ -213,11 +211,11 @@ describe('authentication session coordination', () => {
wait: async () => undefined,
}
- assert.deepEqual(await createRefreshRunner(runtime)(), {
+ expect(await createRefreshRunner(runtime)()).toEqual({
kind: 'out_of_sync',
code: 'AUTH_INVALID_REFRESH_RESPONSE',
})
- assert.equal(cleared, true)
+ expect(cleared).toBe(true)
})
test('a refresh response cannot restore credentials after a newer auth operation', async () => {
@@ -241,8 +239,8 @@ describe('authentication session coordination', () => {
const outcome = await createRefreshRunner(runtime)()
- assert.equal(outcome.kind, 'transient_error')
- assert.equal(accepted, false)
+ expect(outcome.kind).toBe('transient_error')
+ expect(accepted).toBe(false)
})
test('explicit rotations update only the current session', () => {
@@ -254,41 +252,35 @@ describe('authentication session coordination', () => {
session: { ...bundle.session, last_active_at: 200 },
})
- assert.equal(useAuthStore.getState().auth.accessToken, 'rotated-token')
- assert.strictEqual(useAuthStore.getState().auth.user, bundle.user)
-
- assert.throws(
- () =>
- applyAuthRotation({
- access_token: 'non-bearer-token',
- token_type: 'Custom',
- access_expires_at: bundle.access_expires_at + 120,
- session: bundle.session,
- }),
- /Invalid authentication rotation response/
- )
- assert.throws(
- () =>
- applyAuthRotation({
- access_token: 'non-current-token',
- token_type: 'Bearer',
- access_expires_at: bundle.access_expires_at + 120,
- session: { ...bundle.session, current: false },
- }),
- /Invalid authentication rotation response/
- )
-
- assert.throws(
- () =>
- applyAuthRotation({
- access_token: 'wrong-session-token',
- token_type: 'Bearer',
- access_expires_at: bundle.access_expires_at + 120,
- session: { ...bundle.session, sid: 'session-b' },
- }),
- /session mismatch/
- )
- assert.equal(useAuthStore.getState().auth.accessToken, 'rotated-token')
+ expect(useAuthStore.getState().auth.accessToken).toBe('rotated-token')
+ expect(useAuthStore.getState().auth.user).toBe(bundle.user)
+
+ expect(() =>
+ applyAuthRotation({
+ access_token: 'non-bearer-token',
+ token_type: 'Custom',
+ access_expires_at: bundle.access_expires_at + 120,
+ session: bundle.session,
+ })
+ ).toThrow(/Invalid authentication rotation response/)
+ expect(() =>
+ applyAuthRotation({
+ access_token: 'non-current-token',
+ token_type: 'Bearer',
+ access_expires_at: bundle.access_expires_at + 120,
+ session: { ...bundle.session, current: false },
+ })
+ ).toThrow(/Invalid authentication rotation response/)
+
+ expect(() =>
+ applyAuthRotation({
+ access_token: 'wrong-session-token',
+ token_type: 'Bearer',
+ access_expires_at: bundle.access_expires_at + 120,
+ session: { ...bundle.session, sid: 'session-b' },
+ })
+ ).toThrow(/session mismatch/)
+ expect(useAuthStore.getState().auth.accessToken).toBe('rotated-token')
})
test('sign-out clears user-scoped query, mutation, and authentication state', () => {
@@ -305,13 +297,13 @@ describe('authentication session coordination', () => {
clearAuthenticatedClientState(queryClient, false)
- assert.equal(queryClient.getQueryCache().getAll().length, 0)
- assert.equal(queryClient.getMutationCache().getAll().length, 0)
- assert.equal(useAuthStore.getState().auth.user, null)
- assert.equal(useAuthStore.getState().auth.accessToken, null)
- assert.equal(useAuthStore.getState().auth.session, null)
- assert.equal(useAuthStore.getState().auth.pending2FAFlowToken, null)
- assert.equal(useAuthStore.getState().auth.bootstrapState, 'complete')
+ expect(queryClient.getQueryCache().getAll().length).toBe(0)
+ expect(queryClient.getMutationCache().getAll().length).toBe(0)
+ expect(useAuthStore.getState().auth.user).toBe(null)
+ expect(useAuthStore.getState().auth.accessToken).toBe(null)
+ expect(useAuthStore.getState().auth.session).toBe(null)
+ expect(useAuthStore.getState().auth.pending2FAFlowToken).toBe(null)
+ expect(useAuthStore.getState().auth.bootstrapState).toBe('complete')
const nextBundle: AuthBundle = {
...bundle,
@@ -320,8 +312,7 @@ describe('authentication session coordination', () => {
session: { ...bundle.session, sid: 'session-b' },
}
useAuthStore.getState().auth.setBundle(nextBundle)
- assert.equal(
- queryClient.getQueryData(['account', bundle.user.id]),
+ expect(queryClient.getQueryData(['account', bundle.user.id])).toBe(
undefined
)
})
diff --git a/web/src/lib/legacy-route.test.ts b/web/src/lib/legacy-route.test.ts
index f14a94b11711..737a48506633 100644
--- a/web/src/lib/legacy-route.test.ts
+++ b/web/src/lib/legacy-route.test.ts
@@ -16,8 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { describe, test } from 'node:test'
+import { describe, expect, test } from 'vitest'
import { resolveLegacyRoute } from './legacy-route'
@@ -43,17 +42,15 @@ describe('legacy frontend route migration', () => {
}
for (const [source, target] of Object.entries(routes)) {
- assert.equal(resolveLegacyRoute(source), target)
+ expect(resolveLegacyRoute(source)).toBe(target)
}
})
test('preserves search and hash while applying route-specific behavior', () => {
- assert.equal(
- resolveLegacyRoute('/login?redirect=%2Fkeys#continue'),
+ expect(resolveLegacyRoute('/login?redirect=%2Fkeys#continue')).toBe(
'/sign-in?redirect=%2Fkeys#continue'
)
- assert.equal(
- resolveLegacyRoute('/console/topup?source=email#orders'),
+ expect(resolveLegacyRoute('/console/topup?source=email#orders')).toBe(
'/wallet?source=email#orders'
)
})
@@ -75,23 +72,20 @@ describe('legacy frontend route migration', () => {
}
for (const [tab, target] of Object.entries(settingsTabs)) {
- assert.equal(
- resolveLegacyRoute(`/console/setting?tab=${tab}&from=bookmark#form`),
- `${target}?tab=${tab}&from=bookmark#form`
- )
+ expect(
+ resolveLegacyRoute(`/console/setting?tab=${tab}&from=bookmark#form`)
+ ).toBe(`${target}?tab=${tab}&from=bookmark#form`)
}
- assert.equal(
- resolveLegacyRoute('/console/setting?tab=unknown'),
+ expect(resolveLegacyRoute('/console/setting?tab=unknown')).toBe(
'/system-settings?tab=unknown'
)
})
test('safely redirects unknown console locations without touching new routes', () => {
- assert.equal(
- resolveLegacyRoute('/console/removed?page=2#old'),
+ expect(resolveLegacyRoute('/console/removed?page=2#old')).toBe(
'/dashboard?page=2#old'
)
- assert.equal(resolveLegacyRoute('/dashboard'), null)
- assert.equal(resolveLegacyRoute('/api/status'), null)
+ expect(resolveLegacyRoute('/dashboard')).toBe(null)
+ expect(resolveLegacyRoute('/api/status')).toBe(null)
})
})
diff --git a/web/src/lib/server-error-message.test.ts b/web/src/lib/server-error-message.test.ts
index 9e59f4239b07..f3b1eb848a68 100644
--- a/web/src/lib/server-error-message.test.ts
+++ b/web/src/lib/server-error-message.test.ts
@@ -16,8 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import assert from 'node:assert/strict'
-import { describe, test } from 'node:test'
+import { describe, expect, test } from 'vitest'
import { getServerErrorMessageKey } from './server-error-message'
@@ -25,8 +24,8 @@ describe('server error message mapping', () => {
test('maps the active-session limit to recovery instructions', () => {
const message = getServerErrorMessageKey({ code: 'AUTH_SESSION_LIMIT' })
- assert.match(message ?? '', /Sign out other sessions/)
- assert.match(message ?? '', /reset your password/)
+ expect(message ?? '').toMatch(/Sign out other sessions/)
+ expect(message ?? '').toMatch(/reset your password/)
})
test('maps an Axios-shaped issuance limit to rolling-window guidance', () => {
@@ -34,8 +33,8 @@ describe('server error message mapping', () => {
response: { data: { code: 'AUTH_SESSION_ISSUANCE_LIMIT' } },
})
- assert.match(message ?? '', /rolling window/)
- assert.equal(getServerErrorMessageKey({ code: 'UNKNOWN_CODE' }), null)
+ expect(message ?? '').toMatch(/rolling window/)
+ expect(getServerErrorMessageKey({ code: 'UNKNOWN_CODE' })).toBe(null)
})
test('maps stable Telegram bind errors without exposing server text', () => {
@@ -55,16 +54,15 @@ describe('server error message mapping', () => {
}
for (const [code, message] of Object.entries(expected)) {
- assert.equal(getServerErrorMessageKey({ code }), message)
+ expect(getServerErrorMessageKey({ code })).toBe(message)
}
- assert.equal(
+ expect(
getServerErrorMessageKey({
response: {
data: { code: 'TELEGRAM_BIND_INTERNAL_ERROR', message: 'raw detail' },
},
- }),
- expected.TELEGRAM_BIND_INTERNAL_ERROR
- )
+ })
+ ).toBe(expected.TELEGRAM_BIND_INTERNAL_ERROR)
})
})
diff --git a/web/src/test-setup.ts b/web/src/test-setup.ts
new file mode 100644
index 000000000000..cf898360827d
--- /dev/null
+++ b/web/src/test-setup.ts
@@ -0,0 +1,73 @@
+/*
+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 '@testing-library/jest-dom/vitest'
+import { cleanup } from '@testing-library/react'
+import i18next from 'i18next'
+import { initReactI18next } from 'react-i18next'
+import { afterEach, beforeAll } from 'vitest'
+
+beforeAll(async () => {
+ await i18next.use(initReactI18next).init({
+ lng: 'en',
+ fallbackLng: 'en',
+ resources: {
+ en: {
+ translation: {},
+ },
+ },
+ })
+})
+
+afterEach(() => {
+ cleanup()
+})
+
+Object.defineProperty(window, 'matchMedia', {
+ configurable: true,
+ value: (query: string): MediaQueryList => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => undefined,
+ removeListener: () => undefined,
+ addEventListener: () => undefined,
+ removeEventListener: () => undefined,
+ dispatchEvent: () => false,
+ }),
+})
+
+window.requestAnimationFrame = (callback: FrameRequestCallback) =>
+ window.setTimeout(() => callback(performance.now()), 0)
+window.cancelAnimationFrame = (handle: number) => window.clearTimeout(handle)
+
+class ResizeObserverMock {
+ observe(): void {}
+ unobserve(): void {}
+ disconnect(): void {}
+}
+
+Object.defineProperty(globalThis, 'ResizeObserver', {
+ configurable: true,
+ value: ResizeObserverMock,
+})
+
+Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
+ configurable: true,
+ value: () => undefined,
+})
diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json
index e5b903966588..f576ef0d0b38 100644
--- a/web/tsconfig.node.json
+++ b/web/tsconfig.node.json
@@ -20,5 +20,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
- "include": ["rsbuild.config.ts"]
+ "include": ["rsbuild.config.ts", "vitest.config.ts"]
}
diff --git a/web/vitest.config.ts b/web/vitest.config.ts
new file mode 100644
index 000000000000..144e0f44454d
--- /dev/null
+++ b/web/vitest.config.ts
@@ -0,0 +1,39 @@
+/*
+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 path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+import { defineConfig } from 'vitest/config'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+ test: {
+ environment: 'jsdom',
+ setupFiles: ['./src/test-setup.ts'],
+ clearMocks: true,
+ restoreMocks: true,
+ include: ['src/**/*.{test,spec}.{ts,tsx}'],
+ },
+})
From 3dda1d50c6d4a35edf1c74200fcb02d46d0fd075 Mon Sep 17 00:00:00 2001
From: Seefs <40468931+seefs001@users.noreply.github.com>
Date: Tue, 18 Aug 2026 17:30:59 +0800
Subject: [PATCH 46/99] fix(relaykit): preserve parameterless tools in Claude
conversion (#6862)
---
.../oai_chat/to_claude_messages_req.go | 25 ++----
.../oai_chat/to_claude_messages_req_test.go | 81 +++++++++++++++++++
.../oai_responses/to_claude_messages_req.go | 22 +----
.../internal/shared/claude/schema.go | 16 ++++
4 files changed, 105 insertions(+), 39 deletions(-)
create mode 100644 relaykit/relayconvert/internal/oai_chat/to_claude_messages_req_test.go
create mode 100644 relaykit/relayconvert/internal/shared/claude/schema.go
diff --git a/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req.go b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req.go
index fec9d95e3042..ee4722f571fd 100644
--- a/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req.go
+++ b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req.go
@@ -32,25 +32,14 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
claudeTools := make([]any, 0, len(textRequest.Tools))
for _, tool := range textRequest.Tools {
- if params, ok := tool.Function.Parameters.(map[string]any); ok {
- claudeTool := dto.Tool{
- Name: tool.Function.Name,
- Description: tool.Function.Description,
- }
- claudeTool.InputSchema = make(map[string]interface{})
- if params["type"] != nil {
- claudeTool.InputSchema["type"] = params["type"].(string)
- }
- claudeTool.InputSchema["properties"] = params["properties"]
- claudeTool.InputSchema["required"] = params["required"]
- for key, value := range params {
- if key == "type" || key == "properties" || key == "required" {
- continue
- }
- claudeTool.InputSchema[key] = value
- }
- claudeTools = append(claudeTools, &claudeTool)
+ if _, ok := tool.Function.Parameters.(map[string]any); !ok && tool.Type != "function" {
+ continue
}
+ claudeTools = append(claudeTools, &dto.Tool{
+ Name: tool.Function.Name,
+ Description: tool.Function.Description,
+ InputSchema: sharedclaude.FunctionParametersToInputSchema(tool.Function.Parameters),
+ })
}
if textRequest.WebSearchOptions != nil {
diff --git a/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req_test.go b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req_test.go
new file mode 100644
index 000000000000..c718bd259ae7
--- /dev/null
+++ b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req_test.go
@@ -0,0 +1,81 @@
+package oaichat
+
+import (
+ "context"
+ "testing"
+
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestOpenAIChatRequestToClaudeMessagesNormalizesToolInputSchema(t *testing.T) {
+ tests := []struct {
+ name string
+ parameters any
+ wantSchema map[string]any
+ }{
+ {
+ name: "omitted parameters",
+ parameters: nil,
+ wantSchema: map[string]any{
+ "type": "object",
+ "properties": map[string]any{},
+ },
+ },
+ {
+ name: "missing type and properties",
+ parameters: map[string]any{
+ "additionalProperties": false,
+ },
+ wantSchema: map[string]any{
+ "type": "object",
+ "properties": map[string]any{},
+ "additionalProperties": false,
+ },
+ },
+ {
+ name: "non-string type",
+ parameters: map[string]any{
+ "type": 123,
+ "properties": map[string]any{},
+ },
+ wantSchema: map[string]any{
+ "type": 123,
+ "properties": map[string]any{},
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ maxTokens := uint(1024)
+ got, err := OpenAIChatRequestToClaudeMessages(context.Background(), nil, dto.GeneralOpenAIRequest{
+ Model: "claude-test",
+ MaxTokens: &maxTokens,
+ Messages: []dto.Message{
+ {Role: "user", Content: "Call the tool."},
+ },
+ Tools: []dto.ToolCallRequest{
+ {
+ Type: "function",
+ Function: dto.FunctionRequest{
+ Name: "get_current_time",
+ Description: "Get the current time",
+ Parameters: tt.parameters,
+ },
+ },
+ },
+ })
+
+ require.NoError(t, err)
+ tools, ok := got.Tools.([]any)
+ require.True(t, ok)
+ require.Len(t, tools, 1)
+ tool, ok := tools[0].(*dto.Tool)
+ require.True(t, ok)
+ assert.Equal(t, "get_current_time", tool.Name)
+ assert.Equal(t, tt.wantSchema, tool.InputSchema)
+ })
+ }
+}
diff --git a/relaykit/relayconvert/internal/oai_responses/to_claude_messages_req.go b/relaykit/relayconvert/internal/oai_responses/to_claude_messages_req.go
index 53aa674c8788..3695449f0ab3 100644
--- a/relaykit/relayconvert/internal/oai_responses/to_claude_messages_req.go
+++ b/relaykit/relayconvert/internal/oai_responses/to_claude_messages_req.go
@@ -134,32 +134,12 @@ func responsesFunctionDeclarationsToClaudeTools(functions []dto.FunctionRequest)
tools = append(tools, &dto.Tool{
Name: function.Name,
Description: function.Description,
- InputSchema: responsesFunctionParametersToClaudeInputSchema(function.Parameters),
+ InputSchema: sharedclaude.FunctionParametersToInputSchema(function.Parameters),
})
}
return tools
}
-func responsesFunctionParametersToClaudeInputSchema(parameters any) map[string]interface{} {
- if params, ok := parameters.(map[string]any); ok {
- schema := make(map[string]interface{}, len(params))
- for key, value := range params {
- schema[key] = value
- }
- if schema["type"] == nil {
- schema["type"] = "object"
- }
- if schema["properties"] == nil {
- schema["properties"] = map[string]interface{}{}
- }
- return schema
- }
- return map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{},
- }
-}
-
func applyResponsesReasoningToClaude(req *dto.OpenAIResponsesRequest, claudeRequest *dto.ClaudeRequest) {
effort := ReasoningEffort(req)
switch effort {
diff --git a/relaykit/relayconvert/internal/shared/claude/schema.go b/relaykit/relayconvert/internal/shared/claude/schema.go
new file mode 100644
index 000000000000..4878130d80d2
--- /dev/null
+++ b/relaykit/relayconvert/internal/shared/claude/schema.go
@@ -0,0 +1,16 @@
+package claude
+
+func FunctionParametersToInputSchema(parameters any) map[string]any {
+ params, _ := parameters.(map[string]any)
+ schema := make(map[string]any, len(params)+2)
+ for key, value := range params {
+ schema[key] = value
+ }
+ if schema["type"] == nil {
+ schema["type"] = "object"
+ }
+ if schema["properties"] == nil {
+ schema["properties"] = map[string]any{}
+ }
+ return schema
+}
From 2b0efd8484cc1e20b6de64f8600586fe61dee867 Mon Sep 17 00:00:00 2001
From: Seefs <40468931+seefs001@users.noreply.github.com>
Date: Tue, 18 Aug 2026 17:31:21 +0800
Subject: [PATCH 47/99] refactor: advanced custom channel route editor (#6865)
* refactor: advanced custom channel route editor
* fix(channels): show raw balance response from balance cell
---
common/json.go | 8 +
controller/channel-billing.go | 149 ++-
controller/channel_upstream_update.go | 30 +-
controller/channel_upstream_update_test.go | 9 +
relay/channel/advancedcustom/adaptor.go | 23 +-
relay/channel/advancedcustom/adaptor_test.go | 43 +
relaykit/dto/channel_settings.go | 45 +-
relaykit/dto/channel_settings_test.go | 64 ++
.../channels/components/channels-columns.tsx | 53 +-
.../dialogs/advanced-custom-editor-dialog.tsx | 951 ++++++++++++------
.../dialogs/balance-query-dialog.tsx | 90 +-
.../features/channels/lib/advanced-custom.ts | 285 +++---
.../features/channels/lib/channel-actions.ts | 38 -
web/src/features/channels/types.ts | 1 +
web/src/i18n/locales/en.json | 38 +
web/src/i18n/locales/fr.json | 38 +
web/src/i18n/locales/ja.json | 38 +
web/src/i18n/locales/ru.json | 38 +
web/src/i18n/locales/vi.json | 38 +
web/src/i18n/locales/zh-TW.json | 38 +
web/src/i18n/locales/zh.json | 38 +
21 files changed, 1550 insertions(+), 505 deletions(-)
diff --git a/common/json.go b/common/json.go
index 1625be6d51f7..d7effa36ef32 100644
--- a/common/json.go
+++ b/common/json.go
@@ -22,6 +22,14 @@ func Marshal(v any) ([]byte, error) {
return json.Marshal(v)
}
+func IndentJson(data []byte) ([]byte, error) {
+ var buffer bytes.Buffer
+ if err := json.Indent(&buffer, data, "", " "); err != nil {
+ return nil, err
+ }
+ return buffer.Bytes(), nil
+}
+
func GetJsonType(data json.RawMessage) string {
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 {
diff --git a/controller/channel-billing.go b/controller/channel-billing.go
index 62982d2f5ceb..5974628d01ef 100644
--- a/controller/channel-billing.go
+++ b/controller/channel-billing.go
@@ -5,13 +5,19 @@ import (
"errors"
"fmt"
"io"
+ "math"
"net/http"
"strconv"
+ "strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/relay/channel/advancedcustom"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ relayconstant "github.com/QuantumNous/new-api/relay/constant"
+ "github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/operation_setting"
@@ -47,6 +53,13 @@ type OpenAICreditGrants struct {
TotalAvailable float64 `json:"total_available"`
}
+const maxAdvancedCustomBalanceResponseBytes = 256 << 10
+
+type channelBalanceResult struct {
+ Balance float64
+ RawResponse string
+}
+
type OpenAIUsageResponse struct {
Object string `json:"object"`
//DailyCosts []OpenAIUsageDailyCost `json:"daily_costs"`
@@ -174,7 +187,7 @@ func updateChannelCloseAIBalance(channel *model.Channel) (float64, error) {
return 0, err
}
response := OpenAICreditGrants{}
- err = json.Unmarshal(body, &response)
+ err = common.Unmarshal(body, &response)
if err != nil {
return 0, err
}
@@ -189,7 +202,7 @@ func updateChannelOpenAISBBalance(channel *model.Channel) (float64, error) {
return 0, err
}
response := OpenAISBUsageResponse{}
- err = json.Unmarshal(body, &response)
+ err = common.Unmarshal(body, &response)
if err != nil {
return 0, err
}
@@ -213,7 +226,7 @@ func updateChannelAIProxyBalance(channel *model.Channel) (float64, error) {
return 0, err
}
response := AIProxyUserOverviewResponse{}
- err = json.Unmarshal(body, &response)
+ err = common.Unmarshal(body, &response)
if err != nil {
return 0, err
}
@@ -232,7 +245,7 @@ func updateChannelAPI2GPTBalance(channel *model.Channel) (float64, error) {
return 0, err
}
response := API2GPTUsageResponse{}
- err = json.Unmarshal(body, &response)
+ err = common.Unmarshal(body, &response)
if err != nil {
return 0, err
}
@@ -247,7 +260,7 @@ func updateChannelSiliconFlowBalance(channel *model.Channel) (float64, error) {
return 0, err
}
response := SiliconFlowUsageResponse{}
- err = json.Unmarshal(body, &response)
+ err = common.Unmarshal(body, &response)
if err != nil {
return 0, err
}
@@ -269,7 +282,7 @@ func updateChannelDeepSeekBalance(channel *model.Channel) (float64, error) {
return 0, err
}
response := DeepSeekUsageResponse{}
- err = json.Unmarshal(body, &response)
+ err = common.Unmarshal(body, &response)
if err != nil {
return 0, err
}
@@ -298,7 +311,7 @@ func updateChannelAIGC2DBalance(channel *model.Channel) (float64, error) {
return 0, err
}
response := APGC2DGPTUsageResponse{}
- err = json.Unmarshal(body, &response)
+ err = common.Unmarshal(body, &response)
if err != nil {
return 0, err
}
@@ -313,7 +326,7 @@ func updateChannelOpenRouterBalance(channel *model.Channel) (float64, error) {
return 0, err
}
response := OpenRouterCreditResponse{}
- err = json.Unmarshal(body, &response)
+ err = common.Unmarshal(body, &response)
if err != nil {
return 0, err
}
@@ -343,7 +356,7 @@ func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) {
}
response := MoonshotBalanceResponse{}
- err = json.Unmarshal(body, &response)
+ err = common.Unmarshal(body, &response)
if err != nil {
return 0, err
}
@@ -356,7 +369,100 @@ func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) {
return availableBalanceUsd, nil
}
-func updateChannelBalance(channel *model.Channel) (float64, error) {
+func fetchAdvancedCustomBalance(channel *model.Channel) (channelBalanceResult, error) {
+ key := strings.TrimSpace(channel.Key)
+ info := &relaycommon.RelayInfo{
+ RelayFormat: types.RelayFormatOpenAI,
+ RelayMode: relayconstant.RelayModeUnknown,
+ RequestURLPath: dto.AdvancedCustomBalancePath,
+ ChannelMeta: &relaycommon.ChannelMeta{
+ ChannelType: constant.ChannelTypeAdvancedCustom,
+ ChannelBaseUrl: channel.GetBaseURL(),
+ ApiKey: key,
+ ChannelOtherSettings: channel.GetOtherSettings(),
+ },
+ }
+ requestURL, headers, err := (&advancedcustom.Adaptor{}).BuildBalanceRequest(info)
+ if err != nil {
+ return channelBalanceResult{}, sanitizeFetchModelsError(err, key)
+ }
+ if err := applyFetchModelsHeaderOverrides(channel, key, headers); err != nil {
+ return channelBalanceResult{}, sanitizeFetchModelsError(err, key)
+ }
+
+ request, err := http.NewRequest(http.MethodGet, requestURL, nil)
+ if err != nil {
+ return channelBalanceResult{}, sanitizeFetchModelsError(err, key)
+ }
+ for name, values := range headers {
+ for _, value := range values {
+ request.Header.Add(name, value)
+ }
+ if strings.EqualFold(name, "Host") {
+ request.Host = headers.Get(name)
+ }
+ }
+ client, err := service.GetHttpClientWithProxy(channel.GetSetting().Proxy)
+ if err != nil {
+ return channelBalanceResult{}, sanitizeFetchModelsError(err, key)
+ }
+ response, err := client.Do(request)
+ if err != nil {
+ return channelBalanceResult{}, sanitizeAdvancedCustomRequestError(err, key, requestURL)
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusOK {
+ return channelBalanceResult{}, fmt.Errorf("status code: %d", response.StatusCode)
+ }
+ body, err := io.ReadAll(io.LimitReader(response.Body, maxAdvancedCustomBalanceResponseBytes+1))
+ if err != nil {
+ return channelBalanceResult{}, sanitizeAdvancedCustomRequestError(err, key, requestURL)
+ }
+ if len(body) > maxAdvancedCustomBalanceResponseBytes {
+ return channelBalanceResult{}, fmt.Errorf("balance response exceeds %d bytes", maxAdvancedCustomBalanceResponseBytes)
+ }
+
+ var validated json.RawMessage
+ if err := common.Unmarshal(body, &validated); err != nil {
+ return channelBalanceResult{}, fmt.Errorf("invalid balance JSON response: %w", err)
+ }
+ if common.GetJsonType(validated) == "object" {
+ var creditSummary struct {
+ Object string `json:"object"`
+ TotalAvailable json.RawMessage `json:"total_available"`
+ }
+ if err := common.Unmarshal(body, &creditSummary); err != nil {
+ return channelBalanceResult{}, fmt.Errorf("invalid balance JSON response: %w", err)
+ }
+ if creditSummary.Object == "credit_summary" &&
+ common.GetJsonType(creditSummary.TotalAvailable) == "number" {
+ var balance float64
+ if err := common.Unmarshal(creditSummary.TotalAvailable, &balance); err == nil &&
+ balance >= 0 &&
+ !math.IsNaN(balance) &&
+ !math.IsInf(balance, 0) {
+ channel.UpdateBalance(balance)
+ return channelBalanceResult{Balance: balance}, nil
+ }
+ }
+ }
+
+ formatted, err := common.IndentJson(body)
+ if err != nil {
+ return channelBalanceResult{}, fmt.Errorf("invalid balance JSON response: %w", err)
+ }
+ return channelBalanceResult{RawResponse: string(formatted)}, nil
+}
+
+func updateChannelBalance(channel *model.Channel) (channelBalanceResult, error) {
+ if channel.Type == constant.ChannelTypeAdvancedCustom {
+ return fetchAdvancedCustomBalance(channel)
+ }
+ balance, err := updateStandardChannelBalance(channel)
+ return channelBalanceResult{Balance: balance}, err
+}
+
+func updateStandardChannelBalance(channel *model.Channel) (float64, error) {
baseURL := constant.ChannelBaseURLs[channel.Type]
if channel.GetBaseURL() == "" {
channel.BaseURL = &baseURL
@@ -396,7 +502,7 @@ func updateChannelBalance(channel *model.Channel) (float64, error) {
return 0, err
}
subscription := OpenAISubscriptionResponse{}
- err = json.Unmarshal(body, &subscription)
+ err = common.Unmarshal(body, &subscription)
if err != nil {
return 0, err
}
@@ -412,7 +518,7 @@ func updateChannelBalance(channel *model.Channel) (float64, error) {
return 0, err
}
usage := OpenAIUsageResponse{}
- err = json.Unmarshal(body, &usage)
+ err = common.Unmarshal(body, &usage)
if err != nil {
return 0, err
}
@@ -439,16 +545,21 @@ func UpdateChannelBalance(c *gin.Context) {
})
return
}
- balance, err := updateChannelBalance(channel)
+ result, err := updateChannelBalance(channel)
if err != nil {
common.ApiError(c, err)
return
}
- c.JSON(http.StatusOK, gin.H{
+ response := gin.H{
"success": true,
"message": "",
- "balance": balance,
- })
+ }
+ if result.RawResponse == "" {
+ response["balance"] = result.Balance
+ } else {
+ response["raw_response"] = result.RawResponse
+ }
+ c.JSON(http.StatusOK, response)
}
func updateAllChannelsBalance() error {
@@ -467,12 +578,12 @@ func updateAllChannelsBalance() error {
//if channel.Type != common.ChannelTypeOpenAI && channel.Type != common.ChannelTypeCustom {
// continue
//}
- balance, err := updateChannelBalance(channel)
+ result, err := updateChannelBalance(channel)
if err != nil {
continue
- } else {
+ } else if result.RawResponse == "" {
// err is nil & balance <= 0 means quota is used up
- if balance <= 0 {
+ if result.Balance <= 0 {
service.DisableChannel(*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, "", channel.GetAutoBan()), "余额不足")
}
}
diff --git a/controller/channel_upstream_update.go b/controller/channel_upstream_update.go
index 71ab0e53fafe..e1918c257c19 100644
--- a/controller/channel_upstream_update.go
+++ b/controller/channel_upstream_update.go
@@ -304,6 +304,34 @@ func sanitizeFetchModelsError(err error, key string) error {
return errors.New(message)
}
+func sanitizeAdvancedCustomRequestError(err error, key string, requestURL string) error {
+ err = sanitizeFetchModelsError(err, key)
+ if err == nil {
+ return nil
+ }
+ parsedURL, parseErr := url.Parse(requestURL)
+ if parseErr != nil {
+ return err
+ }
+ message := err.Error()
+ for _, value := range parsedURL.Query() {
+ for _, secret := range value {
+ if secret == "" {
+ continue
+ }
+ message = strings.ReplaceAll(message, secret, "[REDACTED]")
+ message = strings.ReplaceAll(message, url.QueryEscape(secret), "[REDACTED]")
+ message = strings.ReplaceAll(message, url.PathEscape(secret), "[REDACTED]")
+ }
+ }
+ if key != "" {
+ message = strings.ReplaceAll(message, key, "[REDACTED]")
+ message = strings.ReplaceAll(message, url.QueryEscape(key), "[REDACTED]")
+ message = strings.ReplaceAll(message, url.PathEscape(key), "[REDACTED]")
+ }
+ return errors.New(message)
+}
+
func getFetchModelsResponseBody(method string, requestURL string, channel *model.Channel, headers http.Header) ([]byte, error) {
request, err := http.NewRequest(method, requestURL, nil)
if err != nil {
@@ -409,7 +437,7 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
body, err := getFetchModelsResponseBody(http.MethodGet, url, channel, headers)
if err != nil {
- return nil, sanitizeFetchModelsError(err, key)
+ return nil, sanitizeAdvancedCustomRequestError(err, key, url)
}
var result OpenAIModelsResponse
diff --git a/controller/channel_upstream_update_test.go b/controller/channel_upstream_update_test.go
index 0a524d704bdf..5cb4fac4b0bf 100644
--- a/controller/channel_upstream_update_test.go
+++ b/controller/channel_upstream_update_test.go
@@ -168,6 +168,15 @@ func TestFetchAdvancedCustomModelsRedactsQueryKeyFromTransportErrors(t *testing.
Err: errors.New("connection refused"),
}, secret)
require.EqualError(t, direct, "connection refused")
+
+ queryValue := "prefix-" + secret
+ queryError := sanitizeAdvancedCustomRequestError(
+ errors.New("dial "+queryValue+": connection refused"),
+ queryValue,
+ baseURL+"/v1/models?custom-token="+url.QueryEscape(queryValue),
+ )
+ require.NotContains(t, queryError.Error(), queryValue)
+ require.EqualError(t, queryError, "dial [REDACTED]: connection refused")
}
func TestFetchOrdinaryOpenAIModelsKeepsExistingEmptyDataBehavior(t *testing.T) {
diff --git a/relay/channel/advancedcustom/adaptor.go b/relay/channel/advancedcustom/adaptor.go
index 74af2d5ce1c8..d5f1ed7e47bf 100644
--- a/relay/channel/advancedcustom/adaptor.go
+++ b/relay/channel/advancedcustom/adaptor.go
@@ -194,6 +194,14 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
}
func (a *Adaptor) BuildModelListRequest(info *relaycommon.RelayInfo) (string, http.Header, error) {
+ return a.buildManagementRequest(info, dto.AdvancedCustomModelListPath)
+}
+
+func (a *Adaptor) BuildBalanceRequest(info *relaycommon.RelayInfo) (string, http.Header, error) {
+ return a.buildManagementRequest(info, dto.AdvancedCustomBalancePath)
+}
+
+func (a *Adaptor) buildManagementRequest(info *relaycommon.RelayInfo, managementPath string) (string, http.Header, error) {
if info == nil {
return "", nil, errors.New("missing relay info")
}
@@ -204,16 +212,25 @@ func (a *Adaptor) BuildModelListRequest(info *relaycommon.RelayInfo) (string, ht
if err := config.Validate(); err != nil {
return "", nil, err
}
- route, ok := config.ModelListRoute()
+ var route dto.AdvancedCustomRoute
+ var ok bool
+ switch managementPath {
+ case dto.AdvancedCustomModelListPath:
+ route, ok = config.ModelListRoute()
+ case dto.AdvancedCustomBalancePath:
+ route, ok = config.BalanceRoute()
+ default:
+ return "", nil, fmt.Errorf("unsupported advanced custom management path: %s", managementPath)
+ }
if !ok {
- return "", nil, errors.New("advanced custom channel does not configure a /v1/models route")
+ return "", nil, fmt.Errorf("advanced custom channel does not configure a %s route", managementPath)
}
converter := strings.TrimSpace(route.Converter)
if converter == "" {
converter = relayconvert.ConverterNone
}
if converter != relayconvert.ConverterNone {
- return "", nil, fmt.Errorf("converter %q does not support model list requests", converter)
+ return "", nil, fmt.Errorf("converter %q does not support %s requests", converter, managementPath)
}
requestURL, err := buildRouteURL(route, converter, info)
diff --git a/relay/channel/advancedcustom/adaptor_test.go b/relay/channel/advancedcustom/adaptor_test.go
index 5a11a972447a..85672cbf89b4 100644
--- a/relay/channel/advancedcustom/adaptor_test.go
+++ b/relay/channel/advancedcustom/adaptor_test.go
@@ -422,6 +422,49 @@ func TestAdaptorBuildModelListRequestRequiresConfiguredRoute(t *testing.T) {
assert.Contains(t, err.Error(), "does not configure a /v1/models route")
}
+func TestAdaptorBuildBalanceRequestUsesConfiguredRoute(t *testing.T) {
+ info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
+ Routes: []dto.AdvancedCustomRoute{
+ {
+ IncomingPath: dto.AdvancedCustomModelListPath,
+ UpstreamPath: "/provider/models",
+ },
+ {
+ IncomingPath: dto.AdvancedCustomBalancePath,
+ UpstreamPath: "/provider/balance?existing=1",
+ Auth: &dto.AdvancedCustomRouteAuth{
+ Type: dto.AdvancedCustomAuthTypeQuery,
+ Name: "token",
+ Value: "prefix-{api_key}",
+ },
+ },
+ },
+ })
+
+ requestURL, header, err := (&Adaptor{}).BuildBalanceRequest(info)
+ require.NoError(t, err)
+
+ parsedURL, err := url.Parse(requestURL)
+ require.NoError(t, err)
+ assert.Equal(t, "/provider/balance", parsedURL.Path)
+ assert.Equal(t, "1", parsedURL.Query().Get("existing"))
+ assert.Equal(t, "prefix-sk-test", parsedURL.Query().Get("token"))
+ assert.Empty(t, header.Get("Authorization"))
+}
+
+func TestAdaptorBuildBalanceRequestRequiresConfiguredRoute(t *testing.T) {
+ info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
+ Routes: []dto.AdvancedCustomRoute{{
+ IncomingPath: dto.AdvancedCustomModelListPath,
+ UpstreamPath: "/provider/models",
+ }},
+ })
+
+ _, _, err := (&Adaptor{}).BuildBalanceRequest(info)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "does not configure a /v1/dashboard/billing/credit_grants route")
+}
+
func TestAdaptorConvertsResponsesRequestToOpenAIChatUpstream(t *testing.T) {
adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
diff --git a/relaykit/dto/channel_settings.go b/relaykit/dto/channel_settings.go
index d3ede20d69c5..4b4e71911283 100644
--- a/relaykit/dto/channel_settings.go
+++ b/relaykit/dto/channel_settings.go
@@ -145,8 +145,12 @@ const (
advancedCustomEndpointPathEmbeddings = "/v1/embeddings"
)
-// AdvancedCustomModelListPath identifies the optional OpenAI Models discovery route.
-const AdvancedCustomModelListPath = "/v1/models"
+const (
+ // AdvancedCustomModelListPath identifies the optional OpenAI Models discovery route.
+ AdvancedCustomModelListPath = "/v1/models"
+ // AdvancedCustomBalancePath identifies the optional balance lookup route used by channel management.
+ AdvancedCustomBalancePath = "/v1/dashboard/billing/credit_grants"
+)
// MatchPath returns the first route whose IncomingPath matches requestPath.
// Matching mirrors the relay adaptor: exact match, {model} placeholder, and
@@ -193,6 +197,19 @@ func (c *AdvancedCustomConfig) ModelListRoute() (AdvancedCustomRoute, bool) {
return AdvancedCustomRoute{}, false
}
+// BalanceRoute returns the explicitly configured channel-management balance route.
+func (c *AdvancedCustomConfig) BalanceRoute() (AdvancedCustomRoute, bool) {
+ if c == nil {
+ return AdvancedCustomRoute{}, false
+ }
+ for _, route := range c.Routes {
+ if strings.TrimSpace(route.IncomingPath) == AdvancedCustomBalancePath {
+ return route, true
+ }
+ }
+ return AdvancedCustomRoute{}, false
+}
+
// SupportsPath reports whether any route matches requestPath.
func (c *AdvancedCustomConfig) SupportsPath(requestPath string) bool {
_, ok := c.MatchPath(requestPath)
@@ -360,6 +377,7 @@ func (c *AdvancedCustomConfig) Validate() error {
paths := make(map[string]*advancedCustomPathModelState, len(c.Routes))
modelListRouteIndex := -1
+ balanceRouteIndex := -1
for i := range c.Routes {
route := c.Routes[i]
route.IncomingPath = strings.TrimSpace(route.IncomingPath)
@@ -378,19 +396,28 @@ func (c *AdvancedCustomConfig) Validate() error {
if strings.Contains(route.IncomingPath, "?") {
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must not include query", i)
}
- if route.IncomingPath == AdvancedCustomModelListPath {
- if modelListRouteIndex >= 0 {
- return fmt.Errorf("advanced_custom.advanced_routes[%d] duplicates the /v1/models route at advanced_routes[%d]", i, modelListRouteIndex)
+ if route.IncomingPath == AdvancedCustomModelListPath || route.IncomingPath == AdvancedCustomBalancePath {
+ managementRouteName := route.IncomingPath
+ previousIndex := modelListRouteIndex
+ if route.IncomingPath == AdvancedCustomBalancePath {
+ previousIndex = balanceRouteIndex
+ }
+ if previousIndex >= 0 {
+ return fmt.Errorf("advanced_custom.advanced_routes[%d] duplicates the %s route at advanced_routes[%d]", i, managementRouteName, previousIndex)
+ }
+ if route.IncomingPath == AdvancedCustomModelListPath {
+ modelListRouteIndex = i
+ } else {
+ balanceRouteIndex = i
}
- modelListRouteIndex = i
if len(normalizeAdvancedCustomRouteModels(route.Models)) > 0 {
- return fmt.Errorf("advanced_custom.advanced_routes[%d].models must be empty for /v1/models", i)
+ return fmt.Errorf("advanced_custom.advanced_routes[%d].models must be empty for %s", i, managementRouteName)
}
if route.Converter != advancedCustomConverterNone {
- return fmt.Errorf("advanced_custom.advanced_routes[%d].converter must be none for /v1/models", i)
+ return fmt.Errorf("advanced_custom.advanced_routes[%d].converter must be none for %s", i, managementRouteName)
}
if strings.Contains(upstreamPath, advancedCustomModelPlaceholder) {
- return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must not contain %s for /v1/models", i, advancedCustomModelPlaceholder)
+ return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must not contain %s for %s", i, advancedCustomModelPlaceholder, managementRouteName)
}
}
if err := validateAdvancedCustomRouteModels(i, route.IncomingPath, route.Models, paths); err != nil {
diff --git a/relaykit/dto/channel_settings_test.go b/relaykit/dto/channel_settings_test.go
index d482679a1a4c..e84988731bf8 100644
--- a/relaykit/dto/channel_settings_test.go
+++ b/relaykit/dto/channel_settings_test.go
@@ -147,6 +147,70 @@ func TestAdvancedCustomModelListRouteRequiresExactIncomingPath(t *testing.T) {
assert.Equal(t, "/provider/models", route.UpstreamPath)
}
+func TestAdvancedCustomValidateBalanceRouteConstraints(t *testing.T) {
+ valid := &AdvancedCustomConfig{
+ Routes: []AdvancedCustomRoute{{
+ IncomingPath: AdvancedCustomBalancePath,
+ UpstreamPath: "/provider/balance",
+ Converter: advancedCustomConverterNone,
+ }},
+ }
+ require.NoError(t, valid.Validate())
+
+ route, ok := valid.BalanceRoute()
+ require.True(t, ok)
+ assert.Equal(t, "/provider/balance", route.UpstreamPath)
+
+ tests := []struct {
+ name string
+ routes []AdvancedCustomRoute
+ want string
+ }{
+ {
+ name: "model matching rules",
+ routes: []AdvancedCustomRoute{{
+ IncomingPath: AdvancedCustomBalancePath,
+ UpstreamPath: "/provider/balance",
+ Models: []string{"gpt-4o"},
+ }},
+ want: "models must be empty",
+ },
+ {
+ name: "converter",
+ routes: []AdvancedCustomRoute{{
+ IncomingPath: AdvancedCustomBalancePath,
+ UpstreamPath: "/provider/balance",
+ Converter: advancedCustomConverterOpenAIChatToOpenAIResponses,
+ }},
+ want: "converter must be none",
+ },
+ {
+ name: "model placeholder",
+ routes: []AdvancedCustomRoute{{
+ IncomingPath: AdvancedCustomBalancePath,
+ UpstreamPath: "/provider/{model}/balance",
+ }},
+ want: "upstream_path must not contain {model}",
+ },
+ {
+ name: "duplicate routes",
+ routes: []AdvancedCustomRoute{
+ {IncomingPath: AdvancedCustomBalancePath, UpstreamPath: "/provider/balance"},
+ {IncomingPath: AdvancedCustomBalancePath, UpstreamPath: "/provider/credits"},
+ },
+ want: "duplicates the /v1/dashboard/billing/credit_grants route",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := (&AdvancedCustomConfig{Routes: tt.routes}).Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.want)
+ })
+ }
+}
+
func TestAdvancedCustomValidateDuplicateIncomingPathWithDisjointModels(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
diff --git a/web/src/features/channels/components/channels-columns.tsx b/web/src/features/channels/components/channels-columns.tsx
index 36dc8f677625..ad6fadcd7c95 100644
--- a/web/src/features/channels/components/channels-columns.tsx
+++ b/web/src/features/channels/components/channels-columns.tsx
@@ -55,7 +55,7 @@ import {
import { formatTimestampToDate } from '@/lib/format'
import { truncateText } from '@/lib/utils'
-import { getCodexUsage } from '../api'
+import { getCodexUsage, updateChannelBalance } from '../api'
import { CHANNEL_STATUS_CONFIG, MODEL_FETCHABLE_TYPES } from '../constants'
import {
formatRelativeTime,
@@ -68,9 +68,9 @@ import {
parseModelsList,
parseGroupsList,
parseChannelSettings,
+ channelsQueryKeys,
handleUpdateChannelField,
handleUpdateTagField,
- handleUpdateChannelBalance,
createChannelFieldUpdateScheduler,
isTagAggregateRow,
type TagRow,
@@ -81,6 +81,7 @@ import { ChannelRowActionsLayoutContext } from './channel-row-actions-context'
import { useChannels } from './channels-provider'
import { DataTableRowActions } from './data-table-row-actions'
import { DataTableTagRowActions } from './data-table-tag-row-actions'
+import { BalanceQueryDialog } from './dialogs/balance-query-dialog'
import {
CodexUsageDialog,
type CodexUsageDialogData,
@@ -325,15 +326,18 @@ const SENSITIVE_MASK = '••••'
/**
* Balance cell component with click to update
*/
-function BalanceCell({ channel }: { channel: Channel }) {
+export function BalanceCell({ channel }: { channel: Channel }) {
const { t, i18n } = useTranslation()
const queryClient = useQueryClient()
const layout = useContext(ChannelRowActionsLayoutContext)
- const { sensitiveVisible } = useChannels()
+ const { sensitiveVisible, setCurrentRow } = useChannels()
const isTagRow = isTagAggregateRow(channel)
const balance = channel.balance || 0
const usedQuota = channel.used_quota || 0
const [isUpdating, setIsUpdating] = useState(false)
+ const [rawBalanceResponse, setRawBalanceResponse] = useState(
+ null
+ )
const [codexUsageOpen, setCodexUsageOpen] = useState(false)
const [codexUsageResponse, setCodexUsageResponse] =
useState(null)
@@ -442,8 +446,34 @@ function BalanceCell({ channel }: { channel: Channel }) {
return
}
- await handleUpdateChannelBalance(channel.id, queryClient)
- setIsUpdating(false)
+ try {
+ const response = await updateChannelBalance(channel.id)
+ if (response.success && response.balance !== undefined) {
+ toast.success(
+ t('Balance updated: {{balance}}', {
+ balance: formatCurrencyFromUSD(response.balance, {
+ digitsLarge: 2,
+ digitsSmall: 4,
+ abbreviate: false,
+ }),
+ })
+ )
+ void queryClient.invalidateQueries({
+ queryKey: channelsQueryKeys.lists(),
+ })
+ } else if (response.success && response.raw_response !== undefined) {
+ setCurrentRow(channel)
+ setRawBalanceResponse(response.raw_response)
+ } else {
+ toast.error(response.message || t('Failed to update balance'))
+ }
+ } catch (error: unknown) {
+ toast.error(
+ error instanceof Error ? error.message : t('Failed to update balance')
+ )
+ } finally {
+ setIsUpdating(false)
+ }
}
let remainingBadgeLabel = sensitiveVisible ? remainingDisplay : SENSITIVE_MASK
if (sensitiveVisible && isUpdating) {
@@ -536,6 +566,17 @@ function BalanceCell({ channel }: { channel: Channel }) {
}}
isRefreshing={isUpdating}
/>
+ {rawBalanceResponse !== null && (
+ {
+ if (!open) {
+ setRawBalanceResponse(null)
+ }
+ }}
+ />
+ )}
)
}
diff --git a/web/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx b/web/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx
index 614de2cc265d..1941b3c79aa9 100644
--- a/web/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx
+++ b/web/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx
@@ -22,21 +22,32 @@ import {
ArrowRight,
ArrowUp,
Check,
+ ChevronDown,
+ ChevronRight,
+ CircleDollarSign,
+ Code2,
Info,
+ ListTree,
Plus,
Shuffle,
Trash2,
type LucideIcon,
} from 'lucide-react'
-import { type ReactNode, useMemo, useRef, useState } from 'react'
+import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
+import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { JsonCodeEditor } from '@/components/json-code-editor'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input'
import {
Popover,
@@ -55,6 +66,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Tooltip,
TooltipContent,
@@ -64,6 +76,8 @@ import {
import { cn } from '@/lib/utils'
import {
+ ADVANCED_CUSTOM_BALANCE_LABEL,
+ ADVANCED_CUSTOM_BALANCE_PATH,
ADVANCED_CUSTOM_AUTH_MODE_OPTIONS,
ADVANCED_CUSTOM_CONVERTER_OPTIONS,
ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS,
@@ -73,22 +87,27 @@ import {
type AdvancedCustomAuthMode,
buildAdvancedCustomAuth,
createAdvancedCustomConfig,
+ createAdvancedCustomManagementRoute,
createAdvancedCustomRoute,
getAdvancedCustomAuthMode,
getAdvancedCustomConverterDefaults,
getAdvancedCustomConverterOptions,
getAdvancedCustomIncomingPathLabel,
getAdvancedCustomModelRuleKind,
+ getAdvancedCustomManagementRoute,
getAdvancedCustomRegexModelPattern,
getAdvancedCustomTemplateConfig,
getAdvancedCustomUpstreamPathPlaceholder,
getDefaultAdvancedCustomIncomingPath,
isAdvancedCustomIncomingPathAllowed,
+ isAdvancedCustomManagementPath,
normalizeAdvancedCustomConfig,
parseAdvancedCustomRouteModels,
parseAdvancedCustomConfig,
stringifyAdvancedCustomConfig,
validateAdvancedCustomConfig,
+ replaceAdvancedCustomForwardingRoutes,
+ replaceAdvancedCustomManagementRoute,
} from '../../lib/advanced-custom'
import type {
AdvancedCustomAuthType,
@@ -104,18 +123,24 @@ type AdvancedCustomEditorDialogProps = {
onSave: (value: string) => void
}
-type AdvancedCustomEditMode = 'visual' | 'json'
+type AdvancedCustomEditorTab = 'forwarding' | 'models' | 'balance' | 'json'
const longSelectContentClass = 'w-[360px] max-w-[calc(100vw-2rem)]'
const longSelectItemClass =
'items-start py-2 [&_[data-slot=select-item-text]]:min-w-0 [&_[data-slot=select-item-text]]:shrink [&_[data-slot=select-item-text]]:whitespace-normal'
const routeEditorGridClassName =
- 'lg:grid-cols-[6rem_minmax(0,1fr)_minmax(0,1.25fr)_minmax(0,1fr)_minmax(0,0.85fr)_7rem]'
+ 'lg:grid-cols-[minmax(9rem,0.9fr)_minmax(0,1fr)_minmax(0,1.25fr)_minmax(0,1.1fr)_minmax(0,0.85fr)_7rem]'
const upstreamPathDescriptionKey =
'Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.'
const catchAllOrderErrorMessage =
'Catch-all route must be last for the same incoming path'
const emptyAdvancedRoutes: AdvancedCustomRoute[] = []
+const advancedCustomTabs = new Set([
+ 'forwarding',
+ 'models',
+ 'balance',
+ 'json',
+])
type AdvancedCustomRouteRow = {
route: AdvancedCustomRoute
@@ -143,6 +168,52 @@ function isCatchAllRoute(route: AdvancedCustomRoute): boolean {
return !route.models || route.models.length === 0
}
+function getRouteConverterLabel(route: AdvancedCustomRoute): string {
+ const converter = route.converter || 'none'
+ return (
+ ADVANCED_CUSTOM_CONVERTER_OPTIONS.find(
+ (option) => option.value === converter
+ )?.triggerLabel || converter
+ )
+}
+
+function getRouteConverters(
+ routes: AdvancedCustomRoute[]
+): Array<{ converter: AdvancedCustomConverter; label: string }> {
+ const converters = new Map<
+ AdvancedCustomConverter,
+ { converter: AdvancedCustomConverter; label: string }
+ >()
+ for (const route of routes) {
+ const converter = route.converter || 'none'
+ if (!converters.has(converter)) {
+ converters.set(converter, {
+ converter,
+ label: getRouteConverterLabel(route),
+ })
+ }
+ }
+ return [...converters.values()]
+}
+
+export function RouteModeBadges(props: { routes: AdvancedCustomRoute[] }) {
+ const { t } = useTranslation()
+ return getRouteConverters(props.routes).map((item) => (
+
+ {item.converter === 'none' ? (
+
+ ) : (
+
+ )}
+ {t(item.label)}
+
+ ))
+}
+
function buildRouteGroups(
routeRows: AdvancedCustomRouteRow[]
): AdvancedCustomRouteGroup[] {
@@ -182,7 +253,8 @@ export function AdvancedCustomEditorDialog({
(_, routeIndex) => `advanced-custom-route-initial-${routeIndex}`
)
})
- const [editMode, setEditMode] = useState('visual')
+ const [activeTab, setActiveTab] =
+ useState('forwarding')
const [jsonText, setJsonText] = useState(() =>
stringifyAdvancedCustomConfig(
parseAdvancedCustomConfig(value) || createAdvancedCustomConfig()
@@ -192,9 +264,9 @@ export function AdvancedCustomEditorDialog({
const [templateKey, setTemplateKey] = useState(
ADVANCED_CUSTOM_TEMPLATE_OPTIONS[0]?.value || ''
)
- const templateLabel = useMemo(
- () => getOptionLabel(ADVANCED_CUSTOM_TEMPLATE_OPTIONS, templateKey),
- [templateKey]
+ const [templateConfirmOpen, setTemplateConfirmOpen] = useState(false)
+ const [expandedRouteGroups, setExpandedRouteGroups] = useState>(
+ () => new Set()
)
const normalizedConfig = useMemo(
@@ -202,7 +274,7 @@ export function AdvancedCustomEditorDialog({
[config]
)
const routes = normalizedConfig.advanced_routes || emptyAdvancedRoutes
- const routeRows = useMemo(
+ const allRouteRows = useMemo(
() =>
routes.map((route, index) => ({
route,
@@ -216,6 +288,14 @@ export function AdvancedCustomEditorDialog({
})),
[routeKeys, routes]
)
+ const routeRows = useMemo(
+ () =>
+ allRouteRows.filter(
+ (routeRow) =>
+ !isAdvancedCustomManagementPath(getRouteIncomingPath(routeRow.route))
+ ),
+ [allRouteRows]
+ )
const routeGroups = useMemo(() => buildRouteGroups(routeRows), [routeRows])
const usedIncomingPaths = useMemo(
() => new Set(routeGroups.map((routeGroup) => routeGroup.incomingPath)),
@@ -224,7 +304,9 @@ export function AdvancedCustomEditorDialog({
const availableIncomingPathOptions = useMemo(
() =>
ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.filter(
- (option) => !usedIncomingPaths.has(option.value)
+ (option) =>
+ !isAdvancedCustomManagementPath(option.value) &&
+ !usedIncomingPaths.has(option.value)
),
[usedIncomingPaths]
)
@@ -234,6 +316,43 @@ export function AdvancedCustomEditorDialog({
)
const canFixCatchAllOrder =
validationError?.message === catchAllOrderErrorMessage
+ const modelListRoute = getAdvancedCustomManagementRoute(
+ normalizedConfig,
+ ADVANCED_CUSTOM_MODEL_LIST_PATH
+ )
+ const balanceRoute = getAdvancedCustomManagementRoute(
+ normalizedConfig,
+ ADVANCED_CUSTOM_BALANCE_PATH
+ )
+ const selectedTemplate = useMemo(
+ () =>
+ ADVANCED_CUSTOM_TEMPLATE_OPTIONS.find(
+ (template) => template.value === templateKey
+ ) || ADVANCED_CUSTOM_TEMPLATE_OPTIONS[0],
+ [templateKey]
+ )
+
+ // Synchronize the draft only when the dialog opens or the source value changes.
+ // Route keys are disposable UI identity and do not belong in the saved config.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ useEffect(() => {
+ if (!open) return
+ const parsed =
+ parseAdvancedCustomConfig(value) || createAdvancedCustomConfig()
+ const normalized = normalizeAdvancedCustomConfig(parsed)
+ setConfig(normalized)
+ setRouteKeys(createRouteKeys(normalized.advanced_routes?.length || 0))
+ setJsonText(stringifyAdvancedCustomConfig(normalized))
+ setJsonError('')
+ setActiveTab('forwarding')
+ setTemplateConfirmOpen(false)
+ const firstForwardingPath = (normalized.advanced_routes || [])
+ .map((route) => getRouteIncomingPath(route))
+ .find((path) => !isAdvancedCustomManagementPath(path))
+ setExpandedRouteGroups(
+ firstForwardingPath ? new Set([firstForwardingPath]) : new Set()
+ )
+ }, [open, value])
const createRouteKey = () => {
routeKeyCounterRef.current += 1
@@ -254,7 +373,7 @@ export function AdvancedCustomEditorDialog({
const replaceRoutes = (
nextRoutes: AdvancedCustomRoute[],
- nextRouteKeys = routeRows.map((routeRow) => routeRow.routeKey)
+ nextRouteKeys = allRouteRows.map((routeRow) => routeRow.routeKey)
) => {
setConfig((current) => {
const next = normalizeAdvancedCustomConfig(current)
@@ -280,6 +399,7 @@ export function AdvancedCustomEditorDialog({
}
})
setRouteKeys((current) => [...current, createRouteKey()])
+ setExpandedRouteGroups((current) => new Set(current).add(incomingPath))
}
const addRouteForIncomingPath = (incomingPath: string) => {
@@ -299,6 +419,9 @@ export function AdvancedCustomEditorDialog({
}
})
setRouteKeys((current) => [...current, createRouteKey()])
+ setExpandedRouteGroups((current) =>
+ new Set(current).add(resolvedIncomingPath)
+ )
}
const removeRoute = (index: number) => {
@@ -348,12 +471,18 @@ export function AdvancedCustomEditorDialog({
}
})
replaceRoutes(nextRoutes)
+ setExpandedRouteGroups((current) => {
+ const next = new Set(current)
+ next.delete(group.incomingPath)
+ next.add(resolvedIncomingPath)
+ return next
+ })
}
const swapRoutes = (fromIndex: number, toIndex: number) => {
if (fromIndex === toIndex) return
const nextRoutes = [...routes]
- const nextRouteKeys = routeRows.map((routeRow) => routeRow.routeKey)
+ const nextRouteKeys = allRouteRows.map((routeRow) => routeRow.routeKey)
const fromRoute = nextRoutes[fromIndex]
nextRoutes[fromIndex] = nextRoutes[toIndex]
nextRoutes[toIndex] = fromRoute
@@ -387,7 +516,7 @@ export function AdvancedCustomEditorDialog({
if (lastSamePathIndex < 0 || index === lastSamePathIndex) return
const nextRoutes = [...routes]
- const nextRouteKeys = routeRows.map((routeRow) => routeRow.routeKey)
+ const nextRouteKeys = allRouteRows.map((routeRow) => routeRow.routeKey)
const [route] = nextRoutes.splice(index, 1)
const [routeKey] = nextRouteKeys.splice(index, 1)
nextRoutes.splice(lastSamePathIndex, 0, route)
@@ -418,9 +547,19 @@ export function AdvancedCustomEditorDialog({
const orderedRows = orderedRowsByPath.get(incomingPath)
return orderedRows?.shift() || routeRow
})
+ const orderedRoutes = [...nextRows]
+ const orderedRouteKeys = [...nextRows]
replaceRoutes(
- nextRows.map((routeRow) => routeRow.route),
- nextRows.map((routeRow) => routeRow.routeKey)
+ routes.map((route) =>
+ isAdvancedCustomManagementPath(getRouteIncomingPath(route))
+ ? route
+ : orderedRoutes.shift()?.route || route
+ ),
+ allRouteRows.map((routeRow) =>
+ isAdvancedCustomManagementPath(getRouteIncomingPath(routeRow.route))
+ ? routeRow.routeKey
+ : orderedRouteKeys.shift()?.routeKey || routeRow.routeKey
+ )
)
}
@@ -441,19 +580,23 @@ export function AdvancedCustomEditorDialog({
return parsed
}
- const switchToVisualMode = () => {
+ const switchTab = (nextTab: AdvancedCustomEditorTab) => {
+ if (!advancedCustomTabs.has(nextTab)) return
+ if (activeTab !== 'json') {
+ if (nextTab === 'json') {
+ setJsonText(stringifyAdvancedCustomConfig(normalizedConfig))
+ setJsonError('')
+ }
+ setActiveTab(nextTab)
+ return
+ }
+
const parsed = parseJsonEditorConfig()
if (!parsed) return
const normalized = normalizeAdvancedCustomConfig(parsed)
setConfig(normalized)
setRouteKeys(createRouteKeys(normalized.advanced_routes?.length || 0))
- setEditMode('visual')
- }
-
- const switchToJsonMode = () => {
- setJsonText(stringifyAdvancedCustomConfig(normalizedConfig))
- setJsonError('')
- setEditMode('json')
+ setActiveTab(nextTab)
}
const handleJsonChange = (nextValue: string) => {
@@ -461,33 +604,39 @@ export function AdvancedCustomEditorDialog({
if (jsonError) setJsonError('')
}
- const applyTemplate = (mode: 'fill' | 'append') => {
- const templateConfig = getAdvancedCustomTemplateConfig(templateKey)
- let nextConfig = templateConfig
-
- if (mode === 'append') {
- const baseConfig =
- editMode === 'json' ? parseJsonEditorConfig() : normalizedConfig
- if (!baseConfig) return
- const base = normalizeAdvancedCustomConfig(baseConfig)
- const template = normalizeAdvancedCustomConfig(templateConfig)
- nextConfig = {
- advanced_routes: [
- ...(base.advanced_routes || []),
- ...(template.advanced_routes || []),
- ],
- }
- }
+ const selectTemplate = (nextTemplateKey: string) => {
+ const template =
+ ADVANCED_CUSTOM_TEMPLATE_OPTIONS.find(
+ (option) => option.value === nextTemplateKey
+ ) || ADVANCED_CUSTOM_TEMPLATE_OPTIONS[0]
+ setTemplateKey(template.value)
+ setTemplateConfirmOpen(true)
+ }
- const normalized = normalizeAdvancedCustomConfig(nextConfig)
+ const applySelectedTemplate = () => {
+ if (!selectedTemplate) return
+ const templateRoutes = (selectedTemplate.config.advanced_routes || []).map(
+ (route) => ({
+ ...route,
+ models: [],
+ })
+ )
+ const normalized = replaceAdvancedCustomForwardingRoutes(
+ normalizedConfig,
+ templateRoutes
+ )
setConfig(normalized)
setRouteKeys(createRouteKeys(normalized.advanced_routes?.length || 0))
setJsonText(stringifyAdvancedCustomConfig(normalized))
setJsonError('')
+ setExpandedRouteGroups(
+ new Set(templateRoutes.map((route) => getRouteIncomingPath(route)))
+ )
+ setTemplateConfirmOpen(false)
}
const saveConfig = () => {
- if (editMode === 'json') {
+ if (activeTab === 'json') {
const parsed = parseJsonEditorConfig()
if (!parsed) {
toast.error(t('Please fix JSON errors before saving'))
@@ -532,126 +681,125 @@ export function AdvancedCustomEditorDialog({
>
}
>
-
-
-
- {t('Mode')}
-
-
- {t('Visual')}
-
-
- {t('JSON Text')}
-
-
-
-
-
- {t('Template')}
-
-
- setTemplateKey(
- nextValue || ADVANCED_CUSTOM_TEMPLATE_OPTIONS[0]?.value || ''
- )
- }
- >
-
-
- {t(templateLabel)}
-
-
-
-
- {ADVANCED_CUSTOM_TEMPLATE_OPTIONS.map((option) => (
-
-
- {t(option.label)}
-
-
- ))}
-
-
-
-
applyTemplate('fill')}
- >
- {t('Fill Template')}
-
-
applyTemplate('append')}
- >
- {t('Append Template')}
-
+
switchTab(value as AdvancedCustomEditorTab)}
+ className='min-w-0 gap-0'
+ >
+
+
+
+
+ {t('Forwarding Routes')}
+ {routeRows.length}
+
+
+
+ {t('Model List')}
+
+ {modelListRoute ? t('Configured') : t('Not configured')}
+
+
+
+
+ {t('Balance Query')}
+
+ {balanceRoute ? t('Configured') : t('Not configured')}
+
+
+
+
+ {t('Full JSON')}
+
+
-
- {editMode === 'visual' ? (
-
-
-
{
- if (typeof incomingPath === 'string') {
- addRoute(incomingPath)
- }
- }}
- >
-
+
+
+
{t('Forwarding Routes')}
+
+ {t('Add routes individually or replace them from a template.')}
+
+
+
+
{
+ if (typeof incomingPath === 'string') addRoute(incomingPath)
+ }}
+ >
+
+
+
+
+
+
+ {availableIncomingPathOptions.map((option) => (
+
+
+ {option.label}
+
+ {option.value}
+
+
+
+ ))}
+
+
+
+
{
+ if (typeof value === 'string') selectTemplate(value)
+ }}
+ >
+
+
+
+
+
+ {ADVANCED_CUSTOM_TEMPLATE_OPTIONS.map((template) => (
+
+ {t(template.label)}
+
+ ))}
+
+
+
+
setExpandedRouteGroups(new Set())}
>
-
-
-
-
+
+ setExpandedRouteGroups(
+ new Set(routeGroups.map((group) => group.incomingPath))
+ )
+ }
>
-
- {availableIncomingPathOptions.map((option) => (
-
-
- {option.label}
-
- {option.value}
-
-
-
- ))}
-
-
-
+ {t('Expand all')}
+
+
{validationError ? (
@@ -677,40 +825,127 @@ export function AdvancedCustomEditorDialog({
) : null}
-
- {t(upstreamPathDescriptionKey)}
-
-
-
+
{routeGroups.map((routeGroup) => (
-
- addRouteForIncomingPath(routeGroup.incomingPath)
- }
- onIncomingPathChange={(nextIncomingPath) =>
- updateGroupIncomingPath(routeGroup, nextIncomingPath)
- }
- onMoveRoute={(index, direction) =>
- moveRouteWithinGroup(index, direction)
+ open={expandedRouteGroups.has(routeGroup.incomingPath)}
+ onOpenChange={(expanded) =>
+ setExpandedRouteGroups((current) => {
+ const next = new Set(current)
+ if (expanded) next.add(routeGroup.incomingPath)
+ else next.delete(routeGroup.incomingPath)
+ return next
+ })
}
- onMoveRouteToEnd={moveRouteToGroupEnd}
- onRemoveRoute={removeRoute}
- onRouteChange={updateRoute}
- />
+ className='rounded-md border'
+ >
+
+ }
+ >
+ {expandedRouteGroups.has(routeGroup.incomingPath) ? (
+
+ ) : (
+
+ )}
+
+
+
+ {getAdvancedCustomIncomingPathLabel(
+ routeGroup.incomingPath
+ )}
+
+ routeRow.route
+ )}
+ />
+
+
+ {routeGroup.incomingPath}
+
+
+
+ {routeGroup.routeRows.length} {t('Routes')}
+
+
+
+
+ addRouteForIncomingPath(routeGroup.incomingPath)
+ }
+ onIncomingPathChange={(nextIncomingPath) =>
+ updateGroupIncomingPath(routeGroup, nextIncomingPath)
+ }
+ onMoveRoute={(index, direction) =>
+ moveRouteWithinGroup(index, direction)
+ }
+ onMoveRouteToEnd={moveRouteToGroupEnd}
+ onRemoveRoute={removeRoute}
+ onRouteChange={updateRoute}
+ />
+
+
))}
-
- ) : (
-
-
-
- {t('Advanced text editing')}
-
-
+
+
+
+
+ setConfig((current) =>
+ replaceAdvancedCustomManagementRoute(
+ current,
+ ADVANCED_CUSTOM_MODEL_LIST_PATH,
+ route
+ )
+ )
+ }
+ />
+
+
+
+
+ setConfig((current) =>
+ replaceAdvancedCustomManagementRoute(
+ current,
+ ADVANCED_CUSTOM_BALANCE_PATH,
+ route
+ )
+ )
+ }
+ />
+
+
+
{jsonError}
) : null}
-
- )}
+
+
+
+
)
}
+function ManagementRouteEditor({
+ route,
+ path,
+ title,
+ description,
+ onChange,
+}: {
+ route: AdvancedCustomRoute | undefined
+ path: string
+ title: string
+ description: string
+ onChange: (route: AdvancedCustomRoute | null) => void
+}) {
+ const { t } = useTranslation()
+ const authMode = route ? getAdvancedCustomAuthMode(route) : 'default'
+
+ if (!route) {
+ return (
+
+
+
{title}
+
+ {description}
+
+
{path}
+
+
onChange(createAdvancedCustomManagementRoute(path))}
+ >
+
+ {t('Add management route')}
+
+
+ )
+ }
+
+ const updateAuth = (
+ field: Exclude, 'type'>,
+ value: string
+ ) => {
+ if (!route.auth || route.auth.type === 'none') return
+ onChange({
+ ...route,
+ auth: {
+ type: route.auth.type,
+ name: route.auth.name || '',
+ value: route.auth.value || '',
+ [field]: value,
+ },
+ })
+ }
+
+ return (
+
+
+
+
{title}
+
{description}
+
{path}
+
+
onChange(null)}
+ >
+
+ {t('Delete')}
+
+
+
+
+
+ onChange({ ...route, upstream_path: event.target.value })
+ }
+ placeholder={
+ path === ADVANCED_CUSTOM_MODEL_LIST_PATH
+ ? '/v1/models'
+ : '/dashboard/billing/credit_grants'
+ }
+ />
+
+
+
+ onChange({
+ ...route,
+ auth: buildAdvancedCustomAuth(
+ value as AdvancedCustomAuthMode,
+ route.auth
+ ),
+ })
+ }
+ >
+
+
+ {t(getOptionLabel(ADVANCED_CUSTOM_AUTH_MODE_OPTIONS, authMode))}
+
+
+
+
+ {ADVANCED_CUSTOM_AUTH_MODE_OPTIONS.map((option) => (
+
+ {t(option.label)}
+
+ ))}
+
+
+
+
+ {authMode === 'header' || authMode === 'query' ? (
+ <>
+
+ updateAuth('name', event.target.value)}
+ placeholder={
+ authMode === 'header' ? 'Authorization' : 'api_key'
+ }
+ />
+
+
+ updateAuth('value', event.target.value)}
+ placeholder={
+ authMode === 'header' ? 'Bearer {api_key}' : '{api_key}'
+ }
+ />
+
+ >
+ ) : null}
+
+
+ {t(upstreamPathDescriptionKey)}
+
+
+ )
+}
+
function RouteGroupEditor({
group,
usedIncomingPaths,
validationError,
+ hideHeader = false,
onAddRoute,
onIncomingPathChange,
onMoveRoute,
@@ -747,6 +1143,7 @@ function RouteGroupEditor({
group: AdvancedCustomRouteGroup
usedIncomingPaths: ReadonlySet
validationError: ReturnType
+ hideHeader?: boolean
onAddRoute: () => void
onIncomingPathChange: (incomingPath: string | null) => void
onMoveRoute: (index: number, direction: -1 | 1) => void
@@ -782,80 +1179,84 @@ function RouteGroupEditor({
groupHasError && 'border-destructive/60'
)}
>
-
-
-
-
{t('Route group')}
-
- {group.routeRows.length} {t('Routes')}
-
- {isModelListGroup ? (
-
- {ADVANCED_CUSTOM_MODEL_LIST_LABEL}
-
- ) : (
-
- {hasCatchAll ? t('Fallback route') : t('Model-scoped only')}
+ {!hideHeader ? (
+
+
+
+ {t('Route group')}
+
+ {group.routeRows.length} {t('Routes')}
- )}
- {!isModelListGroup && !catchAllIsLast ? (
- {t('Fallback must be last')}
- ) : null}
+ {isModelListGroup ? (
+
+ {ADVANCED_CUSTOM_MODEL_LIST_LABEL}
+
+ ) : (
+
+ {hasCatchAll ? t('Fallback route') : t('Model-scoped only')}
+
+ )}
+ {!isModelListGroup && !catchAllIsLast ? (
+
+ {t('Fallback must be last')}
+
+ ) : null}
+
+
+
+
+ {incomingPathLabel}
+
+
+
+
+ {ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.map((option) => (
+ 1)
+ }
+ className={longSelectItemClass}
+ >
+
+ {option.label}
+
+ {option.value}
+
+
+
+ ))}
+
+
+
-
-
-
- {incomingPathLabel}
-
-
-
-
- {ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.map((option) => (
- 1)
- }
- className={longSelectItemClass}
- >
-
- {option.label}
-
- {option.value}
-
-
-
- ))}
-
-
-
+
+ {t('Add split')}
+
+ ) : null}
+ ) : null}
- {!isModelListGroup ? (
-
-
- {t('Add split')}
-
- ) : null}
-
-
-
+
{isModelListGroup
? t(
@@ -960,17 +1361,11 @@ function RouteEditor({
() => getAdvancedCustomConverterOptions(incomingPath),
[incomingPath]
)
- const converterLabel = getOptionLabel(
- ADVANCED_CUSTOM_CONVERTER_OPTIONS,
- converter
- )
const converterTriggerLabel =
ADVANCED_CUSTOM_CONVERTER_OPTIONS.find(
(option) => option.value === converter
- )?.triggerLabel || converterLabel
+ )?.triggerLabel || converter
const authLabel = getOptionLabel(ADVANCED_CUSTOM_AUTH_MODE_OPTIONS, authMode)
- const isNativeConverter = converter === 'none'
- const ConverterVisualIcon = isNativeConverter ? ArrowRight : Shuffle
const modelsInputValue = route.models?.join(', ') || ''
const parsedRouteModels = parseAdvancedCustomRouteModels(modelsInputValue)
const isFallback = !isModelListRoute && parsedRouteModels.length === 0
@@ -1036,8 +1431,8 @@ function RouteEditor({
)}
>
-
-
+
+
{t('Route')} {index + 1}
@@ -1049,31 +1444,7 @@ function RouteEditor({
{!isModelListRoute && isFallback ? (
{t('Fallback')}
) : null}
-
-
-
- }
- >
-
- {t(converterLabel)}
-
-
- {t(converterLabel)}
-
-
-
+
diff --git a/web/src/features/channels/components/dialogs/balance-query-dialog.tsx b/web/src/features/channels/components/dialogs/balance-query-dialog.tsx
index a9f6d11e314f..d5f60fe281e5 100644
--- a/web/src/features/channels/components/dialogs/balance-query-dialog.tsx
+++ b/web/src/features/channels/components/dialogs/balance-query-dialog.tsx
@@ -22,7 +22,12 @@ import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
+import {
+ CodeBlock,
+ CodeBlockCopyButton,
+} from '@/components/ai-elements/code-block'
import { Dialog } from '@/components/dialog'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { IconBadge } from '@/components/ui/icon-badge'
import { formatCurrencyFromUSD } from '@/lib/currency'
@@ -37,14 +42,12 @@ import {
} from './codex-usage-dialog'
type BalanceQueryDialogProps = {
+ initialRawResponse?: string
open: boolean
onOpenChange: (open: boolean) => void
}
-export function BalanceQueryDialog({
- open,
- onOpenChange,
-}: BalanceQueryDialogProps) {
+export function BalanceQueryDialog(props: BalanceQueryDialogProps) {
const { t } = useTranslation()
const { currentRow, setCurrentRow } = useChannels()
const queryClient = useQueryClient()
@@ -53,6 +56,9 @@ export function BalanceQueryDialog({
const [balanceUpdatedTime, setBalanceUpdatedTime] = useState
(
null
)
+ const [rawResponse, setRawResponse] = useState(
+ props.initialRawResponse ?? null
+ )
const [codexUsageResponse, setCodexUsageResponse] =
useState(null)
@@ -79,10 +85,10 @@ export function BalanceQueryDialog({
useEffect(() => {
if (!isCodex) return
- if (!open) return
+ if (!props.open) return
handleQueryCodexUsage()
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [open, isCodex])
+ }, [props.open, isCodex])
if (!currentRow) return null
@@ -109,6 +115,9 @@ export function BalanceQueryDialog({
await queryClient.invalidateQueries({
queryKey: channelsQueryKeys.lists(),
})
+ setRawResponse(null)
+ } else if (response.success && response.raw_response !== undefined) {
+ setRawResponse(response.raw_response)
} else {
toast.error(response.message || t('Failed to query balance'))
}
@@ -124,8 +133,9 @@ export function BalanceQueryDialog({
const handleClose = () => {
setBalance(null)
setBalanceUpdatedTime(null)
+ setRawResponse(null)
setCodexUsageResponse(null)
- onOpenChange(false)
+ props.onOpenChange(false)
}
const formatBalance = (bal: number) =>
@@ -143,7 +153,7 @@ export function BalanceQueryDialog({
if (isCodex) {
return (
{
if (!v) handleClose()
}}
@@ -158,7 +168,7 @@ export function BalanceQueryDialog({
return (
- {/* Current Balance Display */}
-
-
-
-
-
- {t('Current Balance')}
-
-
- {balance !== null
- ? formatBalance(balance)
- : formatBalance(currentRow.balance)}
-
-
- {t('Last updated:')}{' '}
- {formatDate(balanceUpdatedTime ?? currentRow.balance_updated_time)}
-
-
+ {rawResponse !== null ? (
+ <>
+
+ {t('Balance response not recognized')}
+
+ {t(
+ 'The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.'
+ )}
+
+
+
+
+
+ >
+ ) : (
+ <>
+ {/* Current Balance Display */}
+
+
+
+
+
+ {t('Current Balance')}
+
+
+ {balance !== null
+ ? formatBalance(balance)
+ : formatBalance(currentRow.balance)}
+
+
+ {t('Last updated:')}{' '}
+ {formatDate(
+ balanceUpdatedTime ?? currentRow.balance_updated_time
+ )}
+
+
+ >
+ )}
{/* Balance Update Button */}
= {
'/v1/chat/completions': 'OpenAI Chat',
[ADVANCED_CUSTOM_MODEL_LIST_PATH]: ADVANCED_CUSTOM_MODEL_LIST_LABEL,
+ [ADVANCED_CUSTOM_BALANCE_PATH]: ADVANCED_CUSTOM_BALANCE_LABEL,
}
export type AdvancedCustomValidationError = {
@@ -217,122 +217,93 @@ const geminiQueryAuth = (): AdvancedCustomRouteAuth => ({
value: '{api_key}',
})
-export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] =
- [
- {
- value: 'official_openai_chat',
- label: 'Official OpenAI Chat',
- config: {
- advanced_routes: [
- {
- incoming_path: '/v1/chat/completions',
- upstream_path: '/v1/chat/completions',
- converter: 'none',
- auth: bearerHeaderAuth(),
- },
- ],
- },
- },
- {
- value: 'official_openai_responses',
- label: 'Official OpenAI Responses',
- config: {
- advanced_routes: [
- {
- incoming_path: '/v1/responses',
- upstream_path: '/v1/responses',
- converter: 'none',
- auth: bearerHeaderAuth(),
- },
- ],
- },
- },
+function createOpenAINativeRoutes(): AdvancedCustomRoute[] {
+ return [
+ '/v1/chat/completions',
+ '/v1/completions',
+ '/v1/responses',
+ '/v1/responses/compact',
+ '/v1/embeddings',
+ '/v1/images/generations',
+ '/v1/images/edits',
+ '/v1/audio/speech',
+ '/v1/audio/transcriptions',
+ '/v1/audio/translations',
+ '/v1/realtime',
+ ].map((path) => ({
+ incoming_path: path,
+ upstream_path: path,
+ converter: 'none',
+ auth: bearerHeaderAuth(),
+ }))
+}
+
+function createClaudeNativeRoutes(): AdvancedCustomRoute[] {
+ return [
{
- value: 'official_openai_embeddings',
- label: 'Official OpenAI Embeddings',
- config: {
- advanced_routes: [
- {
- incoming_path: '/v1/embeddings',
- upstream_path: '/v1/embeddings',
- converter: 'none',
- auth: bearerHeaderAuth(),
- },
- ],
- },
+ incoming_path: '/v1/messages',
+ upstream_path: '/v1/messages',
+ converter: 'none',
+ auth: apiKeyHeaderAuth(),
},
+ ]
+}
+
+function createGeminiNativeRoutes(): AdvancedCustomRoute[] {
+ return [
+ '/v1beta/models/{model}:generateContent',
+ '/v1beta/models/{model}:embedContent',
+ '/v1beta/models/{model}:batchEmbedContents',
+ ].map((path) => ({
+ incoming_path: path,
+ upstream_path: path,
+ converter: 'none',
+ auth: geminiQueryAuth(),
+ }))
+}
+
+function createGatewayNativeRoutes(): AdvancedCustomRoute[] {
+ return ['/v1/alpha/search', '/v1/rerank'].map((path) => ({
+ incoming_path: path,
+ upstream_path: path,
+ converter: 'none',
+ auth: bearerHeaderAuth(),
+ }))
+}
+
+export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] =
+ [
{
- value: 'official_openai_images',
- label: 'Official OpenAI Images',
+ value: 'all_protocols',
+ label: 'All routes',
config: {
advanced_routes: [
- {
- incoming_path: '/v1/images/generations',
- upstream_path: '/v1/images/generations',
- converter: 'none',
- auth: bearerHeaderAuth(),
- },
- {
- incoming_path: '/v1/images/edits',
- upstream_path: '/v1/images/edits',
- converter: 'none',
- auth: bearerHeaderAuth(),
- },
+ ...createOpenAINativeRoutes(),
+ ...createClaudeNativeRoutes(),
+ ...createGeminiNativeRoutes(),
+ ...createGatewayNativeRoutes(),
],
},
},
{
- value: 'official_claude_messages',
- label: 'Official Claude Messages',
+ value: 'openai_only',
+ label: 'OpenAI only',
config: {
- advanced_routes: [
- {
- incoming_path: '/v1/messages',
- upstream_path: '/v1/messages',
- converter: 'none',
- auth: apiKeyHeaderAuth(),
- },
- ],
+ advanced_routes: createOpenAINativeRoutes(),
},
},
{
- value: 'official_gemini_native',
- label: 'Official Gemini Native',
+ value: 'claude_only',
+ label: 'Claude only',
config: {
- advanced_routes: [
- {
- incoming_path: '/v1beta/models/{model}:generateContent',
- upstream_path: '/v1beta/models/{model}:generateContent',
- converter: 'none',
- auth: geminiQueryAuth(),
- },
- {
- incoming_path: '/v1beta/models/{model}:embedContent',
- upstream_path: '/v1beta/models/{model}:embedContent',
- converter: 'none',
- auth: geminiQueryAuth(),
- },
- {
- incoming_path: '/v1beta/models/{model}:batchEmbedContents',
- upstream_path: '/v1beta/models/{model}:batchEmbedContents',
- converter: 'none',
- auth: geminiQueryAuth(),
- },
- ],
+ advanced_routes: createClaudeNativeRoutes(),
},
},
{
- value: 'official_gemini_from_openai_chat',
- label: 'Official Gemini from OpenAI Chat',
+ value: 'gemini_only',
+ label: 'Gemini only',
config: {
- advanced_routes: [
- {
- incoming_path: '/v1/chat/completions',
- upstream_path: '/v1beta/models/{model}:generateContent',
- converter: 'openai_chat_completions_to_gemini_generate_content',
- auth: geminiQueryAuth(),
- },
- ],
+ advanced_routes: createGeminiNativeRoutes(),
},
},
]
@@ -340,7 +311,7 @@ export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] =
export function cloneAdvancedCustomConfig(
config: AdvancedCustomConfig
): AdvancedCustomConfig {
- return JSON.parse(JSON.stringify(config)) as AdvancedCustomConfig
+ return structuredClone(config)
}
export function getAdvancedCustomTemplateConfig(
@@ -353,6 +324,78 @@ export function getAdvancedCustomTemplateConfig(
return cloneAdvancedCustomConfig(template.config)
}
+export function isAdvancedCustomManagementPath(path: string): boolean {
+ return (
+ path === ADVANCED_CUSTOM_MODEL_LIST_PATH ||
+ path === ADVANCED_CUSTOM_BALANCE_PATH
+ )
+}
+
+export function getAdvancedCustomManagementRoute(
+ config: AdvancedCustomConfig,
+ path: string
+): AdvancedCustomRoute | undefined {
+ return normalizeAdvancedCustomConfig(config).advanced_routes?.find(
+ (route) => route.incoming_path?.trim() === path
+ )
+}
+
+export function replaceAdvancedCustomManagementRoute(
+ config: AdvancedCustomConfig,
+ path: string,
+ route: AdvancedCustomRoute | null
+): AdvancedCustomConfig {
+ const normalized = normalizeAdvancedCustomConfig(config)
+ const routes = [...(normalized.advanced_routes || [])]
+ const index = routes.findIndex(
+ (candidate) => candidate.incoming_path?.trim() === path
+ )
+ if (route === null) {
+ if (index >= 0) routes.splice(index, 1)
+ } else {
+ const managementRoute: AdvancedCustomRoute = {
+ incoming_path: path,
+ upstream_path: route.upstream_path || '',
+ converter: 'none',
+ models: [],
+ auth: route.auth,
+ }
+ if (index >= 0) routes[index] = managementRoute
+ else routes.push(managementRoute)
+ }
+ return { advanced_routes: routes }
+}
+
+export function replaceAdvancedCustomForwardingRoutes(
+ config: AdvancedCustomConfig,
+ forwardingRoutes: AdvancedCustomRoute[]
+): AdvancedCustomConfig {
+ const normalized = normalizeAdvancedCustomConfig(config)
+ const routes = normalized.advanced_routes || []
+ const firstForwardingIndex = routes.findIndex(
+ (route) =>
+ !isAdvancedCustomManagementPath(route.incoming_path?.trim() || '')
+ )
+ const managementRoutes = routes.filter((route) =>
+ isAdvancedCustomManagementPath(route.incoming_path?.trim() || '')
+ )
+ if (firstForwardingIndex < 0) {
+ return { advanced_routes: [...managementRoutes, ...forwardingRoutes] }
+ }
+
+ const before = routes
+ .slice(0, firstForwardingIndex)
+ .filter((route) =>
+ isAdvancedCustomManagementPath(route.incoming_path?.trim() || '')
+ )
+ const after = routes
+ .slice(firstForwardingIndex)
+ .filter((route) =>
+ isAdvancedCustomManagementPath(route.incoming_path?.trim() || '')
+ )
+ return { advanced_routes: [...before, ...forwardingRoutes, ...after] }
+}
+
export function createAdvancedCustomRoute(): AdvancedCustomRoute {
return {
incoming_path: openAIChatPath,
@@ -367,6 +410,17 @@ export function createAdvancedCustomConfig(): AdvancedCustomConfig {
}
}
+export function createAdvancedCustomManagementRoute(
+ path: string
+): AdvancedCustomRoute {
+ return {
+ incoming_path: path,
+ upstream_path: path,
+ converter: 'none',
+ models: [],
+ }
+}
+
export function getAdvancedCustomUpstreamPathPlaceholder(
converter: AdvancedCustomConverter,
incomingPath = getDefaultAdvancedCustomIncomingPath(converter)
@@ -550,6 +604,7 @@ export function validateAdvancedCustomConfig(
{ catchAllIndex: number | null; models: Map }
>()
let modelListRouteIndex: number | null = null
+ let balanceRouteIndex: number | null = null
for (let index = 0; index < routes.length; index += 1) {
const route = routes[index]
const incomingPath = route.incoming_path?.trim() || ''
@@ -569,30 +624,36 @@ export function validateAdvancedCustomConfig(
message: 'Incoming path must not include query',
}
}
- if (incomingPath === ADVANCED_CUSTOM_MODEL_LIST_PATH) {
- if (modelListRouteIndex !== null) {
+ if (isAdvancedCustomManagementPath(incomingPath)) {
+ const isModelListRoute = incomingPath === ADVANCED_CUSTOM_MODEL_LIST_PATH
+ const existingIndex = isModelListRoute
+ ? modelListRouteIndex
+ : balanceRouteIndex
+ const routeLabel = isModelListRoute ? 'OpenAI Models' : 'Balance Query'
+ if (existingIndex !== null) {
return {
routeIndex: index,
- message: 'Only one OpenAI Models route is allowed',
+ message: `Only one ${routeLabel} route is allowed`,
}
}
- modelListRouteIndex = index
+ if (isModelListRoute) modelListRouteIndex = index
+ else balanceRouteIndex = index
if (routeModels.length > 0) {
return {
routeIndex: index,
- message: 'OpenAI Models route does not support client model rules',
+ message: `${routeLabel} route does not support client model rules`,
}
}
if (converter !== 'none') {
return {
routeIndex: index,
- message: 'OpenAI Models route must use native forwarding',
+ message: `${routeLabel} route must use native forwarding`,
}
}
if (upstreamPath.includes('{model}')) {
return {
routeIndex: index,
- message: 'OpenAI Models upstream path must not contain {model}',
+ message: `${routeLabel} upstream path must not contain {model}`,
}
}
}
diff --git a/web/src/features/channels/lib/channel-actions.ts b/web/src/features/channels/lib/channel-actions.ts
index 7efee24d3da2..a5d36d0b5219 100644
--- a/web/src/features/channels/lib/channel-actions.ts
+++ b/web/src/features/channels/lib/channel-actions.ts
@@ -20,8 +20,6 @@ import type { QueryClient } from '@tanstack/react-query'
import i18next from 'i18next'
import { toast } from 'sonner'
-import { formatCurrencyFromUSD } from '@/lib/currency'
-
import {
copyChannel,
deleteChannel,
@@ -38,7 +36,6 @@ import {
editTagChannels,
testAllChannels,
updateAllChannelsBalance,
- updateChannelBalance,
} from '../api'
import { CHANNEL_STATUS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
import type { ChannelTestResponse, CopyChannelParams } from '../types'
@@ -362,41 +359,6 @@ export async function handleCopyChannel(
}
}
-/**
- * Update channel balance
- */
-export async function handleUpdateChannelBalance(
- id: number,
- queryClient?: QueryClient,
- onSuccess?: (balance: number) => void
-): Promise {
- try {
- const response = await updateChannelBalance(id)
- if (response.success && response.balance !== undefined) {
- const balance = response.balance
- toast.success(
- i18next.t('Balance updated: {{balance}}', {
- balance: formatCurrencyFromUSD(balance, {
- digitsLarge: 2,
- digitsSmall: 4,
- abbreviate: false,
- }),
- })
- )
- queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
- onSuccess?.(balance)
- } else {
- toast.error(response.message || i18next.t('Failed to update balance'))
- }
- } catch (_error: unknown) {
- toast.error(
- _error instanceof Error
- ? _error.message
- : i18next.t('Failed to update balance')
- )
- }
-}
-
// ============================================================================
// Batch Actions
// ============================================================================
diff --git a/web/src/features/channels/types.ts b/web/src/features/channels/types.ts
index f7747fa21210..6b53c336f238 100644
--- a/web/src/features/channels/types.ts
+++ b/web/src/features/channels/types.ts
@@ -197,6 +197,7 @@ export interface ChannelBalanceResponse {
message?: string
balance?: number
currency?: string
+ raw_response?: string
}
export interface FetchModelsResponse {
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index b3fec13839ec..91db57bc33ea 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -65,6 +65,9 @@
"{{modality}} supported": "{{modality}} supported",
"{{n}} model(s) selected": "{{n}} model(s) selected",
"{{processed}} of {{total}} log entries processed.": "{{processed}} of {{total}} log entries processed.",
+ "{{protocol}} auth name": "{{protocol}} auth name",
+ "{{protocol}} auth value": "{{protocol}} auth value",
+ "{{protocol}} authentication": "{{protocol}} authentication",
"{{success}} succeeded, {{failed}} failed": "{{success}} succeeded, {{failed}} failed",
"{{target}} test failed": "{{target}} test failed",
"{{target}} test succeeded": "{{target}} test succeeded",
@@ -188,6 +191,7 @@
"Add Group": "Add Group",
"Add group rate limit": "Add group rate limit",
"Add group rules": "Add group rules",
+ "Add management route": "Add management route",
"Add Mapping": "Add Mapping",
"Add method": "Add method",
"Add missing models": "Add missing models",
@@ -207,6 +211,7 @@
"Add Quota": "Add Quota",
"Add ratio override": "Add ratio override",
"Add route": "Add route",
+ "Add routes individually or replace them from a template.": "Add routes individually or replace them from a template.",
"Add Row": "Add Row",
"Add Rule": "Add Rule",
"Add rule group": "Add rule group",
@@ -215,6 +220,7 @@
"Add split": "Add split",
"Add subscription": "Add subscription",
"Add tags...": "Add tags...",
+ "Add template": "Add template",
"Add tier": "Add tier",
"Add time condition": "Add time condition",
"Add time rule group": "Add time rule group",
@@ -298,6 +304,7 @@
"All nodes": "All nodes",
"All playground messages saved in this browser will be removed. This cannot be undone.": "All playground messages saved in this browser will be removed. This cannot be undone.",
"All requests must include": "All requests must include",
+ "All routes": "All routes",
"All Status": "All Status",
"All Sync Status": "All Sync Status",
"All systems operational": "All systems operational",
@@ -427,6 +434,7 @@
"Apply Filters": "Apply Filters",
"Apply IP Filter to Resolved Domains": "Apply IP Filter to Resolved Domains",
"Apply Overwrite": "Apply Overwrite",
+ "Apply plan": "Apply plan",
"Apply reset": "Apply reset",
"Apply Sync": "Apply Sync",
"Applying...": "Applying...",
@@ -571,6 +579,8 @@
"Balance depleted": "Balance depleted",
"Balance is shown in quota units": "Balance is shown in quota units",
"Balance queried successfully": "Balance queried successfully",
+ "Balance Query": "Balance Query",
+ "Balance response not recognized": "Balance response not recognized",
"Balance updated successfully": "Balance updated successfully",
"Balance updated: {{balance}}": "Balance updated: {{balance}}",
"Bar Chart": "Bar Chart",
@@ -808,6 +818,7 @@
"checkout.session.completed": "checkout.session.completed",
"checkout.session.expired": "checkout.session.expired",
"Chinese": "Chinese",
+ "Choose a complete upstream protocol plan or edit individual route groups.": "Choose a complete upstream protocol plan or edit individual route groups.",
"Choose a username": "Choose a username",
"Choose an amount and payment method": "Choose an amount and payment method",
"Choose and order the groups this API key will try.": "Choose and order the groups this API key will try.",
@@ -830,6 +841,7 @@
"Clamped to": "Clamped to",
"Claude": "Claude",
"Claude CLI Header Passthrough": "Claude CLI Header Passthrough",
+ "Claude only": "Claude only",
"Clean": "Clean",
"Clean history logs": "Clean history logs",
"Clean logs": "Clean logs",
@@ -906,6 +918,7 @@
"Codex credential must be a JSON object with access_token and account_id": "Codex credential must be a JSON object with access_token and account_id",
"Cohere": "Cohere",
"Collapse": "Collapse",
+ "Collapse all": "Collapse all",
"Collapse All": "Collapse All",
"Collect relay latency and success-rate metrics for the model square.": "Collect relay latency and success-rate metrics for the model square.",
"Color": "Color",
@@ -991,6 +1004,7 @@
"Configure Waffo payment aggregation platform integration": "Configure Waffo payment aggregation platform integration",
"Configure your account behavior preferences": "Configure your account behavior preferences",
"Configure your account preferences and integrations": "Configure your account preferences and integrations",
+ "Configured": "Configured",
"Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.": "Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.",
"Configured routes and latency checks": "Configured routes and latency checks",
"Confirm": "Confirm",
@@ -2079,6 +2093,7 @@
"Format: APPID|APISecret|APIKey": "Format: APPID|APISecret|APIKey",
"Format: TokenHub API Key, or legacy AppId|SecretId|SecretKey": "Format: TokenHub API Key, or legacy AppId|SecretId|SecretKey",
"Forward requests directly to upstream providers without any post-processing.": "Forward requests directly to upstream providers without any post-processing.",
+ "Forwarding Routes": "Forwarding Routes",
"Frames per second": "Frames per second",
"Free": "Free",
"Free: {{free}} / Total: {{total}}": "Free: {{free}} / Total: {{total}}",
@@ -2091,6 +2106,7 @@
"Full Base URL (supports": "Full Base URL (supports",
"Full Code": "Full Code",
"Full input length": "Full input length",
+ "Full JSON": "Full JSON",
"Full layout": "Full layout",
"Full width": "Full width",
"Function calling": "Function calling",
@@ -2101,6 +2117,7 @@
"Gemini": "Gemini",
"Gemini Generate Content to OpenAI Chat": "Gemini Generate Content to OpenAI Chat",
"Gemini Image 4K": "Gemini Image 4K",
+ "Gemini only": "Gemini only",
"Gemini will continue to auto-detect thinking mode even with the adapter disabled. Enable this only when you need finer control over pricing and budgeting.": "Gemini will continue to auto-detect thinking mode even with the adapter disabled. Enable this only when you need finer control over pricing and budgeting.",
"General": "General",
"General Settings": "General Settings",
@@ -2702,6 +2719,7 @@
"Model fixed pricing": "Model fixed pricing",
"Model Group": "Model Group",
"Model Limits": "Model Limits",
+ "Model List": "Model List",
"Model Mapping": "Model Mapping",
"Model Mapping (JSON)": "Model Mapping (JSON)",
"Model Mapping must be a JSON object like": "Model Mapping must be a JSON object like",
@@ -2836,8 +2854,11 @@
"Name the channel, choose the provider, configure API access, and set credentials.": "Name the channel, choose the provider, configure API access, and set credentials.",
"Name, provider type, and availability.": "Name, provider type, and availability.",
"name@example.com": "name@example.com",
+ "Native Claude Messages plus OpenAI Chat compatibility forwarding.": "Native Claude Messages plus OpenAI Chat compatibility forwarding.",
"Native format": "Native format",
"Native forwarding": "Native forwarding",
+ "Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.",
+ "Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "Native OpenAI routes plus optional Claude and Gemini compatibility routes.",
"Need a redemption code?": "Need a redemption code?",
"Needs API key": "Needs API key",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.",
@@ -3051,6 +3072,7 @@
"Not available": "Not available",
"Not backed up": "Not backed up",
"Not bound": "Not bound",
+ "Not configured": "Not configured",
"Not Equals": "Not Equals",
"Not in pricing table": "Not in pricing table",
"Not included": "Not included",
@@ -3149,6 +3171,7 @@
"Open in new tab": "Open in new tab",
"Open in New Tab": "Open in New Tab",
"Open menu": "Open menu",
+ "Open Query Balance to view the upstream JSON response": "Open Query Balance to view the upstream JSON response",
"Open release": "Open release",
"Open source": "Open source",
"Open Source": "Open Source",
@@ -3161,10 +3184,12 @@
"OpenAI Chat to Gemini Generate Content": "OpenAI Chat to Gemini Generate Content",
"OpenAI Chat to OpenAI Responses": "OpenAI Chat to OpenAI Responses",
"OpenAI Compatible": "OpenAI Compatible",
+ "OpenAI Compatible Upstream": "OpenAI Compatible Upstream",
"OpenAI Models route does not support client model rules": "OpenAI Models route does not support client model rules",
"OpenAI Models route is required to enable upstream model checks": "OpenAI Models route is required to enable upstream model checks",
"OpenAI Models route must use native forwarding": "OpenAI Models route must use native forwarding",
"OpenAI Models upstream path must not contain {model}": "OpenAI Models upstream path must not contain {model}",
+ "OpenAI only": "OpenAI only",
"OpenAI Organization": "OpenAI Organization",
"OpenAI Organization ID (optional)": "OpenAI Organization ID (optional)",
"OpenAI Responses to Gemini Generate Content": "OpenAI Responses to Gemini Generate Content",
@@ -3798,6 +3823,7 @@
"Replace": "Replace",
"Replace all existing keys": "Replace all existing keys",
"Replace channel models": "Replace channel models",
+ "Replace forwarding routes?": "Replace forwarding routes?",
"Replace mode: Will completely replace all existing keys": "Replace mode: Will completely replace all existing keys",
"Replace With": "Replace With",
"replaced": "replaced",
@@ -3941,6 +3967,7 @@
"Route models must be unique for the same incoming path": "Route models must be unique for the same incoming path",
"Route, auth, and balance check in one place": "Route, auth, and balance check in one place",
"Routes": "Routes",
+ "Routes in this plan": "Routes in this plan",
"Routes with the same incoming path are matched by model. Leave the model scope empty only for the final fallback route.": "Routes with the same incoming path are matched by model. Leave the model scope empty only for the final fallback route.",
"Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.": "Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.",
"Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.": "Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.",
@@ -4073,6 +4100,7 @@
"Seed": "Seed",
"Select": "Select",
"Select a color": "Select a color",
+ "Select a complete plan, then keep only the routes you need.": "Select a complete plan, then keep only the routes you need.",
"Select a group": "Select a group",
"Select a group type": "Select a group type",
"Select a model to edit pricing": "Select a model to edit pricing",
@@ -4093,6 +4121,7 @@
"Select announcement type": "Select announcement type",
"Select at least one Auto group or restore global Auto.": "Select at least one Auto group or restore global Auto.",
"Select at least one field to overwrite.": "Select at least one field to overwrite.",
+ "Select at least one route": "Select at least one route",
"Select at least one target model": "Select at least one target model",
"Select at most {{max}} Auto groups": "Select at most {{max}} Auto groups",
"Select body font": "Select body font",
@@ -4557,6 +4586,8 @@
"The unique identifier for this model": "The unique identifier for this model",
"The unique name for this vendor": "The unique name for this vendor",
"The upstream channel that served the requests": "The upstream channel that served the requests",
+ "The upstream natively supports all three protocols; every selected route is forwarded without conversion.": "The upstream natively supports all three protocols; every selected route is forwarded without conversion.",
+ "The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.": "The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.",
"The URL for this chat client.": "The URL for this chat client.",
"The user group applied to the requests": "The user group applied to the requests",
"The user who made the requests": "The user who made the requests",
@@ -4606,6 +4637,8 @@
"This project must be used in compliance with the": "This project must be used in compliance with the",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "This removes {{count}} failed models from this channel. This action cannot be undone.",
"This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "This route discovers upstream OpenAI models and cannot be split or matched by client model rules.",
+ "This route is used only by channel management to discover upstream models.": "This route is used only by channel management to discover upstream models.",
+ "This route is used only by channel management to query the upstream balance.": "This route is used only by channel management to query the upstream balance.",
"This session will lose access immediately and must sign in again.": "This session will lose access immediately and must sign in again.",
"This site currently has {{count}} models enabled": "This site currently has {{count}} models enabled",
"This Telegram account is already bound.": "This Telegram account is already bound.",
@@ -4632,6 +4665,8 @@
"This will permanently remove all log entries created before {{date}}.": "This will permanently remove all log entries created before {{date}}.",
"This will permanently remove log entries before the selected timestamp.": "This will permanently remove log entries before the selected timestamp.",
"This will rebuild the channel routing index from every channel configuration, including supported models, groups, priorities, and weights. Routing may be briefly incomplete while the rebuild is running. Continue?": "This will rebuild the channel routing index from every channel configuration, including supported models, groups, priorities, and weights. Routing may be briefly incomplete while the rebuild is running. Continue?",
+ "This will remove {{removed}} forwarding routes and create {{created}} selected routes. Model list and balance routes will be preserved.": "This will remove {{removed}} forwarding routes and create {{created}} selected routes. Model list and balance routes will be preserved.",
+ "This will remove {{removed}} forwarding routes and replace them with the {{template}} template ({{created}} routes). Model list and balance routes will be preserved.": "This will remove {{removed}} forwarding routes and replace them with the {{template}} template ({{created}} routes). Model list and balance routes will be preserved.",
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?",
"This year": "This year",
@@ -4912,6 +4947,7 @@
"Upscale": "Upscale",
"Upstream": "Upstream",
"Upstream did not return reset credit details.": "Upstream did not return reset credit details.",
+ "Upstream JSON response": "Upstream JSON response",
"Upstream Model Detection Settings": "Upstream Model Detection Settings",
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.",
"Upstream Model Update Check": "Upstream Model Update Check",
@@ -4922,6 +4958,7 @@
"Upstream path must be a full URL or a path starting with /": "Upstream path must be a full URL or a path starting with /",
"Upstream price sync": "Upstream price sync",
"Upstream prices fetched successfully": "Upstream prices fetched successfully",
+ "Upstream protocol plan": "Upstream protocol plan",
"Upstream ratios fetched successfully": "Upstream ratios fetched successfully",
"Upstream Request ID": "Upstream Request ID",
"Upstream Response": "Upstream Response",
@@ -4963,6 +5000,7 @@
"Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.": "Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.",
"Use authenticator code": "Use authenticator code",
"Use backup code": "Use backup code",
+ "Use Bearer for all protocols": "Use Bearer for all protocols",
"Use disk cache when request body exceeds this size": "Use disk cache when request body exceeds this size",
"Use exact client model names, separated by commas. Prefixes and wildcards are not supported.": "Use exact client model names, separated by commas. Prefixes and wildcards are not supported.",
"Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.",
diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json
index e5528b779cc0..9fffe0334850 100644
--- a/web/src/i18n/locales/fr.json
+++ b/web/src/i18n/locales/fr.json
@@ -65,6 +65,9 @@
"{{modality}} supported": "{{modality}} pris en charge",
"{{n}} model(s) selected": "{{n}} modèle(s) sélectionné(s)",
"{{processed}} of {{total}} log entries processed.": "{{processed}} sur {{total}} entrées de journal traitées.",
+ "{{protocol}} auth name": "Nom d’authentification {{protocol}}",
+ "{{protocol}} auth value": "Valeur d’authentification {{protocol}}",
+ "{{protocol}} authentication": "Authentification {{protocol}}",
"{{success}} succeeded, {{failed}} failed": "{{success}} réussi(s), {{failed}} échoué(s)",
"{{target}} test failed": "Échec du test de {{target}}",
"{{target}} test succeeded": "Test de {{target}} réussi",
@@ -188,6 +191,7 @@
"Add Group": "Ajouter un groupe",
"Add group rate limit": "Ajouter une limite de taux de groupe",
"Add group rules": "Ajouter des règles de groupe",
+ "Add management route": "Ajouter une route de gestion",
"Add Mapping": "Ajouter un mappage",
"Add method": "Ajouter une méthode",
"Add missing models": "Ajouter les modèles manquants",
@@ -207,6 +211,7 @@
"Add Quota": "Ajouter un quota",
"Add ratio override": "Ajouter un remplacement de ratio",
"Add route": "Ajouter une route",
+ "Add routes individually or replace them from a template.": "Ajoutez des routes individuellement ou remplacez-les à partir d’un modèle.",
"Add Row": "Ajouter une ligne",
"Add Rule": "Ajouter une règle",
"Add rule group": "Ajouter un groupe de règles",
@@ -215,6 +220,7 @@
"Add split": "Ajouter une branche",
"Add subscription": "Ajouter un abonnement",
"Add tags...": "Ajouter des étiquettes...",
+ "Add template": "Ajouter un modèle",
"Add tier": "Ajouter un palier",
"Add time condition": "Ajouter une condition temporelle",
"Add time rule group": "Ajouter un groupe de règles temporelles",
@@ -298,6 +304,7 @@
"All nodes": "Tous les nœuds",
"All playground messages saved in this browser will be removed. This cannot be undone.": "Tous les messages du Playground enregistrés dans ce navigateur seront supprimés. Cette action est irréversible.",
"All requests must include": "Toutes les requêtes doivent inclure",
+ "All routes": "Toutes les routes",
"All Status": "Tous les statuts",
"All Sync Status": "Tous les statuts de synchronisation",
"All systems operational": "Tous les systèmes opérationnels",
@@ -427,6 +434,7 @@
"Apply Filters": "Appliquer les filtres",
"Apply IP Filter to Resolved Domains": "Appliquer le filtre IP aux domaines résolus",
"Apply Overwrite": "Appliquer l'écrasement",
+ "Apply plan": "Appliquer le plan",
"Apply reset": "Appliquer la réinitialisation",
"Apply Sync": "Appliquer la synchronisation",
"Applying...": "Application en cours...",
@@ -571,6 +579,8 @@
"Balance depleted": "Solde épuisé",
"Balance is shown in quota units": "Le solde est affiché en unités de quota",
"Balance queried successfully": "Solde interrogé avec succès",
+ "Balance Query": "Consultation du solde",
+ "Balance response not recognized": "Réponse de solde non reconnue",
"Balance updated successfully": "Solde mis à jour avec succès",
"Balance updated: {{balance}}": "Solde mis à jour : {{balance}}",
"Bar Chart": "Graphique en barres",
@@ -808,6 +818,7 @@
"checkout.session.completed": "checkout.session.completed",
"checkout.session.expired": "checkout.session.expired",
"Chinese": "Chinois",
+ "Choose a complete upstream protocol plan or edit individual route groups.": "Choisissez un plan de protocole amont complet ou modifiez les groupes de routes.",
"Choose a username": "Choisir un nom d'utilisateur",
"Choose an amount and payment method": "Choisir un montant et un mode de paiement",
"Choose and order the groups this API key will try.": "Sélectionnez et ordonnez les groupes que cette clé API essaiera.",
@@ -830,6 +841,7 @@
"Clamped to": "Limité à",
"Claude": "Claude",
"Claude CLI Header Passthrough": "Passthrough en-tête Claude CLI",
+ "Claude only": "Claude uniquement",
"Clean": "Sans conflit",
"Clean history logs": "Nettoyer les journaux d'historique",
"Clean logs": "Nettoyer les logs",
@@ -906,6 +918,7 @@
"Codex credential must be a JSON object with access_token and account_id": "L'identifiant Codex doit être un objet JSON avec access_token et account_id",
"Cohere": "Cohere",
"Collapse": "Réduire",
+ "Collapse all": "Tout replier",
"Collapse All": "Tout réduire",
"Collect relay latency and success-rate metrics for the model square.": "Collecte les métriques de latence Relay et de taux de réussite pour la place des modèles.",
"Color": "Couleur",
@@ -991,6 +1004,7 @@
"Configure Waffo payment aggregation platform integration": "Configurer l'intégration de la plateforme d'agrégation de paiement Waffo",
"Configure your account behavior preferences": "Configurer les préférences de comportement de votre compte",
"Configure your account preferences and integrations": "Configurer les préférences et les intégrations de votre compte",
+ "Configured": "Configuré",
"Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.": "Enregistré comme JSON PayMethods. La valeur type décide du flux de paiement utilisé : stripe pour Stripe, waffo_pancake pour Waffo Pancake, et les autres valeurs sont envoyées à Epay comme paramètre type.",
"Configured routes and latency checks": "Routes configurées et contrôles de latence",
"Confirm": "Confirmer",
@@ -2079,6 +2093,7 @@
"Format: APPID|APISecret|APIKey": "Format : APPID|APISecret|APIKey",
"Format: TokenHub API Key, or legacy AppId|SecretId|SecretKey": "Format : TokenHub API Key, ou l'ancien format AppId|SecretId|SecretKey",
"Forward requests directly to upstream providers without any post-processing.": "Transférer les requêtes directement aux fournisseurs amont sans aucun post-traitement.",
+ "Forwarding Routes": "Routes de transfert",
"Frames per second": "Images par seconde",
"Free": "Libre",
"Free: {{free}} / Total: {{total}}": "Disponible : {{free}} / Total : {{total}}",
@@ -2091,6 +2106,7 @@
"Full Base URL (supports": "URL de base complète (prend en charge",
"Full Code": "Code complet",
"Full input length": "Longueur complète de l’entrée",
+ "Full JSON": "JSON complet",
"Full layout": "Disposition complète",
"Full width": "Pleine largeur",
"Function calling": "Appel de fonction",
@@ -2101,6 +2117,7 @@
"Gemini": "Gemini",
"Gemini Generate Content to OpenAI Chat": "Gemini Generate Content vers OpenAI Chat",
"Gemini Image 4K": "Gemini Image 4K",
+ "Gemini only": "Gemini uniquement",
"Gemini will continue to auto-detect thinking mode even with the adapter disabled. Enable this only when you need finer control over pricing and budgeting.": "Gemini continuera à détecter automatiquement le mode de pensée même avec l'adaptateur désactivé. Activez ceci uniquement lorsque vous avez besoin d'un contrôle plus fin sur la tarification et le budget.",
"General": "Général",
"General Settings": "Paramètres généraux",
@@ -2702,6 +2719,7 @@
"Model fixed pricing": "Tarification fixe du modèle",
"Model Group": "Groupe de modèles",
"Model Limits": "Limites du modèle",
+ "Model List": "Liste des modèles",
"Model Mapping": "Mappage de modèle",
"Model Mapping (JSON)": "Mappage de modèle (JSON)",
"Model Mapping must be a JSON object like": "Le mappage de modèle doit être un objet JSON tel que",
@@ -2836,8 +2854,11 @@
"Name the channel, choose the provider, configure API access, and set credentials.": "Nommez le canal, choisissez le fournisseur, configurez l’accès API et définissez les identifiants.",
"Name, provider type, and availability.": "Nom, type de fournisseur et disponibilité.",
"name@example.com": "name@example.com",
+ "Native Claude Messages plus OpenAI Chat compatibility forwarding.": "Claude Messages natif avec transfert compatible OpenAI Chat.",
"Native format": "Format natif",
"Native forwarding": "Transfert natif",
+ "Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Routes Gemini natives avec transfert compatible OpenAI Chat et Responses.",
+ "Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "Routes OpenAI natives avec compatibilité Claude et Gemini en option.",
"Need a redemption code?": "Besoin d'un code d'échange ?",
"Needs API key": "Clé API requise",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "JSON imbriqué définissant des règles par groupe pour ajouter (+:), supprimer (-:), ou ajouter des groupes utilisables.",
@@ -3051,6 +3072,7 @@
"Not available": "Non disponible",
"Not backed up": "Non sauvegardé",
"Not bound": "Non lié",
+ "Not configured": "Non configuré",
"Not Equals": "Différent de",
"Not in pricing table": "Absent du tableau tarifaire",
"Not included": "Non inclus",
@@ -3149,6 +3171,7 @@
"Open in new tab": "Ouvrir dans un nouvel onglet",
"Open in New Tab": "Ouvrir dans un nouvel onglet",
"Open menu": "Ouvrir le menu",
+ "Open Query Balance to view the upstream JSON response": "Ouvrez « Consulter le solde » pour voir la réponse JSON amont",
"Open release": "Ouvrir la version",
"Open source": "Open source",
"Open Source": "Open source",
@@ -3161,10 +3184,12 @@
"OpenAI Chat to Gemini Generate Content": "OpenAI Chat vers Gemini Generate Content",
"OpenAI Chat to OpenAI Responses": "OpenAI Chat vers OpenAI Responses",
"OpenAI Compatible": "Compatible OpenAI",
+ "OpenAI Compatible Upstream": "Amont compatible OpenAI",
"OpenAI Models route does not support client model rules": "La route Modèles OpenAI ne prend pas en charge les règles de modèles clients",
"OpenAI Models route is required to enable upstream model checks": "La route Modèles OpenAI est requise pour activer la vérification des modèles en amont",
"OpenAI Models route must use native forwarding": "La route Modèles OpenAI doit utiliser le transfert natif",
"OpenAI Models upstream path must not contain {model}": "Le chemin amont de la route Modèles OpenAI ne doit pas contenir {model}",
+ "OpenAI only": "OpenAI uniquement",
"OpenAI Organization": "Organisation OpenAI",
"OpenAI Organization ID (optional)": "Identifiant d'organisation OpenAI (optionnel)",
"OpenAI Responses to Gemini Generate Content": "OpenAI Responses vers Gemini Generate Content",
@@ -3798,6 +3823,7 @@
"Replace": "Remplacer",
"Replace all existing keys": "Remplacer toutes les clés existantes",
"Replace channel models": "Remplacer les modèles du canal",
+ "Replace forwarding routes?": "Remplacer les routes de transfert ?",
"Replace mode: Will completely replace all existing keys": "Mode remplacement : Remplacera complètement toutes les clés existantes",
"Replace With": "Remplacer par",
"replaced": "remplacé",
@@ -3941,6 +3967,7 @@
"Route models must be unique for the same incoming path": "Les modèles de route doivent être uniques pour le même chemin d'entrée",
"Route, auth, and balance check in one place": "Routage, authentification et solde au même endroit",
"Routes": "Routes",
+ "Routes in this plan": "Routes de ce plan",
"Routes with the same incoming path are matched by model. Leave the model scope empty only for the final fallback route.": "Les routes avec le même chemin d’entrée sont associées par modèle. Laissez la portée de modèles vide uniquement pour la route de repli finale.",
"Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.": "Les routes avec le même chemin entrant sont réparties selon les règles du model client. Les requêtes non appariées utilisent la dernière route de secours.",
"Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.": "Les routes avec le même chemin d’entrée sont réparties par modèle client exact. Les requêtes non associées utilisent le repli final.",
@@ -4073,6 +4100,7 @@
"Seed": "Graine",
"Select": "Sélectionner",
"Select a color": "Sélectionner une couleur",
+ "Select a complete plan, then keep only the routes you need.": "Sélectionnez un plan complet, puis gardez les routes nécessaires.",
"Select a group": "Sélectionner un groupe",
"Select a group type": "Sélectionner un type de groupe",
"Select a model to edit pricing": "Sélectionnez un modèle pour modifier sa tarification",
@@ -4093,6 +4121,7 @@
"Select announcement type": "Sélectionner le type d'annonce",
"Select at least one Auto group or restore global Auto.": "Sélectionnez au moins un groupe Auto ou restaurez l’Auto global.",
"Select at least one field to overwrite.": "Sélectionnez au moins un champ à écraser.",
+ "Select at least one route": "Sélectionnez au moins une route",
"Select at least one target model": "Sélectionnez au moins un modèle cible",
"Select at most {{max}} Auto groups": "Sélectionnez au maximum {{max}} groupes Auto",
"Select body font": "Sélectionner la police du corps de texte",
@@ -4557,6 +4586,8 @@
"The unique identifier for this model": "L'identifiant unique de ce modèle",
"The unique name for this vendor": "Le nom unique de ce fournisseur",
"The upstream channel that served the requests": "Le canal en amont ayant servi les requêtes",
+ "The upstream natively supports all three protocols; every selected route is forwarded without conversion.": "Le service amont prend nativement en charge les trois protocoles ; chaque route sélectionnée est transférée sans conversion.",
+ "The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.": "La réponse amont est un JSON valide, mais ne correspond pas au format OpenAI credit_summary. Le solde du canal n'a pas été mis à jour.",
"The URL for this chat client.": "L'URL de ce client de discussion.",
"The user group applied to the requests": "Le groupe d'utilisateurs appliqué aux requêtes",
"The user who made the requests": "L'utilisateur à l'origine des requêtes",
@@ -4606,6 +4637,8 @@
"This project must be used in compliance with the": "Ce projet doit être utilisé conformément aux",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "Cela supprime {{count}} modèles en échec de ce canal. Cette action est irréversible.",
"This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "Cette route découvre les modèles OpenAI en amont et ne peut être ni divisée ni associée par des règles de modèles clients.",
+ "This route is used only by channel management to discover upstream models.": "Cette route sert uniquement à la gestion du canal pour découvrir les modèles amont.",
+ "This route is used only by channel management to query the upstream balance.": "Cette route sert uniquement à la gestion du canal pour consulter le solde amont.",
"This session will lose access immediately and must sign in again.": "Cette session perdra immédiatement l’accès ; vous devrez vous reconnecter.",
"This site currently has {{count}} models enabled": "Ce site compte actuellement {{count}} modèles activés",
"This Telegram account is already bound.": "Ce compte Telegram est déjà lié.",
@@ -4632,6 +4665,8 @@
"This will permanently remove all log entries created before {{date}}.": "Cela supprimera définitivement toutes les entrées de journal créées avant le {{date}}.",
"This will permanently remove log entries before the selected timestamp.": "Cela supprimera définitivement les entrées de journal antérieures à l'horodatage sélectionné.",
"This will rebuild the channel routing index from every channel configuration, including supported models, groups, priorities, and weights. Routing may be briefly incomplete while the rebuild is running. Continue?": "Cette action reconstruit l’index de routage des canaux à partir de toutes les configurations, y compris les modèles pris en charge, les groupes, les priorités et les poids. Le routage peut être brièvement incomplet pendant la reconstruction. Continuer ?",
+ "This will remove {{removed}} forwarding routes and create {{created}} selected routes. Model list and balance routes will be preserved.": "Cette action supprimera {{removed}} routes de transfert et créera les {{created}} routes sélectionnées. Les routes de modèles et de solde seront conservées.",
+ "This will remove {{removed}} forwarding routes and replace them with the {{template}} template ({{created}} routes). Model list and balance routes will be preserved.": "Cette action supprimera {{removed}} routes de transfert et les remplacera par le modèle {{template}} ({{created}} routes). Les routes de liste des modèles et de solde seront conservées.",
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "La priorité de tous les {{count}} canaux avec le tag \"{{tag}}\" sera mise à jour à {{value}}. Continuer ?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "Le poids de tous les {{count}} canaux avec le tag \"{{tag}}\" sera mis à jour à {{value}}. Continuer ?",
"This year": "Cette année",
@@ -4912,6 +4947,7 @@
"Upscale": "Agrandir",
"Upstream": "Amont",
"Upstream did not return reset credit details.": "L'amont n'a renvoyé aucun détail de crédit de réinitialisation.",
+ "Upstream JSON response": "Réponse JSON amont",
"Upstream Model Detection Settings": "Paramètres de détection des modèles en amont",
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "Tâche de détection des modèles en amont démarrée. Suivez la progression dans Infos système, puis actualisez pour examiner les mises à jour en attente.",
"Upstream Model Update Check": "Vérification des mises à jour des modèles en amont",
@@ -4922,6 +4958,7 @@
"Upstream path must be a full URL or a path starting with /": "Le chemin amont doit être une URL complète ou un chemin commençant par /",
"Upstream price sync": "Synchronisation amont des prix",
"Upstream prices fetched successfully": "Prix amont récupérés avec succès",
+ "Upstream protocol plan": "Plan de protocole amont",
"Upstream ratios fetched successfully": "Ratios en amont récupérés avec succès",
"Upstream Request ID": "ID de requête en amont",
"Upstream Response": "Réponse amont",
@@ -4963,6 +5000,7 @@
"Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.": "Utilisez un chemin pour l’ajouter à la Base URL du canal, ou saisissez une URL complète pour remplacer la Base URL pour cette route.",
"Use authenticator code": "Utiliser le code de l'authentificateur",
"Use backup code": "Utiliser un code de secours",
+ "Use Bearer for all protocols": "Utiliser Bearer pour tous les protocoles",
"Use disk cache when request body exceeds this size": "Utiliser le cache disque quand le corps de requête dépasse cette taille",
"Use exact client model names, separated by commas. Prefixes and wildcards are not supported.": "Utilisez les noms exacts des modèles client, séparés par des virgules. Les préfixes et jokers ne sont pas pris en charge.",
"Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "Utilisez des noms de modèle exacts comme gpt-4o, ou des règles regex préfixées par re: comme re:^gemini-.",
diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json
index dc72689c0bdc..c341c28b1aec 100644
--- a/web/src/i18n/locales/ja.json
+++ b/web/src/i18n/locales/ja.json
@@ -65,6 +65,9 @@
"{{modality}} supported": "{{modality}} をサポート",
"{{n}} model(s) selected": "{{n}} 件のモデルを選択済み",
"{{processed}} of {{total}} log entries processed.": "{{total}} 件中 {{processed}} 件のログを処理しました。",
+ "{{protocol}} auth name": "{{protocol}} 認証名",
+ "{{protocol}} auth value": "{{protocol}} 認証値",
+ "{{protocol}} authentication": "{{protocol}} 認証",
"{{success}} succeeded, {{failed}} failed": "{{success}} 件成功、{{failed}} 件失敗",
"{{target}} test failed": "{{target}} のテストに失敗しました",
"{{target}} test succeeded": "{{target}} のテストに成功しました",
@@ -188,6 +191,7 @@
"Add Group": "グループを追加",
"Add group rate limit": "グループレート制限を追加",
"Add group rules": "グループルールを追加",
+ "Add management route": "管理ルートを追加",
"Add Mapping": "マッピングを追加",
"Add method": "メソッドを追加",
"Add missing models": "不足しているモデルを追加",
@@ -207,6 +211,7 @@
"Add Quota": "クォータを追加",
"Add ratio override": "倍率オーバーライドを追加",
"Add route": "ルートを追加",
+ "Add routes individually or replace them from a template.": "ルートを個別に追加するか、テンプレートで置き換えます。",
"Add Row": "行を追加",
"Add Rule": "ルールを追加",
"Add rule group": "ルールグループを追加",
@@ -215,6 +220,7 @@
"Add split": "分岐を追加",
"Add subscription": "サブスクリプションを追加",
"Add tags...": "タグを追加...",
+ "Add template": "テンプレートを追加",
"Add tier": "ティアを追加",
"Add time condition": "時間条件を追加",
"Add time rule group": "時間ルールグループを追加",
@@ -298,6 +304,7 @@
"All nodes": "すべてのノード",
"All playground messages saved in this browser will be removed. This cannot be undone.": "このブラウザに保存されたすべての Playground メッセージが削除されます。この操作は元に戻せません。",
"All requests must include": "すべてのリクエストには",
+ "All routes": "すべてのルート",
"All Status": "すべてのステータス",
"All Sync Status": "すべての同期状態",
"All systems operational": "すべて正常稼働中",
@@ -427,6 +434,7 @@
"Apply Filters": "フィルターを適用",
"Apply IP Filter to Resolved Domains": "解決されたドメインにIPフィルターを適用",
"Apply Overwrite": "上書き適用",
+ "Apply plan": "プランを適用",
"Apply reset": "リセットを実行",
"Apply Sync": "同期を適用",
"Applying...": "適用中...",
@@ -571,6 +579,8 @@
"Balance depleted": "残高なし",
"Balance is shown in quota units": "残高はクォータ単位で表示されます",
"Balance queried successfully": "残高の取得に成功しました",
+ "Balance Query": "残高照会",
+ "Balance response not recognized": "残高レスポンスを認識できません",
"Balance updated successfully": "残高が正常に更新されました",
"Balance updated: {{balance}}": "残高更新:{{balance}}",
"Bar Chart": "棒グラフ",
@@ -808,6 +818,7 @@
"checkout.session.completed": "checkout.session.completed",
"checkout.session.expired": "checkout.session.expired",
"Chinese": "中国語",
+ "Choose a complete upstream protocol plan or edit individual route groups.": "完全な上流プロトコルプランを選ぶか、ルートグループを個別に編集します。",
"Choose a username": "ユーザー名を選択",
"Choose an amount and payment method": "金額と支払い方法を選択してください",
"Choose and order the groups this API key will try.": "この API キーが試行するグループを選択して並べ替えます。",
@@ -830,6 +841,7 @@
"Clamped to": "制限後の値",
"Claude": "Claude",
"Claude CLI Header Passthrough": "Claude CLI ヘッダーパススルー",
+ "Claude only": "Claude のみ",
"Clean": "問題なし",
"Clean history logs": "履歴ログをクリーンアップ",
"Clean logs": "ログをクリア",
@@ -906,6 +918,7 @@
"Codex credential must be a JSON object with access_token and account_id": "Codex 認証情報は access_token と account_id を含む JSON オブジェクトである必要があります",
"Cohere": "Cohere",
"Collapse": "折りたたむ",
+ "Collapse all": "すべて折りたたむ",
"Collapse All": "すべて折りたたむ",
"Collect relay latency and success-rate metrics for the model square.": "モデル広場向けに Relay のレイテンシと成功率メトリクスを収集します。",
"Color": "カラー",
@@ -991,6 +1004,7 @@
"Configure Waffo payment aggregation platform integration": "Waffo決済アグリゲーションプラットフォームの連携を設定",
"Configure your account behavior preferences": "アカウントの動作設定を設定します。",
"Configure your account preferences and integrations": "アカウントの設定と統合を設定します。",
+ "Configured": "設定済み",
"Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.": "PayMethods JSON として保存されます。type 値で使用する決済フローを決定します。stripe は Stripe、waffo_pancake は Waffo Pancake、それ以外の値は Epay の type パラメーターとして送信されます。",
"Configured routes and latency checks": "設定済みルートとレイテンシ確認",
"Confirm": "確認",
@@ -2079,6 +2093,7 @@
"Format: APPID|APISecret|APIKey": "形式: APPID|APISecret|APIKey",
"Format: TokenHub API Key, or legacy AppId|SecretId|SecretKey": "形式: TokenHub API Key、または旧形式の AppId|SecretId|SecretKey",
"Forward requests directly to upstream providers without any post-processing.": "ポストプロセスなしで、リクエストをアップストリームプロバイダーに直接転送します。",
+ "Forwarding Routes": "転送ルート",
"Frames per second": "フレームレート",
"Free": "空き",
"Free: {{free}} / Total: {{total}}": "空き容量: {{free}} / 合計: {{total}}",
@@ -2091,6 +2106,7 @@
"Full Base URL (supports": "完全なベースURL (サポート",
"Full Code": "完全なコード",
"Full input length": "完全な入力長",
+ "Full JSON": "完全な JSON",
"Full layout": "フルレイアウト",
"Full width": "全幅",
"Function calling": "関数呼び出し",
@@ -2101,6 +2117,7 @@
"Gemini": "Gemini",
"Gemini Generate Content to OpenAI Chat": "Gemini Generate Content から OpenAI Chat",
"Gemini Image 4K": "Gemini Image 4K",
+ "Gemini only": "Gemini のみ",
"Gemini will continue to auto-detect thinking mode even with the adapter disabled. Enable this only when you need finer control over pricing and budgeting.": "アダプターが無効になっていても、Geminiは思考モードを自動検出します。価格設定と予算編成をより細かく制御する必要がある場合にのみ、これを有効にしてください。",
"General": "一般",
"General Settings": "一般設定",
@@ -2702,6 +2719,7 @@
"Model fixed pricing": "モデルの固定価格設定",
"Model Group": "モデルグループ",
"Model Limits": "モデル制限",
+ "Model List": "モデル一覧",
"Model Mapping": "モデルマッピング",
"Model Mapping (JSON)": "モデルマッピング (JSON)",
"Model Mapping must be a JSON object like": "モデルマッピングは次のようなJSONオブジェクトである必要があります",
@@ -2836,8 +2854,11 @@
"Name the channel, choose the provider, configure API access, and set credentials.": "チャネル名を設定し、プロバイダーを選択し、API アクセスと認証情報を設定します。",
"Name, provider type, and availability.": "名前、プロバイダー種別、利用可否。",
"name@example.com": "name@example.com",
+ "Native Claude Messages plus OpenAI Chat compatibility forwarding.": "Claude Messages ネイティブ転送と OpenAI Chat 互換転送。",
"Native format": "ネイティブ形式",
"Native forwarding": "ネイティブ転送",
+ "Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Gemini ネイティブルートと OpenAI Chat / Responses 互換転送。",
+ "Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "OpenAI ネイティブルートと、任意の Claude / Gemini 互換ルート。",
"Need a redemption code?": "引き換えコードが必要ですか?",
"Needs API key": "API キーが必要",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "追加 (+:)、削除 (-:)、または使用可能なグループの追加を行うグループごとのルールを定義するネストされたJSON。",
@@ -3051,6 +3072,7 @@
"Not available": "利用できません",
"Not backed up": "未バックアップ",
"Not bound": "未バインド",
+ "Not configured": "未設定",
"Not Equals": "等しくない",
"Not in pricing table": "料金グループ表にありません",
"Not included": "未登録",
@@ -3149,6 +3171,7 @@
"Open in new tab": "新しいタブで開く",
"Open in New Tab": "新しいタブで開く",
"Open menu": "メニューを開く",
+ "Open Query Balance to view the upstream JSON response": "「残高照会」を開いて上流の JSON レスポンスを確認してください",
"Open release": "リリースを開く",
"Open source": "オープンソース",
"Open Source": "オープンソース",
@@ -3161,10 +3184,12 @@
"OpenAI Chat to Gemini Generate Content": "OpenAI Chat から Gemini Generate Content",
"OpenAI Chat to OpenAI Responses": "OpenAI Chat から OpenAI Responses",
"OpenAI Compatible": "OpenAI互換",
+ "OpenAI Compatible Upstream": "OpenAI 互換アップストリーム",
"OpenAI Models route does not support client model rules": "OpenAI モデルルートはクライアントモデルルールに対応していません",
"OpenAI Models route is required to enable upstream model checks": "アップストリームモデルの確認を有効にするには OpenAI モデルルートが必要です",
"OpenAI Models route must use native forwarding": "OpenAI モデルルートではネイティブ転送を使用する必要があります",
"OpenAI Models upstream path must not contain {model}": "OpenAI モデルのアップストリームパスに {model} を含めることはできません",
+ "OpenAI only": "OpenAI のみ",
"OpenAI Organization": "OpenAI組織",
"OpenAI Organization ID (optional)": "OpenAI 組織 ID (オプション)",
"OpenAI Responses to Gemini Generate Content": "OpenAI Responses から Gemini Generate Content",
@@ -3798,6 +3823,7 @@
"Replace": "置換",
"Replace all existing keys": "既存のすべてのキーを置き換える",
"Replace channel models": "チャネルモデルを置き換える",
+ "Replace forwarding routes?": "転送ルートを置き換えますか?",
"Replace mode: Will completely replace all existing keys": "置換モード: 既存のすべてのキーを完全に置き換えます",
"Replace With": "置換後",
"replaced": "置換済み",
@@ -3941,6 +3967,7 @@
"Route models must be unique for the same incoming path": "同じ入力パスではルートのモデルを一意にしてください",
"Route, auth, and balance check in one place": "ルート、認証、残高確認を一か所に集約",
"Routes": "ルート",
+ "Routes in this plan": "このプランのルート",
"Routes with the same incoming path are matched by model. Leave the model scope empty only for the final fallback route.": "同じ入口パスのルートはモデルで照合されます。モデル範囲を空にできるのは最後のフォールバックルートだけです。",
"Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.": "同じ入口パスのルートはクライアント model ルールで分岐します。一致しないリクエストは最後のフォールバックを使います。",
"Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.": "同じ入口パスのルートは、クライアント model の完全一致で分岐します。一致しないリクエストは最後のフォールバックを使います。",
@@ -4073,6 +4100,7 @@
"Seed": "シード",
"Select": "選択",
"Select a color": "色を選択",
+ "Select a complete plan, then keep only the routes you need.": "完全なプランを選び、必要なルートだけを残します。",
"Select a group": "グループを選択",
"Select a group type": "グループタイプを選択",
"Select a model to edit pricing": "料金を編集するモデルを選択",
@@ -4093,6 +4121,7 @@
"Select announcement type": "アナウンスメントタイプを選択",
"Select at least one Auto group or restore global Auto.": "Auto グループを1つ以上選択するか、グローバル Auto に戻してください。",
"Select at least one field to overwrite.": "上書きするフィールドを少なくとも 1 つ選択してください。",
+ "Select at least one route": "少なくとも1つのルートを選択してください",
"Select at least one target model": "少なくとも1つの対象モデルを選択してください",
"Select at most {{max}} Auto groups": "Auto グループは最大 {{max}} 個まで選択できます",
"Select body font": "本文フォントを選択",
@@ -4557,6 +4586,8 @@
"The unique identifier for this model": "このモデルの一意の識別子",
"The unique name for this vendor": "このベンダーの一意の名前",
"The upstream channel that served the requests": "リクエストを処理した上流チャネル",
+ "The upstream natively supports all three protocols; every selected route is forwarded without conversion.": "上流が3つのプロトコルをネイティブ対応し、選択したルートを変換せず転送します。",
+ "The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.": "上流レスポンスは有効な JSON ですが、OpenAI credit_summary 形式ではありません。チャネル残高は更新されていません。",
"The URL for this chat client.": "このチャットクライアントのURL。",
"The user group applied to the requests": "リクエストに適用されたユーザーグループ",
"The user who made the requests": "リクエストを行ったユーザー",
@@ -4606,6 +4637,8 @@
"This project must be used in compliance with the": "このプロジェクトは、以下を遵守して使用する必要があります",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "この操作はこのチャンネルから失敗した {{count}} 個のモデルを削除します。元に戻せません。",
"This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "このルートはアップストリームの OpenAI モデルを検出するためのもので、分割やクライアントモデルルールによる照合はできません。",
+ "This route is used only by channel management to discover upstream models.": "このルートは、チャネル管理で上流モデルを取得するためだけに使用されます。",
+ "This route is used only by channel management to query the upstream balance.": "このルートは、チャネル管理で上流残高を照会するためだけに使用されます。",
"This session will lose access immediately and must sign in again.": "このセッションは直ちにアクセスできなくなり、再度サインインが必要になります。",
"This site currently has {{count}} models enabled": "このサイトでは現在 {{count}} 個のモデルが有効です",
"This Telegram account is already bound.": "この Telegram アカウントはすでに連携されています。",
@@ -4632,6 +4665,8 @@
"This will permanently remove all log entries created before {{date}}.": "{{date}} より前に作成されたすべてのログエントリが完全に削除されます。",
"This will permanently remove log entries before the selected timestamp.": "選択したタイムスタンプより前のログエントリが完全に削除されます。",
"This will rebuild the channel routing index from every channel configuration, including supported models, groups, priorities, and weights. Routing may be briefly incomplete while the rebuild is running. Continue?": "すべてのチャネル設定からルーティングインデックスを再構築します。対応モデル、グループ、優先度、重みが含まれます。再構築中はルーティングが一時的に不完全になる可能性があります。続行しますか?",
+ "This will remove {{removed}} forwarding routes and create {{created}} selected routes. Model list and balance routes will be preserved.": "{{removed}} 件の転送ルートを削除し、選択した {{created}} 件を作成します。モデル一覧と残高ルートは保持されます。",
+ "This will remove {{removed}} forwarding routes and replace them with the {{template}} template ({{created}} routes). Model list and balance routes will be preserved.": "{{removed}} 件の転送ルートを削除し、{{template}} テンプレート({{created}} 件のルート)に置き換えます。モデル一覧と残高ルートは保持されます。",
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "タグ \"{{tag}}\" の {{count}} 件すべてのチャネルの優先度を {{value}} に更新します。続行しますか?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "タグ \"{{tag}}\" の {{count}} 件すべてのチャネルの重みを {{value}} に更新します。続行しますか?",
"This year": "今年",
@@ -4912,6 +4947,7 @@
"Upscale": "アップスケール",
"Upstream": "アップストリーム",
"Upstream did not return reset credit details.": "上流からリセット回数の詳細が返されませんでした。",
+ "Upstream JSON response": "上流 JSON レスポンス",
"Upstream Model Detection Settings": "アップストリームモデル検出設定",
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "上流モデル検出タスクを開始しました。システム情報で進捗を確認し、完了後に更新してステージングされた変更をご確認ください。",
"Upstream Model Update Check": "アップストリームモデル更新チェック",
@@ -4922,6 +4958,7 @@
"Upstream path must be a full URL or a path starting with /": "上流パスは完全な URL、または / で始まるパスである必要があります",
"Upstream price sync": "アップストリーム価格同期",
"Upstream prices fetched successfully": "上流価格を正常に取得しました",
+ "Upstream protocol plan": "上流プロトコルプラン",
"Upstream ratios fetched successfully": "アップストリーム比率が正常に取得されました",
"Upstream Request ID": "上流リクエストID",
"Upstream Response": "アップストリームレスポンス",
@@ -4963,6 +5000,7 @@
"Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.": "パスを入力するとチャネルの Base URL に追加されます。完全な URL を入力すると、このルートでは Base URL を使わずその URL を使用します。",
"Use authenticator code": "認証コードを使用",
"Use backup code": "バックアップコードを使用",
+ "Use Bearer for all protocols": "すべてのプロトコルで Bearer を使用",
"Use disk cache when request body exceeds this size": "リクエストボディがこのサイズを超えた場合にディスクキャッシュを使用",
"Use exact client model names, separated by commas. Prefixes and wildcards are not supported.": "クライアントの正確なモデル名をカンマ区切りで入力します。プレフィックスやワイルドカードは使えません。",
"Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "gpt-4o のような完全一致のモデル名、または re:^gemini- のように re: で始まる正規表現ルールを使えます。",
diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json
index 4ea860704561..ff62a40888b5 100644
--- a/web/src/i18n/locales/ru.json
+++ b/web/src/i18n/locales/ru.json
@@ -65,6 +65,9 @@
"{{modality}} supported": "{{modality}} поддерживается",
"{{n}} model(s) selected": "Выбрано моделей: {{n}}",
"{{processed}} of {{total}} log entries processed.": "Обработано {{processed}} из {{total}} записей журнала.",
+ "{{protocol}} auth name": "Имя аутентификации {{protocol}}",
+ "{{protocol}} auth value": "Значение аутентификации {{protocol}}",
+ "{{protocol}} authentication": "Аутентификация {{protocol}}",
"{{success}} succeeded, {{failed}} failed": "{{success}} успешно, {{failed}} с ошибкой",
"{{target}} test failed": "Тест {{target}} не выполнен",
"{{target}} test succeeded": "Тест {{target}} успешно выполнен",
@@ -188,6 +191,7 @@
"Add Group": "Добавить группу",
"Add group rate limit": "Добавить ограничение скорости группы",
"Add group rules": "Добавить правила группы",
+ "Add management route": "Добавить служебный маршрут",
"Add Mapping": "Добавить сопоставление",
"Add method": "Добавить метод",
"Add missing models": "Добавить отсутствующие модели",
@@ -207,6 +211,7 @@
"Add Quota": "Добавить квоту",
"Add ratio override": "Добавить переопределение коэффициента",
"Add route": "Добавить маршрут",
+ "Add routes individually or replace them from a template.": "Добавляйте маршруты по отдельности или замените их шаблоном.",
"Add Row": "Добавить строку",
"Add Rule": "Добавить правило",
"Add rule group": "Добавить группу правил",
@@ -215,6 +220,7 @@
"Add split": "Добавить ветку",
"Add subscription": "Добавить подписку",
"Add tags...": "Добавить теги...",
+ "Add template": "Добавить шаблон",
"Add tier": "Добавить уровень",
"Add time condition": "Добавить условие по времени",
"Add time rule group": "Добавить группу правил по времени",
@@ -298,6 +304,7 @@
"All nodes": "Все узлы",
"All playground messages saved in this browser will be removed. This cannot be undone.": "Все сообщения Playground, сохраненные в этом браузере, будут удалены. Это действие нельзя отменить.",
"All requests must include": "Все запросы должны содержать",
+ "All routes": "Все маршруты",
"All Status": "Все статусы",
"All Sync Status": "Все статусы синхронизации",
"All systems operational": "Все системы работают штатно",
@@ -427,6 +434,7 @@
"Apply Filters": "Применить фильтры",
"Apply IP Filter to Resolved Domains": "Применить IP-фильтр к разрешенным доменам",
"Apply Overwrite": "Применить перезапись",
+ "Apply plan": "Применить схему",
"Apply reset": "Выполнить сброс",
"Apply Sync": "Применить синхронизацию",
"Applying...": "Применение...",
@@ -571,6 +579,8 @@
"Balance depleted": "Баланс исчерпан",
"Balance is shown in quota units": "Баланс показан в единицах квоты",
"Balance queried successfully": "Баланс успешно запрошен",
+ "Balance Query": "Запрос баланса",
+ "Balance response not recognized": "Ответ с балансом не распознан",
"Balance updated successfully": "Баланс успешно обновлён",
"Balance updated: {{balance}}": "Баланс обновлён: {{balance}}",
"Bar Chart": "Столбчатая диаграмма",
@@ -808,6 +818,7 @@
"checkout.session.completed": "checkout.session.completed",
"checkout.session.expired": "checkout.session.expired",
"Chinese": "Китайский",
+ "Choose a complete upstream protocol plan or edit individual route groups.": "Выберите полную схему протокола поставщика или измените группы маршрутов.",
"Choose a username": "Выберите имя пользователя",
"Choose an amount and payment method": "Выберите сумму и способ оплаты",
"Choose and order the groups this API key will try.": "Выберите и упорядочьте группы, которые будет использовать этот API-ключ.",
@@ -830,6 +841,7 @@
"Clamped to": "Ограничено до",
"Claude": "Клод",
"Claude CLI Header Passthrough": "Проброс заголовков Claude CLI",
+ "Claude only": "Только Claude",
"Clean": "Без конфликта",
"Clean history logs": "Очистить журналы истории",
"Clean logs": "Очистить логи",
@@ -906,6 +918,7 @@
"Codex credential must be a JSON object with access_token and account_id": "Учетные данные Codex должны быть JSON-объектом с access_token и account_id",
"Cohere": "Cohere",
"Collapse": "Свернуть",
+ "Collapse all": "Свернуть всё",
"Collapse All": "Свернуть все",
"Collect relay latency and success-rate metrics for the model square.": "Собирает метрики задержки Relay и доли успешных запросов для витрины моделей.",
"Color": "Цвет",
@@ -991,6 +1004,7 @@
"Configure Waffo payment aggregation platform integration": "Настроить интеграцию платёжной платформы Waffo",
"Configure your account behavior preferences": "Настроить предпочтения поведения вашей учетной записи",
"Configure your account preferences and integrations": "Настроить параметры и интеграции вашей учетной записи",
+ "Configured": "Настроено",
"Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.": "Сохраняется как JSON PayMethods. Значение type определяет платежный сценарий: stripe для Stripe, waffo_pancake для Waffo Pancake, остальные значения отправляются в Epay как параметр type.",
"Configured routes and latency checks": "Настроенные маршруты и проверки задержки",
"Confirm": "Подтверждение",
@@ -2079,6 +2093,7 @@
"Format: APPID|APISecret|APIKey": "Формат: APPID|APISecret|APIKey",
"Format: TokenHub API Key, or legacy AppId|SecretId|SecretKey": "Формат: TokenHub API Key или устаревший AppId|SecretId|SecretKey",
"Forward requests directly to upstream providers without any post-processing.": "Перенаправлять запросы напрямую upstream-провайдерам без какой-либо постобработки.",
+ "Forwarding Routes": "Маршруты пересылки",
"Frames per second": "Кадров в секунду",
"Free": "Свободно",
"Free: {{free}} / Total: {{total}}": "Свободно: {{free}} / Всего: {{total}}",
@@ -2091,6 +2106,7 @@
"Full Base URL (supports": "Полный базовый URL (поддерживает",
"Full Code": "Полный код",
"Full input length": "Полная длина входа",
+ "Full JSON": "Полный JSON",
"Full layout": "Полная разметка",
"Full width": "Полная ширина",
"Function calling": "Вызов функций",
@@ -2101,6 +2117,7 @@
"Gemini": "Gemini",
"Gemini Generate Content to OpenAI Chat": "Gemini Generate Content в OpenAI Chat",
"Gemini Image 4K": "Gemini Image 4K",
+ "Gemini only": "Только Gemini",
"Gemini will continue to auto-detect thinking mode even with the adapter disabled. Enable this only when you need finer control over pricing and budgeting.": "Gemini продолжит автоматически определять режим мышления, даже если адаптер отключен. Включайте это только тогда, когда вам нужен более тонкий контроль над ценообразованием и бюджетированием.",
"General": "Общие",
"General Settings": "Общие настройки",
@@ -2702,6 +2719,7 @@
"Model fixed pricing": "Фиксированная цена модели",
"Model Group": "Группа моделей",
"Model Limits": "Лимиты модели",
+ "Model List": "Список моделей",
"Model Mapping": "Сопоставление моделей",
"Model Mapping (JSON)": "Сопоставление моделей (JSON)",
"Model Mapping must be a JSON object like": "Сопоставление моделей должно быть JSON-объектом, например",
@@ -2836,8 +2854,11 @@
"Name the channel, choose the provider, configure API access, and set credentials.": "Задайте имя канала, выберите провайдера, настройте доступ к API и учетные данные.",
"Name, provider type, and availability.": "Название, тип провайдера и доступность.",
"name@example.com": "name@example.com",
+ "Native Claude Messages plus OpenAI Chat compatibility forwarding.": "Нативный Claude Messages и совместимая пересылка OpenAI Chat.",
"Native format": "Собственный формат",
"Native forwarding": "Нативная пересылка",
+ "Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Нативные маршруты Gemini и совместимая пересылка OpenAI Chat и Responses.",
+ "Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "Нативные маршруты OpenAI и дополнительные маршруты совместимости Claude и Gemini.",
"Need a redemption code?": "Нужен код активации?",
"Needs API key": "Нужен API-ключ",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "Вложенный JSON, определяющий правила для каждой группы для добавления (+:), удаления (-:) или добавления используемых групп.",
@@ -3051,6 +3072,7 @@
"Not available": "Недоступно",
"Not backed up": "Не сохранено",
"Not bound": "Не привязан",
+ "Not configured": "Не настроено",
"Not Equals": "Не равно",
"Not in pricing table": "Нет в таблице тарифных групп",
"Not included": "Не включена",
@@ -3149,6 +3171,7 @@
"Open in new tab": "Открыть в новой вкладке",
"Open in New Tab": "Открыть в новой вкладке",
"Open menu": "Открыть меню",
+ "Open Query Balance to view the upstream JSON response": "Откройте «Запрос баланса», чтобы просмотреть JSON-ответ поставщика",
"Open release": "Открыть выпуск",
"Open source": "Открытый исходный код",
"Open Source": "Открытый исходный код",
@@ -3161,10 +3184,12 @@
"OpenAI Chat to Gemini Generate Content": "OpenAI Chat в Gemini Generate Content",
"OpenAI Chat to OpenAI Responses": "OpenAI Chat в OpenAI Responses",
"OpenAI Compatible": "Совместимо с OpenAI",
+ "OpenAI Compatible Upstream": "Поставщик, совместимый с OpenAI",
"OpenAI Models route does not support client model rules": "Маршрут моделей OpenAI не поддерживает правила клиентских моделей",
"OpenAI Models route is required to enable upstream model checks": "Для проверки моделей вышестоящего сервиса требуется маршрут моделей OpenAI",
"OpenAI Models route must use native forwarding": "Маршрут моделей OpenAI должен использовать прямую передачу",
"OpenAI Models upstream path must not contain {model}": "Путь вышестоящего сервиса для моделей OpenAI не должен содержать {model}",
+ "OpenAI only": "Только OpenAI",
"OpenAI Organization": "Организация OpenAI",
"OpenAI Organization ID (optional)": "Идентификатор организации OpenAI (необязательно)",
"OpenAI Responses to Gemini Generate Content": "OpenAI Responses в Gemini Generate Content",
@@ -3798,6 +3823,7 @@
"Replace": "Заменить",
"Replace all existing keys": "Заменить все существующие ключи",
"Replace channel models": "Замена моделей каналов",
+ "Replace forwarding routes?": "Заменить маршруты пересылки?",
"Replace mode: Will completely replace all existing keys": "Режим замены: полностью заменит все существующие ключи",
"Replace With": "Заменить на",
"replaced": "заменено",
@@ -3941,6 +3967,7 @@
"Route models must be unique for the same incoming path": "Модели маршрутов для одного входного пути должны быть уникальными",
"Route, auth, and balance check in one place": "Маршрут, аутентификация и баланс в одном месте",
"Routes": "Маршруты",
+ "Routes in this plan": "Маршруты в этой схеме",
"Routes with the same incoming path are matched by model. Leave the model scope empty only for the final fallback route.": "Маршруты с одним входным путем сопоставляются по модели. Оставляйте область моделей пустой только для последнего резервного маршрута.",
"Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.": "Маршруты с одним входным путем разделяются правилами client model. Неподходящие запросы используют последний резервный маршрут.",
"Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.": "Маршруты с одним входным путем разделяются по точной модели клиента. Несовпавшие запросы идут в последний резерв.",
@@ -4073,6 +4100,7 @@
"Seed": "Seed",
"Select": "Выбрать",
"Select a color": "Выбрать цвет",
+ "Select a complete plan, then keep only the routes you need.": "Выберите полную схему, затем оставьте только нужные маршруты.",
"Select a group": "Выбрать группу",
"Select a group type": "Выбрать тип группы",
"Select a model to edit pricing": "Выберите модель для редактирования тарифа",
@@ -4093,6 +4121,7 @@
"Select announcement type": "Выбрать тип объявления",
"Select at least one Auto group or restore global Auto.": "Выберите хотя бы одну группу Auto или восстановите глобальный порядок Auto.",
"Select at least one field to overwrite.": "Выберите хотя бы одно поле для перезаписи.",
+ "Select at least one route": "Выберите хотя бы один маршрут",
"Select at least one target model": "Выберите хотя бы одну целевую модель",
"Select at most {{max}} Auto groups": "Выберите не более {{max}} групп Auto",
"Select body font": "Выберите шрифт текста",
@@ -4557,6 +4586,8 @@
"The unique identifier for this model": "Уникальный идентификатор для этой модели",
"The unique name for this vendor": "Уникальное имя для этого поставщика",
"The upstream channel that served the requests": "Вышестоящий канал, обслуживший запросы",
+ "The upstream natively supports all three protocols; every selected route is forwarded without conversion.": "Поставщик нативно поддерживает все три протокола; выбранные маршруты пересылаются без преобразования.",
+ "The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.": "Ответ поставщика содержит допустимый JSON, но не соответствует формату OpenAI credit_summary. Баланс канала не обновлён.",
"The URL for this chat client.": "URL для этого чат-клиента.",
"The user group applied to the requests": "Группа пользователей, применённая к запросам",
"The user who made the requests": "Пользователь, отправивший запросы",
@@ -4606,6 +4637,8 @@
"This project must be used in compliance with the": "Этот проект должен использоваться в соответствии с",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "Это удалит {{count}} неуспешных моделей из этого канала. Действие необратимо.",
"This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "Этот маршрут обнаруживает модели OpenAI вышестоящего сервиса; его нельзя разделять или сопоставлять по правилам клиентских моделей.",
+ "This route is used only by channel management to discover upstream models.": "Этот маршрут используется только управлением каналами для получения моделей поставщика.",
+ "This route is used only by channel management to query the upstream balance.": "Этот маршрут используется только управлением каналами для запроса баланса поставщика.",
"This session will lose access immediately and must sign in again.": "Этот сеанс немедленно потеряет доступ, и потребуется повторный вход.",
"This site currently has {{count}} models enabled": "На этом сайте сейчас включено моделей: {{count}}",
"This Telegram account is already bound.": "Эта учётная запись Telegram уже привязана.",
@@ -4632,6 +4665,8 @@
"This will permanently remove all log entries created before {{date}}.": "Это безвозвратно удалит все записи журнала, созданные до {{date}}.",
"This will permanently remove log entries before the selected timestamp.": "Это безвозвратно удалит записи журнала до выбранной временной метки.",
"This will rebuild the channel routing index from every channel configuration, including supported models, groups, priorities, and weights. Routing may be briefly incomplete while the rebuild is running. Continue?": "Будет заново построен индекс маршрутизации каналов на основе всех конфигураций каналов, включая поддерживаемые модели, группы, приоритеты и веса. Во время перестроения маршрутизация может кратковременно быть неполной. Продолжить?",
+ "This will remove {{removed}} forwarding routes and create {{created}} selected routes. Model list and balance routes will be preserved.": "Будут удалены маршруты пересылки ({{removed}}) и созданы выбранные маршруты ({{created}}). Маршруты моделей и баланса сохранятся.",
+ "This will remove {{removed}} forwarding routes and replace them with the {{template}} template ({{created}} routes). Model list and balance routes will be preserved.": "Будет удалено маршрутов переадресации: {{removed}}. Они будут заменены шаблоном {{template}} (маршрутов: {{created}}). Маршруты списка моделей и баланса сохранятся.",
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "Приоритет всех каналов ({{count}}) с тегом \"{{tag}}\" будет изменен на {{value}}. Продолжить?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "Вес всех каналов ({{count}}) с тегом \"{{tag}}\" будет изменен на {{value}}. Продолжить?",
"This year": "Этот год",
@@ -4912,6 +4947,7 @@
"Upscale": "Увеличение",
"Upstream": "Источник",
"Upstream did not return reset credit details.": "Вышестоящий сервис не вернул сведения о сбросах лимита.",
+ "Upstream JSON response": "JSON-ответ поставщика",
"Upstream Model Detection Settings": "Настройки обнаружения моделей провайдера",
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "Задача обнаружения моделей вышестоящего источника запущена. Следите за ходом в разделе «Информация о системе», затем обновите, чтобы просмотреть подготовленные изменения.",
"Upstream Model Update Check": "Проверка обновлений моделей провайдера",
@@ -4922,6 +4958,7 @@
"Upstream path must be a full URL or a path starting with /": "Путь upstream должен быть полным URL или путем, начинающимся с /",
"Upstream price sync": "Синхронизация цен upstream",
"Upstream prices fetched successfully": "Цены провайдера успешно получены",
+ "Upstream protocol plan": "Схема протокола поставщика",
"Upstream ratios fetched successfully": "Коэффициенты upstream успешно получены",
"Upstream Request ID": "ID вышестоящего запроса",
"Upstream Response": "Ответ Upstream",
@@ -4963,6 +5000,7 @@
"Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.": "Укажите путь, чтобы добавить его к Base URL канала, или введите полный URL, чтобы переопределить Base URL для этого маршрута.",
"Use authenticator code": "Использовать код аутентификатора",
"Use backup code": "Использовать резервный код",
+ "Use Bearer for all protocols": "Использовать Bearer для всех протоколов",
"Use disk cache when request body exceeds this size": "Использовать дисковый кэш, когда тело запроса превышает этот размер",
"Use exact client model names, separated by commas. Prefixes and wildcards are not supported.": "Укажите точные имена моделей клиента через запятую. Префиксы и подстановочные знаки не поддерживаются.",
"Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "Используйте точные имена моделей, например gpt-4o, или regex-правила с префиксом re:, например re:^gemini-.",
diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json
index 4f42938a7e95..00893a538cbd 100644
--- a/web/src/i18n/locales/vi.json
+++ b/web/src/i18n/locales/vi.json
@@ -65,6 +65,9 @@
"{{modality}} supported": "Hỗ trợ {{modality}}",
"{{n}} model(s) selected": "Đã chọn {{n}} model",
"{{processed}} of {{total}} log entries processed.": "Đã xử lý {{processed}}/{{total}} mục nhật ký.",
+ "{{protocol}} auth name": "Tên xác thực {{protocol}}",
+ "{{protocol}} auth value": "Giá trị xác thực {{protocol}}",
+ "{{protocol}} authentication": "Xác thực {{protocol}}",
"{{success}} succeeded, {{failed}} failed": "{{success}} thành công, {{failed}} thất bại",
"{{target}} test failed": "Kiểm tra {{target}} thất bại",
"{{target}} test succeeded": "Kiểm tra {{target}} thành công",
@@ -188,6 +191,7 @@
"Add Group": "Thêm Nhóm",
"Add group rate limit": "Thêm giới hạn tốc độ nhóm",
"Add group rules": "Thêm quy tắc nhóm",
+ "Add management route": "Thêm route quản lý",
"Add Mapping": "Thêm ánh xạ",
"Add method": "Thêm phương thức",
"Add missing models": "Thêm mô hình còn thiếu",
@@ -207,6 +211,7 @@
"Add Quota": "Thêm Hạn mức",
"Add ratio override": "Thêm ghi đè tỷ lệ",
"Add route": "Thêm route",
+ "Add routes individually or replace them from a template.": "Thêm từng tuyến hoặc thay thế bằng một mẫu.",
"Add Row": "Thêm Hàng",
"Add Rule": "Thêm quy tắc",
"Add rule group": "Thêm nhóm quy tắc",
@@ -215,6 +220,7 @@
"Add split": "Thêm nhánh",
"Add subscription": "Thêm đăng ký",
"Add tags...": "Thêm thẻ...",
+ "Add template": "Thêm mẫu",
"Add tier": "Thêm bậc",
"Add time condition": "Thêm điều kiện thời gian",
"Add time rule group": "Thêm nhóm quy tắc theo thời gian",
@@ -298,6 +304,7 @@
"All nodes": "Tất cả nút",
"All playground messages saved in this browser will be removed. This cannot be undone.": "Tất cả tin nhắn Playground đã lưu trong trình duyệt này sẽ bị xóa. Không thể hoàn tác hành động này.",
"All requests must include": "Mọi yêu cầu phải có header",
+ "All routes": "Tất cả tuyến",
"All Status": "Tất cả trạng thái",
"All Sync Status": "Tất cả Trạng thái Đồng bộ",
"All systems operational": "Tất cả hệ thống hoạt động bình thường",
@@ -427,6 +434,7 @@
"Apply Filters": "Áp dụng bộ lọc",
"Apply IP Filter to Resolved Domains": "Áp dụng Bộ lọc IP cho Tên miền đã phân giải",
"Apply Overwrite": "Áp dụng Ghi đè",
+ "Apply plan": "Áp dụng cấu hình",
"Apply reset": "Thực hiện đặt lại",
"Apply Sync": "Áp dụng đồng bộ",
"Applying...": "Đang áp dụng...",
@@ -571,6 +579,8 @@
"Balance depleted": "Đã hết số dư",
"Balance is shown in quota units": "Số dư được hiển thị theo đơn vị hạn mức",
"Balance queried successfully": "Truy vấn số dư thành công",
+ "Balance Query": "Truy vấn số dư",
+ "Balance response not recognized": "Không nhận dạng được phản hồi số dư",
"Balance updated successfully": "Đã cập nhật số dư thành công",
"Balance updated: {{balance}}": "Số dư đã cập nhật: {{balance}}",
"Bar Chart": "Biểu đồ cột",
@@ -808,6 +818,7 @@
"checkout.session.completed": "thanh toán.phiên.hoàn thành",
"checkout.session.expired": "Phiên thanh toán đã hết hạn.",
"Chinese": "Tiếng Trung",
+ "Choose a complete upstream protocol plan or edit individual route groups.": "Chọn cấu hình giao thức thượng nguồn đầy đủ hoặc chỉnh sửa từng nhóm route.",
"Choose a username": "Chọn tên người dùng",
"Choose an amount and payment method": "Chọn số tiền và phương thức thanh toán",
"Choose and order the groups this API key will try.": "Chọn và sắp xếp các nhóm mà khóa API này sẽ thử.",
@@ -830,6 +841,7 @@
"Clamped to": "Giới hạn thành",
"Claude": "Claude",
"Claude CLI Header Passthrough": "Chuyển tiếp header Claude CLI",
+ "Claude only": "Chỉ Claude",
"Clean": "Không xung đột",
"Clean history logs": "Xóa nhật ký lịch sử",
"Clean logs": "Dọn dẹp nhật ký",
@@ -906,6 +918,7 @@
"Codex credential must be a JSON object with access_token and account_id": "Thông tin xác thực Codex phải là đối tượng JSON có access_token và account_id",
"Cohere": "Cohere",
"Collapse": "Thu gọn",
+ "Collapse all": "Thu gọn tất cả",
"Collapse All": "Thu gọn tất cả",
"Collect relay latency and success-rate metrics for the model square.": "Thu thập độ trễ Relay và tỷ lệ thành công cho quảng trường mô hình.",
"Color": "Màu",
@@ -991,6 +1004,7 @@
"Configure Waffo payment aggregation platform integration": "Cấu hình tích hợp nền tảng tổng hợp thanh toán Waffo",
"Configure your account behavior preferences": "Cấu hình tùy chọn hành vi tài khoản của bạn",
"Configure your account preferences and integrations": "Cấu hình các tùy chọn và tích hợp tài khoản của bạn",
+ "Configured": "Đã cấu hình",
"Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.": "Được lưu dưới dạng JSON PayMethods. Giá trị type quyết định luồng thanh toán sẽ dùng: stripe cho Stripe, waffo_pancake cho Waffo Pancake, các giá trị khác được gửi tới Epay dưới dạng tham số type.",
"Configured routes and latency checks": "Tuyến đã cấu hình và kiểm tra độ trễ",
"Confirm": "Xác nhận",
@@ -2079,6 +2093,7 @@
"Format: APPID|APISecret|APIKey": "Định dạng: APPID|APISecret|APIKey",
"Format: TokenHub API Key, or legacy AppId|SecretId|SecretKey": "Định dạng: TokenHub API Key, hoặc định dạng cũ AppId|SecretId|SecretKey",
"Forward requests directly to upstream providers without any post-processing.": "Chuyển tiếp các yêu cầu trực tiếp đến các nhà cung cấp ngược dòng mà không cần xử lý hậu kỳ nào.",
+ "Forwarding Routes": "Route chuyển tiếp",
"Frames per second": "Khung hình / giây",
"Free": "Trống",
"Free: {{free}} / Total: {{total}}": "Còn trống: {{free}} / Tổng: {{total}}",
@@ -2091,6 +2106,7 @@
"Full Base URL (supports": "URL cơ sở đầy đủ (hỗ trợ",
"Full Code": "Mã đầy đủ",
"Full input length": "Độ dài đầu vào đầy đủ",
+ "Full JSON": "JSON đầy đủ",
"Full layout": "Bố cục đầy đủ",
"Full width": "Toàn chiều rộng",
"Function calling": "Gọi hàm",
@@ -2101,6 +2117,7 @@
"Gemini": "Song Tử",
"Gemini Generate Content to OpenAI Chat": "Gemini Generate Content sang OpenAI Chat",
"Gemini Image 4K": "Gemini Image 4K",
+ "Gemini only": "Chỉ Gemini",
"Gemini will continue to auto-detect thinking mode even with the adapter disabled. Enable this only when you need finer control over pricing and budgeting.": "Gemini sẽ tiếp tục tự động phát hiện chế độ suy nghĩ ngay cả khi bộ điều hợp bị tắt. Chỉ bật tính năng này khi bạn cần kiểm soát chi tiết hơn về giá cả và lập ngân sách.",
"General": "Chung",
"General Settings": "General settings",
@@ -2702,6 +2719,7 @@
"Model fixed pricing": "Fixed-price model",
"Model Group": "Nhóm Mô hình",
"Model Limits": "Giới hạn Mô hình",
+ "Model List": "Danh sách mô hình",
"Model Mapping": "Ánh xạ mô hình",
"Model Mapping (JSON)": "Ánh xạ mô hình (JSON)",
"Model Mapping must be a JSON object like": "Ánh xạ Mô hình phải là một đối tượng JSON như",
@@ -2836,8 +2854,11 @@
"Name the channel, choose the provider, configure API access, and set credentials.": "Đặt tên kênh, chọn nhà cung cấp, cấu hình truy cập API và thiết lập thông tin xác thực.",
"Name, provider type, and availability.": "Tên, loại nhà cung cấp và trạng thái khả dụng.",
"name@example.com": "name@example.com",
+ "Native Claude Messages plus OpenAI Chat compatibility forwarding.": "Chuyển tiếp Claude Messages nguyên bản và tương thích OpenAI Chat.",
"Native format": "Định dạng gốc",
"Native forwarding": "Chuyển tiếp nguyên bản",
+ "Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Route Gemini nguyên bản cùng chuyển tiếp tương thích OpenAI Chat và Responses.",
+ "Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "Route OpenAI nguyên bản cùng các route tương thích Claude và Gemini tùy chọn.",
"Need a redemption code?": "Cần mã đổi thưởng?",
"Needs API key": "Cần khóa API",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "JSON lồng nhau xác định quy tắc theo nhóm để thêm (+:), xóa (-:), hoặc nối các nhóm có thể sử dụng.",
@@ -3051,6 +3072,7 @@
"Not available": "Không khả dụng",
"Not backed up": "Chưa sao lưu",
"Not bound": "Không bị ràng buộc",
+ "Not configured": "Chưa cấu hình",
"Not Equals": "Không bằng",
"Not in pricing table": "Không có trong bảng định giá",
"Not included": "Không bao gồm",
@@ -3149,6 +3171,7 @@
"Open in new tab": "Mở trong tab mới",
"Open in New Tab": "Mở trong tab mới",
"Open menu": "Mở menu",
+ "Open Query Balance to view the upstream JSON response": "Mở “Truy vấn số dư” để xem phản hồi JSON thượng nguồn",
"Open release": "Phát hành mở",
"Open source": "Mã nguồn mở",
"Open Source": "Mã nguồn mở",
@@ -3161,10 +3184,12 @@
"OpenAI Chat to Gemini Generate Content": "OpenAI Chat sang Gemini Generate Content",
"OpenAI Chat to OpenAI Responses": "OpenAI Chat sang OpenAI Responses",
"OpenAI Compatible": "Tương thích OpenAI",
+ "OpenAI Compatible Upstream": "Thượng nguồn tương thích OpenAI",
"OpenAI Models route does not support client model rules": "Tuyến Mô hình OpenAI không hỗ trợ quy tắc mô hình phía máy khách",
"OpenAI Models route is required to enable upstream model checks": "Cần có tuyến Mô hình OpenAI để bật kiểm tra mô hình thượng nguồn",
"OpenAI Models route must use native forwarding": "Tuyến Mô hình OpenAI phải dùng chuyển tiếp nguyên bản",
"OpenAI Models upstream path must not contain {model}": "Đường dẫn thượng nguồn của Mô hình OpenAI không được chứa {model}",
+ "OpenAI only": "Chỉ OpenAI",
"OpenAI Organization": "Tổ chức OpenAI",
"OpenAI Organization ID (optional)": "ID Tổ chức OpenAI (tùy chọn)",
"OpenAI Responses to Gemini Generate Content": "OpenAI Responses sang Gemini Generate Content",
@@ -3798,6 +3823,7 @@
"Replace": "Thay thế",
"Replace all existing keys": "Thay thế tất cả các khóa hiện có",
"Replace channel models": "Thay thế mô hình kênh",
+ "Replace forwarding routes?": "Thay thế các route chuyển tiếp?",
"Replace mode: Will completely replace all existing keys": "Chế độ Thay thế: Sẽ thay thế hoàn toàn tất cả các khóa hiện có",
"Replace With": "Thay bằng",
"replaced": "thay thế",
@@ -3941,6 +3967,7 @@
"Route models must be unique for the same incoming path": "Các mô hình tuyến phải là duy nhất cho cùng đường dẫn đầu vào",
"Route, auth, and balance check in one place": "Kiểm tra tuyến, xác thực và số dư ở cùng một nơi",
"Routes": "Route",
+ "Routes in this plan": "Các route trong cấu hình",
"Routes with the same incoming path are matched by model. Leave the model scope empty only for the final fallback route.": "Các tuyến có cùng đường dẫn vào được khớp theo mô hình. Chỉ để trống phạm vi mô hình cho tuyến dự phòng cuối cùng.",
"Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.": "Các route có cùng đường vào được phân nhánh theo quy tắc client model. Yêu cầu không khớp dùng nhánh dự phòng cuối.",
"Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.": "Các tuyến có cùng đường dẫn vào được tách theo model client chính xác. Yêu cầu chưa khớp sẽ dùng nhánh dự phòng cuối cùng.",
@@ -4073,6 +4100,7 @@
"Seed": "Seed",
"Select": "Chọn",
"Select a color": "Chọn một màu",
+ "Select a complete plan, then keep only the routes you need.": "Chọn cấu hình đầy đủ, sau đó chỉ giữ các route cần thiết.",
"Select a group": "Chọn một nhóm",
"Select a group type": "Chọn loại nhóm",
"Select a model to edit pricing": "Chọn mô hình để chỉnh sửa giá",
@@ -4093,6 +4121,7 @@
"Select announcement type": "Select notification type",
"Select at least one Auto group or restore global Auto.": "Chọn ít nhất một nhóm Auto hoặc khôi phục Auto toàn cục.",
"Select at least one field to overwrite.": "Chọn ít nhất một trường để ghi đè.",
+ "Select at least one route": "Chọn ít nhất một route",
"Select at least one target model": "Chọn ít nhất một mô hình đích",
"Select at most {{max}} Auto groups": "Chọn tối đa {{max}} nhóm Auto",
"Select body font": "Chọn phông chữ nội dung",
@@ -4557,6 +4586,8 @@
"The unique identifier for this model": "Mã định danh duy nhất cho mô hình này",
"The unique name for this vendor": "Tên duy nhất cho nhà cung cấp này",
"The upstream channel that served the requests": "Kênh thượng nguồn đã phục vụ các yêu cầu",
+ "The upstream natively supports all three protocols; every selected route is forwarded without conversion.": "Thượng nguồn hỗ trợ nguyên bản cả ba giao thức; mọi route đã chọn được chuyển tiếp mà không chuyển đổi.",
+ "The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.": "Phản hồi thượng nguồn là JSON hợp lệ nhưng không khớp định dạng OpenAI credit_summary. Số dư kênh chưa được cập nhật.",
"The URL for this chat client.": "URL của ứng dụng chat này.",
"The user group applied to the requests": "Nhóm người dùng được áp dụng cho các yêu cầu",
"The user who made the requests": "Người dùng đã thực hiện các yêu cầu",
@@ -4606,6 +4637,8 @@
"This project must be used in compliance with the": "Dự án này phải được sử dụng tuân thủ theo",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "Thao tác này sẽ xóa {{count}} mô hình thất bại khỏi kênh này. Không thể hoàn tác.",
"This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "Tuyến này khám phá các mô hình OpenAI thượng nguồn và không thể tách hoặc đối sánh bằng quy tắc mô hình phía máy khách.",
+ "This route is used only by channel management to discover upstream models.": "Route này chỉ được quản lý kênh dùng để lấy mô hình thượng nguồn.",
+ "This route is used only by channel management to query the upstream balance.": "Route này chỉ được quản lý kênh dùng để truy vấn số dư thượng nguồn.",
"This session will lose access immediately and must sign in again.": "Phiên này sẽ mất quyền truy cập ngay lập tức và phải đăng nhập lại.",
"This site currently has {{count}} models enabled": "Trang này hiện đã bật {{count}} mô hình",
"This Telegram account is already bound.": "Tài khoản Telegram này đã được liên kết.",
@@ -4632,6 +4665,8 @@
"This will permanently remove all log entries created before {{date}}.": "Thao tác này sẽ xóa vĩnh viễn tất cả các mục nhật ký được tạo trước {{date}}.",
"This will permanently remove log entries before the selected timestamp.": "Thao tác này sẽ xóa vĩnh viễn các mục nhật ký trước mốc thời gian đã chọn.",
"This will rebuild the channel routing index from every channel configuration, including supported models, groups, priorities, and weights. Routing may be briefly incomplete while the rebuild is running. Continue?": "Thao tác này sẽ xây dựng lại chỉ mục định tuyến kênh từ toàn bộ cấu hình kênh, bao gồm mô hình được hỗ trợ, nhóm, độ ưu tiên và trọng số. Định tuyến có thể tạm thời chưa đầy đủ trong khi xây dựng lại. Tiếp tục?",
+ "This will remove {{removed}} forwarding routes and create {{created}} selected routes. Model list and balance routes will be preserved.": "Thao tác này sẽ xóa {{removed}} route chuyển tiếp và tạo {{created}} route đã chọn. Route danh sách mô hình và số dư sẽ được giữ lại.",
+ "This will remove {{removed}} forwarding routes and replace them with the {{template}} template ({{created}} routes). Model list and balance routes will be preserved.": "Thao tác này sẽ xóa {{removed}} tuyến chuyển tiếp và thay thế bằng mẫu {{template}} ({{created}} tuyến). Các tuyến danh sách mô hình và số dư sẽ được giữ lại.",
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "Thao tác này sẽ cập nhật mức ưu tiên thành {{value}} cho tất cả {{count}} kênh có thẻ \"{{tag}}\". Tiếp tục?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "Thao tác này sẽ cập nhật trọng số thành {{value}} cho tất cả {{count}} kênh có thẻ \"{{tag}}\". Tiếp tục?",
"This year": "Năm nay",
@@ -4912,6 +4947,7 @@
"Upscale": "Phóng to",
"Upstream": "Thượng nguồn",
"Upstream did not return reset credit details.": "Upstream không trả về chi tiết lượt đặt lại.",
+ "Upstream JSON response": "Phản hồi JSON thượng nguồn",
"Upstream Model Detection Settings": "Cài đặt phát hiện mô hình nguồn",
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "Đã bắt đầu tác vụ phát hiện mô hình thượng nguồn. Theo dõi tiến trình trong Thông tin hệ thống, sau đó làm mới để xem các cập nhật đang chờ.",
"Upstream Model Update Check": "Kiểm tra cập nhật mô hình nguồn",
@@ -4922,6 +4958,7 @@
"Upstream path must be a full URL or a path starting with /": "Đường dẫn upstream phải là URL đầy đủ hoặc đường dẫn bắt đầu bằng /",
"Upstream price sync": "Đồng bộ giá thượng nguồn",
"Upstream prices fetched successfully": "Lấy giá upstream thành công",
+ "Upstream protocol plan": "Cấu hình giao thức thượng nguồn",
"Upstream ratios fetched successfully": "Đã lấy tỷ lệ upstream thành công",
"Upstream Request ID": "ID yêu cầu thượng nguồn",
"Upstream Response": "Upstream feedback",
@@ -4963,6 +5000,7 @@
"Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.": "Dùng đường dẫn để nối vào Base URL của kênh, hoặc nhập URL đầy đủ để ghi đè Base URL cho tuyến này.",
"Use authenticator code": "Sử dụng mã xác thực",
"Use backup code": "Sử dụng mã dự phòng",
+ "Use Bearer for all protocols": "Dùng Bearer cho mọi giao thức",
"Use disk cache when request body exceeds this size": "Sử dụng bộ nhớ đệm đĩa khi nội dung yêu cầu vượt quá kích thước này",
"Use exact client model names, separated by commas. Prefixes and wildcards are not supported.": "Nhập tên model chính xác từ yêu cầu client, ngăn cách bằng dấu phẩy. Không hỗ trợ tiền tố hoặc ký tự đại diện.",
"Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "Dùng tên model chính xác như gpt-4o, hoặc quy tắc regex có tiền tố re: như re:^gemini-.",
diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json
index ab6acc0de9a3..b002211c307a 100644
--- a/web/src/i18n/locales/zh-TW.json
+++ b/web/src/i18n/locales/zh-TW.json
@@ -65,6 +65,9 @@
"{{modality}} supported": "支援 {{modality}}",
"{{n}} model(s) selected": "已選 {{n}} 個模型",
"{{processed}} of {{total}} log entries processed.": "已處理 {{processed}} / {{total}} 條日誌。",
+ "{{protocol}} auth name": "{{protocol}} 驗證名稱",
+ "{{protocol}} auth value": "{{protocol}} 驗證值",
+ "{{protocol}} authentication": "{{protocol}} 驗證",
"{{success}} succeeded, {{failed}} failed": "{{success}} 個成功,{{failed}} 個失敗",
"{{target}} test failed": "{{target}} 測試失敗",
"{{target}} test succeeded": "{{target}} 測試成功",
@@ -188,6 +191,7 @@
"Add Group": "新增分組",
"Add group rate limit": "新增組速率限制",
"Add group rules": "新增分組規則",
+ "Add management route": "新增管理路由",
"Add Mapping": "新增映射",
"Add method": "新增方式",
"Add missing models": "新增缺失模型",
@@ -207,6 +211,7 @@
"Add Quota": "新增配額",
"Add ratio override": "新增倍率覆蓋",
"Add route": "新增路由",
+ "Add routes individually or replace them from a template.": "逐一新增路由,或使用範本取代現有路由。",
"Add Row": "新增列",
"Add Rule": "新增規則",
"Add rule group": "新增規則組",
@@ -215,6 +220,7 @@
"Add split": "新增分流",
"Add subscription": "新增訂閱",
"Add tags...": "新增標籤...",
+ "Add template": "新增範本",
"Add tier": "新增檔位",
"Add time condition": "新增時間條件",
"Add time rule group": "新增時間規則組",
@@ -298,6 +304,7 @@
"All nodes": "全部節點",
"All playground messages saved in this browser will be removed. This cannot be undone.": "儲存在此瀏覽器中的所有遊樂場訊息都將被移除。此操作無法撤銷。",
"All requests must include": "所有請求必須攜帶",
+ "All routes": "全部路由",
"All Status": "所有狀態",
"All Sync Status": "所有同步狀態",
"All systems operational": "所有系統正常運作",
@@ -427,6 +434,7 @@
"Apply Filters": "套用篩選器",
"Apply IP Filter to Resolved Domains": "對已解析的網域套用 IP 篩選器",
"Apply Overwrite": "套用覆蓋",
+ "Apply plan": "套用方案",
"Apply reset": "執行重置",
"Apply Sync": "套用同步",
"Applying...": "正在套用...",
@@ -571,6 +579,8 @@
"Balance depleted": "餘額已耗盡",
"Balance is shown in quota units": "餘額以額度單位顯示",
"Balance queried successfully": "餘額查詢成功",
+ "Balance Query": "餘額查詢",
+ "Balance response not recognized": "無法識別餘額回應",
"Balance updated successfully": "餘額更新成功",
"Balance updated: {{balance}}": "餘額已更新:{{balance}}",
"Bar Chart": "柱狀圖",
@@ -808,6 +818,7 @@
"checkout.session.completed": "checkout.session.completed",
"checkout.session.expired": "checkout.session.expired",
"Chinese": "中文",
+ "Choose a complete upstream protocol plan or edit individual route groups.": "選擇完整的上游協議方案,或逐組編輯路由。",
"Choose a username": "選擇一個用戶名",
"Choose an amount and payment method": "選擇金額和支付方式",
"Choose and order the groups this API key will try.": "選擇此 API 金鑰要依序嘗試的分組並排序。",
@@ -830,6 +841,7 @@
"Clamped to": "限制為",
"Claude": "Claude",
"Claude CLI Header Passthrough": "Claude CLI 請求頭透傳",
+ "Claude only": "僅 Claude",
"Clean": "無衝突",
"Clean history logs": "清理歷史日誌",
"Clean logs": "清理日誌",
@@ -906,6 +918,7 @@
"Codex credential must be a JSON object with access_token and account_id": "Codex 憑證必須是包含 access_token 和 account_id 的 JSON 物件",
"Cohere": "Cohere",
"Collapse": "收起",
+ "Collapse all": "全部收合",
"Collapse All": "全部收起",
"Collect relay latency and success-rate metrics for the model square.": "收集 Relay 延遲和成功率指標,用於模型廣場展示。",
"Color": "顏色",
@@ -991,6 +1004,7 @@
"Configure Waffo payment aggregation platform integration": "設定 Waffo 支付聚合平台整合",
"Configure your account behavior preferences": "設定您的用戶行為偏好",
"Configure your account preferences and integrations": "設定您的用戶偏好和整合",
+ "Configured": "已設定",
"Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.": "儲存為 PayMethods JSON。type 值決定點擊後使用哪個支付流程:stripe 走 Stripe,waffo_pancake 走 Waffo Pancake,其他值作為 Epay 的 type 參數提交。",
"Configured routes and latency checks": "已設定路由和延遲檢測",
"Confirm": "確認",
@@ -2079,6 +2093,7 @@
"Format: APPID|APISecret|APIKey": "格式:APPID|APISecret|APIKey",
"Format: TokenHub API Key, or legacy AppId|SecretId|SecretKey": "格式:TokenHub API Key,或舊版 AppId|SecretId|SecretKey",
"Forward requests directly to upstream providers without any post-processing.": "將請求直接轉發給上游供應商,不進行任何後處理。",
+ "Forwarding Routes": "路由轉發",
"Frames per second": "幀率",
"Free": "可用",
"Free: {{free}} / Total: {{total}}": "可用空間: {{free}} / 總空間: {{total}}",
@@ -2091,6 +2106,7 @@
"Full Base URL (supports": "完整基礎 URL (支援",
"Full Code": "完整代碼",
"Full input length": "完整輸入長度",
+ "Full JSON": "完整 JSON",
"Full layout": "全屏佈局",
"Full width": "全寬",
"Function calling": "函數呼叫",
@@ -2101,6 +2117,7 @@
"Gemini": "Gemini",
"Gemini Generate Content to OpenAI Chat": "Gemini Generate Content 到 OpenAI Chat",
"Gemini Image 4K": "Gemini 圖片 4K",
+ "Gemini only": "僅 Gemini",
"Gemini will continue to auto-detect thinking mode even with the adapter disabled. Enable this only when you need finer control over pricing and budgeting.": "即使停用配接器,Gemini 也會繼續自動偵測思考模式。僅當您需要對定價和預算進行更精細的控制時才啟用此選項。",
"General": "常規",
"General Settings": "通用設定",
@@ -2702,6 +2719,7 @@
"Model fixed pricing": "模型固定定價",
"Model Group": "模型分組",
"Model Limits": "模型限制",
+ "Model List": "模型列表",
"Model Mapping": "模型映射",
"Model Mapping (JSON)": "模型映射 (JSON)",
"Model Mapping must be a JSON object like": "模型映射必須是如下所示的 JSON 物件",
@@ -2836,8 +2854,11 @@
"Name the channel, choose the provider, configure API access, and set credentials.": "命名渠道、選擇供應商、設定 API 存取並設定憑證。",
"Name, provider type, and availability.": "名稱、供應商類型和可用狀態。",
"name@example.com": "name@example.com",
+ "Native Claude Messages plus OpenAI Chat compatibility forwarding.": "Claude Messages 原生轉發,並相容 OpenAI Chat 轉換。",
"Native format": "原生格式",
"Native forwarding": "原生轉發",
+ "Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Gemini 原生轉發,並相容 OpenAI Chat 和 Responses 轉換。",
+ "Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "OpenAI 原生轉發,並提供可選的 Claude 和 Gemini 相容轉換。",
"Need a redemption code?": "需要兌換碼?",
"Needs API key": "需要 API 金鑰",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "嵌套 JSON,定義按分組新增(+:)、移除(-:)或追加可用分組的規則。",
@@ -3051,6 +3072,7 @@
"Not available": "不可用",
"Not backed up": "未備份",
"Not bound": "未連結",
+ "Not configured": "未設定",
"Not Equals": "不等於",
"Not in pricing table": "不在定價分組表中",
"Not included": "未加入",
@@ -3149,6 +3171,7 @@
"Open in new tab": "在新標籤頁中打開",
"Open in New Tab": "在新標籤頁中打開",
"Open menu": "打開選單",
+ "Open Query Balance to view the upstream JSON response": "請開啟「查詢餘額」檢視上游 JSON 回應",
"Open release": "打開版本",
"Open source": "開源",
"Open Source": "開源項目",
@@ -3161,10 +3184,12 @@
"OpenAI Chat to Gemini Generate Content": "OpenAI Chat 到 Gemini Generate Content",
"OpenAI Chat to OpenAI Responses": "OpenAI Chat 到 OpenAI Responses",
"OpenAI Compatible": "兼容 OpenAI",
+ "OpenAI Compatible Upstream": "OpenAI 相容上游",
"OpenAI Models route does not support client model rules": "OpenAI 模型路由不支援用戶端模型規則",
"OpenAI Models route is required to enable upstream model checks": "啟用上游模型檢查必須設定 OpenAI 模型路由",
"OpenAI Models route must use native forwarding": "OpenAI 模型路由必須使用原生轉發",
"OpenAI Models upstream path must not contain {model}": "OpenAI 模型上游路徑不得包含 {model}",
+ "OpenAI only": "僅 OpenAI",
"OpenAI Organization": "OpenAI 組織",
"OpenAI Organization ID (optional)": "OpenAI 組織 ID(可選)",
"OpenAI Responses to Gemini Generate Content": "OpenAI Responses 轉 Gemini Generate Content",
@@ -3798,6 +3823,7 @@
"Replace": "替換",
"Replace all existing keys": "替換所有現有金鑰",
"Replace channel models": "覆蓋渠道模型",
+ "Replace forwarding routes?": "取代轉發路由?",
"Replace mode: Will completely replace all existing keys": "替換模式:將完全替換所有現有鍵",
"Replace With": "替換為",
"replaced": "已替換",
@@ -3941,6 +3967,7 @@
"Route models must be unique for the same incoming path": "同一入口路徑下的路由模型必須唯一",
"Route, auth, and balance check in one place": "路由、認證和餘額檢查集中展示",
"Routes": "路由",
+ "Routes in this plan": "方案中的路由",
"Routes with the same incoming path are matched by model. Leave the model scope empty only for the final fallback route.": "同一入口路徑的路由按模型匹配。僅最後一個兜底路由可留空模型範圍。",
"Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.": "同一入口路徑按客戶端 model 規則分流;未命中的請求走最後的兜底。",
"Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.": "同一入口路徑按客戶端 model 精確分流;未命中的請求走最後的兜底。",
@@ -4073,6 +4100,7 @@
"Seed": "隨機種子",
"Select": "選擇",
"Select a color": "選擇顏色",
+ "Select a complete plan, then keep only the routes you need.": "選擇完整方案,再保留需要的路由。",
"Select a group": "選擇一個分組",
"Select a group type": "選擇分組類型",
"Select a model to edit pricing": "選擇一個模型編輯定價",
@@ -4093,6 +4121,7 @@
"Select announcement type": "選擇公告類型",
"Select at least one Auto group or restore global Auto.": "請至少選擇一個 Auto 分組,或恢復全域 Auto。",
"Select at least one field to overwrite.": "請選擇至少一個要覆蓋的欄位。",
+ "Select at least one route": "請至少選擇一條路由",
"Select at least one target model": "請至少選擇一個目標模型",
"Select at most {{max}} Auto groups": "最多選擇 {{max}} 個 Auto 分組",
"Select body font": "選擇正文字體",
@@ -4557,6 +4586,8 @@
"The unique identifier for this model": "此模型的唯一標識符",
"The unique name for this vendor": "此供應商的唯一名稱",
"The upstream channel that served the requests": "處理請求的上游渠道",
+ "The upstream natively supports all three protocols; every selected route is forwarded without conversion.": "上游原生支援三種協議,所選路由均不經轉換直接轉發。",
+ "The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.": "上游回應是有效 JSON,但不符合 OpenAI credit_summary 格式,渠道餘額未更新。",
"The URL for this chat client.": "此聊天用戶端的 URL。",
"The user group applied to the requests": "請求所套用的用戶分組",
"The user who made the requests": "發起請求的用戶",
@@ -4606,6 +4637,8 @@
"This project must be used in compliance with the": "此項目的使用必須遵守",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "此操作將從該渠道移除 {{count}} 個測試失敗的模型,且無法撤銷。",
"This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "此路由用於探索上游 OpenAI 模型,無法拆分或使用用戶端模型規則配對。",
+ "This route is used only by channel management to discover upstream models.": "此路由僅供渠道管理取得上游模型。",
+ "This route is used only by channel management to query the upstream balance.": "此路由僅供渠道管理查詢上游餘額。",
"This session will lose access immediately and must sign in again.": "此工作階段將立即失去存取權限,且必須重新登入。",
"This site currently has {{count}} models enabled": "本站目前已啟用模型,總計 {{count}} 個",
"This Telegram account is already bound.": "此 Telegram 帳號已綁定。",
@@ -4632,6 +4665,8 @@
"This will permanently remove all log entries created before {{date}}.": "這將永久刪除 {{date}} 之前建立的所有日誌條目。",
"This will permanently remove log entries before the selected timestamp.": "這將永久刪除所選時間戳之前的日誌條目。",
"This will rebuild the channel routing index from every channel configuration, including supported models, groups, priorities, and weights. Routing may be briefly incomplete while the rebuild is running. Continue?": "這會根據所有渠道設定重建渠道路由索引,包括支援模型、分組、優先級和權重。重建期間路由可能短暫不完整。是否繼續?",
+ "This will remove {{removed}} forwarding routes and create {{created}} selected routes. Model list and balance routes will be preserved.": "將刪除 {{removed}} 條轉發路由並建立 {{created}} 條所選路由。模型列表和餘額路由會保留。",
+ "This will remove {{removed}} forwarding routes and replace them with the {{template}} template ({{created}} routes). Model list and balance routes will be preserved.": "這將移除 {{removed}} 條轉送路由,並取代為 {{template}} 範本({{created}} 條路由)。模型清單與餘額路由將保留。",
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "這會將標籤「{{tag}}」下所有 {{count}} 個渠道的優先級更新為 {{value}}。繼續嗎?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "這會將標籤「{{tag}}」下所有 {{count}} 個渠道的權重更新為 {{value}}。繼續嗎?",
"This year": "本年",
@@ -4912,6 +4947,7 @@
"Upscale": "放大",
"Upstream": "上游",
"Upstream did not return reset credit details.": "上游未返回重置次數詳情。",
+ "Upstream JSON response": "上游 JSON 回應",
"Upstream Model Detection Settings": "偵測上游模型設定",
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "上游模型檢測任務已開始。可在「系統資訊」中查看進度,完成後重新整理以查看待處理的更新。",
"Upstream Model Update Check": "上游模型更新檢查",
@@ -4922,6 +4958,7 @@
"Upstream path must be a full URL or a path starting with /": "上游路徑必須是完整 URL,或以 / 開頭的路徑",
"Upstream price sync": "上游價格同步",
"Upstream prices fetched successfully": "已成功獲取上游價格",
+ "Upstream protocol plan": "上游協議方案",
"Upstream ratios fetched successfully": "上游比率獲取成功",
"Upstream Request ID": "上游請求 ID",
"Upstream Response": "上游返回",
@@ -4963,6 +5000,7 @@
"Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.": "填寫以 / 開頭的路徑時會自動拼接渠道 Base URL;填寫完整 URL 時,此路由會直接使用該 URL。",
"Use authenticator code": "使用驗證器代碼",
"Use backup code": "使用備用代碼",
+ "Use Bearer for all protocols": "所有協議統一使用 Bearer",
"Use disk cache when request body exceeds this size": "請求體超過此大小時使用磁碟緩存",
"Use exact client model names, separated by commas. Prefixes and wildcards are not supported.": "填寫客戶端請求裡的精確 model 名,多個用英文逗號分隔。不支援前綴或萬用字元。",
"Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "可以填寫 gpt-4o 這類精確模型名,也可以填寫 re:^gemini- 這類以 re: 開頭的正則規則。",
diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json
index 848b5b9db446..29223be0f3bb 100644
--- a/web/src/i18n/locales/zh.json
+++ b/web/src/i18n/locales/zh.json
@@ -65,6 +65,9 @@
"{{modality}} supported": "支持 {{modality}}",
"{{n}} model(s) selected": "已选 {{n}} 个模型",
"{{processed}} of {{total}} log entries processed.": "已处理 {{processed}} / {{total}} 条日志。",
+ "{{protocol}} auth name": "{{protocol}} 认证名称",
+ "{{protocol}} auth value": "{{protocol}} 认证值",
+ "{{protocol}} authentication": "{{protocol}} 认证",
"{{success}} succeeded, {{failed}} failed": "{{success}} 个成功,{{failed}} 个失败",
"{{target}} test failed": "{{target}} 测试失败",
"{{target}} test succeeded": "{{target}} 测试成功",
@@ -188,6 +191,7 @@
"Add Group": "添加分组",
"Add group rate limit": "添加组速率限制",
"Add group rules": "添加分组规则",
+ "Add management route": "添加管理路由",
"Add Mapping": "添加映射",
"Add method": "添加方法",
"Add missing models": "添加缺失模型",
@@ -207,6 +211,7 @@
"Add Quota": "添加配额",
"Add ratio override": "添加倍率覆盖",
"Add route": "添加路由",
+ "Add routes individually or replace them from a template.": "单独添加路由,或使用模板替换现有路由。",
"Add Row": "添加行",
"Add Rule": "添加规则",
"Add rule group": "新增规则组",
@@ -215,6 +220,7 @@
"Add split": "添加分流",
"Add subscription": "新增订阅",
"Add tags...": "添加标签...",
+ "Add template": "添加模板",
"Add tier": "新增档位",
"Add time condition": "新增时间条件",
"Add time rule group": "新增时间规则组",
@@ -298,6 +304,7 @@
"All nodes": "全部节点",
"All playground messages saved in this browser will be removed. This cannot be undone.": "保存在此浏览器中的所有游乐场消息都将被移除。此操作无法撤销。",
"All requests must include": "所有请求必须携带",
+ "All routes": "全部路由",
"All Status": "所有状态",
"All Sync Status": "所有同步状态",
"All systems operational": "所有系统正常运行",
@@ -427,6 +434,7 @@
"Apply Filters": "应用筛选器",
"Apply IP Filter to Resolved Domains": "对已解析的域应用 IP 筛选器",
"Apply Overwrite": "应用覆盖",
+ "Apply plan": "应用方案",
"Apply reset": "执行重置",
"Apply Sync": "应用同步",
"Applying...": "正在应用...",
@@ -571,6 +579,8 @@
"Balance depleted": "余额已耗尽",
"Balance is shown in quota units": "余额以额度单位显示",
"Balance queried successfully": "余额查询成功",
+ "Balance Query": "余额查询",
+ "Balance response not recognized": "无法识别余额响应",
"Balance updated successfully": "余额更新成功",
"Balance updated: {{balance}}": "余额已更新:{{balance}}",
"Bar Chart": "柱状图",
@@ -808,6 +818,7 @@
"checkout.session.completed": "checkout.session.completed",
"checkout.session.expired": "checkout.session.expired",
"Chinese": "中文",
+ "Choose a complete upstream protocol plan or edit individual route groups.": "选择完整的上游协议方案,或逐组编辑路由。",
"Choose a username": "选择一个用户名",
"Choose an amount and payment method": "选择金额和支付方式",
"Choose and order the groups this API key will try.": "选择并排列此 API 密钥将依次尝试的分组。",
@@ -830,6 +841,7 @@
"Clamped to": "钳制为",
"Claude": "Claude",
"Claude CLI Header Passthrough": "Claude CLI 请求头透传",
+ "Claude only": "仅 Claude",
"Clean": "无冲突",
"Clean history logs": "清理历史日志",
"Clean logs": "清理日志",
@@ -906,6 +918,7 @@
"Codex credential must be a JSON object with access_token and account_id": "Codex 凭据必须是包含 access_token 和 account_id 的 JSON 对象",
"Cohere": "Cohere",
"Collapse": "收起",
+ "Collapse all": "全部折叠",
"Collapse All": "全部收起",
"Collect relay latency and success-rate metrics for the model square.": "收集 Relay 延迟和成功率指标,用于模型广场展示。",
"Color": "颜色",
@@ -991,6 +1004,7 @@
"Configure Waffo payment aggregation platform integration": "配置 Waffo 支付聚合平台集成",
"Configure your account behavior preferences": "配置您的账户行为偏好",
"Configure your account preferences and integrations": "配置您的账户偏好和集成",
+ "Configured": "已配置",
"Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.": "保存为 PayMethods JSON。type 值决定点击后使用哪个支付流程:stripe 走 Stripe,waffo_pancake 走 Waffo Pancake,其他值作为 Epay 的 type 参数提交。",
"Configured routes and latency checks": "已配置路由和延迟检测",
"Confirm": "确认",
@@ -2079,6 +2093,7 @@
"Format: APPID|APISecret|APIKey": "格式:APPID|APISecret|APIKey",
"Format: TokenHub API Key, or legacy AppId|SecretId|SecretKey": "格式:TokenHub API Key,或旧版 AppId|SecretId|SecretKey",
"Forward requests directly to upstream providers without any post-processing.": "将请求直接转发给上游提供商,不进行任何后处理。",
+ "Forwarding Routes": "路由转发",
"Frames per second": "帧率",
"Free": "可用",
"Free: {{free}} / Total: {{total}}": "可用空间: {{free}} / 总空间: {{total}}",
@@ -2091,6 +2106,7 @@
"Full Base URL (supports": "完整基础 URL (支持",
"Full Code": "完整代码",
"Full input length": "完整输入长度",
+ "Full JSON": "完整 JSON",
"Full layout": "全屏布局",
"Full width": "全宽",
"Function calling": "函数调用",
@@ -2101,6 +2117,7 @@
"Gemini": "Gemini",
"Gemini Generate Content to OpenAI Chat": "Gemini Generate Content 到 OpenAI Chat",
"Gemini Image 4K": "Gemini 图片 4K",
+ "Gemini only": "仅 Gemini",
"Gemini will continue to auto-detect thinking mode even with the adapter disabled. Enable this only when you need finer control over pricing and budgeting.": "即使禁用适配器,Gemini 也会继续自动检测思维模式。仅当您需要对定价和预算进行更精细的控制时才启用此选项。",
"General": "常规",
"General Settings": "通用设置",
@@ -2702,6 +2719,7 @@
"Model fixed pricing": "模型固定定价",
"Model Group": "模型分组",
"Model Limits": "模型限制",
+ "Model List": "模型列表",
"Model Mapping": "模型映射",
"Model Mapping (JSON)": "模型映射 (JSON)",
"Model Mapping must be a JSON object like": "模型映射必须是如下所示的 JSON 对象",
@@ -2836,8 +2854,11 @@
"Name the channel, choose the provider, configure API access, and set credentials.": "命名渠道、选择供应商、配置 API 访问并设置凭据。",
"Name, provider type, and availability.": "名称、供应商类型和可用状态。",
"name@example.com": "name@example.com",
+ "Native Claude Messages plus OpenAI Chat compatibility forwarding.": "Claude Messages 原生转发,并兼容 OpenAI Chat 转换。",
"Native format": "原生格式",
"Native forwarding": "原生转发",
+ "Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Gemini 原生转发,并兼容 OpenAI Chat 和 Responses 转换。",
+ "Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "OpenAI 原生转发,并提供可选的 Claude 和 Gemini 兼容转换。",
"Need a redemption code?": "需要兑换码?",
"Needs API key": "需要 API 密钥",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "嵌套 JSON,定义按分组添加(+:)、移除(-:)或追加可用分组的规则。",
@@ -3051,6 +3072,7 @@
"Not available": "不可用",
"Not backed up": "未备份",
"Not bound": "未绑定",
+ "Not configured": "未配置",
"Not Equals": "不等于",
"Not in pricing table": "不在定价分组表中",
"Not included": "未加入",
@@ -3149,6 +3171,7 @@
"Open in new tab": "在新标签页中打开",
"Open in New Tab": "在新标签页中打开",
"Open menu": "打开菜单",
+ "Open Query Balance to view the upstream JSON response": "请打开“查询余额”查看上游 JSON 响应",
"Open release": "打开版本",
"Open source": "开源",
"Open Source": "开源项目",
@@ -3161,10 +3184,12 @@
"OpenAI Chat to Gemini Generate Content": "OpenAI Chat 到 Gemini Generate Content",
"OpenAI Chat to OpenAI Responses": "OpenAI Chat 到 OpenAI Responses",
"OpenAI Compatible": "兼容 OpenAI",
+ "OpenAI Compatible Upstream": "OpenAI 兼容上游",
"OpenAI Models route does not support client model rules": "OpenAI 模型路由不支持客户端模型规则",
"OpenAI Models route is required to enable upstream model checks": "启用上游模型检查必须配置 OpenAI 模型路由",
"OpenAI Models route must use native forwarding": "OpenAI 模型路由必须使用原生转发",
"OpenAI Models upstream path must not contain {model}": "OpenAI 模型上游路径不能包含 {model}",
+ "OpenAI only": "仅 OpenAI",
"OpenAI Organization": "OpenAI 组织",
"OpenAI Organization ID (optional)": "OpenAI 组织 ID(可选)",
"OpenAI Responses to Gemini Generate Content": "OpenAI Responses 转 Gemini Generate Content",
@@ -3798,6 +3823,7 @@
"Replace": "替换",
"Replace all existing keys": "替换所有现有密钥",
"Replace channel models": "覆盖渠道模型",
+ "Replace forwarding routes?": "替换转发路由?",
"Replace mode: Will completely replace all existing keys": "替换模式:将完全替换所有现有键",
"Replace With": "替换为",
"replaced": "已替换",
@@ -3941,6 +3967,7 @@
"Route models must be unique for the same incoming path": "同一入口路径下的路由模型必须唯一",
"Route, auth, and balance check in one place": "路由、认证和余额检查集中展示",
"Routes": "路由",
+ "Routes in this plan": "方案中的路由",
"Routes with the same incoming path are matched by model. Leave the model scope empty only for the final fallback route.": "同一入口路径的路由按模型匹配。仅最后一个兜底路由可留空模型范围。",
"Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.": "同一入口路径按客户端 model 规则分流;未命中的请求走最后的兜底。",
"Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.": "同一入口路径按客户端 model 精确分流;未命中的请求走最后的兜底。",
@@ -4073,6 +4100,7 @@
"Seed": "随机种子",
"Select": "选择",
"Select a color": "选择颜色",
+ "Select a complete plan, then keep only the routes you need.": "选择完整方案,再保留需要的路由。",
"Select a group": "选择一个分组",
"Select a group type": "选择分组类型",
"Select a model to edit pricing": "选择一个模型编辑定价",
@@ -4093,6 +4121,7 @@
"Select announcement type": "选择公告类型",
"Select at least one Auto group or restore global Auto.": "请至少选择一个 Auto 分组,或恢复全局 Auto。",
"Select at least one field to overwrite.": "请选择至少一个要覆盖的字段。",
+ "Select at least one route": "请至少选择一条路由",
"Select at least one target model": "请至少选择一个目标模型",
"Select at most {{max}} Auto groups": "最多选择 {{max}} 个 Auto 分组",
"Select body font": "选择正文字体",
@@ -4557,6 +4586,8 @@
"The unique identifier for this model": "此模型的唯一标识符",
"The unique name for this vendor": "此供应商的唯一名称",
"The upstream channel that served the requests": "处理请求的上游渠道",
+ "The upstream natively supports all three protocols; every selected route is forwarded without conversion.": "上游原生支持三种协议,所选路由均不经转换直接转发。",
+ "The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.": "上游响应是有效 JSON,但不符合 OpenAI credit_summary 格式,渠道余额未更新。",
"The URL for this chat client.": "此聊天客户端的 URL。",
"The user group applied to the requests": "请求所应用的用户分组",
"The user who made the requests": "发起请求的用户",
@@ -4606,6 +4637,8 @@
"This project must be used in compliance with the": "此项目的使用必须遵守",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "此操作将从该渠道移除 {{count}} 个测试失败的模型,且无法撤销。",
"This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "此路由用于发现上游 OpenAI 模型,不能拆分或使用客户端模型规则匹配。",
+ "This route is used only by channel management to discover upstream models.": "此路由仅供渠道管理获取上游模型。",
+ "This route is used only by channel management to query the upstream balance.": "此路由仅供渠道管理查询上游余额。",
"This session will lose access immediately and must sign in again.": "此会话将立即失去访问权限,并且必须重新登录。",
"This site currently has {{count}} models enabled": "本站当前已启用模型,总计 {{count}} 个",
"This Telegram account is already bound.": "此 Telegram 账户已被绑定。",
@@ -4632,6 +4665,8 @@
"This will permanently remove all log entries created before {{date}}.": "这将永久删除 {{date}} 之前创建的所有日志条目。",
"This will permanently remove log entries before the selected timestamp.": "这将永久删除所选时间戳之前的日志条目。",
"This will rebuild the channel routing index from every channel configuration, including supported models, groups, priorities, and weights. Routing may be briefly incomplete while the rebuild is running. Continue?": "这会根据所有渠道配置重建渠道路由索引,包括支持模型、分组、优先级和权重。重建期间路由可能短暂不完整。是否继续?",
+ "This will remove {{removed}} forwarding routes and create {{created}} selected routes. Model list and balance routes will be preserved.": "将删除 {{removed}} 条转发路由并创建 {{created}} 条所选路由。模型列表和余额路由会保留。",
+ "This will remove {{removed}} forwarding routes and replace them with the {{template}} template ({{created}} routes). Model list and balance routes will be preserved.": "这将移除 {{removed}} 条转发路由,并替换为 {{template}} 模板({{created}} 条路由)。模型列表和余额路由将保留。",
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "这会将标签 \"{{tag}}\" 下所有 {{count}} 个渠道的优先级更新为 {{value}}。继续吗?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "这会将标签 \"{{tag}}\" 下所有 {{count}} 个渠道的权重更新为 {{value}}。继续吗?",
"This year": "本年",
@@ -4912,6 +4947,7 @@
"Upscale": "放大",
"Upstream": "上游",
"Upstream did not return reset credit details.": "上游未返回重置次数详情。",
+ "Upstream JSON response": "上游 JSON 响应",
"Upstream Model Detection Settings": "检测上游模型设置",
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "上游模型检测任务已开始。可在「系统信息」中查看进度,完成后刷新以查看待处理的更新。",
"Upstream Model Update Check": "上游模型更新检查",
@@ -4922,6 +4958,7 @@
"Upstream path must be a full URL or a path starting with /": "上游路径必须是完整 URL,或以 / 开头的路径",
"Upstream price sync": "上游价格同步",
"Upstream prices fetched successfully": "已成功获取上游价格",
+ "Upstream protocol plan": "上游协议方案",
"Upstream ratios fetched successfully": "上游比率获取成功",
"Upstream Request ID": "上游请求 ID",
"Upstream Response": "上游返回",
@@ -4963,6 +5000,7 @@
"Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.": "填写以 / 开头的路径时会自动拼接渠道 Base URL;填写完整 URL 时,此路由会直接使用该 URL。",
"Use authenticator code": "使用验证器代码",
"Use backup code": "使用备用代码",
+ "Use Bearer for all protocols": "所有协议统一使用 Bearer",
"Use disk cache when request body exceeds this size": "请求体超过此大小时使用磁盘缓存",
"Use exact client model names, separated by commas. Prefixes and wildcards are not supported.": "填写客户端请求里的精确 model 名,多个用英文逗号分隔。不支持前缀或通配符。",
"Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "可以填写 gpt-4o 这类精确模型名,也可以填写 re:^gemini- 这类以 re: 开头的正则规则。",
From 4add708ebe3b74e02dcf141887da2c81cb9b1526 Mon Sep 17 00:00:00 2001
From: Seefs <40468931+seefs001@users.noreply.github.com>
Date: Tue, 18 Aug 2026 18:03:59 +0800
Subject: [PATCH 48/99] feat: channel test (#6917)
* feat: channel test
* fix: code smell
---
controller/channel-test.go | 211 ++++++++++++------
controller/channel_test_internal_test.go | 107 +++++++++
model/option.go | 3 +
setting/operation_setting/monitor_setting.go | 26 +++
.../operation_setting/monitor_setting_test.go | 34 +++
.../drawers/model-mutate-drawer.tsx | 1 +
.../features/system-settings/models/index.tsx | 1 +
.../models/routing-reliability-section.tsx | 147 ++++++++----
.../models/section-registry.tsx | 2 +
web/src/features/system-settings/types.ts | 1 +
web/src/i18n/locales/en.json | 5 +
web/src/i18n/locales/fr.json | 5 +
web/src/i18n/locales/ja.json | 5 +
web/src/i18n/locales/ru.json | 5 +
web/src/i18n/locales/vi.json | 5 +
web/src/i18n/locales/zh-TW.json | 5 +
web/src/i18n/locales/zh.json | 5 +
17 files changed, 452 insertions(+), 116 deletions(-)
diff --git a/controller/channel-test.go b/controller/channel-test.go
index fffc59d24d5a..b294979d5877 100644
--- a/controller/channel-test.go
+++ b/controller/channel-test.go
@@ -12,6 +12,7 @@ import (
"net/http/httptest"
"strconv"
"strings"
+ "sync"
"time"
"github.com/QuantumNous/new-api/common"
@@ -908,92 +909,167 @@ type channelTestSummary struct {
Enabled int `json:"enabled"`
}
-// performChannelTests runs the channel test loop synchronously, honoring ctx
-// cancellation so a system-task runner that loses its lease stops promptly. When
-// report is non-nil it is called after each channel with (processed, total) so
-// the system task can surface progress.
-func performChannelTests(ctx context.Context, channels []*model.Channel, testUserID int, allowDisable bool, report func(processed, total int)) channelTestSummary {
+func testChannelForHealthCheck(ctx context.Context, channel *model.Channel, testUserID int, allowDisable bool, disableThreshold int64) channelTestSummary {
summary := channelTestSummary{}
- var disableThreshold = int64(common.ChannelDisableThreshold * 1000)
- if disableThreshold == 0 {
- disableThreshold = 10000000 // a impossible value
+ isChannelEnabled := channel.Status == common.ChannelStatusEnabled
+ tik := time.Now()
+ result := testChannel(ctx, channel, testUserID, "", "", shouldUseStreamForAutomaticChannelTest(channel))
+ milliseconds := time.Since(tik).Milliseconds()
+ if ctx.Err() != nil {
+ return summary
}
- total := len(channels)
- for index, channel := range channels {
- if ctx != nil && ctx.Err() != nil {
- break
- }
- if report != nil {
- report(index, total) // channels completed before this one
- }
- if channel.Status == common.ChannelStatusManuallyDisabled {
- continue
- }
- isChannelEnabled := channel.Status == common.ChannelStatusEnabled
- tik := time.Now()
- result := testChannel(ctx, channel, testUserID, "", "", shouldUseStreamForAutomaticChannelTest(channel))
- tok := time.Now()
- milliseconds := tok.Sub(tik).Milliseconds()
- if ctx != nil && ctx.Err() != nil {
- break
- }
+ summary.Tested++
- summary.Tested++
+ shouldBanChannel := false
+ newAPIError := result.newAPIError
+ if newAPIError != nil {
+ shouldBanChannel = service.ShouldDisableChannel(result.newAPIError)
+ }
- shouldBanChannel := false
- newAPIError := result.newAPIError
- // request error disables the channel
- if newAPIError != nil {
- shouldBanChannel = service.ShouldDisableChannel(result.newAPIError)
+ if common.AutomaticDisableChannelEnabled && !shouldBanChannel {
+ if milliseconds > disableThreshold {
+ err := fmt.Errorf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0)
+ newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout)
+ shouldBanChannel = true
}
+ }
- // 当错误检查通过,才检查响应时间
- if common.AutomaticDisableChannelEnabled && !shouldBanChannel {
- if milliseconds > disableThreshold {
- err := fmt.Errorf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0)
- newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout)
- shouldBanChannel = true
- }
- }
+ if newAPIError == nil {
+ summary.Succeeded++
+ } else {
+ summary.Failed++
+ }
- if newAPIError == nil {
- summary.Succeeded++
- } else {
- summary.Failed++
- }
+ if allowDisable && isChannelEnabled && shouldBanChannel && channel.GetAutoBan() {
+ processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
+ summary.Disabled++
+ }
- // disable channel
- if allowDisable && isChannelEnabled && shouldBanChannel && channel.GetAutoBan() {
- processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
- summary.Disabled++
- }
+ if result.localErr == nil && !isChannelEnabled && service.ShouldEnableChannel(newAPIError, channel.Status) {
+ service.EnableChannel(channel.Id, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.Name)
+ summary.Enabled++
+ }
- // enable channel
- if result.localErr == nil && !isChannelEnabled && service.ShouldEnableChannel(newAPIError, channel.Status) {
- service.EnableChannel(channel.Id, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.Name)
- summary.Enabled++
- }
+ channel.UpdateResponseTime(milliseconds)
+ return summary
+}
- channel.UpdateResponseTime(milliseconds)
- if common.RequestInterval > 0 {
- if ctx == nil {
- time.Sleep(common.RequestInterval)
- } else {
+// runChannelTestWorkers executes independent channel tests with bounded
+// concurrency. Results and progress are reduced by the caller goroutine, so
+// summary counts and the progress reporter remain serialized.
+func runChannelTestWorkers(
+ ctx context.Context,
+ channels []*model.Channel,
+ concurrency int,
+ run func(context.Context, *model.Channel) channelTestSummary,
+ report func(processed, total int),
+) channelTestSummary {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ total := len(channels)
+ if report != nil {
+ report(0, total)
+ }
+ if total == 0 {
+ return channelTestSummary{}
+ }
+
+ workerCount := min(operation_setting.NormalizeChannelTestConcurrency(concurrency), total)
+ jobs := make(chan *model.Channel)
+ results := make(chan channelTestSummary)
+
+ var workers sync.WaitGroup
+ workers.Add(workerCount)
+ for range workerCount {
+ go func() {
+ defer workers.Done()
+ for {
select {
case <-ctx.Done():
- return summary
- case <-time.After(common.RequestInterval):
+ return
+ case channel, ok := <-jobs:
+ if !ok {
+ return
+ }
+ if ctx.Err() != nil {
+ return
+ }
+
+ result := channelTestSummary{}
+ if channel != nil && channel.Status != common.ChannelStatusManuallyDisabled {
+ result = run(ctx, channel)
+ }
+
+ results <- result
+
+ if common.RequestInterval > 0 {
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(common.RequestInterval):
+ }
+ }
}
}
- }
+ }()
}
- if report != nil && (ctx == nil || ctx.Err() == nil) {
- report(total, total) // mark complete only when the full set was tested
+
+ go func() {
+ defer close(jobs)
+ for _, channel := range channels {
+ select {
+ case <-ctx.Done():
+ return
+ case jobs <- channel:
+ }
+ }
+ }()
+
+ go func() {
+ workers.Wait()
+ close(results)
+ }()
+
+ summary := channelTestSummary{}
+ processed := 0
+ for result := range results {
+ summary.Tested += result.Tested
+ summary.Succeeded += result.Succeeded
+ summary.Failed += result.Failed
+ summary.Disabled += result.Disabled
+ summary.Enabled += result.Enabled
+ processed++
+ if report != nil && ctx.Err() == nil {
+ report(processed, total)
+ }
}
return summary
}
+// performChannelTests runs channel health checks with the configured bounded
+// concurrency and honors cancellation when a system-task runner loses its
+// lease.
+func performChannelTests(ctx context.Context, channels []*model.Channel, testUserID int, allowDisable bool, concurrency int, report func(processed, total int)) channelTestSummary {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ disableThreshold := int64(common.ChannelDisableThreshold * 1000)
+ if disableThreshold == 0 {
+ disableThreshold = 10000000 // an impossible value
+ }
+ return runChannelTestWorkers(
+ ctx,
+ channels,
+ concurrency,
+ func(ctx context.Context, channel *model.Channel) channelTestSummary {
+ return testChannelForHealthCheck(ctx, channel, testUserID, allowDisable, disableThreshold)
+ },
+ report,
+ )
+}
+
// runChannelTestTask runs one synchronous channel test cycle for the system task
// runner (both the scheduled job and the manual "test all channels" trigger go
// through here). It honors ctx cancellation so a runner that loses its lease
@@ -1016,7 +1092,8 @@ func runChannelTestTask(ctx context.Context, mode string, notify bool, report fu
}
selected := selectChannelsForAutomaticTest(channels, mode)
allowDisable := mode != operation_setting.ChannelTestModePassiveRecovery
- summary := performChannelTests(ctx, selected, testUserID, allowDisable, report)
+ concurrency := operation_setting.GetMonitorSetting().ChannelTestConcurrency
+ summary := performChannelTests(ctx, selected, testUserID, allowDisable, concurrency, report)
if notify && (ctx == nil || ctx.Err() == nil) {
service.NotifyRootUser(dto.NotifyTypeChannelTest, "通道测试完成", "所有通道测试已完成")
}
diff --git a/controller/channel_test_internal_test.go b/controller/channel_test_internal_test.go
index 904e08da21de..85da7f7bab5e 100644
--- a/controller/channel_test_internal_test.go
+++ b/controller/channel_test_internal_test.go
@@ -2,9 +2,11 @@ package controller
import (
"bytes"
+ "context"
"fmt"
"net/http"
"net/http/httptest"
+ "sync/atomic"
"testing"
"github.com/QuantumNous/new-api/common"
@@ -339,6 +341,111 @@ func TestSelectChannelsForAutomaticTestAutoBanOnlyUsesEligibleChannels(t *testin
require.Equal(t, 3, selected[1].Id)
}
+func TestRunChannelTestWorkersHonorsConfiguredConcurrency(t *testing.T) {
+ originalInterval := common.RequestInterval
+ common.RequestInterval = 0
+ t.Cleanup(func() { common.RequestInterval = originalInterval })
+
+ channels := []*model.Channel{
+ {Id: 1, Status: common.ChannelStatusEnabled},
+ {Id: 2, Status: common.ChannelStatusEnabled},
+ {Id: 3, Status: common.ChannelStatusEnabled},
+ {Id: 4, Status: common.ChannelStatusEnabled},
+ }
+ started := make(chan struct{}, len(channels))
+ release := make(chan struct{})
+ var active atomic.Int32
+ var maxActive atomic.Int32
+ progress := make([]int, 0, len(channels)+1)
+ summaryResult := make(chan channelTestSummary, 1)
+
+ go func() {
+ summaryResult <- runChannelTestWorkers(
+ context.Background(),
+ channels,
+ 2,
+ func(_ context.Context, _ *model.Channel) channelTestSummary {
+ current := active.Add(1)
+ defer active.Add(-1)
+ for {
+ observed := maxActive.Load()
+ if current <= observed || maxActive.CompareAndSwap(observed, current) {
+ break
+ }
+ }
+ started <- struct{}{}
+ <-release
+ return channelTestSummary{Tested: 1, Succeeded: 1}
+ },
+ func(processed, _ int) {
+ progress = append(progress, processed)
+ },
+ )
+ }()
+
+ <-started
+ <-started
+ select {
+ case <-started:
+ t.Fatal("started more channel tests than the configured concurrency")
+ default:
+ }
+ close(release)
+
+ summary := <-summaryResult
+
+ assert.Equal(t, int32(2), maxActive.Load())
+ assert.Equal(t, channelTestSummary{Tested: 4, Succeeded: 4}, summary)
+ assert.Equal(t, []int{0, 1, 2, 3, 4}, progress)
+}
+
+func TestRunChannelTestWorkersStopsAfterCancellation(t *testing.T) {
+ originalInterval := common.RequestInterval
+ common.RequestInterval = 0
+ t.Cleanup(func() { common.RequestInterval = originalInterval })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ channels := []*model.Channel{
+ {Id: 1, Status: common.ChannelStatusEnabled},
+ {Id: 2, Status: common.ChannelStatusEnabled},
+ {Id: 3, Status: common.ChannelStatusEnabled},
+ {Id: 4, Status: common.ChannelStatusEnabled},
+ }
+ started := make(chan struct{}, len(channels))
+ progress := make([]int, 0, 1)
+ summaryResult := make(chan channelTestSummary, 1)
+
+ go func() {
+ summaryResult <- runChannelTestWorkers(
+ ctx,
+ channels,
+ 2,
+ func(ctx context.Context, _ *model.Channel) channelTestSummary {
+ started <- struct{}{}
+ <-ctx.Done()
+ return channelTestSummary{Tested: 1, Succeeded: 1}
+ },
+ func(processed, _ int) {
+ progress = append(progress, processed)
+ },
+ )
+ }()
+
+ <-started
+ <-started
+ cancel()
+
+ summary := <-summaryResult
+
+ select {
+ case <-started:
+ t.Fatal("started another channel test after cancellation")
+ default:
+ }
+ assert.Equal(t, channelTestSummary{Tested: 2, Succeeded: 2}, summary)
+ assert.Equal(t, []int{0}, progress)
+}
+
func TestTestAllChannelsRejectsExistingActiveTask(t *testing.T) {
db := setupModelListControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.SystemTask{}, &model.SystemTaskLock{}))
diff --git a/model/option.go b/model/option.go
index e7fda5231be7..d78706537a80 100644
--- a/model/option.go
+++ b/model/option.go
@@ -209,6 +209,9 @@ func validateOptionValue(key string, value string) error {
if key == operation_setting.ToolPriceOptionKey {
return operation_setting.ValidateToolPricesJSON(value)
}
+ if key == operation_setting.ChannelTestConcurrencyOptionKey {
+ return operation_setting.ValidateChannelTestConcurrency(value)
+ }
if key == "MaxTokenAutoGroups" {
return setting.ValidateMaxTokenAutoGroups(value)
}
diff --git a/setting/operation_setting/monitor_setting.go b/setting/operation_setting/monitor_setting.go
index a88087f21569..858cc0c7a73b 100644
--- a/setting/operation_setting/monitor_setting.go
+++ b/setting/operation_setting/monitor_setting.go
@@ -1,6 +1,7 @@
package operation_setting
import (
+ "fmt"
"os"
"strconv"
@@ -11,12 +12,17 @@ type MonitorSetting struct {
AutoTestChannelEnabled bool `json:"auto_test_channel_enabled"`
AutoTestChannelMinutes float64 `json:"auto_test_channel_minutes"`
ChannelTestMode string `json:"channel_test_mode"`
+ ChannelTestConcurrency int `json:"channel_test_concurrency"`
}
const (
ChannelTestModeScheduledAll = "scheduled_all"
ChannelTestModeAutoBanOnly = "auto_ban_only"
ChannelTestModePassiveRecovery = "passive_recovery"
+
+ ChannelTestConcurrencyOptionKey = "monitor_setting.channel_test_concurrency"
+ DefaultChannelTestConcurrency = 1
+ MaxChannelTestConcurrency = 32
)
// 默认配置
@@ -24,6 +30,7 @@ var monitorSetting = MonitorSetting{
AutoTestChannelEnabled: false,
AutoTestChannelMinutes: 10,
ChannelTestMode: ChannelTestModeScheduledAll,
+ ChannelTestConcurrency: DefaultChannelTestConcurrency,
}
func init() {
@@ -51,5 +58,24 @@ func GetMonitorSetting() *MonitorSetting {
default:
monitorSetting.ChannelTestMode = ChannelTestModeScheduledAll
}
+ monitorSetting.ChannelTestConcurrency = NormalizeChannelTestConcurrency(monitorSetting.ChannelTestConcurrency)
return &monitorSetting
}
+
+func NormalizeChannelTestConcurrency(concurrency int) int {
+ if concurrency < 1 {
+ return DefaultChannelTestConcurrency
+ }
+ if concurrency > MaxChannelTestConcurrency {
+ return MaxChannelTestConcurrency
+ }
+ return concurrency
+}
+
+func ValidateChannelTestConcurrency(value string) error {
+ concurrency, err := strconv.Atoi(value)
+ if err != nil || concurrency < 1 || concurrency > MaxChannelTestConcurrency {
+ return fmt.Errorf("channel test concurrency must be between 1 and %d", MaxChannelTestConcurrency)
+ }
+ return nil
+}
diff --git a/setting/operation_setting/monitor_setting_test.go b/setting/operation_setting/monitor_setting_test.go
index c31023e84b8b..78e6f7c0525f 100644
--- a/setting/operation_setting/monitor_setting_test.go
+++ b/setting/operation_setting/monitor_setting_test.go
@@ -55,3 +55,37 @@ func TestGetMonitorSettingPreservesAutoBanOnlyMode(t *testing.T) {
require.NotNil(t, setting)
assert.Equal(t, ChannelTestModeAutoBanOnly, setting.ChannelTestMode)
}
+
+func TestGetMonitorSettingNormalizesChannelTestConcurrency(t *testing.T) {
+ orig := monitorSetting
+ t.Cleanup(func() { monitorSetting = orig })
+
+ tests := []struct {
+ name string
+ concurrency int
+ want int
+ }{
+ {name: "missing uses safe default", concurrency: 0, want: DefaultChannelTestConcurrency},
+ {name: "configured value is preserved", concurrency: 8, want: 8},
+ {name: "oversized value is capped", concurrency: MaxChannelTestConcurrency + 1, want: MaxChannelTestConcurrency},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ monitorSetting = MonitorSetting{ChannelTestConcurrency: test.concurrency}
+
+ setting := GetMonitorSetting()
+
+ require.NotNil(t, setting)
+ assert.Equal(t, test.want, setting.ChannelTestConcurrency)
+ })
+ }
+}
+
+func TestValidateChannelTestConcurrency(t *testing.T) {
+ require.NoError(t, ValidateChannelTestConcurrency("1"))
+ require.NoError(t, ValidateChannelTestConcurrency("32"))
+ assert.Error(t, ValidateChannelTestConcurrency("0"))
+ assert.Error(t, ValidateChannelTestConcurrency("33"))
+ assert.Error(t, ValidateChannelTestConcurrency("1.5"))
+}
diff --git a/web/src/features/models/components/drawers/model-mutate-drawer.tsx b/web/src/features/models/components/drawers/model-mutate-drawer.tsx
index 532b8931bc4d..d9d7ccad52a0 100644
--- a/web/src/features/models/components/drawers/model-mutate-drawer.tsx
+++ b/web/src/features/models/components/drawers/model-mutate-drawer.tsx
@@ -335,6 +335,7 @@ export function ModelMutateDrawer({
'100-199,300-399,401-407,409-499,500-503,505-523,525-599',
'monitor_setting.auto_test_channel_enabled': false,
'monitor_setting.auto_test_channel_minutes': 10,
+ 'monitor_setting.channel_test_concurrency': 1,
'monitor_setting.channel_test_mode': 'scheduled_all',
'channel_affinity_setting.enabled': false,
'channel_affinity_setting.switch_on_success': true,
diff --git a/web/src/features/system-settings/models/index.tsx b/web/src/features/system-settings/models/index.tsx
index 0448720a2067..0bc0f9923ac2 100644
--- a/web/src/features/system-settings/models/index.tsx
+++ b/web/src/features/system-settings/models/index.tsx
@@ -73,6 +73,7 @@ const defaultModelSettings: ModelSettings = {
'100-199,300-399,401-407,409-499,500-503,505-523,525-599',
'monitor_setting.auto_test_channel_enabled': false,
'monitor_setting.auto_test_channel_minutes': 10,
+ 'monitor_setting.channel_test_concurrency': 1,
'monitor_setting.channel_test_mode': 'scheduled_all',
'channel_affinity_setting.enabled': false,
'channel_affinity_setting.switch_on_success': true,
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 1b8527ec02d9..dec744371549 100644
--- a/web/src/features/system-settings/models/routing-reliability-section.tsx
+++ b/web/src/features/system-settings/models/routing-reliability-section.tsx
@@ -69,55 +69,70 @@ const channelTestModes = [
'passive_recovery',
] as const
type ChannelTestMode = (typeof channelTestModes)[number]
-
-const routingReliabilitySchema = z
- .object({
- RetryTimes: z.coerce.number().min(0).max(10),
- ChannelDisableThreshold: numericString,
- AutomaticDisableChannelEnabled: z.boolean(),
- AutomaticEnableChannelEnabled: z.boolean(),
- AutomaticDisableKeywords: z.string(),
- AutomaticDisableStatusCodes: z.string(),
- AutomaticRetryStatusCodes: z.string(),
- monitor_setting: z.object({
- auto_test_channel_enabled: z.boolean(),
- auto_test_channel_minutes: z.coerce
- .number()
- .int()
- .min(1, 'Interval must be at least 1 minute'),
- channel_test_mode: z.enum(channelTestModes),
- }),
- })
- .superRefine((values, ctx) => {
- const disableParsed = parseHttpStatusCodeRules(
- values.AutomaticDisableStatusCodes
- )
- if (!disableParsed.ok) {
- ctx.addIssue({
- code: 'custom',
- path: ['AutomaticDisableStatusCodes'],
- message: `Invalid status code rules: ${disableParsed.invalidTokens.join(
- ', '
- )}`,
- })
- }
-
- const retryParsed = parseHttpStatusCodeRules(
- values.AutomaticRetryStatusCodes
- )
- if (!retryParsed.ok) {
- ctx.addIssue({
- code: 'custom',
- path: ['AutomaticRetryStatusCodes'],
- message: `Invalid status code rules: ${retryParsed.invalidTokens.join(
- ', '
- )}`,
- })
- }
- })
-
-type RoutingReliabilityFormValues = z.output
-type RoutingReliabilityFormInput = z.input
+const MAX_CHANNEL_TEST_CONCURRENCY = 32
+
+const createRoutingReliabilitySchema = (
+ t: (key: string, options?: Record) => string
+) =>
+ z
+ .object({
+ RetryTimes: z.coerce.number().min(0).max(10),
+ ChannelDisableThreshold: numericString,
+ AutomaticDisableChannelEnabled: z.boolean(),
+ AutomaticEnableChannelEnabled: z.boolean(),
+ AutomaticDisableKeywords: z.string(),
+ AutomaticDisableStatusCodes: z.string(),
+ AutomaticRetryStatusCodes: z.string(),
+ monitor_setting: z.object({
+ auto_test_channel_enabled: z.boolean(),
+ auto_test_channel_minutes: z.coerce
+ .number()
+ .int()
+ .min(1, t('Interval must be at least 1 minute')),
+ channel_test_concurrency: z.coerce
+ .number()
+ .int(t('Enter a positive integer'))
+ .min(1, t('Channel test concurrency must be between 1 and 32'))
+ .max(
+ MAX_CHANNEL_TEST_CONCURRENCY,
+ t('Channel test concurrency must be between 1 and 32')
+ ),
+ channel_test_mode: z.enum(channelTestModes),
+ }),
+ })
+ .superRefine((values, ctx) => {
+ const disableParsed = parseHttpStatusCodeRules(
+ values.AutomaticDisableStatusCodes
+ )
+ if (!disableParsed.ok) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['AutomaticDisableStatusCodes'],
+ message: t('Invalid status code rules: {{tokens}}', {
+ tokens: disableParsed.invalidTokens.join(', '),
+ }),
+ })
+ }
+
+ const retryParsed = parseHttpStatusCodeRules(
+ values.AutomaticRetryStatusCodes
+ )
+ if (!retryParsed.ok) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['AutomaticRetryStatusCodes'],
+ message: t('Invalid status code rules: {{tokens}}', {
+ tokens: retryParsed.invalidTokens.join(', '),
+ }),
+ })
+ }
+ })
+
+type RoutingReliabilitySchema = ReturnType<
+ typeof createRoutingReliabilitySchema
+>
+type RoutingReliabilityFormValues = z.output
+type RoutingReliabilityFormInput = z.input
type RoutingReliabilitySectionProps = {
defaultValues: {
@@ -130,6 +145,7 @@ type RoutingReliabilitySectionProps = {
AutomaticRetryStatusCodes: string
'monitor_setting.auto_test_channel_enabled': boolean
'monitor_setting.auto_test_channel_minutes': number
+ 'monitor_setting.channel_test_concurrency': number
'monitor_setting.channel_test_mode': ChannelTestMode
}
}
@@ -148,6 +164,7 @@ type NormalizedRoutingReliabilityValues = {
AutomaticRetryStatusCodes: string
'monitor_setting.auto_test_channel_enabled': boolean
'monitor_setting.auto_test_channel_minutes': number
+ 'monitor_setting.channel_test_concurrency': number
'monitor_setting.channel_test_mode': ChannelTestMode
}
@@ -175,6 +192,8 @@ const buildFormDefaults = (
defaults['monitor_setting.auto_test_channel_enabled'],
auto_test_channel_minutes:
defaults['monitor_setting.auto_test_channel_minutes'],
+ channel_test_concurrency:
+ defaults['monitor_setting.channel_test_concurrency'],
channel_test_mode: normalizeChannelTestMode(
defaults['monitor_setting.channel_test_mode']
),
@@ -201,6 +220,8 @@ const normalizeDefaults = (
defaults['monitor_setting.auto_test_channel_enabled'],
'monitor_setting.auto_test_channel_minutes':
defaults['monitor_setting.auto_test_channel_minutes'],
+ 'monitor_setting.channel_test_concurrency':
+ defaults['monitor_setting.channel_test_concurrency'],
'monitor_setting.channel_test_mode': normalizeChannelTestMode(
defaults['monitor_setting.channel_test_mode']
),
@@ -226,6 +247,8 @@ const normalizeFormValues = (
values.monitor_setting.auto_test_channel_enabled,
'monitor_setting.auto_test_channel_minutes':
values.monitor_setting.auto_test_channel_minutes,
+ 'monitor_setting.channel_test_concurrency':
+ values.monitor_setting.channel_test_concurrency,
'monitor_setting.channel_test_mode': values.monitor_setting.channel_test_mode,
})
@@ -234,6 +257,7 @@ export function RoutingReliabilitySection({
}: RoutingReliabilitySectionProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
+ const routingReliabilitySchema = createRoutingReliabilitySchema(t)
const baselineRef = useRef(
normalizeDefaults(defaultValues)
)
@@ -484,6 +508,31 @@ export function RoutingReliabilitySection({
)}
/>
+ (
+
+ {t('Channel test concurrency')}
+
+
+
+
+ {t(
+ 'Maximum number of channels tested at the same time (1-32)'
+ )}
+
+
+
+ )}
+ />
+
Date: Tue, 18 Aug 2026 18:20:53 +0800
Subject: [PATCH 49/99] feat(web): fade in streamed response words and harden
playground editor (#6895)
* feat(web): fade in newly streamed response words
Animate only new word-level deltas while markdown is still streaming, and
cache markdown-it instances per parser id so concurrent Response trees do
not rebuild or reparse on every render.
* fix(web): keep CodeMirror editor alive across keystroke re-renders
Deliver onKeyDown through a ref instead of the extensions memo so a new
handler identity no longer tears down the EditorView, which reset the
cursor to the document start and made typing appear right-to-left.
* feat(web): add unsaved changes confirmation dialog in PlaygroundMessageEditor
Implement a confirmation dialog to warn users about unsaved changes when attempting to leave the editor. This includes handling the beforeunload event to prevent accidental navigation away from the editor. Additionally, add tests to verify the dialog's behavior under various scenarios.
* test(web): cover beforeunload guard and fade hydration suppression
Address review feedback: add regression tests for the unsaved-changes
beforeunload guard and the first-render fade suppression of hydrated
content, and annotate getCachedMarkdown's return type.
---
.../__tests__/code-block-editor.test.tsx | 58 +++++
.../__tests__/response-fade-render.test.tsx | 114 +++++++++
.../__tests__/response-fade.test.ts | 190 +++++++++++++++
web/src/components/ai-elements/code-block.tsx | 35 +--
web/src/components/ai-elements/reasoning.tsx | 40 ++--
.../components/ai-elements/response-fade.ts | 222 ++++++++++++++++++
.../ai-elements/response-renderer-inline.tsx | 38 ++-
.../ai-elements/response-renderer.tsx | 77 ++++--
.../components/ai-elements/response-types.ts | 5 +
web/src/components/ai-elements/response.tsx | 77 +++++-
.../playground-message-editor.test.tsx | 125 ++++++++++
.../message/playground-message-editor.tsx | 80 +++++--
web/src/styles/index.css | 22 ++
13 files changed, 993 insertions(+), 90 deletions(-)
create mode 100644 web/src/components/ai-elements/__tests__/code-block-editor.test.tsx
create mode 100644 web/src/components/ai-elements/__tests__/response-fade-render.test.tsx
create mode 100644 web/src/components/ai-elements/__tests__/response-fade.test.ts
create mode 100644 web/src/components/ai-elements/response-fade.ts
create mode 100644 web/src/features/playground/components/message/__tests__/playground-message-editor.test.tsx
diff --git a/web/src/components/ai-elements/__tests__/code-block-editor.test.tsx b/web/src/components/ai-elements/__tests__/code-block-editor.test.tsx
new file mode 100644
index 000000000000..10c7c4936cc8
--- /dev/null
+++ b/web/src/components/ai-elements/__tests__/code-block-editor.test.tsx
@@ -0,0 +1,58 @@
+/*
+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 { cleanup, render } from '@testing-library/react'
+import { afterEach, describe, expect, test } from 'vitest'
+
+import { CodeBlockEditor } from '../code-block'
+
+afterEach(() => {
+ cleanup()
+})
+
+function editorTree(value: string) {
+ // A fresh inline onKeyDown per call mirrors PlaygroundMessageEditor, which
+ // recreates its handler on every keystroke-driven render.
+ return (
+ undefined}
+ onKeyDown={() => undefined}
+ value={value}
+ />
+ )
+}
+
+describe('CodeBlockEditor', () => {
+ test('keeps the same editor instance when value and onKeyDown change on rerender', () => {
+ const { rerender } = render(editorTree('h'))
+
+ const contentBefore = document.querySelector('.cm-content')
+ expect(contentBefore).not.toBeNull()
+
+ rerender(editorTree('hi'))
+
+ const contentAfter = document.querySelector('.cm-content')
+ // If the EditorView were torn down and rebuilt, the content node would be
+ // replaced and the cursor would reset to the document start, making typed
+ // characters pile up at the beginning (text appears right-to-left).
+ expect(contentAfter).toBe(contentBefore)
+ expect(contentAfter?.textContent).toContain('hi')
+ })
+})
diff --git a/web/src/components/ai-elements/__tests__/response-fade-render.test.tsx b/web/src/components/ai-elements/__tests__/response-fade-render.test.tsx
new file mode 100644
index 000000000000..9efe107eaca9
--- /dev/null
+++ b/web/src/components/ai-elements/__tests__/response-fade-render.test.tsx
@@ -0,0 +1,114 @@
+/*
+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 { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, test, vi } from 'vitest'
+
+import { Response } from '../response'
+import {
+ FADE_DURATION_MS,
+ FADE_HYDRATION_THRESHOLD,
+ FADE_STAGGER_MAX_MS,
+} from '../response-fade'
+
+afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+})
+
+describe('Response streaming fade', () => {
+ test('wraps newly streamed words when final is false', () => {
+ const { rerender } = render(Hello )
+
+ expect(document.querySelectorAll('[data-stream-fade]').length).toBeGreaterThan(
+ 0
+ )
+ expect(screen.getByText('Hello')).toBeTruthy()
+
+ rerender(Hello world )
+
+ const fades = [...document.querySelectorAll('[data-stream-fade]')]
+ expect(fades.some((node) => node.textContent === 'world')).toBe(true)
+ })
+
+ test('renders settled content with zero fade wrappers when final is true', () => {
+ render(Hello world )
+
+ expect(document.querySelectorAll('[data-stream-fade]')).toHaveLength(0)
+ expect(screen.getByText(/Hello world/)).toBeTruthy()
+ })
+
+ test('does not fade inline code or fenced code blocks', () => {
+ render(
+
+ {['Use `code` and:', '', '```', 'block', '```'].join('\n')}
+
+ )
+
+ const fades = [...document.querySelectorAll('[data-stream-fade]')]
+ const fadedText = fades.map((node) => node.textContent ?? '').join('')
+ expect(fadedText.includes('block')).toBe(false)
+ expect(
+ fades.every((node) => {
+ const text = node.textContent ?? ''
+ return text.trim() !== 'code' && text.trim() !== 'block'
+ })
+ ).toBe(true)
+ })
+
+ test('does not re-animate words after markdown restructuring around strong', () => {
+ vi.spyOn(performance, 'now').mockReturnValue(1000)
+ const { rerender } = render(**fin )
+ expect(document.querySelectorAll('[data-stream-fade]').length).toBeGreaterThan(
+ 0
+ )
+
+ vi.spyOn(performance, 'now').mockReturnValue(
+ 1000 + FADE_DURATION_MS + FADE_STAGGER_MAX_MS + 1
+ )
+ rerender(**final** )
+
+ const strong = document.querySelector('strong')
+ expect(strong?.textContent).toContain('final')
+
+ const fades = [...document.querySelectorAll('[data-stream-fade]')]
+ expect(
+ fades.every((node) => !(node.textContent ?? '').includes('final'))
+ ).toBe(true)
+ })
+
+ test('suppresses fades on the first streaming render of hydrated content', () => {
+ const hydrated = 'word '.repeat(FADE_HYDRATION_THRESHOLD)
+
+ render({hydrated} )
+
+ expect(document.querySelectorAll('[data-stream-fade]')).toHaveLength(0)
+ })
+
+ test('drops all fade wrappers once the stream settles', () => {
+ const { rerender } = render(
+ Streaming text
+ )
+ expect(document.querySelectorAll('[data-stream-fade]').length).toBeGreaterThan(
+ 0
+ )
+
+ rerender(Streaming text )
+ expect(document.querySelectorAll('[data-stream-fade]')).toHaveLength(0)
+ })
+})
diff --git a/web/src/components/ai-elements/__tests__/response-fade.test.ts b/web/src/components/ai-elements/__tests__/response-fade.test.ts
new file mode 100644
index 000000000000..d569b027dc4a
--- /dev/null
+++ b/web/src/components/ai-elements/__tests__/response-fade.test.ts
@@ -0,0 +1,190 @@
+/*
+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 { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
+
+import {
+ beginRun,
+ classifyValue,
+ createFadeState,
+ endRun,
+ FADE_DURATION_MS,
+ FADE_HYDRATION_THRESHOLD,
+ FADE_STAGGER_MAX_MS,
+ FADE_STAGGER_MS,
+ splitWords,
+ stageRun,
+} from '../response-fade'
+
+describe('splitWords', () => {
+ test('round-trips ASCII words with trailing whitespace', () => {
+ const value = 'Hello world, stream.\n'
+ expect(splitWords(value).join('')).toBe(value)
+ })
+
+ test('keeps leading whitespace as its own part', () => {
+ expect(splitWords(' hi')).toEqual([' ', 'hi'])
+ })
+
+ test('segments CJK without spaces via Intl.Segmenter', () => {
+ const value = '你好世界'
+ const parts = splitWords(value)
+ expect(parts.join('')).toBe(value)
+ expect(parts.length).toBeGreaterThan(1)
+ })
+})
+
+describe('classifyValue', () => {
+ beforeEach(() => {
+ vi.spyOn(performance, 'now').mockReturnValue(1000)
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ test('animates only newly appended words with capped stagger', () => {
+ const state = createFadeState()
+ const first = beginRun(state)
+ const firstSegments = classifyValue(first, 'one two ')
+ endRun(first)
+
+ expect(firstSegments.filter((s) => s.animated)).toHaveLength(2)
+ expect(firstSegments[0]?.delay).toBe(0)
+ expect(firstSegments[1]?.delay).toBe(FADE_STAGGER_MS)
+
+ vi.spyOn(performance, 'now').mockReturnValue(
+ 1000 + FADE_DURATION_MS + FADE_STAGGER_MS + 1
+ )
+ const second = beginRun(state)
+ const secondSegments = classifyValue(second, 'one two three four')
+ endRun(second)
+
+ const animated = secondSegments.filter((s) => s.animated)
+ expect(animated.map((s) => s.value.trim())).toEqual(['three', 'four'])
+ expect(animated[0]?.delay).toBe(0)
+ expect(animated[1]?.delay).toBe(FADE_STAGGER_MS)
+ })
+
+ test('caps stagger delay at FADE_STAGGER_MAX_MS', () => {
+ const state = createFadeState()
+ const run = beginRun(state)
+ const words = Array.from({ length: 20 }, (_, i) => `w${i}`).join(' ')
+ const segments = classifyValue(run, words)
+ endRun(run)
+
+ const delays = segments.filter((s) => s.animated).map((s) => s.delay)
+ expect(Math.max(...delays)).toBe(FADE_STAGGER_MAX_MS)
+ })
+
+ test('replays identical delay while still inside the animation window', () => {
+ const state = createFadeState()
+ const first = beginRun(state)
+ classifyValue(first, 'hello ')
+ endRun(first)
+
+ vi.spyOn(performance, 'now').mockReturnValue(1000 + FADE_DURATION_MS / 2)
+ const second = beginRun(state)
+ const segments = classifyValue(second, 'hello world')
+ endRun(second)
+
+ expect(segments[0]).toMatchObject({
+ animated: true,
+ delay: 0,
+ start: 0,
+ value: 'hello ',
+ })
+ expect(segments[1]).toMatchObject({
+ animated: true,
+ start: 6,
+ value: 'world',
+ })
+ })
+
+ test('keeps the same start offset when the head word grows', () => {
+ const state = createFadeState()
+ const first = beginRun(state)
+ const head = classifyValue(first, 'hel')
+ endRun(first)
+ expect(head[0]?.start).toBe(0)
+
+ vi.spyOn(performance, 'now').mockReturnValue(1050)
+ const second = beginRun(state)
+ const grown = classifyValue(second, 'hello')
+ endRun(second)
+
+ expect(grown[0]?.start).toBe(0)
+ expect(grown[0]?.animated).toBe(true)
+ expect(grown[0]?.value).toBe('hello')
+ })
+
+ test('does not animate whitespace-only parts', () => {
+ const state = createFadeState()
+ const run = beginRun(state)
+ const segments = classifyValue(run, ' \n')
+ endRun(run)
+
+ expect(segments.every((s) => !s.animated)).toBe(true)
+ })
+
+ test('suppresses animation on the hydration baseline', () => {
+ const state = createFadeState()
+ const longText = 'a'.repeat(FADE_HYDRATION_THRESHOLD + 1)
+ const run = beginRun(state, true)
+ const segments = classifyValue(run, longText)
+ endRun(run)
+
+ expect(segments.every((s) => !s.animated)).toBe(true)
+ expect(state.prevCount).toBe(longText.length)
+ })
+
+ test('stops replaying animation after the window expires', () => {
+ const state = createFadeState()
+ const first = beginRun(state)
+ classifyValue(first, 'done ')
+ endRun(first)
+
+ vi.spyOn(performance, 'now').mockReturnValue(
+ 1000 + FADE_DURATION_MS + 1
+ )
+ const second = beginRun(state)
+ const segments = classifyValue(second, 'done next')
+ endRun(second)
+
+ expect(segments[0]).toMatchObject({ animated: false, value: 'done ' })
+ expect(segments[1]).toMatchObject({ animated: true, value: 'next' })
+ expect(state.active.has(0)).toBe(false)
+ })
+
+ test('abandoned staged runs leave committed state untouched', () => {
+ const state = createFadeState()
+ const first = beginRun(state)
+ classifyValue(first, 'keep ')
+ endRun(first)
+ expect(state.prevCount).toBe(5)
+
+ const abandoned = beginRun(state)
+ classifyValue(abandoned, 'keep extra')
+ stageRun(abandoned)
+ // Never commit — simulate React discarding the render
+ state.pending = null
+
+ expect(state.prevCount).toBe(5)
+ expect(state.active.size).toBe(1)
+ })
+})
diff --git a/web/src/components/ai-elements/code-block.tsx b/web/src/components/ai-elements/code-block.tsx
index df70915fcc03..77a73409bb3e 100644
--- a/web/src/components/ai-elements/code-block.tsx
+++ b/web/src/components/ai-elements/code-block.tsx
@@ -265,7 +265,7 @@ function getCodeBlockMaxHeight(
function getCodeMirrorExtensions(options: {
language: BundledLanguage | string
- onKeyDown?: (event: globalThis.KeyboardEvent) => void
+ onKeyDown: (event: globalThis.KeyboardEvent) => void
readOnly: boolean
showLineNumbers: boolean
}): Extension[] {
@@ -276,23 +276,18 @@ function getCodeMirrorExtensions(options: {
EditorState.tabSize.of(2),
EditorState.readOnly.of(options.readOnly),
EditorView.editable.of(!options.readOnly),
+ EditorView.domEventHandlers({
+ keydown(event) {
+ options.onKeyDown(event)
+ return event.defaultPrevented
+ },
+ }),
]
if (options.showLineNumbers) {
extensions.unshift(lineNumbers())
}
- if (options.onKeyDown) {
- extensions.push(
- EditorView.domEventHandlers({
- keydown(event) {
- options.onKeyDown?.(event)
- return event.defaultPrevented
- },
- })
- )
- }
-
return extensions
}
@@ -311,21 +306,27 @@ function CodeMirrorCodeView({
const editorViewRef = useRef(null)
const initialValueRef = useRef(value)
const onChangeRef = useRef(onChange)
+ const onKeyDownRef = useRef(onKeyDown)
const editorMinHeight = `${Math.max(4, rows) * 1.5 + 2}rem`
+ // onKeyDown is delivered through a ref so a new handler identity from the
+ // parent (recreated on every keystroke-driven render) does not invalidate
+ // the extensions and tear down the EditorView, which would reset the cursor
+ // to the document start and make typing appear right-to-left.
const editorExtensions = useMemo(
() =>
getCodeMirrorExtensions({
language,
- onKeyDown,
+ onKeyDown: (event) => onKeyDownRef.current?.(event),
readOnly,
showLineNumbers,
}),
- [language, onKeyDown, readOnly, showLineNumbers]
+ [language, readOnly, showLineNumbers]
)
useEffect(() => {
onChangeRef.current = onChange
- }, [onChange])
+ onKeyDownRef.current = onKeyDown
+ }, [onChange, onKeyDown])
useEffect(() => {
const editorHost = editorHostRef.current
@@ -357,6 +358,10 @@ function CodeMirrorCodeView({
}, [autoFocus, editorExtensions])
useEffect(() => {
+ // Track the latest value so a future editor rebuild (e.g. language change)
+ // starts from the current document instead of the mount-time snapshot.
+ initialValueRef.current = value
+
const editorView = editorViewRef.current
if (!editorView) {
return
diff --git a/web/src/components/ai-elements/reasoning.tsx b/web/src/components/ai-elements/reasoning.tsx
index b6f7ba7c3858..31c415bc7d69 100644
--- a/web/src/components/ai-elements/reasoning.tsx
+++ b/web/src/components/ai-elements/reasoning.tsx
@@ -191,22 +191,30 @@ export type ReasoningContentProps = ComponentProps<
}
export const ReasoningContent = memo(
- ({ className, children, ...props }: ReasoningContentProps) => (
-
-
-
- {children}
-
-
-
- )
+ ({ className, children, ...props }: ReasoningContentProps) => {
+ const { isStreaming } = useReasoning()
+
+ return (
+
+
+
+ {children}
+
+
+
+ )
+ }
)
Reasoning.displayName = 'Reasoning'
diff --git a/web/src/components/ai-elements/response-fade.ts b/web/src/components/ai-elements/response-fade.ts
new file mode 100644
index 000000000000..4fad56381255
--- /dev/null
+++ b/web/src/components/ai-elements/response-fade.ts
@@ -0,0 +1,222 @@
+/*
+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
+*/
+
+/** Must match the animation duration on `[data-stream-fade]` in styles/index.css */
+export const FADE_DURATION_MS = 250
+export const FADE_STAGGER_MS = 25
+export const FADE_STAGGER_MAX_MS = 250
+/**
+ * Text already longer than this when animation starts is hydrated/resumed
+ * content (reconnected stream, conversation switch), not a fresh delta —
+ * that content becomes the baseline instead of re-fading.
+ */
+export const FADE_HYDRATION_THRESHOLD = 120
+
+type FadeEntry = { at: number; delay: number }
+
+type PendingRun = {
+ prevCount: number
+ additions: Map
+ now: number
+}
+
+export type FadeState = {
+ /** Total characters classified during the last committed run */
+ prevCount: number
+ /** Parts still mid-animation, keyed by start offset */
+ active: Map
+ /** True until the first run commits */
+ firstRun: boolean
+ /** Staged result of the latest render; published on commit */
+ pending: PendingRun | null
+}
+
+export type FadeRun = {
+ state: FadeState
+ now: number
+ count: number
+ newIndex: number
+ /** Baseline mode: classify everything as already seen */
+ suppress: boolean
+ additions: Map
+}
+
+export type FadeSegment = {
+ start: number
+ value: string
+ animated: boolean
+ delay: number
+}
+
+const WORD_REGEX = /\S+\s*/g
+const NON_WHITESPACE_REGEX = /\S/
+/**
+ * Scripts without word-delimiting spaces: Thai, Lao, Myanmar, Khmer,
+ * Tibetan, CJK ideographs/kana, Hangul, and CJK compatibility ideographs.
+ */
+const SPACELESS_REGEX =
+ /[\u0E00-\u0EFF\u0F00-\u0FFF\u1000-\u109F\u1780-\u17FF\u2E80-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF]/
+
+let wordSegmenter: Intl.Segmenter | null | undefined
+
+function getWordSegmenter(): Intl.Segmenter | null {
+ if (wordSegmenter === undefined) {
+ wordSegmenter =
+ typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
+ ? new Intl.Segmenter(undefined, { granularity: 'word' })
+ : null
+ }
+ return wordSegmenter
+}
+
+function pushSegmentedParts(parts: string[], token: string): void {
+ const segmenter = getWordSegmenter()
+ if (segmenter == null) {
+ parts.push(token)
+ return
+ }
+ const trailing = /\s+$/.exec(token)
+ const word = trailing == null ? token : token.slice(0, trailing.index)
+ for (const segment of segmenter.segment(word)) {
+ parts.push(segment.segment)
+ }
+ if (trailing != null) {
+ parts.push(trailing[0])
+ }
+}
+
+/**
+ * Splits text into word parts (non-whitespace run plus trailing whitespace).
+ * Spaceless scripts are further split via Intl.Segmenter.
+ * Concatenating the result always reproduces the input exactly.
+ */
+export function splitWords(value: string): string[] {
+ const parts: string[] = []
+ WORD_REGEX.lastIndex = 0
+ let index = 0
+ let match: RegExpExecArray | null
+ while ((match = WORD_REGEX.exec(value)) !== null) {
+ if (match.index > index) {
+ parts.push(value.slice(index, match.index))
+ }
+ const token = match[0]
+ if (SPACELESS_REGEX.test(token)) {
+ pushSegmentedParts(parts, token)
+ } else {
+ parts.push(token)
+ }
+ index = match.index + token.length
+ }
+ if (index < value.length) {
+ parts.push(value.slice(index))
+ }
+ return parts
+}
+
+export function createFadeState(): FadeState {
+ return { prevCount: 0, active: new Map(), firstRun: true, pending: null }
+}
+
+export function beginRun(state: FadeState, suppress = false): FadeRun {
+ return {
+ state,
+ now: performance.now(),
+ count: 0,
+ newIndex: 0,
+ suppress,
+ additions: new Map(),
+ }
+}
+
+/**
+ * Stages the run's result without publishing. Classification never mutates
+ * committed state during render, so abandoned renders leave no trace.
+ */
+export function stageRun(run: FadeRun): void {
+ run.state.pending = {
+ prevCount: run.count,
+ additions: run.additions,
+ now: run.now,
+ }
+}
+
+/** Publishes the staged run: baseline offset, new animations, pruned entries. */
+export function commitRun(state: FadeState): void {
+ const pending = state.pending
+ if (pending == null) {
+ return
+ }
+ state.pending = null
+ state.firstRun = false
+ state.prevCount = pending.prevCount
+ for (const [start, entry] of pending.additions) {
+ state.active.set(start, entry)
+ }
+ for (const [start, entry] of state.active) {
+ if (pending.now - entry.at >= entry.delay + FADE_DURATION_MS) {
+ state.active.delete(start)
+ }
+ }
+}
+
+/** Stages and immediately commits — for callers without a commit phase. */
+export function endRun(run: FadeRun): void {
+ stageRun(run)
+ commitRun(run.state)
+}
+
+/**
+ * Classifies one text value into fade segments, advancing document-order
+ * character offset. New parts (start >= prevCount) animate; parts still
+ * inside their animation window replay identical props.
+ */
+export function classifyValue(run: FadeRun, value: string): FadeSegment[] {
+ const { state, now } = run
+ const segments: FadeSegment[] = []
+ for (const part of splitWords(value)) {
+ const start = run.count
+ run.count += part.length
+ if (run.suppress || !NON_WHITESPACE_REGEX.test(part)) {
+ segments.push({ start, value: part, animated: false, delay: 0 })
+ continue
+ }
+ if (start >= state.prevCount) {
+ const staged = run.additions.get(start)
+ const delay =
+ staged?.delay ??
+ Math.min(run.newIndex * FADE_STAGGER_MS, FADE_STAGGER_MAX_MS)
+ run.newIndex += 1
+ run.additions.set(start, staged ?? { at: now, delay })
+ segments.push({ start, value: part, animated: true, delay })
+ continue
+ }
+ const entry = state.active.get(start)
+ if (entry != null && now - entry.at < entry.delay + FADE_DURATION_MS) {
+ segments.push({
+ start,
+ value: part,
+ animated: true,
+ delay: entry.delay,
+ })
+ continue
+ }
+ segments.push({ start, value: part, animated: false, delay: 0 })
+ }
+ return segments
+}
diff --git a/web/src/components/ai-elements/response-renderer-inline.tsx b/web/src/components/ai-elements/response-renderer-inline.tsx
index 0f2a0443dfe7..218945f143d9 100644
--- a/web/src/components/ai-elements/response-renderer-inline.tsx
+++ b/web/src/components/ai-elements/response-renderer-inline.tsx
@@ -16,7 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import type { ReactNode } from 'react'
+import { Fragment, type CSSProperties, type ReactNode } from 'react'
import {
shouldOpenLinkInNewTab,
type ImageNode,
@@ -24,11 +24,43 @@ import {
type TextNode,
} from 'stream-markdown-parser'
+import { classifyValue, type FadeRun } from './response-fade'
import { ResponseImage } from './response-renderer-image'
import type { RenderChildren } from './response-types'
-export function renderTextNode(node: TextNode): ReactNode {
- return node.content
+const STREAM_FADE_DELAY_VAR = '--stream-fade-delay'
+
+export function renderTextNode(
+ node: TextNode,
+ fadeRun?: FadeRun
+): ReactNode {
+ if (!fadeRun) {
+ return node.content
+ }
+
+ const segments = classifyValue(fadeRun, node.content)
+ if (segments.every((segment) => !segment.animated)) {
+ return node.content
+ }
+
+ return segments.map((segment) => {
+ if (!segment.animated) {
+ return {segment.value}
+ }
+
+ const style =
+ segment.delay > 0
+ ? ({
+ [STREAM_FADE_DELAY_VAR]: `${segment.delay}ms`,
+ } as CSSProperties)
+ : undefined
+
+ return (
+
+ {segment.value}
+
+ )
+ })
}
export function renderLink(
diff --git a/web/src/components/ai-elements/response-renderer.tsx b/web/src/components/ai-elements/response-renderer.tsx
index 97230841d512..c524c8cf7507 100644
--- a/web/src/components/ai-elements/response-renderer.tsx
+++ b/web/src/components/ai-elements/response-renderer.tsx
@@ -20,6 +20,7 @@ import type { ReactNode } from 'react'
import type { FootnoteNode, ParsedNode } from 'stream-markdown-parser'
import { getNodeKey } from './response-content'
+import type { FadeRun } from './response-fade'
import {
hasParsedChildren,
isBlockquoteNode,
@@ -52,40 +53,68 @@ import {
renderTextNode,
} from './response-renderer-inline'
import { renderTable } from './response-renderer-table'
+import type { BlockRendererOptions, RenderChildren } from './response-types'
-export function renderChildren(nodes: ParsedNode[]): ReactNode {
- return nodes.map((node, index) => renderNode(node, getNodeKey(node, index)))
+function createRenderChildren(fadeRun?: FadeRun): RenderChildren {
+ return (nodes) => renderChildren(nodes, fadeRun)
}
-export function renderFootnotes(footnotes: FootnoteNode[]): ReactNode {
- return renderFootnotesBlock(footnotes, { renderChildren })
+export function renderChildren(
+ nodes: ParsedNode[],
+ fadeRun?: FadeRun
+): ReactNode {
+ const options: BlockRendererOptions = {
+ fadeRun,
+ renderChildren: createRenderChildren(fadeRun),
+ }
+ return nodes.map((node, index) =>
+ renderNode(node, getNodeKey(node, index), options)
+ )
+}
+
+export function renderFootnotes(
+ footnotes: FootnoteNode[],
+ fadeRun?: FadeRun
+): ReactNode {
+ return renderFootnotesBlock(footnotes, {
+ fadeRun,
+ renderChildren: createRenderChildren(fadeRun),
+ })
}
-function renderNode(node: ParsedNode, key: string): ReactNode {
+/** Settled (non-animated) renderChildren for skipped subtrees */
+const settledRenderChildren = createRenderChildren()
+
+function renderNode(
+ node: ParsedNode,
+ key: string,
+ options: BlockRendererOptions
+): ReactNode {
if (isTextNode(node)) {
- return renderTextNode(node)
+ return renderTextNode(node, options.fadeRun)
}
if (isHeadingNode(node)) {
- return renderHeading(node, key, { renderChildren })
+ return renderHeading(node, key, options)
}
if (node.type === 'paragraph' && hasParsedChildren(node)) {
return (
- {renderChildren(node.children)}
+ {options.renderChildren(node.children)}
)
}
if (node.type === 'inline' && hasParsedChildren(node)) {
- return {renderChildren(node.children)}
+ return {options.renderChildren(node.children)}
}
if (isListNode(node)) {
- return renderList(node, key, { renderChildren })
+ return renderList(node, key, options)
}
+ // Skip list: code / math / html / image — no fade wrapping, offset not advanced
if (isCodeBlockNode(node)) {
return renderCodeBlock(node, key)
}
@@ -102,7 +131,7 @@ function renderNode(node: ParsedNode, key: string): ReactNode {
}
if (isLinkNode(node)) {
- return renderLink(node, key, renderChildren)
+ return renderLink(node, key, options.renderChildren)
}
if (isImageNode(node)) {
@@ -110,47 +139,47 @@ function renderNode(node: ParsedNode, key: string): ReactNode {
}
if (isBlockquoteNode(node)) {
- return renderBlockquote(node, key, { renderChildren })
+ return renderBlockquote(node, key, options)
}
if (isTableNode(node)) {
- return renderTable(node, key, { renderChildren })
+ return renderTable(node, key, options)
}
if (isDefinitionListNode(node)) {
- return renderDefinitionList(node, key, { renderChildren })
+ return renderDefinitionList(node, key, options)
}
if (node.type === 'strong' && hasParsedChildren(node)) {
return (
- {renderChildren(node.children)}
+ {options.renderChildren(node.children)}
)
}
if (node.type === 'emphasis' && hasParsedChildren(node)) {
- return {renderChildren(node.children)}
+ return {options.renderChildren(node.children)}
}
if (node.type === 'strikethrough' && hasParsedChildren(node)) {
- return {renderChildren(node.children)}
+ return {options.renderChildren(node.children)}
}
if (node.type === 'highlight' && hasParsedChildren(node)) {
- return {renderChildren(node.children)}
+ return {options.renderChildren(node.children)}
}
if (node.type === 'insert' && hasParsedChildren(node)) {
- return {renderChildren(node.children)}
+ return {options.renderChildren(node.children)}
}
if (node.type === 'subscript' && hasParsedChildren(node)) {
- return {renderChildren(node.children)}
+ return {options.renderChildren(node.children)}
}
if (node.type === 'superscript' && hasParsedChildren(node)) {
- return {renderChildren(node.children)}
+ return {options.renderChildren(node.children)}
}
if (
@@ -204,7 +233,9 @@ function renderNode(node: ParsedNode, key: string): ReactNode {
}
if (isHtmlBlockNode(node) && node.tag === 'details') {
- return renderDetails(node, key, { renderChildren })
+ return renderDetails(node, key, {
+ renderChildren: settledRenderChildren,
+ })
}
if (node.type === 'html_block' && 'content' in node) {
@@ -216,7 +247,7 @@ function renderNode(node: ParsedNode, key: string): ReactNode {
}
if (hasParsedChildren(node)) {
- return {renderChildren(node.children)}
+ return {options.renderChildren(node.children)}
}
if ('content' in node && typeof node.content === 'string') {
diff --git a/web/src/components/ai-elements/response-types.ts b/web/src/components/ai-elements/response-types.ts
index fb4b8e79f7aa..994b884b4b49 100644
--- a/web/src/components/ai-elements/response-types.ts
+++ b/web/src/components/ai-elements/response-types.ts
@@ -19,10 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
import type { ReactNode } from 'react'
import type { FootnoteNode, ParsedNode } from 'stream-markdown-parser'
+import type { FadeRun } from './response-fade'
+
export type ResponseProps = {
children?: ReactNode
className?: string
final?: boolean
+ /** Distinct stream-markdown-parser cache id when multiple Responses stream concurrently */
+ parserId?: string
}
export type AlertKind = 'note' | 'tip' | 'important' | 'warning' | 'caution'
@@ -42,4 +46,5 @@ export type RenderChildren = (nodes: ParsedNode[]) => ReactNode
export type BlockRendererOptions = {
renderChildren: RenderChildren
+ fadeRun?: FadeRun
}
diff --git a/web/src/components/ai-elements/response.tsx b/web/src/components/ai-elements/response.tsx
index 66e267d02ed7..bff21e2499f6 100644
--- a/web/src/components/ai-elements/response.tsx
+++ b/web/src/components/ai-elements/response.tsx
@@ -18,37 +18,96 @@ For commercial licensing, please contact support@quantumnous.com
*/
'use client'
-import { memo, useMemo } from 'react'
+import { memo, useLayoutEffect, useMemo, useRef } from 'react'
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { cn } from '@/lib/utils'
import { getMarkdownContent, parseResponseContent } from './response-content'
+import {
+ beginRun,
+ commitRun,
+ createFadeState,
+ FADE_HYDRATION_THRESHOLD,
+ stageRun,
+ type FadeRun,
+ type FadeState,
+} from './response-fade'
import { renderChildren, renderFootnotes } from './response-renderer'
import type { ResponseProps } from './response-types'
-const markdown = getMarkdown('new-api-response')
+const DEFAULT_PARSER_ID = 'new-api-response'
const MAX_PARSED_MARKDOWN_CHARS = 20_000
+type MarkdownInstance = ReturnType
+
+const markdownByParserId = new Map()
+
+function getCachedMarkdown(parserId: string): MarkdownInstance {
+ const cached = markdownByParserId.get(parserId)
+ if (cached != null) {
+ return cached
+ }
+ const markdown = getMarkdown(parserId)
+ markdownByParserId.set(parserId, markdown)
+ return markdown
+}
export const Response = memo((props: ResponseProps) => {
const content = getMarkdownContent(props.children)
+ const isFinal = props.final ?? true
+ const shouldAnimate = !isFinal
+ const parserId = props.parserId ?? DEFAULT_PARSER_ID
+ const markdown = getCachedMarkdown(parserId)
const shouldParseMarkdown = content.length <= MAX_PARSED_MARKDOWN_CHARS
+ const fadeStateRef = useRef(null)
+ if (fadeStateRef.current == null) {
+ fadeStateRef.current = createFadeState()
+ }
+
const nodes = useMemo(() => {
if (!shouldParseMarkdown) {
return []
}
return parseMarkdownToStructure(content, markdown, {
- final: props.final ?? true,
+ final: isFinal,
validateLink: markdown.options.validateLink,
})
- }, [content, props.final, shouldParseMarkdown])
+ }, [content, isFinal, markdown, shouldParseMarkdown])
const parsedContent = useMemo(() => parseResponseContent(nodes), [nodes])
- const renderedContent =
- parsedContent.bodyNodes.length > 0
- ? renderChildren(parsedContent.bodyNodes)
- : content
- const footnotes = renderFootnotes(parsedContent.footnotes)
+
+ let fadeRun: FadeRun | undefined
+ let renderedContent
+ let footnotes
+
+ if (parsedContent.bodyNodes.length > 0) {
+ if (shouldAnimate) {
+ const fadeState = fadeStateRef.current
+ const suppress =
+ fadeState.firstRun && content.length > FADE_HYDRATION_THRESHOLD
+ fadeRun = beginRun(fadeState, suppress)
+ renderedContent = renderChildren(parsedContent.bodyNodes, fadeRun)
+ footnotes = renderFootnotes(parsedContent.footnotes, fadeRun)
+ stageRun(fadeRun)
+ } else {
+ renderedContent = renderChildren(parsedContent.bodyNodes)
+ footnotes = renderFootnotes(parsedContent.footnotes)
+ }
+ } else {
+ renderedContent = content
+ footnotes = renderFootnotes(parsedContent.footnotes)
+ }
+
+ useLayoutEffect(() => {
+ if (!shouldAnimate) {
+ return
+ }
+ const fadeState = fadeStateRef.current
+ if (fadeState == null) {
+ return
+ }
+ commitRun(fadeState)
+ })
return (
.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import i18next from 'i18next'
+import { beforeAll, describe, expect, test, vi } from 'vitest'
+
+import type { Message } from '../../../types'
+import { PlaygroundMessageEditor } from '../playground-message-editor'
+
+const leavePrompt = 'You have unsaved changes. Are you sure you want to leave?'
+
+const userMessage: Message = {
+ key: 'msg-1',
+ from: 'user',
+ versions: [{ id: 'v1', content: 'original' }],
+}
+
+function renderEditor(options: {
+ editText: string
+ onCancelEdit?: (open: boolean) => void
+}) {
+ return render(
+ undefined}
+ originalText='original'
+ />
+ )
+}
+
+describe('PlaygroundMessageEditor leave warning', () => {
+ beforeAll(() => {
+ i18next.addResourceBundle('en', 'translation', {
+ Cancel: 'Cancel',
+ Leave: 'Leave',
+ Stay: 'Stay',
+ [leavePrompt]: leavePrompt,
+ })
+ })
+
+ test('cancels immediately when the edit has no unsaved changes', async () => {
+ const user = userEvent.setup()
+ const onCancelEdit = vi.fn()
+
+ renderEditor({ editText: 'original', onCancelEdit })
+
+ await user.click(screen.getByRole('button', { name: 'Cancel' }))
+
+ expect(onCancelEdit).toHaveBeenCalledWith(false)
+ expect(screen.queryByText(leavePrompt)).not.toBeInTheDocument()
+ })
+
+ test('leaves the editor after confirming unsaved changes', async () => {
+ const user = userEvent.setup()
+ const onCancelEdit = vi.fn()
+
+ renderEditor({ editText: 'changed', onCancelEdit })
+
+ await user.click(screen.getByRole('button', { name: 'Cancel' }))
+ await user.click(screen.getByRole('button', { name: 'Leave' }))
+
+ expect(onCancelEdit).toHaveBeenCalledWith(false)
+ })
+
+ test('keeps the editor open after staying with unsaved changes', async () => {
+ const user = userEvent.setup()
+ const onCancelEdit = vi.fn()
+
+ renderEditor({ editText: 'changed', onCancelEdit })
+
+ await user.click(screen.getByRole('button', { name: 'Cancel' }))
+ await user.click(screen.getByRole('button', { name: 'Stay' }))
+
+ expect(onCancelEdit).not.toHaveBeenCalled()
+ expect(screen.queryByText(leavePrompt)).not.toBeInTheDocument()
+ })
+})
+
+describe('PlaygroundMessageEditor beforeunload guard', () => {
+ test('blocks page unload while the edit has unsaved changes', () => {
+ renderEditor({ editText: 'changed' })
+
+ const event = new Event('beforeunload', { cancelable: true })
+ window.dispatchEvent(event)
+
+ expect(event.defaultPrevented).toBe(true)
+ })
+
+ test('stops blocking page unload after the edit reverts to the original text', () => {
+ const { rerender } = renderEditor({ editText: 'changed' })
+
+ rerender(
+ undefined}
+ originalText='original'
+ />
+ )
+
+ const event = new Event('beforeunload', { cancelable: true })
+ window.dispatchEvent(event)
+
+ expect(event.defaultPrevented).toBe(false)
+ })
+})
diff --git a/web/src/features/playground/components/message/playground-message-editor.tsx b/web/src/features/playground/components/message/playground-message-editor.tsx
index 3d82615951a1..c596270017d3 100644
--- a/web/src/features/playground/components/message/playground-message-editor.tsx
+++ b/web/src/features/playground/components/message/playground-message-editor.tsx
@@ -17,9 +17,11 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
import { Check, RotateCcw, Send, X } from 'lucide-react'
+import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CodeBlockEditor } from '@/components/ai-elements/code-block'
+import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@/components/ui/button'
import { getMessageEditorState } from '../../lib'
@@ -45,28 +47,44 @@ export function PlaygroundMessageEditor({
originalText,
}: PlaygroundMessageEditorProps) {
const { t } = useTranslation()
+ const [showLeaveDialog, setShowLeaveDialog] = useState(false)
const { canSave, hasChanged, showSaveAndSubmit } = getMessageEditorState(
message,
editText,
originalText
)
+ useEffect(() => {
+ if (!hasChanged) return
+
+ const handleBeforeUnload = (event: BeforeUnloadEvent) => {
+ event.preventDefault()
+ event.returnValue = ''
+ return ''
+ }
+
+ window.addEventListener('beforeunload', handleBeforeUnload)
+ return () => window.removeEventListener('beforeunload', handleBeforeUnload)
+ }, [hasChanged])
+
+ const leaveEdit = () => {
+ setShowLeaveDialog(false)
+ onCancelEdit?.(false)
+ }
+
const handleCancel = () => {
- if (
- hasChanged &&
- !window.confirm(
- t('You have unsaved changes. Are you sure you want to leave?')
- )
- ) {
+ if (hasChanged) {
+ setShowLeaveDialog(true)
return
}
- onCancelEdit?.(false)
+ leaveEdit()
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
+ if (showLeaveDialog) return
handleCancel()
return
}
@@ -133,23 +151,37 @@ export function PlaygroundMessageEditor({
)
return (
-
- {t('Edit')}
-
- {hasChanged ? t('Unsaved changes') : t('No changes')}
+ <>
+
+ {t('Edit')}
+
+ {hasChanged ? t('Unsaved changes') : t('No changes')}
+
-
- }
- value={editText}
- />
+ }
+ value={editText}
+ />
+ {
+ if (!open) setShowLeaveDialog(false)
+ }}
+ open={showLeaveDialog}
+ title={t('Unsaved changes')}
+ />
+ >
)
}
diff --git a/web/src/styles/index.css b/web/src/styles/index.css
index 940618c0ca5b..a9eadb5c1b10 100644
--- a/web/src/styles/index.css
+++ b/web/src/styles/index.css
@@ -442,6 +442,28 @@ For commercial licensing, please contact support@quantumnous.com
}
}
+/* Smooth streaming: one-shot fade-in on newly streamed words.
+ * Duration must match FADE_DURATION_MS in response-fade.ts */
+@keyframes stream-fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+[data-stream-fade] {
+ animation: stream-fade-in 250ms ease-out both;
+ animation-delay: var(--stream-fade-delay, 0ms);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ [data-stream-fade] {
+ animation: none;
+ }
+}
+
/* ── Landing page scroll-triggered animations ── */
@keyframes landing-fade-up {
from {
From f116414284162ad15d8925f7bca494c109b83e93 Mon Sep 17 00:00:00 2001
From: Qi <108174052+LiaoQi98@users.noreply.github.com>
Date: Tue, 18 Aug 2026 18:24:43 +0800
Subject: [PATCH 50/99] fix: settle Responses cached token usage (#6892)
---
service/billing_usage.go | 23 ++++++++++
service/text_quota_test.go | 86 ++++++++++++++++++++++++++++++++++++++
2 files changed, 109 insertions(+)
diff --git a/service/billing_usage.go b/service/billing_usage.go
index 12656e693708..2ea9429785fe 100644
--- a/service/billing_usage.go
+++ b/service/billing_usage.go
@@ -113,6 +113,29 @@ func usageFromOpenAIBillingUsage(billingUsage *dto.BillingUsage) *dto.Usage {
if usage.TotalTokens == 0 {
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
}
+ if inputDetails := usage.InputTokensDetails; inputDetails != nil {
+ if usage.PromptTokensDetails.CachedTokens == 0 && inputDetails.CachedTokens > 0 {
+ usage.PromptTokensDetails.CachedTokens = inputDetails.CachedTokens
+ }
+ if usage.PromptTokensDetails.CachedCreationTokens == 0 && inputDetails.CachedCreationTokens > 0 {
+ usage.PromptTokensDetails.CachedCreationTokens = inputDetails.CachedCreationTokens
+ }
+ if usage.PromptTokensDetails.CacheWriteTokens == 0 && inputDetails.CacheWriteTokens > 0 {
+ usage.PromptTokensDetails.CacheWriteTokens = inputDetails.CacheWriteTokens
+ }
+ if usage.PromptTokensDetails.TextTokens == 0 && inputDetails.TextTokens > 0 {
+ usage.PromptTokensDetails.TextTokens = inputDetails.TextTokens
+ }
+ if usage.PromptTokensDetails.ImageTokens == 0 && inputDetails.ImageTokens > 0 {
+ usage.PromptTokensDetails.ImageTokens = inputDetails.ImageTokens
+ }
+ if usage.PromptTokensDetails.AudioTokens == 0 && inputDetails.AudioTokens > 0 {
+ usage.PromptTokensDetails.AudioTokens = inputDetails.AudioTokens
+ }
+ }
+ if usage.PromptTokensDetails.CachedTokens == 0 && usage.PromptCacheHitTokens > 0 {
+ usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
+ }
usage.UsageSemantic = dto.BillingUsageSemanticOpenAI
usage.UsageSource = billingUsage.Source
usage.BillingUsage = dto.CloneBillingUsage(billingUsage)
diff --git a/service/text_quota_test.go b/service/text_quota_test.go
index c9e958e7bf2b..e4a1ed68cf5d 100644
--- a/service/text_quota_test.go
+++ b/service/text_quota_test.go
@@ -283,6 +283,92 @@ func TestCalculateTextQuotaSummaryUsesOpenAIBillingUsageBeforeTopLevelUsage(t *t
require.Equal(t, 98, summary.Quota)
}
+func TestCalculateTextQuotaSummaryUsesOpenAIResponsesInputTokenDetails(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+ relayInfo := &relaycommon.RelayInfo{
+ RelayFormat: types.RelayFormatOpenAI,
+ OriginModelName: "gpt-4o",
+ PriceData: hosttypes.PriceData{
+ ModelRatio: 1,
+ CompletionRatio: 2,
+ CacheRatio: 0.25,
+ GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: 1},
+ },
+ StartTime: time.Now(),
+ }
+
+ responsesUsage := &dto.Usage{
+ InputTokens: 100,
+ OutputTokens: 10,
+ TotalTokens: 110,
+ InputTokensDetails: &dto.InputTokenDetails{
+ CachedTokens: 40,
+ },
+ }
+ convertedUsage := &dto.Usage{
+ PromptTokens: 100,
+ CompletionTokens: 10,
+ TotalTokens: 110,
+ PromptTokensDetails: dto.InputTokenDetails{
+ CachedTokens: 40,
+ },
+ BillingUsage: dto.NewOpenAIResponsesBillingUsage(responsesUsage),
+ }
+
+ effectiveUsage := effectiveBillingUsage(convertedUsage)
+ require.Equal(t, 40, effectiveUsage.PromptTokensDetails.CachedTokens)
+ require.Zero(t, convertedUsage.BillingUsage.OpenAIUsage.PromptTokensDetails.CachedTokens)
+
+ summary := calculateTextQuotaSummary(ctx, relayInfo, effectiveUsage)
+ require.Equal(t, 40, summary.CacheTokens)
+ // 60 uncached input + 40*0.25 cached input + 10*2 output = 90.
+ require.Equal(t, 90, summary.Quota)
+}
+
+func TestUsageFromOpenAIBillingUsageNormalizesCacheDetailsWithoutOverwritingCanonicalValues(t *testing.T) {
+ responsesUsage := &dto.Usage{
+ InputTokens: 100,
+ OutputTokens: 10,
+ PromptCacheHitTokens: 55,
+ PromptTokensDetails: dto.InputTokenDetails{
+ CachedTokens: 8,
+ TextTokens: 12,
+ },
+ InputTokensDetails: &dto.InputTokenDetails{
+ CachedTokens: 40,
+ CachedCreationTokens: 5,
+ CacheWriteTokens: 6,
+ TextTokens: 60,
+ ImageTokens: 7,
+ AudioTokens: 9,
+ },
+ }
+
+ billingUsage := dto.NewOpenAIResponsesBillingUsage(responsesUsage)
+ usage := effectiveBillingUsage(&dto.Usage{BillingUsage: billingUsage})
+
+ require.Equal(t, 8, usage.PromptTokensDetails.CachedTokens)
+ require.Equal(t, 5, usage.PromptTokensDetails.CachedCreationTokens)
+ require.Equal(t, 6, usage.PromptTokensDetails.CacheWriteTokens)
+ require.Equal(t, 12, usage.PromptTokensDetails.TextTokens)
+ require.Equal(t, 7, usage.PromptTokensDetails.ImageTokens)
+ require.Equal(t, 9, usage.PromptTokensDetails.AudioTokens)
+ require.Zero(t, billingUsage.OpenAIUsage.PromptTokensDetails.CachedCreationTokens)
+}
+
+func TestUsageFromOpenAIBillingUsageFallsBackToPromptCacheHitTokens(t *testing.T) {
+ usage := effectiveBillingUsage(&dto.Usage{
+ BillingUsage: dto.NewOpenAIChatBillingUsage(&dto.Usage{
+ PromptTokens: 100,
+ CompletionTokens: 10,
+ PromptCacheHitTokens: 35,
+ }),
+ })
+
+ require.Equal(t, 35, usage.PromptTokensDetails.CachedTokens)
+}
+
func TestUsageBillingPathForLog(t *testing.T) {
require.Equal(t, usageBillingPathAnthropic, usageBillingPathForLog(true, &dto.Usage{
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{InputTokens: 1}),
From 2d8e50bf36e94200b809dfb39e73624ec48b1e23 Mon Sep 17 00:00:00 2001
From: Seefs <40468931+seefs001@users.noreply.github.com>
Date: Fri, 21 Aug 2026 23:47:48 +0800
Subject: [PATCH 51/99] refactor(web): prevent credential autofill in usage log
filters (#6966)
---
.../usage-logs/components/common-logs-filter-bar.tsx | 10 ++++++----
.../usage-logs/components/logs-filter-toolbar.tsx | 1 +
2 files changed, 7 insertions(+), 4 deletions(-)
diff --git a/web/src/features/usage-logs/components/common-logs-filter-bar.tsx b/web/src/features/usage-logs/components/common-logs-filter-bar.tsx
index b774aa1cf8b4..5744d151fd84 100644
--- a/web/src/features/usage-logs/components/common-logs-filter-bar.tsx
+++ b/web/src/features/usage-logs/components/common-logs-filter-bar.tsx
@@ -251,7 +251,9 @@ export function CommonLogsFilterBar(
filters.requestId,
filters.upstreamRequestId,
].filter(Boolean).length
- const sensitiveType = sensitiveVisible ? 'text' : 'password'
+ const sensitiveInputClass = sensitiveVisible
+ ? undefined
+ : '[-webkit-text-security:disc]'
const logTypeItems = useMemo(
() =>
LOG_TYPE_FILTERS.map((type) => ({
@@ -315,7 +317,7 @@ export function CommonLogsFilterBar(
handleChange('group', e.target.value)}
onKeyDown={handleKeyDown}
@@ -363,7 +365,7 @@ export function CommonLogsFilterBar(
handleChange('token', e.target.value)}
onKeyDown={handleKeyDown}
@@ -373,7 +375,7 @@ export function CommonLogsFilterBar(
handleChange('username', e.target.value)}
onKeyDown={handleKeyDown}
diff --git a/web/src/features/usage-logs/components/logs-filter-toolbar.tsx b/web/src/features/usage-logs/components/logs-filter-toolbar.tsx
index 4f3798a23553..128d47dc4533 100644
--- a/web/src/features/usage-logs/components/logs-filter-toolbar.tsx
+++ b/web/src/features/usage-logs/components/logs-filter-toolbar.tsx
@@ -79,6 +79,7 @@ export function LogsFilterInput(props: ComponentProps) {
return (
)
From a073f74b38a33bb154821089c097658cbdcc0fbe Mon Sep 17 00:00:00 2001
From: Seefs <40468931+seefs001@users.noreply.github.com>
Date: Wed, 26 Aug 2026 20:57:54 +0800
Subject: [PATCH 52/99] refactor: deprecate int32 (#7025)
* refactor: deprecate int32
* fix(db): reject legacy user quota schemas at startup
* fix(quota): enforce wallet bounds and saturating billing conversions
* fix(rate-limit): keep count*duration from wrapping int64
* fix: error message
---
.env.example | 2 ++
AGENTS.md | 2 +-
common/quota_math.go | 53 +++++++++++++++++++---------
common/quota_math_test.go | 22 +++++++++++-
controller/channel-test.go | 8 ++---
controller/redemption.go | 17 +++++++++
controller/token.go | 15 ++++++--
controller/topup.go | 12 ++++---
controller/topup_quota_limit_test.go | 34 ++++++++++--------
controller/user.go | 8 +++++
controller/user_manage_test.go | 21 +++++++++++
middleware/model-rate-limit.go | 27 ++++++++++++--
model/main.go | 49 +++++++++++++++++++++++++
model/payment_method_guard_test.go | 12 +++----
model/quota_reserve_test.go | 33 +++++++++++++++++
model/redemption.go | 14 +++++++-
model/redemption_test.go | 29 +++++++++++++++
model/subscription.go | 2 +-
model/topup.go | 33 ++++++++---------
model/user.go | 42 ++++++++++++++++------
model/utils.go | 19 ++++++++--
pkg/billingexpr/billingexpr_test.go | 5 +--
pkg/billingexpr/settle_clamp_test.go | 13 ++++---
pkg/billingexpr/types.go | 2 +-
relay/common/relay_info.go | 2 +-
relay/helper/price_test.go | 2 +-
service/quota.go | 10 ++++--
service/quota_saturation_test.go | 25 +++++++++++++
service/text_quota.go | 4 +--
service/text_quota_test.go | 8 ++---
service/tiered_settle.go | 2 +-
service/token_counter.go | 4 +--
service/violation_fee.go | 7 ++--
setting/rate_limit.go | 21 +++++++----
web/src/features/usage-logs/types.ts | 2 +-
35 files changed, 445 insertions(+), 116 deletions(-)
diff --git a/.env.example b/.env.example
index 3b8a2a5b9534..e2f287431665 100644
--- a/.env.example
+++ b/.env.example
@@ -35,6 +35,8 @@
# SQL_MAX_LIFETIME=60
# 慢查询日志阈值(毫秒),0 表示关闭慢查询日志,超出 0-3600000 范围回退默认值 200
# SQL_SLOW_THRESHOLD_MS=200
+# 跳过用户额度列 64 位 schema 检查(仅在已确认数据库列可容纳 64 位时启用)
+# SKIP_64BIT_QUOTA_SCHEMA_CHECK=true
# 缓存相关配置
diff --git a/AGENTS.md b/AGENTS.md
index fa942c7159a6..9314a49b5266 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -107,7 +107,7 @@ Do NOT directly import or call `encoding/json` in business code. `json.RawMessag
- Every user-controlled quantity that becomes a billing multiplier (image `n`, video `seconds`/`duration`, resolution/quality ratios, batch counts) MUST be bounded before it reaches quota calculation. Reject out-of-range values at request validation with a 400. Existing bounds: `dto.MaxImageN` for image generation count, `relaycommon.MaxTaskDurationSeconds` for task video duration, `maxTokensLimit` (`relay/helper/valid_request.go`) for `max_tokens`-family fields on every relay format (OpenAI, Claude, Gemini, Responses). Reuse these constants instead of introducing new ad hoc limits for the same concepts. When adding a new relay format or request DTO, bound its max-tokens and count fields in its validator from day one.
- Watch for validation bypass paths: passthrough fields (e.g. `Extra["parameters"]`), task `metadata` maps, and multipart form fields can carry the same quantities around the standard DTO validation. Any adaptor that reads a multiplier from such a path must enforce the same bound (or clamp) locally.
- Durations parsed from media metadata are user/upstream-controlled too: audio file headers (transcription token counting, TTS response duration) and upstream deduction numbers (e.g. Kling `FinalUnitDeduction`) can claim absurd values. Convert them with saturation before they become token counts.
-- Never convert a computed quota or token count to `int` with a bare cast like `int(float64(quota) * ratio)`, `int(math.Round(...))` on unbounded input, or `int(decimal.IntPart())`. All quota rounding/conversion is centralized in `common/quota_math.go`; use those helpers: `common.QuotaFromFloat` (truncating) for float products, `common.QuotaRound` (half-away-from-zero) where rounding is intended, and `common.QuotaFromDecimal` for decimal products. `billingexpr.QuotaRound` delegates to `common.QuotaRound`. Do not reintroduce local conversion helpers or bare casts. Saturation bounds are int32 because quota columns (user/token/log) are 32-bit integers in the database, and every clamp/NaN fallback is logged via `common.SysError` since a single request should never approach those bounds.
+- Never convert a computed quota or token count to `int` with a bare cast like `int(float64(quota) * ratio)`, `int(math.Round(...))` on unbounded input, or `int(decimal.IntPart())`. All quota rounding/conversion is centralized in `common/quota_math.go`; use those helpers: `common.QuotaFromFloat` (truncating) for float products, `common.QuotaRound` (half-away-from-zero) where rounding is intended, and `common.QuotaFromDecimal` for decimal products. `billingexpr.QuotaRound` delegates to `common.QuotaRound`. Do not reintroduce local conversion helpers or bare casts. Single-request saturation stays at the int32 boundary so batch accumulation cannot approach 64-bit wraparound; wallet/top-up conversion uses `common.WalletQuotaFromDecimalStrict` with the JavaScript-safe `common.MaxWalletQuota` boundary. Every clamp/NaN fallback is logged via `common.SysError`.
- Saturation events are also audited: each helper has a `*Checked` variant (`common.QuotaFromFloatChecked` / `QuotaRoundChecked` / `QuotaFromDecimalChecked`) that additionally returns a `*common.QuotaClamp` when clamping occurred. Billing paths that compute a charge capture that clamp onto `relayInfo.QuotaClamp` (or thread it into task settlement) and, right before writing the consume/task log, call `attachQuotaSaturation` (in `service/log_info_generate.go`) which nests the marker under the log's `other.admin_info.quota_saturation` and emits a request-correlated `logger.LogWarn`. Nesting under `admin_info` makes it admin-only for free (non-admin log views strip `admin_info`). When adding a new billing path, use the `*Checked` variant and surface the clamp the same way so the anomaly stays auditable in both the admin log UI and backend logs.
- Multiplier maps go through `types.PriceData.AddOtherRatio`, which rejects non-positive, NaN, and +Inf ratios. Do not write to `PriceData.OtherRatios` directly, and do not weaken these guards.
- Pre-consume (预扣费) and settle (结算/差额) must both be safe: a saturated oversized quota must fail pre-consume with insufficient-quota, never silently wrap. When adding a new billing path (new relay format, new task platform, new adjustment hook), trace the full chain — validation → EstimateBilling/OtherRatios → quota conversion → pre-consume → settle/refund — and confirm each step preserves these invariants.
diff --git a/common/quota_math.go b/common/quota_math.go
index 66d62093ea53..fb0e71caa612 100644
--- a/common/quota_math.go
+++ b/common/quota_math.go
@@ -8,14 +8,24 @@ import (
)
// Quota conversions are centralized here so every billing path shares one
-// saturation + logging policy. Quota columns (user/token/log) are 32-bit
-// integers in the database, so an oversized product must clamp to the int32
-// range instead of wrapping around and turning a charge into a credit.
+// saturation + logging policy. Single-request charges stay bounded to int32;
+// top-ups and wallet-priced purchases use a JavaScript-safe 64-bit domain.
const (
- MaxQuota = math.MaxInt32
- MinQuota = math.MinInt32
+ MaxQuota = math.MaxInt32
+ MinQuota = math.MinInt32
+ MaxWalletQuota = 1<<53 - 1
)
+// ValidateWalletQuota enforces the upper bound shared by wallet mutations.
+// Negative balances remain valid because billing can temporarily overdraw a
+// wallet; callers that accept credits must apply their own positive check.
+func ValidateWalletQuota(quota int) error {
+ if quota > MaxWalletQuota {
+ return fmt.Errorf("wallet quota exceeds %d", MaxWalletQuota)
+ }
+ return nil
+}
+
// QuotaClampKind identifies why a quota conversion had to be saturated.
type QuotaClampKind string
@@ -27,11 +37,11 @@ const (
)
// QuotaClamp describes a single saturation event: a quota conversion whose
-// input fell outside the representable int32 range (or was NaN) and was
+// input fell outside its supported range (or was NaN) and was
// therefore clamped. It is surfaced to billing callers so the event can be
// recorded on the related consume/task log for admin auditing.
type QuotaClamp struct {
- Op string `json:"op"` // "QuotaFromFloat" | "QuotaRound" | "QuotaFromDecimal"
+ Op string `json:"op"` // "QuotaFromFloat" | "QuotaRound" | "QuotaFromDecimal" | "WalletQuotaFromDecimal"
Kind QuotaClampKind `json:"kind"` // "overflow" | "underflow" | "nan"
Original float64 `json:"original"` // best-effort pre-clamp value (decimal -> float64 approx)
Clamped int `json:"clamped"` // the saturated result actually used
@@ -61,23 +71,27 @@ func (c *QuotaClamp) AuditMap() map[string]interface{} {
}
}
-// saturateQuota converts an already-rounded quota value to int, clamping to
-// the int32 range. Whenever clamping (what would otherwise be an integer
-// wraparound) or a NaN fallback is triggered it logs a warning, because in
+// saturateQuota converts an already-rounded single-request quota to int.
+// Whenever clamping (what would otherwise be an integer wraparound) or a NaN
+// fallback is triggered it logs a warning, because in
// normal operation a single request never approaches these bounds — hitting
// them signals a bug or an abusive request. `op` names the caller. When a
// clamp occurs it returns a non-nil *QuotaClamp so callers can additionally
// record the event (e.g. on the consume log); the returned pointer is nil for
// in-range values.
func saturateQuota(value float64, op string) (int, *QuotaClamp) {
+ return saturateQuotaBounded(value, op, MaxQuota, MinQuota)
+}
+
+func saturateQuotaBounded(value float64, op string, maxQuota int, minQuota int) (int, *QuotaClamp) {
var clamp *QuotaClamp
switch {
case math.IsNaN(value):
clamp = &QuotaClamp{Op: op, Kind: QuotaClampNaN, Original: value, Clamped: 0}
- case value >= MaxQuota:
- clamp = &QuotaClamp{Op: op, Kind: QuotaClampOverflow, Original: value, Clamped: MaxQuota}
- case value <= MinQuota:
- clamp = &QuotaClamp{Op: op, Kind: QuotaClampUnderflow, Original: value, Clamped: MinQuota}
+ case value > float64(maxQuota):
+ clamp = &QuotaClamp{Op: op, Kind: QuotaClampOverflow, Original: value, Clamped: maxQuota}
+ case value < float64(minQuota):
+ clamp = &QuotaClamp{Op: op, Kind: QuotaClampUnderflow, Original: value, Clamped: minQuota}
default:
return int(value), nil
}
@@ -147,8 +161,15 @@ func QuotaFromDecimalChecked(d decimal.Decimal) (int, *QuotaClamp) {
return saturateQuota(f, "QuotaFromDecimal")
}
-// QuotaFromDecimalStrict converts an in-range decimal quota and rejects a
-// value that would otherwise be saturated at the database's int32 boundary.
+// QuotaFromDecimalStrict converts an in-range single-request quota and rejects
+// a value that would otherwise be saturated at the int32 boundary.
func QuotaFromDecimalStrict(d decimal.Decimal) (int, error) {
return strictQuota(QuotaFromDecimalChecked(d))
}
+
+// WalletQuotaFromDecimalStrict converts wallet and top-up values within the
+// JavaScript-safe integer range, which is also exactly representable by float64.
+func WalletQuotaFromDecimalStrict(d decimal.Decimal) (int, error) {
+ f, _ := d.Round(0).Float64()
+ return strictQuota(saturateQuotaBounded(f, "WalletQuotaFromDecimal", MaxWalletQuota, -MaxWalletQuota))
+}
diff --git a/common/quota_math_test.go b/common/quota_math_test.go
index 2d8742e6c176..efa8b9ebc2c2 100644
--- a/common/quota_math_test.go
+++ b/common/quota_math_test.go
@@ -1,6 +1,7 @@
package common
import (
+ "fmt"
"math"
"testing"
@@ -21,6 +22,7 @@ func TestQuotaFromFloat(t *testing.T) {
assert.Equal(t, 42, QuotaFromFloat(42.4))
assert.Equal(t, 42, QuotaFromFloat(42.9))
assert.Equal(t, -42, QuotaFromFloat(-42.9))
+ assert.Equal(t, MaxQuota, QuotaFromFloat(float64(math.MaxInt32)+42))
assert.Equal(t, MaxQuota, QuotaFromFloat(overflowingProduct))
assert.Equal(t, MinQuota, QuotaFromFloat(-overflowingProduct))
assert.Equal(t, MaxQuota, QuotaFromFloat(math.Inf(1)))
@@ -34,6 +36,7 @@ func TestQuotaRound(t *testing.T) {
assert.Equal(t, 42, QuotaRound(41.5))
assert.Equal(t, 43, QuotaRound(42.5))
assert.Equal(t, -43, QuotaRound(-42.5))
+ assert.Equal(t, MaxQuota, QuotaRound(float64(math.MaxInt32)+0.5))
assert.Equal(t, MaxQuota, QuotaRound(overflowingProduct))
assert.Equal(t, MinQuota, QuotaRound(-overflowingProduct))
assert.Equal(t, 0, QuotaRound(math.NaN()))
@@ -93,7 +96,7 @@ func TestQuotaFromFloatStrictReturnsTypedClampError(t *testing.T) {
assert.ErrorContains(t, err, "QuotaFromFloat")
assert.ErrorContains(t, err, "overflow")
assert.ErrorContains(t, err, "original=")
- assert.ErrorContains(t, err, "clamped=2147483647")
+ assert.ErrorContains(t, err, fmt.Sprintf("clamped=%d", MaxQuota))
}
// TestQuotaRoundChecked verifies the rounding entry point reports clamps the
@@ -124,3 +127,20 @@ func TestQuotaFromDecimalChecked(t *testing.T) {
assert.Equal(t, QuotaClampOverflow, clamp.Kind)
}
}
+
+func TestWalletQuotaFromDecimalStrict(t *testing.T) {
+ quota, err := WalletQuotaFromDecimalStrict(decimal.NewFromInt(4_294_500_000))
+ require.NoError(t, err)
+ assert.Equal(t, 4_294_500_000, quota)
+
+ quota, err = WalletQuotaFromDecimalStrict(decimal.NewFromInt(MaxWalletQuota))
+ require.NoError(t, err)
+ assert.Equal(t, MaxWalletQuota, quota)
+
+ quota, err = WalletQuotaFromDecimalStrict(decimal.NewFromInt(MaxWalletQuota + 1))
+ assert.Zero(t, quota)
+ var clamp *QuotaClamp
+ require.ErrorAs(t, err, &clamp)
+ assert.Equal(t, "WalletQuotaFromDecimal", clamp.Op)
+ assert.Equal(t, QuotaClampOverflow, clamp.Kind)
+}
diff --git a/controller/channel-test.go b/controller/channel-test.go
index b294979d5877..e1535d26f669 100644
--- a/controller/channel-test.go
+++ b/controller/channel-test.go
@@ -7,7 +7,6 @@ import (
"errors"
"fmt"
"io"
- "math"
"net/http"
"net/http/httptest"
"strconv"
@@ -540,15 +539,16 @@ func settleTestQuota(info *relaycommon.RelayInfo, priceData hosttypes.PriceData,
quota := 0
if !priceData.UsePrice {
- quota = usage.PromptTokens + int(math.Round(float64(usage.CompletionTokens)*priceData.CompletionRatio))
- quota = int(math.Round(float64(quota) * priceData.ModelRatio))
+ completionQuota := common.QuotaRound(float64(usage.CompletionTokens) * priceData.CompletionRatio)
+ quota = common.QuotaRound(float64(usage.PromptTokens) + float64(completionQuota))
+ quota = common.QuotaRound(float64(quota) * priceData.ModelRatio)
if priceData.ModelRatio != 0 && quota <= 0 {
quota = 1
}
return quota, nil
}
- return int(priceData.ModelPrice * common.QuotaPerUnit), nil
+ return common.QuotaFromFloat(priceData.ModelPrice * common.QuotaPerUnit), nil
}
func buildTestLogOther(c *gin.Context, info *relaycommon.RelayInfo, priceData hosttypes.PriceData, usage *dto.Usage, tieredResult *billingexpr.TieredResult) map[string]interface{} {
diff --git a/controller/redemption.go b/controller/redemption.go
index 838746e7dd7f..86289f8a2dbf 100644
--- a/controller/redemption.go
+++ b/controller/redemption.go
@@ -1,6 +1,7 @@
package controller
import (
+ "errors"
"net/http"
"strconv"
"unicode/utf8"
@@ -85,6 +86,14 @@ func AddRedemption(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax)
return
}
+ if redemption.Quota <= 0 {
+ common.ApiError(c, errors.New("redemption quota must be positive"))
+ return
+ }
+ if err := common.ValidateWalletQuota(redemption.Quota); err != nil {
+ common.ApiError(c, err)
+ return
+ }
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
return
@@ -153,6 +162,14 @@ func UpdateRedemption(c *gin.Context) {
return
}
if statusOnly == "" {
+ if redemption.Quota <= 0 {
+ common.ApiError(c, errors.New("redemption quota must be positive"))
+ return
+ }
+ if err := common.ValidateWalletQuota(redemption.Quota); err != nil {
+ common.ApiError(c, err)
+ return
+ }
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
return
diff --git a/controller/token.go b/controller/token.go
index ff2aca8f0975..09d14fcab373 100644
--- a/controller/token.go
+++ b/controller/token.go
@@ -15,6 +15,7 @@ import (
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
+ "github.com/shopspring/decimal"
)
type tokenAutoGroupsInput struct {
@@ -41,6 +42,16 @@ type tokenResponse struct {
AutoGroups []string `json:"auto_groups"`
}
+func maxTokenQuota() int {
+ quota, err := common.WalletQuotaFromDecimalStrict(
+ decimal.NewFromInt(1_000_000_000).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
+ )
+ if err != nil {
+ return common.MaxWalletQuota
+ }
+ return quota
+}
+
func buildMaskedTokenResponse(token *model.Token) *tokenResponse {
if token == nil {
return nil
@@ -279,7 +290,7 @@ func AddToken(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative)
return
}
- maxQuotaValue := common.QuotaFromFloat(1000000000 * common.QuotaPerUnit)
+ maxQuotaValue := maxTokenQuota()
if token.RemainQuota > maxQuotaValue {
common.ApiErrorI18n(c, i18n.MsgTokenQuotaExceedMax, map[string]any{"Max": maxQuotaValue})
return
@@ -373,7 +384,7 @@ func UpdateToken(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative)
return
}
- maxQuotaValue := common.QuotaFromFloat(1000000000 * common.QuotaPerUnit)
+ maxQuotaValue := maxTokenQuota()
if token.RemainQuota > maxQuotaValue {
common.ApiErrorI18n(c, i18n.MsgTokenQuotaExceedMax, map[string]any{"Max": maxQuotaValue})
return
diff --git a/controller/topup.go b/controller/topup.go
index 08aab8132d0c..30f8d2215784 100644
--- a/controller/topup.go
+++ b/controller/topup.go
@@ -182,7 +182,11 @@ func getMinTopup() int64 {
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
dMinTopup := decimal.NewFromInt(int64(minTopup))
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
- minTopup = common.QuotaFromDecimal(dMinTopup.Mul(dQuotaPerUnit))
+ quota, err := common.WalletQuotaFromDecimalStrict(dMinTopup.Mul(dQuotaPerUnit))
+ if err != nil {
+ return common.MaxWalletQuota
+ }
+ minTopup = quota
}
return int64(minTopup)
}
@@ -195,7 +199,7 @@ func getTopUpQuota(amount int64) (int, error) {
} else {
quota = quota.Mul(decimal.NewFromFloat(common.QuotaPerUnit))
}
- return common.QuotaFromDecimalStrict(quota)
+ return common.WalletQuotaFromDecimalStrict(quota)
}
func getMaxTopUpAmount() int64 {
@@ -203,7 +207,7 @@ func getMaxTopUpAmount() int64 {
return 0
}
quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
- maxStoredAmount := decimal.NewFromInt(common.MaxQuota - 1).
+ maxStoredAmount := decimal.NewFromInt(common.MaxWalletQuota).
Div(quotaPerUnit).
Floor()
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
@@ -217,7 +221,7 @@ func getMaxTopUpAmount() int64 {
}
func validateCreditedQuota(quota decimal.Decimal) (int, error) {
- value, err := common.QuotaFromDecimalStrict(quota)
+ value, err := common.WalletQuotaFromDecimalStrict(quota)
if err != nil {
return 0, errors.New("充值额度超出系统可表示范围")
}
diff --git a/controller/topup_quota_limit_test.go b/controller/topup_quota_limit_test.go
index 5c291deaaa32..710e87a31556 100644
--- a/controller/topup_quota_limit_test.go
+++ b/controller/topup_quota_limit_test.go
@@ -1,6 +1,7 @@
package controller
import (
+ "fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -43,19 +44,19 @@ func TestTopUpQuotaValidation(t *testing.T) {
name: "currency amount above limit",
displayType: operation_setting.QuotaDisplayTypeUSD,
amount: 4295,
- wantErr: true,
+ wantQuota: 2_147_500_000,
},
{
name: "token amount preserves settlement truncation",
displayType: operation_setting.QuotaDisplayTypeTokens,
- amount: common.MaxQuota,
- wantQuota: 2_147_000_000,
+ amount: 2_147_500_000,
+ wantQuota: 2_147_500_000,
},
{
- name: "token amount above settlement limit",
+ name: "token amount above legacy int32 range",
displayType: operation_setting.QuotaDisplayTypeTokens,
- amount: 2_147_500_000,
- wantErr: true,
+ amount: 4_294_500_000,
+ wantQuota: 4_294_500_000,
},
}
@@ -83,14 +84,14 @@ func TestValidateTopUpQuotaReturnsMaximumAmount(t *testing.T) {
operation_setting.GetGeneralSetting().QuotaDisplayType = oldDisplayType
})
- maxAmount := decimal.NewFromInt(common.MaxQuota - 1).
+ maxAmount := decimal.NewFromInt(common.MaxWalletQuota).
Div(decimal.NewFromFloat(common.QuotaPerUnit)).
Floor().IntPart()
_, err := validateTopUpQuota(maxAmount)
require.NoError(t, err)
_, err = validateTopUpQuota(maxAmount + 1)
- require.EqualError(t, err, "单笔充值数量不能大于 4294")
+ require.EqualError(t, err, fmt.Sprintf("单笔充值数量不能大于 %d", maxAmount))
}
func TestRequestAmountRejectsTopUpThatCannotBeSettled(t *testing.T) {
@@ -106,17 +107,20 @@ func TestRequestAmountRejectsTopUpThatCannotBeSettled(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
+ maxAmount := decimal.NewFromInt(common.MaxWalletQuota).
+ Div(decimal.NewFromFloat(common.QuotaPerUnit)).
+ Floor().IntPart()
ctx.Request = httptest.NewRequest(
http.MethodPost,
"/api/user/amount",
- strings.NewReader(`{"amount":4295}`),
+ strings.NewReader(fmt.Sprintf(`{"amount":%d}`, maxAmount+1)),
)
ctx.Request.Header.Set("Content-Type", "application/json")
RequestAmount(ctx)
assert.Equal(t, http.StatusOK, recorder.Code)
- assert.JSONEq(t, `{"message":"error","data":"单笔充值数量不能大于 4294"}`, recorder.Body.String())
+ assert.JSONEq(t, fmt.Sprintf(`{"message":"error","data":"单笔充值数量不能大于 %d"}`, maxAmount), recorder.Body.String())
}
func TestRequestAmountRejectsTopUpThatWouldOverflowWallet(t *testing.T) {
@@ -143,7 +147,7 @@ func TestRequestAmountRejectsTopUpThatWouldOverflowWallet(t *testing.T) {
require.NoError(t, model.DB.Create(&model.User{
Id: 42,
Username: "topup_capacity_user",
- Quota: 1_000_000,
+ Quota: common.MaxWalletQuota - 100_000,
Status: common.UserStatusEnabled,
}).Error)
@@ -154,7 +158,7 @@ func TestRequestAmountRejectsTopUpThatWouldOverflowWallet(t *testing.T) {
ctx.Request = httptest.NewRequest(
http.MethodPost,
"/api/user/amount",
- strings.NewReader(`{"amount":4294}`),
+ strings.NewReader(`{"amount":1}`),
)
ctx.Request.Header.Set("Content-Type", "application/json")
@@ -165,11 +169,11 @@ func TestRequestAmountRejectsTopUpThatWouldOverflowWallet(t *testing.T) {
}
func TestValidateCreditedQuotaRejectsOverflow(t *testing.T) {
- _, err := validateCreditedQuota(decimal.NewFromInt(common.MaxQuota - 1))
+ _, err := validateCreditedQuota(decimal.NewFromInt(int64(common.MaxWalletQuota / 2)))
require.NoError(t, err)
_, err = validateCreditedQuota(decimal.Zero)
require.EqualError(t, err, "充值额度必须大于 0")
- _, err = validateCreditedQuota(decimal.NewFromInt(common.MaxQuota))
+ _, err = validateCreditedQuota(decimal.NewFromInt(common.MaxWalletQuota + 1))
require.EqualError(
t,
err,
@@ -190,6 +194,8 @@ func TestStripeCreditedQuotaIncludesGroupRatio(t *testing.T) {
_, err := validateCreditedQuota(getStripeCreditedQuota(2147, "vip"))
require.NoError(t, err)
_, err = validateCreditedQuota(getStripeCreditedQuota(2148, "vip"))
+ require.NoError(t, err)
+ _, err = validateCreditedQuota(getStripeCreditedQuota(int64(common.MaxWalletQuota), "vip"))
require.Error(t, err)
require.NoError(t, common.UpdateTopupGroupRatioByJSONString(`{"free":0}`))
diff --git a/controller/user.go b/controller/user.go
index 9b8d931ec1f8..7020f1c6a369 100644
--- a/controller/user.go
+++ b/controller/user.go
@@ -1167,6 +1167,10 @@ func ManageUser(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
return
}
+ if err := common.ValidateWalletQuota(req.Value); err != nil {
+ common.ApiError(c, err)
+ return
+ }
if err := model.IncreaseUserQuota(user.Id, req.Value, true); err != nil {
common.ApiError(c, err)
return
@@ -1187,6 +1191,10 @@ func ManageUser(c *gin.Context) {
"quota": logger.LogQuota(req.Value),
})
case "override":
+ if err := common.ValidateWalletQuota(req.Value); err != nil {
+ common.ApiError(c, err)
+ return
+ }
oldQuota := user.Quota
if err := model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("quota", req.Value).Error; err != nil {
common.ApiError(c, err)
diff --git a/controller/user_manage_test.go b/controller/user_manage_test.go
index 1b52ece08835..985640786308 100644
--- a/controller/user_manage_test.go
+++ b/controller/user_manage_test.go
@@ -159,3 +159,24 @@ func TestManageUserDeleteReturnsImmediatelyAndUnknownActionFails(t *testing.T) {
assert.EqualValues(t, 1, unchanged.AuthVersion)
assert.Equal(t, common.UserStatusEnabled, unchanged.Status)
}
+
+func TestManageUserQuotaRespectsWalletCeiling(t *testing.T) {
+ db := setupManageUserTestDB(t)
+ user := model.User{
+ Username: "managed-quota-user", Password: "password", Role: common.RoleCommonUser,
+ Status: common.UserStatusEnabled, Group: "default", Quota: common.MaxWalletQuota - 1,
+ }
+ require.NoError(t, db.Create(&user).Error)
+
+ recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"add_quota","mode":"add","value":2}`, user.Id))
+ assert.Contains(t, recorder.Body.String(), `"success":false`)
+
+ var updated model.User
+ require.NoError(t, db.First(&updated, user.Id).Error)
+ assert.Equal(t, common.MaxWalletQuota-1, updated.Quota)
+
+ recorder = performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"add_quota","mode":"override","value":%d}`, user.Id, common.MaxWalletQuota+1))
+ assert.Contains(t, recorder.Body.String(), `"success":false`)
+ require.NoError(t, db.First(&updated, user.Id).Error)
+ assert.Equal(t, common.MaxWalletQuota-1, updated.Quota)
+}
diff --git a/middleware/model-rate-limit.go b/middleware/model-rate-limit.go
index 9f1d94039685..8cf0ea452869 100644
--- a/middleware/model-rate-limit.go
+++ b/middleware/model-rate-limit.go
@@ -3,6 +3,7 @@ package middleware
import (
"context"
"fmt"
+ "math"
"net/http"
"strconv"
"time"
@@ -103,7 +104,7 @@ func redisRateLimitHandler(duration int64, totalMaxCount, successMaxCount int) g
allowed, err = tb.Allow(
ctx,
totalKey,
- limiter.WithCapacity(int64(totalMaxCount)*duration),
+ limiter.WithCapacity(rateLimitCapacity(totalMaxCount, duration)),
limiter.WithRate(int64(totalMaxCount)),
limiter.WithRequested(duration),
)
@@ -174,7 +175,7 @@ func ModelRequestRateLimit() func(c *gin.Context) {
}
// 计算限流参数
- duration := int64(setting.ModelRequestRateLimitDurationMinutes * 60)
+ duration := rateLimitDurationSeconds(setting.ModelRequestRateLimitDurationMinutes)
totalMaxCount := setting.ModelRequestRateLimitCount
successMaxCount := setting.ModelRequestRateLimitSuccessCount
@@ -199,3 +200,25 @@ func ModelRequestRateLimit() func(c *gin.Context) {
}
}
}
+
+func rateLimitDurationSeconds(durationMinutes int) int64 {
+ if durationMinutes <= 0 {
+ return 0
+ }
+ minutes := int64(durationMinutes)
+ if minutes > math.MaxInt64/60 {
+ return math.MaxInt64
+ }
+ return minutes * 60
+}
+
+func rateLimitCapacity(count int, durationSeconds int64) int64 {
+ if count <= 0 || durationSeconds <= 0 {
+ return 0
+ }
+ c := int64(count)
+ if c > math.MaxInt64/durationSeconds {
+ return math.MaxInt64
+ }
+ return c * durationSeconds
+}
diff --git a/model/main.go b/model/main.go
index 21445593e54e..cd1569db31eb 100644
--- a/model/main.go
+++ b/model/main.go
@@ -186,6 +186,9 @@ func InitDB() (err error) {
panic(err)
}
}
+ if err := ensureUserQuotaColumns(DB, common.MainDatabaseType()); err != nil {
+ return err
+ }
sqlDB, err := DB.DB()
if err != nil {
return err
@@ -250,6 +253,52 @@ func InitLogDB() (err error) {
return err
}
+var userQuotaColumns = []string{"quota", "used_quota", "aff_quota", "aff_history"}
+
+// ensureUserQuotaColumns rejects a legacy 32-bit wallet schema before any
+// migrations run. The 64-bit-only build intentionally does not auto-upgrade
+// an existing wallet; operators must migrate it explicitly before starting.
+func ensureUserQuotaColumns(db *gorm.DB, dbType common.DatabaseType) error {
+ if common.GetEnvOrDefaultBool("SKIP_64BIT_QUOTA_SCHEMA_CHECK", false) {
+ common.SysLog("SKIP_64BIT_QUOTA_SCHEMA_CHECK=true; skipping user quota schema check")
+ return nil
+ }
+ if db == nil || dbType == common.DatabaseTypeSQLite {
+ return nil
+ }
+ if !db.Migrator().HasTable(&User{}) {
+ return nil
+ }
+ columnTypes, err := db.Migrator().ColumnTypes(&User{})
+ if err != nil {
+ return fmt.Errorf("failed to inspect users schema: %w", err)
+ }
+ for _, expected := range userQuotaColumns {
+ for _, actual := range columnTypes {
+ if !strings.EqualFold(actual.Name(), expected) {
+ continue
+ }
+ dataType := actual.DatabaseTypeName()
+ if !is64BitIntegerType(dbType, dataType) {
+ return fmt.Errorf("users.%s uses %s; 32-bit is not supported", expected, dataType)
+ }
+ }
+ }
+ return nil
+}
+
+func is64BitIntegerType(dbType common.DatabaseType, dataType string) bool {
+ normalized := strings.ToLower(strings.TrimSpace(dataType))
+ switch dbType {
+ case common.DatabaseTypeMySQL:
+ return normalized == "bigint" || normalized == "unsigned bigint" || normalized == "bigint unsigned"
+ case common.DatabaseTypePostgreSQL:
+ return normalized == "bigint" || normalized == "int8"
+ default:
+ return false
+ }
+}
+
func migrateDB() error {
// Migrate price_amount column from float/double to decimal for existing tables
migrateSubscriptionPlanPriceAmount()
diff --git a/model/payment_method_guard_test.go b/model/payment_method_guard_test.go
index 33da6cfa8935..80da9e3edfa1 100644
--- a/model/payment_method_guard_test.go
+++ b/model/payment_method_guard_test.go
@@ -297,7 +297,7 @@ func TestRechargeEpayRejectsQuotaOverflowBeforeCompletingOrder(t *testing.T) {
truncateTables(t)
oldQuotaPerUnit := common.QuotaPerUnit
- common.QuotaPerUnit = float64(common.MaxQuota)
+ common.QuotaPerUnit = float64(common.MaxWalletQuota + 1)
t.Cleanup(func() { common.QuotaPerUnit = oldQuotaPerUnit })
user := insertUserForPaymentGuardTest(t, 505, 3)
@@ -323,15 +323,15 @@ func TestRechargeEpayEnforcesFinalWalletQuotaLimit(t *testing.T) {
}{
{
name: "allows exact highest representable wallet balance",
- currentQuota: common.MaxQuota - 1 - 1_000_000,
- wantQuota: common.MaxQuota - 1,
+ currentQuota: common.MaxWalletQuota - 1_000_000,
+ wantQuota: common.MaxWalletQuota,
wantStatus: common.TopUpStatusSuccess,
},
{
- name: "rejects balance above int32 quota domain",
- currentQuota: common.MaxQuota - 1_000_000,
+ name: "rejects balance above wallet quota domain",
+ currentQuota: common.MaxWalletQuota - 999_999,
wantErr: true,
- wantQuota: common.MaxQuota - 1_000_000,
+ wantQuota: common.MaxWalletQuota - 999_999,
wantStatus: common.TopUpStatusPending,
},
}
diff --git a/model/quota_reserve_test.go b/model/quota_reserve_test.go
index 76eab83a15ef..865f73ed67ec 100644
--- a/model/quota_reserve_test.go
+++ b/model/quota_reserve_test.go
@@ -1,6 +1,7 @@
package model
import (
+ "math"
"testing"
"time"
@@ -137,6 +138,38 @@ func TestRedisBatchReserveNeverFallsBackToStaleDatabaseBalance(t *testing.T) {
assert.Equal(t, 7, reloadedToken.UsedQuota)
}
+func TestBatchUpdateAccumulatesTwoMaximumRequestCharges(t *testing.T) {
+ truncateTables(t)
+ resetBatchUpdateTestState(t)
+ common.BatchUpdateEnabled = true
+
+ user := createReserveTestUser(t, common.MaxQuota*2+100)
+ require.NoError(t, DecreaseUserQuota(user.Id, common.MaxQuota, false))
+ require.NoError(t, DecreaseUserQuota(user.Id, common.MaxQuota, false))
+
+ batchUpdate()
+ assert.Equal(t, 100, getUserQuotaFromDB(t, user.Id))
+}
+
+func TestBatchUpdateAccumulatorSaturatesOverflow(t *testing.T) {
+ resetBatchUpdateTestState(t)
+
+ addNewRecord(BatchUpdateTypeUserQuota, 1, math.MaxInt)
+ addNewRecord(BatchUpdateTypeUserQuota, 1, 1)
+ batchUpdateLocks[BatchUpdateTypeUserQuota].Lock()
+ assert.Equal(t, math.MaxInt, batchUpdateStores[BatchUpdateTypeUserQuota][1])
+ batchUpdateLocks[BatchUpdateTypeUserQuota].Unlock()
+
+ batchUpdateLocks[BatchUpdateTypeUserQuota].Lock()
+ batchUpdateStores[BatchUpdateTypeUserQuota] = make(map[int]int)
+ batchUpdateLocks[BatchUpdateTypeUserQuota].Unlock()
+ addNewRecord(BatchUpdateTypeUserQuota, 1, math.MinInt)
+ addNewRecord(BatchUpdateTypeUserQuota, 1, -1)
+ batchUpdateLocks[BatchUpdateTypeUserQuota].Lock()
+ assert.Equal(t, math.MinInt, batchUpdateStores[BatchUpdateTypeUserQuota][1])
+ batchUpdateLocks[BatchUpdateTypeUserQuota].Unlock()
+}
+
func TestReserveFallsBackToDatabaseWhenRedisIsUnavailable(t *testing.T) {
truncateTables(t)
resetBatchUpdateTestState(t)
diff --git a/model/redemption.go b/model/redemption.go
index a7751d90981e..54750d507431 100644
--- a/model/redemption.go
+++ b/model/redemption.go
@@ -175,7 +175,7 @@ func Redeem(key string, userId int) (quota int, err error) {
if result.RowsAffected == 0 {
return errors.New("该兑换码已被使用")
}
- return tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
+ return creditTopUpQuota(tx, userId, redemption.Quota, nil)
})
if err != nil {
common.SysError("redemption failed: " + err.Error())
@@ -187,6 +187,12 @@ func Redeem(key string, userId int) (quota int, err error) {
}
func (redemption *Redemption) Insert() error {
+ if redemption.Quota <= 0 {
+ return errors.New("redemption quota must be positive")
+ }
+ if err := common.ValidateWalletQuota(redemption.Quota); err != nil {
+ return err
+ }
var err error
err = DB.Create(redemption).Error
return err
@@ -199,6 +205,12 @@ func (redemption *Redemption) SelectUpdate() error {
// Update Make sure your token's fields is completed, because this will update non-zero values
func (redemption *Redemption) Update() error {
+ if redemption.Quota <= 0 {
+ return errors.New("redemption quota must be positive")
+ }
+ if err := common.ValidateWalletQuota(redemption.Quota); err != nil {
+ return err
+ }
var err error
err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time", "expired_time").Updates(redemption).Error
return err
diff --git a/model/redemption_test.go b/model/redemption_test.go
index 0ba2e8e8e39f..0150fc19e395 100644
--- a/model/redemption_test.go
+++ b/model/redemption_test.go
@@ -148,6 +148,35 @@ func TestRedeemCreditsQuotaExactlyOnce(t *testing.T) {
assert.Equal(t, 500, user.Quota)
}
+func TestRedeemRejectsWalletOverflow(t *testing.T) {
+ userId, key := setupRedeemFixture(t, 11)
+ require.NoError(t, DB.Model(&User{}).Where("id = ?", userId).Update("quota", common.MaxWalletQuota-10).Error)
+
+ _, err := Redeem(key, userId)
+ require.ErrorIs(t, err, ErrRedeemFailed)
+
+ var user User
+ require.NoError(t, DB.First(&user, "id = ?", userId).Error)
+ assert.Equal(t, common.MaxWalletQuota-10, user.Quota)
+
+ var redemption Redemption
+ require.NoError(t, DB.First(&redemption, "key = ?", key).Error)
+ assert.Equal(t, common.RedemptionCodeStatusEnabled, redemption.Status)
+}
+
+func TestRedemptionQuotaRejectsWalletOverflow(t *testing.T) {
+ setupRedeemFixture(t, 500)
+
+ redemption := &Redemption{
+ Name: "overflow-redemption",
+ Key: "10000000000000000000000000000002",
+ Status: common.RedemptionCodeStatusEnabled,
+ Quota: common.MaxWalletQuota + 1,
+ CreatedTime: common.GetTimestamp(),
+ }
+ require.Error(t, redemption.Insert())
+}
+
// Exactly one of several concurrent redeems of the same code may win, and
// quota must be credited exactly once.
func TestRedeemConcurrentSingleSuccess(t *testing.T) {
diff --git a/model/subscription.go b/model/subscription.go
index c89e63bf0532..769d7b94a898 100644
--- a/model/subscription.go
+++ b/model/subscription.go
@@ -749,7 +749,7 @@ func calcSubscriptionBalanceQuota(priceAmount float64) (int, error) {
quota := decimal.NewFromFloat(priceAmount).
Mul(decimal.NewFromFloat(common.QuotaPerUnit)).
Ceil()
- return common.QuotaFromDecimalStrict(quota)
+ return common.WalletQuotaFromDecimalStrict(quota)
}
// PurchaseSubscriptionWithBalance creates a subscription by deducting the user's wallet quota.
diff --git a/model/topup.go b/model/topup.go
index d837ced2f818..a8b91b77500e 100644
--- a/model/topup.go
+++ b/model/topup.go
@@ -42,11 +42,12 @@ const (
)
var (
- ErrPaymentMethodMismatch = errors.New("payment method mismatch")
- ErrTopUpNotFound = errors.New("topup not found")
- ErrTopUpStatusInvalid = errors.New("topup status invalid")
- ErrInvalidTopUpQuota = errors.New("invalid top-up quota")
- ErrTopUpQuotaLimitExceeded = errors.New("top-up quota limit exceeded")
+ ErrPaymentMethodMismatch = errors.New("payment method mismatch")
+ ErrTopUpNotFound = errors.New("topup not found")
+ ErrTopUpStatusInvalid = errors.New("topup status invalid")
+ ErrInvalidTopUpQuota = errors.New("invalid top-up quota")
+ ErrTopUpQuotaLimitExceeded = errors.New("top-up quota limit exceeded")
+ ErrWalletQuotaLimitExceeded = errors.New("wallet quota limit exceeded")
)
func (topUp *TopUp) Insert() error {
@@ -56,10 +57,10 @@ func (topUp *TopUp) Insert() error {
}
func topUpQuotaMaxCurrent(creditedQuota int) (int, error) {
- if creditedQuota <= 0 || creditedQuota >= common.MaxQuota {
+ if creditedQuota <= 0 || creditedQuota > common.MaxWalletQuota {
return 0, ErrInvalidTopUpQuota
}
- return common.MaxQuota - 1 - creditedQuota, nil
+ return common.MaxWalletQuota - creditedQuota, nil
}
// ValidateTopUpQuotaCapacity performs the user-facing pre-payment check. The
@@ -81,8 +82,8 @@ func ValidateTopUpQuotaCapacity(userId int, creditedQuota int) error {
return nil
}
-// creditTopUpQuota atomically enforces the int32 wallet ceiling while adding
-// quota. Keeping the predicate and increment in one UPDATE prevents two
+// creditTopUpQuota atomically enforces the wallet ceiling while adding quota.
+// Keeping the predicate and increment in one UPDATE prevents two
// concurrent callbacks from both passing a separate read/check.
func creditTopUpQuota(tx *gorm.DB, userId int, creditedQuota int, updates map[string]interface{}) error {
maxCurrentQuota, err := topUpQuotaMaxCurrent(creditedQuota)
@@ -203,7 +204,7 @@ func RechargeEpay(tradeNo string, actualPaymentMethod string, callerIp string) (
topUp.PaymentMethod = actualPaymentMethod
}
var quotaErr error
- quotaToAdd, quotaErr = common.QuotaFromDecimalStrict(
+ quotaToAdd, quotaErr = common.WalletQuotaFromDecimalStrict(
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
if quotaErr != nil || quotaToAdd <= 0 {
@@ -266,7 +267,7 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error
return err
}
- quota, err = common.QuotaFromDecimalStrict(
+ quota, err = common.WalletQuotaFromDecimalStrict(
decimal.NewFromFloat(topUp.Money).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
if err != nil || quota <= 0 {
@@ -482,11 +483,11 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error {
// - 其他订单(如易支付):Amount 为美元数量,* QuotaPerUnit
var quotaErr error
if topUp.PaymentProvider == PaymentProviderStripe {
- quotaToAdd, quotaErr = common.QuotaFromDecimalStrict(
+ quotaToAdd, quotaErr = common.WalletQuotaFromDecimalStrict(
decimal.NewFromFloat(topUp.Money).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
} else {
- quotaToAdd, quotaErr = common.QuotaFromDecimalStrict(
+ quotaToAdd, quotaErr = common.WalletQuotaFromDecimalStrict(
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
}
@@ -556,7 +557,7 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
}
// Creem 直接使用 Amount 作为充值额度(整数)
- quota, err = common.QuotaFromDecimalStrict(decimal.NewFromInt(topUp.Amount))
+ quota, err = common.WalletQuotaFromDecimalStrict(decimal.NewFromInt(topUp.Amount))
if err != nil || quota <= 0 {
return ErrInvalidTopUpQuota
}
@@ -624,7 +625,7 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) {
return errors.New("充值订单状态错误")
}
- quotaToAdd, err = common.QuotaFromDecimalStrict(
+ quotaToAdd, err = common.WalletQuotaFromDecimalStrict(
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
if err != nil || quotaToAdd <= 0 {
@@ -684,7 +685,7 @@ func RechargeWaffoPancake(tradeNo string) (err error) {
return errors.New("充值订单状态错误")
}
- quotaToAdd, err = common.QuotaFromDecimalStrict(
+ quotaToAdd, err = common.WalletQuotaFromDecimalStrict(
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
if err != nil || quotaToAdd <= 0 {
diff --git a/model/user.go b/model/user.go
index 7bc060ad1bf8..1fbaa34ce123 100644
--- a/model/user.go
+++ b/model/user.go
@@ -1271,25 +1271,47 @@ func IncreaseUserQuota(id int, quota int, db bool) (err error) {
if quota < 0 {
return errors.New("quota 不能为负数!")
}
- gopool.Go(func() {
- err := cacheIncrUserQuota(id, int64(quota))
- if err != nil {
- common.SysLog("failed to increase user quota: " + err.Error())
- }
- })
+ if err := common.ValidateWalletQuota(quota); err != nil {
+ return err
+ }
if !db && common.BatchUpdateEnabled {
addNewRecord(BatchUpdateTypeUserQuota, id, quota)
+ gopool.Go(func() {
+ if err := cacheIncrUserQuota(id, int64(quota)); err != nil {
+ common.SysLog("failed to increase user quota: " + err.Error())
+ }
+ })
return nil
}
- return increaseUserQuota(id, quota)
+ if err := increaseUserQuota(id, quota); err != nil {
+ return err
+ }
+ gopool.Go(func() {
+ if err := cacheIncrUserQuota(id, int64(quota)); err != nil {
+ common.SysLog("failed to increase user quota: " + err.Error())
+ }
+ })
+ return nil
}
func increaseUserQuota(id int, quota int) (err error) {
- err = DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota + ?", quota)).Error
- if err != nil {
+ result := DB.Model(&User{}).
+ Where("id = ? AND quota <= ?", id, common.MaxWalletQuota-quota).
+ Update("quota", gorm.Expr("quota + ?", quota))
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected == 1 {
+ return nil
+ }
+ var count int64
+ if err := DB.Model(&User{}).Where("id = ?", id).Count(&count).Error; err != nil {
return err
}
- return err
+ if count == 0 {
+ return gorm.ErrRecordNotFound
+ }
+ return ErrWalletQuotaLimitExceeded
}
func DecreaseUserQuota(id int, quota int, db bool) (err error) {
diff --git a/model/utils.go b/model/utils.go
index b17937064938..63d51c4b88f3 100644
--- a/model/utils.go
+++ b/model/utils.go
@@ -2,6 +2,8 @@ package model
import (
"errors"
+ "fmt"
+ "math"
"sync"
"time"
@@ -42,11 +44,22 @@ func InitBatchUpdater() {
func addNewRecord(type_ int, id int, value int) {
batchUpdateLocks[type_].Lock()
defer batchUpdateLocks[type_].Unlock()
- if _, ok := batchUpdateStores[type_][id]; !ok {
+ old, ok := batchUpdateStores[type_][id]
+ if !ok {
batchUpdateStores[type_][id] = value
- } else {
- batchUpdateStores[type_][id] += value
+ return
+ }
+
+ sum := old + value
+ if (value > 0 && sum < old) || (value < 0 && sum > old) {
+ common.SysError(fmt.Sprintf("batch update overflow: type=%d id=%d old=%d value=%d", type_, id, old, value))
+ if value > 0 {
+ sum = math.MaxInt
+ } else {
+ sum = math.MinInt
+ }
}
+ batchUpdateStores[type_][id] = sum
}
func batchUpdate() {
diff --git a/pkg/billingexpr/billingexpr_test.go b/pkg/billingexpr/billingexpr_test.go
index 90485571a5fb..d34bdcbfdf5a 100644
--- a/pkg/billingexpr/billingexpr_test.go
+++ b/pkg/billingexpr/billingexpr_test.go
@@ -4,6 +4,7 @@ import (
"math"
"testing"
+ "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/pkg/billingexpr"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -344,9 +345,9 @@ func TestQuotaRound(t *testing.T) {
{999.4999, 999},
{999.5, 1000},
{1e9 + 0.5, 1e9 + 1},
- // Oversized expression results saturate at int32 (delegated to
+ // Oversized expression results saturate at the single-request limit (delegated to
// common.QuotaRound); full saturation coverage lives in common.
- {3.6893488147419103e19, math.MaxInt32},
+ {3.6893488147419103e19, common.MaxQuota},
}
for _, tt := range tests {
got := billingexpr.QuotaRound(tt.in)
diff --git a/pkg/billingexpr/settle_clamp_test.go b/pkg/billingexpr/settle_clamp_test.go
index 4d765b23e23d..2d082311bc59 100644
--- a/pkg/billingexpr/settle_clamp_test.go
+++ b/pkg/billingexpr/settle_clamp_test.go
@@ -1,7 +1,6 @@
package billingexpr_test
import (
- "math"
"testing"
"github.com/QuantumNous/new-api/common"
@@ -11,13 +10,13 @@ import (
)
// TestComputeTieredQuota_ClampOnOverflow guards the billing-safety invariant
-// that an oversized tiered settlement clamps to the int32 max instead of
+// that an oversized tiered settlement clamps to the single-request max instead of
// wrapping into a credit, and that the saturation event is surfaced on the
// result so callers can record it for admin auditing.
func TestComputeTieredQuota_ClampOnOverflow(t *testing.T) {
- // exprOutput = p * 1e9 = 1e18; quotaBeforeGroup = 1e18 / 1e6 * 5e5 = 5e17,
- // which far exceeds MaxInt32 and must saturate.
- exprStr := `tier("base", p * 1000000000)`
+ // exprOutput = p * 1e12 = 1e21; quotaBeforeGroup = 1e21 / 1e6 * 5e5 = 5e20,
+ // which far exceeds the supported single-request range and must saturate.
+ exprStr := `tier("base", p * 1000000000000)`
snap := &billingexpr.BillingSnapshot{
BillingMode: "tiered_expr",
ExprString: exprStr,
@@ -29,10 +28,10 @@ func TestComputeTieredQuota_ClampOnOverflow(t *testing.T) {
result, err := billingexpr.ComputeTieredQuota(snap, billingexpr.TokenParams{P: 1_000_000_000})
require.NoError(t, err)
- assert.Equal(t, math.MaxInt32, result.ActualQuotaAfterGroup, "oversized quota must clamp to int32 max, never wrap negative")
+ assert.Equal(t, common.MaxQuota, result.ActualQuotaAfterGroup, "oversized quota must clamp, never wrap negative")
require.NotNil(t, result.Clamp, "clamp event must be surfaced so it can be audited")
assert.Equal(t, common.QuotaClampOverflow, result.Clamp.Kind)
- assert.Equal(t, math.MaxInt32, result.Clamp.Clamped)
+ assert.Equal(t, common.MaxQuota, result.Clamp.Clamped)
}
// TestComputeTieredQuota_NoClampInRange confirms an in-range settlement leaves
diff --git a/pkg/billingexpr/types.go b/pkg/billingexpr/types.go
index 1bdf6834bd57..a67119036c47 100644
--- a/pkg/billingexpr/types.go
+++ b/pkg/billingexpr/types.go
@@ -68,7 +68,7 @@ type TieredResult struct {
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
+ // Clamp records a single-request 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
// the shared quota-saturation audit path.
diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go
index b0bb19bdca3b..56f572343345 100644
--- a/relay/common/relay_info.go
+++ b/relay/common/relay_info.go
@@ -152,7 +152,7 @@ type RelayInfo struct {
PriceData hosttypes.PriceData
// QuotaClamp is set (non-nil) when a quota conversion saturated at the
- // int32 bound (or NaN fallback) while computing this request's charge.
+ // supported single-request bound (or NaN fallback) while computing this request's charge.
// It is surfaced onto the consume/task log's admin_info for auditing.
QuotaClamp *common.QuotaClamp
diff --git a/relay/helper/price_test.go b/relay/helper/price_test.go
index 0f28b5a424c5..ca38b54829ad 100644
--- a/relay/helper/price_test.go
+++ b/relay/helper/price_test.go
@@ -156,7 +156,7 @@ func TestModelPriceHelperTieredRejectsPreConsumeOverflow(t *testing.T) {
require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
"billing_setting.billing_mode": `{"tiered-overflow-model":"tiered_expr"}`,
- "billing_setting.billing_expr": `{"tiered-overflow-model":"tier(\"overflow\", p * 1000000000000000)"}`,
+ "billing_setting.billing_expr": `{"tiered-overflow-model":"tier(\"overflow\", p * 100000000000000000)"}`,
"group_ratio_setting.group_ratio": `{"default":1}`,
}))
diff --git a/service/quota.go b/service/quota.go
index 359956a51f2d..3639ee5f43fc 100644
--- a/service/quota.go
+++ b/service/quota.go
@@ -3,7 +3,6 @@ package service
import (
"errors"
"fmt"
- "math"
"strings"
"time"
@@ -272,11 +271,16 @@ func CalcOpenRouterCacheCreateTokens(usage dto.Usage, priceData types.PriceData)
completionTokens := float64(usage.CompletionTokens)
promptCacheReadTokens := float64(usage.PromptTokensDetails.CachedTokens)
- return int(math.Round((cost -
+ value := (cost -
totalPromptTokens*quotaPrice +
promptCacheReadTokens*(quotaPrice-promptCacheReadPrice) -
completionTokens*completionPrice) /
- (promptCacheCreatePrice - quotaPrice)))
+ (promptCacheCreatePrice - quotaPrice)
+ quota, clamp := common.QuotaRoundChecked(value)
+ if clamp != nil {
+ return -1
+ }
+ return quota
}
func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent string) {
diff --git a/service/quota_saturation_test.go b/service/quota_saturation_test.go
index e8cd55c25e82..da4ff3504f43 100644
--- a/service/quota_saturation_test.go
+++ b/service/quota_saturation_test.go
@@ -1,12 +1,15 @@
package service
import (
+ "math"
"net/http"
"testing"
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
+ hosttypes "github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
@@ -42,6 +45,28 @@ func TestAttachQuotaSaturationNestsUnderAdminInfo(t *testing.T) {
require.Equal(t, common.MaxQuota, sat["clamped"])
}
+func TestCalcViolationFeeQuotaSaturates(t *testing.T) {
+ oldQuotaPerUnit := common.QuotaPerUnit
+ common.QuotaPerUnit = 500_000
+ t.Cleanup(func() { common.QuotaPerUnit = oldQuotaPerUnit })
+
+ require.Equal(t, common.MaxQuota, calcViolationFeeQuota(1e20, 1))
+}
+
+func TestCalcOpenRouterCacheCreateTokensDoesNotWrap(t *testing.T) {
+ oldQuotaPerUnit := common.QuotaPerUnit
+ common.QuotaPerUnit = 500_000
+ t.Cleanup(func() { common.QuotaPerUnit = oldQuotaPerUnit })
+
+ got := CalcOpenRouterCacheCreateTokens(dto.Usage{Cost: math.Inf(1)}, hosttypes.PriceData{
+ ModelRatio: 1,
+ CacheCreationRatio: 2,
+ CacheRatio: 1,
+ CompletionRatio: 1,
+ })
+ require.Equal(t, -1, got)
+}
+
// TestAttachQuotaSaturationPreservesExistingAdminInfo verifies the marker is
// merged into a pre-existing admin_info map without clobbering it.
func TestAttachQuotaSaturationPreservesExistingAdminInfo(t *testing.T) {
diff --git a/service/text_quota.go b/service/text_quota.go
index b7578f732786..19f0e9463a83 100644
--- a/service/text_quota.go
+++ b/service/text_quota.go
@@ -216,8 +216,8 @@ func composeTieredTextQuota(relayInfo *relaycommon.RelayInfo, summary textQuotaS
}
// Saturate the final sum, not just the surcharge: tieredQuota can be near
- // MaxQuota and adding the surcharge could push the total past the int32
- // quota policy bound (persisted quota columns are 32-bit).
+ // MaxQuota and adding the surcharge could push the total past the
+ // single-request quota policy bound.
total, clamp := common.QuotaFromDecimalChecked(
decimal.NewFromInt(int64(tieredQuota)).Add(summary.ToolCallSurchargeQuota),
)
diff --git a/service/text_quota_test.go b/service/text_quota_test.go
index e4a1ed68cf5d..9e935b4060ff 100644
--- a/service/text_quota_test.go
+++ b/service/text_quota_test.go
@@ -774,9 +774,9 @@ func TestComposeTieredTextQuotaErrorFallbackUsesPreConsumedQuota(t *testing.T) {
// settlement both saturates the quota and records the clamp on RelayInfo, so
// every consume path (text, audio, WSS) can surface it under admin_info.
func TestTryTieredSettleRecordsClampOnOverflow(t *testing.T) {
- // exprOutput = p * 1e9; quotaBeforeGroup = p*1e9 / 1e6 * 5e5 far exceeds
- // MaxInt32 and must saturate.
- exprStr := `tier("base", p * 1000000000)`
+ // exprOutput = p * 1e12; quotaBeforeGroup = p*1e12 / 1e6 * 5e5 far exceeds
+ // the supported single-request range and must saturate.
+ exprStr := `tier("base", p * 1000000000000)`
relayInfo := &relaycommon.RelayInfo{
OriginModelName: "overflow-model",
TieredBillingSnapshot: &billingexpr.BillingSnapshot{
@@ -792,7 +792,7 @@ func TestTryTieredSettleRecordsClampOnOverflow(t *testing.T) {
require.True(t, ok)
require.NotNil(t, result)
- require.Equal(t, math.MaxInt32, quota, "oversized settlement must clamp, never wrap negative")
+ require.Equal(t, common.MaxQuota, quota, "oversized settlement must clamp, never wrap negative")
require.NotNil(t, relayInfo.QuotaClamp, "clamp must be recorded on RelayInfo for admin auditing")
require.Equal(t, common.QuotaClampOverflow, relayInfo.QuotaClamp.Kind)
}
diff --git a/service/tiered_settle.go b/service/tiered_settle.go
index 1f3f58fe31c1..0e9618ea59e0 100644
--- a/service/tiered_settle.go
+++ b/service/tiered_settle.go
@@ -180,7 +180,7 @@ func TryTieredSettle(relayInfo *relaycommon.RelayInfo, params billingexpr.TokenP
return true, quota, nil
}
- // Surface any int32 saturation from settlement onto RelayInfo so the
+ // Surface any single-request saturation from settlement onto RelayInfo so the
// consume log records it under admin_info, regardless of which caller
// (text, audio, WSS) consumes the returned quota. First non-nil wins.
noteQuotaClamp(relayInfo, tr.Clamp)
diff --git a/service/token_counter.go b/service/token_counter.go
index aad320a16773..3b0b5cd1c819 100644
--- a/service/token_counter.go
+++ b/service/token_counter.go
@@ -140,11 +140,11 @@ func getImageToken(c *gin.Context, fileMeta *types.FileMeta, model string, strea
if imageTokens > 1536 {
imageTokens = 1536
}
- return int(math.Round(float64(imageTokens) * multiplier)), nil
+ return common.QuotaRound(float64(imageTokens) * multiplier), nil
}
// below cap
imageTokens := rawPatches
- return int(math.Round(float64(imageTokens) * multiplier)), nil
+ return common.QuotaRound(float64(imageTokens) * multiplier), nil
}
// Tile-based calculation for 4o/4.1/4.5/o1/o3/etc.
diff --git a/service/violation_fee.go b/service/violation_fee.go
index f51533629d76..e063d4d8b408 100644
--- a/service/violation_fee.go
+++ b/service/violation_fee.go
@@ -88,15 +88,14 @@ func calcViolationFeeQuota(amount, groupRatio float64) int {
if groupRatio <= 0 {
return 0
}
- quota := decimal.NewFromFloat(amount).
+ quota := common.QuotaFromDecimal(decimal.NewFromFloat(amount).
Mul(decimal.NewFromFloat(common.QuotaPerUnit)).
Mul(decimal.NewFromFloat(groupRatio)).
- Round(0).
- IntPart()
+ Round(0))
if quota <= 0 {
return 0
}
- return int(quota)
+ return quota
}
// ChargeViolationFeeIfNeeded charges an additional fee after the normal flow finishes (including refund).
diff --git a/setting/rate_limit.go b/setting/rate_limit.go
index 413f3958d759..d046ae18521d 100644
--- a/setting/rate_limit.go
+++ b/setting/rate_limit.go
@@ -1,7 +1,6 @@
package setting
import (
- "encoding/json"
"fmt"
"math"
"sync"
@@ -9,6 +8,16 @@ import (
"github.com/QuantumNous/new-api/common"
)
+// maxRateLimitDurationSeconds is the largest window the count cap is computed
+// against (24h). Token-bucket capacity is count*duration; this keeps that
+// product inside int64 when the window is at most a day.
+const maxRateLimitDurationSeconds = 24 * 60 * 60
+
+// maxModelRequestRateLimitCount is math.MaxInt64 / maxRateLimitDurationSeconds.
+// It is the largest count that cannot overflow int64(count)*duration for a
+// window of at most 24 hours.
+const maxModelRequestRateLimitCount int64 = math.MaxInt64 / maxRateLimitDurationSeconds
+
var ModelRequestRateLimitEnabled = false
var ModelRequestRateLimitDurationMinutes = 1
var ModelRequestRateLimitCount = 0
@@ -20,7 +29,7 @@ func ModelRequestRateLimitGroup2JSONString() string {
ModelRequestRateLimitMutex.RLock()
defer ModelRequestRateLimitMutex.RUnlock()
- jsonBytes, err := json.Marshal(ModelRequestRateLimitGroup)
+ jsonBytes, err := common.Marshal(ModelRequestRateLimitGroup)
if err != nil {
common.SysLog("error marshalling model ratio: " + err.Error())
}
@@ -32,7 +41,7 @@ func UpdateModelRequestRateLimitGroupByJSONString(jsonStr string) error {
defer ModelRequestRateLimitMutex.RUnlock()
ModelRequestRateLimitGroup = make(map[string][2]int)
- return json.Unmarshal([]byte(jsonStr), &ModelRequestRateLimitGroup)
+ return common.Unmarshal([]byte(jsonStr), &ModelRequestRateLimitGroup)
}
func GetGroupRateLimit(group string) (totalCount, successCount int, found bool) {
@@ -52,7 +61,7 @@ func GetGroupRateLimit(group string) (totalCount, successCount int, found bool)
func CheckModelRequestRateLimitGroup(jsonStr string) error {
checkModelRequestRateLimitGroup := make(map[string][2]int)
- err := json.Unmarshal([]byte(jsonStr), &checkModelRequestRateLimitGroup)
+ err := common.Unmarshal([]byte(jsonStr), &checkModelRequestRateLimitGroup)
if err != nil {
return err
}
@@ -60,8 +69,8 @@ func CheckModelRequestRateLimitGroup(jsonStr string) error {
if limits[0] < 0 || limits[1] < 1 {
return fmt.Errorf("group %s has negative rate limit values: [%d, %d]", group, limits[0], limits[1])
}
- if limits[0] > math.MaxInt32 || limits[1] > math.MaxInt32 {
- return fmt.Errorf("group %s [%d, %d] has max rate limits value 2147483647", group, limits[0], limits[1])
+ if int64(limits[0]) > maxModelRequestRateLimitCount || int64(limits[1]) > maxModelRequestRateLimitCount {
+ return fmt.Errorf("group %s [%d, %d] exceeds max rate limit %d", group, limits[0], limits[1], maxModelRequestRateLimitCount)
}
}
diff --git a/web/src/features/usage-logs/types.ts b/web/src/features/usage-logs/types.ts
index 3e3789b9cbf3..f2af3155fa87 100644
--- a/web/src/features/usage-logs/types.ts
+++ b/web/src/features/usage-logs/types.ts
@@ -134,7 +134,7 @@ export interface LogOtherData {
admin_role?: number
auth_method?: 'session' | 'access_token' | string
// Quota saturation marker: set when a quota conversion clamped at the
- // int32 bound (overflow/underflow) or hit a NaN fallback while computing
+ // supported single-request bound (overflow/underflow) or hit a NaN fallback while computing
// this request's charge. Admin-only (nested under admin_info).
quota_saturation?: {
op: string
From 8c25eee71ba03ea19851dcc4f12ee4ecfcfb0808 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Wed, 26 Aug 2026 21:04:54 +0800
Subject: [PATCH 53/99] chore(build): upgrade Bun to 1.4.0
---
.github/workflows/ci.yml | 2 +-
.github/workflows/electron-build.yml | 2 +-
.github/workflows/release.yml | 6 +++---
Dockerfile | 2 +-
4 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fc45846daae0..b5578dfb1c3f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -76,7 +76,7 @@ jobs:
- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
- bun-version: '1.3.14'
+ bun-version: '1.4.0'
- name: Install dependencies
run: bun install --frozen-lockfile
diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml
index ac4804a3b18d..08c57c9de0da 100644
--- a/.github/workflows/electron-build.yml
+++ b/.github/workflows/electron-build.yml
@@ -29,7 +29,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
- bun-version: latest
+ bun-version: '1.4.0'
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 547f5ed47c3c..0a71c05b2344 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -28,7 +28,7 @@ jobs:
echo "VERSION=$VERSION" >> $GITHUB_ENV
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
- bun-version: latest
+ bun-version: '1.4.0'
- name: Build Frontend
env:
CI: ""
@@ -77,7 +77,7 @@ jobs:
echo "VERSION=$VERSION" >> $GITHUB_ENV
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
- bun-version: latest
+ bun-version: '1.4.0'
- name: Build Frontend
env:
CI: ""
@@ -125,7 +125,7 @@ jobs:
echo "VERSION=$VERSION" >> $GITHUB_ENV
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
- bun-version: latest
+ bun-version: '1.4.0'
- name: Build Frontend
env:
CI: ""
diff --git a/Dockerfile b/Dockerfile
index 5be311b10e43..399a2ab85d94 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder
+FROM oven/bun:1.4.0@sha256:5ff609364c049b54eb0ff560ec96319729a972078ef2c755d758f0c6ef89c2d6 AS builder
WORKDIR /build/web
COPY web/package.json web/bun.lock ./
From 8f6961c675932f406260ff0c218bc2aa0603e9b2 Mon Sep 17 00:00:00 2001
From: Seefs <40468931+seefs001@users.noreply.github.com>
Date: Wed, 26 Aug 2026 21:06:22 +0800
Subject: [PATCH 54/99] feat: vllm thinking_token_budget (#7027)
---
relaykit/dto/openai_request.go | 18 ++++++++++--------
1 file changed, 10 insertions(+), 8 deletions(-)
diff --git a/relaykit/dto/openai_request.go b/relaykit/dto/openai_request.go
index 0e4ad043cfac..d54ac0d15bde 100644
--- a/relaykit/dto/openai_request.go
+++ b/relaykit/dto/openai_request.go
@@ -106,6 +106,8 @@ type GeneralOpenAIRequest struct {
SearchMode json.RawMessage `json:"search_mode,omitempty"`
// Minimax
ReasoningSplit json.RawMessage `json:"reasoning_split,omitempty"`
+ // vLLM
+ ThinkingTokenBudget json.RawMessage `json:"thinking_token_budget,omitempty"`
}
func (r GeneralOpenAIRequest) MarshalJSON() ([]byte, error) {
@@ -859,14 +861,14 @@ type OpenAIResponsesRequest struct {
Include json.RawMessage `json:"include,omitempty"`
// 在后台运行推理,暂时还不支持依赖的接口
// Background json.RawMessage `json:"background,omitempty"`
- Conversation json.RawMessage `json:"conversation,omitempty"`
- ContextManagement json.RawMessage `json:"context_management,omitempty"`
- Instructions json.RawMessage `json:"instructions,omitempty"`
- MaxOutputTokens *uint `json:"max_output_tokens,omitempty"`
- TopLogProbs *int `json:"top_logprobs,omitempty"`
- Metadata json.RawMessage `json:"metadata,omitempty"`
- Moderation json.RawMessage `json:"moderation,omitempty"`
- ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"`
+ Conversation json.RawMessage `json:"conversation,omitempty"`
+ ContextManagement json.RawMessage `json:"context_management,omitempty"`
+ Instructions json.RawMessage `json:"instructions,omitempty"`
+ MaxOutputTokens *uint `json:"max_output_tokens,omitempty"`
+ TopLogProbs *int `json:"top_logprobs,omitempty"`
+ Metadata json.RawMessage `json:"metadata,omitempty"`
+ Moderation json.RawMessage `json:"moderation,omitempty"`
+ ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"`
// FrequencyPenalty/PresencePenalty are not part of the official OpenAI
// Responses API; they are forwarded verbatim for OpenAI-compatible upstreams
// (e.g. vLLM) that accept them.
From cae3676ec6f46ee5ef596443256f78c4e9b34ceb Mon Sep 17 00:00:00 2001
From: Seefs <40468931+seefs001@users.noreply.github.com>
Date: Thu, 27 Aug 2026 21:25:35 +0800
Subject: [PATCH 55/99] feat: glm chanel /v1/responses (#7050)
---
relay/channel/zhipu_4v/adaptor.go | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/relay/channel/zhipu_4v/adaptor.go b/relay/channel/zhipu_4v/adaptor.go
index 04459015b89d..3500c5aa7bcf 100644
--- a/relay/channel/zhipu_4v/adaptor.go
+++ b/relay/channel/zhipu_4v/adaptor.go
@@ -68,6 +68,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
return fmt.Sprintf("%s/images/generations", specialPlan.OpenAIBaseURL), nil
}
return fmt.Sprintf("%s/api/paas/v4/images/generations", baseURL), nil
+ case relayconstant.RelayModeResponses:
+ return fmt.Sprintf("%s/api/v1/responses", baseURL), nil
default:
if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" {
return fmt.Sprintf("%s/chat/completions", specialPlan.OpenAIBaseURL), nil
@@ -102,8 +104,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
}
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
- // TODO implement me
- return nil, errors.New("not implemented")
+ return request, nil
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
From ba2e9287bb7a8002116c03daa4c457a330054871 Mon Sep 17 00:00:00 2001
From: Seefs <40468931+seefs001@users.noreply.github.com>
Date: Thu, 27 Aug 2026 21:51:07 +0800
Subject: [PATCH 56/99] feat(ollama): passthrough Claude Messages and OpenAI
Responses (#7051)
---
relay/channel/ollama/adaptor.go | 77 +++++++++++++++++++++------------
1 file changed, 50 insertions(+), 27 deletions(-)
diff --git a/relay/channel/ollama/adaptor.go b/relay/channel/ollama/adaptor.go
index 998e438f59fc..244b0f59eed9 100644
--- a/relay/channel/ollama/adaptor.go
+++ b/relay/channel/ollama/adaptor.go
@@ -2,11 +2,12 @@ package ollama
import (
"errors"
+ "fmt"
"io"
"net/http"
- "strings"
"github.com/QuantumNous/new-api/relay/channel"
+ "github.com/QuantumNous/new-api/relay/channel/claude"
"github.com/QuantumNous/new-api/relay/channel/openai"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
@@ -24,16 +25,8 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
- openaiAdaptor := openai.Adaptor{}
- openaiRequest, err := openaiAdaptor.ConvertClaudeRequest(c, info, request)
- if err != nil {
- return nil, err
- }
- openaiRequest.(*dto.GeneralOpenAIRequest).StreamOptions = &dto.StreamOptions{
- IncludeUsage: true,
- }
- // map to ollama chat request (Claude -> OpenAI -> Ollama chat)
- return openAIChatToOllamaChat(c, openaiRequest.(*dto.GeneralOpenAIRequest))
+ adaptor := claude.Adaptor{}
+ return adaptor.ConvertClaudeRequest(c, info, request)
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
@@ -48,18 +41,37 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
- if info.RelayMode == relayconstant.RelayModeEmbeddings {
- return info.ChannelBaseUrl + "/api/embed", nil
- }
- if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
- return info.ChannelBaseUrl + "/api/generate", nil
+ switch info.RelayFormat {
+ case types.RelayFormatClaude:
+ return (&claude.Adaptor{}).GetRequestURL(info)
+ default:
+ switch info.RelayMode {
+ case relayconstant.RelayModeEmbeddings:
+ return fmt.Sprintf("%s/api/embed", info.ChannelBaseUrl), nil
+ case relayconstant.RelayModeResponses:
+ return fmt.Sprintf("%s/v1/responses", info.ChannelBaseUrl), nil
+ case relayconstant.RelayModeResponsesCompact:
+ return fmt.Sprintf("%s/v1/responses/compact", info.ChannelBaseUrl), nil
+ case relayconstant.RelayModeCompletions:
+ return fmt.Sprintf("%s/api/generate", info.ChannelBaseUrl), nil
+ default:
+ return fmt.Sprintf("%s/api/chat", info.ChannelBaseUrl), nil
+ }
}
- return info.ChannelBaseUrl + "/api/chat", nil
}
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
channel.SetupApiRequestHeader(info, c, req)
req.Set("Authorization", "Bearer "+info.ApiKey)
+ switch info.RelayFormat {
+ case types.RelayFormatClaude:
+ claude.CommonClaudeHeadersOperation(c, req, info)
+ anthropicVersion := c.Request.Header.Get("anthropic-version")
+ if anthropicVersion == "" {
+ anthropicVersion = "2023-06-01"
+ }
+ req.Set("anthropic-version", anthropicVersion)
+ }
return nil
}
@@ -67,11 +79,12 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if request == nil {
return nil, errors.New("request is nil")
}
- // decide generate or chat
- if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
+ switch info.RelayMode {
+ case relayconstant.RelayModeCompletions:
return openAIToGenerate(c, request)
+ default:
+ return openAIChatToOllamaChat(c, request)
}
- return openAIChatToOllamaChat(c, request)
}
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
@@ -83,7 +96,8 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
}
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
- return nil, errors.New("not implemented")
+ adaptor := openai.Adaptor{}
+ return adaptor.ConvertOpenAIResponsesRequest(c, info, request)
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
@@ -91,14 +105,23 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
- switch info.RelayMode {
- case relayconstant.RelayModeEmbeddings:
- return ollamaEmbeddingHandler(c, info, resp)
+ switch info.RelayFormat {
+ case types.RelayFormatClaude:
+ adaptor := claude.Adaptor{}
+ return adaptor.DoResponse(c, resp, info)
default:
- if info.IsStream {
- return ollamaStreamHandler(c, info, resp)
+ switch info.RelayMode {
+ case relayconstant.RelayModeEmbeddings:
+ return ollamaEmbeddingHandler(c, info, resp)
+ case relayconstant.RelayModeResponses, relayconstant.RelayModeResponsesCompact:
+ adaptor := openai.Adaptor{}
+ return adaptor.DoResponse(c, resp, info)
+ default:
+ if info.IsStream {
+ return ollamaStreamHandler(c, info, resp)
+ }
+ return ollamaChatHandler(c, info, resp)
}
- return ollamaChatHandler(c, info, resp)
}
}
From e468b73915e5028e9849de62c5018a0faa203012 Mon Sep 17 00:00:00 2001
From: Seefs <40468931+seefs001@users.noreply.github.com>
Date: Thu, 27 Aug 2026 22:36:44 +0800
Subject: [PATCH 57/99] docs: update PR template and remove PR Check workflow
(#7053)
* docs: update PR template and remove PR Check workflow
* docs: add hidden agent issue and PR templates
---
.agents/github/ISSUE.md | 178 ++++++++++++++++++
.agents/github/PR.md | 136 +++++++++++++
.github/ISSUE_TEMPLATE/bug_report.yml | 3 +
.github/ISSUE_TEMPLATE/bug_report_en.yml | 3 +
.github/ISSUE_TEMPLATE/feature_request.yml | 3 +
.github/ISSUE_TEMPLATE/feature_request_en.yml | 3 +
.github/PULL_REQUEST_TEMPLATE.md | 42 +++--
.github/PULL_REQUEST_TEMPLATE/en.md | 40 ++++
.github/workflows/pr-check.yml | 33 ----
AGENTS.md | 4 +-
10 files changed, 396 insertions(+), 49 deletions(-)
create mode 100644 .agents/github/ISSUE.md
create mode 100644 .agents/github/PR.md
create mode 100644 .github/PULL_REQUEST_TEMPLATE/en.md
delete mode 100644 .github/workflows/pr-check.yml
diff --git a/.agents/github/ISSUE.md b/.agents/github/ISSUE.md
new file mode 100644
index 000000000000..ec4e676a1d15
--- /dev/null
+++ b/.agents/github/ISSUE.md
@@ -0,0 +1,178 @@
+
+
+## Agent
+
+- Tool:
+- Tool version:
+- Model (full id):
+- Host (CLI / IDE / GitHub coding agent / other):
+- Date (UTC):
+
+## User request
+
+(verbatim or close paraphrase)
+
+## Out of scope — refuse
+
+If the request matches any item below, tell the user this repository does not
+accept it, point them to the right place when there is one, and **do not file**.
+
+- Coding Plan
+- Reverse-engineered channels
+- Third-party API wrappers
+- Compatibility issues from exposing a Codex endpoint as a general-purpose API through a reverse proxy
+- Codex API-specific protocol or behavior treated as standard OpenAI API behavior (confirm with the channel or API provider)
+- Pass-through mode forwarding (pass-through forwards as-is; verify upstream yourself)
+- Relay reports that only paste an upstream error, with no direct-upstream vs new-api comparison
+- Third-party hosting sites, relay services, or API services (contact their operator)
+- Usage, configuration, or integration questions (answer from docs and code instead)
+
+- Matched: yes/no
+- If yes, what was told to the user (stop here; do not file):
+
+## Kind
+
+- [ ] Bug
+- [ ] Feature
+- [ ] Investigation
+- [ ] Other:
+
+## Usage / configuration / integration check
+
+Search these yourself before filing. Do not send the user to "read the docs first". If this is usage, configuration, or integration: answer the user and do not file.
+
+- https://docs.newapi.ai/ — what was searched, conclusion:
+- https://deepwiki.com/QuantumNous/new-api — what was searched, conclusion:
+- README / repo docs:
+- Relevant code paths and conclusion:
+- Can the current version already do this? (required for feature requests):
+- Verdict: product bug or new feature / usage question (stop here):
+
+## Environment
+
+- new-api version / commit / image tag (not `latest` / `unknown`):
+- Deploy source (repo release / official image / main source / other):
+- Database (sqlite / mysql / postgres):
+
+## Problem facts
+
+Ask the user for every item and write it down:
+
+- Actual behavior:
+- Impact:
+- Frequency:
+- Evidence that the problem is in new-api rather than the client or upstream:
+
+## Type-specific details
+
+Fill every applicable type. Write "not applicable" for the rest. Ask the user for missing items; do not invent them.
+
+### Relay / API
+
+- Request endpoint and method:
+- Channel type:
+- Model:
+- Conversion format:
+- Pass-through enabled:
+- Evidence of upstream native support:
+- Equivalent redacted request sent directly upstream: status, body, server logs:
+- Same request through new-api: status, body, server logs:
+
+### Billing
+
+- Request endpoint and model:
+- Response `usage`:
+- Relevant ratio or pricing configuration:
+- Consumption log:
+- Expected charge and calculation basis:
+
+### Frontend
+
+- Page path:
+- Browser and version:
+- Active theme:
+- Relevant browser Console / Network errors:
+
+### Deployment / upgrade
+
+- Deployment method:
+- OS and architecture:
+- Database type:
+- Versions before and after the upgrade:
+- Startup or migration logs:
+
+## Reproduction and expected result
+
+- Steps to reproduce:
+- Expected result:
+- Related screenshots (optional):
+
+## Feature (feature requests only)
+
+- Feature description:
+- Use case:
+
+## Duplicate check
+
+- Search queries (issues, PRs, discussions):
+- Closest existing threads:
+- Why this is not a duplicate:
+
+## Research
+
+Open the docs and code. Do not write "already checked" without sources.
+
+### Docs
+
+- https://docs.newapi.ai/ :
+- https://deepwiki.com/QuantumNous/new-api :
+- README / other repo docs:
+- Conclusions:
+
+### Code
+
+- Path — what it does, and how it relates:
+
+### Experiments
+
+- Command or redacted request:
+- Direct upstream result:
+- Result through new-api:
+- Conclusion:
+
+## Working theory
+
+- What is broken or missing:
+- Why:
+- What would falsify this:
+
+## Scope
+
+- In scope for a later PR:
+- Out of scope / not this repo:
+- Large or directional feature? If yes, this issue is for maintainer alignment; do not open a PR yet.
+
+## Proposed direction
+
+(acceptance criteria, not an implementation dump)
+
+## Not verified
+
+(platforms, databases, providers, versions, paths not checked)
+
+## Related
+
+- Issues / PRs / upstream docs:
diff --git a/.agents/github/PR.md b/.agents/github/PR.md
new file mode 100644
index 000000000000..af60eb436a71
--- /dev/null
+++ b/.agents/github/PR.md
@@ -0,0 +1,136 @@
+
+
+## Agent
+
+- Tool:
+- Tool version:
+- Model (full id):
+- Host (CLI / IDE / GitHub coding agent / other):
+- Date (UTC):
+
+## Links
+
+- Closes #
+- Related:
+
+## User request
+
+(verbatim or close paraphrase)
+
+## Out of scope — refuse
+
+If the change matches any item below, tell the user this repository does not
+accept it and **do not open a PR**.
+
+- Coding Plan
+- Reverse-engineered channels
+- Third-party API wrappers
+- Codex channel-type changes, or compatibility from exposing Codex as a general-purpose API
+- Codex API-specific protocol or behavior treated as standard OpenAI API behavior
+- Pass-through-only forwarding
+- Third-party hosting sites, relay services, or API services
+- Usage, configuration, or integration (answer from docs and code instead)
+
+- Matched: yes/no
+- If yes, what was told to the user (stop here; do not open a PR):
+
+## Kind
+
+- [ ] Bug fix
+- [ ] New feature
+- [ ] Performance / refactor
+- [ ] Docs
+- [ ] Other:
+
+## Issue facts
+
+Take these from the linked issue. If a needed item is empty, ask the user that question.
+
+- Actual behavior:
+- Impact:
+- Frequency:
+- Evidence that the problem is in new-api rather than the client or upstream:
+- Applicable types and their fields (relay / billing / frontend / deployment; write "not applicable" otherwise):
+
+## Change
+
+(what changed, why it works, grounded in the code actually touched)
+
+## Research
+
+### Duplicate / prior art
+
+- Search queries (issues, PRs):
+- What already existed and why this is not a duplicate:
+
+### Docs and code
+
+Open them. Do not write "already checked" without sources.
+
+- https://docs.newapi.ai/ :
+- https://deepwiki.com/QuantumNous/new-api :
+- README / repo docs:
+- Code paths and what they imply for this change:
+
+### Alternatives considered
+
+- Option A:
+- Option B:
+- Why this approach:
+
+## Files
+
+| Path | Why |
+| --- | --- |
+| | |
+
+## Behavior
+
+- Before:
+- After:
+- Explicit non-goals / leftover work:
+
+## Verification
+
+Only what was actually run.
+
+- Commands and results:
+- Manual steps and observed result:
+- UI: screenshot or recording (or why none):
+- Tests added or updated, or why none:
+- Databases / providers / platforms exercised:
+- Not verified:
+
+## Risks
+
+- Failure modes:
+- Billing / quota / auth impact:
+- Follow-ups:
+
+## Scope check
+
+- Single focused change: yes/no (if no, why):
+- Secrets included: no
+- Out of scope (Coding Plan / reverse-engineered channel / third-party wrapper / Codex): no
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 8ee0cabeb18b..5f7bbdce18c2 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -6,6 +6,9 @@ body:
- type: markdown
attributes:
value: |
+
## 提交前必读(请勿删除本节)
- 文档:https://docs.newapi.ai/
diff --git a/.github/ISSUE_TEMPLATE/bug_report_en.yml b/.github/ISSUE_TEMPLATE/bug_report_en.yml
index 67bf1404e0bb..a707f2857911 100644
--- a/.github/ISSUE_TEMPLATE/bug_report_en.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report_en.yml
@@ -6,6 +6,9 @@ body:
- type: markdown
attributes:
value: |
+
## Read This First (Do Not Remove This Section)
- Docs: https://docs.newapi.ai/
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index 2b1b118c231b..c5453ccd2892 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -6,6 +6,9 @@ body:
- type: markdown
attributes:
value: |
+
## 提交前必读(请勿删除本节)
- 文档:https://docs.newapi.ai/
diff --git a/.github/ISSUE_TEMPLATE/feature_request_en.yml b/.github/ISSUE_TEMPLATE/feature_request_en.yml
index a34070c6c201..c442682bee32 100644
--- a/.github/ISSUE_TEMPLATE/feature_request_en.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request_en.yml
@@ -6,6 +6,9 @@ body:
- type: markdown
attributes:
value: |
+
## Read This First (Do Not Remove This Section)
- Docs: https://docs.newapi.ai/
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 7b1eb508ebef..237c3838256e 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,28 +1,40 @@
+
# ⚠️ 提交说明 / PR Notice
+
+English template: `.github/PULL_REQUEST_TEMPLATE/en.md`
+
> [!IMPORTANT]
>
-> - 请提供**人工撰写**的简洁摘要,避免直接粘贴未经整理的 AI 输出。
+> - 描述可用 AI 辅助。提交前请审阅全文,并**声明对其负责**,避免未经核对的直接粘贴。
+> - 请按本模板填写后再提交。
-## 📝 变更描述 / Description
-(简述:做了什么?为什么这样改能生效?请基于你对代码逻辑的理解来写,避免粘贴未经整理的内容)
+## 🔗 关联任务 / Related Issue
+- 新功能请填写下方 Issue 编号;若还没有对应 Issue,请先自行创建。功能讨论请放在 Issue 中进行。
+- 改动较大或方向性变更,请先在关联 Issue 中与维护者达成一致,再提交 PR。
+- Bug 修复请关联对应 Issue。设计取舍、理解偏差或预期不一致,更适合作为讨论或功能请求。
+
+- Closes #
## 🚀 变更类型 / Type of change
-- [ ] 🐛 Bug 修复 (Bug fix) - *请关联对应 Issue,避免将设计取舍、理解偏差或预期不一致直接归类为 bug*
-- [ ] ✨ 新功能 (New feature) - *重大特性建议先通过 Issue 沟通*
+- [ ] 🐛 Bug 修复 (Bug fix)
+- [ ] ✨ 新功能 (New feature)
- [ ] ⚡ 性能优化 / 重构 (Refactor)
- [ ] 📝 文档更新 (Documentation)
-## 🔗 关联任务 / Related Issue
-- Closes # (如有)
+## 📝 变更描述 / Description
+(简述做了什么、为什么生效。如果难以简述,建议先拆分范围,或在 Issue 中与维护者对齐。)
+
+## 📸 运行证明 / Proof of Work
+(请写明如何验证:实际步骤与观察结果。UI 变更请附截图或录屏;Bug 修复请说明复现过程与修复后结果。)
## ✅ 提交前检查项 / Checklist
-- [ ] **人工确认:** 我已亲自整理并撰写此描述,没有直接粘贴未经处理的 AI 输出。
+- [ ] **人工确认:** 无论描述是否由 AI 生成,我已审阅全部内容,并声明对其准确性与完整性负责。
- [ ] **非重复提交:** 我已搜索现有的 [Issues](https://github.com/QuantumNous/new-api/issues) 与 [PRs](https://github.com/QuantumNous/new-api/pulls),确认不是重复提交。
-- [ ] **Bug fix 说明:** 若此 PR 标记为 `Bug fix`,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。
-- [ ] **变更理解:** 我已理解这些更改的工作原理及可能影响。
-- [ ] **范围聚焦:** 本 PR 未包含任何与当前任务无关的代码改动。
-- [ ] **本地验证:** 已在本地运行并通过测试或手动验证,维护者可以据此复核结果。
+- [ ] **新功能关联 Issue:** 若此 PR 标记为 `New feature`,我已关联对应 Issue;若尚无 Issue,我已先自行创建。
+- [ ] **事前沟通:** 若改动较大或涉及方向性变更,已在关联 Issue 中与维护者沟通并达成一致。
+- [ ] **功能范围:** 本 PR 不是 Coding Plan、逆向渠道、第三方封装接口,也不是对 Codex 渠道类型的改动。
+- [ ] **范围聚焦:** 本 PR 为一项聚焦改动,未包含无关代码。
+- [ ] **本地验证:** 已在本地运行并通过测试或手动验证,维护者可以据此复核。
- [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。
-
-## 📸 运行证明 / Proof of Work
-(请在此粘贴截图、关键日志或测试报告,以证明变更生效)
diff --git a/.github/PULL_REQUEST_TEMPLATE/en.md b/.github/PULL_REQUEST_TEMPLATE/en.md
new file mode 100644
index 000000000000..446e9cbd7962
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE/en.md
@@ -0,0 +1,40 @@
+
+# PR Notice
+
+Chinese template: `.github/PULL_REQUEST_TEMPLATE.md`
+
+> [!IMPORTANT]
+>
+> - AI-assisted descriptions are welcome. Please review the full text before submitting and **take responsibility** for it. Avoid pasting unreviewed content.
+> - Please complete this template before submitting.
+
+## Related Issue
+- For new features, please fill in the Issue number below. If none exists yet, please create one first. Please discuss the feature in the Issue rather than using the PR in its place.
+- For large or directional changes, please reach agreement with maintainers in the linked Issue before opening a PR.
+- Bug fixes should link a corresponding Issue. Design trade-offs, misunderstandings, or mismatched expectations are a better fit for a discussion or feature request.
+
+- Closes #
+
+## Type of change
+- [ ] Bug fix
+- [ ] New feature
+- [ ] Performance / Refactor
+- [ ] Documentation
+
+## Description
+(Briefly describe what changed and why it works. If that is hard to summarize, consider splitting the scope or aligning with maintainers in an Issue first.)
+
+## Proof of Work
+(Please describe how this was verified: the steps run and what was observed. For UI changes, please include a screenshot or recording. For bug fixes, please describe the reproduction and the result after the fix.)
+
+## Checklist
+- [ ] **Human review:** Whether or not the description was AI-generated, I have reviewed the full content and take responsibility for its accuracy and completeness.
+- [ ] **Not a duplicate:** I have searched existing [Issues](https://github.com/QuantumNous/new-api/issues) and [PRs](https://github.com/QuantumNous/new-api/pulls) and confirmed this is not a duplicate.
+- [ ] **Feature issue:** If this PR is a New feature, I have linked a corresponding Issue; if none existed, I created one first.
+- [ ] **Prior discussion:** If this is a large or directional change, I have discussed it with maintainers in the linked Issue and reached agreement.
+- [ ] **Scope:** This PR is not a Coding Plan, reverse-engineered channel, third-party API wrapper, or a change to the Codex channel type.
+- [ ] **Focused change:** This PR is a single focused change and does not include unrelated code.
+- [ ] **Local verification:** I have run tests or manually verified locally so maintainers can re-check from this evidence.
+- [ ] **Security:** This change does not include secrets and follows the project's coding guidelines.
diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml
deleted file mode 100644
index 67591702b4fd..000000000000
--- a/.github/workflows/pr-check.yml
+++ /dev/null
@@ -1,33 +0,0 @@
-name: PR Check
-
-permissions:
- contents: read
- issues: read
- pull-requests: read
-
-on:
- pull_request_target:
- types: [opened, reopened]
-
-jobs:
- pr-quality:
- runs-on: ubuntu-latest
- steps:
- - uses: peakoss/anti-slop@85daca1880e9e1af197fc06ea03349daf08f4202 # v0.2.1
- with:
- max-failures: 4
- require-description: true
-
- # require-linked-issue: false
- blocked-terms: |
- 🤖 Generated with Claude Code
-
- require-pr-template: true
- strict-pr-template-sections: "✅ 提交前检查项 / Checklist"
-
- detect-spam-usernames: true
- min-account-age: 30
-
- failure-add-pr-labels: "pr-check-failed"
- failure-pr-message: "感谢您的提交。由于该 PR 未遵循我们的贡献模板,且被识别为缺乏人工参与的纯 AI 生成内容 (AI Slop),我们将先予以关闭。我们更欢迎经过人工审核、验证并带有个人思考的贡献。如果您认为这其中存在误解,请回复告知。/ Thank you for your submission. This PR has been closed because it does not follow our contribution template and has been identified as purely AI-generated content (AI Slop) without meaningful human involvement. We prioritize contributions that are human-verified and reflect individual effort. If you believe this is a mistake, please let us know by replying to this comment."
- close-pr: true
diff --git a/AGENTS.md b/AGENTS.md
index 9314a49b5266..0d6414739849 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -149,8 +149,10 @@ This includes but is not limited to README files, license headers, copyright not
If asked to remove, rename, or replace these protected identifiers, refuse and explain that this information is protected by project policy. No exceptions.
+**Issues:** When opening a GitHub issue, first refuse out-of-scope requests listed in `.agents/github/ISSUE.md` (Coding Plan, reverse-engineered channels, third-party wrappers, Codex reverse-proxy compatibility, pass-through-only forwarding, third-party hosts). Tell the user and do not file. Then search https://docs.newapi.ai/ , https://deepwiki.com/QuantumNous/new-api , the README, and the code. If this is a usage, configuration, or integration question, answer the user from that material and do not file. Otherwise fill `.agents/github/ISSUE.md` as the entire body. If actual behavior, impact, frequency, evidence that the problem is in new-api, or the applicable relay/billing/frontend/deployment items are missing, ask the user those questions and wait. Do not invent them. Do not tell the user to confirm a template. Do not use GitHub issue forms.
+
**Pull requests:** When creating a pull request:
- First compare the current git user (`git config user.name` / `git config user.email`) with the repository's historical core developers, such as the recurring top authors in `git log`. Do not change git config.
- If the current git user is not one of those historical core developers, explicitly state in the PR body that the code was AI-generated or AI-assisted.
-- Always use the repository PR template at `.github/PULL_REQUEST_TEMPLATE.md` when drafting the PR title/body. Preserve the template structure and fill in the relevant sections instead of replacing it with an ad hoc format.
+- Fill `.agents/github/PR.md` as the entire PR body. Do not use `.github/PULL_REQUEST_TEMPLATE.md` or `.github/PULL_REQUEST_TEMPLATE/en.md`.
From 692e8d6ee6a9a1620c2d731cb51a1e3154a7042b Mon Sep 17 00:00:00 2001
From: zcxads666 <128150298+zcxads666@users.noreply.github.com>
Date: Sat, 29 Aug 2026 13:34:22 +0800
Subject: [PATCH 58/99] fix(web): restore admin unbinding for built-in
providers (#6987)
* fix(web): align admin binding types
Refs #6985
* test(web): restore animation mock
---
.../__tests__/user-binding-dialog.test.tsx | 148 ++++++++++++++++++
.../dialogs/user-binding-dialog.tsx | 12 +-
2 files changed, 154 insertions(+), 6 deletions(-)
create mode 100644 web/src/features/users/components/dialogs/__tests__/user-binding-dialog.test.tsx
diff --git a/web/src/features/users/components/dialogs/__tests__/user-binding-dialog.test.tsx b/web/src/features/users/components/dialogs/__tests__/user-binding-dialog.test.tsx
new file mode 100644
index 000000000000..877b4e938f23
--- /dev/null
+++ b/web/src/features/users/components/dialogs/__tests__/user-binding-dialog.test.tsx
@@ -0,0 +1,148 @@
+/*
+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 { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'
+
+import { api } from '@/lib/api'
+
+import { UserBindingDialog } from '../user-binding-dialog'
+
+type ApiMethod = (url: string) => Promise<{ data: unknown }>
+type MockableApi = {
+ get: ApiMethod
+ delete: ApiMethod
+}
+
+const apiClient = api as unknown as MockableApi
+const originalGet = apiClient.get
+const originalDelete = apiClient.delete
+const originalGetAnimations = Object.getOwnPropertyDescriptor(
+ HTMLElement.prototype,
+ 'getAnimations'
+)
+
+const user = {
+ id: 7,
+ username: 'bound-user',
+ email: 'user@example.com',
+ github_id: 'github-user',
+ discord_id: 'discord-user',
+ wechat_id: 'wechat-user',
+ oidc_id: 'oidc-user',
+ telegram_id: 'telegram-user',
+ linux_do_id: 'linuxdo-user',
+}
+
+function findUnbindButton(provider: string): HTMLButtonElement {
+ let container = screen.getByText(provider).parentElement
+ while (container && !container.querySelector('button')) {
+ container = container.parentElement
+ }
+ const button = container?.querySelector('button')
+ if (!button) {
+ throw new Error(`Expected unbind button for ${provider}`)
+ }
+ return button
+}
+
+beforeAll(() => {
+ Object.defineProperty(HTMLElement.prototype, 'getAnimations', {
+ configurable: true,
+ value: () => [],
+ })
+})
+
+afterAll(() => {
+ if (originalGetAnimations) {
+ Object.defineProperty(
+ HTMLElement.prototype,
+ 'getAnimations',
+ originalGetAnimations
+ )
+ return
+ }
+ Reflect.deleteProperty(HTMLElement.prototype, 'getAnimations')
+})
+
+afterEach(() => {
+ apiClient.get = originalGet
+ apiClient.delete = originalDelete
+})
+
+describe('UserBindingDialog built-in bindings', () => {
+ test('submits every built-in provider type accepted by the backend', async () => {
+ const deletedUrls: string[] = []
+ apiClient.get = async (url) => {
+ switch (url) {
+ case '/api/user/7':
+ return { data: { success: true, data: user } }
+ case '/api/user/7/oauth/bindings':
+ return { data: { success: true, data: [] } }
+ case '/api/status':
+ return {
+ data: {
+ success: true,
+ data: {
+ github_oauth: true,
+ discord_oauth: true,
+ wechat_login: true,
+ oidc_enabled: true,
+ telegram_oauth: true,
+ linuxdo_oauth: true,
+ },
+ },
+ }
+ default:
+ throw new Error(`Unexpected GET ${url}`)
+ }
+ }
+ apiClient.delete = async (url) => {
+ deletedUrls.push(url)
+ return { data: { success: true, message: 'success' } }
+ }
+
+ render( undefined} />)
+
+ const expectedBindings = [
+ ['Email', 'email'],
+ ['GitHub', 'github'],
+ ['Discord', 'discord'],
+ ['WeChat', 'wechat'],
+ ['OIDC', 'oidc'],
+ ['Telegram', 'telegram'],
+ ['LinuxDO', 'linuxdo'],
+ ] as const
+
+ await screen.findByText('bound-user (ID: 7)')
+ for (const [provider, bindingType] of expectedBindings) {
+ fireEvent.click(findUnbindButton(provider))
+ fireEvent.click(screen.getByRole('button', { name: 'Confirm Unbind' }))
+ await waitFor(() => {
+ expect(deletedUrls.at(-1)).toBe(`/api/user/7/bindings/${bindingType}`)
+ })
+ await waitFor(() => {
+ expect(
+ screen.queryByRole('button', { name: 'Confirm Unbind' })
+ ).not.toBeInTheDocument()
+ })
+ }
+
+ expect(deletedUrls).toHaveLength(expectedBindings.length)
+ })
+})
diff --git a/web/src/features/users/components/dialogs/user-binding-dialog.tsx b/web/src/features/users/components/dialogs/user-binding-dialog.tsx
index c4a56c229665..0c1897a3453d 100644
--- a/web/src/features/users/components/dialogs/user-binding-dialog.tsx
+++ b/web/src/features/users/components/dialogs/user-binding-dialog.tsx
@@ -102,42 +102,42 @@ const BUILTIN_BINDINGS: ReadonlyArray<{
statusKey: null,
},
{
- key: 'github_id',
+ key: 'github',
field: 'github_id',
label: 'GitHub',
icon: ,
statusKey: 'github_oauth',
},
{
- key: 'discord_id',
+ key: 'discord',
field: 'discord_id',
label: 'Discord',
icon: ,
statusKey: 'discord_oauth',
},
{
- key: 'wechat_id',
+ key: 'wechat',
field: 'wechat_id',
label: 'WeChat',
icon: ,
statusKey: 'wechat_login',
},
{
- key: 'oidc_id',
+ key: 'oidc',
field: 'oidc_id',
label: 'OIDC',
icon: ,
statusKey: 'oidc_enabled',
},
{
- key: 'telegram_id',
+ key: 'telegram',
field: 'telegram_id',
label: 'Telegram',
icon: ,
statusKey: 'telegram_oauth',
},
{
- key: 'linux_do_id',
+ key: 'linuxdo',
field: 'linux_do_id',
label: 'LinuxDO',
icon: ,
From ac381acf4bf41204b97bb26b4c58c83275877a2e Mon Sep 17 00:00:00 2001
From: zcxads666 <128150298+zcxads666@users.noreply.github.com>
Date: Sat, 29 Aug 2026 13:42:33 +0800
Subject: [PATCH 59/99] =?UTF-8?q?fix(billing):=20=E4=BF=AE=E5=A4=8D?=
=?UTF-8?q?=E6=97=B6=E9=97=B4=E8=A7=84=E5=88=99=E6=81=92=E7=9C=9F=E8=A1=A8?=
=?UTF-8?q?=E8=BE=BE=E5=BC=8F=E5=AF=BC=E8=87=B4=E5=80=8D=E7=8E=87=E5=85=A8?=
=?UTF-8?q?=E5=A4=A9=E7=94=9F=E6=95=88=20(#6934)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: seefs001
---
.../lib/__tests__/time-rule-expr.test.ts | 218 ++++++++++++++++++
web/src/features/pricing/lib/billing-expr.ts | 104 +++++++--
.../models/tiered-pricing-editor.tsx | 7 +-
web/src/i18n/locales/en.json | 3 +-
web/src/i18n/locales/fr.json | 3 +-
web/src/i18n/locales/ja.json | 3 +-
web/src/i18n/locales/ru.json | 3 +-
web/src/i18n/locales/vi.json | 3 +-
web/src/i18n/locales/zh-TW.json | 3 +-
web/src/i18n/locales/zh.json | 3 +-
10 files changed, 323 insertions(+), 27 deletions(-)
create mode 100644 web/src/features/pricing/lib/__tests__/time-rule-expr.test.ts
diff --git a/web/src/features/pricing/lib/__tests__/time-rule-expr.test.ts b/web/src/features/pricing/lib/__tests__/time-rule-expr.test.ts
new file mode 100644
index 000000000000..a9c6c23309a0
--- /dev/null
+++ b/web/src/features/pricing/lib/__tests__/time-rule-expr.test.ts
@@ -0,0 +1,218 @@
+/*
+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 { describe, expect, test } from 'vitest'
+
+import {
+ buildRequestRuleExpr,
+ MATCH_EQ,
+ MATCH_GTE,
+ MATCH_RANGE,
+ type RequestCondition,
+ type RequestRuleGroup,
+ type TimeCondition,
+ type TimeFunc,
+ tryParseRequestRuleExpr,
+} from '../billing-expr'
+
+function timeCondition(overrides: Partial = {}): TimeCondition {
+ return {
+ source: 'time',
+ timeFunc: 'hour',
+ timezone: 'Asia/Shanghai',
+ mode: MATCH_RANGE,
+ value: '',
+ rangeStart: '',
+ rangeEnd: '',
+ ...overrides,
+ }
+}
+
+function timeRangeGroup(start: string, end: string): RequestRuleGroup {
+ return {
+ conditions: [timeCondition({ rangeStart: start, rangeEnd: end })],
+ multiplier: '2',
+ }
+}
+
+function scalarTimeGroup(
+ value: string,
+ timeFunc: TimeFunc = 'hour'
+): RequestRuleGroup {
+ return {
+ conditions: [timeCondition({ mode: MATCH_GTE, value, timeFunc })],
+ multiplier: '2',
+ }
+}
+
+describe('time range expression generation', () => {
+ test('within-day range (start < end) builds an && condition', () => {
+ // Regression test for #6923: the || form is a tautology that applies the
+ // multiplier 24/7.
+ expect(buildRequestRuleExpr([timeRangeGroup('9', '12')])).toBe(
+ '(hour("Asia/Shanghai") >= 9 && hour("Asia/Shanghai") < 12 ? 2 : 1)'
+ )
+ })
+
+ test('overnight range (start > end) keeps the || condition', () => {
+ expect(buildRequestRuleExpr([timeRangeGroup('21', '6')])).toBe(
+ '(hour("Asia/Shanghai") >= 21 || hour("Asia/Shanghai") < 6 ? 2 : 1)'
+ )
+ })
+
+ test('equal bounds build an always-false && range instead of a tautology', () => {
+ expect(buildRequestRuleExpr([timeRangeGroup('9', '9')])).toBe(
+ '(hour("Asia/Shanghai") >= 9 && hour("Asia/Shanghai") < 9 ? 2 : 1)'
+ )
+ })
+
+ test.each([
+ ['out-of-domain negative bounds', '-1', '-5'],
+ ['out-of-domain upper bound', '9', '24'],
+ ['non-integer bound', '9.5', '12'],
+ ])('drops the rule for %s', (_name, start, end) => {
+ expect(buildRequestRuleExpr([timeRangeGroup(start, end)])).toBe('')
+ })
+
+ test('drops a scalar rule whose value is out of domain', () => {
+ expect(buildRequestRuleExpr([scalarTimeGroup('25')])).toBe('')
+ })
+
+ test.each([
+ ['hour', '0', true],
+ ['hour', '23', true],
+ ['hour', '24', false],
+ ['minute', '59', true],
+ ['minute', '60', false],
+ ['weekday', '0', true],
+ ['weekday', '6', true],
+ ['weekday', '7', false],
+ ['month', '1', true],
+ ['month', '12', true],
+ ['month', '0', false],
+ ['month', '13', false],
+ ['day', '1', true],
+ ['day', '31', true],
+ ['day', '32', false],
+ ])('keeps %s value %s in domain: %s', (timeFunc, value, inDomain) => {
+ const expr = buildRequestRuleExpr([
+ scalarTimeGroup(value, timeFunc as TimeFunc),
+ ])
+ expect(expr !== '').toBe(inDomain)
+ })
+})
+
+describe('time range expression parsing', () => {
+ test('parses an && range back into a single MATCH_RANGE condition', () => {
+ const groups = tryParseRequestRuleExpr(
+ '(hour("Asia/Shanghai") >= 9 && hour("Asia/Shanghai") < 12 ? 2 : 1)'
+ )
+ expect(groups).toHaveLength(1)
+ expect(groups?.[0].conditions).toHaveLength(1)
+ const condition = groups?.[0].conditions[0] as TimeCondition
+ expect(condition.mode).toBe(MATCH_RANGE)
+ expect(condition.rangeStart).toBe('9')
+ expect(condition.rangeEnd).toBe('12')
+ })
+
+ test('still parses a legacy || range into a single MATCH_RANGE condition', () => {
+ const groups = tryParseRequestRuleExpr(
+ '(hour("Asia/Shanghai") >= 21 || hour("Asia/Shanghai") < 6 ? 2 : 1)'
+ )
+ expect(groups?.[0].conditions).toHaveLength(1)
+ const condition = groups?.[0].conditions[0] as TimeCondition
+ expect(condition.mode).toBe(MATCH_RANGE)
+ expect(condition.rangeStart).toBe('21')
+ expect(condition.rangeEnd).toBe('6')
+ })
+
+ test('merges adjacent time bounds into MATCH_RANGE when other conditions follow', () => {
+ const groups = tryParseRequestRuleExpr(
+ '(param("service_tier") == "fast" && hour("Asia/Shanghai") >= 9 && hour("Asia/Shanghai") < 12 ? 2 : 1)'
+ )
+ expect(groups?.[0].conditions.map((c) => c.mode)).toEqual([
+ MATCH_EQ,
+ MATCH_RANGE,
+ ])
+ const range = groups?.[0].conditions[1] as TimeCondition
+ expect(range.rangeStart).toBe('9')
+ expect(range.rangeEnd).toBe('12')
+ })
+
+ test('keeps a parenthesized overnight range as MATCH_RANGE in a mixed group', () => {
+ const groups = tryParseRequestRuleExpr(
+ '((hour("Asia/Shanghai") >= 21 || hour("Asia/Shanghai") < 6) && param("service_tier") == "fast" ? 3 : 1)'
+ )
+ expect(groups?.[0].conditions.map((c) => c.mode)).toEqual([
+ MATCH_RANGE,
+ MATCH_EQ,
+ ])
+ expect(groups?.[0].multiplier).toBe('3')
+ })
+
+ test('parses the issue #6923 two-scalar workaround groups as single ranges', () => {
+ const groups = tryParseRequestRuleExpr(
+ '(hour("Asia/Shanghai") >= 9 && hour("Asia/Shanghai") < 12 ? 2 : 1) * (hour("Asia/Shanghai") >= 14 && hour("Asia/Shanghai") < 18 ? 2 : 1)'
+ )
+ expect(groups).toHaveLength(2)
+ for (const group of groups ?? []) {
+ expect(group.conditions).toHaveLength(1)
+ expect(group.conditions[0].mode).toBe(MATCH_RANGE)
+ }
+ })
+
+ test.each([
+ [
+ 'out-of-domain range bounds',
+ '(hour("Asia/Shanghai") >= 25 && hour("Asia/Shanghai") < 30 ? 2 : 1)',
+ ],
+ [
+ 'fractional range bounds',
+ '(hour("Asia/Shanghai") >= 1.5 && hour("Asia/Shanghai") < 2.5 ? 2 : 1)',
+ ],
+ ['out-of-domain scalar value', '(hour("Asia/Shanghai") >= 25 ? 2 : 1)'],
+ ['out-of-domain weekday value', '(weekday("UTC") >= 7 ? 2 : 1)'],
+ ])('rejects %s instead of parsing them', (_name, expr) => {
+ // Rejected rules keep the editor in raw mode; a lenient parse would let
+ // the visual editor silently drop the rule on rebuild.
+ expect(tryParseRequestRuleExpr(expr)).toBeNull()
+ })
+})
+
+describe('time range round-trip stability', () => {
+ test('build → parse → build yields the identical mixed-group expression', () => {
+ const groups: RequestRuleGroup[] = [
+ {
+ conditions: [
+ {
+ source: 'param',
+ path: 'service_tier',
+ mode: MATCH_EQ,
+ value: 'fast',
+ } satisfies RequestCondition,
+ timeCondition({ rangeStart: '9', rangeEnd: '12' }),
+ ],
+ multiplier: '2',
+ },
+ ]
+ const expr = buildRequestRuleExpr(groups)
+ const parsed = tryParseRequestRuleExpr(expr)
+ expect(parsed).not.toBeNull()
+ expect(buildRequestRuleExpr(parsed ?? [])).toBe(expr)
+ })
+})
diff --git a/web/src/features/pricing/lib/billing-expr.ts b/web/src/features/pricing/lib/billing-expr.ts
index f1428f674904..359e93f6f3c4 100644
--- a/web/src/features/pricing/lib/billing-expr.ts
+++ b/web/src/features/pricing/lib/billing-expr.ts
@@ -372,25 +372,44 @@ function parseExprLiteral(raw: string): string | null {
}
}
+// Time function value domains. Values outside these ranges are invalid for
+// the corresponding time function (e.g. hour() is 0-23) and would otherwise
+// produce always-true conditions like hour >= -1 || hour < -5.
+const TIME_FUNC_RANGES: Record = {
+ hour: [0, 23],
+ minute: [0, 59],
+ weekday: [0, 6],
+ month: [1, 12],
+ day: [1, 31],
+}
+
+function isTimeValueInRange(timeFunc: TimeFunc, text: string): boolean {
+ if (!NUMERIC_LITERAL_REGEX.test(text)) return false
+ const value = Number(text)
+ if (!Number.isInteger(value)) return false
+ const [min, max] = TIME_FUNC_RANGES[timeFunc]
+ return value >= min && value <= max
+}
+
function tryParseTimeCondition(expr: string): RequestCondition | null {
let m = expr.match(
- /^(hour|minute|weekday|month|day)\("([^"]+)"\) >= ([\d.eE+-]+) \|\| \1\("\2"\) < ([\d.eE+-]+)$/
+ /^(hour|minute|weekday|month|day)\("([^"]+)"\) >= ([\d.eE+-]+) (?:&&|\|\|) \1\("\2"\) < ([\d.eE+-]+)$/
)
- if (m) {
- return {
- source: 'time',
- timeFunc: m[1] as TimeFunc,
- timezone: m[2],
- mode: MATCH_RANGE,
- value: '',
- rangeStart: m[3],
- rangeEnd: m[4],
- }
+ if (!m) {
+ m = expr.match(
+ /^\((hour|minute|weekday|month|day)\("([^"]+)"\) >= ([\d.eE+-]+) (?:&&|\|\|) \1\("\2"\) < ([\d.eE+-]+)\)$/
+ )
}
- m = expr.match(
- /^\((hour|minute|weekday|month|day)\("([^"]+)"\) >= ([\d.eE+-]+) \|\| \1\("\2"\) < ([\d.eE+-]+)\)$/
- )
if (m) {
+ // Reject invalid bounds at parse time too: an unparseable rule keeps the
+ // editor in raw mode, while a leniently parsed one would be silently
+ // dropped when the visual editor rebuilds the expression.
+ if (
+ !isTimeValueInRange(m[1] as TimeFunc, m[3]) ||
+ !isTimeValueInRange(m[1] as TimeFunc, m[4])
+ ) {
+ return null
+ }
return {
source: 'time',
timeFunc: m[1] as TimeFunc,
@@ -405,6 +424,7 @@ function tryParseTimeCondition(expr: string): RequestCondition | null {
/^(hour|minute|weekday|month|day)\("([^"]+)"\) (==|>=|<) ([\d.eE+-]+)$/
)
if (m) {
+ if (!isTimeValueInRange(m[1] as TimeFunc, m[4])) return null
const opMap: Record = {
'==': MATCH_EQ,
'>=': MATCH_GTE,
@@ -483,13 +503,51 @@ function tryParseRequestCondition(expr: string): RequestCondition | null {
return null
}
+function tryParseTimeRangePair(
+ lower: string,
+ upper: string
+): RequestCondition | null {
+ const a = tryParseTimeCondition(lower)
+ const b = tryParseTimeCondition(upper)
+ if (!a || !b || a.source !== 'time' || b.source !== 'time') return null
+ const ta = a as TimeCondition
+ const tb = b as TimeCondition
+ if (ta.timeFunc !== tb.timeFunc || ta.timezone !== tb.timezone) return null
+ if (ta.mode !== MATCH_GTE || tb.mode !== MATCH_LT) return null
+ return {
+ source: 'time',
+ timeFunc: ta.timeFunc,
+ timezone: ta.timezone,
+ mode: MATCH_RANGE,
+ value: '',
+ rangeStart: ta.value,
+ rangeEnd: tb.value,
+ }
+}
+
function tryParseRequestConditions(
conditionStr: string
): RequestCondition[] | null {
+ // A single time range like hour(tz) >= 9 && hour(tz) < 12 must stay one
+ // MATCH_RANGE condition instead of being split into two scalar conditions.
+ const wholeTimeCond = tryParseTimeCondition(conditionStr.trim())
+ if (wholeTimeCond) return [wholeTimeCond]
+
const andParts = splitTopLevelAnd(conditionStr)
const conditions: RequestCondition[] = []
- for (const part of andParts) {
- const condition = tryParseRequestCondition(part.trim())
+ for (let i = 0; i < andParts.length; i += 1) {
+ const part = andParts[i].trim()
+ // Adjacent matching time bounds (fn >= X && fn < Y) form one range; merge
+ // them so the visual editor keeps a single MATCH_RANGE row even when
+ // other conditions follow in the same group.
+ const next = i + 1 < andParts.length ? andParts[i + 1].trim() : ''
+ const merged = next ? tryParseTimeRangePair(part, next) : null
+ if (merged) {
+ conditions.push(merged)
+ i += 1
+ continue
+ }
+ const condition = tryParseRequestCondition(part)
if (!condition) return null
conditions.push(condition)
}
@@ -732,13 +790,21 @@ function buildTimeConditionExpr(cond: TimeCondition): string {
if (mode === MATCH_RANGE) {
const s = normalized.rangeStart.trim()
const e = normalized.rangeEnd.trim()
- if (!NUMERIC_LITERAL_REGEX.test(s) || !NUMERIC_LITERAL_REGEX.test(e)) {
+ if (!isTimeValueInRange(timeFunc, s) || !isTimeValueInRange(timeFunc, e)) {
return ''
}
- return `${fn} >= ${s} || ${fn} < ${e}`
+ // Overnight range (start > end) crosses the day boundary, e.g. 21-6.
+ // A within-day range (start <= end), e.g. 9-12, must use && so the
+ // condition is not a tautology that always applies the multiplier.
+ const sNum = Number(s)
+ const eNum = Number(e)
+ if (sNum > eNum) {
+ return `${fn} >= ${s} || ${fn} < ${e}`
+ }
+ return `${fn} >= ${s} && ${fn} < ${e}`
}
const v = normalized.value.trim()
- if (!NUMERIC_LITERAL_REGEX.test(v)) return ''
+ if (!isTimeValueInRange(timeFunc, v)) return ''
const opMap: Record = {
[MATCH_EQ]: '==',
[MATCH_GTE]: '>=',
diff --git a/web/src/features/system-settings/models/tiered-pricing-editor.tsx b/web/src/features/system-settings/models/tiered-pricing-editor.tsx
index 15badd6e8e11..6b47cbb0ae3e 100644
--- a/web/src/features/system-settings/models/tiered-pricing-editor.tsx
+++ b/web/src/features/system-settings/models/tiered-pricing-editor.tsx
@@ -946,7 +946,7 @@ function RuleConditionRow({
case MATCH_LTE:
return t('Less than or equal')
case MATCH_RANGE:
- return t('Overnight range')
+ return t('Time range')
default:
return mode
}
@@ -1180,6 +1180,11 @@ function RuleConditionRow({
>
+ {condition.source === SOURCE_TIME && condition.mode === MATCH_RANGE && (
+
+ {t('Start ≤ end: within the day; start > end: across midnight')}
+
+ )}
)
}
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 86095b29c1b3..ed2b9246bdb4 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -3257,7 +3257,6 @@
"overall": "overall",
"Overflow": "Overflow",
"Overflow items": "Overflow items",
- "Overnight range": "Overnight range",
"override": "override",
"Override": "Override",
"Override auto-discovered endpoint": "Override auto-discovered endpoint",
@@ -4345,6 +4344,7 @@
"Standard": "Standard",
"Standard price": "Standard price",
"Start": "Start",
+ "Start ≤ end: within the day; start > end: across midnight": "Start ≤ end: within the day; start > end: across midnight",
"Start a conversation to see messages here": "Start a conversation to see messages here",
"Start a playground chat": "Start a playground chat",
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.",
@@ -4694,6 +4694,7 @@
"tiers": "tiers",
"Time": "Time",
"Time Granularity": "Time Granularity",
+ "Time range": "Time range",
"Time remaining": "Time remaining",
"Time window for rate limiting": "Time window for rate limiting",
"Time-based": "Time-based",
diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json
index 1d4dc4b9550d..4c51ddb6d3a8 100644
--- a/web/src/i18n/locales/fr.json
+++ b/web/src/i18n/locales/fr.json
@@ -3257,7 +3257,6 @@
"overall": "global",
"Overflow": "Débordement",
"Overflow items": "Éléments excédentaires",
- "Overnight range": "Plage nocturne",
"override": "remplacer",
"Override": "Remplacer",
"Override auto-discovered endpoint": "Remplacer le point de terminaison auto-découvert",
@@ -4345,6 +4344,7 @@
"Standard": "Standard",
"Standard price": "Prix standard",
"Start": "Début",
+ "Start ≤ end: within the day; start > end: across midnight": "Début ≤ fin : plage dans la journée ; début > fin : plage après minuit",
"Start a conversation to see messages here": "Démarrez une conversation pour voir les messages ici",
"Start a playground chat": "Démarrer une conversation dans le playground",
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Commencez à encaisser des paiements dans le monde entier sans créer de société. Conçu pour les développeurs indépendants, les entrepreneurs individuels OPC et les startups. Waffo Pancake agit comme Merchant of Record et prend en charge la conformité liée à l’encaissement mondial : taxes à la consommation, facturation, gestion des abonnements, remboursements et rétrofacturations. Les développeurs solo peuvent lancer rapidement leur produit et rester concentrés sur celui-ci plutôt que sur la conformité. Intégration en quelques minutes, d’une seule invite à une intégration complète.",
@@ -4694,6 +4694,7 @@
"tiers": "paliers",
"Time": "Heure",
"Time Granularity": "Granularité temporelle",
+ "Time range": "Plage horaire",
"Time remaining": "Temps restant",
"Time window for rate limiting": "Fenêtre de temps pour la limitation de débit",
"Time-based": "Selon l’heure",
diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json
index 7eaed9574c18..a4de579bb89d 100644
--- a/web/src/i18n/locales/ja.json
+++ b/web/src/i18n/locales/ja.json
@@ -3257,7 +3257,6 @@
"overall": "全体",
"Overflow": "オーバーフロー",
"Overflow items": "超過項目",
- "Overnight range": "日跨ぎ範囲",
"override": "上書き",
"Override": "上書き",
"Override auto-discovered endpoint": "自動検出されたエンドポイントを上書きする",
@@ -4345,6 +4344,7 @@
"Standard": "標準",
"Standard price": "標準価格",
"Start": "開始",
+ "Start ≤ end: within the day; start > end: across midnight": "開始 ≤ 終了は日内範囲、開始 > 終了は日をまたぐ範囲",
"Start a conversation to see messages here": "会話を開始すると、ここにメッセージが表示されます",
"Start a playground chat": "Playground でチャットを開始",
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "法人を設立せずに世界中で決済を受け付けられます。個人開発者、OPC 個人事業主、スタートアップ向けに設計されています。Waffo Pancake は Merchant of Record として、消費税、請求書、サブスクリプション管理、返金、チャージバックなど、グローバル決済のコンプライアンス負担を引き受けます。個人開発者はコンプライアンスではなくプロダクトに集中しながら素早くローンチできます。数分でオンボーディングし、1 つのプロンプトから完全な統合まで進められます。",
@@ -4694,6 +4694,7 @@
"tiers": "階層",
"Time": "時間",
"Time Granularity": "時間の粒度",
+ "Time range": "時間範囲",
"Time remaining": "残り時間",
"Time window for rate limiting": "レート制限の時間枠",
"Time-based": "時間条件あり",
diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json
index 9e087d1fa53f..af284c52e5da 100644
--- a/web/src/i18n/locales/ru.json
+++ b/web/src/i18n/locales/ru.json
@@ -3257,7 +3257,6 @@
"overall": "всего",
"Overflow": "Переполнение",
"Overflow items": "Элементы сверх лимита",
- "Overnight range": "Диапазон через полночь",
"override": "переопределить",
"Override": "Перезаписать",
"Override auto-discovered endpoint": "Переопределить автоматически обнаруженную конечную точку",
@@ -4345,6 +4344,7 @@
"Standard": "Стандартный",
"Standard price": "Стандартная цена",
"Start": "Начало",
+ "Start ≤ end: within the day; start > end: across midnight": "Начало ≤ конца — в пределах дня, начало > конца — через полночь",
"Start a conversation to see messages here": "Начните разговор, чтобы увидеть сообщения здесь",
"Start a playground chat": "Начните чат в Playground",
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Начните принимать платежи по всему миру без регистрации компании. Подходит для независимых разработчиков, индивидуальных предпринимателей OPC и стартапов. Waffo Pancake выступает как Merchant of Record и берет на себя комплаенс глобального приема платежей: потребительские налоги, выставление счетов, управление подписками, возвраты и чарджбеки. Одиночные разработчики могут быстро запуститься и сосредоточиться на продукте, а не на комплаенсе. Подключение за минуты — от одного запроса до полной интеграции.",
@@ -4694,6 +4694,7 @@
"tiers": "уровни",
"Time": "Время",
"Time Granularity": "Гранулярность времени",
+ "Time range": "Временной диапазон",
"Time remaining": "Осталось времени",
"Time window for rate limiting": "Временное окно для ограничения скорости запросов",
"Time-based": "Зависит от времени",
diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json
index 2ae592388174..53e2ab47e471 100644
--- a/web/src/i18n/locales/vi.json
+++ b/web/src/i18n/locales/vi.json
@@ -3257,7 +3257,6 @@
"overall": "tổng",
"Overflow": "Tràn trên",
"Overflow items": "Mục vượt giới hạn",
- "Overnight range": "Khoảng qua nửa đêm",
"override": "ghi đè",
"Override": "Ghi đè",
"Override auto-discovered endpoint": "Ghi đè điểm cuối tự động phát hiện",
@@ -4345,6 +4344,7 @@
"Standard": "Tiêu chuẩn",
"Standard price": "Giá tiêu chuẩn",
"Start": "Bắt đầu",
+ "Start ≤ end: within the day; start > end: across midnight": "Bắt đầu ≤ kết thúc: trong ngày; bắt đầu > kết thúc: qua nửa đêm",
"Start a conversation to see messages here": "Bắt đầu một cuộc trò chuyện để xem tin nhắn tại đây",
"Start a playground chat": "Bắt đầu cuộc trò chuyện trong playground",
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Bắt đầu thu thanh toán toàn cầu mà không cần đăng ký công ty. Dành cho lập trình viên độc lập, chủ sở hữu OPC và startup. Waffo Pancake đóng vai trò Merchant of Record, chịu trách nhiệm tuân thủ cho việc thu thanh toán toàn cầu — thuế tiêu dùng, hóa đơn, quản lý đăng ký, hoàn tiền và tranh chấp thanh toán. Lập trình viên cá nhân có thể ra mắt nhanh và tập trung vào sản phẩm thay vì tuân thủ. Onboard trong vài phút — từ một prompt đến tích hợp hoàn chỉnh.",
@@ -4694,6 +4694,7 @@
"tiers": "tầng",
"Time": "Thời gian",
"Time Granularity": "Độ chi tiết thời gian",
+ "Time range": "Khoảng thời gian",
"Time remaining": "Thời gian còn lại",
"Time window for rate limiting": "Cửa sổ thời gian cho giới hạn tốc độ",
"Time-based": "Theo thời gian",
diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json
index 25250e24c7f5..c42ce58a196a 100644
--- a/web/src/i18n/locales/zh-TW.json
+++ b/web/src/i18n/locales/zh-TW.json
@@ -3257,7 +3257,6 @@
"overall": "總體",
"Overflow": "上溢",
"Overflow items": "超出項",
- "Overnight range": "跨日範圍",
"override": "覆蓋",
"Override": "覆蓋",
"Override auto-discovered endpoint": "覆蓋自動發現的端點",
@@ -4345,6 +4344,7 @@
"Standard": "標準",
"Standard price": "標準價格",
"Start": "開始",
+ "Start ≤ end: within the day; start > end: across midnight": "開始 ≤ 結束為當日區間,開始 > 結束為跨午夜區間",
"Start a conversation to see messages here": "開始對話以在此處查看訊息",
"Start a playground chat": "開始一場遊樂場對話",
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "無需註冊公司即可開始全球收款。面向獨立開發者、OPC 個體經營者和初創團隊構建。Waffo Pancake 作為你的登記商戶(Merchant of Record),承擔全球收款相關的合規負擔,包括消費稅、開票、訂閱管理、退款和拒付。個人開發者可以快速上線,專注產品而不是合規事務。幾分鐘即可完成入駐,從一個提示詞到完整整合。",
@@ -4694,6 +4694,7 @@
"tiers": "檔",
"Time": "時間",
"Time Granularity": "時間粒度",
+ "Time range": "時間範圍",
"Time remaining": "剩餘時間",
"Time window for rate limiting": "速率限制的時間窗口",
"Time-based": "含時間條件",
diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json
index 457b1c53f2b0..e0f35b410feb 100644
--- a/web/src/i18n/locales/zh.json
+++ b/web/src/i18n/locales/zh.json
@@ -3257,7 +3257,6 @@
"overall": "总体",
"Overflow": "上溢",
"Overflow items": "超出项",
- "Overnight range": "跨日范围",
"override": "覆盖",
"Override": "覆盖",
"Override auto-discovered endpoint": "覆盖自动发现的端点",
@@ -4345,6 +4344,7 @@
"Standard": "标准",
"Standard price": "标准价格",
"Start": "开始",
+ "Start ≤ end: within the day; start > end: across midnight": "开始 ≤ 结束为当日区间,开始 > 结束为跨零点区间",
"Start a conversation to see messages here": "开始对话以在此处查看消息",
"Start a playground chat": "开始一场游乐场对话",
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "无需注册公司即可开始全球收款。面向独立开发者、OPC 个体经营者和初创团队构建。Waffo Pancake 作为你的登记商户(Merchant of Record),承担全球收款相关的合规负担,包括消费税、开票、订阅管理、退款和拒付。个人开发者可以快速上线,专注产品而不是合规事务。几分钟即可完成入驻,从一个提示词到完整集成。",
@@ -4694,6 +4694,7 @@
"tiers": "档",
"Time": "时间",
"Time Granularity": "时间粒度",
+ "Time range": "时间范围",
"Time remaining": "剩余时间",
"Time window for rate limiting": "速率限制的时间窗口",
"Time-based": "含时间条件",
From 7037ac15bd8a29f8ee3e2b74e784bcdb75d67d22 Mon Sep 17 00:00:00 2001
From: Uladzislau <53997152+VladKabiak@users.noreply.github.com>
Date: Sat, 29 Aug 2026 12:40:21 +0200
Subject: [PATCH 60/99] fix(docker): add relaykit go.mod to dev build context
(#7072)
---
Dockerfile.dev | 3 +++
1 file changed, 3 insertions(+)
diff --git a/Dockerfile.dev b/Dockerfile.dev
index bdc5be42da77..47475e34fc4f 100644
--- a/Dockerfile.dev
+++ b/Dockerfile.dev
@@ -12,6 +12,9 @@ ENV GOEXPERIMENT=greenteagc
WORKDIR /build
ADD go.mod go.sum ./
+# relaykit is a local submodule referenced via replace; its go.mod must be
+# present for go mod download to resolve the main module graph.
+ADD relaykit/go.mod ./relaykit/go.mod
RUN go mod download
COPY . .
From eb48396d5fe97d27772d0cd5e3ca8aa5caa4f3e9 Mon Sep 17 00:00:00 2001
From: Calcium-Ion
Date: Sat, 29 Aug 2026 18:51:57 +0800
Subject: [PATCH 61/99] feat(task): replace built-in task adaptors with a
sandboxed JS plugin system (#7076)
---
CLAUDE.md | 14 +-
THIRD-PARTY-LICENSES.md | 3 +
common/api_type.go | 5 +
common/api_type_task_plugin_test.go | 14 +
common/init.go | 8 +
common/trusted_proxies.go | 54 +
constant/channel.go | 10 +
constant/channel_test.go | 12 +
constant/context_key.go | 3 +-
constant/env.go | 4 +
constant/task.go | 40 +-
constant/task_test.go | 27 +
controller/billing_option_test.go | 100 +
controller/channel-billing.go | 6 +-
controller/channel-test.go | 1 +
controller/channel.go | 49 +-
controller/channel_pin_retry_test.go | 95 +
controller/channel_task_plugin_bind_test.go | 131 ++
.../channel_task_plugin_validation_test.go | 41 +
controller/channel_upstream_update.go | 10 +-
controller/log.go | 3 +
controller/model.go | 20 +-
controller/option.go | 34 +
controller/plugin_endpoint_test.go | 47 +
controller/plugin_native_e2e_test.go | 244 +++
controller/plugin_protocol.go | 1276 ++++++++++++
controller/plugin_protocol_limiter.go | 157 ++
controller/plugin_protocol_limiter_test.go | 248 +++
controller/plugin_protocol_sdk_test.go | 305 +++
controller/plugin_protocol_test.go | 1555 +++++++++++++++
controller/relay.go | 277 ++-
controller/relay_task_plugin_test.go | 389 ++++
controller/task.go | 478 ++++-
controller/task_generic_test.go | 590 ++++++
controller/task_log_view_test.go | 137 ++
controller/task_plugin.go | 761 +++++++
controller/task_plugin_debug.go | 269 +++
controller/task_plugin_debug_test.go | 88 +
controller/task_plugin_test.go | 1148 +++++++++++
controller/video_proxy.go | 599 +++++-
controller/video_proxy_gemini.go | 294 ---
docs/plugin-api/README.md | 138 ++
docs/plugin-api/v1.d.ts | 48 +
docs/plugin-api/v1.md | 140 ++
docs/plugin-api/v1.schema.json | 46 +
dto/channel_constraints.go | 115 ++
dto/channel_constraints_test.go | 46 +
dto/plugin_protocol.go | 82 +
dto/task.go | 74 +-
dto/task_plugin.go | 23 +
e2e/doc_parse_test.go | 148 ++
go.mod | 7 +-
go.sum | 15 +-
logger/logger.go | 5 +-
main.go | 5 +
middleware/auth.go | 14 +-
middleware/body_cleanup.go | 14 +-
middleware/distributor.go | 247 ++-
middleware/distributor_test.go | 149 ++
middleware/jimeng_adapter.go | 67 -
middleware/kling_adapter.go | 52 -
middleware/logger.go | 1 +
middleware/task_artifact_access.go | 245 +++
middleware/task_artifact_access_test.go | 158 ++
middleware/task_plugin.go | 1397 +++++++++++++
middleware/task_plugin_origin_task_test.go | 500 +++++
middleware/task_plugin_test.go | 1698 ++++++++++++++++
middleware/trusted_proxies.go | 45 +-
middleware/utils.go | 23 +-
model/ability.go | 86 +-
model/channel.go | 11 +-
model/channel_cache.go | 50 +-
model/channel_constraint.go | 108 +
model/channel_constraint_test.go | 218 ++
model/log.go | 15 +
model/log_format_test.go | 48 +
model/main.go | 1 +
model/option.go | 21 +
model/option_task_plugin_test.go | 92 +
model/pricing.go | 66 +-
model/pricing_usage_schema_test.go | 69 +
model/task.go | 128 +-
model/task_cas_test.go | 36 +
model/task_openai_video_test.go | 93 +
model/task_plugin.go | 233 +++
model/task_plugin_channel_select_test.go | 58 +
model/task_plugin_test.go | 168 ++
pkg/billingexpr/compile.go | 67 +-
pkg/billingexpr/compile_usage_test.go | 21 +
pkg/billingexpr/expr.md | 79 +
pkg/billingexpr/run.go | 6 +
pkg/billingexpr/settle.go | 3 +
pkg/billingexpr/task_usage_test.go | 19 +
pkg/billingexpr/types.go | 27 +-
pkg/jsplugin/cli.go | 59 +
pkg/jsplugin/cli_test.go | 38 +
pkg/jsplugin/engine.go | 579 ++++++
pkg/jsplugin/engine_test.go | 372 ++++
pkg/jsplugin/fixture.go | 116 ++
pkg/jsplugin/fixture_test.go | 49 +
pkg/jsplugin/protocol_supports_test.go | 245 +++
pkg/jsplugin/registry.go | 1765 +++++++++++++++++
.../registry_disabled_factory_test.go | 109 +
pkg/jsplugin/registry_master_enabled_test.go | 147 ++
pkg/jsplugin/registry_test.go | 861 ++++++++
pkg/jsplugin/request.go | 41 +
pkg/jsplugin/routing.go | 931 +++++++++
pkg/jsplugin/routing_test.go | 1046 ++++++++++
pkg/jsplugin/utils.go | 139 ++
plugins/.oxfmtrc.json | 11 +
plugins/.oxlintrc.json | 22 +
plugins/alibaba_responses_test.go | 252 +++
plugins/builtin_plugins_test.go | 123 ++
plugins/doubao_responses_test.go | 31 +
plugins/embed.go | 41 +
plugins/google_responses_test.go | 30 +
plugins/hailuo_responses_test.go | 72 +
plugins/jimeng_responses_test.go | 27 +
plugins/kling_responses_test.go | 31 +
plugins/sora_responses_test.go | 25 +
plugins/sunoapi_responses_test.go | 195 ++
plugins/tasks/alibaba/plugin.js | 457 +++++
plugins/tasks/doubao/plugin.js | 501 +++++
plugins/tasks/google/plugin.js | 381 ++++
plugins/tasks/hailuo/plugin.js | 402 ++++
plugins/tasks/jimeng/plugin.js | 558 ++++++
plugins/tasks/kling/plugin.js | 479 +++++
plugins/tasks/sora/plugin.js | 304 +++
plugins/tasks/sunoapi/plugin.js | 336 ++++
plugins/tasks/vertex-ai/plugin.js | 394 ++++
plugins/tasks/vidu/plugin.js | 443 +++++
plugins/vertex_ai_responses_test.go | 203 ++
plugins/video_responses_test_helpers_test.go | 244 +++
plugins/vidu_responses_test.go | 31 +
relay/channel/adapter.go | 54 +-
relay/channel/api_request.go | 9 +-
relay/channel/api_request_test.go | 14 +
relay/channel/minimax/relay-minimax.go | 2 +-
relay/channel/replicate/adaptor.go | 4 +-
relay/channel/task/ali/adaptor.go | 638 ------
relay/channel/task/ali/adaptor_test.go | 172 --
relay/channel/task/ali/constants.go | 13 -
relay/channel/task/doubao/adaptor.go | 372 ----
relay/channel/task/doubao/constants.go | 56 -
relay/channel/task/gemini/adaptor.go | 293 ---
relay/channel/task/gemini/billing.go | 142 --
relay/channel/task/gemini/dto.go | 71 -
relay/channel/task/gemini/image.go | 100 -
relay/channel/task/hailuo/adaptor.go | 303 ---
relay/channel/task/hailuo/constants.go | 52 -
relay/channel/task/hailuo/models.go | 170 --
relay/channel/task/jimeng/adaptor.go | 481 -----
relay/channel/task/jsplugin/adaptor.go | 1430 +++++++++++++
relay/channel/task/jsplugin/adaptor_test.go | 1156 +++++++++++
relay/channel/task/jsplugin/auth.go | 45 +
relay/channel/task/jsplugin/auth_test.go | 77 +
relay/channel/task/kling/adaptor.go | 418 ----
relay/channel/task/sora/adaptor.go | 331 ----
relay/channel/task/sora/adaptor_test.go | 41 -
relay/channel/task/sora/constants.go | 8 -
relay/channel/task/suno/adaptor.go | 167 --
relay/channel/task/suno/models.go | 7 -
relay/channel/task/vertex/adaptor.go | 417 ----
relay/channel/task/vidu/adaptor.go | 301 ---
relay/channel/volcengine/adaptor.go | 8 +-
relay/channel/zhipu_4v/adaptor.go | 2 +-
relay/common/relay_info.go | 31 +-
relay/common/relay_utils.go | 4 +-
relay/common/relay_utils_test.go | 4 +-
relay/constant/relay_mode.go | 17 -
relay/plugin_protocol.go | 1077 ++++++++++
relay/plugin_protocol_test.go | 562 ++++++
relay/relay_adaptor.go | 133 +-
relay/relay_adaptor_jsplugin_test.go | 98 +
relay/relay_task.go | 226 ++-
relay/relay_task_test.go | 18 +
relay/task_platform_error_test.go | 36 +
relay/task_platform_test.go | 18 +
relaykit/dto/channel_settings.go | 1 +
router/api-router.go | 21 +-
router/main.go | 16 +-
router/plugin-router.go | 588 ++++++
router/plugin_router_test.go | 884 +++++++++
router/relay-router.go | 13 -
router/retired_frontend_routes_test.go | 26 -
router/task-plugin-protocol-router.go | 52 +
router/task-router.go | 37 +
router/task_plugin_options_router_test.go | 55 +
router/task_plugin_protocol_router_test.go | 31 +
router/task_router_test.go | 52 +
router/video-router.go | 47 +-
router/video_router_test.go | 151 ++
router/web-router.go | 31 +-
service/authz/authz_test.go | 42 +
service/authz/resources_task_plugin.go | 23 +
service/channel_select.go | 93 +-
service/channel_select_test.go | 152 ++
service/codex_channel_models.go | 2 +-
service/task_artifact_access.go | 137 ++
service/task_artifact_access_test.go | 99 +
service/task_artifact_store.go | 63 +
service/task_artifact_store_test.go | 31 +
service/task_billing.go | 69 +-
service/task_billing_test.go | 528 ++++-
service/task_plugin_audit.go | 98 +
service/task_plugin_view.go | 61 +
service/task_plugin_view_test.go | 64 +
service/task_polling.go | 248 +--
service/task_polling_test.go | 299 ++-
setting/billing_setting/tiered_billing.go | 172 +-
.../billing_setting/tiered_billing_test.go | 105 +
setting/system_setting/system_setting_old.go | 1 +
setting/system_setting/task_artifact.go | 56 +
setting/system_setting/task_artifact_store.go | 165 ++
.../task_artifact_store_test.go | 76 +
setting/system_setting/task_artifact_test.go | 33 +
setting/task_plugin.go | 120 ++
setting/task_plugin_test.go | 65 +
setting/task_pricing_setting/config.go | 61 +
setting/task_pricing_setting/config_test.go | 33 +
types/task_artifact.go | 9 +
web/.gitignore | 4 +-
web/bun.lock | 1 +
web/package.json | 4 +
web/rsbuild.config.ts | 2 +-
web/scripts/sync-i18n.mjs | 2 +
web/src/components/ai-elements/code-block.tsx | 32 +-
web/src/features/channels/api.ts | 10 +
.../drawers/channel-mutate-drawer.tsx | 108 +-
web/src/features/channels/constants.ts | 20 +-
.../__tests__/channel-type-options.test.ts | 44 +
web/src/features/channels/lib/channel-form.ts | 19 +-
.../__tests__/breakdown-tier-match.test.ts | 102 +
.../pricing/__tests__/dynamic-price.test.ts | 409 ++++
.../pricing/__tests__/task-expr.test.ts | 381 ++++
.../__tests__/task-matrix-display.test.ts | 169 ++
.../pricing/__tests__/task-matrix.test.ts | 465 +++++
.../components/dynamic-pricing-breakdown.tsx | 220 +-
.../components/model-billing-mode-badge.tsx | 9 +-
.../pricing/components/model-card.tsx | 94 +-
.../pricing/components/model-details.tsx | 251 ++-
.../pricing/components/pricing-columns.tsx | 42 +-
.../pricing/components/pricing-sidebar.tsx | 16 +-
web/src/features/pricing/constants.ts | 2 +
.../pricing/hooks/use-pricing-data.ts | 3 +-
web/src/features/pricing/lib/billing-expr.ts | 282 +++
web/src/features/pricing/lib/billing-mode.ts | 38 +
.../pricing/lib/breakdown-tier-match.ts | 74 +
web/src/features/pricing/lib/dynamic-price.ts | 236 ++-
web/src/features/pricing/lib/filters.ts | 9 +-
web/src/features/pricing/lib/task-expr.ts | 462 +++++
.../pricing/lib/task-matrix-display.ts | 51 +
web/src/features/pricing/types.ts | 20 +
.../__tests__/task-public-address.test.ts | 54 +
.../general/system-info-section.tsx | 33 +-
.../general/task-public-address.ts | 56 +
.../models/model-pricing-sheet.tsx | 142 +-
.../models/model-ratio-table-columns.tsx | 86 +-
.../models/model-ratio-visual-editor.tsx | 73 +-
.../models/task-pricing-matrix.tsx | 511 +++++
.../models/task-usage-pricing-editor.tsx | 631 ++++++
.../features/system-settings/site/index.tsx | 1 +
.../system-settings/site/section-registry.tsx | 1 +
web/src/features/system-settings/types.ts | 1 +
.../__tests__/enabled-option.test.ts | 66 +
.../__tests__/marketplace-panel.test.tsx | 164 ++
.../__tests__/marketplace.test.ts | 471 +++++
.../__tests__/plugin-card.test.tsx | 162 ++
.../__tests__/plugin-detail-sheet.test.tsx | 239 +++
.../__tests__/plugin-icon.test.ts | 106 +
.../task-plugins/__tests__/plugin-url.test.ts | 223 +++
.../__tests__/upload-dialog.test.tsx | 263 +++
.../__tests__/usage-schema-table.test.tsx | 82 +
web/src/features/task-plugins/api.ts | 191 ++
.../components/javascript-viewer.tsx | 60 +
.../components/marketplace-capabilities.tsx | 90 +
.../components/marketplace-install-dialog.tsx | 325 +++
.../components/marketplace-panel.tsx | 281 +++
.../components/marketplace-plugin-card.tsx | 173 ++
.../components/marketplace-sources-dialog.tsx | 214 ++
.../task-plugins/components/plugin-card.tsx | 159 ++
.../components/plugin-detail-sheet.tsx | 230 +++
.../task-plugins/components/plugin-icon.tsx | 55 +
.../components/plugin-metadata-card.tsx | 214 ++
.../components/plugin-sandbox.tsx | 96 +
.../components/plugin-source-picker.tsx | 112 ++
.../components/plugin-url-import-field.tsx | 140 ++
.../task-plugins/components/plugins-table.tsx | 435 ++++
.../task-plugins/components/source-diff.tsx | 87 +
.../task-plugins/components/upload-dialog.tsx | 214 ++
.../components/usage-schema-table.tsx | 92 +
web/src/features/task-plugins/index.tsx | 208 ++
.../task-plugins/lib/host-protocols.ts | 49 +
.../features/task-plugins/lib/marketplace.ts | 266 +++
.../features/task-plugins/lib/plugin-icon.ts | 87 +
.../features/task-plugins/lib/plugin-url.ts | 139 ++
web/src/features/task-plugins/types.ts | 151 ++
.../usage-logs/__tests__/access.test.ts | 37 +
.../usage-logs/__tests__/artifacts.test.ts | 357 ++++
.../__tests__/mobile-layout.test.ts | 39 +
.../usage-logs/__tests__/task-details.test.ts | 78 +
web/src/features/usage-logs/api.ts | 19 +-
.../components/__tests__/usage-facts.test.tsx | 166 ++
.../columns/common-logs-columns.tsx | 19 +-
.../components/columns/task-logs-columns.tsx | 289 ++-
.../components/dialogs/details-dialog.tsx | 103 +-
.../dialogs/task-details-dialog.tsx | 266 +++
.../components/plugin-author-link.tsx | 65 +
.../usage-logs/components/task-artifacts.tsx | 502 +++++
.../components/timing-metrics-cell.tsx | 7 +-
.../components/usage-logs-mobile-card.tsx | 15 +-
.../components/usage-logs-provider.tsx | 22 +-
.../components/usage-logs-table.tsx | 32 +-
web/src/features/usage-logs/constants.ts | 2 +
web/src/features/usage-logs/lib/columns.ts | 7 +-
.../features/usage-logs/lib/query-params.ts | 31 +
.../features/usage-logs/lib/task-artifacts.ts | 201 ++
.../features/usage-logs/lib/task-details.ts | 44 +
.../usage-logs/lib/task-mobile-layout.ts | 33 +
web/src/features/usage-logs/lib/utils.ts | 20 +-
web/src/features/usage-logs/types.ts | 72 +-
web/src/hooks/use-sidebar-data.ts | 7 +
.../i18n/locales/_reports/_sync-report.json | 47 -
web/src/i18n/locales/en.json | 217 ++
web/src/i18n/locales/fr.json | 217 ++
web/src/i18n/locales/ja.json | 217 ++
web/src/i18n/locales/ru.json | 217 ++
web/src/i18n/locales/vi.json | 217 ++
web/src/i18n/locales/zh-TW.json | 217 ++
web/src/i18n/locales/zh.json | 217 ++
web/src/i18n/static-keys.ts | 1 +
web/src/lib/__tests__/localized-text.test.ts | 116 ++
web/src/lib/admin-permissions.ts | 2 +
web/src/lib/localized-text.ts | 82 +
web/src/routeTree.gen.ts | 22 +
.../_authenticated/task-plugins/index.tsx | 31 +
336 files changed, 52290 insertions(+), 6326 deletions(-)
create mode 100644 common/api_type_task_plugin_test.go
create mode 100644 common/trusted_proxies.go
create mode 100644 constant/channel_test.go
create mode 100644 constant/task_test.go
create mode 100644 controller/billing_option_test.go
create mode 100644 controller/channel_pin_retry_test.go
create mode 100644 controller/channel_task_plugin_bind_test.go
create mode 100644 controller/channel_task_plugin_validation_test.go
create mode 100644 controller/plugin_endpoint_test.go
create mode 100644 controller/plugin_native_e2e_test.go
create mode 100644 controller/plugin_protocol.go
create mode 100644 controller/plugin_protocol_limiter.go
create mode 100644 controller/plugin_protocol_limiter_test.go
create mode 100644 controller/plugin_protocol_sdk_test.go
create mode 100644 controller/plugin_protocol_test.go
create mode 100644 controller/relay_task_plugin_test.go
create mode 100644 controller/task_generic_test.go
create mode 100644 controller/task_log_view_test.go
create mode 100644 controller/task_plugin.go
create mode 100644 controller/task_plugin_debug.go
create mode 100644 controller/task_plugin_debug_test.go
create mode 100644 controller/task_plugin_test.go
delete mode 100644 controller/video_proxy_gemini.go
create mode 100644 docs/plugin-api/README.md
create mode 100644 docs/plugin-api/v1.d.ts
create mode 100644 docs/plugin-api/v1.md
create mode 100644 docs/plugin-api/v1.schema.json
create mode 100644 dto/channel_constraints.go
create mode 100644 dto/channel_constraints_test.go
create mode 100644 dto/plugin_protocol.go
create mode 100644 dto/task_plugin.go
create mode 100644 e2e/doc_parse_test.go
create mode 100644 middleware/distributor_test.go
delete mode 100644 middleware/jimeng_adapter.go
delete mode 100644 middleware/kling_adapter.go
create mode 100644 middleware/task_artifact_access.go
create mode 100644 middleware/task_artifact_access_test.go
create mode 100644 middleware/task_plugin.go
create mode 100644 middleware/task_plugin_origin_task_test.go
create mode 100644 middleware/task_plugin_test.go
create mode 100644 model/channel_constraint.go
create mode 100644 model/channel_constraint_test.go
create mode 100644 model/option_task_plugin_test.go
create mode 100644 model/pricing_usage_schema_test.go
create mode 100644 model/task_openai_video_test.go
create mode 100644 model/task_plugin.go
create mode 100644 model/task_plugin_channel_select_test.go
create mode 100644 model/task_plugin_test.go
create mode 100644 pkg/billingexpr/compile_usage_test.go
create mode 100644 pkg/billingexpr/task_usage_test.go
create mode 100644 pkg/jsplugin/cli.go
create mode 100644 pkg/jsplugin/cli_test.go
create mode 100644 pkg/jsplugin/engine.go
create mode 100644 pkg/jsplugin/engine_test.go
create mode 100644 pkg/jsplugin/fixture.go
create mode 100644 pkg/jsplugin/fixture_test.go
create mode 100644 pkg/jsplugin/protocol_supports_test.go
create mode 100644 pkg/jsplugin/registry.go
create mode 100644 pkg/jsplugin/registry_disabled_factory_test.go
create mode 100644 pkg/jsplugin/registry_master_enabled_test.go
create mode 100644 pkg/jsplugin/registry_test.go
create mode 100644 pkg/jsplugin/request.go
create mode 100644 pkg/jsplugin/routing.go
create mode 100644 pkg/jsplugin/routing_test.go
create mode 100644 pkg/jsplugin/utils.go
create mode 100644 plugins/.oxfmtrc.json
create mode 100644 plugins/.oxlintrc.json
create mode 100644 plugins/alibaba_responses_test.go
create mode 100644 plugins/builtin_plugins_test.go
create mode 100644 plugins/doubao_responses_test.go
create mode 100644 plugins/embed.go
create mode 100644 plugins/google_responses_test.go
create mode 100644 plugins/hailuo_responses_test.go
create mode 100644 plugins/jimeng_responses_test.go
create mode 100644 plugins/kling_responses_test.go
create mode 100644 plugins/sora_responses_test.go
create mode 100644 plugins/sunoapi_responses_test.go
create mode 100644 plugins/tasks/alibaba/plugin.js
create mode 100644 plugins/tasks/doubao/plugin.js
create mode 100644 plugins/tasks/google/plugin.js
create mode 100644 plugins/tasks/hailuo/plugin.js
create mode 100644 plugins/tasks/jimeng/plugin.js
create mode 100644 plugins/tasks/kling/plugin.js
create mode 100644 plugins/tasks/sora/plugin.js
create mode 100644 plugins/tasks/sunoapi/plugin.js
create mode 100644 plugins/tasks/vertex-ai/plugin.js
create mode 100644 plugins/tasks/vidu/plugin.js
create mode 100644 plugins/vertex_ai_responses_test.go
create mode 100644 plugins/video_responses_test_helpers_test.go
create mode 100644 plugins/vidu_responses_test.go
delete mode 100644 relay/channel/task/ali/adaptor.go
delete mode 100644 relay/channel/task/ali/adaptor_test.go
delete mode 100644 relay/channel/task/ali/constants.go
delete mode 100644 relay/channel/task/doubao/adaptor.go
delete mode 100644 relay/channel/task/doubao/constants.go
delete mode 100644 relay/channel/task/gemini/adaptor.go
delete mode 100644 relay/channel/task/gemini/billing.go
delete mode 100644 relay/channel/task/gemini/dto.go
delete mode 100644 relay/channel/task/gemini/image.go
delete mode 100644 relay/channel/task/hailuo/adaptor.go
delete mode 100644 relay/channel/task/hailuo/constants.go
delete mode 100644 relay/channel/task/hailuo/models.go
delete mode 100644 relay/channel/task/jimeng/adaptor.go
create mode 100644 relay/channel/task/jsplugin/adaptor.go
create mode 100644 relay/channel/task/jsplugin/adaptor_test.go
create mode 100644 relay/channel/task/jsplugin/auth.go
create mode 100644 relay/channel/task/jsplugin/auth_test.go
delete mode 100644 relay/channel/task/kling/adaptor.go
delete mode 100644 relay/channel/task/sora/adaptor.go
delete mode 100644 relay/channel/task/sora/adaptor_test.go
delete mode 100644 relay/channel/task/sora/constants.go
delete mode 100644 relay/channel/task/suno/adaptor.go
delete mode 100644 relay/channel/task/suno/models.go
delete mode 100644 relay/channel/task/vertex/adaptor.go
delete mode 100644 relay/channel/task/vidu/adaptor.go
create mode 100644 relay/plugin_protocol.go
create mode 100644 relay/plugin_protocol_test.go
create mode 100644 relay/relay_adaptor_jsplugin_test.go
create mode 100644 relay/relay_task_test.go
create mode 100644 relay/task_platform_error_test.go
create mode 100644 relay/task_platform_test.go
create mode 100644 router/plugin-router.go
create mode 100644 router/plugin_router_test.go
delete mode 100644 router/retired_frontend_routes_test.go
create mode 100644 router/task-plugin-protocol-router.go
create mode 100644 router/task-router.go
create mode 100644 router/task_plugin_options_router_test.go
create mode 100644 router/task_plugin_protocol_router_test.go
create mode 100644 router/task_router_test.go
create mode 100644 router/video_router_test.go
create mode 100644 service/authz/resources_task_plugin.go
create mode 100644 service/channel_select_test.go
create mode 100644 service/task_artifact_access.go
create mode 100644 service/task_artifact_access_test.go
create mode 100644 service/task_artifact_store.go
create mode 100644 service/task_artifact_store_test.go
create mode 100644 service/task_plugin_audit.go
create mode 100644 service/task_plugin_view.go
create mode 100644 service/task_plugin_view_test.go
create mode 100644 setting/billing_setting/tiered_billing_test.go
create mode 100644 setting/system_setting/task_artifact.go
create mode 100644 setting/system_setting/task_artifact_store.go
create mode 100644 setting/system_setting/task_artifact_store_test.go
create mode 100644 setting/system_setting/task_artifact_test.go
create mode 100644 setting/task_plugin.go
create mode 100644 setting/task_plugin_test.go
create mode 100644 setting/task_pricing_setting/config.go
create mode 100644 setting/task_pricing_setting/config_test.go
create mode 100644 types/task_artifact.go
create mode 100644 web/src/features/channels/lib/__tests__/channel-type-options.test.ts
create mode 100644 web/src/features/pricing/__tests__/breakdown-tier-match.test.ts
create mode 100644 web/src/features/pricing/__tests__/dynamic-price.test.ts
create mode 100644 web/src/features/pricing/__tests__/task-expr.test.ts
create mode 100644 web/src/features/pricing/__tests__/task-matrix-display.test.ts
create mode 100644 web/src/features/pricing/__tests__/task-matrix.test.ts
create mode 100644 web/src/features/pricing/lib/billing-mode.ts
create mode 100644 web/src/features/pricing/lib/breakdown-tier-match.ts
create mode 100644 web/src/features/pricing/lib/task-expr.ts
create mode 100644 web/src/features/pricing/lib/task-matrix-display.ts
create mode 100644 web/src/features/system-settings/__tests__/task-public-address.test.ts
create mode 100644 web/src/features/system-settings/general/task-public-address.ts
create mode 100644 web/src/features/system-settings/models/task-pricing-matrix.tsx
create mode 100644 web/src/features/system-settings/models/task-usage-pricing-editor.tsx
create mode 100644 web/src/features/task-plugins/__tests__/enabled-option.test.ts
create mode 100644 web/src/features/task-plugins/__tests__/marketplace-panel.test.tsx
create mode 100644 web/src/features/task-plugins/__tests__/marketplace.test.ts
create mode 100644 web/src/features/task-plugins/__tests__/plugin-card.test.tsx
create mode 100644 web/src/features/task-plugins/__tests__/plugin-detail-sheet.test.tsx
create mode 100644 web/src/features/task-plugins/__tests__/plugin-icon.test.ts
create mode 100644 web/src/features/task-plugins/__tests__/plugin-url.test.ts
create mode 100644 web/src/features/task-plugins/__tests__/upload-dialog.test.tsx
create mode 100644 web/src/features/task-plugins/__tests__/usage-schema-table.test.tsx
create mode 100644 web/src/features/task-plugins/api.ts
create mode 100644 web/src/features/task-plugins/components/javascript-viewer.tsx
create mode 100644 web/src/features/task-plugins/components/marketplace-capabilities.tsx
create mode 100644 web/src/features/task-plugins/components/marketplace-install-dialog.tsx
create mode 100644 web/src/features/task-plugins/components/marketplace-panel.tsx
create mode 100644 web/src/features/task-plugins/components/marketplace-plugin-card.tsx
create mode 100644 web/src/features/task-plugins/components/marketplace-sources-dialog.tsx
create mode 100644 web/src/features/task-plugins/components/plugin-card.tsx
create mode 100644 web/src/features/task-plugins/components/plugin-detail-sheet.tsx
create mode 100644 web/src/features/task-plugins/components/plugin-icon.tsx
create mode 100644 web/src/features/task-plugins/components/plugin-metadata-card.tsx
create mode 100644 web/src/features/task-plugins/components/plugin-sandbox.tsx
create mode 100644 web/src/features/task-plugins/components/plugin-source-picker.tsx
create mode 100644 web/src/features/task-plugins/components/plugin-url-import-field.tsx
create mode 100644 web/src/features/task-plugins/components/plugins-table.tsx
create mode 100644 web/src/features/task-plugins/components/source-diff.tsx
create mode 100644 web/src/features/task-plugins/components/upload-dialog.tsx
create mode 100644 web/src/features/task-plugins/components/usage-schema-table.tsx
create mode 100644 web/src/features/task-plugins/index.tsx
create mode 100644 web/src/features/task-plugins/lib/host-protocols.ts
create mode 100644 web/src/features/task-plugins/lib/marketplace.ts
create mode 100644 web/src/features/task-plugins/lib/plugin-icon.ts
create mode 100644 web/src/features/task-plugins/lib/plugin-url.ts
create mode 100644 web/src/features/task-plugins/types.ts
create mode 100644 web/src/features/usage-logs/__tests__/access.test.ts
create mode 100644 web/src/features/usage-logs/__tests__/artifacts.test.ts
create mode 100644 web/src/features/usage-logs/__tests__/mobile-layout.test.ts
create mode 100644 web/src/features/usage-logs/__tests__/task-details.test.ts
create mode 100644 web/src/features/usage-logs/components/__tests__/usage-facts.test.tsx
create mode 100644 web/src/features/usage-logs/components/dialogs/task-details-dialog.tsx
create mode 100644 web/src/features/usage-logs/components/plugin-author-link.tsx
create mode 100644 web/src/features/usage-logs/components/task-artifacts.tsx
create mode 100644 web/src/features/usage-logs/lib/query-params.ts
create mode 100644 web/src/features/usage-logs/lib/task-artifacts.ts
create mode 100644 web/src/features/usage-logs/lib/task-details.ts
create mode 100644 web/src/features/usage-logs/lib/task-mobile-layout.ts
delete mode 100644 web/src/i18n/locales/_reports/_sync-report.json
create mode 100644 web/src/lib/__tests__/localized-text.test.ts
create mode 100644 web/src/lib/localized-text.ts
create mode 100644 web/src/routes/_authenticated/task-plugins/index.tsx
diff --git a/CLAUDE.md b/CLAUDE.md
index ff3c01f0c766..97ba749380e3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,7 +1,15 @@
# CLAUDE.md — Project Conventions for new-api
-@AGENTS.md
+## MANDATORY: Read AGENTS.md with the Read tool
-## Claude Code
+Do not treat `@AGENTS.md` as loaded. Claude Code does not reliably inline that import.
-- Follow the shared project instructions imported from `AGENTS.md`.
\ No newline at end of file
+Before any planning, coding, reviewing, or answering a project question, you MUST call the Read tool on the repo-root file `AGENTS.md` and wait for the full contents. This is the first action of every session and every new task.
+
+Rules:
+
+- Do not start from memory, summaries, or this file alone.
+- Do not skip the Read because a previous turn mentioned AGENTS.md.
+- Do not replace the Read with a grep, glob, or partial skim.
+- After reading, follow every rule in `AGENTS.md` for the rest of the work.
+- If the task touches `web/`, also Read `web/AGENTS.md` before editing frontend files.
diff --git a/THIRD-PARTY-LICENSES.md b/THIRD-PARTY-LICENSES.md
index e04a8cd7028b..e67e61395f15 100644
--- a/THIRD-PARTY-LICENSES.md
+++ b/THIRD-PARTY-LICENSES.md
@@ -34,11 +34,13 @@ Transitive dependencies should be audited before a final external release.
| backend | production | Go | `github.com/google/uuid` | `v1.6.0` | BSD-3-Clause |
| backend | production | Go | `github.com/gorilla/websocket` | `v1.5.0` | BSD-2-Clause |
| backend | production | Go | `github.com/grafana/pyroscope-go` | `v1.2.7` | Apache-2.0 |
+| backend | production | Go | `github.com/grafana/sobek` | `v0.0.0-20260708062710-267a0e055bb4` | MIT |
| backend | production | Go | `github.com/jfreymuth/oggvorbis` | `v1.0.5` | MIT |
| backend | production | Go | `github.com/jinzhu/copier` | `v0.4.0` | MIT |
| backend | production | Go | `github.com/joho/godotenv` | `v1.5.1` | MIT |
| backend | production | Go | `github.com/mewkiz/flac` | `v1.0.13` | Unlicense |
| backend | production | Go | `github.com/nicksnyder/go-i18n/v2` | `v2.6.1` | MIT |
+| backend | test | Go | `github.com/openai/openai-go` | `v1.12.0` | Apache-2.0 |
| backend | production | Go | `github.com/pkg/errors` | `v0.9.1` | BSD-2-Clause |
| backend | production | Go | `github.com/pquerna/otp` | `v1.5.0` | Apache-2.0 |
| backend | production | Go | `github.com/samber/hot` | `v0.11.0` | MIT |
@@ -66,6 +68,7 @@ Transitive dependencies should be audited before a final external release.
| backend | production | Go | `gorm.io/gorm` | `v1.25.2` | MIT |
| backend | production | Go | `github.com/expr-lang/expr` | `v1.17.8` | MIT |
| web | production | npm | `@base-ui/react` | `1.6.0` | MIT |
+| web | production | npm | `@codemirror/lang-javascript` | `6.2.5` | MIT |
| web | production | npm | `@codemirror/lang-markdown` | `6.5.1` | MIT |
| web | production | npm | `@codemirror/language` | `6.12.4` | MIT |
| web | production | npm | `@codemirror/state` | `6.7.1` | MIT |
diff --git a/common/api_type.go b/common/api_type.go
index 82b088cc8fc9..b4ca7062b614 100644
--- a/common/api_type.go
+++ b/common/api_type.go
@@ -83,6 +83,11 @@ func ChannelType2APIType(channelType int) (int, bool) {
apiType = constant.APITypeNewAPI
}
if apiType == -1 {
+ // Task plugin channels are served by the task relay and must never
+ // fall back to the OpenAI adaptor.
+ if channelType == constant.ChannelTypeTaskPlugin {
+ return -1, false
+ }
return constant.APITypeOpenAI, false
}
return apiType, true
diff --git a/common/api_type_task_plugin_test.go b/common/api_type_task_plugin_test.go
new file mode 100644
index 000000000000..3a13e083bec3
--- /dev/null
+++ b/common/api_type_task_plugin_test.go
@@ -0,0 +1,14 @@
+package common
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestTaskPluginChannelHasNoOrdinaryAPIType(t *testing.T) {
+ apiType, ok := ChannelType2APIType(constant.ChannelTypeTaskPlugin)
+ assert.Equal(t, -1, apiType)
+ assert.False(t, ok)
+}
diff --git a/common/init.go b/common/init.go
index 4d4c62b27cac..323fd207dddd 100644
--- a/common/init.go
+++ b/common/init.go
@@ -187,6 +187,8 @@ func initConstantEnv() {
constant.GetMediaToken = GetEnvOrDefaultBool("GET_MEDIA_TOKEN", true)
constant.GetMediaTokenNotStream = GetEnvOrDefaultBool("GET_MEDIA_TOKEN_NOT_STREAM", false)
constant.UpdateTask = GetEnvOrDefaultBool("UPDATE_TASK", true)
+ constant.TaskPluginEnabled = GetEnvOrDefaultBool("TASK_PLUGIN_ENABLED", true)
+ constant.TaskPluginOverrideEnabled = GetEnvOrDefaultBool("TASK_PLUGIN_OVERRIDE_ENABLED", true)
constant.AzureDefaultAPIVersion = GetEnvOrDefaultString("AZURE_DEFAULT_API_VERSION", "2025-04-01-preview")
constant.NotifyLimitCount = GetEnvOrDefault("NOTIFY_LIMIT_COUNT", 2)
constant.NotificationLimitDurationMinute = GetEnvOrDefault("NOTIFICATION_LIMIT_DURATION_MINUTE", 10)
@@ -198,6 +200,12 @@ func initConstantEnv() {
constant.TaskQueryLimit = GetEnvOrDefault("TASK_QUERY_LIMIT", 1000)
// 异步任务超时时间(分钟),超过此时间未完成的任务将被标记为失败并退款。0 表示禁用。
constant.TaskTimeoutMinutes = GetEnvOrDefault("TASK_TIMEOUT_MINUTES", 1440)
+ // 声明式任务协议桥只观察数据库;这些值控制一次客户端观察连接,
+ // 不改变后台轮询或结算生命周期。
+ constant.TaskPluginProtocolTimeoutSeconds = GetEnvOrDefault("TASK_PLUGIN_PROTOCOL_TIMEOUT_SECONDS", 600)
+ constant.TaskPluginProtocolTickMilliseconds = GetEnvOrDefault("TASK_PLUGIN_PROTOCOL_TICK_MILLISECONDS", 2000)
+ constant.TaskPluginProtocolTickJitterMilliseconds = GetEnvOrDefault("TASK_PLUGIN_PROTOCOL_TICK_JITTER_MILLISECONDS", 500)
+ constant.TaskPluginProtocolHeartbeatSeconds = GetEnvOrDefault("TASK_PLUGIN_PROTOCOL_HEARTBEAT_SECONDS", 15)
soraPatchStr := GetEnvOrDefaultString("TASK_PRICE_PATCH", "")
if soraPatchStr != "" {
diff --git a/common/trusted_proxies.go b/common/trusted_proxies.go
new file mode 100644
index 000000000000..363dc019fc28
--- /dev/null
+++ b/common/trusted_proxies.go
@@ -0,0 +1,54 @@
+package common
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+)
+
+var defaultTrustedProxyCIDRs = []string{
+ "127.0.0.0/8",
+ "::1",
+ "10.0.0.0/8",
+ "172.16.0.0/12",
+ "192.168.0.0/16",
+ "fc00::/7",
+}
+
+// ResolveTrustedProxies parses TRUSTED_PROXIES without applying it to an
+// engine. The returned slice can be reused by the outer and plugin engines.
+func ResolveTrustedProxies(raw string) (trustedProxies []string, usedDefaults bool, err error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return append([]string(nil), defaultTrustedProxyCIDRs...), true, nil
+ }
+ if strings.EqualFold(raw, "none") {
+ return nil, false, nil
+ }
+
+ parts := strings.Split(raw, ",")
+ trustedProxies = make([]string, 0, len(parts))
+ for _, part := range parts {
+ trustedProxy := strings.TrimSpace(part)
+ if trustedProxy == "" {
+ continue
+ }
+ if strings.EqualFold(trustedProxy, "none") {
+ return nil, false, errors.New("TRUSTED_PROXIES=none must be used alone")
+ }
+ trustedProxies = append(trustedProxies, trustedProxy)
+ }
+ if len(trustedProxies) == 0 {
+ return nil, false, errors.New("TRUSTED_PROXIES does not contain an IP address or CIDR")
+ }
+ return trustedProxies, false, nil
+}
+
+func ConfigureTrustedProxies(engine *gin.Engine, trustedProxies []string) error {
+ if err := engine.SetTrustedProxies(trustedProxies); err != nil {
+ return fmt.Errorf("invalid TRUSTED_PROXIES: %w", err)
+ }
+ return nil
+}
diff --git a/constant/channel.go b/constant/channel.go
index 2a6c4a31c138..354dd0e14b2b 100644
--- a/constant/channel.go
+++ b/constant/channel.go
@@ -58,6 +58,7 @@ const (
ChannelTypeAdvancedCustom = 58
ChannelTypeSub2API = 59
ChannelTypeNewAPI = 60
+ ChannelTypeTaskPlugin = 61
ChannelTypeDummy // this one is only for count, do not add any channel after this
)
@@ -124,6 +125,14 @@ var ChannelBaseURLs = []string{
"", //58
"", //59
"", //60
+ "", //61
+}
+
+func GetChannelBaseURL(channelType int) string {
+ if channelType < 0 || channelType >= len(ChannelBaseURLs) {
+ return ""
+ }
+ return ChannelBaseURLs[channelType]
}
var ChannelTypeNames = map[int]string{
@@ -184,6 +193,7 @@ var ChannelTypeNames = map[int]string{
ChannelTypeAdvancedCustom: "Advanced Custom",
ChannelTypeSub2API: "Sub2API",
ChannelTypeNewAPI: "New API",
+ ChannelTypeTaskPlugin: "Task Plugin",
}
func GetChannelTypeName(channelType int) string {
diff --git a/constant/channel_test.go b/constant/channel_test.go
new file mode 100644
index 000000000000..92faa26d075d
--- /dev/null
+++ b/constant/channel_test.go
@@ -0,0 +1,12 @@
+package constant
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGetChannelBaseURLIsBoundsSafe(t *testing.T) {
+ assert.Empty(t, GetChannelBaseURL(ChannelTypeTaskPlugin))
+ assert.Empty(t, GetChannelBaseURL(9999))
+}
diff --git a/constant/context_key.go b/constant/context_key.go
index ccb8010f9476..93a18ba9af01 100644
--- a/constant/context_key.go
+++ b/constant/context_key.go
@@ -15,7 +15,8 @@ const (
ContextKeyTokenKey ContextKey = "token_key"
ContextKeyTokenId ContextKey = "token_id"
ContextKeyTokenGroup ContextKey = "token_group"
- ContextKeyTokenSpecificChannelId ContextKey = "specific_channel_id"
+ ContextKeyOriginTasks ContextKey = "origin_tasks"
+ ContextKeyChannelConstraints ContextKey = "channel_constraints"
ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled"
ContextKeyTokenModelLimit ContextKey = "token_model_limit"
ContextKeyTokenCrossGroupRetry ContextKey = "token_cross_group_retry"
diff --git a/constant/env.go b/constant/env.go
index 512bfc31126b..a6de36bce60c 100644
--- a/constant/env.go
+++ b/constant/env.go
@@ -18,6 +18,10 @@ var GenerateDefaultToken bool
var ErrorLogEnabled bool
var TaskQueryLimit int
var TaskTimeoutMinutes int
+var TaskPluginProtocolTimeoutSeconds int
+var TaskPluginProtocolTickMilliseconds int
+var TaskPluginProtocolTickJitterMilliseconds int
+var TaskPluginProtocolHeartbeatSeconds int
// temporary variable for sora patch, will be removed in future
var TaskPricePatches []string
diff --git a/constant/task.go b/constant/task.go
index ecccf4dfe119..aee856831156 100644
--- a/constant/task.go
+++ b/constant/task.go
@@ -8,17 +8,35 @@ const (
)
const (
- SunoActionMusic = "MUSIC"
- SunoActionLyrics = "LYRICS"
-
- TaskActionGenerate = "generate"
- TaskActionTextGenerate = "textGenerate"
- TaskActionFirstTailGenerate = "firstTailGenerate"
- TaskActionReferenceGenerate = "referenceGenerate"
- TaskActionRemix = "remixGenerate"
+ TaskActionImageToVideo = "image_to_video"
+ TaskActionTextToVideo = "text_to_video"
+ TaskActionFirstTailToVideo = "first_tail_to_video"
+ TaskActionReferenceToVideo = "reference_to_video"
+ TaskActionRemix = "remix"
)
-var SunoModel2Action = map[string]string{
- "suno_music": SunoActionMusic,
- "suno_lyrics": SunoActionLyrics,
+var legacyTaskActionAliases = map[string]string{
+ "generate": TaskActionImageToVideo,
+ "textGenerate": TaskActionTextToVideo,
+ "firstTailGenerate": TaskActionFirstTailToVideo,
+ "referenceGenerate": TaskActionReferenceToVideo,
+ "remixGenerate": TaskActionRemix,
+}
+
+// TaskPluginEnabled is the master switch for the whole task-plugin system.
+// When disabled, factory and override plugins both stop serving.
+var TaskPluginEnabled = true
+
+// TaskPluginOverrideEnabled controls whether the database override layer is
+// active. When disabled, uploaded plugins are ignored and factory plugins are
+// used instead; the factory layer is unaffected.
+var TaskPluginOverrideEnabled = true
+
+// NormalizeTaskAction maps persisted legacy action names to the canonical task
+// action vocabulary. Unknown platform-specific actions pass through unchanged.
+func NormalizeTaskAction(action string) string {
+ if canonical, ok := legacyTaskActionAliases[action]; ok {
+ return canonical
+ }
+ return action
}
diff --git a/constant/task_test.go b/constant/task_test.go
new file mode 100644
index 000000000000..9b0065df69f1
--- /dev/null
+++ b/constant/task_test.go
@@ -0,0 +1,27 @@
+package constant
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNormalizeTaskAction(t *testing.T) {
+ tests := map[string]string{
+ "generate": TaskActionImageToVideo,
+ "textGenerate": TaskActionTextToVideo,
+ "firstTailGenerate": TaskActionFirstTailToVideo,
+ "referenceGenerate": TaskActionReferenceToVideo,
+ "remixGenerate": TaskActionRemix,
+ TaskActionTextToVideo: TaskActionTextToVideo,
+ "MUSIC": "MUSIC",
+ "custom_action": "custom_action",
+ "": "",
+ }
+
+ for input, expected := range tests {
+ t.Run(input, func(t *testing.T) {
+ assert.Equal(t, expected, NormalizeTaskAction(input))
+ })
+ }
+}
diff --git a/controller/billing_option_test.go b/controller/billing_option_test.go
new file mode 100644
index 000000000000..c02334d52ab0
--- /dev/null
+++ b/controller/billing_option_test.go
@@ -0,0 +1,100 @@
+package controller
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUpdateOptionRejectsInvalidTaskBillingExpressions(t *testing.T) {
+ const pluginKey = "billing-save-probe"
+ const modelName = "billing-save-model"
+ source := `
+export const meta = {
+ apiVersion: 1, key: "billing-save-probe", name: "Billing Save Probe", version: "1.0.0", author: {name: "Test"},
+ models: ["billing-save-model"], fetchMode: "per_task",
+ usageSchema: {seconds: {type: "number", unit: "second"}}
+};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+`
+ _, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(pluginKey) })
+
+ tests := []struct {
+ name string
+ expression string
+ errorText string
+ }{
+ {
+ name: "invalid syntax",
+ expression: `tier("base",`,
+ errorText: "expr compile error",
+ },
+ {
+ name: "undeclared usage key",
+ expression: `tier("base", u("clips") * 0.1)`,
+ errorText: `usage key \"clips\" is not declared`,
+ },
+ }
+
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ expressions, marshalErr := common.Marshal(map[string]string{modelName: testCase.expression})
+ require.NoError(t, marshalErr)
+ body, marshalErr := common.Marshal(OptionUpdateRequest{
+ Key: "billing_setting.billing_expr",
+ Value: string(expressions),
+ })
+ require.NoError(t, marshalErr)
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Request = httptest.NewRequest(http.MethodPut, "/api/option/", strings.NewReader(string(body)))
+
+ UpdateOption(context)
+
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ assert.Contains(t, recorder.Body.String(), `"success":false`)
+ assert.Contains(t, recorder.Body.String(), modelName)
+ assert.Contains(t, recorder.Body.String(), testCase.errorText)
+ })
+ }
+}
+
+func TestUpdateOptionRejectsUsageExpressionWithoutTaskPlugin(t *testing.T) {
+ const modelName = "billing-save-model-without-plugin"
+ expressions, err := common.Marshal(map[string]string{
+ modelName: `u("mode") == "std" ? 1 : 2`,
+ })
+ require.NoError(t, err)
+ body, err := common.Marshal(OptionUpdateRequest{
+ Key: "billing_setting.billing_expr",
+ Value: string(expressions),
+ })
+ require.NoError(t, err)
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Request = httptest.NewRequest(
+ http.MethodPut,
+ "/api/option/",
+ strings.NewReader(string(body)),
+ )
+
+ UpdateOption(context)
+
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ assert.Contains(t, recorder.Body.String(), `"success":false`)
+ assert.Contains(t, recorder.Body.String(), modelName)
+ assert.Contains(t, recorder.Body.String(), "mode")
+ assert.Contains(t, recorder.Body.String(), "no task plugin usage schema")
+}
diff --git a/controller/channel-billing.go b/controller/channel-billing.go
index 5974628d01ef..36cd887b805f 100644
--- a/controller/channel-billing.go
+++ b/controller/channel-billing.go
@@ -463,7 +463,7 @@ func updateChannelBalance(channel *model.Channel) (channelBalanceResult, error)
}
func updateStandardChannelBalance(channel *model.Channel) (float64, error) {
- baseURL := constant.ChannelBaseURLs[channel.Type]
+ baseURL := constant.GetChannelBaseURL(channel.Type)
if channel.GetBaseURL() == "" {
channel.BaseURL = &baseURL
}
@@ -538,6 +538,10 @@ func UpdateChannelBalance(c *gin.Context) {
common.ApiError(c, err)
return
}
+ if channel.Type == constant.ChannelTypeTaskPlugin {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": "Task Plugin channels do not support balance queries"})
+ return
+ }
if channel.ChannelInfo.IsMultiKey {
c.JSON(http.StatusOK, gin.H{
"success": false,
diff --git a/controller/channel-test.go b/controller/channel-test.go
index e1535d26f669..895099a10627 100644
--- a/controller/channel-test.go
+++ b/controller/channel-test.go
@@ -82,6 +82,7 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
constant.ChannelTypeJimeng,
constant.ChannelTypeDoubaoVideo,
constant.ChannelTypeVidu,
+ constant.ChannelTypeTaskPlugin,
}
if lo.Contains(unsupportedTestChannelTypes, channel.Type) {
channelTypeName := constant.GetChannelTypeName(channel.Type)
diff --git a/controller/channel.go b/controller/channel.go
index 3a1e58328923..19ddca8e6a07 100644
--- a/controller/channel.go
+++ b/controller/channel.go
@@ -13,6 +13,7 @@ import (
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
relaychannel "github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/ollama"
relaycommon "github.com/QuantumNous/new-api/relay/common"
@@ -480,6 +481,21 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
if err := channel.ValidateSettings(); err != nil {
return fmt.Errorf("渠道额外设置[channel setting] 格式错误:%s", err.Error())
}
+ if channel.Type == constant.ChannelTypeTaskPlugin {
+ pluginKey := strings.TrimSpace(channel.GetSetting().TaskPluginKey)
+ if pluginKey == "" {
+ return fmt.Errorf("task plugin key is required")
+ }
+ if len(pluginKey) > 30 {
+ return fmt.Errorf("task plugin key must not exceed 30 characters")
+ }
+ if _, ok := jsplugin.DefaultRegistry.Get(pluginKey); !ok {
+ return fmt.Errorf("task plugin %q is not registered", pluginKey)
+ }
+ if channel.BaseURL == nil || strings.TrimSpace(*channel.BaseURL) == "" {
+ return fmt.Errorf("base URL is required for task plugin channels")
+ }
+ }
if channel.Type == constant.ChannelTypeNewAPI && strings.TrimSpace(channel.GetBaseURL()) == "" {
return fmt.Errorf("New API channel base URL cannot be empty")
@@ -617,6 +633,15 @@ func AddChannel(c *gin.Context) {
return
}
+ if addChannelRequest.Channel != nil && addChannelRequest.Channel.Type == constant.ChannelTypeTaskPlugin &&
+ !authz.Can(c.GetInt("id"), c.GetInt("role"), authz.TaskPluginBind) {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "task plugin channels require the task_plugin.bind permission",
+ })
+ return
+ }
+
// 使用统一的校验函数
if err := validateChannel(addChannelRequest.Channel, true); err != nil {
c.JSON(http.StatusOK, gin.H{
@@ -964,6 +989,15 @@ func UpdateChannel(c *gin.Context) {
}
clearChannelReadOnlyFields(&channel, requestData)
+ if channel.Type == constant.ChannelTypeTaskPlugin &&
+ !authz.Can(c.GetInt("id"), c.GetInt("role"), authz.TaskPluginBind) {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "task plugin channels require the task_plugin.bind permission",
+ })
+ return
+ }
+
// 使用统一的校验函数
if err := validateChannel(&channel.Channel, false); err != nil {
c.JSON(http.StatusOK, gin.H{
@@ -1299,7 +1333,7 @@ func FetchModels(c *gin.Context) {
baseURL = strings.TrimSpace(*req.BaseURL)
}
if baseURL == "" {
- baseURL = constant.ChannelBaseURLs[req.Type]
+ baseURL = constant.GetChannelBaseURL(req.Type)
}
key := strings.TrimSpace(req.Key)
@@ -1424,6 +1458,11 @@ func CopyChannel(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道信息失败,请稍后重试"})
return
}
+ if origin.Type == constant.ChannelTypeTaskPlugin &&
+ !authz.Can(c.GetInt("id"), c.GetInt("role"), authz.TaskPluginBind) {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": "task plugin channels require the task_plugin.bind permission"})
+ return
+ }
// clone channel
clone := *origin // shallow copy is sufficient as we will overwrite primitives
@@ -2010,7 +2049,7 @@ func OllamaPullModel(c *gin.Context) {
return
}
- baseURL := constant.ChannelBaseURLs[channel.Type]
+ baseURL := constant.GetChannelBaseURL(channel.Type)
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
@@ -2073,7 +2112,7 @@ func OllamaPullModelStream(c *gin.Context) {
return
}
- baseURL := constant.ChannelBaseURLs[channel.Type]
+ baseURL := constant.GetChannelBaseURL(channel.Type)
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
@@ -2155,7 +2194,7 @@ func OllamaDeleteModel(c *gin.Context) {
return
}
- baseURL := constant.ChannelBaseURLs[channel.Type]
+ baseURL := constant.GetChannelBaseURL(channel.Type)
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
@@ -2204,7 +2243,7 @@ func OllamaVersion(c *gin.Context) {
return
}
- baseURL := constant.ChannelBaseURLs[channel.Type]
+ baseURL := constant.GetChannelBaseURL(channel.Type)
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
diff --git a/controller/channel_pin_retry_test.go b/controller/channel_pin_retry_test.go
new file mode 100644
index 000000000000..f79b7b8b1123
--- /dev/null
+++ b/controller/channel_pin_retry_test.go
@@ -0,0 +1,95 @@
+package controller
+
+import (
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/QuantumNous/new-api/dto"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/relaykit/types"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestShouldRetryHonorsPinRetryMode(t *testing.T) {
+ openaiErr := types.NewOpenAIError(errors.New("upstream"), types.ErrorCodeBadResponseStatusCode, http.StatusInternalServerError)
+
+ c := newPinRetryContext()
+ assert.True(t, shouldRetry(c, openaiErr, 1))
+
+ origin := newPinRetryContext()
+ service.GetChannelConstraints(origin).AddPin(dto.ChannelPin{
+ ChannelId: 2,
+ Source: dto.PinSourceOriginTask,
+ Rank: dto.PinRankOriginTask,
+ RetryMode: dto.PinRetrySameChannel,
+ })
+ assert.True(t, shouldRetry(origin, openaiErr, 1), "origin pin retries on the same channel")
+
+ token := newPinRetryContext()
+ service.GetChannelConstraints(token).AddPin(dto.ChannelPin{
+ ChannelId: 1,
+ Source: dto.PinSourceToken,
+ Rank: dto.PinRankToken,
+ RetryMode: dto.PinRetrySingleAttempt,
+ })
+ assert.False(t, shouldRetry(token, openaiErr, 1), "token pin suppresses retry")
+}
+
+func TestShouldRetryTaskRelayHonorsPinRetryMode(t *testing.T) {
+ taskErr := &dto.TaskError{StatusCode: http.StatusInternalServerError}
+
+ c := newPinRetryContext()
+ assert.True(t, shouldRetryTaskRelay(c, 1, taskErr, 1))
+
+ origin := newPinRetryContext()
+ service.GetChannelConstraints(origin).AddPin(dto.ChannelPin{
+ ChannelId: 2,
+ Source: dto.PinSourceOriginTask,
+ Rank: dto.PinRankOriginTask,
+ RetryMode: dto.PinRetrySameChannel,
+ })
+ assert.True(t, shouldRetryTaskRelay(origin, 2, taskErr, 1))
+
+ token := newPinRetryContext()
+ service.GetChannelConstraints(token).AddPin(dto.ChannelPin{
+ ChannelId: 1,
+ Source: dto.PinSourceToken,
+ Rank: dto.PinRankToken,
+ RetryMode: dto.PinRetrySingleAttempt,
+ })
+ assert.False(t, shouldRetryTaskRelay(token, 1, taskErr, 1))
+}
+
+func TestSameChannelPinsMergeToStricterRetryMode(t *testing.T) {
+ c := newPinRetryContext()
+ constraints := service.GetChannelConstraints(c)
+ constraints.AddPin(dto.ChannelPin{
+ ChannelId: 7,
+ Source: dto.PinSourceOriginTask,
+ Rank: dto.PinRankOriginTask,
+ RetryMode: dto.PinRetrySameChannel,
+ })
+ constraints.AddPin(dto.ChannelPin{
+ ChannelId: 7,
+ Source: dto.PinSourceToken,
+ Rank: dto.PinRankToken,
+ RetryMode: dto.PinRetrySingleAttempt,
+ })
+ pin, found, overridden := constraints.ResolvedPin()
+ require.True(t, found)
+ assert.Equal(t, 7, pin.ChannelId)
+ assert.Equal(t, dto.PinRetrySingleAttempt, pin.RetryMode)
+ assert.Empty(t, overridden)
+ assert.False(t, shouldRetry(c, types.NewOpenAIError(errors.New("upstream"), types.ErrorCodeBadResponseStatusCode, http.StatusInternalServerError), 1))
+}
+
+func newPinRetryContext() *gin.Context {
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
+ return c
+}
diff --git a/controller/channel_task_plugin_bind_test.go b/controller/channel_task_plugin_bind_test.go
new file mode 100644
index 000000000000..b8f326661594
--- /dev/null
+++ b/controller/channel_task_plugin_bind_test.go
@@ -0,0 +1,131 @@
+package controller
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/QuantumNous/new-api/service/authz"
+ "github.com/gin-gonic/gin"
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+func setupTaskPluginBindChannelTest(t *testing.T) {
+ t.Helper()
+ wasMaster := common.IsMasterNode
+ common.IsMasterNode = true
+ previousRedisEnabled := common.RedisEnabled
+ common.RedisEnabled = false
+ originalDB, originalLogDB := model.DB, model.LOG_DB
+ database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ require.NoError(t, err)
+ sqlDB, err := database.DB()
+ require.NoError(t, err)
+ sqlDB.SetMaxOpenConns(1)
+ require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Ability{}, &model.CasbinRule{}, &model.AuthzRole{}, &model.Log{}, &model.User{}))
+ model.DB = database
+ model.LOG_DB = database
+ require.NoError(t, authz.Init(database))
+ t.Cleanup(func() {
+ common.IsMasterNode = wasMaster
+ common.RedisEnabled = previousRedisEnabled
+ model.DB = originalDB
+ model.LOG_DB = originalLogDB
+ })
+}
+
+func postAddChannel(t *testing.T, userID, role int, body string) *httptest.ResponseRecorder {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Set("id", userID)
+ context.Set("role", role)
+ context.Request = httptest.NewRequest(http.MethodPost, "/api/channel", strings.NewReader(body))
+ context.Request.Header.Set("Content-Type", "application/json")
+ AddChannel(context)
+ return recorder
+}
+
+func TestAddChannelTaskPluginRequiresBindPermission(t *testing.T) {
+ setupTaskPluginBindChannelTest(t)
+ const key = "channel-bind"
+ source := `
+export const meta = {apiVersion: 1, key: "channel-bind", name: "Bind", version: "1.0.0", author: {name: "Test"}, models: ["doc"], fetchMode: "per_task"};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+`
+ _, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(key) })
+
+ taskPluginBody := `{"mode":"single","channel":{"type":61,"name":"plugin-channel","key":"sk","models":"doc","group":"default","base_url":"https://example.com","setting":"{\"task_plugin_key\":\"channel-bind\"}"}}`
+ openaiBody := `{"mode":"single","channel":{"type":1,"name":"openai-channel","key":"sk","models":"gpt","group":"default"}}`
+
+ adminDenied := postAddChannel(t, 2, common.RoleAdminUser, taskPluginBody)
+ assert.Contains(t, adminDenied.Body.String(), "task plugin channels require the task_plugin.bind permission")
+ assert.Contains(t, adminDenied.Body.String(), `"success":false`)
+
+ rootAllowed := postAddChannel(t, 1, common.RoleRootUser, taskPluginBody)
+ assert.Contains(t, rootAllowed.Body.String(), `"success":true`)
+ assert.NotContains(t, rootAllowed.Body.String(), "task_plugin.bind")
+
+ adminOtherType := postAddChannel(t, 2, common.RoleAdminUser, openaiBody)
+ assert.Contains(t, adminOtherType.Body.String(), `"success":true`)
+ assert.NotContains(t, adminOtherType.Body.String(), "task_plugin.bind")
+}
+
+func TestUpdateChannelTaskPluginRequiresBindPermission(t *testing.T) {
+ setupTaskPluginBindChannelTest(t)
+ const key = "channel-bind-update"
+ source := `
+export const meta = {apiVersion: 1, key: "channel-bind-update", name: "Bind", version: "1.0.0", author: {name: "Test"}, models: ["doc"], fetchMode: "per_task"};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+`
+ _, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(key) })
+
+ baseURL := "https://example.com"
+ setting := `{"task_plugin_key":"channel-bind-update"}`
+ channel := model.Channel{
+ Type: constant.ChannelTypeTaskPlugin,
+ Status: common.ChannelStatusEnabled,
+ Name: "existing-plugin",
+ Models: "doc",
+ Group: "default",
+ Key: "sk",
+ BaseURL: &baseURL,
+ Setting: &setting,
+ }
+ require.NoError(t, channel.Insert())
+
+ payload := fmt.Sprintf(
+ `{"id":%d,"type":61,"name":"existing-plugin","key":"sk","models":"doc","group":"default","base_url":"https://example.com","setting":"{\"task_plugin_key\":\"channel-bind-update\"}"}`,
+ channel.Id,
+ )
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Set("id", 2)
+ context.Set("role", common.RoleAdminUser)
+ context.Request = httptest.NewRequest(http.MethodPut, "/api/channel", strings.NewReader(payload))
+ context.Request.Header.Set("Content-Type", "application/json")
+ UpdateChannel(context)
+ assert.Contains(t, recorder.Body.String(), "task plugin channels require the task_plugin.bind permission")
+ assert.Contains(t, recorder.Body.String(), `"success":false`)
+}
diff --git a/controller/channel_task_plugin_validation_test.go b/controller/channel_task_plugin_validation_test.go
new file mode 100644
index 000000000000..6dfe065040f5
--- /dev/null
+++ b/controller/channel_task_plugin_validation_test.go
@@ -0,0 +1,41 @@
+package controller
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestValidateTaskPluginChannel(t *testing.T) {
+ source := `
+export const meta = {apiVersion: 1, key: "channel-validation", name: "Validation", version: "1.0.0", author: {name: "Test"}, models: ["doc"], fetchMode: "per_task"};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+`
+ _, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("channel-validation") })
+ baseURL := "https://example.com"
+
+ channel := &model.Channel{Type: constant.ChannelTypeTaskPlugin, BaseURL: &baseURL}
+ require.ErrorContains(t, validateChannel(channel, false), "task plugin key is required")
+
+ missing := `{"task_plugin_key":"missing"}`
+ channel.Setting = &missing
+ require.ErrorContains(t, validateChannel(channel, false), "is not registered")
+
+ longKey := `{"task_plugin_key":"` + strings.Repeat("x", 31) + `"}`
+ channel.Setting = &longKey
+ require.ErrorContains(t, validateChannel(channel, false), "must not exceed 30")
+
+ valid := `{"task_plugin_key":"channel-validation"}`
+ channel.Setting = &valid
+ channel.BaseURL = nil
+ require.ErrorContains(t, validateChannel(channel, false), "base URL is required")
+}
diff --git a/controller/channel_upstream_update.go b/controller/channel_upstream_update.go
index e1918c257c19..6817a0086ac6 100644
--- a/controller/channel_upstream_update.go
+++ b/controller/channel_upstream_update.go
@@ -16,6 +16,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relay/channel/advancedcustom"
"github.com/QuantumNous/new-api/relay/channel/gemini"
"github.com/QuantumNous/new-api/relay/channel/ollama"
@@ -361,7 +362,14 @@ func getFetchModelsResponseBody(method string, requestURL string, channel *model
}
func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
- baseURL := constant.ChannelBaseURLs[channel.Type]
+ if channel.Type == constant.ChannelTypeTaskPlugin {
+ plugin, ok := jsplugin.DefaultRegistry.Get(channel.GetSetting().TaskPluginKey)
+ if !ok {
+ return nil, fmt.Errorf("task plugin %q is not registered", channel.GetSetting().TaskPluginKey)
+ }
+ return normalizeModelNames(plugin.Meta.Models), nil
+ }
+ baseURL := constant.GetChannelBaseURL(channel.Type)
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
diff --git a/controller/log.go b/controller/log.go
index 470c759fc1a1..18ebb10b2bec 100644
--- a/controller/log.go
+++ b/controller/log.go
@@ -27,6 +27,9 @@ func GetAllLogs(c *gin.Context) {
common.ApiError(c, err)
return
}
+ if c.GetInt("role") < common.RoleRootUser {
+ model.FormatAdminLogs(logs)
+ }
pageInfo.SetTotal(int(total))
pageInfo.SetItems(logs)
common.ApiSuccess(c, pageInfo)
diff --git a/controller/model.go b/controller/model.go
index 1d759301bc7e..779739477fe1 100644
--- a/controller/model.go
+++ b/controller/model.go
@@ -9,6 +9,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relay"
"github.com/QuantumNous/new-api/relay/channel/ai360"
"github.com/QuantumNous/new-api/relay/channel/lingyiwanwu"
@@ -97,6 +98,9 @@ func init() {
for i := 1; i <= constant.ChannelTypeDummy; i++ {
apiType, success := common.ChannelType2APIType(i)
if !success || apiType == constant.APITypeAIProxyLibrary {
+ if plugin, ok := jsplugin.DefaultRegistry.GetByChannelType(i); ok {
+ channelId2Models[i] = append([]string(nil), plugin.Meta.Models...)
+ }
continue
}
meta := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{
@@ -105,6 +109,11 @@ func init() {
adaptor := relay.GetAdaptor(apiType)
adaptor.Init(meta)
channelId2Models[i] = adaptor.GetModelList()
+ if len(channelId2Models[i]) == 0 {
+ if plugin, ok := jsplugin.DefaultRegistry.GetByChannelType(i); ok {
+ channelId2Models[i] = append([]string(nil), plugin.Meta.Models...)
+ }
+ }
}
openAIModels = lo.UniqBy(openAIModels, func(m dto.OpenAIModels) string {
return m.Id
@@ -314,9 +323,18 @@ func ChannelListModels(c *gin.Context) {
}
func DashboardListModels(c *gin.Context) {
+ modelsByChannel := make(map[int][]string, len(channelId2Models))
+ for channelType, models := range channelId2Models {
+ modelsByChannel[channelType] = append([]string(nil), models...)
+ }
+ for channelType := 1; channelType <= constant.ChannelTypeDummy; channelType++ {
+ if plugin, ok := jsplugin.DefaultRegistry.GetByChannelType(channelType); ok {
+ modelsByChannel[channelType] = append([]string(nil), plugin.Meta.Models...)
+ }
+ }
c.JSON(200, gin.H{
"success": true,
- "data": channelId2Models,
+ "data": modelsByChannel,
})
}
diff --git a/controller/option.go b/controller/option.go
index 940bb3069023..70a5f1921894 100644
--- a/controller/option.go
+++ b/controller/option.go
@@ -3,13 +3,17 @@ package controller
import (
"fmt"
"net/http"
+ "sort"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
+ "github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/console_setting"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
@@ -153,6 +157,12 @@ func UpdateOption(c *gin.Context) {
return
}
}
+ if option.Key == "TaskPublicAddress" && option.Value.(string) != "" {
+ if err := service.ValidateTaskArtifactBaseURL(option.Value.(string)); err != nil {
+ common.ApiErrorMsg(c, err.Error())
+ return
+ }
+ }
switch option.Key {
case "GitHubOAuthEnabled":
if option.Value == "true" && common.GitHubClientId == "" {
@@ -326,6 +336,30 @@ func UpdateOption(c *gin.Context) {
})
return
}
+ case "billing_setting.billing_expr":
+ expressions := make(map[string]string)
+ if err = common.UnmarshalJsonStr(option.Value.(string), &expressions); err != nil {
+ common.ApiErrorMsg(c, "计费表达式配置必须是模型到表达式的 JSON 对象: "+err.Error())
+ return
+ }
+ models := make([]string, 0, len(expressions))
+ for modelName := range expressions {
+ models = append(models, modelName)
+ }
+ sort.Strings(models)
+ generation := jsplugin.DefaultRegistry.Generation()
+ for _, modelName := range models {
+ expression := expressions[modelName]
+ if plugin, ok := generation.GetByModel(modelName); ok {
+ err = billing_setting.SmokeTestTaskExpr(expression, plugin.Meta.UsageSchema)
+ } else {
+ err = billing_setting.SmokeTestExpr(expression)
+ }
+ if err != nil {
+ common.ApiErrorMsg(c, fmt.Sprintf("模型 %s 的计费表达式无效: %v", modelName, err))
+ return
+ }
+ }
case "console_setting.api_info":
err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo")
if err != nil {
diff --git a/controller/plugin_endpoint_test.go b/controller/plugin_endpoint_test.go
new file mode 100644
index 000000000000..5a0aa69f7341
--- /dev/null
+++ b/controller/plugin_endpoint_test.go
@@ -0,0 +1,47 @@
+package controller
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRelayTaskPluginEndpointPreservesUnclaimedFallback(t *testing.T) {
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ fallbackCalls := 0
+
+ RelayTaskPluginEndpoint(c, func(c *gin.Context) {
+ fallbackCalls++
+ c.Status(http.StatusNoContent)
+ c.Writer.WriteHeaderNow()
+ })
+
+ assert.Equal(t, 1, fallbackCalls)
+ assert.Equal(t, http.StatusNoContent, recorder.Code)
+}
+
+func TestRelayTaskPluginEndpointNeverEntersOrdinaryRelayWhenClaimed(t *testing.T) {
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Set(jsplugin.ContextKeyPinnedEndpoint, jsplugin.PinnedEndpoint{
+ Generation: &jsplugin.RoutingGeneration{},
+ Plugin: &jsplugin.LoadedPlugin{},
+ Protocol: "openai_responses",
+ Operation: jsplugin.HostProtocolOperation{Name: "create"},
+ })
+ fallbackCalls := 0
+
+ RelayTaskPluginEndpoint(c, func(c *gin.Context) {
+ fallbackCalls++
+ c.Status(http.StatusNoContent)
+ c.Writer.WriteHeaderNow()
+ })
+
+ assert.Zero(t, fallbackCalls)
+ assert.NotEqual(t, http.StatusNoContent, recorder.Code)
+}
diff --git a/controller/plugin_native_e2e_test.go b/controller/plugin_native_e2e_test.go
new file mode 100644
index 000000000000..f0eb4eef0416
--- /dev/null
+++ b/controller/plugin_native_e2e_test.go
@@ -0,0 +1,244 @@
+package controller
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/middleware"
+ "github.com/QuantumNous/new-api/model"
+ pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/QuantumNous/new-api/relay"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/setting/ratio_setting"
+ "github.com/gin-gonic/gin"
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+type nativeRouteBilling struct {
+ events []string
+ preConsumed int
+ userID int
+ settled bool
+}
+
+func (b *nativeRouteBilling) Settle(int) error {
+ b.events = append(b.events, "settle")
+ b.settled = true
+ return nil
+}
+
+func (b *nativeRouteBilling) Refund(*gin.Context) {
+ b.events = append(b.events, "refund")
+ if !b.settled && b.preConsumed > 0 {
+ _ = model.IncreaseUserQuota(b.userID, b.preConsumed, true)
+ b.preConsumed = 0
+ }
+}
+
+func (b *nativeRouteBilling) NeedsRefund() bool {
+ return !b.settled && b.preConsumed > 0
+}
+
+func (b *nativeRouteBilling) GetPreConsumedQuota() int {
+ return b.preConsumed
+}
+
+func (b *nativeRouteBilling) Reserve(quota int) error {
+ b.events = append(b.events, "reserve")
+ if err := model.DecreaseUserQuota(b.userID, quota, true); err != nil {
+ return err
+ }
+ b.preConsumed = quota
+ return nil
+}
+
+func TestKlingNativeRouteSubmitPollSettleAndQuery(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ service.InitHttpClient()
+
+ previousDB := model.DB
+ previousLogDB := model.LOG_DB
+ previousMemoryCache := common.MemoryCacheEnabled
+ previousBatchUpdate := common.BatchUpdateEnabled
+ previousLogConsume := common.LogConsumeEnabled
+ previousRedisEnabled := common.RedisEnabled
+ database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, database.AutoMigrate(&model.User{}, &model.Channel{}, &model.Task{}, &model.Log{}))
+ model.DB = database
+ model.LOG_DB = database
+ common.MemoryCacheEnabled = false
+ common.BatchUpdateEnabled = false
+ common.LogConsumeEnabled = false
+ common.RedisEnabled = false
+ previousModelRatios := ratio_setting.ModelRatio2JSONString()
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"kling-v1":1}`))
+ t.Cleanup(func() {
+ model.DB = previousDB
+ model.LOG_DB = previousLogDB
+ common.MemoryCacheEnabled = previousMemoryCache
+ common.BatchUpdateEnabled = previousBatchUpdate
+ common.LogConsumeEnabled = previousLogConsume
+ common.RedisEnabled = previousRedisEnabled
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(previousModelRatios))
+ })
+ require.NoError(t, database.Create(&model.User{
+ Id: 7,
+ Username: "native-route-user",
+ Group: "default",
+ Quota: 1_000_000,
+ }).Error)
+
+ var submitCalls atomic.Int32
+ var queryCalls atomic.Int32
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/kling/v1/videos/text2video":
+ submitCalls.Add(1)
+ body, readErr := io.ReadAll(r.Body)
+ if !assert.NoError(t, readErr) {
+ http.Error(w, "read request", http.StatusInternalServerError)
+ return
+ }
+ assert.Contains(t, string(body), `"model_name":"kling-v1"`)
+ _, _ = io.WriteString(w, `{"code":0,"message":"","data":{"task_id":"kling-private-1","task_status":"submitted"}}`)
+ case r.Method == http.MethodGet && r.URL.Path == "/kling/v1/videos/text2video/kling-private-1":
+ queryCalls.Add(1)
+ _, _ = io.WriteString(w, `{"code":0,"message":"","data":{"task_id":"kling-private-1","task_status":"succeed","task_status_msg":"","task_result":{"videos":[{"id":"video-private","url":"https://cdn.example/video.mp4","duration":"5"}]},"final_unit_deduction":"1"}}`)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer upstream.Close()
+
+ channel := model.Channel{
+ Type: constant.ChannelTypeKling,
+ Name: "kling-native-e2e",
+ Key: "sk-test",
+ BaseURL: &upstream.URL,
+ Status: common.ChannelStatusEnabled,
+ Models: "kling-v1",
+ Group: "default",
+ }
+ require.NoError(t, database.Create(&channel).Error)
+
+ generation := pluginruntime.DefaultRegistry.Generation()
+ require.NotNil(t, generation)
+ submitBinding, found := generation.LookupDeclaredRoute(http.MethodPost, "/kling/v1/videos/text2video")
+ require.True(t, found)
+ require.Equal(t, "kling", submitBinding.Plugin.Meta.Key)
+
+ submitRecorder := httptest.NewRecorder()
+ submitContext, _ := gin.CreateTestContext(submitRecorder)
+ submitContext.Request = httptest.NewRequest(
+ http.MethodPost,
+ "/kling/v1/videos/text2video",
+ bytes.NewBufferString(`{"model_name":"kling-v1","prompt":"a lighthouse"}`),
+ )
+ submitContext.Request.Header.Set("Content-Type", "application/json")
+ submitContext.Set(pluginruntime.ContextKeyPinnedRoute, pluginruntime.PinnedRoute{
+ Generation: generation,
+ Plugin: submitBinding.Plugin,
+ Route: submitBinding.Route,
+ })
+ common.SetContextKey(submitContext, constant.ContextKeyUserId, 7)
+ common.SetContextKey(submitContext, constant.ContextKeyUserGroup, "default")
+ common.SetContextKey(submitContext, constant.ContextKeyUsingGroup, "default")
+ common.SetContextKey(submitContext, constant.ContextKeyTokenGroup, "default")
+ common.SetContextKey(submitContext, constant.ContextKeyUserQuota, 1_000_000)
+
+ middleware.PrepareTaskPluginRoute()(submitContext)
+ require.False(t, submitContext.IsAborted(), submitRecorder.Body.String())
+ require.Equal(t, "kling-v1", submitContext.GetString("resolved_task_model"))
+ require.Equal(t, "text_to_video", submitContext.GetString("task_action"))
+ require.Nil(t, middleware.SetupContextForSelectedChannel(submitContext, &channel, "kling-v1"))
+
+ billing := &nativeRouteBilling{userID: 7}
+ relayInfo := &relaycommon.RelayInfo{
+ UserId: 7,
+ UserGroup: "default",
+ UsingGroup: "default",
+ UserQuota: 1_000_000,
+ TokenGroup: "default",
+ OriginModelName: "kling-v1",
+ Billing: billing,
+ TaskRelayInfo: &relaycommon.TaskRelayInfo{
+ Action: submitContext.GetString("task_action"),
+ PublicTaskID: "task_kling_public",
+ LockedChannel: &channel,
+ },
+ }
+
+ outcome, taskErr := executeTaskSubmissionWith(submitContext, relayInfo, relay.RelayTaskSubmit)
+ require.Nil(t, taskErr)
+ require.NotNil(t, outcome)
+ require.Equal(t, []string{"reserve", "settle"}, billing.events)
+ require.False(t, submitContext.Writer.Written())
+
+ presentTaskSubmission(submitContext, outcome)
+ require.Equal(t, http.StatusOK, submitRecorder.Code)
+ assert.Contains(t, submitRecorder.Body.String(), `"task_id":"task_kling_public"`)
+ assert.NotContains(t, submitRecorder.Body.String(), "kling-private-1")
+ assert.Equal(t, int32(1), submitCalls.Load())
+
+ var persisted model.Task
+ require.NoError(t, database.Where("task_id = ?", "task_kling_public").First(&persisted).Error)
+ assert.Equal(t, constant.TaskPlatform("kling"), persisted.Platform)
+ assert.Equal(t, "kling-private-1", persisted.PrivateData.UpstreamTaskID)
+ assert.Equal(t, model.TaskStatus(model.TaskStatusNotStart), persisted.Status)
+
+ previousAdaptorFactory := service.GetTaskAdaptorFunc
+ service.GetTaskAdaptorFunc = func(platform constant.TaskPlatform) service.TaskPollingAdaptor {
+ return relay.GetTaskAdaptor(platform)
+ }
+ t.Cleanup(func() { service.GetTaskAdaptorFunc = previousAdaptorFactory })
+ service.DispatchPlatformUpdate(
+ context.Background(),
+ persisted.Platform,
+ map[int][]string{channel.Id: {"kling-private-1"}},
+ map[string]*model.Task{"kling-private-1": &persisted},
+ )
+
+ require.NoError(t, database.Where("task_id = ?", "task_kling_public").First(&persisted).Error)
+ assert.Equal(t, model.TaskStatus(model.TaskStatusSuccess), persisted.Status)
+ assert.Equal(t, "100%", persisted.Progress)
+ assert.Equal(t, 1, persisted.Quota)
+ assert.Equal(t, int32(1), queryCalls.Load())
+ var settledUser model.User
+ require.NoError(t, database.First(&settledUser, 7).Error)
+ assert.Equal(t, 999_999, settledUser.Quota)
+
+ queryBinding, found := generation.LookupDeclaredRoute(http.MethodGet, "/kling/v1/videos/text2video/:task_id")
+ require.True(t, found)
+ queryRecorder := httptest.NewRecorder()
+ queryContext, _ := gin.CreateTestContext(queryRecorder)
+ queryContext.Request = httptest.NewRequest(http.MethodGet, "/kling/v1/videos/text2video/task_kling_public", nil)
+ queryContext.Params = gin.Params{{Key: "task_id", Value: "task_kling_public"}}
+ queryContext.Set(pluginruntime.ContextKeyPinnedRoute, pluginruntime.PinnedRoute{
+ Generation: generation,
+ Plugin: queryBinding.Plugin,
+ Route: queryBinding.Route,
+ })
+ common.SetContextKey(queryContext, constant.ContextKeyUserId, 7)
+
+ middleware.PrepareTaskPluginRoute()(queryContext)
+
+ require.True(t, queryContext.IsAborted())
+ require.Equal(t, http.StatusOK, queryRecorder.Code)
+ assert.Contains(t, queryRecorder.Body.String(), `"task_id":"task_kling_public"`)
+ assert.Contains(t, queryRecorder.Body.String(), `"task_status":"succeed"`)
+ assert.NotContains(t, queryRecorder.Body.String(), "kling-private-1")
+ assert.NotContains(t, queryRecorder.Body.String(), upstream.URL)
+}
diff --git a/controller/plugin_protocol.go b/controller/plugin_protocol.go
new file mode 100644
index 000000000000..7b9248aeffdb
--- /dev/null
+++ b/controller/plugin_protocol.go
@@ -0,0 +1,1276 @@
+package controller
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "hash/fnv"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/dto"
+ "github.com/QuantumNous/new-api/logger"
+ "github.com/QuantumNous/new-api/model"
+ pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/QuantumNous/new-api/relay"
+ taskjsplugin "github.com/QuantumNous/new-api/relay/channel/task/jsplugin"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ relayconstant "github.com/QuantumNous/new-api/relay/constant"
+ "github.com/QuantumNous/new-api/relay/helper"
+ "github.com/QuantumNous/new-api/relaykit/types"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/gin-gonic/gin"
+)
+
+type pluginProtocolBridgeDeps struct {
+ submit func(*gin.Context, *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError)
+ loadTask func(context.Context, int, constant.TaskPlatform, string) (*model.Task, bool, error)
+ now func() time.Time
+ admissions *pluginProtocolObservationLimiter
+ protocolLimits relay.PluginProtocolLimits
+ artifactContentURL func(taskID, artifactKey string) (string, error)
+ submissionTimeout time.Duration
+ observationTimeout time.Duration
+ loadTimeout time.Duration
+ tickInterval time.Duration
+ tickJitter time.Duration
+ heartbeatInterval time.Duration
+ admissionTimeout time.Duration
+ getByTaskId func(int, string) (*model.Task, bool, error)
+ resolvePlugin func(constant.TaskPlatform) (*pluginruntime.LoadedPlugin, *pluginruntime.RoutingGeneration, bool)
+}
+
+func defaultPluginProtocolBridgeDeps() pluginProtocolBridgeDeps {
+ timeout := time.Duration(constant.TaskPluginProtocolTimeoutSeconds) * time.Second
+ if timeout <= 0 {
+ timeout = 10 * time.Minute
+ }
+ tick := time.Duration(constant.TaskPluginProtocolTickMilliseconds) * time.Millisecond
+ if tick <= 0 {
+ tick = 2 * time.Second
+ }
+ jitter := max(time.Duration(constant.TaskPluginProtocolTickJitterMilliseconds)*time.Millisecond, 0)
+ heartbeat := time.Duration(constant.TaskPluginProtocolHeartbeatSeconds) * time.Second
+ if heartbeat <= 0 {
+ heartbeat = 15 * time.Second
+ }
+ loadTimeout := 5 * time.Second
+ if halfHeartbeat := heartbeat / 2; halfHeartbeat > 0 && halfHeartbeat < loadTimeout {
+ loadTimeout = halfHeartbeat
+ }
+ return pluginProtocolBridgeDeps{
+ submit: executeTaskSubmission,
+ loadTask: model.GetTaskForProtocolObservation,
+ now: time.Now,
+ admissions: pluginProtocolObservationAdmissions,
+ protocolLimits: relay.DefaultPluginProtocolLimits(),
+ artifactContentURL: service.BuildTaskArtifactContentURL,
+ submissionTimeout: timeout,
+ observationTimeout: timeout,
+ loadTimeout: loadTimeout,
+ tickInterval: tick,
+ tickJitter: jitter,
+ heartbeatInterval: heartbeat,
+ admissionTimeout: pluginruntime.DefaultCallTimeout,
+ getByTaskId: model.GetByTaskId,
+ resolvePlugin: resolveTaskPluginForProtocolRetrieve,
+ }
+}
+
+func (d pluginProtocolBridgeDeps) withDefaults() pluginProtocolBridgeDeps {
+ defaults := defaultPluginProtocolBridgeDeps()
+ if d.submit == nil {
+ d.submit = defaults.submit
+ }
+ if d.loadTask == nil {
+ d.loadTask = defaults.loadTask
+ }
+ if d.now == nil {
+ d.now = defaults.now
+ }
+ if d.admissions == nil {
+ d.admissions = defaults.admissions
+ }
+ if d.artifactContentURL == nil {
+ d.artifactContentURL = defaults.artifactContentURL
+ }
+ if d.submissionTimeout <= 0 {
+ d.submissionTimeout = defaults.submissionTimeout
+ }
+ if d.observationTimeout <= 0 {
+ d.observationTimeout = defaults.observationTimeout
+ }
+ if d.loadTimeout <= 0 {
+ d.loadTimeout = defaults.loadTimeout
+ }
+ if d.tickInterval <= 0 {
+ d.tickInterval = defaults.tickInterval
+ }
+ if d.tickJitter < 0 {
+ d.tickJitter = 0
+ }
+ if d.heartbeatInterval <= 0 {
+ d.heartbeatInterval = defaults.heartbeatInterval
+ }
+ if halfHeartbeat := d.heartbeatInterval / 2; halfHeartbeat > 0 && d.loadTimeout > halfHeartbeat {
+ d.loadTimeout = halfHeartbeat
+ }
+ if d.admissionTimeout <= 0 {
+ d.admissionTimeout = defaults.admissionTimeout
+ }
+ if d.getByTaskId == nil {
+ d.getByTaskId = defaults.getByTaskId
+ }
+ if d.resolvePlugin == nil {
+ d.resolvePlugin = defaults.resolvePlugin
+ }
+ return d
+}
+
+func resolveTaskPluginForProtocolRetrieve(platform constant.TaskPlatform) (*pluginruntime.LoadedPlugin, *pluginruntime.RoutingGeneration, bool) {
+ generation := pluginruntime.DefaultRegistry.Generation()
+ plugin, ok := relay.ResolveTaskPluginForPlatform(generation, platform)
+ return plugin, generation, ok
+}
+
+func serveTaskPluginProtocol(
+ c *gin.Context,
+ pinned pluginruntime.PinnedEndpoint,
+ deps pluginProtocolBridgeDeps,
+) {
+ deps = deps.withDefaults()
+ generation := uint64(0)
+ if pinned.Generation != nil {
+ generation = pinned.Generation.Number
+ }
+ pluginKey := ""
+ if pinned.Plugin != nil {
+ pluginKey = pinned.Plugin.Meta.Key
+ }
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=protocol event=bridge_start generation=%d plugin=%q protocol=%q model=%q",
+ generation,
+ pluginKey,
+ pinned.Protocol,
+ c.GetString("resolved_task_model"),
+ )
+ if !pluginruntime.SupportsHostProtocol(pinned.Protocol) {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=bridge_rejected generation=%d plugin=%q reason=unsupported_protocol", generation, pluginKey)
+ respondPluginProtocolError(c, http.StatusNotImplemented, "task_protocol_not_available", "Task protocol bridge is not available")
+ return
+ }
+ requestValue, exists := c.Get(pluginruntime.ContextKeyProtocolRequest)
+ protocolRequest, ok := requestValue.(pluginruntime.ProtocolRequestContext)
+ if !exists || !ok || protocolRequest.Protocol != pinned.Protocol {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=bridge_rejected generation=%d plugin=%q reason=invalid_protocol_context", generation, pluginKey)
+ respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
+ return
+ }
+ if definition, known := pluginruntime.HostProtocol(pinned.Protocol); known && len(definition.DefinedModes()) > 0 && pinned.Plugin != nil {
+ background := false
+ if body, ok := protocolRequest.Body.(map[string]any); ok && body["kind"] == string(pluginruntime.BodyJSON) {
+ if requestBody, ok := body["value"].(map[string]any); ok {
+ background, _ = requestBody["background"].(bool)
+ }
+ }
+ missing := false
+ if protocolRequest.Stream && !pinned.Plugin.Meta.ProtocolSupports(pinned.Protocol, "stream") {
+ missing = true
+ }
+ if background && !pinned.Plugin.Meta.ProtocolSupports(pinned.Protocol, "background") {
+ missing = true
+ }
+ if !protocolRequest.Stream && !background && !pinned.Plugin.Meta.ProtocolSupports(pinned.Protocol, "sync") {
+ missing = true
+ }
+ if missing {
+ logger.LogError(c, "pinned task plugin does not support the requested protocol form")
+ respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
+ return
+ }
+ }
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=protocol event=request_ready generation=%d plugin=%q protocol=%q stream=%t",
+ generation,
+ pluginKey,
+ protocolRequest.Protocol,
+ protocolRequest.Stream,
+ )
+
+ release, admissionErr := deps.admissions.acquire(
+ pinned.Plugin.Meta.Key,
+ common.GetContextKeyInt(c, constant.ContextKeyUserId),
+ common.GetContextKeyInt(c, constant.ContextKeyTokenId),
+ )
+ if admissionErr != nil {
+ if errors.Is(admissionErr, errPluginProtocolObservationLimitExceeded) {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=admission_rejected generation=%d plugin=%q reason=observation_limit", generation, pluginKey)
+ respondPluginProtocolError(c, http.StatusTooManyRequests, "rate_limit_exceeded", "Too many active task observations")
+ return
+ }
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=admission_rejected generation=%d plugin=%q reason=invalid_identity", generation, pluginKey)
+ respondPluginProtocolError(c, http.StatusUnauthorized, "authentication_error", "Authentication failed")
+ return
+ }
+ defer release()
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=admission_acquired generation=%d plugin=%q", generation, pluginKey)
+
+ clientRequest := c.Request
+ var relayInfo *relaycommon.RelayInfo
+ var outcome *taskSubmissionOutcome
+ var taskErr *dto.TaskError
+ var relayInfoErr error
+ submissionStage := "relay_info"
+ // A Responses client only observes an asynchronous task. Once admitted,
+ // disconnecting that observer must not cancel submission, persistence, or
+ // billing settlement; the submission keeps its own bounded lifetime.
+ func() {
+ submissionContext, cancelSubmission := context.WithTimeout(
+ context.WithoutCancel(clientRequest.Context()),
+ deps.submissionTimeout,
+ )
+ c.Request = clientRequest.Clone(submissionContext)
+ defer func() {
+ c.Request = clientRequest
+ cancelSubmission()
+ }()
+
+ relayInfo, relayInfoErr = relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil)
+ if relayInfoErr != nil {
+ return
+ }
+ relayInfo.RelayMode = relayconstant.RelayModeVideoSubmit
+ relayInfo.IsStream = false
+ relayInfo.OriginModelName = c.GetString("resolved_task_model")
+ if action := c.GetString("task_action"); action != "" {
+ relayInfo.Action = action
+ }
+ submissionStage = "origin_task"
+ if taskErr = relay.ResolveOriginTask(c, relayInfo); taskErr != nil {
+ return
+ }
+ if taskErr = relay.ApplyOriginTaskAffinity(c, relayInfo); taskErr != nil {
+ return
+ }
+
+ submissionStage = "submission"
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=submission_start generation=%d plugin=%q protocol=%q stream=%t", generation, pluginKey, protocolRequest.Protocol, protocolRequest.Stream)
+ outcome, taskErr = deps.submit(c, relayInfo)
+ }()
+
+ if clientRequest.Context().Err() != nil {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=client_disconnected generation=%d plugin=%q stage=%s", generation, pluginKey, submissionStage)
+ return
+ }
+ if relayInfoErr != nil {
+ err := relayInfoErr
+ logger.LogError(c, "build task protocol relay info failed: "+err.Error())
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=bridge_failed generation=%d plugin=%q stage=relay_info reason=invalid_context", generation, pluginKey)
+ respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
+ return
+ }
+ if submissionStage == "origin_task" && taskErr != nil {
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=protocol event=bridge_failed generation=%d plugin=%q stage=origin_task code=%q status=%d",
+ generation,
+ pluginKey,
+ taskErr.Code,
+ taskErr.StatusCode,
+ )
+ respondPluginProtocolSubmissionError(c, taskErr)
+ return
+ }
+
+ if taskErr != nil {
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=protocol event=submission_failed generation=%d plugin=%q code=%q status=%d local=%t",
+ generation,
+ pluginKey,
+ taskErr.Code,
+ taskErr.StatusCode,
+ taskErr.LocalError,
+ )
+ respondPluginProtocolSubmissionError(c, taskErr)
+ return
+ }
+ if outcome == nil || outcome.Task == nil || outcome.RelayInfo == nil ||
+ outcome.Task.UserId != relayInfo.UserId ||
+ outcome.Task.Platform != constant.TaskPlatform(pinned.Plugin.Meta.Key) {
+ logger.LogError(c, "task protocol submission returned an invalid durable outcome")
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=submission_failed generation=%d plugin=%q reason=invalid_durable_outcome", generation, pluginKey)
+ respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
+ return
+ }
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=protocol event=submission_durable generation=%d plugin=%q public_task_id=%q status=%q stream=%t",
+ generation,
+ pluginKey,
+ outcome.Task.TaskID,
+ taskPluginDebugStatus(string(outcome.Task.Status)),
+ protocolRequest.Stream,
+ )
+
+ createdAt := outcome.Task.CreatedAt
+ if createdAt == 0 {
+ createdAt = outcome.Task.SubmitTime
+ }
+ if createdAt == 0 {
+ createdAt = deps.now().Unix()
+ }
+ machine := relay.NewPluginResponsesMachine(
+ outcome.Task.TaskID,
+ outcome.RelayInfo.OriginModelName,
+ createdAt,
+ deps.protocolLimits,
+ )
+ background := false
+ if body, ok := protocolRequest.Body.(map[string]any); ok && body["kind"] == string(pluginruntime.BodyJSON) {
+ if requestBody, ok := body["value"].(map[string]any); ok {
+ background, _ = requestBody["background"].(bool)
+ }
+ }
+ if background {
+ outcome.Task.PrivateData.ResponsesBackground = true
+ if outcome.Task.ID != 0 {
+ if err := model.DB.Model(outcome.Task).Update("private_data", outcome.Task.PrivateData).Error; err != nil {
+ logger.LogError(c, "persist task background flag failed: "+err.Error())
+ }
+ }
+ machine.SetBackground(true)
+ if !protocolRequest.Stream {
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=protocol event=background_return generation=%d plugin=%q public_task_id=%q status=%q",
+ generation,
+ pluginKey,
+ outcome.Task.TaskID,
+ taskPluginDebugStatus(string(outcome.Task.Status)),
+ )
+ c.JSON(http.StatusOK, machine.PendingResponse(string(outcome.Task.Status)))
+ return
+ }
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=background_stream generation=%d plugin=%q public_task_id=%q", generation, pluginKey, outcome.Task.TaskID)
+ }
+ if protocolRequest.Stream {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_enter generation=%d plugin=%q mode=stream public_task_id=%q", generation, pluginKey, outcome.Task.TaskID)
+ streamTaskPluginProtocol(c, pinned, protocolRequest, outcome.Task.TaskID, machine, deps)
+ return
+ }
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_enter generation=%d plugin=%q mode=nonstream public_task_id=%q", generation, pluginKey, outcome.Task.TaskID)
+ waitTaskPluginProtocol(c, pinned, protocolRequest, outcome.Task.TaskID, machine, deps)
+}
+
+func streamTaskPluginProtocol(
+ c *gin.Context,
+ pinned pluginruntime.PinnedEndpoint,
+ protocolRequest pluginruntime.ProtocolRequestContext,
+ taskID string,
+ machine *relay.PluginResponsesMachine,
+ deps pluginProtocolBridgeDeps,
+) {
+ generation := pinned.Generation.Number
+ pluginKey := pinned.Plugin.Meta.Key
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=protocol event=observation_start generation=%d plugin=%q mode=stream public_task_id=%q timeout_ms=%d tick_ms=%d heartbeat_ms=%d",
+ generation,
+ pluginKey,
+ taskID,
+ deps.observationTimeout.Milliseconds(),
+ deps.tickInterval.Milliseconds(),
+ deps.heartbeatInterval.Milliseconds(),
+ )
+ created, err := machine.CreatedEvent()
+ if err != nil {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_failed generation=%d plugin=%q mode=stream stage=created_event reason=state_machine_error", generation, pluginKey)
+ respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
+ return
+ }
+ helper.SetEventStreamHeaders(c)
+ if err = writeTaskPluginProtocolEvent(c, created); err != nil {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=client_write_failed generation=%d plugin=%q mode=stream stage=created_event", generation, pluginKey)
+ return
+ }
+
+ observationContext, cancelObservation := context.WithTimeout(c.Request.Context(), deps.observationTimeout)
+ defer cancelObservation()
+ heartbeatTicker := time.NewTicker(deps.heartbeatInterval)
+ defer heartbeatTicker.Stop()
+
+ var previous relay.ProtocolState
+ tickNumber := uint64(0)
+ lastStatus := ""
+ for {
+ loadStarted := deps.now()
+ loadContext, cancelLoad := context.WithTimeout(observationContext, deps.loadTimeout)
+ task, exists, loadErr := deps.loadTask(
+ loadContext,
+ common.GetContextKeyInt(c, constant.ContextKeyUserId),
+ constant.TaskPlatform(pinned.Plugin.Meta.Key),
+ taskID,
+ )
+ loadContextErr := loadContext.Err()
+ cancelLoad()
+ loadElapsed := deps.now().Sub(loadStarted)
+ if errors.Is(loadContextErr, context.DeadlineExceeded) &&
+ observationContext.Err() == nil &&
+ c.Request.Context().Err() == nil {
+ logger.LogWarn(c, fmt.Sprintf(
+ "task protocol database observation overloaded; plugin=%s task=%s",
+ pinned.Plugin.Meta.Key,
+ taskID,
+ ))
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=protocol event=observation_tick generation=%d plugin=%q mode=stream tick=%d load_ms=%d overloaded=true",
+ generation,
+ pluginKey,
+ tickNumber,
+ loadElapsed.Milliseconds(),
+ )
+ delay := pluginProtocolTickDelay(taskID, tickNumber, deps.tickInterval, deps.tickJitter) + deps.tickInterval
+ tickNumber++
+ if !waitForTaskPluginProtocolTick(c, observationContext, heartbeatTicker, delay) {
+ if errors.Is(observationContext.Err(), context.DeadlineExceeded) {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_timeout generation=%d plugin=%q mode=stream last_status=%q", generation, pluginKey, taskPluginDebugStatus(lastStatus))
+ writeTaskPluginProtocolTimeout(c, machine, lastStatus)
+ } else if c.Request.Context().Err() != nil {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=client_disconnected generation=%d plugin=%q mode=stream stage=backoff_wait", generation, pluginKey)
+ } else {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=client_write_failed generation=%d plugin=%q mode=stream stage=heartbeat", generation, pluginKey)
+ }
+ return
+ }
+ continue
+ }
+ if loadErr != nil || !exists || task == nil {
+ if errors.Is(observationContext.Err(), context.DeadlineExceeded) {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_timeout generation=%d plugin=%q mode=stream last_status=%q", generation, pluginKey, taskPluginDebugStatus(lastStatus))
+ writeTaskPluginProtocolTimeout(c, machine, lastStatus)
+ return
+ }
+ if loadErr != nil && !errors.Is(loadErr, context.Canceled) {
+ logger.LogError(c, "task protocol database observation failed")
+ }
+ if c.Request.Context().Err() == nil {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_failed generation=%d plugin=%q mode=stream stage=load reason=task_unavailable", generation, pluginKey)
+ writeTaskPluginProtocolFailure(c, machine, lastStatus)
+ } else {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=client_disconnected generation=%d plugin=%q mode=stream stage=load", generation, pluginKey)
+ }
+ return
+ }
+ previousStatus := lastStatus
+ lastStatus = string(task.Status)
+ if lastStatus != previousStatus {
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=protocol event=status_transition generation=%d plugin=%q mode=stream tick=%d previous=%q status=%q load_ms=%d",
+ generation,
+ pluginKey,
+ tickNumber,
+ taskPluginDebugStatus(previousStatus),
+ taskPluginDebugStatus(lastStatus),
+ loadElapsed.Milliseconds(),
+ )
+ }
+ view, viewErr := service.BuildTaskPluginView(task)
+ if viewErr != nil {
+ logger.LogError(c, "build task protocol view failed: "+viewErr.Error())
+ writeTaskPluginProtocolFailure(c, machine, lastStatus)
+ return
+ }
+ viewValue, viewErr := taskPluginProtocolJSONValue(view)
+ if viewErr != nil {
+ logger.LogError(c, "encode task protocol view failed: "+viewErr.Error())
+ writeTaskPluginProtocolFailure(c, machine, lastStatus)
+ return
+ }
+ hookStarted := deps.now()
+ rendererContext, contextErr := taskPluginProtocolRendererContext(protocolRequest, pinned, task, deps.artifactContentURL)
+ if contextErr != nil {
+ logger.LogError(c, "build task protocol renderer context failed")
+ writeTaskPluginProtocolFailure(c, machine, lastStatus)
+ return
+ }
+ args := []any{rendererContext, viewValue}
+ if previous.Present {
+ previousValue, stateErr := previous.PluginValue()
+ if stateErr != nil {
+ logger.LogError(c, "decode task protocol state failed: "+stateErr.Error())
+ writeTaskPluginProtocolFailure(c, machine, lastStatus)
+ return
+ }
+ args = append(args, previousValue)
+ }
+ value, callErr := pinned.Plugin.Engine.CallPathWithAdmissionTimeout(observationContext, deps.admissionTimeout, "protocols", []string{pinned.Protocol, "renderEvents"}, args...)
+ hookElapsed := deps.now().Sub(hookStarted)
+ overloaded := false
+ if callErr != nil {
+ if errors.Is(observationContext.Err(), context.DeadlineExceeded) {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_timeout generation=%d plugin=%q mode=stream stage=render_events last_status=%q", generation, pluginKey, taskPluginDebugStatus(lastStatus))
+ writeTaskPluginProtocolTimeout(c, machine, lastStatus)
+ return
+ }
+ if c.Request.Context().Err() != nil {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=client_disconnected generation=%d plugin=%q mode=stream stage=render_events", generation, pluginKey)
+ return
+ }
+ if errors.Is(callErr, pluginruntime.ErrCallAdmissionTimeout) {
+ overloaded = true
+ logger.LogWarn(c, fmt.Sprintf(
+ "task protocol render hook overloaded; plugin=%s task=%s",
+ pinned.Plugin.Meta.Key,
+ taskID,
+ ))
+ } else {
+ logger.LogError(c, "task protocol render hook failed")
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=protocol event=observation_failed generation=%d plugin=%q mode=stream stage=render_events reason=hook_failed elapsed_ms=%d",
+ generation,
+ pluginKey,
+ hookElapsed.Milliseconds(),
+ )
+ writeTaskPluginProtocolFailure(c, machine, lastStatus)
+ return
+ }
+ }
+ if !overloaded {
+ result, decodeErr := relay.DecodePluginProtocolEventResult(value, deps.protocolLimits)
+ if decodeErr != nil {
+ logger.LogError(c, "task protocol render result invalid: "+decodeErr.Error())
+ writeTaskPluginProtocolFailure(c, machine, lastStatus)
+ return
+ }
+ events, applyErr := machine.ApplyTick(result, lastStatus)
+ if applyErr != nil {
+ logger.LogError(c, "task protocol state transition failed: "+applyErr.Error())
+ writeTaskPluginProtocolFailure(c, machine, lastStatus)
+ return
+ }
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=protocol event=render_events generation=%d plugin=%q mode=stream tick=%d status=%q semantic_events=%d wire_events=%d done=%t state_present=%t elapsed_ms=%d",
+ generation,
+ pluginKey,
+ tickNumber,
+ taskPluginDebugStatus(lastStatus),
+ len(result.Events),
+ len(events),
+ result.Done,
+ result.State.Present,
+ hookElapsed.Milliseconds(),
+ )
+ for _, event := range events {
+ if err = writeTaskPluginProtocolEvent(c, event); err != nil {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=client_write_failed generation=%d plugin=%q mode=stream stage=event event_type=%q sequence=%d", generation, pluginKey, event.Type, event.SequenceNumber)
+ return
+ }
+ }
+ if taskPluginProtocolEventsTerminal(events) {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_complete generation=%d plugin=%q mode=stream reason=terminal status=%q ticks=%d", generation, pluginKey, taskPluginDebugStatus(lastStatus), tickNumber+1)
+ return
+ }
+ previous = result.State
+ }
+
+ delay := pluginProtocolTickDelay(taskID, tickNumber, deps.tickInterval, deps.tickJitter)
+ tickNumber++
+ if overloaded {
+ delay += deps.tickInterval
+ } else if hookElapsed > deps.tickInterval {
+ delay += deps.tickInterval
+ logger.LogWarn(c, fmt.Sprintf(
+ "task protocol render hook slow; plugin=%s task=%s elapsed_ms=%d",
+ pinned.Plugin.Meta.Key,
+ taskID,
+ hookElapsed.Milliseconds(),
+ ))
+ }
+ if !waitForTaskPluginProtocolTick(c, observationContext, heartbeatTicker, delay) {
+ if errors.Is(observationContext.Err(), context.DeadlineExceeded) {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_timeout generation=%d plugin=%q mode=stream last_status=%q", generation, pluginKey, taskPluginDebugStatus(lastStatus))
+ writeTaskPluginProtocolTimeout(c, machine, lastStatus)
+ } else if c.Request.Context().Err() != nil {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=client_disconnected generation=%d plugin=%q mode=stream stage=tick_wait", generation, pluginKey)
+ } else {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=client_write_failed generation=%d plugin=%q mode=stream stage=heartbeat", generation, pluginKey)
+ }
+ return
+ }
+ }
+}
+
+func waitForTaskPluginProtocolTick(
+ c *gin.Context,
+ observationContext context.Context,
+ heartbeatTicker *time.Ticker,
+ delay time.Duration,
+) bool {
+ tickTimer := time.NewTimer(delay)
+ defer tickTimer.Stop()
+ for {
+ select {
+ case <-c.Request.Context().Done():
+ return false
+ case <-observationContext.Done():
+ return false
+ case <-heartbeatTicker.C:
+ helper.ExtendWriteDeadline(c)
+ if err := writeTaskPluginProtocolHeartbeat(c); err != nil {
+ return false
+ }
+ case <-tickTimer.C:
+ return true
+ }
+ }
+}
+
+func waitTaskPluginProtocol(
+ c *gin.Context,
+ pinned pluginruntime.PinnedEndpoint,
+ protocolRequest pluginruntime.ProtocolRequestContext,
+ taskID string,
+ machine *relay.PluginResponsesMachine,
+ deps pluginProtocolBridgeDeps,
+) {
+ generation := pinned.Generation.Number
+ pluginKey := pinned.Plugin.Meta.Key
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=protocol event=observation_start generation=%d plugin=%q mode=nonstream public_task_id=%q timeout_ms=%d tick_ms=%d",
+ generation,
+ pluginKey,
+ taskID,
+ deps.observationTimeout.Milliseconds(),
+ deps.tickInterval.Milliseconds(),
+ )
+ observationContext, cancelObservation := context.WithTimeout(c.Request.Context(), deps.observationTimeout)
+ defer cancelObservation()
+ tickNumber := uint64(0)
+ lastStatus := ""
+ for {
+ loadStarted := deps.now()
+ loadContext, cancelLoad := context.WithTimeout(observationContext, deps.loadTimeout)
+ task, exists, err := deps.loadTask(
+ loadContext,
+ common.GetContextKeyInt(c, constant.ContextKeyUserId),
+ constant.TaskPlatform(pinned.Plugin.Meta.Key),
+ taskID,
+ )
+ loadContextErr := loadContext.Err()
+ cancelLoad()
+ loadElapsed := deps.now().Sub(loadStarted)
+ loadOverloaded := errors.Is(loadContextErr, context.DeadlineExceeded) &&
+ observationContext.Err() == nil &&
+ c.Request.Context().Err() == nil
+ if loadOverloaded {
+ logger.LogWarn(c, fmt.Sprintf(
+ "task protocol database observation overloaded; plugin=%s task=%s",
+ pinned.Plugin.Meta.Key,
+ taskID,
+ ))
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=protocol event=observation_tick generation=%d plugin=%q mode=nonstream tick=%d load_ms=%d overloaded=true",
+ generation,
+ pluginKey,
+ tickNumber,
+ loadElapsed.Milliseconds(),
+ )
+ } else if err != nil || !exists || task == nil {
+ if errors.Is(observationContext.Err(), context.DeadlineExceeded) {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_timeout generation=%d plugin=%q mode=nonstream last_status=%q", generation, pluginKey, taskPluginDebugStatus(lastStatus))
+ writeTaskPluginProtocolTimeoutResponse(c, machine, lastStatus)
+ return
+ }
+ if err != nil && !errors.Is(err, context.Canceled) {
+ logger.LogError(c, "task protocol database observation failed")
+ }
+ if c.Request.Context().Err() == nil {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_failed generation=%d plugin=%q mode=nonstream stage=load reason=task_unavailable", generation, pluginKey)
+ writeTaskPluginProtocolFailureResponse(c, machine, lastStatus)
+ } else {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=client_disconnected generation=%d plugin=%q mode=nonstream stage=load", generation, pluginKey)
+ }
+ return
+ }
+ overloaded := loadOverloaded
+ if !loadOverloaded {
+ previousStatus := lastStatus
+ lastStatus = string(task.Status)
+ if lastStatus != previousStatus {
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=protocol event=status_transition generation=%d plugin=%q mode=nonstream tick=%d previous=%q status=%q load_ms=%d",
+ generation,
+ pluginKey,
+ tickNumber,
+ taskPluginDebugStatus(previousStatus),
+ taskPluginDebugStatus(lastStatus),
+ loadElapsed.Milliseconds(),
+ )
+ }
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=protocol event=observation_tick generation=%d plugin=%q mode=nonstream tick=%d status=%q load_ms=%d overloaded=false",
+ generation,
+ pluginKey,
+ tickNumber,
+ taskPluginDebugStatus(lastStatus),
+ loadElapsed.Milliseconds(),
+ )
+ }
+ if !loadOverloaded && (task.Status == model.TaskStatusSuccess || task.Status == model.TaskStatusFailure) {
+ if task.Status == model.TaskStatusFailure {
+ writeTaskPluginProtocolFailureResponse(c, machine, string(task.Status))
+ return
+ }
+ response, hookElapsed, callErr := renderTaskPluginProtocolFinalResponse(
+ observationContext,
+ pinned,
+ protocolRequest,
+ task,
+ machine,
+ deps,
+ )
+ if callErr != nil {
+ if errors.Is(observationContext.Err(), context.DeadlineExceeded) {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_timeout generation=%d plugin=%q mode=nonstream stage=render_final last_status=%q", generation, pluginKey, taskPluginDebugStatus(lastStatus))
+ writeTaskPluginProtocolTimeoutResponse(c, machine, lastStatus)
+ return
+ }
+ if c.Request.Context().Err() != nil {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=client_disconnected generation=%d plugin=%q mode=nonstream stage=render_final", generation, pluginKey)
+ return
+ }
+ if errors.Is(callErr, pluginruntime.ErrCallAdmissionTimeout) {
+ overloaded = true
+ logger.LogWarn(c, fmt.Sprintf(
+ "task protocol final hook overloaded; plugin=%s task=%s",
+ pinned.Plugin.Meta.Key,
+ taskID,
+ ))
+ } else {
+ logger.LogError(c, "task protocol final hook failed")
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=protocol event=observation_failed generation=%d plugin=%q mode=nonstream stage=render_final reason=hook_failed elapsed_ms=%d",
+ generation,
+ pluginKey,
+ hookElapsed.Milliseconds(),
+ )
+ writeTaskPluginProtocolFailureResponse(c, machine, lastStatus)
+ return
+ }
+ } else {
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=protocol event=render_final generation=%d plugin=%q mode=nonstream status=%q elapsed_ms=%d",
+ generation,
+ pluginKey,
+ taskPluginDebugStatus(lastStatus),
+ hookElapsed.Milliseconds(),
+ )
+ c.JSON(http.StatusOK, response)
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=protocol event=observation_complete generation=%d plugin=%q mode=nonstream reason=terminal status=%q ticks=%d",
+ generation,
+ pluginKey,
+ taskPluginDebugStatus(lastStatus),
+ tickNumber+1,
+ )
+ return
+ }
+ }
+
+ delay := pluginProtocolTickDelay(taskID, tickNumber, deps.tickInterval, deps.tickJitter)
+ tickNumber++
+ if overloaded {
+ delay += deps.tickInterval
+ }
+ tickTimer := time.NewTimer(delay)
+ select {
+ case <-c.Request.Context().Done():
+ if !tickTimer.Stop() {
+ select {
+ case <-tickTimer.C:
+ default:
+ }
+ }
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=client_disconnected generation=%d plugin=%q mode=nonstream stage=tick_wait", generation, pluginKey)
+ return
+ case <-observationContext.Done():
+ if !tickTimer.Stop() {
+ select {
+ case <-tickTimer.C:
+ default:
+ }
+ }
+ if errors.Is(observationContext.Err(), context.DeadlineExceeded) {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_timeout generation=%d plugin=%q mode=nonstream last_status=%q", generation, pluginKey, taskPluginDebugStatus(lastStatus))
+ writeTaskPluginProtocolTimeoutResponse(c, machine, lastStatus)
+ }
+ return
+ case <-tickTimer.C:
+ }
+ }
+}
+
+func renderTaskPluginProtocolFinalResponse(
+ ctx context.Context,
+ pinned pluginruntime.PinnedEndpoint,
+ protocolRequest pluginruntime.ProtocolRequestContext,
+ task *model.Task,
+ machine *relay.PluginResponsesMachine,
+ deps pluginProtocolBridgeDeps,
+) (map[string]any, time.Duration, error) {
+ view, err := service.BuildTaskPluginView(task)
+ if err != nil {
+ return nil, 0, err
+ }
+ viewValue, err := taskPluginProtocolJSONValue(view)
+ if err != nil {
+ return nil, 0, err
+ }
+ rendererContext, err := taskPluginProtocolRendererContext(
+ protocolRequest,
+ pinned,
+ task,
+ deps.artifactContentURL,
+ )
+ if err != nil {
+ return nil, 0, err
+ }
+ hookStarted := deps.now()
+ payload, err := pinned.Plugin.Engine.CallPathWithAdmissionTimeout(
+ ctx,
+ deps.admissionTimeout,
+ "protocols",
+ []string{pinned.Protocol, "renderFinal"},
+ rendererContext,
+ viewValue,
+ )
+ hookElapsed := deps.now().Sub(hookStarted)
+ if err != nil {
+ return nil, hookElapsed, err
+ }
+ response, err := machine.FinalResponse(payload, string(task.Status))
+ if err != nil {
+ return nil, hookElapsed, err
+ }
+ return response, hookElapsed, nil
+}
+
+func renderTaskPluginProtocolEventsResponse(
+ ctx context.Context,
+ pinned pluginruntime.PinnedEndpoint,
+ protocolRequest pluginruntime.ProtocolRequestContext,
+ task *model.Task,
+ machine *relay.PluginResponsesMachine,
+ deps pluginProtocolBridgeDeps,
+) (map[string]any, time.Duration, error) {
+ view, err := service.BuildTaskPluginView(task)
+ if err != nil {
+ return nil, 0, err
+ }
+ viewValue, err := taskPluginProtocolJSONValue(view)
+ if err != nil {
+ return nil, 0, err
+ }
+ rendererContext, err := taskPluginProtocolRendererContext(
+ protocolRequest,
+ pinned,
+ task,
+ deps.artifactContentURL,
+ )
+ if err != nil {
+ return nil, 0, err
+ }
+ hookStarted := deps.now()
+ value, err := pinned.Plugin.Engine.CallPathWithAdmissionTimeout(
+ ctx,
+ deps.admissionTimeout,
+ "protocols",
+ []string{pinned.Protocol, "renderEvents"},
+ rendererContext,
+ viewValue,
+ )
+ hookElapsed := deps.now().Sub(hookStarted)
+ if err != nil {
+ return nil, hookElapsed, err
+ }
+ result, err := relay.DecodePluginProtocolEventResult(value, deps.protocolLimits)
+ if err != nil {
+ return nil, hookElapsed, err
+ }
+ response, err := machine.FinalFromEvents(result, string(task.Status))
+ if err != nil {
+ return nil, hookElapsed, err
+ }
+ return response, hookElapsed, nil
+}
+
+func RetrieveTaskPluginResponse(c *gin.Context) {
+ retrieveTaskPluginResponse(c, defaultPluginProtocolBridgeDeps())
+}
+
+func retrieveTaskPluginResponse(c *gin.Context, deps pluginProtocolBridgeDeps) {
+ deps = deps.withDefaults()
+ responseID := strings.TrimSpace(c.Param("response_id"))
+ if !strings.HasPrefix(responseID, "resp_") {
+ writeTaskPluginResponseNotFound(c, responseID, "bad_prefix")
+ return
+ }
+ taskID := "task_" + strings.TrimPrefix(responseID, "resp_")
+ userID := common.GetContextKeyInt(c, constant.ContextKeyUserId)
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=retrieve_start response_id=%q public_task_id=%q", responseID, taskID)
+
+ task, exists, err := deps.getByTaskId(userID, taskID)
+ if err != nil {
+ logger.LogError(c, "task protocol retrieve lookup failed")
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=retrieve_failed reason=lookup_error public_task_id=%q", taskID)
+ respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
+ return
+ }
+ if !exists || task == nil {
+ writeTaskPluginResponseNotFound(c, responseID, "missing")
+ return
+ }
+
+ plugin, generation, ok := deps.resolvePlugin(task.Platform)
+ if !ok || plugin == nil {
+ writeTaskPluginResponseNotFound(c, responseID, "no_plugin")
+ return
+ }
+ claimsProtocol := false
+ for _, claim := range plugin.Meta.Protocols {
+ if claim.Name == "openai_responses" {
+ claimsProtocol = true
+ break
+ }
+ }
+ if !claimsProtocol {
+ writeTaskPluginResponseNotFound(c, responseID, "no_claim")
+ return
+ }
+
+ generationNumber := uint64(0)
+ if generation != nil {
+ generationNumber = generation.Number
+ }
+ createdAt := task.CreatedAt
+ if createdAt == 0 {
+ createdAt = task.SubmitTime
+ }
+ if createdAt == 0 {
+ createdAt = deps.now().Unix()
+ }
+ machine := relay.NewPluginResponsesMachine(
+ task.TaskID,
+ task.Properties.OriginModelName,
+ createdAt,
+ deps.protocolLimits,
+ )
+ machine.SetBackground(task.PrivateData.ResponsesBackground)
+ pinned := pluginruntime.PinnedEndpoint{
+ Generation: generation,
+ Plugin: plugin,
+ Protocol: "openai_responses",
+ Model: task.Properties.OriginModelName,
+ }
+ protocolRequest := pluginruntime.ProtocolRequestContext{
+ RouteRequestContext: pluginruntime.RouteRequestContext{
+ Path: c.Request.URL.Path,
+ Method: http.MethodGet,
+ Params: map[string]string{"response_id": responseID},
+ Query: c.Request.URL.Query(),
+ Body: map[string]any{"kind": string(pluginruntime.BodyNone)},
+ },
+ Protocol: "openai_responses",
+ Operation: "retrieve",
+ Model: task.Properties.OriginModelName,
+ }
+
+ if task.Status == model.TaskStatusFailure {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=retrieve_final generation=%d plugin=%q public_task_id=%q status=%q", generationNumber, plugin.Meta.Key, task.TaskID, taskPluginDebugStatus(string(task.Status)))
+ writeTaskPluginProtocolFailureResponse(c, machine, string(task.Status))
+ return
+ }
+ if task.Status != model.TaskStatusSuccess {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=retrieve_pending generation=%d plugin=%q public_task_id=%q status=%q", generationNumber, plugin.Meta.Key, task.TaskID, taskPluginDebugStatus(string(task.Status)))
+ c.JSON(http.StatusOK, machine.PendingResponse(string(task.Status)))
+ return
+ }
+
+ var (
+ response map[string]any
+ hookElapsed time.Duration
+ renderErr error
+ )
+ if plugin.Meta.ProtocolSupports("openai_responses", "sync") || plugin.Meta.ProtocolSupports("openai_responses", "background") {
+ response, hookElapsed, renderErr = renderTaskPluginProtocolFinalResponse(
+ c.Request.Context(),
+ pinned,
+ protocolRequest,
+ task,
+ machine,
+ deps,
+ )
+ } else {
+ response, hookElapsed, renderErr = renderTaskPluginProtocolEventsResponse(
+ c.Request.Context(),
+ pinned,
+ protocolRequest,
+ task,
+ machine,
+ deps,
+ )
+ }
+ if renderErr != nil {
+ logger.LogError(c, "task protocol retrieve render failed")
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=protocol event=retrieve_failed generation=%d plugin=%q public_task_id=%q stage=render_final elapsed_ms=%d",
+ generationNumber,
+ plugin.Meta.Key,
+ task.TaskID,
+ hookElapsed.Milliseconds(),
+ )
+ writeTaskPluginProtocolFailureResponse(c, machine, string(task.Status))
+ return
+ }
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=protocol event=retrieve_final generation=%d plugin=%q public_task_id=%q status=%q elapsed_ms=%d",
+ generationNumber,
+ plugin.Meta.Key,
+ task.TaskID,
+ taskPluginDebugStatus(string(task.Status)),
+ hookElapsed.Milliseconds(),
+ )
+ c.JSON(http.StatusOK, response)
+}
+
+func writeTaskPluginResponseNotFound(c *gin.Context, responseID, reason string) {
+ logger.LogDebug(c, "task_plugin subsystem=protocol event=retrieve_not_found reason=%s response_id=%q", reason, responseID)
+ respondPluginProtocolError(c, http.StatusNotFound, "not_found", "No response found with id '"+responseID+"'.")
+}
+
+func writeTaskPluginProtocolHeartbeat(c *gin.Context) error {
+ if _, err := c.Writer.Write([]byte(": PING\n")); err != nil {
+ return err
+ }
+ return helper.FlushWriter(c)
+}
+
+func writeTaskPluginProtocolFailure(
+ c *gin.Context,
+ machine *relay.PluginResponsesMachine,
+ taskStatus string,
+) {
+ failed, err := machine.FailureEvent(taskStatus)
+ if err != nil {
+ logger.LogError(c, "task protocol failure event failed: "+err.Error())
+ return
+ }
+ _ = writeTaskPluginProtocolEvent(c, failed)
+}
+
+func writeTaskPluginProtocolTimeout(
+ c *gin.Context,
+ machine *relay.PluginResponsesMachine,
+ taskStatus string,
+) {
+ incomplete, err := machine.TimeoutEvent(taskStatus)
+ if err != nil {
+ logger.LogError(c, "task protocol timeout event failed: "+err.Error())
+ return
+ }
+ _ = writeTaskPluginProtocolEvent(c, incomplete)
+}
+
+func writeTaskPluginProtocolFailureResponse(
+ c *gin.Context,
+ machine *relay.PluginResponsesMachine,
+ taskStatus string,
+) {
+ if taskStatus == string(model.TaskStatusFailure) {
+ response, err := machine.FinalResponse(nil, taskStatus)
+ if err != nil {
+ logger.LogError(c, "task protocol terminal failure response failed: "+err.Error())
+ respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
+ return
+ }
+ c.JSON(http.StatusOK, response)
+ return
+ }
+ response, err := machine.FailureResponse(taskStatus)
+ if err != nil {
+ logger.LogError(c, "task protocol failure response failed: "+err.Error())
+ respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
+ return
+ }
+ c.JSON(http.StatusOK, response)
+}
+
+func writeTaskPluginProtocolTimeoutResponse(
+ c *gin.Context,
+ machine *relay.PluginResponsesMachine,
+ lastStatus string,
+) {
+ response, err := machine.TimeoutResponse(lastStatus)
+ if err != nil {
+ logger.LogError(c, "task protocol timeout response failed: "+err.Error())
+ respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
+ return
+ }
+ c.JSON(http.StatusOK, response)
+}
+
+func taskPluginProtocolJSONValue(value any) (any, error) {
+ encoded, err := common.Marshal(value)
+ if err != nil {
+ return nil, err
+ }
+ var decoded any
+ if err = common.Unmarshal(encoded, &decoded); err != nil {
+ return nil, err
+ }
+ return decoded, nil
+}
+
+func taskPluginProtocolRendererContext(
+ request pluginruntime.ProtocolRequestContext,
+ pinned pluginruntime.PinnedEndpoint,
+ task *model.Task,
+ artifactContentURL func(taskID, artifactKey string) (string, error),
+) (map[string]any, error) {
+ rendererContext := request.JSValue()
+ if task == nil || task.Status != model.TaskStatusSuccess {
+ return rendererContext, nil
+ }
+ if pinned.Plugin == nil {
+ return nil, errors.New("task artifact projection is unavailable")
+ }
+
+ artifacts, err := taskjsplugin.New(pinned.Plugin).ListArtifacts(task)
+ if err != nil {
+ return nil, fmt.Errorf("project task artifacts: %w", err)
+ }
+ artifacts, err = validateProjectedTaskArtifacts(artifacts)
+ if err != nil {
+ return nil, err
+ }
+ if len(artifacts) > 0 && artifactContentURL == nil {
+ return nil, errors.New("task artifact projection is unavailable")
+ }
+
+ rendererArtifacts := make(map[string]any, len(artifacts))
+ for _, artifact := range artifacts {
+ contentURL, buildErr := artifactContentURL(task.TaskID, artifact.Key)
+ if buildErr != nil {
+ return nil, fmt.Errorf("build task artifact content URL: %w", buildErr)
+ }
+ item := map[string]any{
+ "key": artifact.Key,
+ "type": artifact.Type,
+ "url": contentURL,
+ }
+ if artifact.MimeType != "" {
+ item["mimeType"] = artifact.MimeType
+ }
+ rendererArtifacts[artifact.Key] = item
+ }
+ rendererContext["artifacts"] = rendererArtifacts
+ return rendererContext, nil
+}
+
+func pluginProtocolTickDelay(taskID string, tick uint64, base, jitter time.Duration) time.Duration {
+ if jitter <= 0 {
+ return base
+ }
+ hash := fnv.New64a()
+ _, _ = hash.Write([]byte(taskID))
+ _, _ = hash.Write([]byte(":"))
+ _, _ = hash.Write([]byte(strconv.FormatUint(tick, 10)))
+ return base + time.Duration(hash.Sum64()%uint64(jitter+1))
+}
+
+func taskPluginProtocolEventsTerminal(events []dto.PluginResponsesStreamEvent) bool {
+ for _, event := range events {
+ switch event.Type {
+ case "response.completed", "response.failed", "response.incomplete":
+ return true
+ }
+ }
+ return false
+}
+
+func writeTaskPluginProtocolEvent(c *gin.Context, event dto.PluginResponsesStreamEvent) error {
+ encoded, err := common.Marshal(event)
+ if err != nil {
+ return err
+ }
+ helper.ExtendWriteDeadline(c)
+ if _, err = c.Writer.Write([]byte("event: " + event.Type + "\n")); err != nil {
+ return err
+ }
+ if _, err = c.Writer.Write([]byte("data: " + string(encoded) + "\n\n")); err != nil {
+ return err
+ }
+ if err = helper.FlushWriter(c); err != nil {
+ return err
+ }
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=protocol event=sse_event_sent event_type=%q sequence=%d",
+ event.Type,
+ event.SequenceNumber,
+ )
+ return nil
+}
+
+func respondPluginProtocolSubmissionError(c *gin.Context, taskErr *dto.TaskError) {
+ status := http.StatusInternalServerError
+ if taskErr != nil && taskErr.StatusCode >= 400 && taskErr.StatusCode <= 599 {
+ status = taskErr.StatusCode
+ }
+ switch status {
+ case http.StatusBadRequest:
+ message := "Invalid task protocol request"
+ if taskErr != nil && taskErr.Message != "" && (taskErr.Code == "invalid_request" || strings.HasPrefix(taskErr.Code, "invalid_request")) {
+ message = taskErr.Message
+ }
+ respondPluginProtocolError(c, status, "invalid_request_error", message)
+ case http.StatusUnauthorized:
+ respondPluginProtocolError(c, status, "authentication_error", "Authentication failed")
+ case http.StatusForbidden:
+ respondPluginProtocolError(c, status, "permission_denied", "Task protocol request was denied")
+ case http.StatusTooManyRequests:
+ respondPluginProtocolError(c, status, "rate_limit_exceeded", "Too many requests")
+ default:
+ respondPluginProtocolError(c, status, "task_protocol_error", "Task protocol request failed")
+ }
+}
+
+func respondPluginProtocolError(c *gin.Context, status int, code, message string) {
+ c.JSON(status, gin.H{
+ "error": gin.H{
+ "message": message,
+ "type": "new_api_error",
+ "code": code,
+ },
+ })
+}
diff --git a/controller/plugin_protocol_limiter.go b/controller/plugin_protocol_limiter.go
new file mode 100644
index 000000000000..d88d177ee8de
--- /dev/null
+++ b/controller/plugin_protocol_limiter.go
@@ -0,0 +1,157 @@
+package controller
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+ "sync"
+)
+
+var (
+ errPluginProtocolObservationLimitExceeded = errors.New("plugin protocol observation limit exceeded")
+ errInvalidPluginProtocolObservationIdentity = errors.New("invalid plugin protocol observation identity")
+)
+
+type pluginProtocolObservationLimits struct {
+ global int
+ perPlugin int
+ perUser int
+ perToken int
+}
+
+var defaultPluginProtocolObservationLimits = pluginProtocolObservationLimits{
+ global: 128,
+ perPlugin: 32,
+ perUser: 4,
+ perToken: 2,
+}
+
+var pluginProtocolObservationAdmissions = newPluginProtocolObservationLimiter(
+ defaultPluginProtocolObservationLimits,
+)
+
+type pluginProtocolObservationLimitError struct {
+ scope string
+ limit int
+}
+
+func (e *pluginProtocolObservationLimitError) Error() string {
+ return fmt.Sprintf("%s: %s capacity is %d", errPluginProtocolObservationLimitExceeded, e.scope, e.limit)
+}
+
+func (e *pluginProtocolObservationLimitError) Unwrap() error {
+ return errPluginProtocolObservationLimitExceeded
+}
+
+type pluginProtocolObservationLimiter struct {
+ mu sync.Mutex
+
+ limits pluginProtocolObservationLimits
+ global int
+ plugin map[string]int
+ user map[int]int
+ token map[int]int
+}
+
+func newPluginProtocolObservationLimiter(limits pluginProtocolObservationLimits) *pluginProtocolObservationLimiter {
+ return &pluginProtocolObservationLimiter{
+ limits: limits,
+ plugin: make(map[string]int),
+ user: make(map[int]int),
+ token: make(map[int]int),
+ }
+}
+
+func (l *pluginProtocolObservationLimiter) acquire(
+ pluginKey string,
+ userID int,
+ tokenID int,
+) (func(), error) {
+ pluginKey = strings.TrimSpace(pluginKey)
+ switch {
+ case pluginKey == "":
+ return nil, fmt.Errorf("%w: plugin key is required", errInvalidPluginProtocolObservationIdentity)
+ case userID <= 0:
+ return nil, fmt.Errorf("%w: user id must be positive", errInvalidPluginProtocolObservationIdentity)
+ case tokenID <= 0:
+ return nil, fmt.Errorf("%w: token id must be positive", errInvalidPluginProtocolObservationIdentity)
+ }
+
+ l.mu.Lock()
+ if l.global >= l.limits.global {
+ l.mu.Unlock()
+ return nil, &pluginProtocolObservationLimitError{
+ scope: "global",
+ limit: l.limits.global,
+ }
+ }
+ l.global++
+
+ if l.plugin[pluginKey] >= l.limits.perPlugin {
+ l.global--
+ l.mu.Unlock()
+ return nil, &pluginProtocolObservationLimitError{
+ scope: "plugin",
+ limit: l.limits.perPlugin,
+ }
+ }
+ l.plugin[pluginKey]++
+
+ if l.user[userID] >= l.limits.perUser {
+ l.global--
+ l.plugin[pluginKey]--
+ if l.plugin[pluginKey] == 0 {
+ delete(l.plugin, pluginKey)
+ }
+ l.mu.Unlock()
+ return nil, &pluginProtocolObservationLimitError{
+ scope: "user",
+ limit: l.limits.perUser,
+ }
+ }
+ l.user[userID]++
+
+ if l.token[tokenID] >= l.limits.perToken {
+ l.global--
+ l.plugin[pluginKey]--
+ if l.plugin[pluginKey] == 0 {
+ delete(l.plugin, pluginKey)
+ }
+ l.user[userID]--
+ if l.user[userID] == 0 {
+ delete(l.user, userID)
+ }
+ l.mu.Unlock()
+ return nil, &pluginProtocolObservationLimitError{
+ scope: "token",
+ limit: l.limits.perToken,
+ }
+ }
+ l.token[tokenID]++
+ l.mu.Unlock()
+
+ var once sync.Once
+ return func() {
+ once.Do(func() {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ l.global--
+
+ l.plugin[pluginKey]--
+ if l.plugin[pluginKey] == 0 {
+ delete(l.plugin, pluginKey)
+ }
+
+ l.user[userID]--
+ if l.user[userID] == 0 {
+ delete(l.user, userID)
+ }
+
+ l.token[tokenID]--
+ if l.token[tokenID] == 0 {
+ delete(l.token, tokenID)
+ }
+ })
+ }, nil
+}
diff --git a/controller/plugin_protocol_limiter_test.go b/controller/plugin_protocol_limiter_test.go
new file mode 100644
index 000000000000..a6b48cf65d43
--- /dev/null
+++ b/controller/plugin_protocol_limiter_test.go
@@ -0,0 +1,248 @@
+package controller
+
+import (
+ "errors"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPluginProtocolObservationLimiterCaps(t *testing.T) {
+ t.Run("global", func(t *testing.T) {
+ limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
+ global: 2,
+ perPlugin: 2,
+ perUser: 2,
+ perToken: 2,
+ })
+ releaseFirst, err := limiter.acquire("first", 1, 1)
+ require.NoError(t, err)
+ defer releaseFirst()
+ releaseSecond, err := limiter.acquire("second", 2, 2)
+ require.NoError(t, err)
+ defer releaseSecond()
+
+ release, err := limiter.acquire("third", 3, 3)
+ assert.Nil(t, release)
+ assertLimitError(t, err, "global", 2)
+ })
+
+ t.Run("plugin", func(t *testing.T) {
+ limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
+ global: 3,
+ perPlugin: 1,
+ perUser: 3,
+ perToken: 3,
+ })
+ releaseFirst, err := limiter.acquire("shared", 1, 1)
+ require.NoError(t, err)
+ defer releaseFirst()
+
+ release, err := limiter.acquire("shared", 2, 2)
+ assert.Nil(t, release)
+ assertLimitError(t, err, "plugin", 1)
+
+ releaseOther, err := limiter.acquire("other", 2, 2)
+ require.NoError(t, err)
+ defer releaseOther()
+ })
+
+ t.Run("user", func(t *testing.T) {
+ limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
+ global: 3,
+ perPlugin: 3,
+ perUser: 1,
+ perToken: 3,
+ })
+ releaseFirst, err := limiter.acquire("first", 1, 1)
+ require.NoError(t, err)
+ defer releaseFirst()
+
+ release, err := limiter.acquire("second", 1, 2)
+ assert.Nil(t, release)
+ assertLimitError(t, err, "user", 1)
+
+ releaseOther, err := limiter.acquire("second", 2, 2)
+ require.NoError(t, err)
+ defer releaseOther()
+ })
+
+ t.Run("token", func(t *testing.T) {
+ limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
+ global: 3,
+ perPlugin: 3,
+ perUser: 3,
+ perToken: 1,
+ })
+ releaseFirst, err := limiter.acquire("first", 1, 1)
+ require.NoError(t, err)
+ defer releaseFirst()
+
+ release, err := limiter.acquire("second", 2, 1)
+ assert.Nil(t, release)
+ assertLimitError(t, err, "token", 1)
+
+ releaseOther, err := limiter.acquire("second", 2, 2)
+ require.NoError(t, err)
+ defer releaseOther()
+ })
+}
+
+func TestPluginProtocolObservationLimiterRollsBackFailedAdmission(t *testing.T) {
+ t.Run("user failure", func(t *testing.T) {
+ limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
+ global: 2,
+ perPlugin: 2,
+ perUser: 1,
+ perToken: 2,
+ })
+ releaseHeld, err := limiter.acquire("first", 1, 1)
+ require.NoError(t, err)
+ defer releaseHeld()
+
+ release, err := limiter.acquire("second", 1, 2)
+ assert.Nil(t, release)
+ assertLimitError(t, err, "user", 1)
+
+ releaseReplacement, err := limiter.acquire("second", 2, 2)
+ require.NoError(t, err)
+ defer releaseReplacement()
+ })
+
+ t.Run("token failure", func(t *testing.T) {
+ limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
+ global: 2,
+ perPlugin: 2,
+ perUser: 2,
+ perToken: 1,
+ })
+ releaseHeld, err := limiter.acquire("first", 1, 1)
+ require.NoError(t, err)
+ defer releaseHeld()
+
+ release, err := limiter.acquire("second", 2, 1)
+ assert.Nil(t, release)
+ assertLimitError(t, err, "token", 1)
+
+ releaseReplacement, err := limiter.acquire("second", 2, 2)
+ require.NoError(t, err)
+ defer releaseReplacement()
+ })
+}
+
+func TestPluginProtocolObservationLimiterReleaseIsIdempotent(t *testing.T) {
+ limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
+ global: 1,
+ perPlugin: 1,
+ perUser: 1,
+ perToken: 1,
+ })
+ release, err := limiter.acquire("plugin", 1, 1)
+ require.NoError(t, err)
+
+ release()
+ release()
+
+ releaseAgain, err := limiter.acquire("plugin", 1, 1)
+ require.NoError(t, err)
+ releaseAgain()
+}
+
+func TestPluginProtocolObservationLimiterRejectsMissingIdentityWithoutConsumingCapacity(t *testing.T) {
+ limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
+ global: 1,
+ perPlugin: 1,
+ perUser: 1,
+ perToken: 1,
+ })
+
+ for _, testCase := range []struct {
+ name string
+ pluginKey string
+ userID int
+ tokenID int
+ }{
+ {name: "empty plugin", userID: 1, tokenID: 1},
+ {name: "blank plugin", pluginKey: " \t", userID: 1, tokenID: 1},
+ {name: "zero user", pluginKey: "plugin", tokenID: 1},
+ {name: "negative user", pluginKey: "plugin", userID: -1, tokenID: 1},
+ {name: "zero token", pluginKey: "plugin", userID: 1},
+ {name: "negative token", pluginKey: "plugin", userID: 1, tokenID: -1},
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ release, err := limiter.acquire(testCase.pluginKey, testCase.userID, testCase.tokenID)
+ assert.Nil(t, release)
+ assert.ErrorIs(t, err, errInvalidPluginProtocolObservationIdentity)
+ })
+ }
+
+ release, err := limiter.acquire("plugin", 1, 1)
+ require.NoError(t, err)
+ release()
+}
+
+func TestPluginProtocolObservationLimiterConcurrentAdmissionsRespectCap(t *testing.T) {
+ const (
+ workerCount = 8
+ globalLimit = 3
+ )
+ limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
+ global: globalLimit,
+ perPlugin: workerCount,
+ perUser: workerCount,
+ perToken: workerCount,
+ })
+ start := make(chan struct{})
+ releases := make(chan func(), workerCount)
+ errorsFound := make(chan error, workerCount)
+
+ var workers sync.WaitGroup
+ workers.Add(workerCount)
+ for worker := 1; worker <= workerCount; worker++ {
+ go func(id int) {
+ defer workers.Done()
+ <-start
+ release, err := limiter.acquire("plugin", id, id)
+ if err != nil {
+ errorsFound <- err
+ return
+ }
+ releases <- release
+ }(worker)
+ }
+ close(start)
+ workers.Wait()
+ close(releases)
+ close(errorsFound)
+
+ assert.Len(t, releases, globalLimit)
+ assert.Len(t, errorsFound, workerCount-globalLimit)
+ for err := range errorsFound {
+ assert.ErrorIs(t, err, errPluginProtocolObservationLimitExceeded)
+ }
+ for release := range releases {
+ release()
+ }
+
+ release, err := limiter.acquire("plugin", 1, 1)
+ require.NoError(t, err)
+ release()
+}
+
+func assertLimitError(
+ t *testing.T,
+ err error,
+ expectedScope string,
+ expectedLimit int,
+) {
+ t.Helper()
+ require.Error(t, err)
+ assert.ErrorIs(t, err, errPluginProtocolObservationLimitExceeded)
+
+ var limitError *pluginProtocolObservationLimitError
+ require.True(t, errors.As(err, &limitError))
+ assert.Equal(t, expectedScope, limitError.scope)
+ assert.Equal(t, expectedLimit, limitError.limit)
+}
diff --git a/controller/plugin_protocol_sdk_test.go b/controller/plugin_protocol_sdk_test.go
new file mode 100644
index 000000000000..971794c9b249
--- /dev/null
+++ b/controller/plugin_protocol_sdk_test.go
@@ -0,0 +1,305 @@
+package controller
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/dto"
+ "github.com/QuantumNous/new-api/model"
+ pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
+ builtinplugins "github.com/QuantumNous/new-api/plugins"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/gin-gonic/gin"
+ "github.com/openai/openai-go"
+ "github.com/openai/openai-go/option"
+ "github.com/openai/openai-go/responses"
+ "github.com/openai/openai-go/shared"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTaskPluginResponsesNonStreamDecodesWithOfficialGoSDK(t *testing.T) {
+ pinned := compilePluginProtocolTestEndpoint(t, "official-sdk-non-stream", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() { throw new Error("non-stream called renderEvents"); },
+ renderFinal: function() {
+ return {
+ output: [{
+ type: "message",
+ status: "completed",
+ role: "assistant",
+ content: [{
+ type: "output_text",
+ text: "official-sdk-final",
+ annotations: [],
+ logprobs: []
+ }]
+ }]
+ };
+ }
+ }};
+ `)
+ deps := pluginProtocolTestDeps()
+ deps.submit = func(_ *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
+ return pluginProtocolTestOutcome(info, pinned.Plugin.Meta.Key, "task_sdk_final", nil), nil
+ }
+ deps.loadTask = func(context.Context, int, constant.TaskPlatform, string) (*model.Task, bool, error) {
+ return &model.Task{
+ TaskID: "task_sdk_final",
+ UserId: 71,
+ Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
+ Status: model.TaskStatusSuccess,
+ }, true, nil
+ }
+ server := newPluginProtocolSDKTestServer(t, pinned, deps)
+ defer server.Close()
+ client := openai.NewClient(
+ option.WithAPIKey("test-key"),
+ option.WithBaseURL(server.URL+"/v1/"),
+ option.WithMaxRetries(0),
+ )
+ requestContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ response, err := client.Responses.New(requestContext, responses.ResponseNewParams{
+ Model: shared.ResponsesModel("video-model"),
+ Input: responses.ResponseNewParamsInputUnion{
+ OfString: openai.String("create a video"),
+ },
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, "resp_sdk_final", response.ID)
+ assert.Equal(t, responses.ResponseStatusCompleted, response.Status)
+ assert.Equal(t, "video-model", response.Model)
+ assert.Equal(t, "official-sdk-final", response.OutputText())
+ assert.Equal(t, "task_sdk_final", response.Metadata["task_id"])
+}
+
+func TestTaskPluginResponsesStreamDecodesWithOfficialGoSDK(t *testing.T) {
+ pinned := compilePluginProtocolTestEndpoint(t, "official-sdk-stream", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() {
+ return {events: [{type: "output", data: "official-sdk-stream"}], done: true};
+ },
+ renderFinal: function() { throw new Error("stream called renderFinal"); }
+ }};
+ `)
+ deps := pluginProtocolTestDeps()
+ deps.submit = func(_ *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
+ return pluginProtocolTestOutcome(info, pinned.Plugin.Meta.Key, "task_sdk_stream", nil), nil
+ }
+ deps.loadTask = func(context.Context, int, constant.TaskPlatform, string) (*model.Task, bool, error) {
+ return &model.Task{
+ TaskID: "task_sdk_stream",
+ UserId: 71,
+ Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
+ Status: model.TaskStatusSuccess,
+ }, true, nil
+ }
+ server := newPluginProtocolSDKTestServer(t, pinned, deps)
+ defer server.Close()
+ client := openai.NewClient(
+ option.WithAPIKey("test-key"),
+ option.WithBaseURL(server.URL+"/v1/"),
+ option.WithMaxRetries(0),
+ )
+ requestContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ stream := client.Responses.NewStreaming(requestContext, responses.ResponseNewParams{
+ Model: shared.ResponsesModel("video-model"),
+ Input: responses.ResponseNewParamsInputUnion{
+ OfString: openai.String("create a video"),
+ },
+ })
+ defer stream.Close()
+ eventTypes := make([]string, 0, 8)
+ sequenceNumbers := make([]int64, 0, 8)
+ var completedText string
+ for stream.Next() {
+ event := stream.Current()
+ eventTypes = append(eventTypes, event.Type)
+ sequenceNumbers = append(sequenceNumbers, event.SequenceNumber)
+ if event.Type == "response.completed" {
+ completedText = event.Response.OutputText()
+ }
+ }
+
+ require.NoError(t, stream.Err())
+ assert.Equal(t, []string{
+ "response.created",
+ "response.output_item.added",
+ "response.content_part.added",
+ "response.output_text.delta",
+ "response.output_text.done",
+ "response.content_part.done",
+ "response.output_item.done",
+ "response.completed",
+ }, eventTypes)
+ assert.Equal(t, []int64{0, 1, 2, 3, 4, 5, 6, 7}, sequenceNumbers)
+ assert.Equal(t, "official-sdk-stream", completedText)
+}
+
+func TestBuiltInKlingResponsesNonStreamDecodesWithOfficialGoSDK(t *testing.T) {
+ pinned, deps := builtInKlingProtocolSDKFixture(t, "task_kling_sdk_final")
+ server := newPluginProtocolSDKTestServer(t, pinned, deps)
+ defer server.Close()
+ client := openai.NewClient(
+ option.WithAPIKey("test-key"),
+ option.WithBaseURL(server.URL+"/v1/"),
+ option.WithMaxRetries(0),
+ )
+ requestContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ response, err := client.Responses.New(requestContext, responses.ResponseNewParams{
+ Model: shared.ResponsesModel("kling-v2-master"),
+ Input: responses.ResponseNewParamsInputUnion{
+ OfString: openai.String("camera orbit"),
+ },
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, responses.ResponseStatusCompleted, response.Status)
+ assert.Equal(t, "kling-v2-master", response.Model)
+ assert.Contains(t, response.OutputText(), "https://gateway.example/v1/tasks/task_kling_sdk_final/artifacts/video/content")
+ assert.NotContains(t, response.OutputText(), "upstream.example")
+ assert.Equal(t, "kling", response.Metadata["vendor"])
+ assert.Equal(t, "task_kling_sdk_final", response.Metadata["task_id"])
+}
+
+func TestBuiltInKlingResponsesStreamDecodesWithOfficialGoSDK(t *testing.T) {
+ pinned, deps := builtInKlingProtocolSDKFixture(t, "task_kling_sdk_stream")
+ server := newPluginProtocolSDKTestServer(t, pinned, deps)
+ defer server.Close()
+ client := openai.NewClient(
+ option.WithAPIKey("test-key"),
+ option.WithBaseURL(server.URL+"/v1/"),
+ option.WithMaxRetries(0),
+ )
+ requestContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ stream := client.Responses.NewStreaming(requestContext, responses.ResponseNewParams{
+ Model: shared.ResponsesModel("kling-v2-master"),
+ Input: responses.ResponseNewParamsInputUnion{
+ OfString: openai.String("camera orbit"),
+ },
+ })
+ defer stream.Close()
+ eventTypes := make([]string, 0, 8)
+ sequenceNumbers := make([]int64, 0, 8)
+ var completedText string
+ for stream.Next() {
+ event := stream.Current()
+ eventTypes = append(eventTypes, event.Type)
+ sequenceNumbers = append(sequenceNumbers, event.SequenceNumber)
+ if event.Type == "response.completed" {
+ completedText = event.Response.OutputText()
+ }
+ }
+
+ require.NoError(t, stream.Err())
+ assert.Equal(t, []string{
+ "response.created",
+ "response.output_item.added",
+ "response.content_part.added",
+ "response.output_text.delta",
+ "response.output_text.done",
+ "response.content_part.done",
+ "response.output_item.done",
+ "response.completed",
+ }, eventTypes)
+ assert.Equal(t, []int64{0, 1, 2, 3, 4, 5, 6, 7}, sequenceNumbers)
+ assert.Contains(t, completedText, "https://gateway.example/v1/tasks/task_kling_sdk_stream/artifacts/video/content")
+ assert.NotContains(t, completedText, "upstream.example")
+}
+
+func builtInKlingProtocolSDKFixture(t *testing.T, taskID string) (pluginruntime.PinnedEndpoint, pluginProtocolBridgeDeps) {
+ t.Helper()
+ source, err := builtinplugins.Source("kling")
+ require.NoError(t, err)
+ registry := pluginruntime.NewRegistry()
+ plugin, err := registry.RegisterFactory(source, pluginruntime.Options{Key: "kling"})
+ require.NoError(t, err)
+ binding, found := registry.Generation().LookupEndpoint(http.MethodPost, "/v1/responses", "kling-v2-master")
+ require.True(t, found)
+ pinned := pluginruntime.PinnedEndpoint{
+ Generation: registry.Generation(),
+ Plugin: plugin,
+ Protocol: binding.Protocol,
+ Operation: binding.Operation,
+ Model: binding.Model,
+ Candidates: []pluginruntime.ProtocolBinding{binding},
+ }
+ deps := pluginProtocolTestDeps()
+ deps.submit = func(_ *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
+ return pluginProtocolTestOutcome(info, plugin.Meta.Key, taskID, nil), nil
+ }
+ deps.loadTask = func(context.Context, int, constant.TaskPlatform, string) (*model.Task, bool, error) {
+ task := &model.Task{
+ TaskID: taskID, Platform: constant.TaskPlatform(plugin.Meta.Key), UserId: 71,
+ Status: model.TaskStatusSuccess, Progress: "100%", CreatedAt: 1_710_000_000,
+ }
+ task.SetData(map[string]any{
+ "code": 0,
+ "data": map[string]any{
+ "task_id": "upstream-private",
+ "task_status": "succeed",
+ "task_result": map[string]any{
+ "videos": []any{map[string]any{"url": "https://upstream.example/private-video.mp4"}},
+ },
+ },
+ })
+ return task, true, nil
+ }
+ deps.artifactContentURL = func(publicTaskID, artifactKey string) (string, error) {
+ return "https://gateway.example/v1/tasks/" + publicTaskID + "/artifacts/" + artifactKey + "/content", nil
+ }
+ return pinned, deps
+}
+
+func newPluginProtocolSDKTestServer(
+ t *testing.T,
+ pinned pluginruntime.PinnedEndpoint,
+ deps pluginProtocolBridgeDeps,
+) *httptest.Server {
+ t.Helper()
+ return httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ if request.Method != http.MethodPost || request.URL.Path != "/v1/responses" {
+ http.NotFound(writer, request)
+ return
+ }
+ var requestBody map[string]any
+ if err := common.DecodeJson(request.Body, &requestBody); err != nil {
+ http.Error(writer, "invalid request", http.StatusBadRequest)
+ return
+ }
+ modelName, _ := requestBody["model"].(string)
+ stream, _ := requestBody["stream"].(bool)
+ c, _ := gin.CreateTestContext(writer)
+ c.Request = request
+ common.SetContextKey(c, constant.ContextKeyUserId, 71)
+ common.SetContextKey(c, constant.ContextKeyTokenId, 81)
+ common.SetContextKey(c, constant.ContextKeyUsingGroup, "default")
+ c.Set("resolved_task_model", modelName)
+ c.Set(pluginruntime.ContextKeyProtocolRequest, pluginruntime.ProtocolRequestContext{
+ RouteRequestContext: pluginruntime.RouteRequestContext{
+ Path: request.URL.Path,
+ Method: request.Method,
+ Params: map[string]string{},
+ Query: request.URL.Query(),
+ RequestBody: requestBody,
+ },
+ Protocol: pinned.Protocol,
+ Stream: stream,
+ })
+ serveTaskPluginProtocol(c, pinned, deps)
+ }))
+}
diff --git a/controller/plugin_protocol_test.go b/controller/plugin_protocol_test.go
new file mode 100644
index 000000000000..a6a5fdc1bdbe
--- /dev/null
+++ b/controller/plugin_protocol_test.go
@@ -0,0 +1,1555 @@
+package controller
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/dto"
+ "github.com/QuantumNous/new-api/model"
+ pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/QuantumNous/new-api/relay"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/gin-gonic/gin"
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+func TestServeTaskPluginProtocolWaitsForDurableSubmissionBeforeWriting(t *testing.T) {
+ pinned := compilePluginProtocolTestEndpoint(t, "durable-barrier", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() { return {events: [], done: false}; },
+ renderFinal: function() { return {}; }
+ }};
+ `)
+ c, recorder := newPluginProtocolTestContext(true, false)
+ submitStarted := make(chan struct{})
+ releaseSubmit := make(chan struct{})
+ done := make(chan struct{})
+
+ deps := pluginProtocolTestDeps()
+ deps.submit = func(_ *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
+ close(submitStarted)
+ <-releaseSubmit
+ return pluginProtocolTestOutcome(info, pinned.Plugin.Meta.Key, "task_durable", map[string]any{
+ "must_not": "be_written",
+ }), nil
+ }
+ deps.loadTask = func(context.Context, int, constant.TaskPlatform, string) (*model.Task, bool, error) {
+ return nil, false, errors.New("observation failed after durable barrier")
+ }
+
+ go func() {
+ defer close(done)
+ serveTaskPluginProtocol(c, pinned, deps)
+ }()
+ select {
+ case <-submitStarted:
+ case <-time.After(2 * time.Second):
+ require.FailNow(t, "submission did not start")
+ }
+
+ assert.Empty(t, recorder.Header().Get("Content-Type"))
+ assert.Empty(t, recorder.Body.String())
+ assert.False(t, recorder.Flushed)
+
+ close(releaseSubmit)
+ select {
+ case <-done:
+ case <-time.After(2 * time.Second):
+ require.FailNow(t, "protocol handler did not finish")
+ }
+ assert.Equal(t, "text/event-stream", recorder.Header().Get("Content-Type"))
+ assert.Contains(t, recorder.Body.String(), "event: response.created\n")
+ assert.NotContains(t, recorder.Body.String(), "must_not")
+}
+
+func TestServeTaskPluginProtocolDisconnectDuringSubmissionFinishesDurableWithoutWriting(t *testing.T) {
+ pinned := compilePluginProtocolTestEndpoint(t, "disconnect-during-submit", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() { return {events: [], done: false}; },
+ renderFinal: function() { return {}; }
+ }};
+ `)
+ c, recorder := newPluginProtocolTestContext(true, true)
+ requestContext, cancel := context.WithCancel(c.Request.Context())
+ c.Request = c.Request.WithContext(requestContext)
+ submitStarted := make(chan struct{})
+ checkSubmissionContext := make(chan struct{})
+ submissionContextActive := make(chan struct{})
+ releaseSubmit := make(chan struct{})
+ observationStarted := make(chan struct{}, 1)
+ done := make(chan struct{})
+ deps := pluginProtocolTestDeps()
+ deps.submit = func(c *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
+ close(submitStarted)
+ <-checkSubmissionContext
+ select {
+ case <-c.Request.Context().Done():
+ return nil, service.TaskErrorWrapperLocal(c.Request.Context().Err(), "request_cancelled", http.StatusRequestTimeout)
+ default:
+ close(submissionContextActive)
+ }
+ <-releaseSubmit
+ return pluginProtocolTestOutcome(info, pinned.Plugin.Meta.Key, "task_disconnect_durable", nil), nil
+ }
+ deps.loadTask = func(context.Context, int, constant.TaskPlatform, string) (*model.Task, bool, error) {
+ observationStarted <- struct{}{}
+ return nil, false, errors.New("observation must not start after disconnect")
+ }
+
+ go func() {
+ defer close(done)
+ serveTaskPluginProtocol(c, pinned, deps)
+ }()
+ select {
+ case <-submitStarted:
+ case <-time.After(2 * time.Second):
+ require.FailNow(t, "submission did not start")
+ }
+ cancel()
+ close(checkSubmissionContext)
+ select {
+ case <-submissionContextActive:
+ case <-time.After(2 * time.Second):
+ require.FailNow(t, "submission context was canceled with the client")
+ }
+ select {
+ case <-done:
+ require.FailNow(t, "protocol handler stopped before submission became durable")
+ default:
+ }
+ close(releaseSubmit)
+ select {
+ case <-done:
+ case <-time.After(2 * time.Second):
+ require.FailNow(t, "protocol handler did not finish after durable submission")
+ }
+
+ assert.Empty(t, recorder.Header().Get("Content-Type"))
+ assert.Empty(t, recorder.Body.String())
+ assert.False(t, recorder.Flushed)
+ select {
+ case <-observationStarted:
+ require.FailNow(t, "protocol observation started after client disconnect")
+ default:
+ }
+}
+
+func TestServeTaskPluginProtocolDisconnectBeforeDurableBarrierPersistsAndSettlesWithoutRefund(t *testing.T) {
+ events := make([]string, 0, 3)
+ database := setupTaskSubmissionDatabase(t, true, &events)
+ previousLogConsumeEnabled := common.LogConsumeEnabled
+ common.LogConsumeEnabled = false
+ t.Cleanup(func() { common.LogConsumeEnabled = previousLogConsumeEnabled })
+
+ pinned := compilePluginProtocolTestEndpoint(t, "disconnect-before-durable", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() { return {events: [], done: false}; },
+ renderFinal: function() { return {}; }
+ }};
+ `)
+ c, recorder := newPluginProtocolTestContext(true, true)
+ requestContext, cancel := context.WithCancel(c.Request.Context())
+ c.Request = c.Request.WithContext(requestContext)
+ billing := &taskSubmissionTestBilling{events: &events}
+ submitStarted := make(chan struct{})
+ releaseSubmit := make(chan struct{})
+ observationStarted := make(chan struct{}, 1)
+ done := make(chan struct{})
+
+ deps := pluginProtocolTestDeps()
+ deps.submit = func(c *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
+ info.Billing = billing
+ info.TaskRelayInfo.PublicTaskID = "task_disconnect_persisted"
+ info.TaskRelayInfo.LockedChannel = &model.Channel{
+ Id: 1,
+ Type: constant.ChannelTypeTaskPlugin,
+ Name: "disconnect-before-durable",
+ }
+ info.ChannelMeta = &relaycommon.ChannelMeta{
+ ChannelId: 1,
+ ChannelType: constant.ChannelTypeTaskPlugin,
+ }
+ return executeTaskSubmissionWith(c, info, func(*gin.Context, *relaycommon.RelayInfo) (*relay.TaskSubmitResult, *dto.TaskError) {
+ close(submitStarted)
+ <-releaseSubmit
+ return &relay.TaskSubmitResult{
+ UpstreamTaskID: "upstream_disconnect_persisted",
+ Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
+ Quota: 7,
+ }, nil
+ })
+ }
+ deps.loadTask = func(context.Context, int, constant.TaskPlatform, string) (*model.Task, bool, error) {
+ observationStarted <- struct{}{}
+ return nil, false, errors.New("observation must not start after disconnect")
+ }
+
+ go func() {
+ defer close(done)
+ serveTaskPluginProtocol(c, pinned, deps)
+ }()
+ select {
+ case <-submitStarted:
+ case <-time.After(2 * time.Second):
+ require.FailNow(t, "submission did not start")
+ }
+ cancel()
+ close(releaseSubmit)
+ select {
+ case <-done:
+ case <-time.After(2 * time.Second):
+ require.FailNow(t, "detached submission did not finish")
+ }
+
+ assert.Equal(t, []string{"reserve", "insert", "settle"}, events)
+ assert.Zero(t, billing.refunds)
+ var persisted model.Task
+ require.NoError(t, database.Where("task_id = ?", "task_disconnect_persisted").First(&persisted).Error)
+ assert.Equal(t, model.TaskStatus(model.TaskStatusNotStart), persisted.Status)
+ assert.Equal(t, 7, persisted.Quota)
+ assert.Equal(t, "upstream_disconnect_persisted", persisted.PrivateData.UpstreamTaskID)
+ assert.Empty(t, recorder.Header().Get("Content-Type"))
+ assert.Empty(t, recorder.Body.String())
+ assert.False(t, recorder.Flushed)
+ select {
+ case <-observationStarted:
+ require.FailNow(t, "protocol observation started after client disconnect")
+ default:
+ }
+}
+
+func TestServeTaskPluginProtocolDisconnectDuringTerminalSettlementStopsOnlyObservation(t *testing.T) {
+ pinned := compilePluginProtocolTestEndpoint(t, "disconnect-terminal-settlement", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() { return {events: [], done: false}; },
+ renderFinal: function() { return {}; }
+ }};
+ `)
+
+ previousDB := model.DB
+ previousMemoryCache := common.MemoryCacheEnabled
+ database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Task{}))
+ model.DB = database
+ common.MemoryCacheEnabled = false
+ t.Cleanup(func() {
+ model.DB = previousDB
+ common.MemoryCacheEnabled = previousMemoryCache
+ })
+ baseURL := "https://example.com"
+ channel := model.Channel{
+ Type: constant.ChannelTypeTaskPlugin,
+ Name: "terminal-settlement",
+ Key: "test-key",
+ BaseURL: &baseURL,
+ Status: common.ChannelStatusEnabled,
+ }
+ require.NoError(t, database.Create(&channel).Error)
+ task := model.Task{
+ TaskID: "task_terminal_disconnect",
+ Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
+ UserId: 71,
+ ChannelId: channel.Id,
+ Quota: 10,
+ Status: model.TaskStatusSubmitted,
+ PrivateData: model.TaskPrivateData{
+ UpstreamTaskID: "upstream-terminal",
+ },
+ }
+ require.NoError(t, database.Create(&task).Error)
+
+ c, recorder := newPluginProtocolTestContext(true, true)
+ requestContext, cancel := context.WithCancel(c.Request.Context())
+ c.Request = c.Request.WithContext(requestContext)
+ billingEvents := make([]string, 0)
+ billing := &taskSubmissionTestBilling{events: &billingEvents}
+ observationStarted := make(chan struct{})
+ settlementStarted := make(chan struct{})
+ releaseSettlement := make(chan struct{})
+ t.Cleanup(func() {
+ select {
+ case <-releaseSettlement:
+ default:
+ close(releaseSettlement)
+ }
+ })
+ pollingDone := make(chan struct{})
+ adaptor := &terminalSettlementPollingAdaptor{
+ started: settlementStarted,
+ release: releaseSettlement,
+ }
+ previousAdaptorFactory := service.GetTaskAdaptorFunc
+ service.GetTaskAdaptorFunc = func(constant.TaskPlatform) service.TaskPollingAdaptor {
+ return adaptor
+ }
+ t.Cleanup(func() { service.GetTaskAdaptorFunc = previousAdaptorFactory })
+
+ deps := pluginProtocolTestDeps()
+ deps.submit = func(_ *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
+ info.Billing = billing
+ return &taskSubmissionOutcome{
+ Result: &relay.TaskSubmitResult{},
+ Task: &task,
+ RelayInfo: info,
+ }, nil
+ }
+ deps.loadTask = func(ctx context.Context, _ int, _ constant.TaskPlatform, _ string) (*model.Task, bool, error) {
+ close(observationStarted)
+ <-ctx.Done()
+ return nil, false, ctx.Err()
+ }
+ done := make(chan struct{})
+
+ go func() {
+ <-observationStarted
+ defer close(pollingDone)
+ service.DispatchPlatformUpdate(
+ context.Background(),
+ task.Platform,
+ map[int][]string{channel.Id: {"upstream-terminal"}},
+ map[string]*model.Task{"upstream-terminal": &task},
+ )
+ }()
+ go func() {
+ defer close(done)
+ serveTaskPluginProtocol(c, pinned, deps)
+ }()
+ select {
+ case <-settlementStarted:
+ case <-time.After(2 * time.Second):
+ require.FailNow(t, "terminal settlement did not start")
+ }
+ cancel()
+ select {
+ case <-done:
+ case <-time.After(2 * time.Second):
+ require.FailNow(t, "protocol observation did not stop after terminal disconnect")
+ }
+ assert.Equal(t, []string{"response.created"}, pluginProtocolTestSSEEventTypes(recorder.Body.String()))
+ assert.Zero(t, billing.refunds)
+
+ close(releaseSettlement)
+ select {
+ case <-pollingDone:
+ case <-time.After(2 * time.Second):
+ require.FailNow(t, "terminal settlement was canceled with the client observation")
+ }
+
+ var persisted model.Task
+ require.NoError(t, database.Where("task_id = ?", task.TaskID).First(&persisted).Error)
+ assert.Equal(t, model.TaskStatus(model.TaskStatusSuccess), persisted.Status)
+ assert.Equal(t, "100%", persisted.Progress)
+ assert.Equal(t, 10, persisted.Quota)
+ assert.True(t, adaptor.completed)
+ assert.Empty(t, billingEvents)
+}
+
+type terminalSettlementPollingAdaptor struct {
+ started chan struct{}
+ release chan struct{}
+ completed bool
+}
+
+func (a *terminalSettlementPollingAdaptor) Init(*relaycommon.RelayInfo) {}
+
+func (a *terminalSettlementPollingAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{}`)),
+ }, nil
+}
+
+func (a *terminalSettlementPollingAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) {
+ return &relaycommon.TaskInfo{
+ Status: model.TaskStatusSuccess,
+ Progress: "100%",
+ }, nil
+}
+
+func (a *terminalSettlementPollingAdaptor) AdjustBillingOnComplete(task *model.Task, _ *relaycommon.TaskInfo) int {
+ close(a.started)
+ <-a.release
+ a.completed = true
+ return task.Quota
+}
+
+func TestPluginProtocolBridgeBoundsDatabaseReadBelowHeartbeat(t *testing.T) {
+ deps := pluginProtocolBridgeDeps{
+ observationTimeout: time.Minute,
+ loadTimeout: 10 * time.Second,
+ tickInterval: time.Second,
+ heartbeatInterval: 4 * time.Second,
+ admissionTimeout: time.Second,
+ }.withDefaults()
+
+ assert.Equal(t, 2*time.Second, deps.loadTimeout)
+}
+
+func TestServeTaskPluginProtocolPostDurableObservationFailureUsesCanonicalResponse(t *testing.T) {
+ pinned := compilePluginProtocolTestEndpoint(t, "observation-failure", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() { return {events: [], done: false}; },
+ renderFinal: function() { return {}; }
+ }};
+ `)
+ c, recorder := newPluginProtocolTestContext(false, false)
+ deps := pluginProtocolTestDeps()
+ deps.submit = func(_ *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
+ return pluginProtocolTestOutcome(info, pinned.Plugin.Meta.Key, "task_observation_failure", nil), nil
+ }
+ deps.loadTask = func(context.Context, int, constant.TaskPlatform, string) (*model.Task, bool, error) {
+ return nil, false, errors.New("database-secret https://database.invalid/?token=hidden")
+ }
+
+ serveTaskPluginProtocol(c, pinned, deps)
+
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ var response dto.PluginResponsesResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.Equal(t, "failed", response.Status)
+ require.NotNil(t, response.Error)
+ assert.Equal(t, "server_error", response.Error.Code)
+ assert.Equal(t, "The task could not be observed.", response.Error.Message)
+ assert.Equal(t, "queued", response.Metadata["task_status"])
+ assert.Equal(t, "/v1/responses/resp_observation_failure", response.Metadata["retrieval_path"])
+ assert.NotContains(t, recorder.Body.String(), "secret")
+ assert.NotContains(t, recorder.Body.String(), "database.invalid")
+}
+
+func TestServeTaskPluginProtocolStreamsPinnedGenerationWithHostFraming(t *testing.T) {
+ oldPinned := compilePluginProtocolTestEndpoint(t, "generation-pinned", `
+ export const protocols = {openai_responses: {
+ renderEvents: function(ctx, task, previousState) {
+ if (ctx.stream !== true || ctx.body.value.stream !== true) {
+ throw new Error("host did not preserve parsed stream mode");
+ }
+ if (arguments.length === 2) {
+ return {events: [], state: null, done: false};
+ }
+ if (arguments.length !== 3 || previousState !== null) {
+ throw new Error("explicit null state was not supplied on the next tick");
+ }
+ return {events: [{type: "output", data: "old-generation"}], done: true};
+ },
+ renderFinal: function() { throw new Error("stream called renderFinal"); }
+ }};
+ `)
+ newPinned := compilePluginProtocolTestEndpoint(t, "generation-pinned", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() {
+ return {events: [{type: "output", data: "new-generation"}], done: true};
+ },
+ renderFinal: function() { return "new-generation"; }
+ }};
+ `)
+ require.NotSame(t, oldPinned.Plugin.Engine, newPinned.Plugin.Engine)
+
+ c, recorder := newPluginProtocolTestContext(true, true)
+ c.Set(pluginruntime.ContextKeyRouteRequest, pluginruntime.RouteRequestContext{
+ Path: "/v1/responses",
+ Method: http.MethodPost,
+ RequestBody: map[string]any{"model": "video-model", "stream": false},
+ })
+ loadCount := 0
+ deps := pluginProtocolTestDeps()
+ deps.submit = func(_ *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
+ return pluginProtocolTestOutcome(info, oldPinned.Plugin.Meta.Key, "task_generation", map[string]any{
+ "client_response": "ignored",
+ }), nil
+ }
+ deps.loadTask = func(_ context.Context, userID int, platform constant.TaskPlatform, taskID string) (*model.Task, bool, error) {
+ loadCount++
+ assert.Equal(t, 71, userID)
+ assert.Equal(t, constant.TaskPlatform(oldPinned.Plugin.Meta.Key), platform)
+ assert.Equal(t, "task_generation", taskID)
+ status := model.TaskStatus(model.TaskStatusInProgress)
+ if loadCount == 2 {
+ status = model.TaskStatus(model.TaskStatusSuccess)
+ }
+ return &model.Task{
+ TaskID: taskID,
+ UserId: userID,
+ Platform: platform,
+ Status: status,
+ }, true, nil
+ }
+
+ serveTaskPluginProtocol(c, oldPinned, deps)
+
+ assert.Equal(t, 2, loadCount)
+ assert.True(t, recorder.Flushed)
+ assert.Equal(t, "text/event-stream", recorder.Header().Get("Content-Type"))
+ assert.Equal(t, []string{
+ "response.created",
+ "response.output_item.added",
+ "response.content_part.added",
+ "response.output_text.delta",
+ "response.output_text.done",
+ "response.content_part.done",
+ "response.output_item.done",
+ "response.completed",
+ }, pluginProtocolTestSSEEventTypes(recorder.Body.String()))
+ assert.True(t, strings.HasPrefix(recorder.Body.String(), "event: response.created\ndata: {"))
+ assert.Contains(t, recorder.Body.String(), `"sequence_number":0`)
+ assert.Contains(t, recorder.Body.String(), `"sequence_number":7`)
+ assert.Contains(t, recorder.Body.String(), "old-generation")
+ assert.NotContains(t, recorder.Body.String(), "new-generation")
+}
+
+func TestServeTaskPluginProtocolStreamMissingRenderEventsUsesFailureEnvelope(t *testing.T) {
+ tests := []struct {
+ name string
+ status model.TaskStatus
+ }{
+ {name: "success", status: model.TaskStatusSuccess},
+ {name: "failure", status: model.TaskStatusFailure},
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ pinned := compilePluginProtocolTestEndpoint(t, "default-events-"+testCase.name, `
+ export const protocols = {openai_responses: {
+ renderFinal: function() { throw new Error("stream must not call renderFinal"); }
+ }};
+ `)
+ c, recorder := newPluginProtocolTestContext(true, true)
+ deps := pluginProtocolTestDeps()
+ deps.submit = func(_ *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
+ return pluginProtocolTestOutcome(info, pinned.Plugin.Meta.Key, "task_default_events"), nil
+ }
+ deps.loadTask = func(context.Context, int, constant.TaskPlatform, string) (*model.Task, bool, error) {
+ return &model.Task{TaskID: "task_default_events", Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key), UserId: 71, Status: testCase.status}, true, nil
+ }
+
+ serveTaskPluginProtocol(c, pinned, deps)
+
+ assert.Equal(t, []string{"response.created", "response.failed"}, pluginProtocolTestSSEEventTypes(recorder.Body.String()))
+ assert.NotContains(t, recorder.Body.String(), "stream must not call")
+ })
+ }
+}
+
+func TestServeTaskPluginProtocolStreamInjectsHostArtifactCapabilities(t *testing.T) {
+ pinned := compilePluginProtocolTestEndpoint(t, "stream-artifacts", `
+ export function listArtifacts(task) {
+ if (task.data.output.video_url !== "https://upstream.invalid/video.mp4?secret=hidden") {
+ throw new Error("listArtifacts did not receive raw Task.Data");
+ }
+ return [{key: "video", type: "video", mimeType: "video/mp4"}];
+ }
+ export function buildContentRequest() {
+ throw new Error("rendering must not resolve provider content");
+ }
+ export const protocols = {openai_responses: {
+ renderEvents: function(ctx, task) {
+ const artifact = ctx.artifacts && ctx.artifacts.video;
+ if (!artifact || artifact.key !== "video" || artifact.type !== "video" ||
+ artifact.mimeType !== "video/mp4") {
+ throw new Error("host artifact context is invalid");
+ }
+ return {events: [{type: "output", data: artifact.url}], done: true};
+ },
+ renderFinal: function() { throw new Error("stream called renderFinal"); }
+ }};
+ `)
+ c, recorder := newPluginProtocolTestContext(true, true)
+ deps := pluginProtocolTestDeps()
+ deps.submit = func(_ *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
+ return pluginProtocolTestOutcome(info, pinned.Plugin.Meta.Key, "task_stream_artifact", nil), nil
+ }
+ task := &model.Task{
+ TaskID: "task_stream_artifact",
+ Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
+ UserId: 71,
+ Status: model.TaskStatusSuccess,
+ }
+ task.SetData(map[string]any{
+ "output": map[string]any{
+ "video_url": "https://upstream.invalid/video.mp4?secret=hidden",
+ },
+ })
+ deps.loadTask = func(context.Context, int, constant.TaskPlatform, string) (*model.Task, bool, error) {
+ return task, true, nil
+ }
+ deps.artifactContentURL = func(taskID, artifactKey string) (string, error) {
+ assert.Equal(t, "task_stream_artifact", taskID)
+ assert.Equal(t, "video", artifactKey)
+ return "https://gateway.example/v1/tasks/task_stream_artifact/artifacts/video/content?access=host-capability", nil
+ }
+
+ serveTaskPluginProtocol(c, pinned, deps)
+
+ assert.Equal(t, model.TaskStatus(model.TaskStatusSuccess), task.Status)
+ assert.Equal(t, []string{
+ "response.created",
+ "response.output_item.added",
+ "response.content_part.added",
+ "response.output_text.delta",
+ "response.output_text.done",
+ "response.content_part.done",
+ "response.output_item.done",
+ "response.completed",
+ }, pluginProtocolTestSSEEventTypes(recorder.Body.String()))
+ assert.Contains(t, recorder.Body.String(), "host-capability")
+ assert.NotContains(t, recorder.Body.String(), "upstream.invalid")
+ assert.NotContains(t, recorder.Body.String(), "secret")
+}
+
+func TestTaskPluginProtocolHeartbeatDoesNotDispatchEmptySDKEvent(t *testing.T) {
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
+
+ require.NoError(t, writeTaskPluginProtocolHeartbeat(c))
+
+ assert.Equal(t, ": PING\n", recorder.Body.String())
+ assert.True(t, recorder.Flushed)
+}
+
+func TestServeTaskPluginProtocolNonStreamUsesFinalHookAndHostEnvelope(t *testing.T) {
+ pinned := compilePluginProtocolTestEndpoint(t, "final-response", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() { throw new Error("non-stream called renderEvents"); },
+ renderFinal: function(ctx, task) {
+ if (ctx.stream !== false || ctx.body.value.stream !== false) {
+ throw new Error("host did not preserve parsed non-stream mode");
+ }
+ return {
+ id: "plugin-controlled-id",
+ status: "plugin-controlled-status",
+ metadata: {plugin_field: "kept", task_id: "plugin-controlled-task"},
+ output: [{
+ id: "plugin-controlled-item",
+ type: "message",
+ status: "plugin-controlled-item-status",
+ role: "assistant",
+ content: [{
+ id: "plugin-controlled-content",
+ type: "output_text",
+ text: task.data.value,
+ annotations: [],
+ logprobs: []
+ }]
+ }],
+ custom_field: "kept"
+ };
+ }
+ }};
+ `)
+ c, recorder := newPluginProtocolTestContext(false, false)
+ c.Set(pluginruntime.ContextKeyRouteRequest, pluginruntime.RouteRequestContext{
+ Path: "/v1/responses",
+ Method: http.MethodPost,
+ RequestBody: map[string]any{"model": "video-model", "stream": true},
+ })
+ deps := pluginProtocolTestDeps()
+ deps.submit = func(_ *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
+ return pluginProtocolTestOutcome(info, pinned.Plugin.Meta.Key, "task_final", map[string]any{
+ "client_response_secret": "must-be-ignored",
+ }), nil
+ }
+ deps.loadTask = func(context.Context, int, constant.TaskPlatform, string) (*model.Task, bool, error) {
+ task := &model.Task{
+ TaskID: "task_final",
+ Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
+ UserId: 71,
+ Status: model.TaskStatusSuccess,
+ }
+ task.SetData(map[string]any{"value": "plugin-semantic-result"})
+ return task, true, nil
+ }
+
+ serveTaskPluginProtocol(c, pinned, deps)
+
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ assert.NotEqual(t, "text/event-stream", recorder.Header().Get("Content-Type"))
+ var response dto.PluginResponsesResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.Equal(t, "resp_final", response.ID)
+ assert.Equal(t, "response", response.Object)
+ assert.Equal(t, "completed", response.Status)
+ assert.Equal(t, "video-model", response.Model)
+ assert.Equal(t, "task_final", response.Metadata["task_id"])
+ assert.Equal(t, "kept", response.Metadata["plugin_field"])
+ require.Len(t, response.Output, 1)
+ assert.Equal(t, "item_task_final_0", response.Output[0].ID)
+ assert.Equal(t, "completed", response.Output[0].Status)
+ require.Len(t, response.Output[0].Content, 1)
+ assert.Equal(t, "content_task_final_0_0", response.Output[0].Content[0].ID)
+ assert.Equal(t, "plugin-semantic-result", response.Output[0].Content[0].Text)
+ assert.Contains(t, recorder.Body.String(), `"custom_field":"kept"`)
+ assert.NotContains(t, recorder.Body.String(), "plugin-controlled-id")
+ assert.NotContains(t, recorder.Body.String(), "client_response_secret")
+}
+
+func TestServeTaskPluginProtocolNonStreamInjectsHostArtifactCapabilities(t *testing.T) {
+ pinned := compilePluginProtocolTestEndpoint(t, "final-artifacts", `
+ export function listArtifacts() {
+ return [{key: "video", type: "video"}];
+ }
+ export function buildContentRequest() {
+ throw new Error("rendering must not resolve provider content");
+ }
+ export const protocols = {openai_responses: {
+ renderEvents: function() { throw new Error("non-stream called renderEvents"); },
+ renderFinal: function(ctx) {
+ const artifact = ctx.artifacts && ctx.artifacts.video;
+ if (!artifact || artifact.key !== "video" || artifact.type !== "video" ||
+ Object.prototype.hasOwnProperty.call(artifact, "mimeType")) {
+ throw new Error("host artifact context is invalid");
+ }
+ return {
+ output: [{
+ type: "message",
+ status: "completed",
+ role: "assistant",
+ content: [{
+ type: "output_text",
+ text: artifact.url,
+ annotations: [],
+ logprobs: []
+ }]
+ }]
+ };
+ }
+ }};
+ `)
+ c, recorder := newPluginProtocolTestContext(false, false)
+ deps := pluginProtocolTestDeps()
+ deps.submit = func(_ *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
+ return pluginProtocolTestOutcome(info, pinned.Plugin.Meta.Key, "task_final_artifact", nil), nil
+ }
+ task := &model.Task{
+ TaskID: "task_final_artifact",
+ Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
+ UserId: 71,
+ Status: model.TaskStatusSuccess,
+ }
+ deps.loadTask = func(context.Context, int, constant.TaskPlatform, string) (*model.Task, bool, error) {
+ return task, true, nil
+ }
+ deps.artifactContentURL = func(taskID, artifactKey string) (string, error) {
+ assert.Equal(t, "task_final_artifact", taskID)
+ assert.Equal(t, "video", artifactKey)
+ return "https://gateway.example/v1/tasks/task_final_artifact/artifacts/video/content?access=host-capability", nil
+ }
+
+ serveTaskPluginProtocol(c, pinned, deps)
+
+ assert.Equal(t, model.TaskStatus(model.TaskStatusSuccess), task.Status)
+ var response dto.PluginResponsesResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.Equal(t, "completed", response.Status)
+ require.Len(t, response.Output, 1)
+ require.Len(t, response.Output[0].Content, 1)
+ assert.Contains(t, response.Output[0].Content[0].Text, "host-capability")
+}
+
+func TestServeTaskPluginProtocolArtifactURLFailureOnlyFailsCurrentRendering(t *testing.T) {
+ for _, stream := range []bool{false, true} {
+ t.Run(strconv.FormatBool(stream), func(t *testing.T) {
+ pinned := compilePluginProtocolTestEndpoint(t, "artifact-url-failure-"+strconv.FormatBool(stream), `
+ export function listArtifacts() {
+ return [{key: "video", type: "video"}];
+ }
+ export function buildContentRequest() {
+ throw new Error("unused");
+ }
+ export const protocols = {openai_responses: {
+ renderEvents: function() {
+ return {events: [{type: "output", data: "must-not-render"}], done: true};
+ },
+ renderFinal: function() {
+ return {output: []};
+ }
+ }};
+ `)
+ c, recorder := newPluginProtocolTestContext(stream, stream)
+ deps := pluginProtocolTestDeps()
+ deps.submit = func(_ *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
+ return pluginProtocolTestOutcome(info, pinned.Plugin.Meta.Key, "task_capability_failure", nil), nil
+ }
+ task := &model.Task{
+ TaskID: "task_capability_failure",
+ Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
+ UserId: 71,
+ Status: model.TaskStatusSuccess,
+ }
+ deps.loadTask = func(context.Context, int, constant.TaskPlatform, string) (*model.Task, bool, error) {
+ return task, true, nil
+ }
+ deps.artifactContentURL = func(string, string) (string, error) {
+ return "", errors.New("public address is unavailable")
+ }
+
+ serveTaskPluginProtocol(c, pinned, deps)
+
+ assert.Equal(t, model.TaskStatus(model.TaskStatusSuccess), task.Status)
+ assert.NotContains(t, recorder.Body.String(), "must-not-render")
+ assert.NotContains(t, recorder.Body.String(), "public address")
+ if stream {
+ assert.Equal(t, []string{"response.created", "response.failed"}, pluginProtocolTestSSEEventTypes(recorder.Body.String()))
+ } else {
+ var response dto.PluginResponsesResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.Equal(t, "failed", response.Status)
+ require.NotNil(t, response.Error)
+ assert.Equal(t, "server_error", response.Error.Code)
+ assert.Equal(t, "completed", response.Metadata["task_status"])
+ }
+ })
+ }
+}
+
+func TestServeTaskPluginProtocolNonStreamTaskFailureSkipsFinalHook(t *testing.T) {
+ logs := make([]string, 0, 1)
+ pinned := compilePluginProtocolTestEndpointWithOptions(t, "failed-final", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() { return {events: [], done: false}; },
+ renderFinal: function() {
+ console.log("renderFinal called");
+ return {
+ output: [{id: "secret-id", content: [{text: "plugin-secret"}]}],
+ secret: "https://secret.invalid/"
+ };
+ }
+ }};
+ `, pluginruntime.Options{
+ Log: func(message string) { logs = append(logs, message) },
+ })
+ c, recorder := newPluginProtocolTestContext(false, false)
+ deps := pluginProtocolTestDeps()
+ deps.artifactContentURL = func(string, string) (string, error) {
+ require.FailNow(t, "failed tasks must not project artifact URLs")
+ return "", nil
+ }
+ deps.submit = func(_ *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
+ return pluginProtocolTestOutcome(info, pinned.Plugin.Meta.Key, "task_failed", map[string]any{
+ "credential": "client-response-secret",
+ }), nil
+ }
+ deps.loadTask = func(context.Context, int, constant.TaskPlatform, string) (*model.Task, bool, error) {
+ task := &model.Task{
+ TaskID: "task_failed",
+ Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
+ UserId: 71,
+ Status: model.TaskStatusFailure,
+ FailReason: "upstream credential at https://secret.invalid/",
+ }
+ task.SetData(map[string]any{"secret": "database-secret"})
+ return task, true, nil
+ }
+
+ serveTaskPluginProtocol(c, pinned, deps)
+
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ var response dto.PluginResponsesResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.Equal(t, "failed", response.Status)
+ require.NotNil(t, response.Error)
+ assert.Equal(t, "server_error", response.Error.Code)
+ assert.Equal(t, "The task failed.", response.Error.Message)
+ assert.Empty(t, response.Output)
+ assert.NotContains(t, recorder.Body.String(), "secret")
+ assert.NotContains(t, recorder.Body.String(), "credential")
+ assert.Empty(t, logs)
+}
+
+func TestServeTaskPluginProtocolStreamTaskFailureSuppressesPluginAndDatabaseDetails(t *testing.T) {
+ pinned := compilePluginProtocolTestEndpoint(t, "failed-stream", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() {
+ return {
+ events: [{type: "output", data: "plugin-secret https://plugin.invalid/?key=hidden"}],
+ done: true
+ };
+ },
+ renderFinal: function() { return "unused-secret"; }
+ }};
+ `)
+ c, recorder := newPluginProtocolTestContext(true, false)
+ deps := pluginProtocolTestDeps()
+ deps.artifactContentURL = func(string, string) (string, error) {
+ require.FailNow(t, "failed tasks must not project artifact URLs")
+ return "", nil
+ }
+ deps.submit = func(_ *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
+ return pluginProtocolTestOutcome(info, pinned.Plugin.Meta.Key, "task_stream_failed", nil), nil
+ }
+ deps.loadTask = func(context.Context, int, constant.TaskPlatform, string) (*model.Task, bool, error) {
+ task := &model.Task{
+ TaskID: "task_stream_failed",
+ Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
+ UserId: 71,
+ Status: model.TaskStatusFailure,
+ FailReason: "database-secret https://database.invalid/?token=hidden",
+ }
+ task.SetData(map[string]any{"secret": "private-result"})
+ return task, true, nil
+ }
+
+ serveTaskPluginProtocol(c, pinned, deps)
+
+ assert.Equal(t, []string{"response.created", "response.failed"}, pluginProtocolTestSSEEventTypes(recorder.Body.String()))
+ assert.Contains(t, recorder.Body.String(), `"code":"server_error"`)
+ assert.Contains(t, recorder.Body.String(), `"message":"The task failed."`)
+ assert.Contains(t, recorder.Body.String(), `"task_status":"failed"`)
+ assert.NotContains(t, recorder.Body.String(), "secret")
+ assert.NotContains(t, recorder.Body.String(), "invalid")
+ assert.NotContains(t, recorder.Body.String(), "hidden")
+}
+
+func TestServeTaskPluginProtocolRejectsUnsupportedProtocolBeforeSubmission(t *testing.T) {
+ pinned := compilePluginProtocolTestEndpoint(t, "unsupported-protocol", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() { return {events: [], done: false}; },
+ renderFinal: function() { return {}; }
+ }};
+ `)
+ pinned.Protocol = "unsupported"
+ c, recorder := newPluginProtocolTestContext(false, false)
+ c.Set(pluginruntime.ContextKeyProtocolRequest, pluginruntime.ProtocolRequestContext{
+ RouteRequestContext: pluginruntime.RouteRequestContext{
+ Path: "/v1/videos",
+ Method: http.MethodPost,
+ RequestBody: map[string]any{"model": "video-model"},
+ },
+ Protocol: pinned.Protocol,
+ })
+ submitted := false
+ deps := pluginProtocolTestDeps()
+ deps.submit = func(*gin.Context, *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
+ submitted = true
+ return nil, nil
+ }
+
+ serveTaskPluginProtocol(c, pinned, deps)
+
+ assert.False(t, submitted)
+ assert.Equal(t, http.StatusNotImplemented, recorder.Code)
+ assert.Contains(t, recorder.Body.String(), `"code":"task_protocol_not_available"`)
+}
+
+func TestServeTaskPluginProtocolRejectsObservationAdmissionBeforeSubmission(t *testing.T) {
+ pinned := compilePluginProtocolTestEndpoint(t, "admission-limit", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() { return {events: [], done: false}; },
+ renderFinal: function() { return {}; }
+ }};
+ `)
+ for _, stream := range []bool{false, true} {
+ t.Run(map[bool]string{false: "non-stream", true: "stream"}[stream], func(t *testing.T) {
+ c, recorder := newPluginProtocolTestContext(stream, stream)
+ submitted := false
+ deps := pluginProtocolTestDeps()
+ deps.admissions = newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
+ global: 0,
+ perPlugin: 1,
+ perUser: 1,
+ perToken: 1,
+ })
+ deps.submit = func(*gin.Context, *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
+ submitted = true
+ return nil, nil
+ }
+
+ serveTaskPluginProtocol(c, pinned, deps)
+
+ assert.False(t, submitted)
+ assert.Equal(t, http.StatusTooManyRequests, recorder.Code)
+ assert.Contains(t, recorder.Body.String(), `"code":"rate_limit_exceeded"`)
+ assert.NotEqual(t, "text/event-stream", recorder.Header().Get("Content-Type"))
+ })
+ }
+}
+
+func TestServeTaskPluginProtocolBackgroundNonStreamReturnsPendingWithoutObservation(t *testing.T) {
+ pinned := compilePluginProtocolTestEndpoint(t, "background-create", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() { throw new Error("background create called renderEvents"); },
+ renderFinal: function() { throw new Error("background create called renderFinal"); }
+ }};
+ `)
+ c, recorder := newPluginProtocolTestContext(false, false)
+ setProtocolRequestBackground(c, true)
+ loadCalls := 0
+ deps := pluginProtocolTestDeps()
+ deps.submit = func(_ *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
+ return pluginProtocolTestOutcome(info, pinned.Plugin.Meta.Key, "task_background", nil), nil
+ }
+ deps.loadTask = func(context.Context, int, constant.TaskPlatform, string) (*model.Task, bool, error) {
+ loadCalls++
+ return nil, false, errors.New("observation must not start for background create")
+ }
+
+ serveTaskPluginProtocol(c, pinned, deps)
+
+ assert.Equal(t, 0, loadCalls)
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ var response map[string]any
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.Equal(t, "resp_background", response["id"])
+ assert.Equal(t, "response", response["object"])
+ assert.Equal(t, "queued", response["status"])
+ assert.Equal(t, true, response["background"])
+ assert.Nil(t, response["completed_at"])
+ assert.Nil(t, response["error"])
+ assert.Nil(t, response["usage"])
+ assert.Empty(t, response["output"])
+ metadata, ok := response["metadata"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "task_background", metadata["task_id"])
+ assert.Equal(t, "queued", metadata["task_status"])
+ assert.Equal(t, "/v1/responses/resp_background", metadata["retrieval_path"])
+ assert.NotEqual(t, "text/event-stream", recorder.Header().Get("Content-Type"))
+}
+
+func TestServeTaskPluginProtocolBackgroundStreamEntersObservation(t *testing.T) {
+ pinned := compilePluginProtocolTestEndpoint(t, "background-stream", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() { return {events: [{type: "output", data: "streamed"}], done: true}; },
+ renderFinal: function() { throw new Error("stream called renderFinal"); }
+ }};
+ `)
+ c, recorder := newPluginProtocolTestContext(true, true)
+ setProtocolRequestBackground(c, true)
+ loadCalls := 0
+ deps := pluginProtocolTestDeps()
+ deps.submit = func(_ *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
+ return pluginProtocolTestOutcome(info, pinned.Plugin.Meta.Key, "task_background_stream", nil), nil
+ }
+ deps.loadTask = func(context.Context, int, constant.TaskPlatform, string) (*model.Task, bool, error) {
+ loadCalls++
+ return &model.Task{
+ TaskID: "task_background_stream",
+ Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
+ UserId: 71,
+ Status: model.TaskStatusSuccess,
+ }, true, nil
+ }
+
+ serveTaskPluginProtocol(c, pinned, deps)
+
+ assert.Greater(t, loadCalls, 0)
+ assert.Equal(t, []string{
+ "response.created",
+ "response.output_item.added",
+ "response.content_part.added",
+ "response.output_text.delta",
+ "response.output_text.done",
+ "response.content_part.done",
+ "response.output_item.done",
+ "response.completed",
+ }, pluginProtocolTestSSEEventTypes(recorder.Body.String()))
+}
+
+func TestRetrieveTaskPluginResponsePendingSkipsRenderFinal(t *testing.T) {
+ logs := make([]string, 0, 1)
+ pinned := compilePluginProtocolRetrieveEndpoint(t, "retrieve-pending", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() { throw new Error("retrieve pending called renderEvents"); },
+ renderFinal: function() {
+ console.log("renderFinal called");
+ return {};
+ }
+ }};
+ `, logsAppender(&logs))
+ c, recorder := newPluginProtocolRetrieveContext("resp_retrieve_pending")
+ deps := pluginProtocolRetrieveDeps(pinned, &model.Task{
+ TaskID: "task_retrieve_pending",
+ Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
+ UserId: 71,
+ Status: model.TaskStatusInProgress,
+ PrivateData: model.TaskPrivateData{
+ ResponsesBackground: true,
+ },
+ Properties: model.Properties{OriginModelName: "video-model"},
+ CreatedAt: 1_710_000_000,
+ }, true, nil)
+
+ retrieveTaskPluginResponse(c, deps)
+
+ assert.Empty(t, logs)
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ var response map[string]any
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.Equal(t, "resp_retrieve_pending", response["id"])
+ assert.Equal(t, "in_progress", response["status"])
+ assert.Equal(t, true, response["background"])
+ assert.Nil(t, response["completed_at"])
+ assert.Empty(t, response["output"])
+ metadata, ok := response["metadata"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "/v1/responses/resp_retrieve_pending", metadata["retrieval_path"])
+}
+
+func TestRetrieveTaskPluginResponseSuccessRendersFinal(t *testing.T) {
+ logs := make([]string, 0, 1)
+ pinned := compilePluginProtocolRetrieveEndpoint(t, "retrieve-success", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() { throw new Error("retrieve success called renderEvents"); },
+ renderFinal: function() {
+ console.log("renderFinal called");
+ return {
+ output: [{
+ type: "message",
+ status: "completed",
+ role: "assistant",
+ content: [{type: "output_text", text: "retrieved-final", annotations: [], logprobs: []}]
+ }]
+ };
+ }
+ }};
+ `, logsAppender(&logs))
+ c, recorder := newPluginProtocolRetrieveContext("resp_retrieve_success")
+ deps := pluginProtocolRetrieveDeps(pinned, &model.Task{
+ TaskID: "task_retrieve_success",
+ Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
+ UserId: 71,
+ Status: model.TaskStatusSuccess,
+ Properties: model.Properties{OriginModelName: "video-model"},
+ CreatedAt: 1_710_000_000,
+ }, true, nil)
+
+ retrieveTaskPluginResponse(c, deps)
+
+ require.NotEmpty(t, logs)
+ assert.Contains(t, logs[0], "renderFinal called")
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ var response dto.PluginResponsesResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.Equal(t, "completed", response.Status)
+ assert.Equal(t, "resp_retrieve_success", response.ID)
+ require.Len(t, response.Output, 1)
+ require.Len(t, response.Output[0].Content, 1)
+ assert.Equal(t, "retrieved-final", response.Output[0].Content[0].Text)
+}
+
+func TestRetrieveTaskPluginResponseStreamOnlySuccessSynthesizesFromEvents(t *testing.T) {
+ logs := make([]string, 0, 1)
+ pinned := compilePluginProtocolRetrieveEndpoint(t, "retrieve-stream-only", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() {
+ console.log("renderEvents called");
+ return {events: [{type: "output", data: "synthesized-retrieve"}], done: true};
+ },
+ renderFinal: function() { throw new Error("stream-only retrieve called renderFinal"); }
+ }};
+ `, logsAppender(&logs))
+ pinned.Plugin.Meta.Protocols = []pluginruntime.ProtocolClaim{{Name: "openai_responses", Supports: []string{"stream"}}}
+ c, recorder := newPluginProtocolRetrieveContext("resp_retrieve_stream")
+ deps := pluginProtocolRetrieveDeps(pinned, &model.Task{
+ TaskID: "task_retrieve_stream",
+ Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
+ UserId: 71,
+ Status: model.TaskStatusSuccess,
+ Properties: model.Properties{OriginModelName: "video-model"},
+ CreatedAt: 1_710_000_000,
+ }, true, nil)
+
+ retrieveTaskPluginResponse(c, deps)
+
+ require.NotEmpty(t, logs)
+ assert.Contains(t, logs[0], "renderEvents called")
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ var response dto.PluginResponsesResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.Equal(t, "completed", response.Status)
+ require.NotEmpty(t, response.Output)
+}
+
+func TestRetrieveTaskPluginResponseStreamOnlyPendingAndFailureStayHostEnvelopes(t *testing.T) {
+ logs := make([]string, 0, 1)
+ pinned := compilePluginProtocolRetrieveEndpoint(t, "retrieve-stream-envelope", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() { throw new Error("envelope retrieve called renderEvents"); },
+ renderFinal: function() { throw new Error("envelope retrieve called renderFinal"); }
+ }};
+ `, logsAppender(&logs))
+ pinned.Plugin.Meta.Protocols = []pluginruntime.ProtocolClaim{{Name: "openai_responses", Supports: []string{"stream"}}}
+
+ t.Run("pending", func(t *testing.T) {
+ logs = logs[:0]
+ c, recorder := newPluginProtocolRetrieveContext("resp_retrieve_stream_pending")
+ deps := pluginProtocolRetrieveDeps(pinned, &model.Task{
+ TaskID: "task_retrieve_stream_pending",
+ Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
+ UserId: 71,
+ Status: model.TaskStatusInProgress,
+ Properties: model.Properties{OriginModelName: "video-model"},
+ CreatedAt: 1_710_000_000,
+ }, true, nil)
+ retrieveTaskPluginResponse(c, deps)
+ assert.Empty(t, logs)
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ var response map[string]any
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.Equal(t, "in_progress", response["status"])
+ assert.Empty(t, response["output"])
+ })
+
+ t.Run("failure", func(t *testing.T) {
+ logs = logs[:0]
+ c, recorder := newPluginProtocolRetrieveContext("resp_retrieve_stream_failure")
+ deps := pluginProtocolRetrieveDeps(pinned, &model.Task{
+ TaskID: "task_retrieve_stream_failure",
+ Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
+ UserId: 71,
+ Status: model.TaskStatusFailure,
+ Properties: model.Properties{OriginModelName: "video-model"},
+ }, true, nil)
+ retrieveTaskPluginResponse(c, deps)
+ assert.Empty(t, logs)
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ var response dto.PluginResponsesResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.Equal(t, "failed", response.Status)
+ require.NotNil(t, response.Error)
+ assert.Equal(t, "The task failed.", response.Error.Message)
+ })
+}
+
+func TestRetrieveTaskPluginResponseStreamOnlyRenderErrorUsesFailureEnvelope(t *testing.T) {
+ pinned := compilePluginProtocolRetrieveEndpoint(t, "retrieve-stream-throw", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() { throw new Error("retrieve boom"); },
+ renderFinal: function() { throw new Error("stream-only retrieve called renderFinal"); }
+ }};
+ `, pluginruntime.Options{})
+ pinned.Plugin.Meta.Protocols = []pluginruntime.ProtocolClaim{{Name: "openai_responses", Supports: []string{"stream"}}}
+ c, recorder := newPluginProtocolRetrieveContext("resp_retrieve_stream_throw")
+ deps := pluginProtocolRetrieveDeps(pinned, &model.Task{
+ TaskID: "task_retrieve_stream_throw",
+ Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
+ UserId: 71,
+ Status: model.TaskStatusSuccess,
+ Properties: model.Properties{OriginModelName: "video-model"},
+ CreatedAt: 1_710_000_000,
+ }, true, nil)
+
+ retrieveTaskPluginResponse(c, deps)
+
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ assert.NotContains(t, recorder.Body.String(), "retrieve boom")
+ var response dto.PluginResponsesResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.Equal(t, "failed", response.Status)
+ require.NotNil(t, response.Error)
+ assert.Equal(t, "server_error", response.Error.Code)
+ assert.Equal(t, "The task could not be observed.", response.Error.Message)
+}
+
+func TestRetrieveTaskPluginResponseFailureUsesFailedEnvelope(t *testing.T) {
+ logs := make([]string, 0, 1)
+ pinned := compilePluginProtocolRetrieveEndpoint(t, "retrieve-failure", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() { throw new Error("retrieve failure called renderEvents"); },
+ renderFinal: function() {
+ console.log("renderFinal called");
+ return {};
+ }
+ }};
+ `, logsAppender(&logs))
+ c, recorder := newPluginProtocolRetrieveContext("resp_retrieve_failure")
+ deps := pluginProtocolRetrieveDeps(pinned, &model.Task{
+ TaskID: "task_retrieve_failure",
+ Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
+ UserId: 71,
+ Status: model.TaskStatusFailure,
+ Properties: model.Properties{OriginModelName: "video-model"},
+ }, true, nil)
+
+ retrieveTaskPluginResponse(c, deps)
+
+ assert.Empty(t, logs)
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ var response dto.PluginResponsesResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.Equal(t, "failed", response.Status)
+ require.NotNil(t, response.Error)
+ assert.Equal(t, "server_error", response.Error.Code)
+ assert.Equal(t, "The task failed.", response.Error.Message)
+}
+
+func TestRetrieveTaskPluginResponseNotFound(t *testing.T) {
+ pinned := compilePluginProtocolRetrieveEndpoint(t, "retrieve-404", `
+ export const protocols = {openai_responses: {
+ renderFinal: function() { return {}; }
+ }};
+ `, pluginruntime.Options{})
+ owned := &model.Task{
+ TaskID: "task_owned",
+ Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
+ UserId: 71,
+ Status: model.TaskStatusInProgress,
+ Properties: model.Properties{OriginModelName: "video-model"},
+ }
+
+ tests := []struct {
+ name string
+ responseID string
+ userID int
+ task *model.Task
+ exists bool
+ plugin *pluginruntime.LoadedPlugin
+ claims []pluginruntime.ProtocolClaim
+ }{
+ {name: "bad prefix", responseID: "task_owned", userID: 71, task: owned, exists: true, plugin: pinned.Plugin},
+ {name: "missing", responseID: "resp_missing", userID: 71, exists: false, plugin: pinned.Plugin},
+ {name: "other user", responseID: "resp_owned", userID: 99, task: owned, exists: false, plugin: pinned.Plugin},
+ {name: "no plugin", responseID: "resp_owned", userID: 71, task: owned, exists: true},
+ {name: "plugin does not claim protocol", responseID: "resp_owned", userID: 71, task: owned, exists: true, plugin: pinned.Plugin, claims: []pluginruntime.ProtocolClaim{{Name: "openai_video"}}},
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ if testCase.plugin != nil {
+ if testCase.claims != nil {
+ testCase.plugin.Meta.Protocols = testCase.claims
+ } else {
+ testCase.plugin.Meta.Protocols = []pluginruntime.ProtocolClaim{{Name: "openai_responses", Supports: []string{"stream", "sync", "background"}}}
+ }
+ }
+ c, recorder := newPluginProtocolRetrieveContext(testCase.responseID)
+ common.SetContextKey(c, constant.ContextKeyUserId, testCase.userID)
+ deps := pluginProtocolRetrieveDeps(pinned, testCase.task, testCase.exists, nil)
+ if testCase.plugin == nil {
+ deps.resolvePlugin = func(constant.TaskPlatform) (*pluginruntime.LoadedPlugin, *pluginruntime.RoutingGeneration, bool) {
+ return nil, nil, false
+ }
+ }
+
+ retrieveTaskPluginResponse(c, deps)
+
+ assert.Equal(t, http.StatusNotFound, recorder.Code)
+ assert.Contains(t, recorder.Body.String(), `"code":"not_found"`)
+ assert.Contains(t, recorder.Body.String(), "No response found with id '"+testCase.responseID+"'.")
+ })
+ }
+}
+
+func TestRespondPluginProtocolSubmissionErrorPassesValidationMessage(t *testing.T) {
+ pinned := compilePluginProtocolTestEndpoint(t, "protocol-validation-detail", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() { return {events: [], done: false}; },
+ renderFinal: function() { return {}; }
+ }};
+ `)
+ c, recorder := newPluginProtocolTestContext(false, false)
+ deps := pluginProtocolTestDeps()
+ deps.submit = func(*gin.Context, *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
+ return nil, &dto.TaskError{
+ Code: "invalid_request",
+ Message: "model is required",
+ StatusCode: http.StatusBadRequest,
+ LocalError: true,
+ }
+ }
+
+ serveTaskPluginProtocol(c, pinned, deps)
+
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ assert.Contains(t, recorder.Body.String(), `"message":"model is required"`)
+ assert.Contains(t, recorder.Body.String(), `"code":"invalid_request_error"`)
+ assert.NotContains(t, recorder.Body.String(), "Invalid task protocol request")
+}
+
+func TestRespondPluginProtocolSubmissionErrorKeepsGenericNonValidation400(t *testing.T) {
+ pinned := compilePluginProtocolTestEndpoint(t, "protocol-generic-400", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() { return {events: [], done: false}; },
+ renderFinal: function() { return {}; }
+ }};
+ `)
+ c, recorder := newPluginProtocolTestContext(false, false)
+ deps := pluginProtocolTestDeps()
+ deps.submit = func(*gin.Context, *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
+ return nil, &dto.TaskError{
+ Code: "task_not_exist",
+ Message: "task_origin_not_exist",
+ StatusCode: http.StatusBadRequest,
+ LocalError: true,
+ }
+ }
+
+ serveTaskPluginProtocol(c, pinned, deps)
+
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ assert.Contains(t, recorder.Body.String(), "Invalid task protocol request")
+ assert.NotContains(t, recorder.Body.String(), "task_origin_not_exist")
+}
+
+func compilePluginProtocolTestEndpoint(t *testing.T, key, source string) pluginruntime.PinnedEndpoint {
+ t.Helper()
+ return compilePluginProtocolTestEndpointWithOptions(t, key, source, pluginruntime.Options{})
+}
+
+func compilePluginProtocolTestEndpointWithOptions(
+ t *testing.T,
+ key string,
+ source string,
+ options pluginruntime.Options,
+) pluginruntime.PinnedEndpoint {
+ t.Helper()
+ options.Key = key
+ options.Version = "1.0.0"
+ options.Concurrency = 1
+ engine, err := pluginruntime.Compile(source, options)
+ require.NoError(t, err)
+ return pluginruntime.PinnedEndpoint{
+ Generation: &pluginruntime.RoutingGeneration{Number: 41},
+ Plugin: &pluginruntime.LoadedPlugin{
+ Meta: pluginruntime.Meta{
+ Key: key,
+ Version: "1.0.0",
+ Protocols: []pluginruntime.ProtocolClaim{{
+ Name: "openai_responses",
+ Supports: []string{"stream", "sync", "background"},
+ }},
+ },
+ Engine: engine,
+ },
+ Protocol: "openai_responses",
+ Operation: pluginruntime.HostProtocolOperation{Name: "create", Methods: []string{http.MethodPost}, Path: "/v1/responses", BodyKinds: []pluginruntime.BodyKind{pluginruntime.BodyJSON}, ModelField: "model"},
+ Model: "video-model",
+ }
+}
+
+func newPluginProtocolTestContext(stream, requestBodyStream bool) (*gin.Context, *httptest.ResponseRecorder) {
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{}`))
+ common.SetContextKey(c, constant.ContextKeyUserId, 71)
+ common.SetContextKey(c, constant.ContextKeyTokenId, 81)
+ common.SetContextKey(c, constant.ContextKeyUsingGroup, "default")
+ c.Set("resolved_task_model", "video-model")
+ c.Set(pluginruntime.ContextKeyProtocolRequest, pluginruntime.ProtocolRequestContext{
+ RouteRequestContext: pluginruntime.RouteRequestContext{
+ Path: "/v1/responses",
+ Method: http.MethodPost,
+ Params: map[string]string{},
+ Query: map[string][]string{},
+ Body: map[string]any{
+ "kind": "json",
+ "value": map[string]any{"model": "video-model", "stream": requestBodyStream},
+ },
+ RequestBody: map[string]any{
+ "model": "video-model",
+ "stream": requestBodyStream,
+ },
+ },
+ Protocol: "openai_responses",
+ Stream: stream,
+ })
+ return c, recorder
+}
+
+func pluginProtocolTestDeps() pluginProtocolBridgeDeps {
+ return pluginProtocolBridgeDeps{
+ now: func() time.Time { return time.Unix(1_710_000_000, 0) },
+ admissions: newPluginProtocolObservationLimiter(defaultPluginProtocolObservationLimits),
+ protocolLimits: relay.DefaultPluginProtocolLimits(),
+ observationTimeout: time.Hour,
+ tickInterval: time.Nanosecond,
+ tickJitter: 0,
+ heartbeatInterval: time.Hour,
+ admissionTimeout: time.Second,
+ }
+}
+
+func pluginProtocolTestOutcome(
+ info *relaycommon.RelayInfo,
+ pluginKey string,
+ taskID string,
+ _ ...any,
+) *taskSubmissionOutcome {
+ return &taskSubmissionOutcome{
+ Result: &relay.TaskSubmitResult{},
+ Task: &model.Task{
+ TaskID: taskID,
+ Platform: constant.TaskPlatform(pluginKey),
+ UserId: info.UserId,
+ Status: model.TaskStatusSubmitted,
+ CreatedAt: 1_710_000_000,
+ },
+ RelayInfo: info,
+ }
+}
+
+func pluginProtocolTestSSEEventTypes(body string) []string {
+ lines := strings.Split(body, "\n")
+ events := make([]string, 0)
+ for _, line := range lines {
+ if after, ok := strings.CutPrefix(line, "event: "); ok {
+ events = append(events, after)
+ }
+ }
+ return events
+}
+
+func setProtocolRequestBackground(c *gin.Context, background bool) {
+ request := c.MustGet(pluginruntime.ContextKeyProtocolRequest).(pluginruntime.ProtocolRequestContext)
+ if body, ok := request.Body.(map[string]any); ok {
+ if value, ok := body["value"].(map[string]any); ok {
+ value["background"] = background
+ }
+ }
+}
+
+func compilePluginProtocolRetrieveEndpoint(t *testing.T, key, source string, options pluginruntime.Options) pluginruntime.PinnedEndpoint {
+ t.Helper()
+ pinned := compilePluginProtocolTestEndpointWithOptions(t, key, source, options)
+ pinned.Plugin.Meta.Protocols = []pluginruntime.ProtocolClaim{{Name: "openai_responses", Supports: []string{"stream", "sync", "background"}}}
+ return pinned
+}
+
+func logsAppender(logs *[]string) pluginruntime.Options {
+ if logs == nil {
+ return pluginruntime.Options{}
+ }
+ return pluginruntime.Options{
+ Log: func(message string) { *logs = append(*logs, message) },
+ }
+}
+
+func newPluginProtocolRetrieveContext(responseID string) (*gin.Context, *httptest.ResponseRecorder) {
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses/"+responseID, nil)
+ c.Params = gin.Params{{Key: "response_id", Value: responseID}}
+ common.SetContextKey(c, constant.ContextKeyUserId, 71)
+ common.SetContextKey(c, constant.ContextKeyTokenId, 81)
+ return c, recorder
+}
+
+func pluginProtocolRetrieveDeps(pinned pluginruntime.PinnedEndpoint, task *model.Task, exists bool, err error) pluginProtocolBridgeDeps {
+ deps := pluginProtocolTestDeps()
+ deps.getByTaskId = func(userId int, taskId string) (*model.Task, bool, error) {
+ if !exists {
+ return nil, false, err
+ }
+ if task != nil && (userId != task.UserId || taskId != task.TaskID) {
+ return nil, false, err
+ }
+ return task, task != nil, err
+ }
+ deps.resolvePlugin = func(constant.TaskPlatform) (*pluginruntime.LoadedPlugin, *pluginruntime.RoutingGeneration, bool) {
+ if pinned.Plugin == nil {
+ return nil, nil, false
+ }
+ return pinned.Plugin, pinned.Generation, true
+ }
+ return deps
+}
diff --git a/controller/relay.go b/controller/relay.go
index 8dccfe76dddd..0f7792efd970 100644
--- a/controller/relay.go
+++ b/controller/relay.go
@@ -15,8 +15,10 @@ import (
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
+ pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
"github.com/QuantumNous/new-api/relay"
+ "github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relay/helper"
@@ -344,7 +346,7 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
if retryTimes <= 0 {
return false
}
- if _, ok := c.Get("specific_channel_id"); ok {
+ if service.GetChannelConstraints(c).SuppressesRetry() {
return false
}
code := openaiErr.StatusCode
@@ -397,6 +399,7 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
}
service.AppendChannelAffinityAdminInfo(c, adminInfo)
other["admin_info"] = adminInfo
+ service.AppendTaskPluginContextAuditInfo(c, other)
startTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime)
if startTime.IsZero() {
startTime = time.Now()
@@ -474,6 +477,33 @@ func RelayNotFound(c *gin.Context) {
})
}
+// RelayTaskPluginEndpoint keeps unclaimed shared-endpoint traffic on its
+// existing handler while claimed requests enter the generation-pinned
+// host-owned protocol bridge.
+func RelayTaskPluginEndpoint(c *gin.Context, fallback gin.HandlerFunc) {
+ pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedEndpoint)
+ if !exists {
+ fallback(c)
+ return
+ }
+ pinned, ok := pinnedValue.(pluginruntime.PinnedEndpoint)
+ if !ok || pinned.Plugin == nil || pinned.Generation == nil {
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": gin.H{
+ "message": "Task protocol request failed",
+ "type": "new_api_error",
+ "code": "task_protocol_error",
+ },
+ })
+ return
+ }
+ if pinned.Protocol != "openai_responses" {
+ fallback(c)
+ return
+ }
+ serveTaskPluginProtocol(c, pinned, defaultPluginProtocolBridgeDeps())
+}
+
func RelayTaskFetch(c *gin.Context) {
relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil)
if err != nil {
@@ -489,29 +519,76 @@ func RelayTaskFetch(c *gin.Context) {
}
}
+type taskSubmissionOutcome struct {
+ Result *relay.TaskSubmitResult
+ Task *model.Task
+ RelayInfo *relaycommon.RelayInfo
+}
+
func RelayTask(c *gin.Context) {
relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil)
if err != nil {
- c.JSON(http.StatusInternalServerError, &taskdto.TaskError{
+ respondTaskSubmissionError(c, &taskdto.TaskError{
Code: "gen_relay_info_failed",
Message: err.Error(),
StatusCode: http.StatusInternalServerError,
})
return
}
+ if action := c.GetString("task_action"); action != "" {
+ relayInfo.Action = action
+ }
if taskErr := relay.ResolveOriginTask(c, relayInfo); taskErr != nil {
- respondTaskError(c, taskErr)
+ respondTaskSubmissionError(c, taskErr)
+ return
+ }
+ if taskErr := relay.ApplyOriginTaskAffinity(c, relayInfo); taskErr != nil {
+ respondTaskSubmissionError(c, taskErr)
return
}
+ outcome, taskErr := executeTaskSubmission(c, relayInfo)
+ if taskErr != nil {
+ respondTaskSubmissionError(c, taskErr)
+ return
+ }
+ presentTaskSubmission(c, outcome)
+}
+
+// executeTaskSubmission owns the retry, billing, and persistence lifecycle.
+// It deliberately performs no client response writes so JSON and protocol
+// presenters share the same durable task barrier. Its cancellation semantics
+// come from c.Request.Context: native task endpoints use the client context,
+// while the Responses bridge supplies an independently bounded context.
+func executeTaskSubmission(c *gin.Context, relayInfo *relaycommon.RelayInfo) (*taskSubmissionOutcome, *taskdto.TaskError) {
+ return executeTaskSubmissionWith(c, relayInfo, relay.RelayTaskSubmit)
+}
+
+type taskSubmitAttempt func(*gin.Context, *relaycommon.RelayInfo) (*relay.TaskSubmitResult, *taskdto.TaskError)
+
+func executeTaskSubmissionWith(
+ c *gin.Context,
+ relayInfo *relaycommon.RelayInfo,
+ submit taskSubmitAttempt,
+) (*taskSubmissionOutcome, *taskdto.TaskError) {
+ diagnostics := newTaskPluginSubmitDiagnostics(c)
+ diagnostics.start(relayInfo)
var result *relay.TaskSubmitResult
var taskErr *taskdto.TaskError
+ durable := false
+ stage := "start"
defer func() {
- if taskErr != nil && relayInfo.Billing != nil {
+ if !durable && relayInfo.Billing != nil {
+ diagnostics.refund(stage)
relayInfo.Billing.Refund(c)
}
}()
+ stage = "before_attempt"
+ if requestErr := c.Request.Context().Err(); requestErr != nil {
+ diagnostics.cancelled("before_attempt", 0)
+ return nil, service.TaskErrorWrapperLocal(requestErr, "request_cancelled", http.StatusRequestTimeout)
+ }
retryParam := &service.RetryParam{
Ctx: c,
@@ -522,6 +599,12 @@ func RelayTask(c *gin.Context) {
}
for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() {
+ stage = "select_channel"
+ if requestErr := c.Request.Context().Err(); requestErr != nil {
+ diagnostics.cancelled("before_attempt", retryParam.GetRetry()+1)
+ taskErr = service.TaskErrorWrapperLocal(requestErr, "request_cancelled", http.StatusRequestTimeout)
+ break
+ }
var channel *model.Channel
if lockedCh, ok := relayInfo.LockedChannel.(*model.Channel); ok && lockedCh != nil {
@@ -541,10 +624,12 @@ func RelayTask(c *gin.Context) {
break
}
}
+ diagnostics.attempt(retryParam.GetRetry()+1, channel, relayInfo.LockedChannel != nil)
addUsedChannel(c, channel.Id)
bodyStorage, bodyErr := common.GetBodyStorage(c)
if bodyErr != nil {
+ stage = "read_body"
if common.IsRequestBodyTooLargeError(bodyErr) || errors.Is(bodyErr, common.ErrRequestBodyTooLarge) {
taskErr = service.TaskErrorWrapperLocal(bodyErr, "read_request_body_failed", http.StatusRequestEntityTooLarge)
} else {
@@ -554,8 +639,15 @@ func RelayTask(c *gin.Context) {
}
c.Request.Body = io.NopCloser(bodyStorage)
- result, taskErr = relay.RelayTaskSubmit(c, relayInfo)
+ stage = "submit"
+ result, taskErr = submit(c, relayInfo)
+ if requestErr := c.Request.Context().Err(); requestErr != nil {
+ diagnostics.cancelled("after_submit", retryParam.GetRetry()+1)
+ taskErr = service.TaskErrorWrapperLocal(requestErr, "request_cancelled", http.StatusRequestTimeout)
+ break
+ }
if taskErr == nil {
+ diagnostics.attemptSucceeded(retryParam.GetRetry()+1, result)
break
}
@@ -566,7 +658,9 @@ func RelayTask(c *gin.Context) {
types.NewOpenAIError(taskErr.Error, types.ErrorCodeBadResponseStatusCode, taskErr.StatusCode))
}
- if !shouldRetryTaskRelay(c, channel.Id, taskErr, common.RetryTimes-retryParam.GetRetry()) {
+ willRetry := shouldRetryTaskRelay(c, channel.Id, taskErr, common.RetryTimes-retryParam.GetRetry())
+ diagnostics.attemptFailed(retryParam.GetRetry()+1, channel, taskErr, willRetry)
+ if !willRetry {
break
}
}
@@ -577,38 +671,157 @@ func RelayTask(c *gin.Context) {
logger.LogInfo(c, retryLogStr)
}
- // ── 成功:结算 + 日志 + 插入任务 ──
- if taskErr == nil {
- if settleErr := service.SettleBilling(c, relayInfo, result.Quota); settleErr != nil {
- common.SysError("settle task billing error: " + settleErr.Error())
+ if taskErr != nil {
+ diagnostics.failed(stage, "task_error", taskErr, false)
+ return nil, taskErr
+ }
+ if result == nil {
+ taskErr = service.TaskErrorWrapperLocal(errors.New("task submission returned no result"), "task_submit_failed", http.StatusInternalServerError)
+ diagnostics.failed("submit", "missing_result", taskErr, false)
+ return nil, taskErr
+ }
+ if requestErr := c.Request.Context().Err(); requestErr != nil {
+ diagnostics.cancelled("before_reserve", retryParam.GetRetry()+1)
+ return nil, service.TaskErrorWrapperLocal(requestErr, "request_cancelled", http.StatusRequestTimeout)
+ }
+
+ // Reserve any submit-time upward billing adjustment before persistence.
+ // This keeps insertion failures fully refundable while ensuring settlement
+ // after the barrier normally has a zero positive delta.
+ if relayInfo.Billing != nil {
+ stage = "reserve"
+ diagnostics.reserve("reserve_start", result.Quota)
+ if reserveErr := relayInfo.Billing.Reserve(result.Quota); reserveErr != nil {
+ common.SysError("reserve adjusted task billing error: " + reserveErr.Error())
+ taskErr = service.TaskErrorWrapperLocal(errors.New("insufficient quota for adjusted task cost"), string(types.ErrorCodeInsufficientUserQuota), http.StatusForbidden)
+ diagnostics.failed("reserve", "insufficient_quota", taskErr, false)
+ return nil, taskErr
+ }
+ diagnostics.reserve("reserve_complete", result.Quota)
+ }
+ if requestErr := c.Request.Context().Err(); requestErr != nil {
+ diagnostics.cancelled("before_insert", retryParam.GetRetry()+1)
+ return nil, service.TaskErrorWrapperLocal(requestErr, "request_cancelled", http.StatusRequestTimeout)
+ }
+
+ stage = "insert"
+ task := model.InitTask(result.Platform, relayInfo)
+ task.PrivateData.Execution = service.TaskExecutionSnapshotFromContext(c)
+ task.PrivateData.UpstreamTaskID = result.UpstreamTaskID
+ task.PrivateData.BillingSource = relayInfo.BillingSource
+ task.PrivateData.SubscriptionId = relayInfo.SubscriptionId
+ task.PrivateData.TokenId = relayInfo.TokenId
+ task.PrivateData.NodeName = common.NodeName
+ task.PrivateData.BillingContext = &model.TaskBillingContext{
+ ModelPrice: relayInfo.PriceData.ModelPrice,
+ GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio,
+ ModelRatio: relayInfo.PriceData.ModelRatio,
+ OtherRatios: relayInfo.PriceData.OtherRatios(),
+ OriginModelName: relayInfo.OriginModelName,
+ PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice,
+ TieredSnapshot: relayInfo.TieredBillingSnapshot,
+ }
+ task.Quota = result.Quota
+ task.Data = result.TaskData
+ task.Action = relayInfo.Action
+ if immediate := result.Immediate; immediate != nil {
+ task.Status = model.TaskStatus(immediate.Status)
+ task.Progress = immediate.Progress
+ if immediate.Status == model.TaskStatusSuccess || immediate.Status == model.TaskStatusFailure {
+ task.FinishTime = time.Now().Unix()
}
- service.LogTaskConsumption(c, relayInfo)
+ if immediate.Status == model.TaskStatusFailure {
+ task.FailReason = immediate.Reason
+ }
+ if immediate.Url != "" {
+ task.PrivateData.ResultURL = immediate.Url
+ } else if immediate.Status == model.TaskStatusSuccess {
+ task.PrivateData.ResultURL = taskcommon.BuildProxyURL(task.TaskID)
+ }
+ }
+ diagnostics.insertStart(task)
+ if insertErr := task.InsertWithContext(c.Request.Context()); insertErr != nil {
+ common.SysError("insert task error: " + insertErr.Error())
+ taskErr = service.TaskErrorWrapperLocal(errors.New("failed to persist task"), "task_insert_failed", http.StatusInternalServerError)
+ diagnostics.failed("insert", "database_error", taskErr, false)
+ return nil, taskErr
+ }
+ durable = true
+ stage = "settle"
+ diagnostics.durable(task)
+ diagnostics.settleStart(task, result.Quota)
+
+ if settleErr := service.SettleBilling(c, relayInfo, result.Quota); settleErr != nil {
+ common.SysError("settle task billing error: " + settleErr.Error())
+ taskErr = service.TaskErrorWrapperLocal(errors.New("failed to settle task billing"), "task_billing_settlement_failed", http.StatusInternalServerError)
+ diagnostics.failed("settle", "billing_error", taskErr, true)
+ return nil, taskErr
+ }
+ service.LogTaskConsumption(c, relayInfo, task)
+ diagnostics.complete(task, result.Quota)
- task := model.InitTask(result.Platform, relayInfo)
- task.PrivateData.UpstreamTaskID = result.UpstreamTaskID
- task.PrivateData.BillingSource = relayInfo.BillingSource
- task.PrivateData.SubscriptionId = relayInfo.SubscriptionId
- task.PrivateData.TokenId = relayInfo.TokenId
- task.PrivateData.NodeName = common.NodeName
- task.PrivateData.BillingContext = &model.TaskBillingContext{
- ModelPrice: relayInfo.PriceData.ModelPrice,
- GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio,
- ModelRatio: relayInfo.PriceData.ModelRatio,
- OtherRatios: relayInfo.PriceData.OtherRatios(),
- OriginModelName: relayInfo.OriginModelName,
- PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice,
+ return &taskSubmissionOutcome{Result: result, Task: task, RelayInfo: relayInfo}, nil
+}
+
+func presentTaskSubmission(c *gin.Context, outcome *taskSubmissionOutcome) {
+ diagnostics := newTaskPluginSubmitDiagnostics(c)
+ otherRatios := outcome.RelayInfo.PriceData.OtherRatios()
+ if otherRatios == nil {
+ otherRatios = map[string]float64{}
+ }
+ if ratiosJSON, err := common.Marshal(otherRatios); err == nil {
+ c.Header("X-New-Api-Other-Ratios", string(ratiosJSON))
+ }
+ if pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedRoute); exists {
+ if pinned, ok := pinnedValue.(pluginruntime.PinnedRoute); ok && pinned.Plugin != nil && pinned.Route.Render != "" {
+ view, err := service.BuildTaskPluginView(outcome.Task)
+ requestValue, _ := c.Get(pluginruntime.ContextKeyRouteRequest)
+ requestContext, _ := requestValue.(pluginruntime.RouteRequestContext)
+ if err == nil {
+ viewValue, valueErr := taskPluginProtocolJSONValue(view)
+ if valueErr == nil {
+ if body, callErr := pinned.Plugin.Engine.CallPath(c.Request.Context(), "native", []string{pinned.Route.Render}, requestContext.JSValue(), viewValue); callErr == nil {
+ diagnostics.present(outcome.Task, "native_presenter")
+ c.JSON(http.StatusOK, body)
+ return
+ } else {
+ logger.LogError(c, "task plugin native submit presenter failed: "+callErr.Error())
+ }
+ } else {
+ logger.LogError(c, "encode task plugin native submit view failed: "+valueErr.Error())
+ }
+ } else {
+ logger.LogError(c, "build task plugin native submit view failed: "+err.Error())
+ }
}
- task.Quota = result.Quota
- task.Data = result.TaskData
- task.Action = relayInfo.Action
- if insertErr := task.Insert(); insertErr != nil {
- common.SysError("insert task error: " + insertErr.Error())
+ }
+ if pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedEndpoint); exists {
+ if pinned, ok := pinnedValue.(pluginruntime.PinnedEndpoint); ok && pinned.Protocol == "openai_video" && pinned.Operation.Name == "create" {
+ diagnostics.present(outcome.Task, "openai_video_create")
+ c.JSON(http.StatusOK, outcome.Task.ToOpenAIVideo())
+ return
}
}
+ createdAt := outcome.Task.CreatedAt
+ if createdAt == 0 {
+ createdAt = outcome.Task.SubmitTime
+ }
+ diagnostics.present(outcome.Task, "host_fallback")
+ c.JSON(http.StatusOK, map[string]any{
+ "id": outcome.Task.TaskID,
+ "task_id": outcome.Task.TaskID,
+ "status": "queued",
+ "model": outcome.RelayInfo.OriginModelName,
+ "created_at": createdAt,
+ })
+}
- if taskErr != nil {
- respondTaskError(c, taskErr)
+func respondTaskSubmissionError(c *gin.Context, taskErr *taskdto.TaskError) {
+ newTaskPluginSubmitDiagnostics(c).presentError(taskErr)
+ if middleware.RespondTaskPluginError(c, taskErr) {
+ return
}
+ respondTaskError(c, taskErr)
}
// respondTaskError 统一输出 Task 错误响应(含 429 限流提示改写)
@@ -629,7 +842,7 @@ func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *taskdto.TaskEr
if retryTimes <= 0 {
return false
}
- if _, ok := c.Get("specific_channel_id"); ok {
+ if service.GetChannelConstraints(c).SuppressesRetry() {
return false
}
if taskErr.StatusCode == http.StatusTooManyRequests {
diff --git a/controller/relay_task_plugin_test.go b/controller/relay_task_plugin_test.go
new file mode 100644
index 000000000000..e9405e2e6280
--- /dev/null
+++ b/controller/relay_task_plugin_test.go
@@ -0,0 +1,389 @@
+package controller
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/dto"
+ "github.com/QuantumNous/new-api/model"
+ pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/QuantumNous/new-api/relay"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/types"
+ "github.com/gin-gonic/gin"
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+type taskSubmissionTestBilling struct {
+ events *[]string
+ settleErr error
+ onSettle func()
+ refunds int
+}
+
+func (b *taskSubmissionTestBilling) Settle(int) error {
+ *b.events = append(*b.events, "settle")
+ if b.onSettle != nil {
+ b.onSettle()
+ }
+ return b.settleErr
+}
+
+func (b *taskSubmissionTestBilling) Refund(*gin.Context) {
+ *b.events = append(*b.events, "refund")
+ b.refunds++
+}
+
+func (b *taskSubmissionTestBilling) NeedsRefund() bool { return b.refunds == 0 }
+func (b *taskSubmissionTestBilling) GetPreConsumedQuota() int { return 0 }
+func (b *taskSubmissionTestBilling) Reserve(int) error {
+ *b.events = append(*b.events, "reserve")
+ return nil
+}
+
+func TestPresentTaskSubmissionUsesNativePresenterAfterPersistence(t *testing.T) {
+ plugin, err := pluginruntime.CompilePlugin(`
+export const meta = {apiVersion:1,key:"presenter-test",name:"Presenter",version:"1.0.0",author:{name:"Test"},models:["model"],fetchMode:"per_task",routes:[{method:"POST",path:"/vendor/jobs",type:"submit",decode:"decode",render:"created"}]};
+export const native = {decode:function(ctx){return {kind:"submit",model:"model",requestBody:ctx.body.value};},created:function(ctx,task){return {data:{task_id:task.task_id},upstream:task.data};}};
+export function buildSubmitRequest(){return {}} export function parseSubmitResponse(){return {taskId:"upstream"}} export function buildQueryRequest(){return {}} export function parseTaskResult(){return {status:"SUCCESS"}}
+`, pluginruntime.Options{})
+ require.NoError(t, err)
+ priceData := types.PriceData{}
+ priceData.AddOtherRatio("seconds", 5)
+ task := &model.Task{TaskID: "task_public", SubmitTime: 123}
+ task.SetData(map[string]any{"task_id": "upstream_private"})
+ outcome := &taskSubmissionOutcome{
+ Result: &relay.TaskSubmitResult{},
+ Task: task,
+ RelayInfo: &relaycommon.RelayInfo{PriceData: priceData},
+ }
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/vendor/jobs", strings.NewReader(`{"model":"model"}`))
+ c.Set(pluginruntime.ContextKeyPinnedRoute, pluginruntime.PinnedRoute{Plugin: plugin, Route: plugin.Meta.Routes[0]})
+ c.Set(pluginruntime.ContextKeyRouteRequest, pluginruntime.RouteRequestContext{Path: "/vendor/jobs", Method: http.MethodPost, Body: map[string]any{"kind": "json", "value": map[string]any{"model": "model"}}})
+
+ presentTaskSubmission(c, outcome)
+
+ assert.JSONEq(t, `{
+ "data":{"task_id":"task_public"},
+ "upstream":{"task_id":"upstream_private"}
+ }`, recorder.Body.String())
+ assert.JSONEq(t, `{"seconds":5}`, recorder.Header().Get("X-New-Api-Other-Ratios"))
+}
+
+func TestPresentTaskSubmissionFallbackUsesPersistedPublicID(t *testing.T) {
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ outcome := &taskSubmissionOutcome{
+ Result: &relay.TaskSubmitResult{},
+ Task: &model.Task{TaskID: "task_persisted", SubmitTime: 456},
+ RelayInfo: &relaycommon.RelayInfo{OriginModelName: "video-model"},
+ }
+
+ presentTaskSubmission(c, outcome)
+
+ assert.JSONEq(t, `{
+ "id":"task_persisted",
+ "task_id":"task_persisted",
+ "status":"queued",
+ "model":"video-model",
+ "created_at":456
+ }`, recorder.Body.String())
+}
+
+func TestPresentTaskSubmissionUsesHostOpenAIVideoCreateReceipt(t *testing.T) {
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Set(pluginruntime.ContextKeyPinnedEndpoint, pluginruntime.PinnedEndpoint{
+ Protocol: "openai_video",
+ Operation: pluginruntime.HostProtocolOperation{Name: "create"},
+ })
+ task := &model.Task{
+ TaskID: "task_public",
+ Status: model.TaskStatusSubmitted,
+ Progress: "0%",
+ CreatedAt: 456,
+ Properties: model.Properties{OriginModelName: "video-model"},
+ }
+ outcome := &taskSubmissionOutcome{Result: &relay.TaskSubmitResult{}, Task: task, RelayInfo: &relaycommon.RelayInfo{}}
+
+ presentTaskSubmission(c, outcome)
+
+ assert.JSONEq(t, `{"id":"task_public","object":"video","model":"video-model","status":"queued","progress":0,"created_at":456}`, recorder.Body.String())
+ assert.NotContains(t, recorder.Body.String(), "task_id")
+}
+
+func TestExecuteTaskSubmissionRefundsWhenInsertFails(t *testing.T) {
+ events := make([]string, 0, 3)
+ database := setupTaskSubmissionDatabase(t, false, &events)
+ _ = database
+ billing := &taskSubmissionTestBilling{events: &events}
+ c := taskSubmissionTestContext()
+ info := taskSubmissionRelayInfo(billing)
+
+ outcome, taskErr := executeTaskSubmissionWith(c, info, func(*gin.Context, *relaycommon.RelayInfo) (*relay.TaskSubmitResult, *dto.TaskError) {
+ return &relay.TaskSubmitResult{
+ UpstreamTaskID: "upstream_private",
+ Platform: constant.TaskPlatform("plugin"),
+ }, nil
+ })
+
+ assert.Nil(t, outcome)
+ require.NotNil(t, taskErr)
+ assert.Equal(t, "task_insert_failed", taskErr.Code)
+ assert.Equal(t, []string{"reserve", "insert", "refund"}, events)
+ assert.Equal(t, 1, billing.refunds)
+ assert.False(t, c.Writer.Written())
+}
+
+func TestExecuteTaskSubmissionSettlementFailureStaysDurableAndWritesNothing(t *testing.T) {
+ events := make([]string, 0, 3)
+ database := setupTaskSubmissionDatabase(t, true, &events)
+ billing := &taskSubmissionTestBilling{events: &events, settleErr: errors.New("settlement failed")}
+ c := taskSubmissionTestContext()
+ info := taskSubmissionRelayInfo(billing)
+
+ outcome, taskErr := executeTaskSubmissionWith(c, info, func(*gin.Context, *relaycommon.RelayInfo) (*relay.TaskSubmitResult, *dto.TaskError) {
+ return &relay.TaskSubmitResult{
+ UpstreamTaskID: "upstream_private",
+ Platform: constant.TaskPlatform("plugin"),
+ }, nil
+ })
+
+ assert.Nil(t, outcome)
+ require.NotNil(t, taskErr)
+ assert.Equal(t, "task_billing_settlement_failed", taskErr.Code)
+ assert.Equal(t, []string{"reserve", "insert", "settle"}, events)
+ assert.Zero(t, billing.refunds)
+ var count int64
+ require.NoError(t, database.Model(&model.Task{}).Where("task_id = ?", "task_public").Count(&count).Error)
+ assert.Equal(t, int64(1), count)
+ assert.False(t, c.Writer.Written())
+}
+
+func TestExecuteTaskSubmissionPersistsPinnedPluginProvenance(t *testing.T) {
+ events := make([]string, 0, 3)
+ database := setupTaskSubmissionDatabase(t, true, &events)
+ previousLogConsumeEnabled := common.LogConsumeEnabled
+ common.LogConsumeEnabled = false
+ t.Cleanup(func() { common.LogConsumeEnabled = previousLogConsumeEnabled })
+
+ c := taskSubmissionTestContext()
+ c.Set(common.RequestIdKey, "request-public")
+ c.Set(pluginruntime.ContextKeyPinnedPlugin, pluginruntime.PinnedPlugin{
+ Generation: &pluginruntime.RoutingGeneration{Number: 42},
+ Plugin: &pluginruntime.LoadedPlugin{Meta: pluginruntime.Meta{
+ Key: "document-parser",
+ Name: "Document Parser",
+ Version: "1.2.3",
+ APIVersion: 1,
+ Author: pluginruntime.AuthorMeta{
+ Name: "Community Author",
+ URL: "https://plugins.example/author",
+ },
+ }},
+ })
+ billing := &taskSubmissionTestBilling{events: &events}
+ info := taskSubmissionRelayInfo(billing)
+
+ outcome, taskErr := executeTaskSubmissionWith(c, info, func(*gin.Context, *relaycommon.RelayInfo) (*relay.TaskSubmitResult, *dto.TaskError) {
+ return &relay.TaskSubmitResult{
+ UpstreamTaskID: "upstream-private",
+ Platform: constant.TaskPlatform("document-parser"),
+ }, nil
+ })
+
+ require.Nil(t, taskErr)
+ require.NotNil(t, outcome)
+ require.NotNil(t, outcome.Task.PrivateData.Execution)
+ require.NotNil(t, outcome.Task.PrivateData.Execution.TaskPlugin)
+ assert.Equal(t, "request-public", outcome.Task.PrivateData.Execution.RequestID)
+ assert.Equal(t, "/plugin/submit", outcome.Task.PrivateData.Execution.RequestPath)
+ assert.Equal(t, "1.2.3", outcome.Task.PrivateData.Execution.TaskPlugin.Version)
+ assert.Equal(t, uint64(42), outcome.Task.PrivateData.Execution.TaskPlugin.Generation)
+ require.NotNil(t, outcome.Task.PrivateData.Execution.TaskPlugin.Author)
+ assert.Equal(t, "Community Author", outcome.Task.PrivateData.Execution.TaskPlugin.Author.Name)
+ assert.Equal(t, "https://plugins.example/author", outcome.Task.PrivateData.Execution.TaskPlugin.Author.URL)
+
+ var stored model.Task
+ require.NoError(t, database.Where("task_id = ?", "task_public").First(&stored).Error)
+ require.NotNil(t, stored.PrivateData.Execution)
+ require.NotNil(t, stored.PrivateData.Execution.TaskPlugin)
+ assert.Equal(t, "document-parser", stored.PrivateData.Execution.TaskPlugin.Key)
+ require.NotNil(t, stored.PrivateData.Execution.TaskPlugin.Author)
+ assert.Equal(t, "Community Author", stored.PrivateData.Execution.TaskPlugin.Author.Name)
+ assert.Equal(t, "upstream-private", stored.PrivateData.UpstreamTaskID)
+}
+
+func TestExecuteTaskSubmissionRefundsCancellationBeforeDurableBarrier(t *testing.T) {
+ events := make([]string, 0, 2)
+ setupTaskSubmissionDatabase(t, true, &events)
+ billing := &taskSubmissionTestBilling{events: &events}
+ c := taskSubmissionTestContext()
+ requestContext, cancel := context.WithCancel(c.Request.Context())
+ c.Request = c.Request.WithContext(requestContext)
+ info := taskSubmissionRelayInfo(billing)
+
+ outcome, taskErr := executeTaskSubmissionWith(c, info, func(*gin.Context, *relaycommon.RelayInfo) (*relay.TaskSubmitResult, *dto.TaskError) {
+ cancel()
+ return &relay.TaskSubmitResult{
+ UpstreamTaskID: "upstream_private",
+ Platform: constant.TaskPlatform("plugin"),
+ }, nil
+ })
+
+ assert.Nil(t, outcome)
+ require.NotNil(t, taskErr)
+ assert.Equal(t, "request_cancelled", taskErr.Code)
+ assert.Equal(t, []string{"refund"}, events)
+ assert.Equal(t, 1, billing.refunds)
+ assert.False(t, c.Writer.Written())
+}
+
+func TestExecuteTaskSubmissionDisconnectBeforeUpstreamAcceptanceSkipsSubmitAndRefunds(t *testing.T) {
+ events := make([]string, 0, 1)
+ setupTaskSubmissionDatabase(t, true, &events)
+ billing := &taskSubmissionTestBilling{events: &events}
+ c := taskSubmissionTestContext()
+ requestContext, cancel := context.WithCancel(c.Request.Context())
+ cancel()
+ c.Request = c.Request.WithContext(requestContext)
+ info := taskSubmissionRelayInfo(billing)
+ submitted := false
+
+ outcome, taskErr := executeTaskSubmissionWith(c, info, func(*gin.Context, *relaycommon.RelayInfo) (*relay.TaskSubmitResult, *dto.TaskError) {
+ submitted = true
+ return nil, nil
+ })
+
+ assert.Nil(t, outcome)
+ require.NotNil(t, taskErr)
+ assert.Equal(t, "request_cancelled", taskErr.Code)
+ assert.False(t, submitted)
+ assert.Equal(t, []string{"refund"}, events)
+ assert.Equal(t, 1, billing.refunds)
+ assert.False(t, c.Writer.Written())
+}
+
+func TestExecuteTaskSubmissionCallerCancellationDuringSubmitRefundsBeforeDurableBarrier(t *testing.T) {
+ events := make([]string, 0, 1)
+ setupTaskSubmissionDatabase(t, true, &events)
+ billing := &taskSubmissionTestBilling{events: &events}
+ c := taskSubmissionTestContext()
+ requestContext, cancel := context.WithCancel(c.Request.Context())
+ c.Request = c.Request.WithContext(requestContext)
+ info := taskSubmissionRelayInfo(billing)
+ submitStarted := make(chan struct{})
+ done := make(chan struct{})
+ var outcome *taskSubmissionOutcome
+ var taskErr *dto.TaskError
+
+ go func() {
+ defer close(done)
+ outcome, taskErr = executeTaskSubmissionWith(c, info, func(c *gin.Context, _ *relaycommon.RelayInfo) (*relay.TaskSubmitResult, *dto.TaskError) {
+ close(submitStarted)
+ <-c.Request.Context().Done()
+ return nil, service.TaskErrorWrapperLocal(c.Request.Context().Err(), "do_request_failed", http.StatusInternalServerError)
+ })
+ }()
+ select {
+ case <-submitStarted:
+ case <-time.After(2 * time.Second):
+ require.FailNow(t, "submission did not start")
+ }
+ cancel()
+ select {
+ case <-done:
+ case <-time.After(2 * time.Second):
+ require.FailNow(t, "submission did not stop after disconnect")
+ }
+
+ assert.Nil(t, outcome)
+ require.NotNil(t, taskErr)
+ assert.Equal(t, "request_cancelled", taskErr.Code)
+ assert.Equal(t, []string{"refund"}, events)
+ assert.Equal(t, 1, billing.refunds)
+ assert.False(t, c.Writer.Written())
+}
+
+func TestExecuteTaskSubmissionDisconnectAfterDurableInsertDoesNotRefund(t *testing.T) {
+ events := make([]string, 0, 3)
+ database := setupTaskSubmissionDatabase(t, true, &events)
+ previousLogConsumeEnabled := common.LogConsumeEnabled
+ common.LogConsumeEnabled = false
+ t.Cleanup(func() { common.LogConsumeEnabled = previousLogConsumeEnabled })
+ c := taskSubmissionTestContext()
+ requestContext, cancel := context.WithCancel(c.Request.Context())
+ c.Request = c.Request.WithContext(requestContext)
+ billing := &taskSubmissionTestBilling{
+ events: &events,
+ onSettle: cancel,
+ }
+ info := taskSubmissionRelayInfo(billing)
+
+ outcome, taskErr := executeTaskSubmissionWith(c, info, func(*gin.Context, *relaycommon.RelayInfo) (*relay.TaskSubmitResult, *dto.TaskError) {
+ return &relay.TaskSubmitResult{
+ UpstreamTaskID: "upstream_private",
+ Platform: constant.TaskPlatform("plugin"),
+ }, nil
+ })
+
+ require.Nil(t, taskErr)
+ require.NotNil(t, outcome)
+ assert.Equal(t, "task_public", outcome.Task.TaskID)
+ assert.Equal(t, []string{"reserve", "insert", "settle"}, events)
+ assert.Zero(t, billing.refunds)
+ var count int64
+ require.NoError(t, database.Model(&model.Task{}).Where("task_id = ?", "task_public").Count(&count).Error)
+ assert.Equal(t, int64(1), count)
+ assert.False(t, c.Writer.Written())
+}
+
+func setupTaskSubmissionDatabase(t *testing.T, migrate bool, events *[]string) *gorm.DB {
+ t.Helper()
+ previousDB := model.DB
+ database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, database.Callback().Create().Before("gorm:create").Register("test:task-submit-order", func(*gorm.DB) {
+ *events = append(*events, "insert")
+ }))
+ if migrate {
+ require.NoError(t, database.AutoMigrate(&model.Task{}))
+ }
+ model.DB = database
+ t.Cleanup(func() { model.DB = previousDB })
+ return database
+}
+
+func taskSubmissionTestContext() *gin.Context {
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/plugin/submit", strings.NewReader(`{}`))
+ return c
+}
+
+func taskSubmissionRelayInfo(billing relaycommon.BillingSettler) *relaycommon.RelayInfo {
+ return &relaycommon.RelayInfo{
+ UserId: 1,
+ UsingGroup: "default",
+ OriginModelName: "plugin-model",
+ Billing: billing,
+ TaskRelayInfo: &relaycommon.TaskRelayInfo{
+ PublicTaskID: "task_public",
+ LockedChannel: &model.Channel{Id: 1, Type: constant.ChannelTypeTaskPlugin, Name: "plugin"},
+ },
+ ChannelMeta: &relaycommon.ChannelMeta{ChannelId: 1, ChannelType: constant.ChannelTypeTaskPlugin},
+ }
+}
diff --git a/controller/task.go b/controller/task.go
index a80f1a687aab..c514bdb325b5 100644
--- a/controller/task.go
+++ b/controller/task.go
@@ -1,88 +1,478 @@
package controller
import (
+ "errors"
+ "fmt"
+ "net/http"
+ "regexp"
"strconv"
+ "strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
+ "github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay"
+ relaychannel "github.com/QuantumNous/new-api/relay/channel"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/types"
-
"github.com/gin-gonic/gin"
)
+type taskArtifactResponse struct {
+ Key string `json:"key"`
+ Type string `json:"type"`
+ MimeType string `json:"mime_type,omitempty"`
+ ContentURL string `json:"content_url"`
+}
+
+var (
+ taskArtifactKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$`)
+ errTaskArtifactPluginUnavailable = errors.New("task artifact plugin unavailable")
+ errTaskArtifactPlugin = errors.New("task artifact plugin error")
+)
+
+func GetTask(c *gin.Context) {
+ task, exists, err := model.GetByTaskId(c.GetInt("id"), c.Param("key"))
+ if err != nil {
+ videoProxyError(c, http.StatusInternalServerError, "server_error", "Failed to query task")
+ return
+ }
+ if !exists {
+ videoProxyError(c, http.StatusNotFound, "invalid_request_error", "Task not found")
+ return
+ }
+ createdAt := task.CreatedAt
+ if createdAt == 0 {
+ createdAt = task.SubmitTime
+ }
+ failReason := task.FailReason
+ if task.Status == model.TaskStatusSuccess && taskFailReasonIsLegacyResultURL(task.FailReason) {
+ failReason = ""
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "task_id": task.TaskID,
+ "platform": task.Platform,
+ "status": task.Status,
+ "progress": task.Progress,
+ "fail_reason": failReason,
+ "created_at": createdAt,
+ "finished_at": task.FinishTime,
+ })
+}
+
+func GetTaskArtifacts(c *gin.Context) {
+ task, exists, err := model.GetByTaskId(c.GetInt("id"), c.Param("key"))
+ if err != nil {
+ writeTaskArtifactError(c, http.StatusInternalServerError, "artifact_internal_error", "Failed to query task")
+ return
+ }
+ if !exists || task == nil {
+ writeTaskArtifactError(c, http.StatusNotFound, "artifact_not_found", "Task or artifact not found")
+ return
+ }
+ writeTaskArtifacts(c, task, false)
+}
+
+func GetDashboardTaskArtifacts(c *gin.Context) {
+ task, exists, err := getTaskForArtifactRequest(c, c.Param("task_id"))
+ if err != nil {
+ writeTaskArtifactError(c, http.StatusInternalServerError, "artifact_internal_error", "Failed to query task")
+ return
+ }
+ if !exists || task == nil {
+ writeTaskArtifactError(c, http.StatusNotFound, "artifact_not_found", "Task or artifact not found")
+ return
+ }
+ writeTaskArtifacts(c, task, true)
+}
+
+func writeTaskArtifacts(c *gin.Context, task *model.Task, dashboard bool) {
+ c.Header("Cache-Control", "private, no-store")
+ artifacts, err := projectTaskArtifacts(task)
+ if err != nil {
+ writeTaskArtifactProjectionError(c, err)
+ return
+ }
+ items := make([]taskArtifactResponse, 0, len(artifacts))
+ for _, artifact := range artifacts {
+ contentURL, buildErr := service.BuildTaskArtifactContentURL(task.TaskID, artifact.Key)
+ if buildErr != nil {
+ writeTaskArtifactError(c, http.StatusInternalServerError, "artifact_url_error", "Failed to build artifact content URL")
+ return
+ }
+ items = append(items, taskArtifactResponse{
+ Key: artifact.Key,
+ Type: artifact.Type,
+ MimeType: artifact.MimeType,
+ ContentURL: contentURL,
+ })
+ }
+ response := gin.H{"task_id": task.TaskID, "artifacts": items}
+ if legacyVideoAvailable(task) {
+ legacyContentURL, buildErr := service.BuildTaskArtifactContentURL(task.TaskID, "video")
+ if buildErr != nil {
+ writeTaskArtifactError(c, http.StatusInternalServerError, "artifact_url_error", "Failed to build artifact content URL")
+ return
+ }
+ response["legacy_content_url"] = legacyContentURL
+ }
+ if dashboard {
+ common.ApiSuccess(c, response)
+ return
+ }
+ c.JSON(http.StatusOK, response)
+}
+
+func projectTaskArtifacts(task *model.Task) ([]relaychannel.TaskArtifact, error) {
+ if task == nil || task.Status != model.TaskStatusSuccess || !taskHasPluginExecution(task) {
+ return []relaychannel.TaskArtifact{}, nil
+ }
+ adaptor := relay.GetTaskAdaptor(task.Platform)
+ if adaptor == nil {
+ return nil, errTaskArtifactPluginUnavailable
+ }
+ provider, ok := adaptor.(relaychannel.TaskArtifactProvider)
+ if !ok {
+ return []relaychannel.TaskArtifact{}, nil
+ }
+ artifacts, err := provider.ListArtifacts(task)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", errTaskArtifactPlugin, err)
+ }
+ return validateProjectedTaskArtifacts(artifacts)
+}
+
+func validateProjectedTaskArtifacts(artifacts []relaychannel.TaskArtifact) ([]relaychannel.TaskArtifact, error) {
+ if len(artifacts) > 64 {
+ return nil, fmt.Errorf("%w: too many artifacts", errTaskArtifactPlugin)
+ }
+ seen := make(map[string]struct{}, len(artifacts))
+ for i := range artifacts {
+ if artifacts[i].Key != strings.TrimSpace(artifacts[i].Key) ||
+ artifacts[i].Type != strings.TrimSpace(artifacts[i].Type) {
+ return nil, fmt.Errorf("%w: invalid artifact identity", errTaskArtifactPlugin)
+ }
+ if !taskArtifactKeyPattern.MatchString(artifacts[i].Key) {
+ return nil, fmt.Errorf("%w: invalid artifact key", errTaskArtifactPlugin)
+ }
+ if _, exists := seen[artifacts[i].Key]; exists {
+ return nil, fmt.Errorf("%w: duplicate artifact key", errTaskArtifactPlugin)
+ }
+ seen[artifacts[i].Key] = struct{}{}
+ switch artifacts[i].Type {
+ case "video", "audio", "image", "file":
+ default:
+ return nil, fmt.Errorf("%w: invalid artifact type", errTaskArtifactPlugin)
+ }
+ if len(artifacts[i].MimeType) > 255 || strings.ContainsAny(artifacts[i].MimeType, "\r\n") {
+ return nil, fmt.Errorf("%w: invalid artifact mime type", errTaskArtifactPlugin)
+ }
+ }
+ return artifacts, nil
+}
+
+func initTaskArtifactAdaptor(task *model.Task) (relaychannel.TaskAdaptor, error) {
+ if task == nil || !taskHasPluginExecution(task) {
+ return nil, errTaskArtifactPluginUnavailable
+ }
+ channelModel, err := model.CacheGetChannel(task.ChannelId)
+ if err != nil {
+ return nil, fmt.Errorf("%w: channel unavailable", errTaskArtifactPluginUnavailable)
+ }
+ adaptor := relay.GetTaskAdaptor(task.Platform)
+ if adaptor == nil {
+ return nil, errTaskArtifactPluginUnavailable
+ }
+ pluginKey := task.PrivateData.Key
+ if pluginKey == "" {
+ pluginKey = channelModel.Key
+ }
+ baseURL := channelModel.GetBaseURL()
+ if baseURL == "" {
+ baseURL = constant.GetChannelBaseURL(channelModel.Type)
+ }
+ adaptor.Init(&relaycommon.RelayInfo{
+ ChannelMeta: &relaycommon.ChannelMeta{
+ ChannelType: channelModel.Type,
+ ChannelBaseUrl: baseURL,
+ ApiKey: pluginKey,
+ ChannelSetting: channelModel.GetSetting(),
+ },
+ })
+ return adaptor, nil
+}
+
+func taskHasPluginExecution(task *model.Task) bool {
+ return task != nil &&
+ task.PrivateData.Execution != nil &&
+ task.PrivateData.Execution.TaskPlugin != nil &&
+ strings.TrimSpace(task.PrivateData.Execution.TaskPlugin.Key) != ""
+}
+
+func legacyVideoAvailable(task *model.Task) bool {
+ if task == nil || task.Status != model.TaskStatusSuccess ||
+ taskHasPluginExecution(task) || task.Platform == constant.TaskPlatformSuno ||
+ strings.TrimSpace(task.GetResultURL()) == "" {
+ return false
+ }
+ switch constant.NormalizeTaskAction(task.Action) {
+ case constant.TaskActionImageToVideo,
+ constant.TaskActionTextToVideo,
+ constant.TaskActionFirstTailToVideo,
+ constant.TaskActionReferenceToVideo,
+ constant.TaskActionRemix:
+ return true
+ default:
+ return false
+ }
+}
+
+func getTaskForArtifactRequest(c *gin.Context, taskID string) (*model.Task, bool, error) {
+ if middleware.IsTaskArtifactAccess(c) {
+ task, exists, err := model.GetUniqueByOnlyTaskId(taskID)
+ if err != nil || !exists || task == nil {
+ return task, exists, err
+ }
+ owner, err := model.GetUserCache(task.UserId)
+ if err != nil || owner == nil || owner.Status != common.UserStatusEnabled {
+ return nil, false, err
+ }
+ return task, true, nil
+ }
+ if c.GetInt("token_id") == 0 && c.GetInt("role") >= common.RoleAdminUser {
+ return model.GetByOnlyTaskId(taskID)
+ }
+ return model.GetByTaskId(c.GetInt("id"), taskID)
+}
+
+func writeTaskArtifactProjectionError(c *gin.Context, err error) {
+ if errors.Is(err, errTaskArtifactPluginUnavailable) {
+ writeTaskArtifactError(c, http.StatusServiceUnavailable, "artifact_plugin_unavailable", "Artifact preview plugin is unavailable")
+ return
+ }
+ writeTaskArtifactError(c, http.StatusInternalServerError, "artifact_plugin_error", "Artifact preview plugin failed")
+}
+
+func writeTaskArtifactError(c *gin.Context, status int, code, message string) {
+ c.Header("Cache-Control", "private, no-store")
+ if middleware.IsTaskArtifactAccess(c) {
+ status = http.StatusNotFound
+ code = "artifact_not_found"
+ message = "Task or artifact not found"
+ }
+ if strings.HasPrefix(c.Request.URL.Path, "/api/") {
+ c.JSON(status, gin.H{"success": false, "code": code, "message": message})
+ return
+ }
+ c.JSON(status, gin.H{
+ "error": gin.H{
+ "message": message,
+ "type": code,
+ "code": code,
+ },
+ })
+}
+
+func TaskArtifactContent(c *gin.Context) {
+ task, exists, err := getTaskForArtifactRequest(c, c.Param("key"))
+ if err != nil {
+ writeTaskArtifactError(c, http.StatusInternalServerError, "artifact_internal_error", "Failed to query task")
+ return
+ }
+ if !exists || task == nil {
+ writeTaskArtifactError(c, http.StatusNotFound, "artifact_not_found", "Task or artifact not found")
+ return
+ }
+ artifactKey := strings.TrimSpace(c.Param("artifact_key"))
+ if !taskArtifactKeyPattern.MatchString(artifactKey) {
+ writeTaskArtifactError(c, http.StatusNotFound, "artifact_not_found", "Task or artifact not found")
+ return
+ }
+ if task.Status != model.TaskStatusSuccess {
+ writeTaskArtifactError(c, http.StatusConflict, "artifact_not_ready", "Task artifacts are not ready")
+ return
+ }
+ if !taskHasPluginExecution(task) {
+ if artifactKey != "video" || !legacyVideoAvailable(task) {
+ writeTaskArtifactError(c, http.StatusNotFound, "artifact_not_found", "Task or artifact not found")
+ return
+ }
+ descriptor := &relaychannel.TaskContentRequest{
+ URL: task.GetResultURL(),
+ Method: c.Request.Method,
+ Credentialless: true,
+ }
+ if err := proxyTaskMedia(c, task, descriptor); err != nil {
+ writeTaskMediaProxyError(c, err)
+ }
+ return
+ }
+ artifacts, err := projectTaskArtifacts(task)
+ if err != nil {
+ writeTaskArtifactProjectionError(c, err)
+ return
+ }
+ found := false
+ for _, artifact := range artifacts {
+ if artifact.Key == artifactKey {
+ found = true
+ break
+ }
+ }
+ if !found {
+ writeTaskArtifactError(c, http.StatusNotFound, "artifact_not_found", "Task or artifact not found")
+ return
+ }
+ artifactStore := service.GetTaskArtifactStore()
+ if ref, resolveErr := artifactStore.Resolve(task, artifactKey); resolveErr == nil && ref != nil {
+ _ = artifactStore.Serve(c, task, ref)
+ return
+ }
+
+ adaptor, err := initTaskArtifactAdaptor(task)
+ if err != nil {
+ writeTaskArtifactProjectionError(c, err)
+ return
+ }
+ provider, ok := adaptor.(relaychannel.TaskContentRequestProvider)
+ if !ok {
+ writeTaskArtifactError(c, http.StatusServiceUnavailable, "artifact_plugin_unavailable", "Artifact content plugin is unavailable")
+ return
+ }
+ clientRequest := relaychannel.TaskArtifactClientRequest{
+ Method: c.Request.Method,
+ Headers: taskArtifactClientHeaders(c.Request.Header),
+ }
+ descriptor, err := provider.BuildContentRequest(task, artifactKey, clientRequest)
+ if err != nil || descriptor == nil {
+ writeTaskArtifactError(c, http.StatusInternalServerError, "artifact_plugin_error", "Artifact content plugin failed")
+ return
+ }
+ if err := proxyTaskMedia(c, task, descriptor); err != nil {
+ writeTaskMediaProxyError(c, err)
+ }
+}
+
+func taskArtifactClientHeaders(headers http.Header) map[string]string {
+ result := make(map[string]string, 4)
+ for _, name := range []string{"Range", "If-Range", "If-None-Match", "If-Modified-Since"} {
+ if value := strings.TrimSpace(headers.Get(name)); value != "" {
+ result[name] = value
+ }
+ }
+ return result
+}
+
+/*
+ The task list handlers below deliberately do not call projectTaskArtifacts.
+ Artifact projection is confined to the explicit endpoints above.
+*/
+
func GetAllTask(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
-
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
- // 解析其他查询参数
- queryParams := model.SyncTaskQueryParams{
- Platform: constant.TaskPlatform(c.Query("platform")),
- TaskID: c.Query("task_id"),
- Status: c.Query("status"),
- Action: c.Query("action"),
- StartTimestamp: startTimestamp,
- EndTimestamp: endTimestamp,
- ChannelID: c.Query("channel_id"),
- }
-
+ queryParams := model.SyncTaskQueryParams{Platform: constant.TaskPlatform(c.Query("platform")), TaskID: c.Query("task_id"), Status: c.Query("status"), Action: c.Query("action"), StartTimestamp: startTimestamp, EndTimestamp: endTimestamp, ChannelID: c.Query("channel_id")}
items := model.TaskGetAllTasks(pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
- total := model.TaskCountAllTasks(queryParams)
- pageInfo.SetTotal(int(total))
- pageInfo.SetItems(tasksToDto(items, true))
+ pageInfo.SetTotal(int(model.TaskCountAllTasks(queryParams)))
+ pageInfo.SetItems(tasksToDto(items, true, c.GetInt("role")))
common.ApiSuccess(c, pageInfo)
}
func GetUserTask(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
-
- userId := c.GetInt("id")
-
+ userID := c.GetInt("id")
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
-
- queryParams := model.SyncTaskQueryParams{
- Platform: constant.TaskPlatform(c.Query("platform")),
- TaskID: c.Query("task_id"),
- Status: c.Query("status"),
- Action: c.Query("action"),
- StartTimestamp: startTimestamp,
- EndTimestamp: endTimestamp,
- }
-
- items := model.TaskGetAllUserTask(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
- total := model.TaskCountAllUserTask(userId, queryParams)
- pageInfo.SetTotal(int(total))
- pageInfo.SetItems(tasksToDto(items, false))
+ queryParams := model.SyncTaskQueryParams{Platform: constant.TaskPlatform(c.Query("platform")), TaskID: c.Query("task_id"), Status: c.Query("status"), Action: c.Query("action"), StartTimestamp: startTimestamp, EndTimestamp: endTimestamp}
+ items := model.TaskGetAllUserTask(userID, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
+ pageInfo.SetTotal(int(model.TaskCountAllUserTask(userID, queryParams)))
+ pageInfo.SetItems(tasksToDto(items, false, common.RoleCommonUser))
common.ApiSuccess(c, pageInfo)
}
-func tasksToDto(tasks []*model.Task, fillUser bool) []*dto.TaskDto {
- var userIdMap map[int]*model.UserBase
+func tasksToDto(tasks []*model.Task, fillUser bool, viewerRole int) []*dto.TaskDto {
+ var userIDMap map[int]*model.UserBase
if fillUser {
- userIdMap = make(map[int]*model.UserBase)
- userIds := types.NewSet[int]()
+ userIDMap = make(map[int]*model.UserBase)
+ userIDs := types.NewSet[int]()
for _, task := range tasks {
- userIds.Add(task.UserId)
+ userIDs.Add(task.UserId)
}
- for _, userId := range userIds.Items() {
- cacheUser, err := model.GetUserCache(userId)
- if err == nil {
- userIdMap[userId] = cacheUser
+ for _, userID := range userIDs.Items() {
+ if cacheUser, err := model.GetUserCache(userID); err == nil {
+ userIDMap[userID] = cacheUser
}
}
}
result := make([]*dto.TaskDto, len(tasks))
for i, task := range tasks {
if fillUser {
- if user, ok := userIdMap[task.UserId]; ok {
+ if user, ok := userIDMap[task.UserId]; ok {
task.Username = user.Username
}
}
- result[i] = relay.TaskModel2Dto(task)
+ item := relay.TaskModel2Dto(task)
+ item.LegacyVideoAvailable = legacyVideoAvailable(task)
+ if task.Status == model.TaskStatusSuccess {
+ item.ResultURL = ""
+ if taskFailReasonIsLegacyResultURL(task.FailReason) {
+ item.FailReason = ""
+ }
+ }
+ if viewerRole >= common.RoleAdminUser {
+ adminInfo := &dto.TaskAdminInfo{}
+ if execution := task.PrivateData.Execution; execution != nil {
+ adminInfo.RequestID = execution.RequestID
+ adminInfo.RequestPath = execution.RequestPath
+ if snapshot := execution.TaskPlugin; snapshot != nil {
+ adminInfo.TaskPlugin = &dto.TaskPluginInfo{
+ Key: snapshot.Key,
+ Name: snapshot.Name,
+ Version: snapshot.Version,
+ }
+ if snapshot.Author != nil {
+ adminInfo.TaskPlugin.Author = &dto.TaskPluginAuthorInfo{
+ Name: snapshot.Author.Name,
+ URL: snapshot.Author.URL,
+ }
+ }
+ }
+ }
+ if adminInfo.RequestID != "" || adminInfo.RequestPath != "" || adminInfo.TaskPlugin != nil {
+ item.AdminInfo = adminInfo
+ }
+ }
+ if viewerRole >= common.RoleRootUser {
+ rootInfo := &dto.TaskRootInfo{
+ UpstreamTaskID: task.PrivateData.UpstreamTaskID,
+ NodeName: task.PrivateData.NodeName,
+ }
+ if execution := task.PrivateData.Execution; execution != nil {
+ if snapshot := execution.TaskPlugin; snapshot != nil {
+ rootInfo.TaskPlugin = &dto.TaskPluginRuntimeInfo{
+ Key: snapshot.Key,
+ Version: snapshot.Version,
+ APIVersion: snapshot.APIVersion,
+ Generation: snapshot.Generation,
+ }
+ }
+ }
+ if rootInfo.TaskPlugin != nil || rootInfo.UpstreamTaskID != "" || rootInfo.NodeName != "" {
+ item.RootInfo = rootInfo
+ }
+ }
+ result[i] = item
}
return result
}
+
+func taskFailReasonIsLegacyResultURL(value string) bool {
+ value = strings.TrimSpace(value)
+ return len(value) >= len("https://") && strings.EqualFold(value[:len("https://")], "https://") ||
+ len(value) >= len("http://") && strings.EqualFold(value[:len("http://")], "http://") ||
+ len(value) >= len("data:") && strings.EqualFold(value[:len("data:")], "data:")
+}
diff --git a/controller/task_generic_test.go b/controller/task_generic_test.go
new file mode 100644
index 000000000000..a3160b62ceb9
--- /dev/null
+++ b/controller/task_generic_test.go
@@ -0,0 +1,590 @@
+package controller
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/middleware"
+ "github.com/QuantumNous/new-api/model"
+ relaychannel "github.com/QuantumNous/new-api/relay/channel"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/setting/system_setting"
+ "github.com/gin-gonic/gin"
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+func setupGenericTaskTest(t *testing.T) *model.Task {
+ t.Helper()
+ originalDB := model.DB
+ previousRedisEnabled := common.RedisEnabled
+ common.RedisEnabled = false
+ database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, database.AutoMigrate(&model.Task{}, &model.Channel{}, &model.User{}))
+ model.DB = database
+ t.Cleanup(func() {
+ model.DB = originalDB
+ common.RedisEnabled = previousRedisEnabled
+ })
+
+ require.NoError(t, database.Create(&model.User{
+ Id: 7, Username: "artifact-owner", Status: common.UserStatusEnabled,
+ Role: common.RoleCommonUser, Group: "default",
+ }).Error)
+ baseURL := "https://example.com"
+ require.NoError(t, database.Create(&model.Channel{
+ Id: 1, Name: "artifact", Key: "key", BaseURL: &baseURL, Status: common.ChannelStatusEnabled,
+ }).Error)
+ task := &model.Task{
+ TaskID: "task_generic", Platform: "document", UserId: 7, ChannelId: 1,
+ Status: model.TaskStatusSuccess, Progress: "100%", SubmitTime: 10, FinishTime: 20,
+ }
+ require.NoError(t, database.Create(task).Error)
+ return task
+}
+
+func allowPrivateTaskMediaTest(t *testing.T) {
+ t.Helper()
+ originalFetchSetting := *system_setting.GetFetchSetting()
+ system_setting.GetFetchSetting().EnableSSRFProtection = true
+ system_setting.GetFetchSetting().AllowPrivateIp = true
+ system_setting.GetFetchSetting().AllowedPorts = []string{"1-65535"}
+ t.Cleanup(func() { *system_setting.GetFetchSetting() = originalFetchSetting })
+ service.InitHttpClient()
+}
+
+func TestGetTaskDoesNotProjectArtifacts(t *testing.T) {
+ task := setupGenericTaskTest(t)
+ task.FailReason = "https://stale-upstream.invalid/video.mp4"
+ task.PrivateData = model.TaskPrivateData{
+ ResultURL: "https://private-upstream.invalid/video.mp4",
+ Execution: &model.TaskExecutionSnapshot{
+ TaskPlugin: &model.TaskPluginSnapshot{Key: "missing-plugin", Name: "Missing"},
+ },
+ }
+ require.NoError(t, model.DB.Save(task).Error)
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Set("id", 7)
+ c.Params = gin.Params{{Key: "key", Value: task.TaskID}}
+ c.Request = httptest.NewRequest(http.MethodGet, "/v1/tasks/"+task.TaskID, nil)
+
+ GetTask(c)
+
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ var response map[string]any
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.Equal(t, task.TaskID, response["task_id"])
+ assert.NotContains(t, response, "artifacts")
+ assert.NotContains(t, recorder.Body.String(), "upstream.invalid")
+}
+
+func TestGetTaskArtifactsReturnsEmptyForLegacyTask(t *testing.T) {
+ task := setupGenericTaskTest(t)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Set("id", task.UserId)
+ c.Params = gin.Params{{Key: "key", Value: task.TaskID}}
+ c.Request = httptest.NewRequest(http.MethodGet, "/v1/tasks/"+task.TaskID+"/artifacts", nil)
+
+ GetTaskArtifacts(c)
+
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ assert.Equal(t, "private, no-store", recorder.Header().Get("Cache-Control"))
+ var response struct {
+ TaskID string `json:"task_id"`
+ Artifacts []taskArtifactResponse `json:"artifacts"`
+ }
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.Equal(t, task.TaskID, response.TaskID)
+ assert.Empty(t, response.Artifacts)
+}
+
+func TestTaskArtifactAuthorizationKeepsForeignTasksHidden(t *testing.T) {
+ task := setupGenericTaskTest(t)
+
+ commonUser, _ := gin.CreateTestContext(httptest.NewRecorder())
+ commonUser.Set("id", 8)
+ commonUser.Set("role", common.RoleCommonUser)
+ _, exists, err := getTaskForArtifactRequest(commonUser, task.TaskID)
+ require.NoError(t, err)
+ assert.False(t, exists)
+
+ admin, _ := gin.CreateTestContext(httptest.NewRecorder())
+ admin.Set("id", 8)
+ admin.Set("role", common.RoleAdminUser)
+ found, exists, err := getTaskForArtifactRequest(admin, task.TaskID)
+ require.NoError(t, err)
+ require.True(t, exists)
+ assert.Equal(t, task.TaskID, found.TaskID)
+
+ apiToken, _ := gin.CreateTestContext(httptest.NewRecorder())
+ apiToken.Set("id", 8)
+ apiToken.Set("role", common.RoleRootUser)
+ apiToken.Set("token_id", 99)
+ _, exists, err = getTaskForArtifactRequest(apiToken, task.TaskID)
+ require.NoError(t, err)
+ assert.False(t, exists)
+}
+
+func TestDashboardTaskArtifactsReturnsLegacyCapabilityWithoutUpstreamURL(t *testing.T) {
+ task := setupGenericTaskTest(t)
+ previousSecret := common.CryptoSecret
+ previousPublicAddress := system_setting.TaskPublicAddress
+ common.CryptoSecret = "controller-task-artifact-access-secret"
+ system_setting.TaskPublicAddress = "https://gateway.example/prefix"
+ t.Cleanup(func() {
+ common.CryptoSecret = previousSecret
+ system_setting.TaskPublicAddress = previousPublicAddress
+ })
+ task.Action = constant.TaskActionTextToVideo
+ task.FailReason = "https://upstream.invalid/private-video.mp4?signature=secret"
+ require.NoError(t, model.DB.Save(task).Error)
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Set("id", task.UserId)
+ c.Set("role", common.RoleCommonUser)
+ c.Params = gin.Params{{Key: "task_id", Value: task.TaskID}}
+ c.Request = httptest.NewRequest(http.MethodGet, "/api/task/"+task.TaskID+"/artifacts", nil)
+
+ GetDashboardTaskArtifacts(c)
+
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ assert.Equal(t, "private, no-store", recorder.Header().Get("Cache-Control"))
+ var response struct {
+ Success bool `json:"success"`
+ Data struct {
+ Artifacts []taskArtifactResponse `json:"artifacts"`
+ LegacyContentURL string `json:"legacy_content_url"`
+ } `json:"data"`
+ }
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.True(t, response.Success)
+ assert.Empty(t, response.Data.Artifacts)
+ contentURL, err := url.Parse(response.Data.LegacyContentURL)
+ require.NoError(t, err)
+ assert.Equal(t, "/prefix/v1/tasks/"+task.TaskID+"/artifacts/video/content", contentURL.Path)
+ assert.True(t, service.VerifyTaskArtifactAccess(
+ contentURL.Query().Get(service.TaskArtifactAccessQueryParameter),
+ task.TaskID,
+ "video",
+ ))
+ assert.NotContains(t, recorder.Body.String(), "upstream.invalid")
+ assert.NotContains(t, recorder.Body.String(), "signature=secret")
+}
+
+func TestTaskArtifactAccessRequiresActiveOwner(t *testing.T) {
+ task := setupGenericTaskTest(t)
+ task.Action = constant.TaskActionTextToVideo
+ task.FailReason = "https://upstream.invalid/private-video.mp4"
+ require.NoError(t, model.DB.Save(task).Error)
+ require.NoError(t, model.DB.Model(&model.User{}).
+ Where("id = ?", task.UserId).
+ Update("status", common.UserStatusDisabled).Error)
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Set(middleware.TaskArtifactAccessContextKey, true)
+ c.Params = gin.Params{
+ {Key: "key", Value: task.TaskID},
+ {Key: "artifact_key", Value: "video"},
+ }
+ c.Request = httptest.NewRequest(
+ http.MethodGet,
+ "/v1/tasks/"+task.TaskID+"/artifacts/video/content",
+ nil,
+ )
+
+ TaskArtifactContent(c)
+
+ assert.Equal(t, http.StatusNotFound, recorder.Code)
+ assert.Equal(t, "private, no-store", recorder.Header().Get("Cache-Control"))
+}
+
+func TestTaskArtifactAccessRejectsAmbiguousHistoricalTaskID(t *testing.T) {
+ task := setupGenericTaskTest(t)
+ task.Action = constant.TaskActionTextToVideo
+ task.FailReason = "https://first-upstream.invalid/video.mp4"
+ require.NoError(t, model.DB.Save(task).Error)
+ require.NoError(t, model.DB.Create(&model.User{
+ Id: 8, Username: "other-artifact-owner", Status: common.UserStatusEnabled,
+ Role: common.RoleCommonUser, Group: "default", AffCode: "artifact-owner-8",
+ }).Error)
+ require.NoError(t, model.DB.Create(&model.Task{
+ TaskID: task.TaskID, Platform: task.Platform, UserId: 8, ChannelId: task.ChannelId,
+ Action: constant.TaskActionTextToVideo, Status: model.TaskStatusSuccess,
+ FailReason: "https://second-upstream.invalid/video.mp4",
+ }).Error)
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Set(middleware.TaskArtifactAccessContextKey, true)
+ c.Params = gin.Params{
+ {Key: "key", Value: task.TaskID},
+ {Key: "artifact_key", Value: "video"},
+ }
+ c.Request = httptest.NewRequest(
+ http.MethodGet,
+ "/v1/tasks/"+task.TaskID+"/artifacts/video/content",
+ nil,
+ )
+
+ TaskArtifactContent(c)
+
+ assert.Equal(t, http.StatusNotFound, recorder.Code)
+ assert.NotContains(t, recorder.Body.String(), "upstream.invalid")
+}
+
+func TestLegacyVideoArtifactContentUsesGetResultURL(t *testing.T) {
+ task := setupGenericTaskTest(t)
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "bytes=0-3", r.Header.Get("Range"))
+ w.Header().Set("Content-Type", "video/mp4")
+ w.Header().Set("Content-Range", "bytes 0-3/4")
+ w.WriteHeader(http.StatusPartialContent)
+ _, _ = w.Write([]byte("data"))
+ }))
+ defer upstream.Close()
+ allowPrivateTaskMediaTest(t)
+
+ task.Action = constant.TaskActionTextToVideo
+ task.PrivateData.ResultURL = upstream.URL
+ task.FailReason = "https://stale.invalid/legacy-fallback.mp4"
+ require.NoError(t, model.DB.Save(task).Error)
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Set(middleware.TaskArtifactAccessContextKey, true)
+ c.Params = gin.Params{
+ {Key: "key", Value: task.TaskID},
+ {Key: "artifact_key", Value: "video"},
+ }
+ c.Request = httptest.NewRequest(
+ http.MethodGet,
+ "/v1/tasks/"+task.TaskID+"/artifacts/video/content",
+ nil,
+ )
+ c.Request.Header.Set("Range", "bytes=0-3")
+
+ TaskArtifactContent(c)
+
+ assert.Equal(t, http.StatusPartialContent, recorder.Code)
+ assert.Equal(t, "data", recorder.Body.String())
+ assert.Equal(t, "bytes 0-3/4", recorder.Header().Get("Content-Range"))
+}
+
+func TestDisabledArtifactStorePreservesPluginUpstreamContent(t *testing.T) {
+ task := setupGenericTaskTest(t)
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "provider-key", r.Header.Get("x-goog-api-key"))
+ assert.Equal(t, "bytes=0-13", r.Header.Get("Range"))
+ w.Header().Set("Content-Type", "video/mp4")
+ w.Header().Set("Content-Range", "bytes 0-13/14")
+ w.WriteHeader(http.StatusPartialContent)
+ _, _ = w.Write([]byte("artifact-bytes"))
+ }))
+ defer upstream.Close()
+ allowPrivateTaskMediaTest(t)
+ previousMemoryCache := common.MemoryCacheEnabled
+ common.MemoryCacheEnabled = false
+ t.Cleanup(func() { common.MemoryCacheEnabled = previousMemoryCache })
+
+ require.NoError(t, model.DB.Model(&model.Channel{}).Where("id = ?", task.ChannelId).Updates(map[string]any{
+ "type": constant.ChannelTypeGemini,
+ "key": "provider-key",
+ "base_url": upstream.URL,
+ }).Error)
+ task.Platform = constant.TaskPlatform("google")
+ task.PrivateData.Execution = &model.TaskExecutionSnapshot{TaskPlugin: &model.TaskPluginSnapshot{
+ Key: "google", Name: "Google Veo (Gemini API)", Version: "1.0.0", APIVersion: 1,
+ }}
+ task.SetData(map[string]any{"response": map[string]any{
+ "generateVideoResponse": map[string]any{
+ "generatedVideos": []any{map[string]any{"video": map[string]any{"uri": upstream.URL}}},
+ },
+ }})
+ require.NoError(t, model.DB.Save(task).Error)
+ require.False(t, service.GetTaskArtifactStore().Enabled())
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Set(middleware.TaskArtifactAccessContextKey, true)
+ c.Params = gin.Params{
+ {Key: "key", Value: task.TaskID},
+ {Key: "artifact_key", Value: "video"},
+ }
+ c.Request = httptest.NewRequest(http.MethodGet, "/v1/tasks/"+task.TaskID+"/artifacts/video/content", nil)
+ c.Request.Header.Set("Range", "bytes=0-13")
+
+ TaskArtifactContent(c)
+
+ assert.Equal(t, http.StatusPartialContent, recorder.Code)
+ assert.Equal(t, "artifact-bytes", recorder.Body.String())
+ assert.Equal(t, "video/mp4", recorder.Header().Get("Content-Type"))
+ assert.Equal(t, "bytes 0-13/14", recorder.Header().Get("Content-Range"))
+}
+
+func TestProjectedTaskArtifactValidationRejectsAmbiguousIdentity(t *testing.T) {
+ validated, err := validateProjectedTaskArtifacts([]relaychannel.TaskArtifact{
+ {Key: "video-main", Type: "video", MimeType: "video/mp4"},
+ {Key: "cover.main", Type: "image", MimeType: "image/png"},
+ })
+ require.NoError(t, err)
+ require.Len(t, validated, 2)
+ assert.Equal(t, "video-main", validated[0].Key)
+
+ for _, artifacts := range [][]relaychannel.TaskArtifact{
+ {{Key: "../video", Type: "video"}},
+ {{Key: "video/0", Type: "video"}},
+ {{Key: "video-main", Type: "video"}, {Key: "video-main", Type: "image"}},
+ {{Key: "video-main", Type: "unknown"}},
+ {{Key: "video-main", Type: "video", MimeType: "video/mp4\r\nX-Test: injected"}},
+ } {
+ _, err := validateProjectedTaskArtifacts(artifacts)
+ assert.ErrorIs(t, err, errTaskArtifactPlugin)
+ }
+}
+
+func TestProxyTaskMediaForwardsRangeAndFiltersResponseHeaders(t *testing.T) {
+ task := setupGenericTaskTest(t)
+ var receivedRange, receivedAuthorization string
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ receivedRange = r.Header.Get("Range")
+ receivedAuthorization = r.Header.Get("Authorization")
+ w.Header().Set("Content-Type", "video/mp4")
+ w.Header().Set("Content-Range", "bytes 0-3/10")
+ w.Header().Set("Accept-Ranges", "bytes")
+ w.Header().Set("Set-Cookie", "provider=secret")
+ w.Header().Set("WWW-Authenticate", "Bearer provider")
+ w.Header().Set("X-Provider-Secret", "hidden")
+ w.Header().Set("Cache-Control", "public, max-age=86400")
+ w.WriteHeader(http.StatusPartialContent)
+ _, _ = w.Write([]byte("data"))
+ }))
+ defer upstream.Close()
+
+ allowPrivateTaskMediaTest(t)
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodGet, "/v1/tasks/task_generic/artifacts/video-main/content", nil)
+ c.Request.Header.Set("Range", "bytes=0-3")
+
+ err := proxyTaskMedia(c, task, &relaychannel.TaskContentRequest{
+ URL: upstream.URL,
+ Method: http.MethodGet,
+ Headers: map[string]string{"Authorization": "Bearer provider-secret"},
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusPartialContent, recorder.Code)
+ assert.Equal(t, "data", recorder.Body.String())
+ assert.Equal(t, "bytes=0-3", receivedRange)
+ assert.Equal(t, "Bearer provider-secret", receivedAuthorization)
+ assert.Equal(t, "bytes 0-3/10", recorder.Header().Get("Content-Range"))
+ assert.Equal(t, "bytes", recorder.Header().Get("Accept-Ranges"))
+ assert.Equal(t, "private, no-store", recorder.Header().Get("Cache-Control"))
+ assert.Equal(t, "sandbox; default-src 'none'", recorder.Header().Get("Content-Security-Policy"))
+ assert.Equal(t, "no-referrer", recorder.Header().Get("Referrer-Policy"))
+ assert.Equal(t, "nosniff", recorder.Header().Get("X-Content-Type-Options"))
+ assert.Empty(t, recorder.Header().Get("Set-Cookie"))
+ assert.Empty(t, recorder.Header().Get("WWW-Authenticate"))
+ assert.Empty(t, recorder.Header().Get("X-Provider-Secret"))
+}
+
+func TestProxyTaskMediaPassesThroughUnsatisfiedRange(t *testing.T) {
+ task := setupGenericTaskTest(t)
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Range", "bytes */10")
+ w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
+ }))
+ defer upstream.Close()
+
+ allowPrivateTaskMediaTest(t)
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodGet, "/content", nil)
+
+ require.NoError(t, proxyTaskMedia(c, task, &relaychannel.TaskContentRequest{
+ URL: upstream.URL, Method: http.MethodGet,
+ }))
+ assert.Equal(t, http.StatusRequestedRangeNotSatisfiable, recorder.Code)
+ assert.Equal(t, "bytes */10", recorder.Header().Get("Content-Range"))
+ assert.Equal(t, "private, no-store", recorder.Header().Get("Cache-Control"))
+}
+
+func TestTaskMediaResponseHeaderTimeoutDoesNotTruncateBody(t *testing.T) {
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "video/mp4")
+ w.WriteHeader(http.StatusOK)
+ w.(http.Flusher).Flush()
+ time.Sleep(75 * time.Millisecond)
+ _, _ = w.Write([]byte("complete-body"))
+ }))
+ defer upstream.Close()
+
+ request, err := http.NewRequest(http.MethodGet, upstream.URL, nil)
+ require.NoError(t, err)
+ response, err := doTaskMediaRequest(upstream.Client(), request, 20*time.Millisecond)
+ require.NoError(t, err)
+ defer response.Body.Close()
+ body, err := io.ReadAll(response.Body)
+ require.NoError(t, err)
+ assert.Equal(t, "complete-body", string(body))
+}
+
+func TestTaskMediaResponseHeaderTimeoutCancelsBeforeHeaders(t *testing.T) {
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ time.Sleep(75 * time.Millisecond)
+ _, _ = w.Write([]byte("late"))
+ }))
+ defer upstream.Close()
+
+ request, err := http.NewRequest(http.MethodGet, upstream.URL, nil)
+ require.NoError(t, err)
+ _, err = doTaskMediaRequest(upstream.Client(), request, 10*time.Millisecond)
+ assert.ErrorIs(t, err, context.DeadlineExceeded)
+}
+
+func TestWriteVideoDataURLStreamsAndSupportsHead(t *testing.T) {
+ const dataURL = "data:video/mp4;base64,Y29tcGxldGUtYm9keQ=="
+
+ getRecorder := httptest.NewRecorder()
+ getContext, _ := gin.CreateTestContext(getRecorder)
+ getContext.Request = httptest.NewRequest(http.MethodGet, "/content", nil)
+ require.NoError(t, writeVideoDataURL(getContext, dataURL))
+ assert.Equal(t, http.StatusOK, getRecorder.Code)
+ assert.Equal(t, "complete-body", getRecorder.Body.String())
+ assert.Equal(t, "13", getRecorder.Header().Get("Content-Length"))
+
+ headRecorder := httptest.NewRecorder()
+ headContext, _ := gin.CreateTestContext(headRecorder)
+ headContext.Request = httptest.NewRequest(http.MethodHead, "/content", nil)
+ require.NoError(t, writeVideoDataURL(headContext, dataURL))
+ assert.Equal(t, http.StatusOK, headRecorder.Code)
+ assert.Empty(t, headRecorder.Body.String())
+ assert.Equal(t, "13", headRecorder.Header().Get("Content-Length"))
+}
+
+func TestWriteVideoDataURLRejectsOversizedPayloadBeforeDecode(t *testing.T) {
+ previousLimit := taskMediaDataURLMaxEncodedBytes
+ taskMediaDataURLMaxEncodedBytes = 32
+ t.Cleanup(func() { taskMediaDataURLMaxEncodedBytes = previousLimit })
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodGet, "/content", nil)
+
+ err := writeVideoDataURL(c, "data:video/mp4;base64,"+strings.Repeat("A", 64))
+
+ assert.ErrorIs(t, err, errTaskMediaRequestRejected)
+ assert.Empty(t, recorder.Header().Get("Content-Type"))
+}
+
+func TestProxyTaskMediaAllowsOnlyCredentiallessCrossOriginRedirect(t *testing.T) {
+ task := setupGenericTaskTest(t)
+ var destinationAuthorization, destinationRange string
+ destination := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ destinationAuthorization = r.Header.Get("Authorization")
+ destinationRange = r.Header.Get("Range")
+ _, _ = w.Write([]byte("redirected"))
+ }))
+ defer destination.Close()
+ source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, destination.URL, http.StatusFound)
+ }))
+ defer source.Close()
+ allowPrivateTaskMediaTest(t)
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodGet, "/content", nil)
+ c.Request.Header.Set("Range", "bytes=0-3")
+
+ err := proxyTaskMedia(c, task, &relaychannel.TaskContentRequest{
+ URL: source.URL, Method: http.MethodGet, Credentialless: true,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ assert.Equal(t, "redirected", recorder.Body.String())
+ assert.Empty(t, destinationAuthorization)
+ assert.Equal(t, "bytes=0-3", destinationRange)
+
+ destinationRange = ""
+ rejectedRecorder := httptest.NewRecorder()
+ rejectedContext, _ := gin.CreateTestContext(rejectedRecorder)
+ rejectedContext.Request = httptest.NewRequest(http.MethodGet, "/content", nil)
+ err = proxyTaskMedia(rejectedContext, task, &relaychannel.TaskContentRequest{
+ URL: source.URL, Method: http.MethodGet,
+ Headers: map[string]string{"Authorization": "Bearer provider-secret"},
+ })
+ var proxyErr *taskMediaProxyError
+ require.ErrorAs(t, err, &proxyErr)
+ assert.Equal(t, "artifact_request_rejected", proxyErr.code)
+ assert.Empty(t, destinationRange)
+}
+
+func TestTaskMediaRequestHeaderPolicy(t *testing.T) {
+ header := http.Header{}
+ require.NoError(t, applyTaskMediaRequestHeaders(header, map[string]string{
+ "Authorization": "Bearer provider-secret",
+ "X-Signature": "signed",
+ }))
+ assert.Equal(t, "Bearer provider-secret", header.Get("Authorization"))
+ assert.Equal(t, "signed", header.Get("X-Signature"))
+
+ for _, name := range []string{"Host", "Content-Length", "Accept-Encoding", "Connection", "Proxy-Authorization", "Transfer-Encoding"} {
+ t.Run(name, func(t *testing.T) {
+ assert.ErrorIs(t, applyTaskMediaRequestHeaders(http.Header{}, map[string]string{name: "bad"}), errTaskMediaRequestRejected)
+ })
+ }
+ assert.ErrorIs(t, applyTaskMediaRequestHeaders(http.Header{}, map[string]string{"X-Test": "bad\r\ninjected"}), errTaskMediaRequestRejected)
+}
+
+func TestCredentiallessTaskMediaDescriptorRejectsCredentialsAndBody(t *testing.T) {
+ task := setupGenericTaskTest(t)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodGet, "/content", nil)
+
+ for _, descriptor := range []*relaychannel.TaskContentRequest{
+ {URL: "https://example.com/video", Method: http.MethodPost, Credentialless: true},
+ {URL: "https://example.com/video", Method: http.MethodGet, Body: []byte("secret"), Credentialless: true},
+ {URL: "https://example.com/video", Method: http.MethodGet, Headers: map[string]string{"X-Key": "secret"}, Credentialless: true},
+ } {
+ err := proxyTaskMedia(c, task, descriptor)
+ var proxyErr *taskMediaProxyError
+ require.ErrorAs(t, err, &proxyErr)
+ assert.Equal(t, "artifact_request_rejected", proxyErr.code)
+ }
+}
+
+func TestSelfTaskMediaURLGuard(t *testing.T) {
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodGet, "https://gateway.example/v1/videos/task-1/content", nil)
+ c.Request.Host = "gateway.example"
+
+ selfURL, err := url.Parse("https://gateway.example/v1/videos/task-1/content")
+ require.NoError(t, err)
+ assert.True(t, isSelfTaskMediaURL(c, selfURL))
+
+ remoteURL, err := url.Parse("https://cdn.example/v1/videos/task-1/content")
+ require.NoError(t, err)
+ assert.False(t, isSelfTaskMediaURL(c, remoteURL))
+ assert.True(t, isTaskMediaFallbackLoop(remoteURL.String(), "task-1"))
+ assert.False(t, isTaskMediaFallbackLoop(remoteURL.String(), "task-2"))
+}
diff --git a/controller/task_log_view_test.go b/controller/task_log_view_test.go
new file mode 100644
index 000000000000..b9ab2480c2ea
--- /dev/null
+++ b/controller/task_log_view_test.go
@@ -0,0 +1,137 @@
+package controller
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTaskLogDTOSeparatesUserAdminAndRootDetails(t *testing.T) {
+ task := &model.Task{
+ TaskID: "task_public",
+ Platform: "document-parser",
+ PrivateData: model.TaskPrivateData{
+ Key: "channel-secret-canary",
+ UpstreamTaskID: "upstream-private",
+ NodeName: "node-a",
+ Execution: &model.TaskExecutionSnapshot{
+ RequestID: "request-public",
+ RequestPath: "/v1/documents",
+ TaskPlugin: &model.TaskPluginSnapshot{
+ Key: "document-parser",
+ Name: "Document Parser",
+ Version: "1.2.3",
+ Author: &model.TaskPluginAuthorSnapshot{
+ Name: "Community Author",
+ URL: "https://plugins.example/author",
+ },
+ APIVersion: 1,
+ Generation: 42,
+ },
+ },
+ },
+ }
+
+ userView := tasksToDto([]*model.Task{task}, false, common.RoleCommonUser)[0]
+ assert.Nil(t, userView.AdminInfo)
+ assert.Nil(t, userView.RootInfo)
+
+ adminView := tasksToDto([]*model.Task{task}, false, common.RoleAdminUser)[0]
+ require.NotNil(t, adminView.AdminInfo)
+ require.NotNil(t, adminView.AdminInfo.TaskPlugin)
+ assert.Equal(t, "document-parser", adminView.AdminInfo.TaskPlugin.Key)
+ assert.Equal(t, "Document Parser", adminView.AdminInfo.TaskPlugin.Name)
+ assert.Equal(t, "1.2.3", adminView.AdminInfo.TaskPlugin.Version)
+ require.NotNil(t, adminView.AdminInfo.TaskPlugin.Author)
+ assert.Equal(t, "Community Author", adminView.AdminInfo.TaskPlugin.Author.Name)
+ assert.Equal(t, "https://plugins.example/author", adminView.AdminInfo.TaskPlugin.Author.URL)
+ assert.Equal(t, "request-public", adminView.AdminInfo.RequestID)
+ assert.Equal(t, "/v1/documents", adminView.AdminInfo.RequestPath)
+ assert.Nil(t, adminView.RootInfo)
+
+ rootView := tasksToDto([]*model.Task{task}, false, common.RoleRootUser)[0]
+ require.NotNil(t, rootView.AdminInfo)
+ require.NotNil(t, rootView.RootInfo)
+ require.NotNil(t, rootView.RootInfo.TaskPlugin)
+ assert.Equal(t, 1, rootView.RootInfo.TaskPlugin.APIVersion)
+ assert.Equal(t, uint64(42), rootView.RootInfo.TaskPlugin.Generation)
+ assert.Equal(t, "upstream-private", rootView.RootInfo.UpstreamTaskID)
+ assert.Equal(t, "node-a", rootView.RootInfo.NodeName)
+
+ adminJSON, err := common.Marshal(adminView)
+ require.NoError(t, err)
+ assert.NotContains(t, string(adminJSON), "channel-secret-canary")
+ assert.NotContains(t, string(adminJSON), "upstream-private")
+
+ rootJSON, err := common.Marshal(rootView)
+ require.NoError(t, err)
+ assert.NotContains(t, string(rootJSON), "channel-secret-canary")
+ assert.Contains(t, string(rootJSON), "upstream-private")
+}
+
+func TestTaskLogDTODoesNotInventHistoricalPluginProvenance(t *testing.T) {
+ task := &model.Task{
+ TaskID: "task_without_snapshot",
+ Platform: "document-parser",
+ }
+
+ adminView := tasksToDto([]*model.Task{task}, false, common.RoleAdminUser)[0]
+
+ assert.Nil(t, adminView.AdminInfo)
+ assert.Nil(t, adminView.RootInfo)
+}
+
+func TestTaskLogDTOReplacesLegacyVideoURLWithAvailabilityFlag(t *testing.T) {
+ task := &model.Task{
+ TaskID: "task_legacy_video",
+ Platform: "jimeng",
+ Action: constant.TaskActionTextToVideo,
+ Status: model.TaskStatusSuccess,
+ FailReason: "https://private-upstream.invalid/video.mp4?signature=secret",
+ }
+
+ view := tasksToDto([]*model.Task{task}, false, common.RoleCommonUser)[0]
+ assert.True(t, view.LegacyVideoAvailable)
+ assert.Empty(t, view.ResultURL)
+ assert.Empty(t, view.FailReason)
+ encoded, err := common.Marshal(view)
+ require.NoError(t, err)
+ assert.NotContains(t, string(encoded), "private-upstream.invalid")
+ assert.NotContains(t, string(encoded), "result_url")
+ assert.Contains(t, string(encoded), "legacy_video_available")
+}
+
+func TestTaskLogDTOKeepsFailureReasonAndDoesNotMarkPluginTaskLegacy(t *testing.T) {
+ failed := &model.Task{
+ TaskID: "task_failed",
+ Platform: "jimeng",
+ Action: constant.TaskActionTextToVideo,
+ Status: model.TaskStatusFailure,
+ FailReason: "provider rejected the request",
+ }
+ failedView := tasksToDto([]*model.Task{failed}, false, common.RoleCommonUser)[0]
+ assert.Equal(t, "provider rejected the request", failedView.FailReason)
+ assert.False(t, failedView.LegacyVideoAvailable)
+
+ pluginTask := &model.Task{
+ TaskID: "task_plugin_video",
+ Platform: "community-video",
+ Action: constant.TaskActionTextToVideo,
+ Status: model.TaskStatusSuccess,
+ FailReason: "https://stale-upstream.invalid/plugin-video.mp4",
+ PrivateData: model.TaskPrivateData{
+ ResultURL: "https://private-upstream.invalid/plugin-video.mp4",
+ Execution: &model.TaskExecutionSnapshot{
+ TaskPlugin: &model.TaskPluginSnapshot{Key: "community-video"},
+ },
+ },
+ }
+ pluginView := tasksToDto([]*model.Task{pluginTask}, false, common.RoleCommonUser)[0]
+ assert.False(t, pluginView.LegacyVideoAvailable)
+ assert.Empty(t, pluginView.ResultURL)
+ assert.Empty(t, pluginView.FailReason)
+}
diff --git a/controller/task_plugin.go b/controller/task_plugin.go
new file mode 100644
index 000000000000..d88f44114bef
--- /dev/null
+++ b/controller/task_plugin.go
@@ -0,0 +1,761 @@
+package controller
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/url"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/logger"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/QuantumNous/new-api/plugins"
+ "github.com/QuantumNous/new-api/setting"
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+const maxTaskPluginSourceBytes = 1024 * 1024
+
+type taskPluginUploadRequest struct {
+ Source string `json:"source" binding:"required"`
+ Enabled *bool `json:"enabled"`
+ Remark string `json:"remark"`
+ Force bool `json:"force"`
+ SourceSha256 string `json:"sourceSha256"`
+}
+
+func UploadTaskPlugin(c *gin.Context) {
+ var request taskPluginUploadRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ common.ApiErrorMsg(c, err.Error())
+ return
+ }
+ if len(request.Source) > maxTaskPluginSourceBytes {
+ common.ApiErrorMsg(c, "plugin source exceeds 1 MiB")
+ return
+ }
+ if expected := strings.TrimSpace(request.SourceSha256); expected != "" {
+ actual := fmt.Sprintf("%x", sha256.Sum256([]byte(request.Source)))
+ if !strings.EqualFold(actual, expected) {
+ common.ApiErrorMsg(c, "plugin source sha256 mismatch")
+ return
+ }
+ }
+ temporary := jsplugin.NewRegistry()
+ loaded, err := temporary.Register(request.Source, jsplugin.Options{})
+ if err != nil {
+ common.ApiErrorMsg(c, err.Error())
+ return
+ }
+ if err = jsplugin.ValidateV1Meta(loaded.Meta); err != nil {
+ common.ApiErrorMsg(c, err.Error())
+ return
+ }
+ enabled := true
+ if request.Enabled != nil {
+ enabled = *request.Enabled
+ }
+ if enabled && !request.Force {
+ if err = jsplugin.PreflightRoutingConflict(jsplugin.DefaultRegistry.Generation(), loaded); err != nil {
+ common.ApiErrorMsg(c, err.Error())
+ return
+ }
+ }
+ plugin := model.TaskPlugin{
+ Key: loaded.Meta.Key, APIVersion: loaded.Meta.APIVersion, Version: loaded.Meta.Version,
+ Source: request.Source, SourceHash: fmt.Sprintf("%x", sha256.Sum256([]byte(request.Source))),
+ Enabled: enabled, Remark: request.Remark,
+ }
+ if err = model.SaveTaskPlugin(&plugin); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ if err = syncTaskPluginsOnceContext(c.Request.Context()); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, taskPluginDetail{Plugin: &plugin, Meta: loaded.Meta, Source: plugin.Source, Layer: "override"})
+}
+
+func GetTaskPluginVersions(c *gin.Context) {
+ plugins, err := model.ListTaskPluginVersions(c.Param("key"))
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, plugins)
+}
+
+type taskPluginListItem struct {
+ Meta jsplugin.Meta `json:"meta"`
+ Source string `json:"source"`
+ Enabled bool `json:"enabled"`
+ Active bool `json:"active"`
+ SourceHash string `json:"source_hash"`
+ Remark string `json:"remark"`
+ RuntimeStatus string `json:"runtime_status"`
+ RuntimeError string `json:"runtime_error,omitempty"`
+ FactoryMeta *jsplugin.Meta `json:"factory_meta,omitempty"`
+ ChannelCount int `json:"channel_count"`
+ InFlightCount int64 `json:"in_flight_count"`
+}
+
+type taskPluginRebuildOutcome struct {
+ Status string `json:"status"`
+ AttemptedAt time.Time `json:"attempted_at"`
+ Generation uint64 `json:"generation"`
+ DatabaseRevision string `json:"database_revision,omitempty"`
+ PluginErrorCount int `json:"plugin_error_count"`
+ Error string `json:"error,omitempty"`
+}
+
+type taskPluginRuntimeStatus struct {
+ CurrentGeneration uint64 `json:"current_generation"`
+ GenerationPublishedAt time.Time `json:"generation_published_at"`
+ DatabaseRevision string `json:"database_revision"`
+ DatabaseError string `json:"database_error,omitempty"`
+ LastRebuild taskPluginRebuildOutcome `json:"last_rebuild"`
+ PluginErrors map[string]string `json:"plugin_errors"`
+}
+
+func ListTaskPlugins(c *gin.Context) {
+ databasePlugins, err := model.ListTaskPlugins()
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ snapshot := jsplugin.DefaultRegistry.Snapshot()
+ factory := make(map[string]jsplugin.Meta, len(snapshot.Factory))
+ override := make(map[string]jsplugin.Meta, len(snapshot.Override))
+ for _, meta := range snapshot.Factory {
+ factory[meta.Key] = meta
+ }
+ for _, meta := range snapshot.Override {
+ override[meta.Key] = meta
+ }
+ activeRows := make(map[string]model.TaskPlugin)
+ keys := make(map[string]struct{}, len(factory)+len(databasePlugins))
+ for key := range factory {
+ keys[key] = struct{}{}
+ }
+ for _, plugin := range databasePlugins {
+ keys[plugin.Key] = struct{}{}
+ if plugin.Active {
+ activeRows[plugin.Key] = plugin
+ }
+ }
+
+ runtimeErrors := jsplugin.DefaultRegistry.RoutingErrors()
+ taskPluginSyncState.Lock()
+ for key, message := range taskPluginSyncState.errors {
+ runtimeErrors[key] = message
+ }
+ taskPluginSyncState.Unlock()
+
+ items := make([]taskPluginListItem, 0, len(keys))
+ for key := range keys {
+ factoryMeta, hasFactory := factory[key]
+ row, hasOverride := activeRows[key]
+ item := taskPluginListItem{Enabled: true, Active: true, RuntimeStatus: "registered"}
+ if hasOverride {
+ item.Source = "override"
+ if hasFactory {
+ item.Source = "override_over_factory"
+ factoryCopy := factoryMeta
+ item.FactoryMeta = &factoryCopy
+ }
+ item.Meta = jsplugin.Meta{Key: row.Key, Version: row.Version, APIVersion: row.APIVersion}
+ if compiled, compileErr := jsplugin.NewRegistry().Register(row.Source, jsplugin.Options{Key: row.Key, Version: row.Version}); compileErr == nil {
+ item.Meta = compiled.Meta
+ }
+ item.Enabled = row.Enabled
+ item.Active = row.Active
+ item.SourceHash = row.SourceHash
+ item.Remark = row.Remark
+ if !constant.TaskPluginOverrideEnabled {
+ item.RuntimeStatus = "disabled_fallback"
+ } else if message := runtimeErrors[key]; message != "" {
+ item.RuntimeStatus = "compile_failed"
+ item.RuntimeError = message
+ } else if runtimeMeta, ok := override[key]; ok {
+ item.Meta = runtimeMeta
+ } else if !row.Enabled {
+ item.RuntimeStatus = "disabled_fallback"
+ } else {
+ item.RuntimeStatus = "not_registered"
+ }
+ } else {
+ item.Source = "factory"
+ item.Meta = factoryMeta
+ item.Enabled = !setting.IsTaskPluginFactoryDisabled(key)
+ source, sourceErr := plugins.Source(key)
+ if sourceErr == nil {
+ item.SourceHash = fmt.Sprintf("%x", sha256.Sum256([]byte(source)))
+ }
+ if !item.Enabled {
+ item.RuntimeStatus = "disabled"
+ } else if message := runtimeErrors[key]; message != "" {
+ item.RuntimeStatus = "compile_failed"
+ item.RuntimeError = message
+ }
+ }
+ if !hasFactory {
+ channels, inFlight, usageErr := model.GetTaskPluginUsage(key)
+ if usageErr != nil {
+ common.ApiError(c, usageErr)
+ return
+ }
+ item.ChannelCount = len(channels)
+ item.InFlightCount = inFlight
+ }
+ items = append(items, item)
+ }
+ sort.Slice(items, func(i, j int) bool { return items[i].Meta.Key < items[j].Meta.Key })
+ common.ApiSuccess(c, items)
+}
+
+func GetTaskPluginRuntime(c *gin.Context) {
+ routingStatus := jsplugin.DefaultRegistry.RoutingStatus()
+ pluginErrors := routingStatus.Errors
+
+ taskPluginSyncState.Lock()
+ for key, message := range taskPluginSyncState.errors {
+ pluginErrors[key] = message
+ }
+ lastRebuild := taskPluginSyncState.lastRebuild
+ lastDatabaseRevision := lastRebuild.DatabaseRevision
+ taskPluginSyncState.Unlock()
+
+ registryRebuild := routingStatus.LastRebuild
+ if lastRebuild.AttemptedAt.Before(registryRebuild.AttemptedAt) {
+ lastRebuild = taskPluginRebuildOutcome{
+ Status: registryRebuild.Status,
+ AttemptedAt: registryRebuild.AttemptedAt,
+ Generation: registryRebuild.Generation,
+ Error: registryRebuild.Error,
+ }
+ }
+ if lastRebuild.Status == "" {
+ lastRebuild.Status = "never"
+ }
+ lastRebuild.PluginErrorCount = len(pluginErrors)
+ if lastRebuild.Status == "success" && len(pluginErrors) > 0 {
+ lastRebuild.Status = "partial"
+ }
+
+ status := taskPluginRuntimeStatus{
+ DatabaseRevision: lastDatabaseRevision,
+ LastRebuild: lastRebuild,
+ PluginErrors: pluginErrors,
+ }
+ databaseSnapshot, err := model.GetTaskPluginSyncSnapshot()
+ if err != nil {
+ status.DatabaseError = "database snapshot unavailable"
+ } else {
+ status.DatabaseRevision = databaseSnapshot.Revision
+ }
+ if routingStatus.Generation != nil {
+ status.CurrentGeneration = routingStatus.Generation.Number
+ status.GenerationPublishedAt = routingStatus.Generation.PublishedAt
+ }
+ common.ApiSuccess(c, status)
+}
+
+type taskPluginDetail struct {
+ Plugin *model.TaskPlugin `json:"plugin,omitempty"`
+ Meta jsplugin.Meta `json:"meta"`
+ Source string `json:"source"`
+ Layer string `json:"layer"`
+}
+
+func GetTaskPlugin(c *gin.Context) {
+ key := c.Param("key")
+ version := c.Query("version")
+ plugin, err := model.GetTaskPluginVersion(key, version)
+ if err == nil {
+ loaded, compileErr := jsplugin.NewRegistry().Register(plugin.Source, jsplugin.Options{Key: plugin.Key, Version: plugin.Version})
+ if compileErr != nil {
+ common.ApiErrorMsg(c, compileErr.Error())
+ return
+ }
+ common.ApiSuccess(c, taskPluginDetail{Plugin: plugin, Meta: loaded.Meta, Source: plugin.Source, Layer: "override"})
+ return
+ }
+ if !errors.Is(err, gorm.ErrRecordNotFound) || version != "" {
+ common.ApiError(c, err)
+ return
+ }
+ source, err := plugins.Source(key)
+ if err != nil {
+ common.ApiErrorMsg(c, "task plugin not found")
+ return
+ }
+ loaded, err := jsplugin.NewRegistry().RegisterFactory(source, jsplugin.Options{Key: key})
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, taskPluginDetail{Meta: loaded.Meta, Source: source, Layer: "factory"})
+}
+
+type taskPluginDryRunRequest struct {
+ Hook string `json:"hook" binding:"required"`
+ Member string `json:"member"`
+ Args []json.RawMessage `json:"args"`
+}
+
+func DryRunTaskPlugin(c *gin.Context) {
+ var request taskPluginDryRunRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ common.ApiErrorMsg(c, err.Error())
+ return
+ }
+ detailSource := ""
+ plugin, err := model.GetTaskPluginVersion(c.Param("key"), "")
+ if err == nil {
+ detailSource = plugin.Source
+ } else if errors.Is(err, gorm.ErrRecordNotFound) {
+ detailSource, err = plugins.Source(c.Param("key"))
+ }
+ if err != nil {
+ common.ApiErrorMsg(c, "task plugin not found")
+ return
+ }
+ loaded, err := jsplugin.NewRegistry().Register(detailSource, jsplugin.Options{Key: c.Param("key")})
+ if err != nil {
+ common.ApiErrorMsg(c, err.Error())
+ return
+ }
+ args := make([]any, len(request.Args))
+ for index, raw := range request.Args {
+ if err = common.Unmarshal(raw, &args[index]); err != nil {
+ common.ApiErrorMsg(c, fmt.Sprintf("invalid argument %d: %v", index+1, err))
+ return
+ }
+ }
+ var output any
+ if request.Member == "" {
+ output, err = loaded.Engine.Call(context.Background(), request.Hook, args...)
+ } else {
+ output, err = loaded.Engine.CallMember(context.Background(), request.Hook, request.Member, args...)
+ }
+ if err != nil {
+ common.ApiErrorMsg(c, err.Error())
+ return
+ }
+ common.ApiSuccess(c, output)
+}
+
+func DeleteTaskPluginVersion(c *gin.Context) {
+ key := c.Param("key")
+ version := c.Param("version")
+ plugin, lookupErr := model.GetTaskPluginVersion(key, version)
+ if lookupErr != nil {
+ if errors.Is(lookupErr, gorm.ErrRecordNotFound) {
+ common.ApiErrorMsg(c, "override plugin version not found; factory plugins cannot be deleted")
+ return
+ }
+ common.ApiError(c, lookupErr)
+ return
+ }
+ if plugin.Active && !taskPluginHasFactory(key) {
+ channels, inFlight, usageErr := model.GetTaskPluginUsage(key)
+ if usageErr != nil {
+ common.ApiError(c, usageErr)
+ return
+ }
+ if (len(channels) > 0 || inFlight > 0) && c.Query("force") != "true" {
+ c.JSON(200, gin.H{"success": false, "message": "task plugin is still in use", "data": gin.H{"channels": channels, "in_flight_count": inFlight}})
+ return
+ }
+ }
+ _, err := model.DeleteTaskPluginVersion(key, version)
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ common.ApiErrorMsg(c, "override plugin version not found; factory plugins cannot be deleted")
+ return
+ }
+ common.ApiError(c, err)
+ return
+ }
+ if err = syncTaskPluginsOnceContext(c.Request.Context()); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, nil)
+}
+
+type taskPluginActivateRequest struct {
+ Version string `json:"version" binding:"required"`
+}
+
+func ActivateTaskPlugin(c *gin.Context) {
+ var request taskPluginActivateRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ common.ApiErrorMsg(c, err.Error())
+ return
+ }
+ versions, err := model.ListTaskPluginVersions(c.Param("key"))
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ var target *model.TaskPlugin
+ for i := range versions {
+ if versions[i].Version == request.Version {
+ target = &versions[i]
+ break
+ }
+ }
+ if target == nil {
+ common.ApiErrorMsg(c, "plugin version not found")
+ return
+ }
+ if _, err = jsplugin.NewRegistry().Register(target.Source, jsplugin.Options{Key: target.Key, Version: target.Version}); err != nil {
+ common.ApiErrorMsg(c, err.Error())
+ return
+ }
+ if err = model.ActivateTaskPlugin(target.Key, target.Version); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ if err = syncTaskPluginsOnceContext(c.Request.Context()); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, nil)
+}
+
+type taskPluginStatusRequest struct {
+ Enabled *bool `json:"enabled" binding:"required"`
+}
+
+func SetTaskPluginStatus(c *gin.Context) {
+ var request taskPluginStatusRequest
+ if err := c.ShouldBindJSON(&request); err != nil || request.Enabled == nil {
+ common.ApiErrorMsg(c, "enabled is required")
+ return
+ }
+ key := c.Param("key")
+ disabledChannels := 0
+ if !*request.Enabled {
+ channels, inFlight, usageErr := model.GetTaskPluginUsage(key)
+ if usageErr != nil {
+ common.ApiError(c, usageErr)
+ return
+ }
+ cascade := c.Query("cascade") == "true"
+ force := c.Query("force") == "true"
+ if (len(channels) > 0 && !cascade) || (inFlight > 0 && !force) {
+ c.JSON(200, gin.H{"success": false, "message": "task plugin is still in use", "data": gin.H{"channels": channels, "in_flight_count": inFlight}})
+ return
+ }
+ if cascade {
+ for _, channel := range channels {
+ if model.UpdateChannelStatus(channel.Id, "", common.ChannelStatusManuallyDisabled, "task plugin disabled") {
+ disabledChannels++
+ }
+ }
+ }
+ }
+ _, lookupErr := model.GetTaskPluginVersion(key, "")
+ hasActiveOverride := lookupErr == nil
+ if lookupErr != nil && !errors.Is(lookupErr, gorm.ErrRecordNotFound) {
+ common.ApiError(c, lookupErr)
+ return
+ }
+ // The disabled set suppresses only the factory fallback layer. An enabled
+ // override for the same key keeps serving and is toggled independently.
+ if taskPluginHasFactory(key) && !hasActiveOverride {
+ keys := setting.GetTaskPluginDisabledFactoryKeys()
+ if *request.Enabled {
+ next := make([]string, 0, len(keys))
+ for _, item := range keys {
+ if item != key {
+ next = append(next, item)
+ }
+ }
+ keys = next
+ } else {
+ keys = append(append([]string{}, keys...), key)
+ }
+ if err := setting.SetTaskPluginDisabledFactoryKeysOption(keys); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ encoded, err := common.Marshal(setting.GetTaskPluginDisabledFactoryKeys())
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ if err = model.UpdateOption(setting.TaskPluginDisabledFactoryKeysKey, string(encoded)); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, gin.H{"plugin_enabled": *request.Enabled, "disabled_channels": disabledChannels})
+ return
+ }
+ if err := model.SetTaskPluginEnabled(key, *request.Enabled); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ if err := syncTaskPluginsOnceContext(c.Request.Context()); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, gin.H{"plugin_enabled": *request.Enabled, "disabled_channels": disabledChannels})
+}
+
+func taskPluginHasFactory(key string) bool {
+ for _, meta := range jsplugin.DefaultRegistry.Snapshot().Factory {
+ if meta.Key == key {
+ return true
+ }
+ }
+ return false
+}
+
+func GetTaskPluginMarketplaceSources(c *gin.Context) {
+ common.ApiSuccess(c, setting.GetTaskPluginMarketplaceSources())
+}
+
+func UpdateTaskPluginMarketplaceSources(c *gin.Context) {
+ var sources []setting.TaskPluginMarketplaceSource
+ if err := c.ShouldBindJSON(&sources); err != nil {
+ common.ApiErrorMsg(c, err.Error())
+ return
+ }
+ if sources == nil {
+ sources = []setting.TaskPluginMarketplaceSource{}
+ }
+ for i := range sources {
+ name := strings.TrimSpace(sources[i].Name)
+ indexURL := strings.TrimSpace(sources[i].IndexURL)
+ if name == "" {
+ common.ApiErrorMsg(c, "marketplace source name is required")
+ return
+ }
+ parsed, err := url.Parse(indexURL)
+ if err != nil || !parsed.IsAbs() || parsed.Host == "" || (!strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https")) {
+ common.ApiErrorMsg(c, "marketplace source index_url must be an absolute http(s) URL")
+ return
+ }
+ sources[i].Name = name
+ sources[i].IndexURL = indexURL
+ }
+ encoded, err := common.Marshal(sources)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ if err = model.UpdateOption(setting.TaskPluginMarketplaceSourcesKey, string(encoded)); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, sources)
+}
+
+func GetTaskPluginOptions(c *gin.Context) {
+ snapshot := jsplugin.DefaultRegistry.Snapshot()
+ seen := make(map[string]bool)
+ options := make([]gin.H, 0, len(snapshot.Factory)+len(snapshot.Override))
+ for layer, metas := range [][]jsplugin.Meta{snapshot.Override, snapshot.Factory} {
+ for _, meta := range metas {
+ if seen[meta.Key] {
+ continue
+ }
+ // Disabled factory keys are omitted from bind options. The disabled
+ // set suppresses only the factory fallback; an enabled override for
+ // the same key is listed in the override pass and still appears.
+ if layer == 1 && setting.IsTaskPluginFactoryDisabled(meta.Key) {
+ continue
+ }
+ if _, ok := jsplugin.DefaultRegistry.Get(meta.Key); !ok {
+ continue
+ }
+ seen[meta.Key] = true
+ options = append(options, gin.H{
+ "key": meta.Key,
+ "name": meta.Name,
+ "models": meta.Models,
+ "usageSchema": meta.UsageSchema,
+ })
+ }
+ }
+ sort.Slice(options, func(i, j int) bool { return options[i]["key"].(string) < options[j]["key"].(string) })
+ common.ApiSuccess(c, options)
+}
+
+var taskPluginSyncState = struct {
+ sync.Mutex
+ hashes map[string]string
+ errors map[string]string
+ lastRebuild taskPluginRebuildOutcome
+}{hashes: map[string]string{}, errors: map[string]string{}}
+
+func syncTaskPluginsOnce() error {
+ return syncTaskPluginsOnceContext(context.Background())
+}
+
+func syncTaskPluginsOnceContext(ctx context.Context) error {
+ started := time.Now()
+ taskPluginSyncState.Lock()
+ defer taskPluginSyncState.Unlock()
+ databaseSnapshot, err := model.GetTaskPluginSyncSnapshot()
+ if err != nil {
+ syncErr := fmt.Errorf("sync task plugins: %w", err)
+ taskPluginSyncState.lastRebuild = taskPluginRebuildOutcome{
+ Status: "failed",
+ AttemptedAt: time.Now(),
+ Generation: jsplugin.DefaultRegistry.Generation().Number,
+ DatabaseRevision: taskPluginSyncState.lastRebuild.DatabaseRevision,
+ Error: syncErr.Error(),
+ }
+ logger.LogDebug(
+ ctx,
+ "task_plugin subsystem=sync event=failed stage=database_snapshot retained_generation=%d elapsed_ms=%d",
+ jsplugin.DefaultRegistry.Generation().Number,
+ time.Since(started).Milliseconds(),
+ )
+ return syncErr
+ }
+ databasePlugins := databaseSnapshot.Plugins
+ sort.Slice(databasePlugins, func(i, j int) bool { return databasePlugins[i].Key < databasePlugins[j].Key })
+ currentOverrides := jsplugin.DefaultRegistry.OverridePlugins()
+ generationBefore := jsplugin.DefaultRegistry.Generation().Number
+ logger.LogDebug(
+ ctx,
+ "task_plugin subsystem=sync event=start database_revision=%q generation=%d desired_plugins=%d current_overrides=%d",
+ databaseSnapshot.Revision,
+ generationBefore,
+ len(databasePlugins),
+ len(currentOverrides),
+ )
+ nextOverrides := make([]*jsplugin.LoadedPlugin, 0, len(databasePlugins))
+ nextHashes := make(map[string]string, len(databasePlugins))
+ seen := make(map[string]bool, len(databasePlugins))
+ for _, plugin := range databasePlugins {
+ seen[plugin.Key] = true
+ if current := currentOverrides[plugin.Key]; current != nil && taskPluginSyncState.hashes[plugin.Key] == plugin.SourceHash {
+ nextOverrides = append(nextOverrides, current)
+ nextHashes[plugin.Key] = plugin.SourceHash
+ logger.LogDebug(
+ ctx,
+ "task_plugin subsystem=sync event=plugin plugin=%q version=%q action=reuse",
+ plugin.Key,
+ plugin.Version,
+ )
+ continue
+ }
+ logger.LogDebug(
+ ctx,
+ "task_plugin subsystem=sync event=plugin plugin=%q version=%q action=compile_start",
+ plugin.Key,
+ plugin.Version,
+ )
+ compiled, compileErr := jsplugin.CompilePlugin(plugin.Source, jsplugin.Options{Key: plugin.Key, Version: plugin.Version})
+ if compileErr != nil {
+ retainedIncumbent := false
+ if current := currentOverrides[plugin.Key]; current != nil {
+ nextOverrides = append(nextOverrides, current)
+ retainedIncumbent = true
+ if currentHash := taskPluginSyncState.hashes[plugin.Key]; currentHash != "" {
+ nextHashes[plugin.Key] = currentHash
+ }
+ }
+ taskPluginSyncState.errors[plugin.Key] = compileErr.Error()
+ common.SysError(fmt.Sprintf("compile task plugin %s@%s: %v", plugin.Key, plugin.Version, compileErr))
+ logger.LogDebug(
+ ctx,
+ "task_plugin subsystem=sync event=plugin plugin=%q version=%q action=compile_failed retained_incumbent=%t",
+ plugin.Key,
+ plugin.Version,
+ retainedIncumbent,
+ )
+ continue
+ }
+ nextOverrides = append(nextOverrides, compiled)
+ nextHashes[plugin.Key] = plugin.SourceHash
+ delete(taskPluginSyncState.errors, plugin.Key)
+ logger.LogDebug(
+ ctx,
+ "task_plugin subsystem=sync event=plugin plugin=%q version=%q action=compile_success",
+ plugin.Key,
+ plugin.Version,
+ )
+ }
+ if err = jsplugin.DefaultRegistry.ReplaceOverrides(nextOverrides); err != nil {
+ syncErr := fmt.Errorf("publish task plugin generation: %w", err)
+ taskPluginSyncState.lastRebuild = taskPluginRebuildOutcome{
+ Status: "failed",
+ AttemptedAt: time.Now(),
+ Generation: jsplugin.DefaultRegistry.Generation().Number,
+ DatabaseRevision: databaseSnapshot.Revision,
+ Error: syncErr.Error(),
+ }
+ logger.LogDebug(
+ ctx,
+ "task_plugin subsystem=sync event=failed stage=publish retained_generation=%d retained_generation_active=true database_revision=%q elapsed_ms=%d",
+ jsplugin.DefaultRegistry.Generation().Number,
+ databaseSnapshot.Revision,
+ time.Since(started).Milliseconds(),
+ )
+ return syncErr
+ }
+ taskPluginSyncState.hashes = nextHashes
+ for key := range taskPluginSyncState.errors {
+ if !seen[key] {
+ delete(taskPluginSyncState.errors, key)
+ }
+ }
+ pluginErrors := jsplugin.DefaultRegistry.RoutingErrors()
+ for key, message := range taskPluginSyncState.errors {
+ pluginErrors[key] = message
+ }
+ pluginErrorCount := len(pluginErrors)
+ status := "success"
+ if pluginErrorCount > 0 {
+ status = "partial"
+ }
+ taskPluginSyncState.lastRebuild = taskPluginRebuildOutcome{
+ Status: status,
+ AttemptedAt: time.Now(),
+ Generation: jsplugin.DefaultRegistry.Generation().Number,
+ DatabaseRevision: databaseSnapshot.Revision,
+ PluginErrorCount: pluginErrorCount,
+ }
+ logger.LogDebug(
+ ctx,
+ "task_plugin subsystem=sync event=complete database_revision=%q previous_generation=%d generation=%d status=%q active_overrides=%d plugin_errors=%d elapsed_ms=%d",
+ databaseSnapshot.Revision,
+ generationBefore,
+ jsplugin.DefaultRegistry.Generation().Number,
+ status,
+ len(jsplugin.DefaultRegistry.ActiveOverridePlugins()),
+ pluginErrorCount,
+ time.Since(started).Milliseconds(),
+ )
+ return nil
+}
+
+func SyncTaskPluginsOnce() {
+ if err := syncTaskPluginsOnce(); err != nil {
+ common.SysError(err.Error())
+ }
+}
+
+func SyncTaskPlugins() {
+ SyncTaskPluginsOnce()
+ for range time.NewTicker(30 * time.Second).C {
+ SyncTaskPluginsOnce()
+ }
+}
diff --git a/controller/task_plugin_debug.go b/controller/task_plugin_debug.go
new file mode 100644
index 000000000000..450f6b4b9972
--- /dev/null
+++ b/controller/task_plugin_debug.go
@@ -0,0 +1,269 @@
+package controller
+
+import (
+ "github.com/QuantumNous/new-api/dto"
+ "github.com/QuantumNous/new-api/logger"
+ "github.com/QuantumNous/new-api/model"
+ pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/QuantumNous/new-api/relay"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/gin-gonic/gin"
+)
+
+// taskPluginSubmitDiagnostics keeps plugin-only lifecycle logging out of the
+// ordinary task path. An empty plugin key makes every method a no-op.
+type taskPluginSubmitDiagnostics struct {
+ context *gin.Context
+ pluginKey string
+ generation uint64
+}
+
+func newTaskPluginSubmitDiagnostics(c *gin.Context) taskPluginSubmitDiagnostics {
+ diagnostics := taskPluginSubmitDiagnostics{
+ context: c,
+ pluginKey: c.GetString("expected_task_plugin_key"),
+ }
+ if diagnostics.pluginKey == "" {
+ return diagnostics
+ }
+ if pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedPlugin); exists {
+ if pinned, ok := pinnedValue.(pluginruntime.PinnedPlugin); ok && pinned.Generation != nil {
+ diagnostics.generation = pinned.Generation.Number
+ }
+ }
+ return diagnostics
+}
+
+func (d taskPluginSubmitDiagnostics) start(info *relaycommon.RelayInfo) {
+ if d.pluginKey == "" {
+ return
+ }
+ logger.LogDebug(
+ d.context,
+ "task_plugin subsystem=submit event=start generation=%d plugin=%q model=%q action_present=%t",
+ d.generation,
+ d.pluginKey,
+ info.OriginModelName,
+ info.Action != "",
+ )
+}
+
+func (d taskPluginSubmitDiagnostics) refund(stage string) {
+ if d.pluginKey == "" {
+ return
+ }
+ logger.LogDebug(
+ d.context,
+ "task_plugin subsystem=submit event=refund_invoked generation=%d plugin=%q stage=%q durable=false",
+ d.generation,
+ d.pluginKey,
+ stage,
+ )
+}
+
+func (d taskPluginSubmitDiagnostics) cancelled(stage string, attempt int) {
+ if d.pluginKey == "" {
+ return
+ }
+ logger.LogDebug(
+ d.context,
+ "task_plugin subsystem=submit event=cancelled generation=%d plugin=%q stage=%q attempt=%d",
+ d.generation,
+ d.pluginKey,
+ stage,
+ attempt,
+ )
+}
+
+func (d taskPluginSubmitDiagnostics) attempt(attempt int, channel *model.Channel, locked bool) {
+ if d.pluginKey == "" || channel == nil {
+ return
+ }
+ logger.LogDebug(
+ d.context,
+ "task_plugin subsystem=submit event=attempt generation=%d plugin=%q attempt=%d channel_id=%d channel_type=%d locked=%t",
+ d.generation,
+ d.pluginKey,
+ attempt,
+ channel.Id,
+ channel.Type,
+ locked,
+ )
+}
+
+func (d taskPluginSubmitDiagnostics) attemptSucceeded(attempt int, result *relay.TaskSubmitResult) {
+ if d.pluginKey == "" || result == nil {
+ return
+ }
+ logger.LogDebug(
+ d.context,
+ "task_plugin subsystem=submit event=attempt_succeeded generation=%d plugin=%q attempt=%d platform=%q quota=%d task_data_bytes=%d client_response=%t immediate=%t",
+ d.generation,
+ d.pluginKey,
+ attempt,
+ result.Platform,
+ result.Quota,
+ len(result.TaskData),
+ result.ClientResponse != nil,
+ result.Immediate != nil,
+ )
+}
+
+func (d taskPluginSubmitDiagnostics) attemptFailed(attempt int, channel *model.Channel, taskErr *dto.TaskError, willRetry bool) {
+ if d.pluginKey == "" || channel == nil || taskErr == nil {
+ return
+ }
+ logger.LogDebug(
+ d.context,
+ "task_plugin subsystem=submit event=attempt_failed generation=%d plugin=%q attempt=%d channel_id=%d channel_type=%d code=%q status=%d local=%t will_retry=%t",
+ d.generation,
+ d.pluginKey,
+ attempt,
+ channel.Id,
+ channel.Type,
+ taskErr.Code,
+ taskErr.StatusCode,
+ taskErr.LocalError,
+ willRetry,
+ )
+}
+
+func (d taskPluginSubmitDiagnostics) failed(stage, reason string, taskErr *dto.TaskError, durable bool) {
+ if d.pluginKey == "" {
+ return
+ }
+ code := ""
+ status := 0
+ local := true
+ if taskErr != nil {
+ code = taskErr.Code
+ status = taskErr.StatusCode
+ local = taskErr.LocalError
+ }
+ logger.LogDebug(
+ d.context,
+ "task_plugin subsystem=submit event=failed generation=%d plugin=%q stage=%q reason=%q code=%q status=%d local=%t durable=%t",
+ d.generation,
+ d.pluginKey,
+ stage,
+ reason,
+ code,
+ status,
+ local,
+ durable,
+ )
+}
+
+func (d taskPluginSubmitDiagnostics) reserve(event string, quota int) {
+ if d.pluginKey == "" {
+ return
+ }
+ logger.LogDebug(
+ d.context,
+ "task_plugin subsystem=submit event=%s generation=%d plugin=%q quota=%d",
+ event,
+ d.generation,
+ d.pluginKey,
+ quota,
+ )
+}
+
+func (d taskPluginSubmitDiagnostics) insertStart(task *model.Task) {
+ if d.pluginKey == "" || task == nil {
+ return
+ }
+ logger.LogDebug(
+ d.context,
+ "task_plugin subsystem=submit event=insert_start generation=%d plugin=%q public_task_id=%q platform=%q channel_id=%d quota=%d",
+ d.generation,
+ d.pluginKey,
+ task.TaskID,
+ task.Platform,
+ task.ChannelId,
+ task.Quota,
+ )
+}
+
+func (d taskPluginSubmitDiagnostics) durable(task *model.Task) {
+ if d.pluginKey == "" || task == nil {
+ return
+ }
+ logger.LogDebug(
+ d.context,
+ "task_plugin subsystem=submit event=durable generation=%d plugin=%q public_task_id=%q status=%q durable=true",
+ d.generation,
+ d.pluginKey,
+ task.TaskID,
+ taskPluginDebugStatus(string(task.Status)),
+ )
+}
+
+func (d taskPluginSubmitDiagnostics) settleStart(task *model.Task, quota int) {
+ if d.pluginKey == "" || task == nil {
+ return
+ }
+ logger.LogDebug(
+ d.context,
+ "task_plugin subsystem=submit event=settle_start generation=%d plugin=%q public_task_id=%q quota=%d durable=true",
+ d.generation,
+ d.pluginKey,
+ task.TaskID,
+ quota,
+ )
+}
+
+func (d taskPluginSubmitDiagnostics) complete(task *model.Task, quota int) {
+ if d.pluginKey == "" || task == nil {
+ return
+ }
+ logger.LogDebug(
+ d.context,
+ "task_plugin subsystem=submit event=complete generation=%d plugin=%q public_task_id=%q quota=%d durable=true",
+ d.generation,
+ d.pluginKey,
+ task.TaskID,
+ quota,
+ )
+}
+
+func (d taskPluginSubmitDiagnostics) present(task *model.Task, presenter string) {
+ if d.pluginKey == "" || task == nil {
+ return
+ }
+ logger.LogDebug(
+ d.context,
+ "task_plugin subsystem=submit event=present generation=%d plugin=%q public_task_id=%q presenter=%q durable=true",
+ d.generation,
+ d.pluginKey,
+ task.TaskID,
+ presenter,
+ )
+}
+
+func (d taskPluginSubmitDiagnostics) presentError(taskErr *dto.TaskError) {
+ if d.pluginKey == "" || taskErr == nil {
+ return
+ }
+ logger.LogDebug(
+ d.context,
+ "task_plugin subsystem=submit event=present_error generation=%d plugin=%q code=%q status=%d local=%t",
+ d.generation,
+ d.pluginKey,
+ taskErr.Code,
+ taskErr.StatusCode,
+ taskErr.LocalError,
+ )
+}
+
+func taskPluginDebugStatus(status string) string {
+ switch model.TaskStatus(status) {
+ case model.TaskStatusSubmitted,
+ model.TaskStatusQueued,
+ model.TaskStatusInProgress,
+ model.TaskStatusSuccess,
+ model.TaskStatusFailure:
+ return status
+ default:
+ return "unknown"
+ }
+}
diff --git a/controller/task_plugin_debug_test.go b/controller/task_plugin_debug_test.go
new file mode 100644
index 000000000000..ff6cc56dd97a
--- /dev/null
+++ b/controller/task_plugin_debug_test.go
@@ -0,0 +1,88 @@
+package controller
+
+import (
+ "bytes"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/relay"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTaskPluginSubmitDiagnosticsArePluginOnlyAndDoNotLogPayloads(t *testing.T) {
+ previousDebug := common.DebugEnabled
+ common.DebugEnabled = true
+ t.Cleanup(func() { common.DebugEnabled = previousDebug })
+
+ var output bytes.Buffer
+ common.LogWriterMu.Lock()
+ previousWriter := gin.DefaultErrorWriter
+ gin.DefaultErrorWriter = &output
+ common.LogWriterMu.Unlock()
+ t.Cleanup(func() {
+ common.LogWriterMu.Lock()
+ gin.DefaultErrorWriter = previousWriter
+ common.LogWriterMu.Unlock()
+ })
+
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/tasks/debug-plugin", nil)
+ c.Set(common.RequestIdKey, "plugin-submit-request")
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "safe-model",
+ TaskRelayInfo: &relaycommon.TaskRelayInfo{Action: "https://private-action.invalid/?key=hidden"},
+ }
+
+ newTaskPluginSubmitDiagnostics(c).start(info)
+ assert.Empty(t, output.String())
+
+ c.Set("expected_task_plugin_key", "debug-plugin")
+ diagnostics := newTaskPluginSubmitDiagnostics(c)
+ diagnostics.start(info)
+ diagnostics.attemptSucceeded(1, &relay.TaskSubmitResult{
+ UpstreamTaskID: "private-upstream-canary",
+ TaskData: []byte("private-task-data-canary"),
+ ClientResponse: map[string]any{"secret": "private-client-response-canary"},
+ Platform: constant.TaskPlatform("debug-plugin"),
+ Quota: 12,
+ })
+ task := &model.Task{
+ TaskID: "public-task-id",
+ Platform: constant.TaskPlatform("debug-plugin"),
+ Status: model.TaskStatus("https://private-status.invalid/?key=hidden"),
+ PrivateData: model.TaskPrivateData{
+ UpstreamTaskID: "private-task-record-canary",
+ ResultURL: "https://private-url.invalid/result",
+ },
+ }
+ diagnostics.insertStart(task)
+ diagnostics.durable(task)
+ diagnostics.complete(task, 12)
+
+ logOutput := output.String()
+ require.Contains(t, logOutput, "plugin-submit-request")
+ assert.Contains(t, logOutput, `plugin="debug-plugin"`)
+ assert.Contains(t, logOutput, `public_task_id="public-task-id"`)
+ assert.Contains(t, logOutput, "task_data_bytes=24")
+ assert.Contains(t, logOutput, "action_present=true")
+ assert.Contains(t, logOutput, `status="unknown"`)
+ for _, secret := range []string{
+ "private-upstream-canary",
+ "private-task-data-canary",
+ "private-client-response-canary",
+ "private-task-record-canary",
+ "private-url.invalid",
+ "private-action.invalid",
+ "private-status.invalid",
+ "key=hidden",
+ } {
+ assert.NotContains(t, logOutput, secret)
+ }
+}
diff --git a/controller/task_plugin_test.go b/controller/task_plugin_test.go
new file mode 100644
index 000000000000..e78db097b6c7
--- /dev/null
+++ b/controller/task_plugin_test.go
@@ -0,0 +1,1148 @@
+package controller
+
+import (
+ "crypto/sha256"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/QuantumNous/new-api/plugins"
+ "github.com/QuantumNous/new-api/setting"
+ "github.com/gin-gonic/gin"
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+func setupTaskPluginControllerTest(t *testing.T) {
+ t.Helper()
+ originalDB := model.DB
+ database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, database.AutoMigrate(&model.TaskPlugin{}, &model.Channel{}, &model.Ability{}, &model.Task{}, &model.Option{}))
+ model.DB = database
+ t.Cleanup(func() { model.DB = originalDB })
+}
+
+const lifecyclePluginSource = `
+export const meta = {apiVersion: 1, key: "lifecycle-only", name: "Lifecycle", version: "1.0.0", author: {name: "Test"}, models: ["doc"], fetchMode: "per_task"};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+`
+
+func taskPluginControllerTestSource(key, version string) string {
+ return fmt.Sprintf(`
+export const meta = {apiVersion: 1, key: %q, name: "Test", version: %q, author: {name: "Test"}, models: ["doc-1"], fetchMode: "per_task"};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+`, key, version)
+}
+
+func taskPluginControllerChannelSource(key, version string, channelType int) string {
+ return fmt.Sprintf(`
+export const meta = {apiVersion: 1, key: %q, name: "Test", version: %q, author: {name: "Test"}, channelTypes: [%d], models: ["doc-1"], fetchMode: "per_task"};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+`, key, version, channelType)
+}
+
+func cleanupTaskPluginControllerRuntime(t *testing.T, key string) {
+ t.Helper()
+ t.Cleanup(func() {
+ jsplugin.DefaultRegistry.Unregister(key)
+ taskPluginSyncState.Lock()
+ delete(taskPluginSyncState.hashes, key)
+ delete(taskPluginSyncState.errors, key)
+ taskPluginSyncState.Unlock()
+ })
+}
+
+func TestDeleteThirdPartyPluginReportsAssociatedChannelsAndInFlightTasks(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ loaded, err := jsplugin.DefaultRegistry.Register(lifecyclePluginSource, jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("lifecycle-only") })
+ require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{Key: loaded.Meta.Key, APIVersion: 1, Version: "1", Source: lifecyclePluginSource, SourceHash: "hash", Enabled: true}))
+ baseURL := "https://example.com"
+ setting := `{"task_plugin_key":"lifecycle-only"}`
+ channel := model.Channel{Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Name: "linked", Models: "doc", Group: "default", BaseURL: &baseURL, Setting: &setting}
+ require.NoError(t, channel.Insert())
+ require.NoError(t, model.DB.Create(&model.Task{Platform: "lifecycle-only", Status: model.TaskStatusInProgress}).Error)
+
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Params = gin.Params{{Key: "key", Value: "lifecycle-only"}, {Key: "version", Value: "1"}}
+ context.Request = httptest.NewRequest(http.MethodDelete, "/api/plugin/task/lifecycle-only/versions/1", nil)
+ DeleteTaskPluginVersion(context)
+
+ assert.Contains(t, recorder.Body.String(), `"name":"linked"`)
+ assert.Contains(t, recorder.Body.String(), `"in_flight_count":1`)
+}
+
+func TestDisableThirdPartyPluginSupportsCascadeAndForce(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ loaded, err := jsplugin.DefaultRegistry.Register(lifecyclePluginSource, jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("lifecycle-only") })
+ require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{Key: loaded.Meta.Key, APIVersion: 1, Version: "1", Source: lifecyclePluginSource, SourceHash: "hash", Enabled: true}))
+ baseURL := "https://example.com"
+ setting := `{"task_plugin_key":"lifecycle-only"}`
+ channel := model.Channel{Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Name: "linked", Models: "doc", Group: "default", BaseURL: &baseURL, Setting: &setting}
+ require.NoError(t, channel.Insert())
+ require.NoError(t, model.DB.Create(&model.Task{Platform: "lifecycle-only", Status: model.TaskStatusSubmitted}).Error)
+
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Params = gin.Params{{Key: "key", Value: "lifecycle-only"}}
+ context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task/lifecycle-only/status?cascade=true&force=true", strings.NewReader(`{"enabled":false}`))
+ context.Request.Header.Set("Content-Type", "application/json")
+ SetTaskPluginStatus(context)
+
+ assert.Contains(t, recorder.Body.String(), `"success":true`)
+ updated, err := model.GetChannelById(channel.Id, true)
+ require.NoError(t, err)
+ assert.Equal(t, common.ChannelStatusManuallyDisabled, updated.Status)
+}
+
+func setupTaskPluginFactoryDisableTest(t *testing.T) {
+ t.Helper()
+ setupTaskPluginControllerTest(t)
+ originalMap := common.OptionMap
+ common.OptionMapRWMutex.Lock()
+ common.OptionMap = map[string]string{}
+ common.OptionMapRWMutex.Unlock()
+ t.Cleanup(func() {
+ jsplugin.DefaultRegistry.SetDisabledFactoryKeys(nil)
+ common.OptionMapRWMutex.Lock()
+ common.OptionMap = originalMap
+ common.OptionMapRWMutex.Unlock()
+ })
+}
+
+func postTaskPluginStatus(t *testing.T, key, query, body string) *httptest.ResponseRecorder {
+ t.Helper()
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Params = gin.Params{{Key: "key", Value: key}}
+ path := "/api/plugin/task/" + key + "/status"
+ if query != "" {
+ path += "?" + query
+ }
+ context.Request = httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
+ context.Request.Header.Set("Content-Type", "application/json")
+ SetTaskPluginStatus(context)
+ return recorder
+}
+
+func listTaskPluginItem(t *testing.T, key string) taskPluginListItem {
+ t.Helper()
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task", nil)
+ ListTaskPlugins(context)
+ var response struct {
+ Success bool `json:"success"`
+ Data []taskPluginListItem `json:"data"`
+ }
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success)
+ for _, item := range response.Data {
+ if item.Meta.Key == key {
+ return item
+ }
+ }
+ t.Fatalf("task plugin %q not found", key)
+ return taskPluginListItem{}
+}
+
+func taskPluginOptionsHasKey(t *testing.T, key string) bool {
+ t.Helper()
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Request = httptest.NewRequest(http.MethodGet, "/api/task_plugin_options", nil)
+ GetTaskPluginOptions(context)
+ var response struct {
+ Success bool `json:"success"`
+ Data []struct {
+ Key string `json:"key"`
+ } `json:"data"`
+ }
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success)
+ for _, option := range response.Data {
+ if option.Key == key {
+ return true
+ }
+ }
+ return false
+}
+
+func TestDisableFactoryPluginPersistsOptionAndHidesFromBindOptions(t *testing.T) {
+ setupTaskPluginFactoryDisableTest(t)
+ const key = "kling"
+
+ recorder := postTaskPluginStatus(t, key, "", `{"enabled":false}`)
+ assert.Contains(t, recorder.Body.String(), `"success":true`)
+ assert.Equal(t, []string{key}, setting.GetTaskPluginDisabledFactoryKeys())
+ var stored model.Option
+ require.NoError(t, model.DB.Where("key = ?", setting.TaskPluginDisabledFactoryKeysKey).First(&stored).Error)
+ assert.Equal(t, `["kling"]`, stored.Value)
+
+ item := listTaskPluginItem(t, key)
+ assert.Equal(t, "factory", item.Source)
+ assert.False(t, item.Enabled)
+ assert.Equal(t, "disabled", item.RuntimeStatus)
+ assert.False(t, taskPluginOptionsHasKey(t, key))
+ _, ok := jsplugin.DefaultRegistry.Get(key)
+ assert.False(t, ok)
+
+ recorder = postTaskPluginStatus(t, key, "", `{"enabled":true}`)
+ assert.Contains(t, recorder.Body.String(), `"success":true`)
+ assert.Empty(t, setting.GetTaskPluginDisabledFactoryKeys())
+ item = listTaskPluginItem(t, key)
+ assert.True(t, item.Enabled)
+ assert.Equal(t, "registered", item.RuntimeStatus)
+ assert.True(t, taskPluginOptionsHasKey(t, key))
+ _, ok = jsplugin.DefaultRegistry.Get(key)
+ assert.True(t, ok)
+}
+
+func TestDisableFactoryPluginRespectsInUseGuard(t *testing.T) {
+ setupTaskPluginFactoryDisableTest(t)
+ const key = "kling"
+ baseURL := "https://example.com"
+ channelSetting := `{"task_plugin_key":"kling"}`
+ channel := model.Channel{Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Name: "linked-factory", Models: "doc", Group: "default", BaseURL: &baseURL, Setting: &channelSetting}
+ require.NoError(t, channel.Insert())
+
+ recorder := postTaskPluginStatus(t, key, "", `{"enabled":false}`)
+ assert.Contains(t, recorder.Body.String(), `"success":false`)
+ assert.Contains(t, recorder.Body.String(), "task plugin is still in use")
+ assert.Empty(t, setting.GetTaskPluginDisabledFactoryKeys())
+ _, ok := jsplugin.DefaultRegistry.Get(key)
+ assert.True(t, ok)
+}
+
+func TestDisableFactoryOverrideRowKeepsEnabledFlagPath(t *testing.T) {
+ setupTaskPluginFactoryDisableTest(t)
+ factorySource, err := plugins.Source("kling")
+ require.NoError(t, err)
+ overrideSource := strings.Replace(factorySource, `version: "1.0.0"`, `version: "1.0.0-test-factory-status"`, 1)
+ loaded, err := jsplugin.DefaultRegistry.Register(overrideSource, jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("kling") })
+ plugin := model.TaskPlugin{
+ Key: "kling", APIVersion: loaded.Meta.APIVersion, Version: loaded.Meta.Version,
+ Source: overrideSource, SourceHash: "test-hash", Enabled: true,
+ }
+ require.NoError(t, model.SaveTaskPlugin(&plugin))
+ require.NoError(t, syncTaskPluginsOnce())
+
+ recorder := postTaskPluginStatus(t, "kling", "", `{"enabled":false}`)
+ assert.Contains(t, recorder.Body.String(), `"success":true`)
+ assert.Empty(t, setting.GetTaskPluginDisabledFactoryKeys())
+
+ row, err := model.GetTaskPluginVersion("kling", "")
+ require.NoError(t, err)
+ assert.False(t, row.Enabled)
+
+ item := listTaskPluginItem(t, "kling")
+ assert.Equal(t, "override_over_factory", item.Source)
+ assert.False(t, item.Enabled)
+ assert.Equal(t, "disabled_fallback", item.RuntimeStatus)
+ assert.True(t, taskPluginOptionsHasKey(t, "kling"))
+ got, ok := jsplugin.DefaultRegistry.Get("kling")
+ require.True(t, ok)
+ assert.Equal(t, "1.0.0", got.Meta.Version)
+}
+
+func TestListTaskPluginsIncludesFactoryWithoutDatabaseRows(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task", nil)
+
+ ListTaskPlugins(context)
+
+ var response struct {
+ Success bool `json:"success"`
+ Data []taskPluginListItem `json:"data"`
+ }
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success)
+ var factoryItem *taskPluginListItem
+ for i := range response.Data {
+ if response.Data[i].Meta.Key == "kling" {
+ factoryItem = &response.Data[i]
+ break
+ }
+ }
+ require.NotNil(t, factoryItem)
+ assert.Equal(t, "factory", factoryItem.Source)
+ assert.Equal(t, "registered", factoryItem.RuntimeStatus)
+ assert.NotEmpty(t, factoryItem.SourceHash)
+}
+
+func TestMasterSwitchEmptiesOptionsAndKeepsList(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ originalEnabled := constant.TaskPluginEnabled
+ jsplugin.DefaultRegistry.SetEnabled(false)
+ t.Cleanup(func() {
+ constant.TaskPluginEnabled = originalEnabled
+ jsplugin.DefaultRegistry.SetEnabled(originalEnabled)
+ })
+
+ assert.False(t, taskPluginOptionsHasKey(t, "kling"))
+ item := listTaskPluginItem(t, "kling")
+ assert.Equal(t, "factory", item.Source)
+ assert.Equal(t, "kling", item.Meta.Key)
+}
+
+func TestGetTaskPluginOptionsIncludesUsageSchema(t *testing.T) {
+ const key = "usage-options-probe"
+ source := `
+export const meta = {
+ apiVersion: 1, key: "usage-options-probe", name: "Usage Options", version: "1.0.0", author: {name: "Test"},
+ models: ["usage-options-model"], fetchMode: "per_task",
+ usageSchema: {seconds: {type: "number", unit: "second", description: "Generated media duration."}}
+};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+`
+ _, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(key) })
+
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Request = httptest.NewRequest(http.MethodGet, "/api/task_plugin_options", nil)
+
+ GetTaskPluginOptions(context)
+
+ var response struct {
+ Success bool `json:"success"`
+ Data []struct {
+ Key string `json:"key"`
+ UsageSchema map[string]jsplugin.UsageFieldSchema `json:"usageSchema"`
+ } `json:"data"`
+ }
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success)
+ for _, option := range response.Data {
+ if option.Key != key {
+ continue
+ }
+ assert.Equal(t, "second", option.UsageSchema["seconds"].Unit)
+ assert.Equal(t, "Generated media duration.", option.UsageSchema["seconds"].Description["en"])
+ return
+ }
+ t.Fatal("task plugin option not found")
+}
+
+func TestListTaskPluginsShowsDisabledFallbackWhenOverridesAreDisabled(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ factorySource, err := plugins.Source("kling")
+ require.NoError(t, err)
+ overrideSource := strings.Replace(factorySource, `version: "1.0.0"`, `version: "1.0.0-test-disabled-override"`, 1)
+ loaded, err := jsplugin.DefaultRegistry.Register(overrideSource, jsplugin.Options{})
+ require.NoError(t, err)
+ plugin := model.TaskPlugin{
+ Key: "kling", APIVersion: loaded.Meta.APIVersion, Version: loaded.Meta.Version,
+ Source: overrideSource, SourceHash: "test-hash", Enabled: true,
+ }
+ require.NoError(t, model.SaveTaskPlugin(&plugin))
+ originalEnabled := constant.TaskPluginOverrideEnabled
+ constant.TaskPluginOverrideEnabled = false
+ jsplugin.DefaultRegistry.SetOverrideEnabled(false)
+ t.Cleanup(func() {
+ constant.TaskPluginOverrideEnabled = originalEnabled
+ jsplugin.DefaultRegistry.SetOverrideEnabled(originalEnabled)
+ jsplugin.DefaultRegistry.Unregister("kling")
+ })
+
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task", nil)
+ ListTaskPlugins(context)
+
+ var response struct {
+ Success bool `json:"success"`
+ Data []taskPluginListItem `json:"data"`
+ }
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success)
+ for _, item := range response.Data {
+ if item.Meta.Key == "kling" {
+ assert.Equal(t, "disabled_fallback", item.RuntimeStatus)
+ return
+ }
+ }
+ t.Fatal("kling plugin not found")
+}
+
+func TestDeleteActiveOverrideFallsBackToFactoryAndDeletesRecord(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ factorySource, err := plugins.Source("kling")
+ require.NoError(t, err)
+ overrideSource := strings.Replace(factorySource, `version: "1.0.0"`, `version: "1.0.0-test-override"`, 1)
+ loaded, err := jsplugin.DefaultRegistry.Register(overrideSource, jsplugin.Options{Key: "kling", Version: "test-override"})
+ require.NoError(t, err)
+ t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("kling") })
+ plugin := model.TaskPlugin{
+ Key: "kling", APIVersion: loaded.Meta.APIVersion, Version: loaded.Meta.Version,
+ Source: overrideSource, SourceHash: "test-hash", Enabled: true,
+ }
+ require.NoError(t, model.SaveTaskPlugin(&plugin))
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Params = gin.Params{{Key: "key", Value: "kling"}, {Key: "version", Value: loaded.Meta.Version}}
+ context.Request = httptest.NewRequest(http.MethodDelete, "/api/plugin/task/kling/versions/"+loaded.Meta.Version, nil)
+
+ DeleteTaskPluginVersion(context)
+
+ assert.Contains(t, recorder.Body.String(), `"success":true`)
+ versions, err := model.ListTaskPluginVersions("kling")
+ require.NoError(t, err)
+ assert.Empty(t, versions)
+ runtimePlugin, ok := jsplugin.DefaultRegistry.Get("kling")
+ require.True(t, ok)
+ assert.NotEqual(t, loaded.Meta.Version, runtimePlugin.Meta.Version)
+}
+
+func TestDeleteActiveTaskPluginPromotesEnabledVersionInRuntime(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ key := "delete-promote-probe"
+ cleanupTaskPluginControllerRuntime(t, key)
+ v1Source := taskPluginControllerTestSource(key, "1.0.0")
+ v2Source := taskPluginControllerTestSource(key, "2.0.0")
+ require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
+ Key: key, APIVersion: 1, Version: "1.0.0", Source: v1Source, SourceHash: "hash-v1", Enabled: true,
+ }))
+ require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
+ Key: key, APIVersion: 1, Version: "2.0.0", Source: v2Source, SourceHash: "hash-v2", Enabled: true,
+ }))
+ _, err := jsplugin.DefaultRegistry.Register(v1Source, jsplugin.Options{Key: key, Version: "1.0.0"})
+ require.NoError(t, err)
+ taskPluginSyncState.Lock()
+ taskPluginSyncState.hashes[key] = "hash-v1"
+ taskPluginSyncState.errors[key] = "stale compile error"
+ taskPluginSyncState.Unlock()
+
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Params = gin.Params{{Key: "key", Value: key}, {Key: "version", Value: "1.0.0"}}
+ context.Request = httptest.NewRequest(http.MethodDelete, "/api/plugin/task/"+key+"/versions/1.0.0", nil)
+ DeleteTaskPluginVersion(context)
+
+ assert.Contains(t, recorder.Body.String(), `"success":true`)
+ active, err := model.GetTaskPluginVersion(key, "")
+ require.NoError(t, err)
+ assert.Equal(t, "2.0.0", active.Version)
+ runtimePlugin, ok := jsplugin.DefaultRegistry.Get(key)
+ require.True(t, ok)
+ assert.Equal(t, "2.0.0", runtimePlugin.Meta.Version)
+ taskPluginSyncState.Lock()
+ syncedHash := taskPluginSyncState.hashes[key]
+ _, hasSyncError := taskPluginSyncState.errors[key]
+ taskPluginSyncState.Unlock()
+ assert.Equal(t, "hash-v2", syncedHash)
+ assert.False(t, hasSyncError)
+}
+
+func TestUploadTaskPluginRefreshesRuntimeSyncState(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ key := "upload-sync-probe"
+ cleanupTaskPluginControllerRuntime(t, key)
+ source := taskPluginControllerTestSource(key, "1.0.0")
+ taskPluginSyncState.Lock()
+ taskPluginSyncState.hashes[key] = "stale-hash"
+ taskPluginSyncState.errors[key] = "stale compile error"
+ taskPluginSyncState.Unlock()
+ body, err := common.Marshal(map[string]any{"source": source})
+ require.NoError(t, err)
+
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", strings.NewReader(string(body)))
+ context.Request.Header.Set("Content-Type", "application/json")
+ UploadTaskPlugin(context)
+
+ assert.Contains(t, recorder.Body.String(), `"success":true`)
+ stored, err := model.GetTaskPluginVersion(key, "")
+ require.NoError(t, err)
+ taskPluginSyncState.Lock()
+ syncedHash := taskPluginSyncState.hashes[key]
+ _, hasSyncError := taskPluginSyncState.errors[key]
+ taskPluginSyncState.Unlock()
+ assert.Equal(t, stored.SourceHash, syncedHash)
+ assert.False(t, hasSyncError)
+}
+
+func TestActivateTaskPluginRefreshesRuntimeSyncState(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ key := "activate-sync-probe"
+ cleanupTaskPluginControllerRuntime(t, key)
+ v1Source := taskPluginControllerTestSource(key, "1.0.0")
+ v2Source := taskPluginControllerTestSource(key, "2.0.0")
+ require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
+ Key: key, APIVersion: 1, Version: "1.0.0", Source: v1Source, SourceHash: "hash-v1", Enabled: true,
+ }))
+ require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
+ Key: key, APIVersion: 1, Version: "2.0.0", Source: v2Source, SourceHash: "hash-v2", Enabled: true,
+ }))
+ _, err := jsplugin.DefaultRegistry.Register(v1Source, jsplugin.Options{Key: key, Version: "1.0.0"})
+ require.NoError(t, err)
+ taskPluginSyncState.Lock()
+ taskPluginSyncState.hashes[key] = "hash-v1"
+ taskPluginSyncState.errors[key] = "stale compile error"
+ taskPluginSyncState.Unlock()
+
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Params = gin.Params{{Key: "key", Value: key}}
+ context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task/"+key+"/activate", strings.NewReader(`{"version":"2.0.0"}`))
+ context.Request.Header.Set("Content-Type", "application/json")
+ ActivateTaskPlugin(context)
+
+ assert.Contains(t, recorder.Body.String(), `"success":true`)
+ runtimePlugin, ok := jsplugin.DefaultRegistry.Get(key)
+ require.True(t, ok)
+ assert.Equal(t, "2.0.0", runtimePlugin.Meta.Version)
+ taskPluginSyncState.Lock()
+ syncedHash := taskPluginSyncState.hashes[key]
+ _, hasSyncError := taskPluginSyncState.errors[key]
+ taskPluginSyncState.Unlock()
+ assert.Equal(t, "hash-v2", syncedHash)
+ assert.False(t, hasSyncError)
+}
+
+func TestSyncTaskPluginsPublishesOneGenerationForWholeBatch(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ firstKey := "batch-sync-first"
+ secondKey := "batch-sync-second"
+ cleanupTaskPluginControllerRuntime(t, firstKey)
+ cleanupTaskPluginControllerRuntime(t, secondKey)
+ firstSource := taskPluginControllerTestSource(firstKey, "1.0.0")
+ secondSource := taskPluginControllerTestSource(secondKey, "1.0.0")
+ require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
+ Key: firstKey, APIVersion: 1, Version: "1.0.0", Source: firstSource, SourceHash: "first-hash", Enabled: true,
+ }))
+ require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
+ Key: secondKey, APIVersion: 1, Version: "1.0.0", Source: secondSource, SourceHash: "second-hash", Enabled: true,
+ }))
+ before := jsplugin.DefaultRegistry.Generation().Number
+
+ SyncTaskPluginsOnce()
+
+ assert.Equal(t, before+1, jsplugin.DefaultRegistry.Generation().Number)
+ _, firstRegistered := jsplugin.DefaultRegistry.Get(firstKey)
+ _, secondRegistered := jsplugin.DefaultRegistry.Get(secondKey)
+ assert.True(t, firstRegistered)
+ assert.True(t, secondRegistered)
+
+ published := jsplugin.DefaultRegistry.Generation()
+ SyncTaskPluginsOnce()
+ assert.Same(t, published, jsplugin.DefaultRegistry.Generation())
+}
+
+func TestTaskPluginRuntimeExposesDatabaseRevisionAheadOfLocalGeneration(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ key := "runtime-revision-probe"
+ cleanupTaskPluginControllerRuntime(t, key)
+ taskPluginSyncState.Lock()
+ previousRebuild := taskPluginSyncState.lastRebuild
+ taskPluginSyncState.Unlock()
+ t.Cleanup(func() {
+ taskPluginSyncState.Lock()
+ taskPluginSyncState.lastRebuild = previousRebuild
+ taskPluginSyncState.Unlock()
+ })
+
+ v1Source := taskPluginControllerTestSource(key, "1.0.0")
+ require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
+ Key: key, APIVersion: 1, Version: "1.0.0",
+ Source: v1Source, SourceHash: "runtime-v1", Enabled: true,
+ }))
+ require.NoError(t, syncTaskPluginsOnce())
+ localGeneration := jsplugin.DefaultRegistry.Generation().Number
+ taskPluginSyncState.Lock()
+ syncedRevision := taskPluginSyncState.lastRebuild.DatabaseRevision
+ taskPluginSyncState.Unlock()
+
+ v2Source := taskPluginControllerTestSource(key, "2.0.0")
+ require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
+ Key: key, APIVersion: 1, Version: "2.0.0",
+ Source: v2Source, SourceHash: "runtime-v2", Enabled: true,
+ }))
+ require.NoError(t, model.ActivateTaskPlugin(key, "2.0.0"))
+
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/runtime/status", nil)
+ GetTaskPluginRuntime(context)
+
+ var response struct {
+ Success bool `json:"success"`
+ Data taskPluginRuntimeStatus `json:"data"`
+ }
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success)
+ assert.Equal(t, localGeneration, response.Data.CurrentGeneration)
+ assert.NotZero(t, response.Data.GenerationPublishedAt)
+ assert.NotEqual(t, syncedRevision, response.Data.DatabaseRevision)
+ assert.Equal(t, "success", response.Data.LastRebuild.Status)
+ assert.Equal(t, syncedRevision, response.Data.LastRebuild.DatabaseRevision)
+ assert.Empty(t, response.Data.PluginErrors)
+
+ active, ok := jsplugin.DefaultRegistry.Get(key)
+ require.True(t, ok)
+ assert.Equal(t, "1.0.0", active.Meta.Version)
+}
+
+func TestTaskPluginRuntimeReportsPluginLevelCompileErrors(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ key := "runtime-error-probe"
+ cleanupTaskPluginControllerRuntime(t, key)
+ taskPluginSyncState.Lock()
+ previousRebuild := taskPluginSyncState.lastRebuild
+ taskPluginSyncState.Unlock()
+ t.Cleanup(func() {
+ taskPluginSyncState.Lock()
+ taskPluginSyncState.lastRebuild = previousRebuild
+ taskPluginSyncState.Unlock()
+ })
+ require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
+ Key: key, APIVersion: 1, Version: "1.0.0",
+ Source: "export const meta = {", SourceHash: "broken-source", Enabled: true,
+ }))
+ require.NoError(t, syncTaskPluginsOnce())
+
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/runtime/status", nil)
+ GetTaskPluginRuntime(context)
+
+ var response struct {
+ Success bool `json:"success"`
+ Data taskPluginRuntimeStatus `json:"data"`
+ }
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success)
+ assert.Equal(t, "partial", response.Data.LastRebuild.Status)
+ assert.Equal(t, response.Data.DatabaseRevision, response.Data.LastRebuild.DatabaseRevision)
+ assert.GreaterOrEqual(t, response.Data.LastRebuild.PluginErrorCount, 1)
+ assert.NotEmpty(t, response.Data.PluginErrors[key])
+ _, registered := jsplugin.DefaultRegistry.Get(key)
+ assert.False(t, registered)
+}
+
+func TestTaskPluginRuntimeSurvivesDatabaseSyncFailure(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ key := "runtime-database-failure"
+ cleanupTaskPluginControllerRuntime(t, key)
+ taskPluginSyncState.Lock()
+ previousRebuild := taskPluginSyncState.lastRebuild
+ taskPluginSyncState.Unlock()
+ t.Cleanup(func() {
+ taskPluginSyncState.Lock()
+ taskPluginSyncState.lastRebuild = previousRebuild
+ taskPluginSyncState.Unlock()
+ })
+
+ source := taskPluginControllerTestSource(key, "1.0.0")
+ require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
+ Key: key, APIVersion: 1, Version: "1.0.0",
+ Source: source, SourceHash: "runtime-database-v1", Enabled: true,
+ }))
+ require.NoError(t, syncTaskPluginsOnce())
+ generation := jsplugin.DefaultRegistry.Generation().Number
+ taskPluginSyncState.Lock()
+ syncedRevision := taskPluginSyncState.lastRebuild.DatabaseRevision
+ taskPluginSyncState.Unlock()
+
+ sqlDatabase, err := model.DB.DB()
+ require.NoError(t, err)
+ require.NoError(t, sqlDatabase.Close())
+ require.Error(t, syncTaskPluginsOnce())
+
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/runtime/status", nil)
+ GetTaskPluginRuntime(context)
+
+ var response struct {
+ Success bool `json:"success"`
+ Data taskPluginRuntimeStatus `json:"data"`
+ }
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success)
+ assert.Equal(t, generation, response.Data.CurrentGeneration)
+ assert.Equal(t, syncedRevision, response.Data.DatabaseRevision)
+ assert.Equal(t, "database snapshot unavailable", response.Data.DatabaseError)
+ assert.Equal(t, "failed", response.Data.LastRebuild.Status)
+ assert.Equal(t, syncedRevision, response.Data.LastRebuild.DatabaseRevision)
+ assert.Contains(t, response.Data.LastRebuild.Error, "sync task plugins")
+}
+
+func TestSyncTaskPluginsCachesRejectedDesiredSourceWithoutLosingIncumbent(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ pluginKey := "sync-retained-plugin"
+ ownerKey := "sync-retained-owner"
+ cleanupTaskPluginControllerRuntime(t, pluginKey)
+ cleanupTaskPluginControllerRuntime(t, ownerKey)
+ v1Source := taskPluginControllerChannelSource(pluginKey, "1.0.0", 9001)
+ v2Source := taskPluginControllerChannelSource(pluginKey, "2.0.0", 9002)
+ ownerSource := taskPluginControllerChannelSource(ownerKey, "1.0.0", 9002)
+ require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
+ Key: pluginKey, APIVersion: 1, Version: "1.0.0", Source: v1Source, SourceHash: "retained-v1", Enabled: true,
+ }))
+ require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
+ Key: ownerKey, APIVersion: 1, Version: "1.0.0", Source: ownerSource, SourceHash: "owner-v1", Enabled: true,
+ }))
+ require.NoError(t, syncTaskPluginsOnce())
+
+ incumbent, ok := jsplugin.DefaultRegistry.Get(pluginKey)
+ require.True(t, ok)
+ require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
+ Key: pluginKey, APIVersion: 1, Version: "2.0.0", Source: v2Source, SourceHash: "retained-v2", Enabled: true,
+ }))
+ require.NoError(t, model.ActivateTaskPlugin(pluginKey, "2.0.0"))
+
+ require.NoError(t, syncTaskPluginsOnce())
+ rejectedGeneration := jsplugin.DefaultRegistry.Generation()
+ for range 2 {
+ require.NoError(t, syncTaskPluginsOnce())
+ active, found := jsplugin.DefaultRegistry.Get(pluginKey)
+ require.True(t, found)
+ assert.Same(t, incumbent, active)
+ assert.Equal(t, "2.0.0", jsplugin.DefaultRegistry.OverridePlugins()[pluginKey].Meta.Version)
+ assert.Same(t, incumbent, jsplugin.DefaultRegistry.ActiveOverridePlugins()[pluginKey])
+ assert.Contains(t, jsplugin.DefaultRegistry.RoutingErrors()[pluginKey], "channelType 9002 conflicts")
+ taskPluginSyncState.Lock()
+ assert.Equal(t, "retained-v2", taskPluginSyncState.hashes[pluginKey])
+ taskPluginSyncState.Unlock()
+ assert.Same(t, rejectedGeneration, jsplugin.DefaultRegistry.Generation())
+ }
+
+ require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
+ Key: ownerKey, APIVersion: 1, Version: "2.0.0",
+ Source: taskPluginControllerChannelSource(ownerKey, "2.0.0", 9003), SourceHash: "owner-v2", Enabled: true,
+ }))
+ require.NoError(t, model.ActivateTaskPlugin(ownerKey, "2.0.0"))
+ require.NoError(t, syncTaskPluginsOnce())
+ active, ok := jsplugin.DefaultRegistry.Get(pluginKey)
+ require.True(t, ok)
+ assert.Equal(t, "2.0.0", active.Meta.Version)
+ assert.NotContains(t, jsplugin.DefaultRegistry.RoutingErrors(), pluginKey)
+}
+
+func TestSyncTaskPluginsPreservesLastCompiledOverrideWhileOverridesAreDisabled(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ key := "sync-disabled-override"
+ cleanupTaskPluginControllerRuntime(t, key)
+ v1Source := taskPluginControllerTestSource(key, "1.0.0")
+ require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
+ Key: key, APIVersion: 1, Version: "1.0.0", Source: v1Source, SourceHash: "disabled-v1", Enabled: true,
+ }))
+ require.NoError(t, syncTaskPluginsOnce())
+ jsplugin.DefaultRegistry.SetOverrideEnabled(false)
+ t.Cleanup(func() { jsplugin.DefaultRegistry.SetOverrideEnabled(true) })
+
+ disabledGeneration := jsplugin.DefaultRegistry.Generation()
+ require.NoError(t, syncTaskPluginsOnce())
+ assert.Same(t, disabledGeneration, jsplugin.DefaultRegistry.Generation())
+
+ require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
+ Key: key, APIVersion: 1, Version: "2.0.0",
+ Source: "export const meta = {", SourceHash: "disabled-v2", Enabled: true,
+ }))
+ require.NoError(t, model.ActivateTaskPlugin(key, "2.0.0"))
+ require.NoError(t, syncTaskPluginsOnce())
+ assert.Equal(t, "1.0.0", jsplugin.DefaultRegistry.OverridePlugins()[key].Meta.Version)
+ taskPluginSyncState.Lock()
+ assert.Equal(t, "disabled-v1", taskPluginSyncState.hashes[key])
+ assert.NotEmpty(t, taskPluginSyncState.errors[key])
+ taskPluginSyncState.Unlock()
+
+ jsplugin.DefaultRegistry.SetOverrideEnabled(true)
+ active, ok := jsplugin.DefaultRegistry.Get(key)
+ require.True(t, ok)
+ assert.Equal(t, "1.0.0", active.Meta.Version)
+}
+
+const dryRunPluginSource = `
+export const meta = {apiVersion: 1, key: "dryrun-probe", name: "DryRun", version: "1.0.0", author: {name: "Test"}, models: ["doc-1"], fetchMode: "per_task"};
+export function buildSubmitRequest(payload) {
+ if (!payload || !payload.model) { throw new Error("model required"); }
+ return {model: payload.model};
+}
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+export const native = { info: function(ctx, task) { return "task:" + task.id; } };
+`
+
+func runTaskPluginDryRun(t *testing.T, body string) *httptest.ResponseRecorder {
+ t.Helper()
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Params = gin.Params{{Key: "key", Value: "dryrun-probe"}}
+ context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task/dryrun-probe/dryrun", strings.NewReader(body))
+ context.Request.Header.Set("Content-Type", "application/json")
+ DryRunTaskPlugin(context)
+ return recorder
+}
+
+func TestDryRunTaskPluginExecutesHookAndRendererMember(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{Key: "dryrun-probe", APIVersion: 1, Version: "1.0.0", Source: dryRunPluginSource, SourceHash: "hash", Enabled: true}))
+
+ hookRecorder := runTaskPluginDryRun(t, `{"hook":"buildSubmitRequest","args":[{"model":"doc-1"}]}`)
+ assert.Contains(t, hookRecorder.Body.String(), `"success":true`)
+ assert.Contains(t, hookRecorder.Body.String(), `"model":"doc-1"`)
+
+ memberRecorder := runTaskPluginDryRun(t, `{"hook":"native","member":"info","args":[{}, {"id":"t-1"}]}`)
+ assert.Contains(t, memberRecorder.Body.String(), `"success":true`)
+ assert.Contains(t, memberRecorder.Body.String(), "task:t-1")
+}
+
+func TestDryRunTaskPluginReportsUnknownHook(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{Key: "dryrun-probe", APIVersion: 1, Version: "1.0.0", Source: dryRunPluginSource, SourceHash: "hash", Enabled: true}))
+
+ recorder := runTaskPluginDryRun(t, `{"hook":"missingHook"}`)
+
+ assert.Contains(t, recorder.Body.String(), `"success":false`)
+ assert.Contains(t, recorder.Body.String(), `plugin export \"missingHook\" not found`)
+}
+
+func TestDryRunTaskPluginSurfacesBadArgumentErrors(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{Key: "dryrun-probe", APIVersion: 1, Version: "1.0.0", Source: dryRunPluginSource, SourceHash: "hash", Enabled: true}))
+
+ malformedRecorder := runTaskPluginDryRun(t, `{"hook":"buildSubmitRequest","args":[{`)
+ assert.Contains(t, malformedRecorder.Body.String(), `"success":false`)
+
+ rejectedRecorder := runTaskPluginDryRun(t, `{"hook":"buildSubmitRequest","args":[{}]}`)
+ assert.Contains(t, rejectedRecorder.Body.String(), `"success":false`)
+ assert.Contains(t, rejectedRecorder.Body.String(), "model required")
+}
+
+func TestUploadTaskPluginPreflightConflict(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ enabledFalse := false
+ tests := []struct {
+ name string
+ key string
+ enabled *bool
+ force bool
+ wantSuccess bool
+ wantError string
+ }{
+ {
+ name: "enabled conflict rejected",
+ key: "preflight-reject",
+ wantError: "channelType 50 conflicts",
+ },
+ {
+ name: "force saves despite conflict",
+ key: "preflight-force",
+ force: true,
+ wantSuccess: true,
+ },
+ {
+ name: "disabled skips preflight",
+ key: "preflight-disabled",
+ enabled: &enabledFalse,
+ wantSuccess: true,
+ },
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ cleanupTaskPluginControllerRuntime(t, testCase.key)
+ source := taskPluginControllerChannelSource(testCase.key, "1.0.0", 50)
+ payload := map[string]any{"source": source}
+ if testCase.enabled != nil {
+ payload["enabled"] = *testCase.enabled
+ }
+ if testCase.force {
+ payload["force"] = true
+ }
+ body, err := common.Marshal(payload)
+ require.NoError(t, err)
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", strings.NewReader(string(body)))
+ context.Request.Header.Set("Content-Type", "application/json")
+
+ UploadTaskPlugin(context)
+
+ if testCase.wantSuccess {
+ assert.Contains(t, recorder.Body.String(), `"success":true`)
+ _, err = model.GetTaskPluginVersion(testCase.key, "")
+ require.NoError(t, err)
+ return
+ }
+ assert.Contains(t, recorder.Body.String(), `"success":false`)
+ assert.Contains(t, recorder.Body.String(), testCase.wantError)
+ assert.Contains(t, recorder.Body.String(), "kling")
+ var count int64
+ require.NoError(t, model.DB.Model(&model.TaskPlugin{}).Where("key = ?", testCase.key).Count(&count).Error)
+ assert.Zero(t, count)
+ })
+ }
+}
+
+func TestUploadTaskPluginRejectsMetaViolatingV1Schema(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ cases := []struct {
+ name string
+ meta string
+ expectedError string
+ }{
+ {
+ name: "key with uppercase characters",
+ meta: `{apiVersion: 1, key: "Bad-Key", name: "Bad", version: "1.0.0", author: {name: "Test"}, models: ["doc-1"], fetchMode: "per_task"}`,
+ expectedError: "plugin meta key must match",
+ },
+ {
+ name: "version that is not semver",
+ meta: `{apiVersion: 1, key: "bad-plugin", name: "Bad", version: "one", author: {name: "Test"}, models: ["doc-1"], fetchMode: "per_task"}`,
+ expectedError: "plugin meta version must be semver",
+ },
+ {
+ name: "unsupported fetch mode",
+ meta: `{apiVersion: 1, key: "bad-plugin", name: "Bad", version: "1.0.0", author: {name: "Test"}, models: ["doc-1"], fetchMode: "sometimes"}`,
+ expectedError: "plugin meta fetchMode must be per_task or batch",
+ },
+ {
+ name: "empty model list",
+ meta: `{apiVersion: 1, key: "bad-plugin", name: "Bad", version: "1.0.0", author: {name: "Test"}, models: [], fetchMode: "per_task"}`,
+ expectedError: "plugin meta models must contain at least one model",
+ },
+ }
+ for _, testCase := range cases {
+ t.Run(testCase.name, func(t *testing.T) {
+ source := "export const meta = " + testCase.meta + `;
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+`
+ body, err := common.Marshal(map[string]any{"source": source})
+ require.NoError(t, err)
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", strings.NewReader(string(body)))
+ context.Request.Header.Set("Content-Type", "application/json")
+
+ UploadTaskPlugin(context)
+
+ assert.Contains(t, recorder.Body.String(), `"success":false`)
+ assert.Contains(t, recorder.Body.String(), testCase.expectedError)
+ var count int64
+ require.NoError(t, model.DB.Model(&model.TaskPlugin{}).Count(&count).Error)
+ assert.Zero(t, count)
+ })
+ }
+}
+
+func TestDeletePureFactoryPluginIsRejected(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Params = gin.Params{{Key: "key", Value: "kling"}, {Key: "version", Value: "1.0.0"}}
+ context.Request = httptest.NewRequest(http.MethodDelete, "/api/plugin/task/kling/versions/1.0.0", nil)
+
+ DeleteTaskPluginVersion(context)
+
+ assert.Contains(t, recorder.Body.String(), `"success":false`)
+ assert.Contains(t, recorder.Body.String(), "factory plugins cannot be deleted")
+ _, ok := jsplugin.DefaultRegistry.Get("kling")
+ assert.True(t, ok)
+}
+
+func TestUploadTaskPluginSourceSha256(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ tests := []struct {
+ name string
+ key string
+ withHash bool
+ hash string
+ wantSuccess bool
+ wantError string
+ }{
+ {
+ name: "matching hash succeeds",
+ key: "sha256-match",
+ withHash: true,
+ wantSuccess: true,
+ },
+ {
+ name: "mismatching hash rejected",
+ key: "sha256-mismatch",
+ withHash: true,
+ hash: "deadbeef",
+ wantError: "plugin source sha256 mismatch",
+ },
+ {
+ name: "absent field unchanged",
+ key: "sha256-absent",
+ wantSuccess: true,
+ },
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ cleanupTaskPluginControllerRuntime(t, testCase.key)
+ source := taskPluginControllerTestSource(testCase.key, "1.0.0")
+ payload := map[string]any{"source": source}
+ if testCase.withHash {
+ hash := testCase.hash
+ if hash == "" {
+ hash = " " + strings.ToUpper(fmt.Sprintf("%x", sha256.Sum256([]byte(source)))) + " "
+ }
+ payload["sourceSha256"] = hash
+ }
+ body, err := common.Marshal(payload)
+ require.NoError(t, err)
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", strings.NewReader(string(body)))
+ context.Request.Header.Set("Content-Type", "application/json")
+
+ UploadTaskPlugin(context)
+
+ if testCase.wantSuccess {
+ assert.Contains(t, recorder.Body.String(), `"success":true`)
+ _, err = model.GetTaskPluginVersion(testCase.key, "")
+ require.NoError(t, err)
+ return
+ }
+ assert.Contains(t, recorder.Body.String(), `"success":false`)
+ assert.Contains(t, recorder.Body.String(), testCase.wantError)
+ var count int64
+ require.NoError(t, model.DB.Model(&model.TaskPlugin{}).Where("key = ?", testCase.key).Count(&count).Error)
+ assert.Zero(t, count)
+ })
+ }
+}
+
+func setupTaskPluginMarketplaceSourcesTest(t *testing.T) {
+ t.Helper()
+ setupTaskPluginControllerTest(t)
+ originalMap := common.OptionMap
+ common.OptionMapRWMutex.Lock()
+ common.OptionMap = map[string]string{}
+ common.OptionMapRWMutex.Unlock()
+ t.Cleanup(func() {
+ common.OptionMapRWMutex.Lock()
+ common.OptionMap = originalMap
+ common.OptionMapRWMutex.Unlock()
+ })
+}
+
+func TestGetTaskPluginMarketplaceSourcesDefaultWhenUnset(t *testing.T) {
+ setupTaskPluginMarketplaceSourcesTest(t)
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/marketplace/sources", nil)
+
+ GetTaskPluginMarketplaceSources(context)
+
+ var response struct {
+ Success bool `json:"success"`
+ Data []setting.TaskPluginMarketplaceSource `json:"data"`
+ }
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success)
+ require.Equal(t, []setting.TaskPluginMarketplaceSource{
+ {Name: "Official", IndexURL: "https://www.newapi.ai/api/v1/plugins/index.json"},
+ {Name: "GitHub", IndexURL: "https://raw.githubusercontent.com/QuantumNous/new-api-plugins/main/index.json"},
+ }, response.Data)
+ var count int64
+ require.NoError(t, model.DB.Model(&model.Option{}).Where("key = ?", setting.TaskPluginMarketplaceSourcesKey).Count(&count).Error)
+ assert.Zero(t, count)
+}
+
+func TestUpdateTaskPluginMarketplaceSourcesRoundTrip(t *testing.T) {
+ setupTaskPluginMarketplaceSourcesTest(t)
+ payload := []setting.TaskPluginMarketplaceSource{
+ {Name: "Mirror", IndexURL: "https://example.com/plugins/index.json"},
+ {Name: "Official", IndexURL: "https://www.newapi.ai/api/v1/plugins/index.json"},
+ }
+ body, err := common.Marshal(payload)
+ require.NoError(t, err)
+ putRecorder := httptest.NewRecorder()
+ putContext, _ := gin.CreateTestContext(putRecorder)
+ putContext.Request = httptest.NewRequest(http.MethodPut, "/api/plugin/task/marketplace/sources", strings.NewReader(string(body)))
+ putContext.Request.Header.Set("Content-Type", "application/json")
+
+ UpdateTaskPluginMarketplaceSources(putContext)
+
+ assert.Contains(t, putRecorder.Body.String(), `"success":true`)
+
+ getRecorder := httptest.NewRecorder()
+ getContext, _ := gin.CreateTestContext(getRecorder)
+ getContext.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/marketplace/sources", nil)
+ GetTaskPluginMarketplaceSources(getContext)
+
+ var response struct {
+ Success bool `json:"success"`
+ Data []setting.TaskPluginMarketplaceSource `json:"data"`
+ }
+ require.NoError(t, common.Unmarshal(getRecorder.Body.Bytes(), &response))
+ require.True(t, response.Success)
+ assert.Equal(t, payload, response.Data)
+}
+
+func TestUpdateTaskPluginMarketplaceSourcesValidation(t *testing.T) {
+ setupTaskPluginMarketplaceSourcesTest(t)
+ tests := []struct {
+ name string
+ body string
+ wantErr string
+ }{
+ {
+ name: "empty name",
+ body: `[{"name":"","index_url":"https://example.com/index.json"}]`,
+ wantErr: "marketplace source name is required",
+ },
+ {
+ name: "invalid URL",
+ body: `[{"name":"Local","index_url":"not-a-url"}]`,
+ wantErr: "marketplace source index_url must be an absolute http(s) URL",
+ },
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Request = httptest.NewRequest(http.MethodPut, "/api/plugin/task/marketplace/sources", strings.NewReader(testCase.body))
+ context.Request.Header.Set("Content-Type", "application/json")
+
+ UpdateTaskPluginMarketplaceSources(context)
+
+ assert.Contains(t, recorder.Body.String(), `"success":false`)
+ assert.Contains(t, recorder.Body.String(), testCase.wantErr)
+ assert.Empty(t, common.OptionMap[setting.TaskPluginMarketplaceSourcesKey])
+ var count int64
+ require.NoError(t, model.DB.Model(&model.Option{}).Where("key = ?", setting.TaskPluginMarketplaceSourcesKey).Count(&count).Error)
+ assert.Zero(t, count)
+ })
+ }
+}
diff --git a/controller/video_proxy.go b/controller/video_proxy.go
index 996d084d88fa..230d2bddfd38 100644
--- a/controller/video_proxy.go
+++ b/controller/video_proxy.go
@@ -1,27 +1,56 @@
package controller
import (
+ "bytes"
"context"
"encoding/base64"
+ "errors"
"fmt"
"io"
+ "net"
"net/http"
"net/url"
+ "strconv"
"strings"
+ "sync"
"time"
"github.com/QuantumNous/new-api/common"
- "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
+ relaychannel "github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/system_setting"
-
"github.com/gin-gonic/gin"
+ "golang.org/x/net/http/httpguts"
)
+var errTaskMediaRequestRejected = errors.New("task media request rejected")
+
+var taskMediaResponseHeaderTimeout = 60 * time.Second
+var taskMediaDataURLMaxEncodedBytes = 64 << 20
+
+type taskMediaProxyError struct {
+ status int
+ code string
+ message string
+ err error
+}
+
+func (e *taskMediaProxyError) Error() string {
+ if e.err == nil {
+ return e.message
+ }
+ return e.message + ": " + e.err.Error()
+}
+
+func (e *taskMediaProxyError) Unwrap() error {
+ return e.err
+}
+
// videoProxyError returns a standardized OpenAI-style error response.
func videoProxyError(c *gin.Context, status int, errType, message string) {
+ c.Header("Cache-Control", "private, no-store")
c.JSON(status, gin.H{
"error": gin.H{
"message": message,
@@ -37,8 +66,7 @@ func VideoProxy(c *gin.Context) {
return
}
- userID := c.GetInt("id")
- task, exists, err := model.GetByTaskId(userID, taskID)
+ task, exists, err := getTaskForArtifactRequest(c, taskID)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to query task %s: %s", taskID, err.Error()))
videoProxyError(c, http.StatusInternalServerError, "server_error", "Failed to query task")
@@ -48,141 +76,505 @@ func VideoProxy(c *gin.Context) {
videoProxyError(c, http.StatusNotFound, "invalid_request_error", "Task not found")
return
}
-
if task.Status != model.TaskStatusSuccess {
videoProxyError(c, http.StatusBadRequest, "invalid_request_error",
fmt.Sprintf("Task is not completed yet, current status: %s", task.Status))
return
}
+ var descriptor *relaychannel.TaskContentRequest
+ if taskHasPluginExecution(task) {
+ artifacts, projectionErr := projectTaskArtifacts(task)
+ if projectionErr == nil {
+ for _, artifact := range artifacts {
+ if artifact.Type != "video" {
+ continue
+ }
+ adaptor, adaptorErr := initTaskArtifactAdaptor(task)
+ if adaptorErr == nil {
+ if provider, ok := adaptor.(relaychannel.TaskContentRequestProvider); ok {
+ descriptor, adaptorErr = provider.BuildContentRequest(task, artifact.Key, relaychannel.TaskArtifactClientRequest{
+ Method: c.Request.Method,
+ Headers: taskArtifactClientHeaders(c.Request.Header),
+ })
+ }
+ }
+ if adaptorErr != nil {
+ logger.LogWarn(c.Request.Context(), fmt.Sprintf("Failed to resolve plugin video content for task %s", taskID))
+ descriptor = nil
+ }
+ break
+ }
+ } else {
+ logger.LogWarn(c.Request.Context(), fmt.Sprintf("Failed to project plugin video for task %s", taskID))
+ }
+ }
+ if descriptor == nil {
+ resultURL := task.GetResultURL()
+ if isTaskMediaFallbackLoop(resultURL, task.TaskID) {
+ writeTaskMediaProxyError(c, &taskMediaProxyError{
+ status: http.StatusGone, code: "artifact_gone",
+ message: "Artifact content is no longer available",
+ })
+ return
+ }
+ descriptor = &relaychannel.TaskContentRequest{
+ URL: resultURL,
+ Method: c.Request.Method,
+ Credentialless: true,
+ }
+ }
+ if err := proxyTaskMedia(c, task, descriptor); err != nil {
+ writeTaskMediaProxyError(c, err)
+ }
+}
+
+func proxyTaskMedia(c *gin.Context, task *model.Task, descriptor *relaychannel.TaskContentRequest) error {
+ if descriptor == nil {
+ return &taskMediaProxyError{
+ status: http.StatusInternalServerError, code: "artifact_plugin_error",
+ message: "Artifact content plugin returned no request",
+ }
+ }
+ rawURL := strings.TrimSpace(descriptor.URL)
+ if rawURL == "" {
+ return &taskMediaProxyError{
+ status: http.StatusGone, code: "artifact_gone",
+ message: "Artifact content is no longer available",
+ }
+ }
+ if strings.HasPrefix(rawURL, "data:") {
+ if len(rawURL) > taskMediaDataURLMaxEncodedBytes {
+ return &taskMediaProxyError{
+ status: http.StatusBadGateway, code: "artifact_request_rejected",
+ message: "Artifact request was rejected", err: errTaskMediaRequestRejected,
+ }
+ }
+ if err := writeVideoDataURL(c, rawURL); err != nil {
+ return &taskMediaProxyError{
+ status: http.StatusBadGateway, code: "artifact_upstream_error",
+ message: "Failed to decode artifact content", err: err,
+ }
+ }
+ return nil
+ }
+ if len(rawURL) > 64<<10 {
+ return &taskMediaProxyError{
+ status: http.StatusBadGateway, code: "artifact_request_rejected",
+ message: "Artifact request was rejected", err: errTaskMediaRequestRejected,
+ }
+ }
+
+ parsedURL, err := url.Parse(rawURL)
+ if err != nil || parsedURL == nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") ||
+ parsedURL.Host == "" || parsedURL.User != nil || parsedURL.Fragment != "" {
+ return &taskMediaProxyError{
+ status: http.StatusBadGateway, code: "artifact_request_rejected",
+ message: "Artifact request was rejected", err: errTaskMediaRequestRejected,
+ }
+ }
+ if isTaskMediaFallbackLoop(rawURL, task.TaskID) || isSelfTaskMediaURL(c, parsedURL) {
+ return &taskMediaProxyError{
+ status: http.StatusBadGateway, code: "artifact_request_rejected",
+ message: "Artifact proxy loop was rejected", err: errTaskMediaRequestRejected,
+ }
+ }
+
+ method := strings.ToUpper(strings.TrimSpace(descriptor.Method))
+ if method == "" {
+ method = c.Request.Method
+ }
+ switch method {
+ case http.MethodGet, http.MethodHead, http.MethodPost:
+ default:
+ return &taskMediaProxyError{
+ status: http.StatusBadGateway, code: "artifact_request_rejected",
+ message: "Artifact request method was rejected", err: errTaskMediaRequestRejected,
+ }
+ }
+ if len(descriptor.Body) > 1<<20 {
+ return &taskMediaProxyError{
+ status: http.StatusBadGateway, code: "artifact_request_rejected",
+ message: "Artifact request body was rejected", err: errTaskMediaRequestRejected,
+ }
+ }
+ if descriptor.Credentialless &&
+ (method != http.MethodGet && method != http.MethodHead ||
+ descriptor.Body != nil || len(descriptor.Headers) != 0) {
+ return &taskMediaProxyError{
+ status: http.StatusBadGateway, code: "artifact_request_rejected",
+ message: "Credentialless artifact request was rejected", err: errTaskMediaRequestRejected,
+ }
+ }
+
channel, err := model.CacheGetChannel(task.ChannelId)
if err != nil {
- logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get channel for task %s: %s", taskID, err.Error()))
- videoProxyError(c, http.StatusInternalServerError, "server_error", "Failed to retrieve channel information")
- return
+ return &taskMediaProxyError{
+ status: http.StatusServiceUnavailable, code: "artifact_plugin_unavailable",
+ message: "Artifact channel is unavailable", err: err,
+ }
}
- baseURL := channel.GetBaseURL()
- if baseURL == "" {
- baseURL = "https://api.openai.com"
+ proxy := strings.TrimSpace(channel.GetSetting().Proxy)
+ if err := validateTaskMediaURL(rawURL, proxy); err != nil {
+ return &taskMediaProxyError{
+ status: http.StatusBadGateway, code: "artifact_request_rejected",
+ message: "Artifact request was rejected", err: err,
+ }
}
- var videoURL string
- proxy := channel.GetSetting().Proxy
client := service.GetSSRFProtectedHTTPClient()
if proxy != "" {
- // 渠道代理路径的连接由代理侧建立,无法做拨号时逐 IP 校验,
- // 因此后面对 videoURL 保留请求前的一次性 SSRF 校验。
client, err = service.GetHttpClientWithProxy(proxy)
if err != nil {
- logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to create proxy client for task %s: %s", taskID, err.Error()))
- videoProxyError(c, http.StatusInternalServerError, "server_error", "Failed to create proxy client")
- return
+ return &taskMediaProxyError{
+ status: http.StatusInternalServerError, code: "artifact_internal_error",
+ message: "Failed to create artifact proxy client", err: err,
+ }
}
}
+ if client == nil {
+ client = http.DefaultClient
+ }
- ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
- defer cancel()
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, "", nil)
+ req, err := http.NewRequestWithContext(c.Request.Context(), method, parsedURL.String(), bytes.NewReader(descriptor.Body))
if err != nil {
- logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to create request: %s", err.Error()))
- videoProxyError(c, http.StatusInternalServerError, "server_error", "Failed to create proxy request")
- return
+ return &taskMediaProxyError{
+ status: http.StatusInternalServerError, code: "artifact_internal_error",
+ message: "Failed to create artifact request", err: err,
+ }
+ }
+ if err := applyTaskMediaRequestHeaders(req.Header, descriptor.Headers); err != nil {
+ return &taskMediaProxyError{
+ status: http.StatusBadGateway, code: "artifact_request_rejected",
+ message: "Artifact request headers were rejected", err: err,
+ }
+ }
+ clientHeaders := taskArtifactClientHeaders(c.Request.Header)
+ for name, value := range clientHeaders {
+ req.Header.Set(name, value)
}
- switch channel.Type {
- case constant.ChannelTypeGemini:
- apiKey := task.PrivateData.Key
- if apiKey == "" {
- logger.LogError(c.Request.Context(), fmt.Sprintf("Missing stored API key for Gemini task %s", taskID))
- videoProxyError(c, http.StatusInternalServerError, "server_error", "API key not stored for task")
- return
+ client = taskMediaRedirectClient(client, proxy, c, clientHeaders, descriptor.Credentialless)
+ clientWithoutBodyTimeout := *client
+ clientWithoutBodyTimeout.Timeout = 0
+ resp, err := doTaskMediaRequest(&clientWithoutBodyTimeout, req, taskMediaResponseHeaderTimeout)
+ if err != nil {
+ if errors.Is(err, errTaskMediaRequestRejected) {
+ return &taskMediaProxyError{
+ status: http.StatusBadGateway, code: "artifact_request_rejected",
+ message: "Artifact redirect was rejected", err: err,
+ }
}
- videoURL, err = getGeminiVideoURL(channel, task, apiKey)
- if err != nil {
- logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to resolve Gemini video URL for task %s: %s", taskID, err.Error()))
- videoProxyError(c, http.StatusBadGateway, "server_error", "Failed to resolve Gemini video URL")
- return
+ var netErr net.Error
+ if errors.Is(err, context.DeadlineExceeded) || errors.As(err, &netErr) && netErr.Timeout() {
+ return &taskMediaProxyError{
+ status: http.StatusGatewayTimeout, code: "artifact_upstream_timeout",
+ message: "Artifact upstream request timed out", err: err,
+ }
}
- req.Header.Set("x-goog-api-key", apiKey)
- case constant.ChannelTypeVertexAi:
- videoURL, err = getVertexVideoURL(channel, task)
- if err != nil {
- logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to resolve Vertex video URL for task %s: %s", taskID, err.Error()))
- videoProxyError(c, http.StatusBadGateway, "server_error", "Failed to resolve Vertex video URL")
- return
+ return &taskMediaProxyError{
+ status: http.StatusBadGateway, code: "artifact_upstream_error",
+ message: "Failed to fetch artifact content", err: err,
+ }
+ }
+ defer resp.Body.Close()
+
+ switch resp.StatusCode {
+ case http.StatusOK, http.StatusPartialContent, http.StatusNotModified, http.StatusRequestedRangeNotSatisfiable:
+ copyTaskMediaResponseHeaders(c.Writer.Header(), resp.Header)
+ setTaskMediaResponseSecurityHeaders(c.Writer.Header())
+ c.Status(resp.StatusCode)
+ c.Writer.WriteHeaderNow()
+ if c.Request.Method == http.MethodHead || resp.StatusCode == http.StatusNotModified {
+ return nil
+ }
+ if _, err := io.Copy(c.Writer, resp.Body); err != nil {
+ logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to stream task media: %v", err))
+ }
+ return nil
+ case http.StatusUnauthorized, http.StatusForbidden:
+ return &taskMediaProxyError{
+ status: http.StatusBadGateway, code: "artifact_upstream_auth_failed",
+ message: "Artifact upstream authentication failed",
+ }
+ case http.StatusNotFound, http.StatusGone:
+ return &taskMediaProxyError{
+ status: http.StatusGone, code: "artifact_gone",
+ message: "Artifact content is no longer available",
+ }
+ case http.StatusTooManyRequests:
+ if retryAfter := strings.TrimSpace(resp.Header.Get("Retry-After")); retryAfter != "" &&
+ len(retryAfter) <= 256 && !strings.ContainsAny(retryAfter, "\r\n") {
+ c.Header("Retry-After", retryAfter)
+ }
+ return &taskMediaProxyError{
+ status: http.StatusServiceUnavailable, code: "artifact_upstream_busy",
+ message: "Artifact upstream is busy",
}
- case constant.ChannelTypeOpenAI, constant.ChannelTypeSora:
- videoURL = fmt.Sprintf("%s/v1/videos/%s/content", baseURL, task.GetUpstreamTaskID())
- req.Header.Set("Authorization", "Bearer "+channel.Key)
default:
- // Video URL is stored in PrivateData.ResultURL (fallback to FailReason for old data)
- videoURL = task.GetResultURL()
+ return &taskMediaProxyError{
+ status: http.StatusBadGateway, code: "artifact_upstream_error",
+ message: fmt.Sprintf("Artifact upstream returned status %d", resp.StatusCode),
+ }
}
+}
- videoURL = strings.TrimSpace(videoURL)
- if videoURL == "" {
- logger.LogError(c.Request.Context(), fmt.Sprintf("Video URL is empty for task %s", taskID))
- videoProxyError(c, http.StatusBadGateway, "server_error", "Failed to fetch video content")
- return
+type taskMediaHTTPResult struct {
+ response *http.Response
+ err error
+}
+
+type taskMediaCancelBody struct {
+ io.ReadCloser
+ cancel context.CancelFunc
+ once sync.Once
+}
+
+func (b *taskMediaCancelBody) Close() error {
+ b.once.Do(b.cancel)
+ return b.ReadCloser.Close()
+}
+
+func doTaskMediaRequest(client *http.Client, request *http.Request, responseHeaderTimeout time.Duration) (*http.Response, error) {
+ if client == nil {
+ client = http.DefaultClient
}
+ requestContext, cancel := context.WithCancel(request.Context())
+ request = request.Clone(requestContext)
+ resultChannel := make(chan taskMediaHTTPResult, 1)
+ go func() {
+ response, err := client.Do(request)
+ resultChannel <- taskMediaHTTPResult{response: response, err: err}
+ }()
- if strings.HasPrefix(videoURL, "data:") {
- if err := writeVideoDataURL(c, videoURL); err != nil {
- logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to decode video data URL for task %s: %s", taskID, err.Error()))
- videoProxyError(c, http.StatusBadGateway, "server_error", "Failed to fetch video content")
+ timer := time.NewTimer(responseHeaderTimeout)
+ defer timer.Stop()
+ cleanupResult := func() {
+ go func() {
+ result := <-resultChannel
+ if result.response != nil && result.response.Body != nil {
+ _ = result.response.Body.Close()
+ }
+ }()
+ }
+
+ select {
+ case result := <-resultChannel:
+ if result.err != nil {
+ cancel()
+ if result.response != nil && result.response.Body != nil {
+ _ = result.response.Body.Close()
+ }
+ return nil, result.err
}
- return
+ if result.response == nil || result.response.Body == nil {
+ cancel()
+ return nil, errors.New("artifact upstream returned no response body")
+ }
+ result.response.Body = &taskMediaCancelBody{
+ ReadCloser: result.response.Body,
+ cancel: cancel,
+ }
+ return result.response, nil
+ case <-timer.C:
+ cancel()
+ cleanupResult()
+ return nil, context.DeadlineExceeded
+ case <-request.Context().Done():
+ cancel()
+ cleanupResult()
+ return nil, request.Context().Err()
+ }
+}
+
+func applyTaskMediaRequestHeaders(destination http.Header, headers map[string]string) error {
+ if len(headers) > 64 {
+ return errTaskMediaRequestRejected
}
+ for name, value := range headers {
+ name = strings.TrimSpace(name)
+ if !httpguts.ValidHeaderFieldName(name) || !httpguts.ValidHeaderFieldValue(value) || len(value) > 8192 {
+ return errTaskMediaRequestRejected
+ }
+ switch strings.ToLower(name) {
+ case "host", "content-length", "accept-encoding", "connection", "proxy-connection", "keep-alive",
+ "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade":
+ return errTaskMediaRequestRejected
+ }
+ destination.Set(name, value)
+ }
+ return nil
+}
- var validateErr error
+func taskMediaRedirectClient(base *http.Client, proxy string, c *gin.Context, clientHeaders map[string]string, credentialless bool) *http.Client {
+ cloned := *base
+ cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error {
+ if len(via) >= 10 {
+ return fmt.Errorf("%w: too many redirects", errTaskMediaRequestRejected)
+ }
+ if req.URL == nil || (req.URL.Scheme != "http" && req.URL.Scheme != "https") ||
+ req.URL.Host == "" || req.URL.User != nil || req.URL.Fragment != "" {
+ return fmt.Errorf("%w: invalid redirect URL", errTaskMediaRequestRejected)
+ }
+ if err := validateTaskMediaURL(req.URL.String(), proxy); err != nil {
+ return fmt.Errorf("%w: %v", errTaskMediaRequestRejected, err)
+ }
+ if isSelfTaskMediaURL(c, req.URL) {
+ return fmt.Errorf("%w: proxy loop", errTaskMediaRequestRejected)
+ }
+ if len(via) > 0 && !sameTaskMediaOrigin(via[len(via)-1].URL, req.URL) {
+ if !credentialless {
+ return fmt.Errorf("%w: credentialed cross-origin redirect", errTaskMediaRequestRejected)
+ }
+ for name := range req.Header {
+ req.Header.Del(name)
+ }
+ req.Body = http.NoBody
+ req.GetBody = nil
+ req.ContentLength = 0
+ }
+ for name, value := range clientHeaders {
+ req.Header.Set(name, value)
+ }
+ return nil
+ }
+ return &cloned
+}
+
+func validateTaskMediaURL(rawURL, proxy string) error {
if proxy == "" {
- validateErr = service.ValidateSSRFProtectedFetchURL(videoURL)
- } else {
- fetchSetting := system_setting.GetFetchSetting()
- validateErr = common.ValidateURLWithFetchSetting(videoURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain)
- }
- if validateErr != nil {
- logger.LogError(c.Request.Context(), fmt.Sprintf("Video URL blocked for task %s: %v", taskID, validateErr))
- videoProxyError(c, http.StatusForbidden, "server_error", fmt.Sprintf("request blocked: %v", validateErr))
- return
+ return service.ValidateSSRFProtectedFetchURL(rawURL)
}
+ fetchSetting := system_setting.GetFetchSetting()
+ return common.ValidateURLWithFetchSetting(
+ rawURL,
+ fetchSetting.EnableSSRFProtection,
+ fetchSetting.AllowPrivateIp,
+ fetchSetting.DomainFilterMode,
+ fetchSetting.IpFilterMode,
+ fetchSetting.DomainList,
+ fetchSetting.IpList,
+ fetchSetting.AllowedPorts,
+ fetchSetting.ApplyIPFilterForDomain,
+ )
+}
- req.URL, err = url.Parse(videoURL)
- if err != nil {
- logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to parse URL %s: %s", videoURL, err.Error()))
- videoProxyError(c, http.StatusInternalServerError, "server_error", "Failed to create proxy request")
- return
+func sameTaskMediaOrigin(left, right *url.URL) bool {
+ if left == nil || right == nil {
+ return false
}
+ return strings.EqualFold(left.Scheme, right.Scheme) &&
+ strings.EqualFold(normalizeTaskMediaHost(left.Scheme, left.Host), normalizeTaskMediaHost(right.Scheme, right.Host))
+}
- resp, err := client.Do(req)
+func normalizeTaskMediaHost(scheme, host string) string {
+ host = strings.ToLower(strings.TrimSpace(host))
+ hostname, port, err := net.SplitHostPort(host)
if err != nil {
- logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to fetch video from %s: %s", videoURL, err.Error()))
- videoProxyError(c, http.StatusBadGateway, "server_error", "Failed to fetch video content")
- return
+ return strings.TrimSuffix(host, ".")
}
- defer resp.Body.Close()
+ hostname = strings.TrimSuffix(strings.ToLower(hostname), ".")
+ if (strings.EqualFold(scheme, "http") && port == "80") || (strings.EqualFold(scheme, "https") && port == "443") {
+ return hostname
+ }
+ return net.JoinHostPort(hostname, port)
+}
- if resp.StatusCode != http.StatusOK {
- logger.LogError(c.Request.Context(), fmt.Sprintf("Upstream returned status %d for %s", resp.StatusCode, videoURL))
- videoProxyError(c, http.StatusBadGateway, "server_error",
- fmt.Sprintf("Upstream service returned status %d", resp.StatusCode))
- return
+func isSelfTaskMediaURL(c *gin.Context, target *url.URL) bool {
+ if c == nil || target == nil || !isTaskMediaProxyPath(target.Path) {
+ return false
+ }
+ targetHost := normalizeTaskMediaHost(target.Scheme, target.Host)
+ if targetHost == "" {
+ return true
+ }
+ scheme := strings.TrimSpace(strings.Split(c.Request.Header.Get("X-Forwarded-Proto"), ",")[0])
+ if scheme == "" {
+ scheme = "http"
+ if c.Request.TLS != nil {
+ scheme = "https"
+ }
+ }
+ hosts := []string{c.Request.Host}
+ if forwardedHost := strings.TrimSpace(strings.Split(c.Request.Header.Get("X-Forwarded-Host"), ",")[0]); forwardedHost != "" {
+ hosts = append(hosts, forwardedHost)
+ }
+ for _, host := range hosts {
+ if strings.EqualFold(targetHost, normalizeTaskMediaHost(scheme, host)) {
+ return true
+ }
+ }
+ return false
+}
+
+func isTaskMediaProxyPath(path string) bool {
+ if strings.HasPrefix(path, "/v1/videos/") && strings.HasSuffix(path, "/content") {
+ return true
+ }
+ return strings.HasPrefix(path, "/v1/tasks/") &&
+ strings.Contains(path, "/artifacts/") &&
+ strings.HasSuffix(path, "/content")
+}
+
+func isTaskMediaFallbackLoop(rawURL, taskID string) bool {
+ parsedURL, err := url.Parse(strings.TrimSpace(rawURL))
+ if err != nil || parsedURL == nil {
+ return false
+ }
+ path, err := url.PathUnescape(parsedURL.EscapedPath())
+ if err != nil {
+ path = parsedURL.Path
}
+ if path == "/v1/videos/"+taskID+"/content" {
+ return true
+ }
+ artifactPrefix := "/v1/tasks/" + taskID + "/artifacts/"
+ return strings.HasPrefix(path, artifactPrefix) && strings.HasSuffix(path, "/content")
+}
- for key, values := range resp.Header {
- for _, value := range values {
- c.Writer.Header().Add(key, value)
+func copyTaskMediaResponseHeaders(destination, source http.Header) {
+ for _, name := range []string{
+ "Content-Type",
+ "Content-Length",
+ "Content-Range",
+ "Accept-Ranges",
+ "ETag",
+ "Last-Modified",
+ "Content-Disposition",
+ } {
+ for _, value := range source.Values(name) {
+ destination.Add(name, value)
}
}
+}
+
+func setTaskMediaResponseSecurityHeaders(header http.Header) {
+ header.Set("Cache-Control", "private, no-store")
+ header.Set("Content-Security-Policy", "sandbox; default-src 'none'")
+ header.Set("Referrer-Policy", "no-referrer")
+ header.Set("X-Content-Type-Options", "nosniff")
+}
- c.Writer.Header().Set("Cache-Control", "public, max-age=86400")
- c.Writer.WriteHeader(resp.StatusCode)
- if _, err = io.Copy(c.Writer, resp.Body); err != nil {
- logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to stream video content: %s", err.Error()))
+func writeTaskMediaProxyError(c *gin.Context, err error) {
+ if c.Writer.Written() {
+ logger.LogError(c.Request.Context(), err.Error())
+ return
}
+ var proxyErr *taskMediaProxyError
+ if !errors.As(err, &proxyErr) {
+ proxyErr = &taskMediaProxyError{
+ status: http.StatusBadGateway, code: "artifact_upstream_error",
+ message: "Failed to fetch artifact content", err: err,
+ }
+ }
+ c.Header("Cache-Control", "private, no-store")
+ writeTaskArtifactError(c, proxyErr.status, proxyErr.code, proxyErr.message)
}
func writeVideoDataURL(c *gin.Context, dataURL string) error {
+ if len(dataURL) > taskMediaDataURLMaxEncodedBytes {
+ return errTaskMediaRequestRejected
+ }
parts := strings.SplitN(dataURL, ",", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid data url")
@@ -199,18 +591,31 @@ func writeVideoDataURL(c *gin.Context, dataURL string) error {
if mimeType == "" {
mimeType = "video/mp4"
}
+ if len(mimeType) > 255 || !httpguts.ValidHeaderFieldValue(mimeType) {
+ return fmt.Errorf("invalid data url media type")
+ }
- videoBytes, err := base64.StdEncoding.DecodeString(payload)
- if err != nil {
- videoBytes, err = base64.RawStdEncoding.DecodeString(payload)
- if err != nil {
- return err
+ var encoding *base64.Encoding
+ var contentLength int64
+ for _, candidate := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding} {
+ decodedLength, err := io.Copy(io.Discard, base64.NewDecoder(candidate, strings.NewReader(payload)))
+ if err == nil {
+ encoding = candidate
+ contentLength = decodedLength
+ break
}
}
+ if encoding == nil {
+ return fmt.Errorf("invalid base64 data")
+ }
c.Writer.Header().Set("Content-Type", mimeType)
- c.Writer.Header().Set("Cache-Control", "public, max-age=86400")
+ c.Writer.Header().Set("Content-Length", strconv.FormatInt(contentLength, 10))
+ setTaskMediaResponseSecurityHeaders(c.Writer.Header())
c.Writer.WriteHeader(http.StatusOK)
- _, err = c.Writer.Write(videoBytes)
+ if c.Request.Method == http.MethodHead {
+ return nil
+ }
+ _, err := io.Copy(c.Writer, base64.NewDecoder(encoding, strings.NewReader(payload)))
return err
}
diff --git a/controller/video_proxy_gemini.go b/controller/video_proxy_gemini.go
deleted file mode 100644
index 0c76e33c709a..000000000000
--- a/controller/video_proxy_gemini.go
+++ /dev/null
@@ -1,294 +0,0 @@
-package controller
-
-import (
- "fmt"
- "io"
- "strconv"
- "strings"
-
- "github.com/QuantumNous/new-api/common"
- "github.com/QuantumNous/new-api/constant"
- "github.com/QuantumNous/new-api/model"
- "github.com/QuantumNous/new-api/relay"
-)
-
-func getGeminiVideoURL(channel *model.Channel, task *model.Task, apiKey string) (string, error) {
- if channel == nil || task == nil {
- return "", fmt.Errorf("invalid channel or task")
- }
-
- if url := extractGeminiVideoURLFromTaskData(task); url != "" {
- return ensureAPIKey(url, apiKey), nil
- }
-
- baseURL := constant.ChannelBaseURLs[channel.Type]
- if channel.GetBaseURL() != "" {
- baseURL = channel.GetBaseURL()
- }
-
- adaptor := relay.GetTaskAdaptor(constant.TaskPlatform(strconv.Itoa(channel.Type)))
- if adaptor == nil {
- return "", fmt.Errorf("gemini task adaptor not found")
- }
-
- if apiKey == "" {
- return "", fmt.Errorf("api key not available for task")
- }
-
- proxy := channel.GetSetting().Proxy
- resp, err := adaptor.FetchTask(baseURL, apiKey, map[string]any{
- "task_id": task.GetUpstreamTaskID(),
- "action": task.Action,
- }, proxy)
- if err != nil {
- return "", fmt.Errorf("fetch task failed: %w", err)
- }
- defer resp.Body.Close()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return "", fmt.Errorf("read task response failed: %w", err)
- }
-
- taskInfo, parseErr := adaptor.ParseTaskResult(body)
- if parseErr == nil && taskInfo != nil && taskInfo.RemoteUrl != "" {
- return ensureAPIKey(taskInfo.RemoteUrl, apiKey), nil
- }
-
- if url := extractGeminiVideoURLFromPayload(body); url != "" {
- return ensureAPIKey(url, apiKey), nil
- }
-
- if parseErr != nil {
- return "", fmt.Errorf("parse task result failed: %w", parseErr)
- }
-
- return "", fmt.Errorf("gemini video url not found")
-}
-
-func extractGeminiVideoURLFromTaskData(task *model.Task) string {
- if task == nil || len(task.Data) == 0 {
- return ""
- }
- var payload map[string]any
- if err := common.Unmarshal(task.Data, &payload); err != nil {
- return ""
- }
- return extractGeminiVideoURLFromMap(payload)
-}
-
-func extractGeminiVideoURLFromPayload(body []byte) string {
- var payload map[string]any
- if err := common.Unmarshal(body, &payload); err != nil {
- return ""
- }
- return extractGeminiVideoURLFromMap(payload)
-}
-
-func extractGeminiVideoURLFromMap(payload map[string]any) string {
- if payload == nil {
- return ""
- }
- if uri, ok := payload["uri"].(string); ok && uri != "" {
- return uri
- }
- if resp, ok := payload["response"].(map[string]any); ok {
- if uri := extractGeminiVideoURLFromResponse(resp); uri != "" {
- return uri
- }
- }
- return ""
-}
-
-func extractGeminiVideoURLFromResponse(resp map[string]any) string {
- if resp == nil {
- return ""
- }
- if gvr, ok := resp["generateVideoResponse"].(map[string]any); ok {
- if uri := extractGeminiVideoURLFromGeneratedSamples(gvr); uri != "" {
- return uri
- }
- }
- if videos, ok := resp["videos"].([]any); ok {
- for _, video := range videos {
- if vm, ok := video.(map[string]any); ok {
- if uri, ok := vm["uri"].(string); ok && uri != "" {
- return uri
- }
- }
- }
- }
- if uri, ok := resp["video"].(string); ok && uri != "" {
- return uri
- }
- if uri, ok := resp["uri"].(string); ok && uri != "" {
- return uri
- }
- return ""
-}
-
-func extractGeminiVideoURLFromGeneratedSamples(gvr map[string]any) string {
- if gvr == nil {
- return ""
- }
- if samples, ok := gvr["generatedSamples"].([]any); ok {
- for _, sample := range samples {
- if sm, ok := sample.(map[string]any); ok {
- if video, ok := sm["video"].(map[string]any); ok {
- if uri, ok := video["uri"].(string); ok && uri != "" {
- return uri
- }
- }
- }
- }
- }
- return ""
-}
-
-func getVertexVideoURL(channel *model.Channel, task *model.Task) (string, error) {
- if channel == nil || task == nil {
- return "", fmt.Errorf("invalid channel or task")
- }
- if url := strings.TrimSpace(task.GetResultURL()); url != "" && !isTaskProxyContentURL(url, task.TaskID) {
- return url, nil
- }
- if url := extractVertexVideoURLFromTaskData(task); url != "" {
- return url, nil
- }
-
- baseURL := constant.ChannelBaseURLs[channel.Type]
- if channel.GetBaseURL() != "" {
- baseURL = channel.GetBaseURL()
- }
-
- adaptor := relay.GetTaskAdaptor(constant.TaskPlatform(strconv.Itoa(channel.Type)))
- if adaptor == nil {
- return "", fmt.Errorf("vertex task adaptor not found")
- }
-
- key := getVertexTaskKey(channel, task)
- if key == "" {
- return "", fmt.Errorf("vertex key not available for task")
- }
-
- resp, err := adaptor.FetchTask(baseURL, key, map[string]any{
- "task_id": task.GetUpstreamTaskID(),
- "action": task.Action,
- }, channel.GetSetting().Proxy)
- if err != nil {
- return "", fmt.Errorf("fetch task failed: %w", err)
- }
- defer resp.Body.Close()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return "", fmt.Errorf("read task response failed: %w", err)
- }
-
- taskInfo, parseErr := adaptor.ParseTaskResult(body)
- if parseErr == nil && taskInfo != nil && strings.TrimSpace(taskInfo.Url) != "" {
- return taskInfo.Url, nil
- }
- if url := extractVertexVideoURLFromPayload(body); url != "" {
- return url, nil
- }
- if parseErr != nil {
- return "", fmt.Errorf("parse task result failed: %w", parseErr)
- }
- return "", fmt.Errorf("vertex video url not found")
-}
-
-func isTaskProxyContentURL(url string, taskID string) bool {
- if strings.TrimSpace(url) == "" || strings.TrimSpace(taskID) == "" {
- return false
- }
- return strings.Contains(url, "/v1/videos/"+taskID+"/content")
-}
-
-func getVertexTaskKey(channel *model.Channel, task *model.Task) string {
- if task != nil {
- if key := strings.TrimSpace(task.PrivateData.Key); key != "" {
- return key
- }
- }
- if channel == nil {
- return ""
- }
- keys := channel.GetKeys()
- for _, key := range keys {
- key = strings.TrimSpace(key)
- if key != "" {
- return key
- }
- }
- return strings.TrimSpace(channel.Key)
-}
-
-func extractVertexVideoURLFromTaskData(task *model.Task) string {
- if task == nil || len(task.Data) == 0 {
- return ""
- }
- return extractVertexVideoURLFromPayload(task.Data)
-}
-
-func extractVertexVideoURLFromPayload(body []byte) string {
- var payload map[string]any
- if err := common.Unmarshal(body, &payload); err != nil {
- return ""
- }
- resp, ok := payload["response"].(map[string]any)
- if !ok || resp == nil {
- return ""
- }
-
- if videos, ok := resp["videos"].([]any); ok && len(videos) > 0 {
- if video, ok := videos[0].(map[string]any); ok && video != nil {
- if b64, _ := video["bytesBase64Encoded"].(string); strings.TrimSpace(b64) != "" {
- mime, _ := video["mimeType"].(string)
- enc, _ := video["encoding"].(string)
- return buildVideoDataURL(mime, enc, b64)
- }
- }
- }
- if b64, _ := resp["bytesBase64Encoded"].(string); strings.TrimSpace(b64) != "" {
- enc, _ := resp["encoding"].(string)
- return buildVideoDataURL("", enc, b64)
- }
- if video, _ := resp["video"].(string); strings.TrimSpace(video) != "" {
- if strings.HasPrefix(video, "data:") || strings.HasPrefix(video, "http://") || strings.HasPrefix(video, "https://") {
- return video
- }
- enc, _ := resp["encoding"].(string)
- return buildVideoDataURL("", enc, video)
- }
- return ""
-}
-
-func buildVideoDataURL(mimeType string, encoding string, base64Data string) string {
- mime := strings.TrimSpace(mimeType)
- if mime == "" {
- enc := strings.TrimSpace(encoding)
- if enc == "" {
- enc = "mp4"
- }
- if strings.Contains(enc, "/") {
- mime = enc
- } else {
- mime = "video/" + enc
- }
- }
- return "data:" + mime + ";base64," + base64Data
-}
-
-func ensureAPIKey(uri, key string) string {
- if key == "" || uri == "" {
- return uri
- }
- if strings.Contains(uri, "key=") {
- return uri
- }
- if strings.Contains(uri, "?") {
- return fmt.Sprintf("%s&key=%s", uri, key)
- }
- return fmt.Sprintf("%s?key=%s", uri, key)
-}
diff --git a/docs/plugin-api/README.md b/docs/plugin-api/README.md
new file mode 100644
index 000000000000..bb188cec040d
--- /dev/null
+++ b/docs/plugin-api/README.md
@@ -0,0 +1,138 @@
+# Task plugin API v1
+
+Task plugins are single-file synchronous ECMAScript modules. The plugin contract
+is currently unreleased; [`v1.schema.json`](./v1.schema.json) and
+[`v1.d.ts`](./v1.d.ts) are the authoritative v1 contract.
+
+## Contract and lifecycle
+
+Every plugin exports `meta`, `buildSubmitRequest`, `parseSubmitResponse`, and
+`parseTaskResult`. A `per_task` plugin also exports `buildQueryRequest`; a
+`batch` plugin exports `buildBatchQueryRequest` and `parseBatchResult`.
+`meta.author.name` is required and `meta.author.url`, when present, must be an
+absolute HTTP(S) URL. This is self-declared attribution; a future marketplace's
+verified publisher identity is a separate host-owned record.
+Plugins may declare authenticated vendor-native `meta.routes` and claim
+host-owned names through `meta.protocols`. Submit and dynamic routes name a
+`native` decoder and presenter; query routes name only a presenter. Protocol
+bindings are registered once by the host registry, and protocol decoders receive
+the host-parsed `body` union plus the pinned model. Shared protocol hooks are
+synchronous transformations; Go owns connections and wire framing.
+
+The host selects a channel, invokes the request-building hook, validates the
+returned URL against the channel host, performs HTTP, and gives the decoded
+response to the matching parse hook. It owns persistence, retries, polling,
+billing, and settlement. Plugins only transform data and report usage facts.
+See [v1.d.ts](./v1.d.ts) for signatures and
+[v1.schema.json](./v1.schema.json) for machine-readable shapes.
+
+Plugins that expose task outputs export `listArtifacts(task)` and
+`buildContentRequest(ctx)` together. Artifacts are projected on explicit reads
+from persisted `Task.Data`; they are never stored as a second source of truth.
+The list contains only stable `key`, `type`, and optional `mimeType` fields.
+The content hook receives the selected key, raw decoded task data, the explicit
+private upstream task id, the producer plugin version, channel authentication,
+and a safe client Range/conditional-header subset. Its URL and headers exist
+only for that proxy request.
+
+When a Responses observation reaches persisted `SUCCESS`, the host also runs
+the pinned plugin's `listArtifacts` and injects a read-only
+`ctx.artifacts[key] = {key, type, mimeType?, url}` map into `renderEvents` or
+`renderFinal`. Each `url` is a long-lived host-signed capability URL, never the
+provider URL from `Task.Data`. Nonterminal and failed tasks receive no artifact
+map. Capability construction or rendering failure fails only that Responses
+observation; it cannot change the task, billing settlement, or refunds.
+The absolute URL uses `TaskPublicAddress`, falling back only to
+`ServerAddress`; multi-node deployments must share the effective
+`CRYPTO_SECRET`.
+Dashboard artifact reads return each `content_url` (or the legacy
+`legacy_content_url`) directly, without a temporary URL exchange. Capability
+generation and verification are stateless and have no expiry; after
+verification the host still loads the task, owner, and plugin needed to serve
+the artifact. Rotating `CRYPTO_SECRET` invalidates issued URLs. The `access`
+query is redacted before request logging.
+Deployment boundaries and concurrency environment variables are documented in
+[v1.md](./v1.md#generic-task-management-api).
+
+The host treats `protocols.openai_video.render` as a standard DTO, not an arbitrary
+JSON passthrough. Unknown top-level fields and legacy `task_id` are removed,
+`id` is forced to the public task id, and case-insensitive `url` entries are
+removed from metadata. Provider output URLs belong only behind artifact
+capabilities.
+
+Provider-authenticated content URLs must use the channel base host or a
+plugin-declared `meta.allowedHosts` entry. A public dynamic CDN URL may instead
+set `credentialless: true`; the host then permits only GET/HEAD with no
+plugin-supplied headers or body and applies SSRF checks to the initial URL and
+every redirect.
+
+Registry publication is generation-atomic. A request pins one plugin generation
+for its full lifetime, while background polling may use a later active plugin
+version. New versions must continue parsing responses for in-flight tasks.
+Root administrators can inspect the local node with
+`GET /api/plugin/task/runtime/status`. The response includes the node-local generation,
+a deterministic revision of the active database overrides, the latest rebuild
+outcome, and plugin-level compile or routing errors. Generation numbers are
+local to a node; compare database revisions when diagnosing rollout lag between
+nodes. If the database snapshot is temporarily unavailable, the endpoint keeps
+serving node-local state and the last known revision with `database_error` set.
+
+For live diagnosis, start the process with `DEBUG=true` and filter logs on
+`task_plugin`. Plugin registry, routing, endpoint ownership, channel selection,
+submit durability, polling adapters, and protocol observation emit safe
+key/value lifecycle events. Request-context events carry the request id;
+scheduled, background, and context-less work is labeled `SYSTEM`. Plugin
+`console.log` output is also forwarded in DEBUG mode. Hook-time output is
+prefixed with plugin key/version; module-initialization output may have an empty
+identity during initial upload validation. Do not print credentials, headers,
+request bodies, upstream payloads, or private URLs from plugin code; free-form
+console output cannot be redacted by the host.
+
+## Fixtures and dry runs
+
+A fixture case is `{name?, hook, member?, args, expected?, expectedError?}`.
+Keep deterministic cases for every exported hook, its main error branch, batch
+behavior, renderers, usage, and content requests. Run a fixture locally with:
+
+```sh
+new-api plugin lint plugin.js
+new-api plugin test plugin.js --fixture golden.json
+```
+
+Root administrators can open the plugin detail Sandbox tab, choose a hook, and
+submit an `args` JSON array. `POST /api/plugin/task/:key/dryrun` compiles the
+active database source or factory source in a temporary registry and invokes
+only that synchronous function. Dry runs never execute a request descriptor and
+therefore never contact an upstream service.
+
+## Upload and release
+
+Upload from the root-only task plugin page or `POST /api/plugin/task` with
+`{"source":"...","remark":"..."}`. The server compiles the module, validates
+v1 metadata and required exports, and rejects invalid source before saving it.
+Use semantic plugin versions. Reusing a key/version with different source is
+rejected; activate or roll back a stored version through the management page.
+
+For a third-party platform, create a channel of type `Task Plugin`, select the
+plugin key, provide an explicit base URL, and configure models. Clients may use
+the plugin's declared native routes. The generic management surface remains
+`POST /v1/tasks/:pluginKey`, `GET /v1/tasks/:taskId`, and
+`GET /v1/tasks/:taskId/artifacts` plus
+`GET|HEAD /v1/tasks/:taskId/artifacts/:key/content`.
+
+## Security boundary
+
+Plugins have no `fetch`, filesystem, `require`, imports, async functions, or
+environment access. The host limits execution time, concurrency, input size,
+allowed request hosts, and resolves OAuth credentials outside JavaScript.
+Multipart files enter JavaScript only as opaque references.
+
+This is not a hard memory-isolation boundary. A plugin sees data needed for the
+current request and can influence an authenticated upstream request. Uploading a
+plugin is an administrator-level trust decision equivalent to configuring a
+channel credential. Review source and version diffs before activation. Never run
+untrusted plugins merely because they compile.
+
+Usage hooks may return facts such as seconds, resolution, or upstream units, but
+must never calculate prices or attempt quota settlement. The host owns all
+pricing and clamps billing conversions.
diff --git a/docs/plugin-api/v1.d.ts b/docs/plugin-api/v1.d.ts
new file mode 100644
index 000000000000..11911727e5b8
--- /dev/null
+++ b/docs/plugin-api/v1.d.ts
@@ -0,0 +1,48 @@
+export type JSONValue = null | boolean | number | string | readonly JSONValue[] | {readonly [key: string]: JSONValue};
+export type FileReference = Readonly<{ref: string; field: string; filename: string; mimeType: string; size: number}>;
+export type FilePlaceholder = Readonly<{__fileRef: string; encoding: "base64" | "dataUrl"; mimeType?: string; maxBytes?: number}>;
+export type DecodedBody =
+ | Readonly<{kind: "json"; value: JSONValue}>
+ | Readonly<{kind: "form"; fields: Readonly>}>
+ | Readonly<{kind: "multipart"; fields: Readonly>; files: readonly FileReference[]}>
+ | Readonly<{kind: "none"}>;
+
+export interface NativeDecodeContext {method: string; path: string; params: Readonly>; query: Readonly>; body: DecodedBody}
+export interface ProtocolDecodeContext extends NativeDecodeContext {protocol: "openai_responses" | "openai_video"; operation: string; model: string; stream: boolean}
+export type SubmitIntent = {kind: "submit"; model: string; action?: string; requestBody?: unknown; originTaskIds?: readonly string[]};
+export type QueryIntent = {kind: "query"; taskIds: readonly string[]};
+export type TaskIntent = SubmitIntent | QueryIntent;
+export interface NativeRoute {method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; path: string; type: "submit" | "query" | "dynamic"; action?: string; taskIdParam?: string; decode?: string; render: string; models?: readonly string[]}
+export type ProtocolName = "openai_responses" | "openai_video";
+export type ResponsesMode = "stream" | "sync" | "background";
+export type ProtocolClaim =
+ | "openai_video"
+ | {name: "openai_responses"; supports: readonly ResponsesMode[]; models?: readonly string[]}
+ | {name: "openai_video"; models?: readonly string[]};
+export type LocalizedText = string | ({ en: string } & Record);
+export type UsageFieldSchema = {type: "number"; unit: "second" | "count" | "token" | "credit"; description?: LocalizedText} | {type: "boolean"; description?: LocalizedText} | {enum: readonly string[]; description?: LocalizedText};
+export type UsageExample = {label: string; facts: Readonly>};
+export interface Meta {apiVersion: 1; key: string; name: string; icon?: string; description?: LocalizedText; version: string; author: {name: string; url?: string}; channelTypes?: readonly number[]; models: readonly string[]; fetchMode: "per_task" | "batch"; allowedHosts?: readonly string[]; routes?: readonly NativeRoute[]; protocols?: readonly ProtocolClaim[]; usageSchema?: Readonly>; usageExamples?: readonly UsageExample[]; auth?: "none" | "api_key" | "vertex_oauth" | {type: "none" | "api_key" | "oauth2_jwt"}}
+export interface TaskView {task_id: string; status: string; progress?: string; fail_reason?: string; created_at?: number; updated_at?: number; data?: unknown; properties?: Record}
+export interface DriverContext {requestBody: unknown; requestHeaders: Readonly>; action: string; model: string; upstreamModel: string; baseUrl: string; apiKey?: string; authHeader: string; files: readonly FileReference[]; publicTaskId: string; originTasks?: readonly {taskId: string; upstreamTaskId: string; action: string; status: string; data: unknown}[]}
+export interface RequestDescriptor {url: string; method?: string; headers?: Record; /** JSON body may contain FilePlaceholder objects at any depth; the host replaces each with a Base64 or data-URL string. */ body?: unknown; credentialless?: boolean; action?: string; model?: string; rewriteModel?: string; bodyType?: "json" | "multipart"; parts?: readonly {name: string; value?: unknown; fileRef?: string; filename?: string}[]}
+export interface UpstreamResponse {statusCode: number; headers: Readonly>; body: unknown}
+export interface NormalizedTaskResult {taskId?: string; status: "NOT_START" | "SUBMITTED" | "QUEUED" | "IN_PROGRESS" | "SUCCESS" | "FAILURE" | "UNKNOWN"; progress?: string; reason?: string; url?: string; remoteUrl?: string; completionTokens?: number; totalTokens?: number}
+export interface TaskArtifact {key: string; type: "video" | "audio" | "image" | "file"; mimeType?: string}
+export declare const meta: Meta;
+export declare const native: Record TaskIntent) | ((ctx: NativeDecodeContext, task: TaskView | readonly TaskView[]) => unknown)> & {error?: (ctx: NativeDecodeContext, error: {code: string; message: string; httpStatus: number; retryable: boolean}) => unknown};
+export declare const protocols: {
+ openai_responses?: {decodeRequest(ctx: ProtocolDecodeContext): SubmitIntent; renderEvents?(ctx: unknown, task: TaskView, previousState: unknown): unknown; renderFinal?(ctx: unknown, task: TaskView): unknown};
+ openai_video?: {decodeRequest(ctx: ProtocolDecodeContext): SubmitIntent; render(ctx: unknown, task: TaskView): unknown};
+};
+export declare function buildSubmitRequest(ctx: DriverContext): RequestDescriptor;
+export declare function parseSubmitResponse(ctx: DriverContext, response: UpstreamResponse): {taskId: string; taskData?: unknown; immediate?: NormalizedTaskResult};
+export declare function buildQueryRequest(ctx: DriverContext & {taskId: string}): RequestDescriptor;
+export declare function buildBatchQueryRequest(ctx: DriverContext, taskIds: readonly string[]): RequestDescriptor;
+export declare function parseTaskResult(ctx: DriverContext, body: unknown): NormalizedTaskResult;
+export declare function parseBatchResult(ctx: DriverContext, body: unknown): readonly (NormalizedTaskResult & {taskId: string; data?: unknown})[];
+export declare function extractUsage(ctx: DriverContext & {usagePurpose?: "facts" | "billing_ratios"}): Readonly> | null;
+export declare function extractUsageOnSubmit(ctx: DriverContext, taskData: unknown): Readonly> | null;
+export declare function extractUsageOnComplete(task: TaskView, result: NormalizedTaskResult, data: unknown): Readonly> | null;
+export declare function listArtifacts(task: {taskId: string; status: string; action: string; data: unknown; producerVersion: string}): readonly TaskArtifact[];
+export declare function buildContentRequest(ctx: DriverContext & {artifactKey: string; data: unknown; upstreamTaskId: string; clientRequest: {method: "GET" | "HEAD"; headers: Readonly>}}): RequestDescriptor;
diff --git a/docs/plugin-api/v1.md b/docs/plugin-api/v1.md
new file mode 100644
index 000000000000..814513b06ffa
--- /dev/null
+++ b/docs/plugin-api/v1.md
@@ -0,0 +1,140 @@
+# Task Plugin API v1
+
+Task Plugin v1 has two independent entry surfaces. `meta.routes` registers plugin-owned native URLs; `meta.protocols` claims host-owned protocols without registering or copying their URLs. `apiVersion` remains `1`.
+
+## Manifest
+
+```js
+export const meta = {
+ apiVersion: 1,
+ key: "vendor",
+ name: "Vendor",
+ version: "1.0.0",
+ author: {name: "Author"},
+ description: {en: "Video generation via the vendor API", zh: "通过厂商接口生成视频"},
+ models: ["vendor-model"],
+ fetchMode: "per_task",
+ routes: [
+ {method: "POST", path: "/vendor/v1/jobs", type: "submit", decode: "createJob", render: "jobCreated"},
+ {method: "GET", path: "/vendor/v1/jobs/:task_id", type: "query", render: "jobStatus"},
+ ],
+ protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}, "openai_video"],
+};
+```
+
+`submit` and `dynamic` routes require `decode` and `render`. `query` routes require `render`, prohibit `decode`, and use `taskIdParam` (default `task_id`). Names refer only to callable members of `native`. Route conflicts use method plus normalized path shape and the route index is published atomically with its plugin generation.
+
+`routes[].models` optionally restricts a `submit` or `dynamic` route to a subset of `meta.models`. The host matches the canonical top-level `model` body field before any JS hook runs; a missing, non-string, or unlisted model is rejected with 400 (plugin routes are exclusive paths, so there is no relay fallback). Declare it only when the model lives at the body top level — vendor formats that nest the model elsewhere should omit `models` and filter inside `decode`. `query` routes have no body and reject the field.
+
+Each `protocols` entry claims a host protocol. A protocol that defines modes must be claimed in object form with an explicit `supports`; the bare-string form is valid only for protocols without modes (`openai_video`). `supports` declares which client request forms the plugin accepts on `openai_responses`: `"stream"` (`stream: true`), `"sync"` (neither flag; the call blocks until the terminal Response), `"background"` (`background: true`; the create returns a pending Response immediately). An unsupported request form is rejected with a 400 at channel selection, before any plugin hook runs and before billing. Retrieval (`GET /v1/responses/:response_id`) is not a mode: every created response is always retrievable.
+
+`{name, models}` still narrows that protocol's endpoint bindings to a subset of `meta.models` and composes with `supports`. Unlisted models never enter the plugin on that protocol path — they fall through to the built-in Go relay. Cross-plugin endpoint conflicts are judged on the narrowed set, so two plugins may share one protocol path by claiming disjoint model sets.
+
+Enabled uploads pre-flight the candidate against the live routing generation and reject the first channel-type, native-route, or protocol-model conflict (the error names the counterpart plugin). Set `force: true` or `enabled: false` to store the plugin anyway.
+
+`endpoints`, `routes[].renderer`, global `resolveRequest`, global `renderError`, and global `renderers` are rejected. `parseSubmitResponse` returns only `{taskId, taskData}` (plus the documented lifecycle fields); `clientResponse` is rejected.
+
+`icon` is an optional LobeHub icon name string (for example `Sora.Color`). The values `text` and `text:` request a generated text avatar instead (label defaults to the first two characters of `name`). It is display-only and does not participate in routing, billing, or admission beyond type and length checks.
+
+| Field | Type | Notes |
+|-------|------|-------|
+| `key` | string | Required. Canonical plugin id, ≤ 30 characters. |
+| `name` | string | Required. Display name. |
+| `icon` | string | Optional LobeHub icon or `text` / `text:`. ≤ 128 characters. |
+| `description` | LocalizedText | Optional plugin summary. See LocalizedText. ≤ 512 runes per locale. |
+| `version` | string | Required semver. |
+| `author` | `{name, url?}` | Required name; `url`, when present, must be an absolute HTTP(S) URL. |
+
+`channelTypes` lists the legacy channel types this plugin's driver can drive (for example, sora declares `[55, 1]` because the same OpenAI-type base URL and bearer key serve both chat and video). Every entry equally participates in channel selection, historical `Task.Platform` matching, and the `byChannelType` routing index; the same type value may not appear on two plugins. Third-party plugins normally omit `channelTypes` and live on type-59 "Task Plugin" channels bound by `task_plugin_key`. The previous split identity/compatibility field names are rejected.
+
+Numeric `usageSchema` fields declare a host-owned unit of `second`, `count`, `token`, or `credit`. Boolean fields declare `{type: "boolean"}`.
+
+`meta.usageExamples` is an optional display-only list of pricing examples. Each `label` is a human-readable spec name. Each `facts` object must be a complete vector over `usageSchema`: every declared key present, no undeclared keys, and values that pass the same validation as usage facts. Plugins whose schema contains a `unit: "token"` number field must declare at least one example. Examples never affect billing. Labels are spec names such as `std · 10s`, not storefront prices.
+
+## LocalizedText
+
+`meta.description` and each `usageSchema` field `description` accept LocalizedText. A bare string is equivalent to `{en: }`. A map must include a non-empty `en` value. The host normalizes both forms to a map; API responses always emit an object.
+
+```js
+description: "Video generation via the vendor API"
+description: {en: "Video generation via the vendor API", zh: "通过厂商接口生成视频"}
+```
+
+Rules:
+
+- Locale keys match `^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$`. A map has at most 16 locales.
+- Keys are canonicalized to BCP-47 casing (`EN` → `en`, `zh-tw` → `zh-TW`, `zh-hans` → `zh-Hans`); two keys that collide after canonicalization are rejected as duplicates.
+- Each value is trimmed, must be non-empty, and must not contain control characters.
+- `meta.description` is at most 512 runes per locale. Each `usageSchema` field `description` is at most 256 runes per locale.
+- The frontend resolves a locale with exact tag → primary subtag → `en` (for example `zh-TW` → `zh` → `en`).
+- Description copy in any language must not include vendor currency prices. The same prohibition applies to `usageExamples` labels.
+
+## Request body
+
+Every decoder receives one host-parsed body:
+
+```ts
+{kind: "json", value}
+{kind: "form", fields: Record}
+{kind: "multipart", fields: Record, files: readonly FileReference[]}
+{kind: "none"}
+```
+
+`FileReference` contains `{ref, field, filename, mimeType, size}`. The ref is opaque and request-scoped; file bytes, readers, temporary paths, and a second inbound `requestBody` representation are never exposed to JavaScript. The decoder creates a new canonical `{kind:"submit", model, action?, requestBody?, originTaskIds?}` intent. Driver hooks consume that normalized `requestBody` and remain surface-independent.
+
+## Request building
+
+`buildSubmitRequest` returns a request descriptor. JSON bodies (the default) may embed a file placeholder at any object or array depth. The host replaces each placeholder with an encoded string before the request is sent; JavaScript never sees file bytes.
+
+```js
+{ __fileRef: "request_file:input_reference", encoding: "base64" } // raw Base64
+{ __fileRef: "request_file:input_reference", encoding: "dataUrl" } // data:;base64,
+{ __fileRef: "request_file:input_reference", encoding: "dataUrl", mimeType: "image/png", maxBytes: 20971520 }
+```
+
+Placeholder objects may contain only `__fileRef`, `encoding`, `mimeType`, and `maxBytes`. `encoding` is `base64` or `dataUrl`. `__fileRef` must resolve to an uploaded multipart file (`request_file:`). `mimeType` optionally overrides the part `Content-Type` for `dataUrl` (otherwise the part header, else `application/octet-stream`). `maxBytes` is an optional vendor cap; the host also applies `MAX_FILE_DOWNLOAD_MB` (default 64MB) to each file and to the total inlined bytes. Multipart descriptors still stream files with `parts[].fileRef`.
+
+## Origin tasks
+
+Decoders normalize vendor references (draft ids, continuation ids, gateway-issued asset references) into `originTaskIds` as public gateway task IDs. The host never parses vendor request bodies.
+
+The host verifies ownership, requires every referenced task to belong to the same plugin platform set and one enabled channel, pins that channel for the submit (including retries), and injects the resolved rows into driver hooks as `ctx.originTasks` (`taskId`, `upstreamTaskId`, `action`, `status`, and `data`, including the private upstream id). Renderers and presenters never receive `originTasks`.
+
+## Native surface
+
+```js
+export const native = {
+ createJob(ctx) { return {kind: "submit", model: ctx.body.value.model, requestBody: ctx.body.value}; },
+ jobCreated(ctx, task) { return {job_id: task.task_id, state: "queued"}; },
+ jobStatus(ctx, task) { return task.data; },
+ error(ctx, error) { return {code: error.code, message: error.message}; },
+};
+```
+
+The host authenticates, checks ownership, persists the task, builds `TaskView`, and replaces known private task-id fields with the public ID before calling presenters. Native presenters own the vendor envelope. Authenticated native query presenters may intentionally pass through provider URLs from `task.data`.
+
+## Error contract
+
+Hook `Error` messages are surfaced (truncated and sanitized) to API callers. Plugin authors should write validation failures as user-readable sentences.
+
+## Host protocols
+
+The host registry owns these bindings:
+
+| Protocol | Operation | Binding | Body |
+|--------------------|-----------|---------------------------|-----------------------------------|
+| `openai_responses` | create | `POST /v1/responses` | JSON |
+| `openai_responses` | retrieve | `GET /v1/responses/:response_id` | none |
+| `openai_video` | create | `POST /v1/videos` | JSON or multipart |
+| `openai_video` | retrieve | `GET /v1/videos/:task_id` | none |
+| `openai_video` | content | `GET | HEAD /v1/videos/:task_id/content` | none |
+
+`protocols.openai_responses` always requires `decodeRequest`. The remaining hooks are derived from `supports` and verified exactly at load in both directions: `"stream"` requires `renderEvents`; `"sync"` or `"background"` requires `renderFinal`. A hook required by a supported mode but not exported, or an exported hook that no supported mode uses, rejects the plugin. For a plugin supporting only `"stream"`, the host renders retrieval at terminal SUCCESS by calling `renderEvents` once with no previous state — the same first-call-at-terminal semantics streaming already requires. The mode vocabulary is append-only under apiVersion 1: new modes may be added (existing manifests are unaffected; a manifest declaring a mode loads only on gateways that define it), but a mode may never be removed, renamed, or re-mapped to different hooks without an apiVersion bump. `protocols.openai_video` requires `decodeRequest` for create and `render` for retrieve. The host extracts and pins `ctx.model`, normalizes `ctx.stream`, frames SSE, creates failure envelopes, and calls `renderFinal` only for `SUCCESS`.
+
+`background: true` on create returns the pending Response immediately (host-synthesized, no plugin hook). Retrieval renders via `renderFinal` only at terminal status. Plugins declaring `"background"` need no create-time hook; the deliverable is rendered at retrieval via `renderFinal`. Plugins cannot observe the `background` field. Unlike the upstream OpenAI API, a plugin that does not declare `"stream"` rejects `stream: true` with a 400; the error names the supported forms.
+
+Protocol media uses host-injected `ctx.artifacts[key].url`. Provider URLs from `task.data` are not protocol output. OpenAI Video projections are DTO-whitelisted and the host overwrites identity, lifecycle, timestamps, and removes URL-like metadata.
+
+## Persisted data and driver hooks
+
+The persisted field remains `task.data`; there is no `task.raw` alias. Driver hooks (`buildSubmitRequest`, `parseSubmitResponse`, query/result, usage, artifact, and content hooks) stay flat and must not branch on the client path or protocol.
diff --git a/docs/plugin-api/v1.schema.json b/docs/plugin-api/v1.schema.json
new file mode 100644
index 000000000000..ab41078288cc
--- /dev/null
+++ b/docs/plugin-api/v1.schema.json
@@ -0,0 +1,46 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://github.com/QuantumNous/new-api/docs/plugin-api/v1.schema.json",
+ "title": "new-api Task Plugin v1 manifest",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["apiVersion", "key", "name", "version", "author", "models", "fetchMode"],
+ "properties": {
+ "apiVersion": {"const": 1}, "key": {"type": "string"}, "name": {"type": "string"}, "icon": {"type": "string", "maxLength": 128}, "description": {"$ref": "#/$defs/localizedText"}, "version": {"type": "string"},
+ "author": {"type": "object", "required": ["name"], "additionalProperties": false, "properties": {"name": {"type": "string"}, "url": {"type": "string", "format": "uri"}}},
+ "channelTypes": {"type": "array", "uniqueItems": true, "items": {"type": "integer", "minimum": 1}}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}},
+ "fetchMode": {"enum": ["per_task", "batch"]}, "allowedHosts": {"type": "array", "uniqueItems": true, "items": {"type": "string"}},
+ "protocols": {"type": "array", "uniqueItems": true, "items": {"oneOf": [
+ {"enum": ["openai_video"]},
+ {"type": "object", "additionalProperties": false, "required": ["name", "supports"], "properties": {"name": {"const": "openai_responses"}, "supports": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"enum": ["stream", "sync", "background"]}}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}},
+ {"type": "object", "additionalProperties": false, "required": ["name"], "properties": {"name": {"const": "openai_video"}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}}
+ ]}},
+ "routes": {"type": "array", "items": {"$ref": "#/$defs/route"}}, "usageSchema": {"type": "object", "additionalProperties": {"type": "object", "properties": {"description": {"$ref": "#/$defs/localizedText"}}}}, "usageExamples": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["label", "facts"], "properties": {"label": {"type": "string"}, "facts": {"type": "object"}}}}, "auth": {}
+ },
+ "$defs": {
+ "localizedText": {
+ "oneOf": [
+ {"type": "string", "minLength": 1},
+ {
+ "type": "object",
+ "required": ["en"],
+ "maxProperties": 16,
+ "propertyNames": {"pattern": "^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$"},
+ "additionalProperties": {"type": "string", "minLength": 1}
+ }
+ ]
+ },
+ "filePlaceholder": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["__fileRef", "encoding"],
+ "properties": {
+ "__fileRef": {"type": "string", "minLength": 1},
+ "encoding": {"enum": ["base64", "dataUrl"]},
+ "mimeType": {"type": "string", "minLength": 1},
+ "maxBytes": {"type": "integer", "exclusiveMinimum": 0}
+ }
+ },
+ "route": {"type": "object", "additionalProperties": false, "required": ["method", "path", "type", "render"], "properties": {"method": {"enum": ["GET", "POST", "PUT", "PATCH", "DELETE"]}, "path": {"type": "string", "pattern": "^/"}, "type": {"enum": ["submit", "query", "dynamic"]}, "action": {"type": "string"}, "taskIdParam": {"type": "string"}, "decode": {"type": "string"}, "render": {"type": "string"}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}, "allOf": [{"if": {"properties": {"type": {"const": "query"}}}, "then": {"allOf": [{"not": {"required": ["decode"]}}, {"not": {"required": ["models"]}}]}}, {"if": {"properties": {"type": {"enum": ["submit", "dynamic"]}}}, "then": {"required": ["decode"]}}]}
+ }
+}
diff --git a/dto/channel_constraints.go b/dto/channel_constraints.go
new file mode 100644
index 000000000000..3ed88dc1e215
--- /dev/null
+++ b/dto/channel_constraints.go
@@ -0,0 +1,115 @@
+package dto
+
+type ChannelPinSource string
+
+const (
+ PinSourceToken ChannelPinSource = "token" // Rank 0, highest
+ PinSourceOriginTask ChannelPinSource = "origin_task" // Rank 10
+)
+
+const (
+ PinRankToken = 0
+ PinRankOriginTask = 10
+)
+
+type PinRetryMode int
+
+const (
+ PinRetrySameChannel PinRetryMode = iota
+ PinRetrySingleAttempt
+)
+
+func (m PinRetryMode) Stricter(other PinRetryMode) PinRetryMode {
+ if m == PinRetrySingleAttempt || other == PinRetrySingleAttempt {
+ return PinRetrySingleAttempt
+ }
+ return PinRetrySameChannel
+}
+
+type ChannelPin struct {
+ ChannelId int
+ Source ChannelPinSource
+ Rank int
+ RetryMode PinRetryMode
+}
+
+type ChannelFilterKind string
+
+const (
+ FilterRequestPath ChannelFilterKind = "request_path"
+ FilterTaskPluginIdentity ChannelFilterKind = "task_plugin_identity"
+)
+
+type ChannelFilter struct {
+ Kind ChannelFilterKind
+ RequestPath string
+ TaskPluginKey string
+ TaskPluginChannelTypes []int
+}
+
+type ChannelConstraints struct {
+ Pins []ChannelPin
+ Filters []ChannelFilter
+}
+
+func (cc *ChannelConstraints) AddPin(p ChannelPin) {
+ if cc == nil {
+ return
+ }
+ cc.Pins = append(cc.Pins, p)
+}
+
+func (cc *ChannelConstraints) AddFilter(f ChannelFilter) {
+ if cc == nil {
+ return
+ }
+ cc.Filters = append(cc.Filters, f)
+}
+
+// ResolvedPin returns the winning pin after priority resolution.
+// Lowest Rank wins. Pins that name the same channel are merged (stricter RetryMode).
+// overridden lists pins that lost to a different channel (for warn logging).
+func (cc *ChannelConstraints) ResolvedPin() (ChannelPin, bool, []ChannelPin) {
+ if cc == nil || len(cc.Pins) == 0 {
+ return ChannelPin{}, false, nil
+ }
+
+ merged := make(map[int]ChannelPin, len(cc.Pins))
+ order := make([]int, 0, len(cc.Pins))
+ for _, pin := range cc.Pins {
+ existing, seen := merged[pin.ChannelId]
+ if !seen {
+ merged[pin.ChannelId] = pin
+ order = append(order, pin.ChannelId)
+ continue
+ }
+ existing.RetryMode = existing.RetryMode.Stricter(pin.RetryMode)
+ if pin.Rank < existing.Rank {
+ existing.Rank = pin.Rank
+ existing.Source = pin.Source
+ }
+ merged[pin.ChannelId] = existing
+ }
+
+ winner := merged[order[0]]
+ for _, channelID := range order[1:] {
+ candidate := merged[channelID]
+ if candidate.Rank < winner.Rank {
+ winner = candidate
+ }
+ }
+
+ var overridden []ChannelPin
+ for _, channelID := range order {
+ candidate := merged[channelID]
+ if candidate.ChannelId != winner.ChannelId {
+ overridden = append(overridden, candidate)
+ }
+ }
+ return winner, true, overridden
+}
+
+func (cc *ChannelConstraints) SuppressesRetry() bool {
+ pin, found, _ := cc.ResolvedPin()
+ return found && pin.RetryMode == PinRetrySingleAttempt
+}
diff --git a/dto/channel_constraints_test.go b/dto/channel_constraints_test.go
new file mode 100644
index 000000000000..444cc5b0f5d0
--- /dev/null
+++ b/dto/channel_constraints_test.go
@@ -0,0 +1,46 @@
+package dto
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestResolvedPinPriorityAndMerge(t *testing.T) {
+ t.Run("token pin beats origin pin on a different channel", func(t *testing.T) {
+ constraints := &ChannelConstraints{}
+ constraints.AddPin(ChannelPin{ChannelId: 10, Source: PinSourceOriginTask, Rank: PinRankOriginTask, RetryMode: PinRetrySameChannel})
+ constraints.AddPin(ChannelPin{ChannelId: 1, Source: PinSourceToken, Rank: PinRankToken, RetryMode: PinRetrySingleAttempt})
+
+ pin, found, overridden := constraints.ResolvedPin()
+ require.True(t, found)
+ assert.Equal(t, 1, pin.ChannelId)
+ assert.Equal(t, PinSourceToken, pin.Source)
+ assert.Equal(t, PinRetrySingleAttempt, pin.RetryMode)
+ require.Len(t, overridden, 1)
+ assert.Equal(t, PinSourceOriginTask, overridden[0].Source)
+ assert.Equal(t, 10, overridden[0].ChannelId)
+ })
+
+ t.Run("same channel pins merge to the stricter retry mode", func(t *testing.T) {
+ constraints := &ChannelConstraints{}
+ constraints.AddPin(ChannelPin{ChannelId: 7, Source: PinSourceOriginTask, Rank: PinRankOriginTask, RetryMode: PinRetrySameChannel})
+ constraints.AddPin(ChannelPin{ChannelId: 7, Source: PinSourceToken, Rank: PinRankToken, RetryMode: PinRetrySingleAttempt})
+
+ pin, found, overridden := constraints.ResolvedPin()
+ require.True(t, found)
+ assert.Equal(t, 7, pin.ChannelId)
+ assert.Equal(t, PinSourceToken, pin.Source)
+ assert.Equal(t, PinRetrySingleAttempt, pin.RetryMode)
+ assert.Empty(t, overridden)
+ assert.True(t, constraints.SuppressesRetry())
+ })
+
+ t.Run("empty set has no pin", func(t *testing.T) {
+ pin, found, overridden := (*ChannelConstraints)(nil).ResolvedPin()
+ assert.False(t, found)
+ assert.Zero(t, pin.ChannelId)
+ assert.Nil(t, overridden)
+ })
+}
diff --git a/dto/plugin_protocol.go b/dto/plugin_protocol.go
new file mode 100644
index 000000000000..75265c809be2
--- /dev/null
+++ b/dto/plugin_protocol.go
@@ -0,0 +1,82 @@
+package dto
+
+// PluginResponsesResponse is the host-owned Responses facade used for stream
+// snapshots and sanitized terminal failures. Non-stream success objects may
+// retain additional validated plugin fields, but identifiers, lifecycle state,
+// and retrieval metadata are always populated by the host.
+type PluginResponsesResponse struct {
+ ID string `json:"id"`
+ Object string `json:"object"`
+ CreatedAt int64 `json:"created_at"`
+ Status string `json:"status"`
+ Error *PluginResponsesError `json:"error"`
+ IncompleteDetails *PluginResponsesIncompleteDetail `json:"incomplete_details"`
+ Instructions any `json:"instructions"`
+ Model string `json:"model"`
+ Output []PluginResponsesOutput `json:"output"`
+ ParallelToolCalls bool `json:"parallel_tool_calls"`
+ Temperature float64 `json:"temperature"`
+ ToolChoice any `json:"tool_choice"`
+ Tools []any `json:"tools"`
+ TopP float64 `json:"top_p"`
+ Metadata map[string]string `json:"metadata"`
+ Usage *PluginResponsesUsage `json:"usage"`
+}
+
+type PluginResponsesError struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+}
+
+type PluginResponsesIncompleteDetail struct {
+ Reason string `json:"reason"`
+}
+
+type PluginResponsesUsage struct {
+ InputTokens int `json:"input_tokens"`
+ InputTokensDetails PluginResponsesInputTokenDetails `json:"input_tokens_details"`
+ OutputTokens int `json:"output_tokens"`
+ OutputTokensDetails PluginResponsesOutputTokenDetails `json:"output_tokens_details"`
+ TotalTokens int `json:"total_tokens"`
+}
+
+type PluginResponsesInputTokenDetails struct {
+ CachedTokens int `json:"cached_tokens"`
+}
+
+type PluginResponsesOutputTokenDetails struct {
+ ReasoningTokens int `json:"reasoning_tokens"`
+}
+
+type PluginResponsesOutput struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Status string `json:"status"`
+ Role string `json:"role"`
+ Content []PluginResponsesContent `json:"content"`
+}
+
+type PluginResponsesContent struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Text string `json:"text"`
+ Annotations []any `json:"annotations"`
+ Logprobs []any `json:"logprobs"`
+}
+
+// PluginResponsesStreamEvent contains the exact event-specific fields used by
+// the host Responses state machine. Pointer fields preserve required empty
+// strings and arrays on delta/done events while omitting unrelated fields.
+type PluginResponsesStreamEvent struct {
+ Type string `json:"type"`
+ SequenceNumber int `json:"sequence_number"`
+ Response *PluginResponsesResponse `json:"response,omitempty"`
+ OutputIndex *int `json:"output_index,omitempty"`
+ ContentIndex *int `json:"content_index,omitempty"`
+ ItemID string `json:"item_id,omitempty"`
+ Item *PluginResponsesOutput `json:"item,omitempty"`
+ Part *PluginResponsesContent `json:"part,omitempty"`
+ Delta *string `json:"delta,omitempty"`
+ Text *string `json:"text,omitempty"`
+ Logprobs *[]any `json:"logprobs,omitempty"`
+}
diff --git a/dto/task.go b/dto/task.go
index 4a9a8e2e6d18..b1cae356f66d 100644
--- a/dto/task.go
+++ b/dto/task.go
@@ -30,26 +30,60 @@ func (t *TaskResponse[T]) IsSuccess() bool {
}
type TaskDto struct {
- ID int64 `json:"id"`
- CreatedAt int64 `json:"created_at"`
- UpdatedAt int64 `json:"updated_at"`
- TaskID string `json:"task_id"`
- Platform string `json:"platform"`
- UserId int `json:"user_id"`
- Group string `json:"group"`
- ChannelId int `json:"channel_id"`
- Quota int `json:"quota"`
- Action string `json:"action"`
- Status string `json:"status"`
- FailReason string `json:"fail_reason"`
- ResultURL string `json:"result_url,omitempty"` // 任务结果 URL(视频地址等)
- SubmitTime int64 `json:"submit_time"`
- StartTime int64 `json:"start_time"`
- FinishTime int64 `json:"finish_time"`
- Progress string `json:"progress"`
- Properties any `json:"properties"`
- Username string `json:"username,omitempty"`
- Data json.RawMessage `json:"data"`
+ ID int64 `json:"id"`
+ CreatedAt int64 `json:"created_at"`
+ UpdatedAt int64 `json:"updated_at"`
+ TaskID string `json:"task_id"`
+ Platform string `json:"platform"`
+ UserId int `json:"user_id"`
+ Group string `json:"group"`
+ ChannelId int `json:"channel_id"`
+ Quota int `json:"quota"`
+ Action string `json:"action"`
+ Status string `json:"status"`
+ FailReason string `json:"fail_reason"`
+ ResultURL string `json:"result_url,omitempty"` // 任务结果 URL(视频地址等)
+ LegacyVideoAvailable bool `json:"legacy_video_available,omitempty"`
+ SubmitTime int64 `json:"submit_time"`
+ StartTime int64 `json:"start_time"`
+ FinishTime int64 `json:"finish_time"`
+ Progress string `json:"progress"`
+ Properties any `json:"properties"`
+ Username string `json:"username,omitempty"`
+ Data json.RawMessage `json:"data"`
+ AdminInfo *TaskAdminInfo `json:"admin_info,omitempty"`
+ RootInfo *TaskRootInfo `json:"root_info,omitempty"`
+}
+
+type TaskPluginInfo struct {
+ Key string `json:"key"`
+ Name string `json:"name"`
+ Version string `json:"version,omitempty"`
+ Author *TaskPluginAuthorInfo `json:"author,omitempty"`
+}
+
+type TaskPluginAuthorInfo struct {
+ Name string `json:"name"`
+ URL string `json:"url,omitempty"`
+}
+
+type TaskPluginRuntimeInfo struct {
+ Key string `json:"key"`
+ Version string `json:"version"`
+ APIVersion int `json:"api_version"`
+ Generation uint64 `json:"generation"`
+}
+
+type TaskAdminInfo struct {
+ RequestID string `json:"request_id,omitempty"`
+ RequestPath string `json:"request_path,omitempty"`
+ TaskPlugin *TaskPluginInfo `json:"task_plugin,omitempty"`
+}
+
+type TaskRootInfo struct {
+ TaskPlugin *TaskPluginRuntimeInfo `json:"task_plugin,omitempty"`
+ UpstreamTaskID string `json:"upstream_task_id,omitempty"`
+ NodeName string `json:"node_name,omitempty"`
}
type FetchReq struct {
diff --git a/dto/task_plugin.go b/dto/task_plugin.go
new file mode 100644
index 000000000000..84c2a77234be
--- /dev/null
+++ b/dto/task_plugin.go
@@ -0,0 +1,23 @@
+package dto
+
+type TaskPluginError struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ HTTPStatus int `json:"httpStatus"`
+ Retryable bool `json:"retryable"`
+}
+
+// TaskView is the only persisted-task shape exposed to JavaScript plugins.
+// It deliberately excludes ownership, channel, quota, properties, and private
+// upstream identifiers.
+type TaskView struct {
+ TaskID string `json:"task_id"`
+ Platform string `json:"platform"`
+ Status string `json:"status"`
+ Progress string `json:"progress"`
+ FailReason string `json:"fail_reason"`
+ CreatedAt int64 `json:"created_at"`
+ UpdatedAt int64 `json:"updated_at,omitempty"`
+ FinishedAt int64 `json:"finished_at,omitempty"`
+ Data any `json:"data,omitempty"`
+}
diff --git a/e2e/doc_parse_test.go b/e2e/doc_parse_test.go
new file mode 100644
index 000000000000..d5d0629c41f4
--- /dev/null
+++ b/e2e/doc_parse_test.go
@@ -0,0 +1,148 @@
+package e2e
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/controller"
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/middleware"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/QuantumNous/new-api/relay"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/setting/system_setting"
+ "github.com/gin-gonic/gin"
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+const docParsePluginSource = `export const meta = {apiVersion:1,key:"doc-parse",name:"Document Parser",version:"1.0.0",author:{name:"Test"},models:["doc-parse-v1"],fetchMode:"batch"};
+export function buildSubmitRequest(ctx){return {url:ctx.baseUrl+"/submit",method:"POST",headers:{"Content-Type":"application/json"},body:ctx.requestBody,action:"parse_document"};}
+export function parseSubmitResponse(ctx,resp){if(!resp.body.id)throw new Error("missing id");return {taskId:resp.body.id,taskData:resp.body};}
+export function buildBatchQueryRequest(ctx,taskIds){return {url:ctx.baseUrl+"/batch",method:"POST",headers:{"Content-Type":"application/json"},body:{ids:taskIds}};}
+export function parseBatchResult(ctx,body){return body.tasks.map((task)=>({taskId:task.id,status:task.status,progress:"100%",data:task}));}
+export function parseTaskResult(ctx,body){return {taskId:body.id,status:body.status};}
+export function listArtifacts(task){return task.status==="SUCCESS"?(task.data.artifacts||[]).map((item)=>({key:item.key,type:"file",mimeType:item.mimeType})):[];}
+export function buildContentRequest(ctx){const item=(ctx.data.artifacts||[]).find((artifact)=>artifact.key===ctx.artifactKey);if(!item)throw new Error("artifact_not_found");return {url:item.url,method:ctx.clientRequest.method,credentialless:true};}
+`
+
+func TestDocumentPluginRunsGenericBatchArtifactChain(t *testing.T) {
+ service.InitHttpClient()
+ originalDB := model.DB
+ database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, database.AutoMigrate(&model.TaskPlugin{}, &model.Channel{}, &model.Task{}))
+ model.DB = database
+ t.Cleanup(func() { model.DB = originalDB; jsplugin.DefaultRegistry.Unregister("doc-parse") })
+
+ source := docParsePluginSource
+ uploadBody, err := common.Marshal(map[string]any{"source": source, "remark": "phase 4 acceptance"})
+ require.NoError(t, err)
+ uploadRecorder := httptest.NewRecorder()
+ uploadContext, _ := gin.CreateTestContext(uploadRecorder)
+ uploadContext.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", bytes.NewReader(uploadBody))
+ uploadContext.Request.Header.Set("Content-Type", "application/json")
+ controller.UploadTaskPlugin(uploadContext)
+ require.Equal(t, http.StatusOK, uploadRecorder.Code, uploadRecorder.Body.String())
+
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/submit":
+ _, _ = io.WriteString(w, `{"id":"doc-upstream-1"}`)
+ case "/batch":
+ _, _ = io.WriteString(w, `{"tasks":[{"id":"doc-upstream-1","status":"SUCCESS","artifacts":[{"key":"text","url":"`+"http://"+r.Host+`/artifact/text","mimeType":"text/plain"},{"key":"json","url":"`+"http://"+r.Host+`/artifact/json","mimeType":"application/json"}]}]}`)
+ case "/artifact/text":
+ w.Header().Set("Content-Type", "text/plain")
+ _, _ = io.WriteString(w, "parsed text")
+ case "/artifact/json":
+ _, _ = io.WriteString(w, `{"pages":2}`)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer upstream.Close()
+ setting := dto.ChannelSettings{TaskPluginKey: "doc-parse"}
+ channel := model.Channel{Type: constant.ChannelTypeTaskPlugin, Name: "documents", Key: "unused", BaseURL: &upstream.URL, Status: common.ChannelStatusEnabled, Models: "doc-parse-v1", Group: "default"}
+ channel.SetSetting(setting)
+ require.NoError(t, database.Create(&channel).Error)
+
+ adaptor := relay.GetTaskAdaptor("doc-parse")
+ require.NotNil(t, adaptor)
+ info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelType: channel.Type, ChannelBaseUrl: upstream.URL, ApiKey: channel.Key, ChannelSetting: setting, UpstreamModelName: "doc-parse-v1"}, OriginModelName: "doc-parse-v1", TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_doc_parse"}}
+ adaptor.Init(info)
+ submitRecorder := httptest.NewRecorder()
+ submitContext, _ := gin.CreateTestContext(submitRecorder)
+ submitContext.Request = httptest.NewRequest(http.MethodPost, "/v1/tasks/doc-parse", bytes.NewBufferString(`{"model":"doc-parse-v1","document":"opaque-ref"}`))
+ submitContext.Request.Header.Set("Content-Type", "application/json")
+ submitContext.Params = gin.Params{{Key: "key", Value: "doc-parse"}}
+ middleware.PrepareTaskPluginSubmit()(submitContext)
+ require.Empty(t, submitRecorder.Body.String())
+ require.Equal(t, "doc-parse-v1", submitContext.GetString("resolved_task_model"))
+ require.Nil(t, adaptor.ValidateRequestAndSetAction(submitContext, info))
+ require.Equal(t, "parse_document", info.Action)
+ requestBody, err := adaptor.BuildRequestBody(submitContext, info)
+ require.NoError(t, err)
+ response, err := adaptor.DoRequest(submitContext, info, requestBody)
+ require.NoError(t, err)
+ parsed, taskErr := adaptor.ParseResponse(submitContext, response, info)
+ require.Nil(t, taskErr)
+ require.NotNil(t, parsed)
+ require.Equal(t, "doc-upstream-1", parsed.UpstreamTaskID)
+ task := model.Task{
+ TaskID: info.PublicTaskID, Platform: "doc-parse", UserId: 7, ChannelId: channel.Id,
+ Status: model.TaskStatusInProgress, Data: parsed.TaskData,
+ PrivateData: model.TaskPrivateData{
+ UpstreamTaskID: parsed.UpstreamTaskID,
+ Execution: &model.TaskExecutionSnapshot{TaskPlugin: &model.TaskPluginSnapshot{
+ Key: "doc-parse", Name: "Document Parser", Version: "1.0.0",
+ Author: &model.TaskPluginAuthorSnapshot{Name: "Test"}, APIVersion: 1,
+ }},
+ },
+ }
+ require.NoError(t, database.Create(&task).Error)
+
+ originalFactory := service.GetTaskAdaptorFunc
+ service.GetTaskAdaptorFunc = func(platform constant.TaskPlatform) service.TaskPollingAdaptor { return relay.GetTaskAdaptor(platform) }
+ t.Cleanup(func() { service.GetTaskAdaptorFunc = originalFactory })
+ service.DispatchPlatformUpdate(context.Background(), "doc-parse", map[int][]string{channel.Id: {parsed.UpstreamTaskID}}, map[string]*model.Task{parsed.UpstreamTaskID: &task})
+ require.NoError(t, database.First(&task, task.ID).Error)
+ assert.Equal(t, model.TaskStatus(model.TaskStatusSuccess), task.Status)
+
+ queryRecorder := httptest.NewRecorder()
+ queryContext, _ := gin.CreateTestContext(queryRecorder)
+ queryContext.Set("id", 7)
+ queryContext.Params = gin.Params{{Key: "key", Value: task.TaskID}}
+ queryContext.Request = httptest.NewRequest(http.MethodGet, "/v1/tasks/"+task.TaskID+"/artifacts", nil)
+ controller.GetTaskArtifacts(queryContext)
+ require.Equal(t, http.StatusOK, queryRecorder.Code)
+ var query struct {
+ Artifacts []map[string]any `json:"artifacts"`
+ }
+ require.NoError(t, common.Unmarshal(queryRecorder.Body.Bytes(), &query))
+ require.Len(t, query.Artifacts, 2)
+
+ originalFetch := *system_setting.GetFetchSetting()
+ system_setting.GetFetchSetting().EnableSSRFProtection = true
+ system_setting.GetFetchSetting().AllowPrivateIp = true
+ system_setting.GetFetchSetting().AllowedPorts = []string{"1-65535"}
+ t.Cleanup(func() { *system_setting.GetFetchSetting() = originalFetch })
+ contentRecorder := httptest.NewRecorder()
+ contentContext, _ := gin.CreateTestContext(contentRecorder)
+ contentContext.Set("id", 7)
+ contentContext.Params = gin.Params{{Key: "key", Value: task.TaskID}, {Key: "artifact_key", Value: "text"}}
+ contentContext.Request = httptest.NewRequest(http.MethodGet, "/v1/tasks/"+task.TaskID+"/artifacts/text/content", nil)
+ controller.TaskArtifactContent(contentContext)
+ assert.Equal(t, http.StatusOK, contentRecorder.Code)
+ assert.Equal(t, "parsed text", contentRecorder.Body.String())
+}
diff --git a/go.mod b/go.mod
index b0642f162db2..0f181866cef3 100644
--- a/go.mod
+++ b/go.mod
@@ -70,8 +70,11 @@ require (
github.com/ClickHouse/ch-go v0.65.0 // indirect
github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
+ github.com/dlclark/regexp2/v2 v2.2.2 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
+ github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
+ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/paulmach/orb v0.11.1 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
@@ -85,6 +88,8 @@ require (
require (
github.com/Azure/go-ntlmssp v0.1.1
github.com/alicebob/miniredis/v2 v2.38.0
+ github.com/grafana/sobek v0.0.0-20260708062710-267a0e055bb4
+ github.com/openai/openai-go v1.12.0
)
require (
@@ -146,7 +151,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/samber/go-singleflightx v0.3.2 // indirect
github.com/tidwall/match v1.1.1 // indirect
- github.com/tidwall/pretty v1.2.0 // indirect
+ github.com/tidwall/pretty v1.2.1 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
diff --git a/go.sum b/go.sum
index 357374c39ca4..8265831e0ce3 100644
--- a/go.sum
+++ b/go.sum
@@ -643,6 +643,8 @@ github.com/ClickHouse/clickhouse-go/v2 v2.32.0/go.mod h1:rGFIgeNbJVggBp2C+0FXOdf
github.com/DmitriyVTitov/size v1.5.0 h1:/PzqxYrOyOUX1BXj6J9OuVRVGe+66VL4D9FlUaW515g=
github.com/DmitriyVTitov/size v1.5.0/go.mod h1:le6rNI4CoLQV1b9gzp1+3d7hMAD/uu2QcJ+aYbNgiU0=
github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk=
+github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
+github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA=
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw=
@@ -1010,6 +1012,8 @@ github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8
github.com/distribution/distribution/v3 v3.0.0-20220526142353-ffbd94cbe269/go.mod h1:28YO/VJk9/64+sTGNuYaBjWxrXTPrj0C0XmgTIOjxX4=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/dlclark/regexp2/v2 v2.2.2 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpfya0=
+github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dmarkham/enumer v1.5.8/go.mod h1:d10o8R3t/gROm2p3BXqTkMt2+HMuxEmWCXzorAruYak=
github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E=
github.com/docker/cli v0.0.0-20191017083524-a8ff7f821017/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
@@ -1196,6 +1200,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
+github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
+github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
@@ -1211,6 +1217,8 @@ github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGF
github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
+github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus v0.0.0-20151105175453-c7fdd8b5cd55/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
github.com/godbus/dbus v0.0.0-20180201030542-885f9cc04c9c/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
@@ -1375,6 +1383,8 @@ github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb
github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
+github.com/grafana/sobek v0.0.0-20260708062710-267a0e055bb4 h1:LJ2pEOxFbfUIhrkpROwZ6hLhuCn6e5GSX08v5GFwN/4=
+github.com/grafana/sobek v0.0.0-20260708062710-267a0e055bb4/go.mod h1:BL/2XROA/Wtlb+zGEhkSdSZiMFIgw+D2ZdDfrbccyVE=
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
@@ -1677,6 +1687,8 @@ github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmv
github.com/onsi/gomega v1.24.2 h1:J/tulyYK6JwBldPViHJReihxxZ+22FHs0piGjQAvoUE=
github.com/onsi/gomega v1.24.2/go.mod h1:gs3J10IS7Z7r7eXRoNJIrNqU4ToQukCJhFtKrWgHWnk=
github.com/open-policy-agent/opa v0.42.2/go.mod h1:MrmoTi/BsKWT58kXlVayBb+rYVeaMwuBm3nYAN3923s=
+github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0=
+github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
github.com/opencontainers/go-digest v0.0.0-20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
@@ -1951,8 +1963,9 @@ github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4s
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
-github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tiktoken-go/tokenizer v0.6.2 h1:t0GN2DvcUZSFWT/62YOgoqb10y7gSXBGs0A+4VCQK+g=
diff --git a/logger/logger.go b/logger/logger.go
index 867d88322430..8e8a2ef8f0bb 100644
--- a/logger/logger.go
+++ b/logger/logger.go
@@ -77,7 +77,10 @@ func LogInfo(ctx context.Context, msg string) {
logHelper(ctx, loggerINFO, msg)
}
-func LogWarn(ctx context.Context, msg string) {
+func LogWarn(ctx context.Context, msg string, args ...any) {
+ if len(args) > 0 {
+ msg = fmt.Sprintf(msg, args...)
+ }
logHelper(ctx, loggerWarn, msg)
}
diff --git a/main.go b/main.go
index 742d15515876..ac4e6afc3ac2 100644
--- a/main.go
+++ b/main.go
@@ -23,6 +23,7 @@ import (
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
"github.com/QuantumNous/new-api/relay"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
@@ -46,6 +47,9 @@ var buildFS embed.FS
var indexPage []byte
func main() {
+ if len(os.Args) > 1 && os.Args[1] == "plugin" {
+ os.Exit(jsplugin.RunCLI(os.Args[2:], os.Stdout, os.Stderr))
+ }
startTime := time.Now()
kitutil.SetLogging(common.SysLog, func(message string) {
logger.LogError(nil, message)
@@ -107,6 +111,7 @@ func main() {
// 热更新配置
go model.SyncOptions(common.SyncFrequency)
+ go controller.SyncTaskPlugins()
// 周期性重载授权策略,保证多节点/多 master 部署下权限变更能传播到每个实例
go authz.StartPolicySync(common.SyncFrequency)
diff --git a/middleware/auth.go b/middleware/auth.go
index 9f2a9df99604..f70ce96ee7c2 100644
--- a/middleware/auth.go
+++ b/middleware/auth.go
@@ -5,10 +5,12 @@ import (
"fmt"
"net"
"net/http"
+ "strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
@@ -515,7 +517,17 @@ func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) e
}
if len(parts) > 1 {
if model.IsAdmin(token.UserId) {
- c.Set("specific_channel_id", parts[1])
+ id, err := strconv.Atoi(parts[1])
+ if err != nil {
+ abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidChannelId))
+ return fmt.Errorf("invalid specific channel id")
+ }
+ service.GetChannelConstraints(c).AddPin(dto.ChannelPin{
+ ChannelId: id,
+ Source: dto.PinSourceToken,
+ Rank: dto.PinRankToken,
+ RetryMode: dto.PinRetrySingleAttempt,
+ })
} else {
c.Header("specific_channel_version", "701e3ae1dc3f7975556d354e0675168d004891c8")
abortWithOpenAiMessage(c, http.StatusForbidden, "普通用户不支持指定渠道")
diff --git a/middleware/body_cleanup.go b/middleware/body_cleanup.go
index f7b7ab51a0f1..03a5f06f7123 100644
--- a/middleware/body_cleanup.go
+++ b/middleware/body_cleanup.go
@@ -10,13 +10,13 @@ import (
// 在请求处理完成后自动清理磁盘/内存缓存
func BodyStorageCleanup() gin.HandlerFunc {
return func(c *gin.Context) {
- // 处理请求
- c.Next()
-
- // 请求结束后清理存储
- common.CleanupBodyStorage(c)
+ defer func() {
+ // 请求结束后清理存储
+ common.CleanupBodyStorage(c)
- // 清理文件缓存(URL 下载的文件等)
- service.CleanupFileSources(c)
+ // 清理文件缓存(URL 下载的文件等)
+ service.CleanupFileSources(c)
+ }()
+ c.Next()
}
}
diff --git a/middleware/distributor.go b/middleware/distributor.go
index 3f53aa350349..e61bea44aa3f 100644
--- a/middleware/distributor.go
+++ b/middleware/distributor.go
@@ -6,7 +6,6 @@ import (
"io"
"net/http"
"slices"
- "strconv"
"strings"
"time"
@@ -14,7 +13,9 @@ import (
"github.com/QuantumNous/new-api/constant"
taskdto "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/i18n"
+ "github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
@@ -33,25 +34,46 @@ type ModelRequest struct {
func Distribute() func(c *gin.Context) {
return func(c *gin.Context) {
var channel *model.Channel
- channelId, ok := common.GetContextKey(c, constant.ContextKeyTokenSpecificChannelId)
+ constraints := service.GetChannelConstraints(c)
+ constraints.AddFilter(taskdto.ChannelFilter{
+ Kind: taskdto.FilterRequestPath,
+ RequestPath: c.Request.URL.Path,
+ })
+ service.AppendTaskPluginIdentityFilter(c, c.GetString("expected_task_plugin_key"))
modelRequest, shouldSelectChannel, err := getModelRequest(c)
if err != nil {
abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidRequest, map[string]any{"Error": err.Error()}))
return
}
- if ok {
- id, err := strconv.Atoi(channelId.(string))
- if err != nil {
- abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidChannelId))
- return
+ if pin, found, overridden := constraints.ResolvedPin(); found {
+ for _, lost := range overridden {
+ logger.LogWarn(c, fmt.Sprintf(
+ "channel pin overridden: winning_source=%s winning_channel_id=%d overridden_source=%s overridden_channel_id=%d",
+ pin.Source, pin.ChannelId, lost.Source, lost.ChannelId,
+ ))
}
- channel, err = model.GetChannelById(id, true)
+ channel, err = model.CacheGetChannel(pin.ChannelId)
if err != nil {
- abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidChannelId))
+ if pin.Source == taskdto.PinSourceOriginTask {
+ abortWithOpenAiMessage(c, http.StatusBadRequest, "origin_task_channel_disabled", types.ErrorCode("origin_task_channel_disabled"))
+ } else {
+ abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidChannelId))
+ }
return
}
if channel.Status != common.ChannelStatusEnabled {
- abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorChannelDisabled))
+ if pin.Source == taskdto.PinSourceOriginTask {
+ abortWithOpenAiMessage(c, http.StatusBadRequest, "origin_task_channel_disabled", types.ErrorCode("origin_task_channel_disabled"))
+ } else {
+ abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorChannelDisabled))
+ }
+ return
+ }
+ if ok, kind := model.ChannelSatisfiesFilters(channel, modelRequest.Model, constraints.Filters); !ok {
+ if kind == taskdto.FilterTaskPluginIdentity {
+ logTaskPluginChannelDecision(c, channel, modelRequest.Model, "channel_rejected", "identity_mismatch")
+ }
+ abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorNoAvailableChannel, map[string]any{"Group": common.GetContextKeyString(c, constant.ContextKeyUsingGroup), "Model": modelRequest.Model}), types.ErrorCode(kind))
return
}
} else {
@@ -105,8 +127,11 @@ func Distribute() func(c *gin.Context) {
if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
affinityUsable := false
preferred, err := model.CacheGetChannel(preferredChannelID)
- if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled &&
- channelSupportsRequestPath(preferred, c.Request.URL.Path, modelRequest.Model) {
+ affinitySatisfied := false
+ if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled {
+ affinitySatisfied, _ = model.ChannelSatisfiesFilters(preferred, modelRequest.Model, constraints.Filters)
+ }
+ if affinitySatisfied {
if usingGroup == "auto" {
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
autoGroups := service.GetRequestAutoGroups(c, userGroup)
@@ -161,6 +186,15 @@ func Distribute() func(c *gin.Context) {
}
}
}
+ if channel != nil {
+ if ok, kind := model.ChannelSatisfiesFilters(channel, modelRequest.Model, constraints.Filters); !ok {
+ if kind == taskdto.FilterTaskPluginIdentity {
+ logTaskPluginChannelDecision(c, channel, modelRequest.Model, "channel_rejected", "identity_mismatch")
+ }
+ abortWithOpenAiMessage(c, http.StatusServiceUnavailable, i18n.T(c, i18n.MsgDistributorNoAvailableChannel, map[string]any{"Group": common.GetContextKeyString(c, constant.ContextKeyUsingGroup), "Model": modelRequest.Model}), types.ErrorCodeModelNotFound)
+ return
+ }
+ }
common.SetContextKey(c, constant.ContextKeyRequestStartTime, time.Now())
SetupContextForSelectedChannel(c, channel, modelRequest.Model)
c.Next()
@@ -170,18 +204,68 @@ func Distribute() func(c *gin.Context) {
}
}
-// channelSupportsRequestPath reports whether a channel can serve the request path.
-// Only Advanced Custom (type 58) channels are path-checked; all other channel types
-// always pass. A type-58 channel is usable only when one of its routes matches.
-func channelSupportsRequestPath(channel *model.Channel, requestPath string, requestModel string) bool {
+func channelMatchesExpectedTaskPlugin(c *gin.Context, channel *model.Channel, expected string) bool {
if channel == nil {
return false
}
- if channel.Type != constant.ChannelTypeAdvancedCustom {
+ if c != nil {
+ if _, matched := pinnedEndpointCandidateForChannel(c, channel, expected); matched {
+ return true
+ }
+ }
+ if channel.Type == constant.ChannelTypeTaskPlugin {
+ return expected != "" && channel.GetSetting().TaskPluginKey == expected
+ }
+ if expected == "" {
return true
}
- config := channel.GetOtherSettings().AdvancedCustom
- return config != nil && config.SupportsPathForModel(requestPath, requestModel)
+
+ if c == nil {
+ return false
+ }
+ value, exists := c.Get(jsplugin.ContextKeyPinnedPlugin)
+ pinned, ok := value.(jsplugin.PinnedPlugin)
+ if !exists || !ok || pinned.Generation == nil || pinned.Plugin == nil || pinned.Plugin.Meta.Key != expected {
+ return false
+ }
+ plugin, ok := pinned.Generation.GetByChannelType(channel.Type)
+ return ok && plugin == pinned.Plugin
+}
+
+func pinnedEndpointCandidateForChannel(c *gin.Context, channel *model.Channel, expected string) (jsplugin.ProtocolBinding, bool) {
+ if c == nil || channel == nil || expected == "" {
+ return jsplugin.ProtocolBinding{}, false
+ }
+ value, exists := c.Get(jsplugin.ContextKeyPinnedEndpoint)
+ pinned, ok := value.(jsplugin.PinnedEndpoint)
+ if !exists || !ok || pinned.Generation == nil || pinned.Plugin == nil {
+ return jsplugin.ProtocolBinding{}, false
+ }
+ candidates := pinned.Candidates
+ if len(candidates) == 0 {
+ candidates = []jsplugin.ProtocolBinding{{Plugin: pinned.Plugin, Protocol: pinned.Protocol, Operation: pinned.Operation, Model: pinned.Model}}
+ }
+ expectedOwned := false
+ selected := jsplugin.ProtocolBinding{}
+ for _, candidate := range candidates {
+ if candidate.Plugin == nil {
+ continue
+ }
+ if candidate.Plugin.Meta.Key == expected {
+ expectedOwned = true
+ }
+ if channel.Type == constant.ChannelTypeTaskPlugin {
+ if channel.GetSetting().TaskPluginKey == candidate.Plugin.Meta.Key {
+ selected = candidate
+ }
+ continue
+ }
+ plugin, indexed := pinned.Generation.GetByChannelType(channel.Type)
+ if indexed && plugin == candidate.Plugin {
+ selected = candidate
+ }
+ }
+ return selected, expectedOwned && selected.Plugin != nil
}
// getModelFromRequest 从请求中读取模型信息
@@ -190,6 +274,12 @@ func channelSupportsRequestPath(channel *model.Channel, requestPath string, requ
// - application/x-www-form-urlencoded
// - multipart/form-data
func getModelFromRequest(c *gin.Context) (*ModelRequest, error) {
+ if cached, exists := c.Get(contextKeyTaskPluginEndpointModel); exists {
+ if modelRequest, ok := cached.(ModelRequest); ok {
+ cachedRequest := modelRequest
+ return &cachedRequest, nil
+ }
+ }
if strings.HasPrefix(c.Request.Header.Get("Content-Type"), "application/json") {
modelRequest, err := getModelFromJSONBody(c)
if err != nil {
@@ -218,6 +308,9 @@ func getModelFromJSONBody(c *gin.Context) (*ModelRequest, error) {
if !gjson.ValidBytes(requestBody) {
return nil, errors.New("invalid JSON request body")
}
+ if countTopLevelJSONKey(requestBody, "model") > 1 {
+ return nil, errors.New("model must be provided once")
+ }
values := gjson.GetManyBytes(requestBody, "model", "group")
model, err := getJSONStringValue(values[0], "model")
@@ -240,6 +333,64 @@ func getModelFromJSONBody(c *gin.Context) (*ModelRequest, error) {
}, nil
}
+func countTopLevelJSONKey(data []byte, target string) int {
+ depth := 0
+ inString := false
+ escaped := false
+ stringStart := 0
+ expectingKey := false
+ count := 0
+ for index, current := range data {
+ if inString {
+ if escaped {
+ escaped = false
+ continue
+ }
+ if current == '\\' {
+ escaped = true
+ continue
+ }
+ if current != '"' {
+ continue
+ }
+ inString = false
+ if depth == 1 && expectingKey {
+ key := string(data[stringStart:index])
+ var decodedKey string
+ if common.Unmarshal(data[stringStart-1:index+1], &decodedKey) == nil {
+ key = decodedKey
+ }
+ cursor := index + 1
+ for cursor < len(data) && (data[cursor] == ' ' || data[cursor] == '\t' || data[cursor] == '\r' || data[cursor] == '\n') {
+ cursor++
+ }
+ if cursor < len(data) && data[cursor] == ':' && key == target {
+ count++
+ }
+ expectingKey = false
+ }
+ continue
+ }
+ switch current {
+ case '"':
+ inString = true
+ stringStart = index + 1
+ case '{':
+ depth++
+ if depth == 1 {
+ expectingKey = true
+ }
+ case '}':
+ depth--
+ case ',':
+ if depth == 1 {
+ expectingKey = true
+ }
+ }
+ }
+ return count
+}
+
func getJSONStringValue(result gjson.Result, field string) (string, error) {
if !result.Exists() || result.Type == gjson.Null {
return "", nil
@@ -254,7 +405,9 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
var modelRequest ModelRequest
shouldSelectChannel := true
var err error
- if strings.Contains(c.Request.URL.Path, "/mj/") {
+ if modelName := c.GetString("resolved_task_model"); modelName != "" {
+ modelRequest.Model = modelName
+ } else if strings.Contains(c.Request.URL.Path, "/mj/") {
relayMode := relayconstant.Path2RelayModeMidjourney(c.Request.URL.Path)
if relayMode == relayconstant.RelayModeMidjourneyTaskFetch ||
relayMode == relayconstant.RelayModeMidjourneyTaskFetchByCondition ||
@@ -282,17 +435,6 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
modelRequest.Model = midjourneyModel
}
c.Set("relay_mode", relayMode)
- } else if strings.Contains(c.Request.URL.Path, "/suno/") {
- relayMode := relayconstant.Path2RelaySuno(c.Request.Method, c.Request.URL.Path)
- if relayMode == relayconstant.RelayModeSunoFetch ||
- relayMode == relayconstant.RelayModeSunoFetchByID {
- shouldSelectChannel = false
- } else {
- modelName := service.CoverTaskActionToModelName(constant.TaskPlatformSuno, c.Param("action"))
- modelRequest.Model = modelName
- }
- c.Set("platform", string(constant.TaskPlatformSuno))
- c.Set("relay_mode", relayMode)
} else if strings.Contains(c.Request.URL.Path, "/v1/videos/") && strings.HasSuffix(c.Request.URL.Path, "/remix") {
relayMode := relayconstant.RelayModeVideoSubmit
c.Set("relay_mode", relayMode)
@@ -423,10 +565,6 @@ func getTaskOriginModelName(c *gin.Context) string {
}
taskId := c.Param("task_id")
- if taskId == "" {
- // jimeng adapter
- taskId = c.GetString("task_id")
- }
if taskId == "" {
return ""
}
@@ -440,15 +578,54 @@ func getTaskOriginModelName(c *gin.Context) string {
func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, modelName string) *types.NewAPIError {
c.Set("original_model", modelName) // for retry
+ expectedPlugin := c.GetString("expected_task_plugin_key")
if channel == nil {
+ logTaskPluginChannelDecision(c, nil, modelName, "channel_rejected", "nil_channel")
return types.NewError(errors.New("channel is nil"), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
}
+ if expectedPlugin != "" && !channelMatchesExpectedTaskPlugin(c, channel, expectedPlugin) {
+ logTaskPluginChannelDecision(c, channel, modelName, "channel_rejected", "identity_mismatch")
+ return types.NewError(
+ errors.New("selected channel does not match the pinned task plugin"),
+ types.ErrorCodeGetChannelFailed,
+ types.ErrOptionWithSkipRetry(),
+ )
+ }
+ if candidate, matched := pinnedEndpointCandidateForChannel(c, channel, expectedPlugin); matched {
+ if value, exists := c.Get(jsplugin.ContextKeyPinnedEndpoint); exists {
+ if pinned, ok := value.(jsplugin.PinnedEndpoint); ok && candidate.Plugin != nil && candidate.Plugin != pinned.Plugin {
+ previousPlugin := pinned.Plugin.Meta.Key
+ pinned.Plugin = candidate.Plugin
+ pinned.Protocol = candidate.Protocol
+ pinned.Operation = candidate.Operation
+ c.Set(jsplugin.ContextKeyPinnedEndpoint, pinned)
+ c.Set(jsplugin.ContextKeyPinnedPlugin, jsplugin.PinnedPlugin{Generation: pinned.Generation, Plugin: candidate.Plugin})
+ c.Set("expected_task_plugin_key", candidate.Plugin.Meta.Key)
+ c.Set("task_plugin_key", candidate.Plugin.Meta.Key)
+ c.Set("platform", candidate.Plugin.Meta.Key)
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=endpoint event=provider_selected generation=%d previous_plugin=%q plugin=%q model=%q channel_id=%d channel_type=%d",
+ pinned.Generation.Number,
+ previousPlugin,
+ candidate.Plugin.Meta.Key,
+ modelName,
+ channel.Id,
+ channel.Type,
+ )
+ }
+ }
+ }
common.SetContextKey(c, constant.ContextKeyChannelId, channel.Id)
common.SetContextKey(c, constant.ContextKeyChannelName, channel.Name)
common.SetContextKey(c, constant.ContextKeyChannelType, channel.Type)
common.SetContextKey(c, constant.ContextKeyChannelCreateTime, channel.CreatedTime)
common.SetContextKey(c, constant.ContextKeyChannelSetting, channel.GetSetting())
common.SetContextKey(c, constant.ContextKeyChannelOtherSetting, channel.GetOtherSettings())
+ if channel.Type == constant.ChannelTypeTaskPlugin {
+ c.Set("task_plugin_key", channel.GetSetting().TaskPluginKey)
+ }
+ logTaskPluginChannelDecision(c, channel, modelName, "channel_selected", "")
paramOverride := channel.GetParamOverride()
headerOverride := channel.GetHeaderOverride()
if mergedParam, applied := service.ApplyChannelAffinityOverrideTemplate(c, paramOverride); applied {
diff --git a/middleware/distributor_test.go b/middleware/distributor_test.go
new file mode 100644
index 000000000000..10c500b0adaf
--- /dev/null
+++ b/middleware/distributor_test.go
@@ -0,0 +1,149 @@
+package middleware
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestChannelMatchesExpectedTaskPluginUsesGenericChannelSetting(t *testing.T) {
+ channel := &model.Channel{Type: constant.ChannelTypeTaskPlugin}
+ channel.SetSetting(dto.ChannelSettings{TaskPluginKey: "generic-alpha"})
+
+ assert.True(t, channelMatchesExpectedTaskPlugin(nil, channel, "generic-alpha"))
+ assert.False(t, channelMatchesExpectedTaskPlugin(nil, channel, "generic-beta"))
+ assert.False(t, channelMatchesExpectedTaskPlugin(nil, channel, ""))
+}
+
+func TestChannelMatchesExpectedTaskPluginUsesPinnedLegacyIndex(t *testing.T) {
+ registry := jsplugin.NewRegistry()
+ alpha, err := registry.Register(distributorTaskPluginSource("legacy-alpha", constant.ChannelTypeKling), jsplugin.Options{})
+ require.NoError(t, err)
+ pinnedGeneration := registry.Generation()
+
+ require.NoError(t, registry.Unregister("legacy-alpha"))
+ _, err = registry.Register(distributorTaskPluginSource("legacy-beta", constant.ChannelTypeKling), jsplugin.Options{})
+ require.NoError(t, err)
+
+ c, _ := gin.CreateTestContext(nil)
+ c.Set(jsplugin.ContextKeyPinnedPlugin, jsplugin.PinnedPlugin{
+ Generation: pinnedGeneration,
+ Plugin: alpha,
+ })
+ channel := &model.Channel{Type: constant.ChannelTypeKling}
+
+ assert.True(t, channelMatchesExpectedTaskPlugin(c, channel, "legacy-alpha"))
+ assert.False(t, channelMatchesExpectedTaskPlugin(c, channel, "legacy-beta"))
+ assert.False(t, channelMatchesExpectedTaskPlugin(c, &model.Channel{Type: constant.ChannelTypeJimeng}, "legacy-alpha"))
+}
+
+func TestChannelMatchesExpectedTaskPluginRejectsUnindexedLegacyChannel(t *testing.T) {
+ registry := jsplugin.NewRegistry()
+ plugin, err := registry.Register(distributorTaskPluginSource("legacy-alpha", constant.ChannelTypeKling), jsplugin.Options{})
+ require.NoError(t, err)
+
+ c, _ := gin.CreateTestContext(nil)
+ c.Set(jsplugin.ContextKeyPinnedPlugin, jsplugin.PinnedPlugin{
+ Generation: registry.Generation(),
+ Plugin: plugin,
+ })
+
+ assert.False(t, channelMatchesExpectedTaskPlugin(c, &model.Channel{Type: constant.ChannelTypeJimeng}, "legacy-alpha"))
+ assert.False(t, channelMatchesExpectedTaskPlugin(c, &model.Channel{Type: 0}, "legacy-alpha"))
+ assert.True(t, channelMatchesExpectedTaskPlugin(c, &model.Channel{Type: constant.ChannelTypeJimeng}, ""))
+ assert.False(t, channelMatchesExpectedTaskPlugin(nil, &model.Channel{Type: constant.ChannelTypeKling}, "legacy-alpha"))
+
+ c.Set("expected_task_plugin_key", "legacy-alpha")
+ setupErr := SetupContextForSelectedChannel(c, &model.Channel{Type: constant.ChannelTypeJimeng}, "task-model")
+ require.NotNil(t, setupErr)
+ assert.Contains(t, setupErr.Error(), "does not match")
+}
+
+func TestSharedEndpointRebindsToSelectedLegacyProvider(t *testing.T) {
+ registry := jsplugin.NewRegistry()
+ _, err := registry.Register(distributorEndpointPluginSource("gemini-shared", constant.ChannelTypeGemini), jsplugin.Options{})
+ require.NoError(t, err)
+ _, err = registry.Register(distributorEndpointPluginSource("vertex-shared", constant.ChannelTypeVertexAi), jsplugin.Options{})
+ require.NoError(t, err)
+ candidates := registry.Generation().LookupEndpointCandidates("POST", "/v1/responses", "task-model")
+ require.Len(t, candidates, 2)
+
+ c, _ := gin.CreateTestContext(nil)
+ c.Set(jsplugin.ContextKeyPinnedPlugin, jsplugin.PinnedPlugin{Generation: registry.Generation(), Plugin: candidates[0].Plugin})
+ c.Set(jsplugin.ContextKeyPinnedEndpoint, jsplugin.PinnedEndpoint{
+ Generation: registry.Generation(),
+ Plugin: candidates[0].Plugin,
+ Protocol: candidates[0].Protocol,
+ Operation: candidates[0].Operation,
+ Model: "task-model",
+ Candidates: candidates,
+ })
+ c.Set("expected_task_plugin_key", candidates[0].Plugin.Meta.Key)
+
+ geminiChannel := &model.Channel{Id: 1, Type: constant.ChannelTypeGemini}
+ vertexChannel := &model.Channel{Id: 2, Type: constant.ChannelTypeVertexAi}
+ assert.True(t, channelMatchesExpectedTaskPlugin(c, geminiChannel, candidates[0].Plugin.Meta.Key))
+ assert.True(t, channelMatchesExpectedTaskPlugin(c, vertexChannel, candidates[0].Plugin.Meta.Key))
+ assert.False(t, channelMatchesExpectedTaskPlugin(c, &model.Channel{Type: constant.ChannelTypeKling}, candidates[0].Plugin.Meta.Key))
+
+ require.Nil(t, SetupContextForSelectedChannel(c, vertexChannel, "task-model"))
+ pinnedValue, exists := c.Get(jsplugin.ContextKeyPinnedEndpoint)
+ require.True(t, exists)
+ pinned, ok := pinnedValue.(jsplugin.PinnedEndpoint)
+ require.True(t, ok)
+ assert.Equal(t, "vertex-shared", pinned.Plugin.Meta.Key)
+ assert.Equal(t, "vertex-shared", c.GetString("expected_task_plugin_key"))
+ assert.Equal(t, "vertex-shared", c.GetString("task_plugin_key"))
+ assert.True(t, channelMatchesExpectedTaskPlugin(c, geminiChannel, "vertex-shared"), "a retry may select another declared provider")
+}
+
+func distributorTaskPluginSource(key string, channelType int) string {
+ return fmt.Sprintf(`
+export const meta = {
+ apiVersion: 1,
+ key: %q,
+ name: %q,
+ version: "1.0.0",
+ author: {name: "Test"},
+ channelTypes: [%d],
+ models: ["task-model"],
+ fetchMode: "per_task",
+};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {taskId: "task"}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+`, key, key, channelType)
+}
+
+func distributorEndpointPluginSource(key string, channelType int) string {
+ return fmt.Sprintf(`
+export const meta = {
+ apiVersion: 1,
+ key: %q,
+ name: %q,
+ version: "1.0.0",
+ author: {name: "Test"},
+ channelTypes: [%d],
+ models: ["task-model"],
+ fetchMode: "per_task",
+ protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],
+};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {taskId: "task"}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+export const protocols = {openai_responses: {
+ decodeRequest: function(ctx) { return {kind: "submit", model: "task-model", requestBody: ctx.body.value}; },
+ renderEvents: function() { return {events: [], state: null, done: false}; },
+ renderFinal: function() { return {output: []}; },
+}};
+`, key, key, channelType)
+}
diff --git a/middleware/jimeng_adapter.go b/middleware/jimeng_adapter.go
deleted file mode 100644
index 3e3dd7ae52e0..000000000000
--- a/middleware/jimeng_adapter.go
+++ /dev/null
@@ -1,67 +0,0 @@
-package middleware
-
-import (
- "bytes"
- "encoding/json"
- "io"
- "net/http"
-
- "github.com/QuantumNous/new-api/common"
- "github.com/QuantumNous/new-api/constant"
- relayconstant "github.com/QuantumNous/new-api/relay/constant"
- "github.com/gin-gonic/gin"
-)
-
-func JimengRequestConvert() func(c *gin.Context) {
- return func(c *gin.Context) {
- action := c.Query("Action")
- if action == "" {
- abortWithOpenAiMessage(c, http.StatusBadRequest, "Action query parameter is required")
- return
- }
-
- // Handle Jimeng official API request
- var originalReq map[string]interface{}
- if err := common.UnmarshalBodyReusable(c, &originalReq); err != nil {
- abortWithOpenAiMessage(c, http.StatusBadRequest, "Invalid request body")
- return
- }
- model, _ := originalReq["req_key"].(string)
- prompt, _ := originalReq["prompt"].(string)
-
- unifiedReq := map[string]interface{}{
- "model": model,
- "prompt": prompt,
- "metadata": originalReq,
- }
-
- jsonData, err := json.Marshal(unifiedReq)
- if err != nil {
- abortWithOpenAiMessage(c, http.StatusInternalServerError, "Failed to marshal request body")
- return
- }
-
- // Update request body
- c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonData))
- c.Set(common.KeyRequestBody, jsonData)
-
- if image, ok := originalReq["image"]; !ok || image == "" {
- c.Set("action", constant.TaskActionTextGenerate)
- }
-
- c.Request.URL.Path = "/v1/video/generations"
-
- if action == "CVSync2AsyncGetResult" {
- taskId, ok := originalReq["task_id"].(string)
- if !ok || taskId == "" {
- abortWithOpenAiMessage(c, http.StatusBadRequest, "task_id is required for CVSync2AsyncGetResult")
- return
- }
- c.Request.URL.Path = "/v1/video/generations/" + taskId
- c.Request.Method = http.MethodGet
- c.Set("task_id", taskId)
- c.Set("relay_mode", relayconstant.RelayModeVideoFetchByID)
- }
- c.Next()
- }
-}
diff --git a/middleware/kling_adapter.go b/middleware/kling_adapter.go
deleted file mode 100644
index e200379c0c34..000000000000
--- a/middleware/kling_adapter.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package middleware
-
-import (
- "bytes"
- "encoding/json"
- "io"
-
- "github.com/QuantumNous/new-api/common"
- "github.com/QuantumNous/new-api/constant"
-
- "github.com/gin-gonic/gin"
-)
-
-func KlingRequestConvert() func(c *gin.Context) {
- return func(c *gin.Context) {
- var originalReq map[string]interface{}
- if err := common.UnmarshalBodyReusable(c, &originalReq); err != nil {
- c.Next()
- return
- }
-
- // Support both model_name and model fields
- model, _ := originalReq["model_name"].(string)
- if model == "" {
- model, _ = originalReq["model"].(string)
- }
- prompt, _ := originalReq["prompt"].(string)
-
- unifiedReq := map[string]interface{}{
- "model": model,
- "prompt": prompt,
- "metadata": originalReq,
- }
-
- jsonData, err := json.Marshal(unifiedReq)
- if err != nil {
- c.Next()
- return
- }
-
- // Rewrite request body and path
- c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonData))
- c.Request.URL.Path = "/v1/video/generations"
- if image, ok := originalReq["image"]; !ok || image == "" {
- c.Set("action", constant.TaskActionTextGenerate)
- }
-
- // We have to reset the request body for the next handlers
- c.Set(common.KeyRequestBody, jsonData)
- c.Next()
- }
-}
diff --git a/middleware/logger.go b/middleware/logger.go
index 151008d9f23a..90b2d2eef872 100644
--- a/middleware/logger.go
+++ b/middleware/logger.go
@@ -17,6 +17,7 @@ func RouteTag(tag string) gin.HandlerFunc {
}
func SetUpLogger(server *gin.Engine) {
+ server.Use(redactTaskArtifactAccessQuery())
server.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
var requestID string
if param.Keys != nil {
diff --git a/middleware/task_artifact_access.go b/middleware/task_artifact_access.go
new file mode 100644
index 000000000000..49a827a56a2d
--- /dev/null
+++ b/middleware/task_artifact_access.go
@@ -0,0 +1,245 @@
+package middleware
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/setting/system_setting"
+ "github.com/gin-gonic/gin"
+)
+
+const TaskArtifactAccessContextKey = "task_artifact_access"
+
+const (
+ taskArtifactAccessRawContextKey = "task_artifact_access_raw"
+ taskArtifactAccessPresentContextKey = "task_artifact_access_present"
+ taskArtifactAccessInvalidContextKey = "task_artifact_access_invalid"
+ taskArtifactAccessRateWindow = time.Minute
+ taskArtifactAccessCleanupInterval = time.Minute
+ maxEncodedTaskArtifactAccessQuerySize = 128
+)
+
+type taskArtifactRateEntry struct {
+ windowStart time.Time
+ count int
+}
+
+type taskArtifactAccessLimiter struct {
+ mutex sync.Mutex
+ global int
+ byIP map[string]int
+ byObject map[string]int
+ rates map[string]taskArtifactRateEntry
+ nextCleanup time.Time
+ limits system_setting.TaskArtifactAccessLimits
+}
+
+var taskArtifactAnonymousLimiter = newTaskArtifactAccessLimiter(
+ system_setting.LoadTaskArtifactAccessLimits(),
+)
+
+func newTaskArtifactAccessLimiter(limits system_setting.TaskArtifactAccessLimits) *taskArtifactAccessLimiter {
+ return &taskArtifactAccessLimiter{
+ byIP: make(map[string]int),
+ byObject: make(map[string]int),
+ rates: make(map[string]taskArtifactRateEntry),
+ limits: limits,
+ }
+}
+
+func (l *taskArtifactAccessLimiter) invalidAttempt(now time.Time, ip string) bool {
+ l.mutex.Lock()
+ defer l.mutex.Unlock()
+
+ if l.nextCleanup.IsZero() || !now.Before(l.nextCleanup) {
+ for key, entry := range l.rates {
+ if now.Sub(entry.windowStart) >= taskArtifactAccessRateWindow {
+ delete(l.rates, key)
+ }
+ }
+ l.nextCleanup = now.Add(taskArtifactAccessCleanupInterval)
+ }
+
+ rate := l.rates[ip]
+ if rate.windowStart.IsZero() || now.Sub(rate.windowStart) >= taskArtifactAccessRateWindow {
+ rate = taskArtifactRateEntry{windowStart: now}
+ }
+ if rate.count >= l.limits.InvalidRatePerMinute {
+ return false
+ }
+ rate.count++
+ l.rates[ip] = rate
+ return true
+}
+
+func (l *taskArtifactAccessLimiter) acquire(ip, taskID, artifactKey string) (func(), bool) {
+ l.mutex.Lock()
+ defer l.mutex.Unlock()
+ objectKey := taskID + "\x00" + artifactKey
+ if l.global >= l.limits.GlobalConcurrency ||
+ l.byIP[ip] >= l.limits.IPConcurrency ||
+ l.byObject[objectKey] >= l.limits.ObjectConcurrency {
+ return nil, false
+ }
+
+ l.global++
+ l.byIP[ip]++
+ l.byObject[objectKey]++
+
+ var once sync.Once
+ return func() {
+ once.Do(func() {
+ l.mutex.Lock()
+ defer l.mutex.Unlock()
+ l.global--
+ l.byIP[ip]--
+ l.byObject[objectKey]--
+ if l.byIP[ip] == 0 {
+ delete(l.byIP, ip)
+ }
+ if l.byObject[objectKey] == 0 {
+ delete(l.byObject, objectKey)
+ }
+ })
+ }, true
+}
+
+func redactTaskArtifactAccessQuery() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ path := c.Request.URL.Path
+ isArtifactContent := strings.HasPrefix(path, "/v1/tasks/") &&
+ strings.Contains(path, "/artifacts/") &&
+ strings.HasSuffix(path, "/content")
+ isLegacyVideoContent := strings.HasPrefix(path, "/v1/videos/") &&
+ strings.HasSuffix(path, "/content")
+ if !isArtifactContent && !isLegacyVideoContent {
+ c.Next()
+ return
+ }
+
+ rawAccess, present, invalid := popTaskArtifactAccessQuery(c.Request)
+ if present {
+ c.Set(taskArtifactAccessRawContextKey, rawAccess)
+ c.Set(taskArtifactAccessPresentContextKey, true)
+ c.Set(taskArtifactAccessInvalidContextKey, invalid)
+ }
+ c.Next()
+ }
+}
+
+func popTaskArtifactAccessQuery(request *http.Request) (string, bool, bool) {
+ if request == nil || request.URL == nil {
+ return "", false, false
+ }
+ rawAccess := ""
+ count := 0
+ invalid := false
+ kept := make([]string, 0)
+ for _, part := range strings.Split(request.URL.RawQuery, "&") {
+ rawKey, rawValue, _ := strings.Cut(part, "=")
+ key, err := url.QueryUnescape(rawKey)
+ if err != nil || key != service.TaskArtifactAccessQueryParameter {
+ kept = append(kept, part)
+ continue
+ }
+ count++
+ if len(rawValue) > maxEncodedTaskArtifactAccessQuerySize {
+ invalid = true
+ continue
+ }
+ if count == 1 {
+ value, decodeErr := url.QueryUnescape(rawValue)
+ if decodeErr != nil {
+ invalid = true
+ } else {
+ rawAccess = value
+ }
+ }
+ }
+ if count == 0 {
+ return "", false, false
+ }
+ invalid = invalid || count != 1
+ request.URL.RawQuery = strings.Join(kept, "&")
+ request.RequestURI = request.URL.RequestURI()
+ return rawAccess, true, invalid
+}
+
+// TokenOrTaskArtifactAccessAuth accepts the normal relay API Bearer token or a
+// route-bound capability. Capabilities are verified before any database read.
+func TokenOrTaskArtifactAccessAuth(taskParam, artifactParam string) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.Header("Cache-Control", "private, no-store")
+
+ rawAccess := c.GetString(taskArtifactAccessRawContextKey)
+ present := c.GetBool(taskArtifactAccessPresentContextKey)
+ invalid := c.GetBool(taskArtifactAccessInvalidContextKey)
+ if queryAccess, queryPresent, queryInvalid := popTaskArtifactAccessQuery(c.Request); queryPresent {
+ present = true
+ if rawAccess == "" {
+ rawAccess = queryAccess
+ }
+ invalid = invalid || queryInvalid
+ }
+ if !present {
+ TokenAuth()(c)
+ return
+ }
+
+ taskID := c.Param(taskParam)
+ artifactKey := c.Param(artifactParam)
+ ip := c.ClientIP()
+ if ip == "" {
+ ip = "unknown"
+ }
+ if invalid || !service.VerifyTaskArtifactAccess(rawAccess, taskID, artifactKey) {
+ if !taskArtifactAnonymousLimiter.invalidAttempt(time.Now(), ip) {
+ writeTaskArtifactAccessLimited(c)
+ return
+ }
+ writeTaskArtifactAccessNotFound(c)
+ return
+ }
+
+ release, ok := taskArtifactAnonymousLimiter.acquire(ip, taskID, artifactKey)
+ if !ok {
+ writeTaskArtifactAccessLimited(c)
+ return
+ }
+ defer release()
+
+ c.Set(TaskArtifactAccessContextKey, true)
+ c.Next()
+ }
+}
+
+func IsTaskArtifactAccess(c *gin.Context) bool {
+ return c != nil && c.GetBool(TaskArtifactAccessContextKey)
+}
+
+func writeTaskArtifactAccessNotFound(c *gin.Context) {
+ c.Header("Cache-Control", "private, no-store")
+ c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
+ "error": gin.H{
+ "message": "Task or artifact not found",
+ "type": "artifact_not_found",
+ "code": "artifact_not_found",
+ },
+ })
+}
+
+func writeTaskArtifactAccessLimited(c *gin.Context) {
+ c.Header("Cache-Control", "private, no-store")
+ c.Header("Retry-After", "60")
+ c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
+ "error": gin.H{
+ "message": "Artifact access limit exceeded",
+ "type": "rate_limit_error",
+ "code": "artifact_access_limited",
+ },
+ })
+}
diff --git a/middleware/task_artifact_access_test.go b/middleware/task_artifact_access_test.go
new file mode 100644
index 000000000000..817c01efa284
--- /dev/null
+++ b/middleware/task_artifact_access_test.go
@@ -0,0 +1,158 @@
+package middleware
+
+import (
+ "bytes"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/setting/system_setting"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTaskArtifactAccessIsRedactedAndVerifiedBeforeHandler(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ previousSecret := common.CryptoSecret
+ common.CryptoSecret = "task-artifact-middleware-secret"
+ t.Cleanup(func() { common.CryptoSecret = previousSecret })
+
+ access, err := service.IssueTaskArtifactAccess("task-1", "video-main")
+ require.NoError(t, err)
+
+ router := gin.New()
+ router.Use(redactTaskArtifactAccessQuery())
+ router.GET(
+ "/v1/tasks/:key/artifacts/:artifact_key/content",
+ TokenOrTaskArtifactAccessAuth("key", "artifact_key"),
+ func(c *gin.Context) {
+ assert.True(t, IsTaskArtifactAccess(c))
+ assert.NotContains(t, c.Request.URL.RawQuery, service.TaskArtifactAccessQueryParameter)
+ assert.Equal(t, "kept", c.Query("keep"))
+ c.Status(http.StatusNoContent)
+ },
+ )
+ request := httptest.NewRequest(
+ http.MethodGet,
+ "/v1/tasks/task-1/artifacts/video-main/content?access="+urlQueryEscape(access)+"&keep=kept",
+ nil,
+ )
+ request.RemoteAddr = "192.0.2.1:1234"
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, request)
+
+ assert.Equal(t, http.StatusNoContent, recorder.Code)
+}
+
+func TestTaskArtifactAccessRejectsTamperedAndEmptyCapabilitiesAsNotFound(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ previousSecret := common.CryptoSecret
+ common.CryptoSecret = "task-artifact-middleware-reject-secret"
+ t.Cleanup(func() { common.CryptoSecret = previousSecret })
+
+ router := gin.New()
+ router.Use(redactTaskArtifactAccessQuery())
+ router.GET(
+ "/v1/tasks/:key/artifacts/:artifact_key/content",
+ TokenOrTaskArtifactAccessAuth("key", "artifact_key"),
+ func(c *gin.Context) { c.Status(http.StatusNoContent) },
+ )
+
+ for _, query := range []string{
+ "?access=",
+ "?access=invalid",
+ "?access=first&access=second",
+ "?access=" + strings.Repeat("x", 1024),
+ "?access=%20" + strings.Repeat("A", 43) + "%20",
+ } {
+ request := httptest.NewRequest(
+ http.MethodGet,
+ "/v1/tasks/task-1/artifacts/video-main/content"+query,
+ nil,
+ )
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, request)
+ assert.Equal(t, http.StatusNotFound, recorder.Code)
+ }
+}
+
+func TestTaskArtifactAccessLimiterDefaults(t *testing.T) {
+ limits := system_setting.TaskArtifactAccessLimits{
+ InvalidRatePerMinute: system_setting.DefaultTaskArtifactInvalidRateLimitPerMinute,
+ GlobalConcurrency: system_setting.DefaultTaskArtifactGlobalConcurrency,
+ IPConcurrency: system_setting.DefaultTaskArtifactIPConcurrency,
+ ObjectConcurrency: system_setting.DefaultTaskArtifactObjectConcurrency,
+ }
+ limiter := newTaskArtifactAccessLimiter(limits)
+ now := time.Unix(1000, 0)
+ releases := make([]func(), 0, limits.ObjectConcurrency)
+ for i := 0; i < limits.ObjectConcurrency; i++ {
+ release, ok := limiter.acquire("192.0.2.1", "task-1", "video")
+ require.True(t, ok)
+ releases = append(releases, release)
+ }
+ _, ok := limiter.acquire("192.0.2.2", "task-1", "video")
+ assert.False(t, ok, "task+key concurrency is shared across IPs")
+ for _, release := range releases {
+ release()
+ }
+
+ rateLimiter := newTaskArtifactAccessLimiter(limits)
+ for i := 0; i < limits.InvalidRatePerMinute; i++ {
+ assert.True(t, rateLimiter.invalidAttempt(now, "192.0.2.10"))
+ }
+ assert.False(t, rateLimiter.invalidAttempt(now, "192.0.2.10"))
+ assert.True(t, rateLimiter.invalidAttempt(now.Add(time.Minute), "192.0.2.10"))
+}
+
+func TestRedactTaskArtifactAccessAlsoCoversLegacyVideoRoute(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ router := gin.New()
+ router.Use(redactTaskArtifactAccessQuery())
+ router.GET("/v1/videos/:task_id/content", func(c *gin.Context) {
+ assert.NotContains(t, c.Request.URL.RawQuery, "access")
+ assert.NotContains(t, c.Request.RequestURI, "secret-capability")
+ assert.Equal(t, "ok", c.Query("keep"))
+ c.Status(http.StatusNoContent)
+ })
+ request := httptest.NewRequest(
+ http.MethodGet,
+ "/v1/videos/task-1/content?access=secret-capability&keep=ok",
+ nil,
+ )
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, request)
+ assert.Equal(t, http.StatusNoContent, recorder.Code)
+}
+
+func TestSetUpLoggerNeverWritesTaskArtifactAccess(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ previousWriter := gin.DefaultWriter
+ var output bytes.Buffer
+ gin.DefaultWriter = &output
+ t.Cleanup(func() { gin.DefaultWriter = previousWriter })
+
+ router := gin.New()
+ SetUpLogger(router)
+ router.GET("/v1/tasks/:key/artifacts/:artifact_key/content", func(c *gin.Context) {
+ c.Status(http.StatusNoContent)
+ })
+ request := httptest.NewRequest(
+ http.MethodGet,
+ "/v1/tasks/task-1/artifacts/video/content?access=never-log-this&keep=ok",
+ nil,
+ )
+ router.ServeHTTP(httptest.NewRecorder(), request)
+
+ assert.False(t, strings.Contains(output.String(), "never-log-this"))
+}
+
+func urlQueryEscape(value string) string {
+ replacer := strings.NewReplacer("+", "%2B", "=", "%3D")
+ return replacer.Replace(value)
+}
diff --git a/middleware/task_plugin.go b/middleware/task_plugin.go
new file mode 100644
index 000000000000..3ca33ab7329b
--- /dev/null
+++ b/middleware/task_plugin.go
@@ -0,0 +1,1397 @@
+package middleware
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "mime"
+ "mime/multipart"
+ "net/http"
+ "net/url"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/dto"
+ "github.com/QuantumNous/new-api/logger"
+ "github.com/QuantumNous/new-api/model"
+ pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
+ relayconstant "github.com/QuantumNous/new-api/relay/constant"
+ "github.com/QuantumNous/new-api/relaykit/types"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/gin-gonic/gin"
+ "github.com/tidwall/gjson"
+)
+
+const contextKeyTaskPluginEndpointModel = "task_plugin_endpoint_model_request"
+
+var errTaskPluginUnsupportedMediaType = errors.New("unsupported task plugin media type")
+
+const taskPluginInvalidRouteResult = "plugin returned an invalid route result"
+
+const (
+ maxTaskPluginFormFields = 256
+ maxTaskPluginMultipartParts = 256
+ maxTaskPluginFiles = 32
+ maxTaskPluginFieldNameBytes = 256
+ maxTaskPluginFieldValueBytes = 1 << 20
+ maxTaskPluginFilenameBytes = 255
+)
+
+// PrepareTaskPluginRoute resolves and executes the pinned declarative route.
+// Query requests terminate here so channel distribution and billing are never
+// entered; submit requests continue through the remaining route handlers.
+func PrepareTaskPluginRoute() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedRoute)
+ pinned, ok := pinnedValue.(pluginruntime.PinnedRoute)
+ if !exists || !ok || pinned.Plugin == nil {
+ abortTaskPluginRouteErrorDetail(c, http.StatusInternalServerError, "")
+ return
+ }
+ c.Set(pluginruntime.ContextKeyPinnedPlugin, pluginruntime.PinnedPlugin{
+ Generation: pinned.Generation,
+ Plugin: pinned.Plugin,
+ })
+ generation := uint64(0)
+ if pinned.Generation != nil {
+ generation = pinned.Generation.Number
+ }
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=route event=prepare_start generation=%d plugin=%q method=%q declared_type=%q",
+ generation,
+ pinned.Plugin.Meta.Key,
+ pinned.Route.Method,
+ pinned.Route.Type,
+ )
+
+ requestContext, err := buildTaskPluginRouteRequest(c)
+ c.Set(pluginruntime.ContextKeyRouteRequest, requestContext)
+ if err != nil {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=request_decode reason=invalid_request",
+ generation,
+ pinned.Plugin.Meta.Key,
+ )
+ status := http.StatusBadRequest
+ if errors.Is(err, errTaskPluginUnsupportedMediaType) {
+ status = http.StatusUnsupportedMediaType
+ }
+ abortTaskPluginRouteErrorDetail(c, status, err.Error())
+ return
+ }
+ bodyObject, _ := requestContext.Body.(map[string]any)
+ bodyKind, _ := bodyObject["kind"].(string)
+ if pinned.Route.Type == pluginruntime.RouteTypeQuery && bodyKind != string(pluginruntime.BodyNone) || pinned.Route.Type != pluginruntime.RouteTypeQuery && bodyKind != string(pluginruntime.BodyJSON) {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=request_decode reason=body_kind_mismatch body_kind=%q",
+ generation,
+ pinned.Plugin.Meta.Key,
+ bodyKind,
+ )
+ detail := "this route requires a JSON body"
+ if pinned.Route.Type == pluginruntime.RouteTypeQuery {
+ detail = "unsupported request body for this operation"
+ }
+ abortTaskPluginRouteErrorDetail(c, http.StatusUnsupportedMediaType, detail)
+ return
+ }
+ if pinned.Route.Type == pluginruntime.RouteTypeQuery {
+ taskID := requestContext.Params[pinned.Route.TaskIDParam]
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=route event=resolved generation=%d plugin=%q kind=query renderer=%q task_count=1 distribute=false",
+ generation,
+ pinned.Plugin.Meta.Key,
+ pinned.Route.Render,
+ )
+ renderTaskPluginQuery(c, pinned, requestContext, []string{taskID}, pinned.Route.Render, false)
+ return
+ }
+
+ if len(pinned.Route.Models) > 0 {
+ bodyValue, _ := bodyObject["value"].(map[string]any)
+ claimedModel, _ := bodyValue["model"].(string)
+ if claimedModel == "" || !slices.Contains(pinned.Route.Models, claimedModel) {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=model_not_allowed model=%q",
+ generation,
+ pinned.Plugin.Meta.Key,
+ claimedModel,
+ )
+ abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, fmt.Sprintf("model %q is not allowed on this route", claimedModel))
+ return
+ }
+ }
+
+ hookStarted := time.Now()
+ resolvedValue, err := pinned.Plugin.Engine.CallMember(c.Request.Context(), "native", pinned.Route.Decode, requestContext.JSValue())
+ if err != nil {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=hook_failed err=%q elapsed_ms=%d",
+ generation,
+ pinned.Plugin.Meta.Key,
+ err.Error(),
+ time.Since(hookStarted).Milliseconds(),
+ )
+ abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, taskPluginHookDetail(err))
+ return
+ }
+ resolved, ok := resolvedValue.(map[string]any)
+ if !ok {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=result_not_object elapsed_ms=%d",
+ generation,
+ pinned.Plugin.Meta.Key,
+ time.Since(hookStarted).Milliseconds(),
+ )
+ abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, taskPluginInvalidRouteResult)
+ return
+ }
+ kind, ok := resolved["kind"].(string)
+ if !ok {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=missing_kind elapsed_ms=%d",
+ generation,
+ pinned.Plugin.Meta.Key,
+ time.Since(hookStarted).Milliseconds(),
+ )
+ abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, taskPluginInvalidRouteResult)
+ return
+ }
+ if _, forbidden := resolved["renderer"]; forbidden {
+ logger.LogWarn(c, "task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=forbidden_renderer", generation, pinned.Plugin.Meta.Key)
+ abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, taskPluginInvalidRouteResult)
+ return
+ }
+
+ switch kind {
+ case string(pluginruntime.RouteTypeSubmit):
+ modelName, valid := resolved["model"].(string)
+ if !valid || strings.TrimSpace(modelName) == "" {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=invalid_model",
+ generation,
+ pinned.Plugin.Meta.Key,
+ )
+ abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, "decoded request is missing a model")
+ return
+ }
+ owned := slices.Contains(pinned.Plugin.Meta.Models, modelName)
+ if !owned {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=model_not_owned model=%q",
+ generation,
+ pinned.Plugin.Meta.Key,
+ modelName,
+ )
+ abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, fmt.Sprintf("model %q is not served by this plugin", modelName))
+ return
+ }
+ if len(pinned.Route.Models) > 0 && !slices.Contains(pinned.Route.Models, modelName) {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=resolved_model_not_allowed model=%q",
+ generation,
+ pinned.Plugin.Meta.Key,
+ modelName,
+ )
+ abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, fmt.Sprintf("model %q is not allowed on this route", modelName))
+ return
+ }
+ action := pinned.Route.Action
+ if resolvedAction, present := resolved["action"]; present {
+ actionValue, actionOK := resolvedAction.(string)
+ if !actionOK {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=invalid_action",
+ generation,
+ pinned.Plugin.Meta.Key,
+ )
+ abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, taskPluginInvalidRouteResult)
+ return
+ }
+ if strings.TrimSpace(actionValue) != "" {
+ action = actionValue
+ }
+ }
+ if replacementBody, present := resolved["requestBody"]; present {
+ requestContext.RequestBody = replacementBody
+ c.Set(pluginruntime.ContextKeyRouteRequest, requestContext)
+ }
+ c.Set("task_request", requestContext.RequestBody)
+ c.Set("resolved_task_model", modelName)
+ c.Set("expected_task_plugin_key", pinned.Plugin.Meta.Key)
+ c.Set("task_plugin_key", pinned.Plugin.Meta.Key)
+ c.Set("platform", pinned.Plugin.Meta.Key)
+ service.AppendTaskPluginIdentityFilter(c, pinned.Plugin.Meta.Key)
+ if action != "" {
+ c.Set("task_action", action)
+ }
+ c.Set("relay_mode", relayconstant.RelayModeVideoSubmit)
+ if intentErr := applyOriginTaskIntent(c, resolved, pinned.Plugin.Meta); intentErr != nil {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=origin_task reason=%s",
+ generation,
+ pinned.Plugin.Meta.Key,
+ intentErr.Code,
+ )
+ abortTaskPluginRouteErrorDetail(c, intentErr.StatusCode, intentErr.Message)
+ return
+ }
+ _, bodyReplaced := resolved["requestBody"]
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=route event=resolved generation=%d plugin=%q kind=submit model=%q action_present=%t request_body_replaced=%t distribute=true elapsed_ms=%d",
+ generation,
+ pinned.Plugin.Meta.Key,
+ modelName,
+ action != "",
+ bodyReplaced,
+ time.Since(hookStarted).Milliseconds(),
+ )
+ c.Next()
+ case string(pluginruntime.RouteTypeQuery):
+ if pinned.Route.Type != pluginruntime.RouteTypeDynamic {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=query_from_non_dynamic_route",
+ generation,
+ pinned.Plugin.Meta.Key,
+ )
+ abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, taskPluginInvalidRouteResult)
+ return
+ }
+ taskIDs, valid := resolvedTaskPluginIDs(resolved["taskIds"])
+ if !valid {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=invalid_query_result",
+ generation,
+ pinned.Plugin.Meta.Key,
+ )
+ abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, taskPluginInvalidRouteResult)
+ return
+ }
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=route event=resolved generation=%d plugin=%q kind=query renderer=%q task_count=%d distribute=false elapsed_ms=%d",
+ generation,
+ pinned.Plugin.Meta.Key,
+ pinned.Route.Render,
+ len(taskIDs),
+ time.Since(hookStarted).Milliseconds(),
+ )
+ renderTaskPluginQuery(c, pinned, requestContext, taskIDs, pinned.Route.Render, true)
+ default:
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=unsupported_kind",
+ generation,
+ pinned.Plugin.Meta.Key,
+ )
+ abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, taskPluginInvalidRouteResult)
+ }
+ }
+}
+
+// PinTaskPluginEndpoint decides shared-endpoint ownership without executing
+// plugin code. Invalid or unidentifiable ordinary requests deliberately fall
+// through so the existing endpoint remains responsible for its validation.
+func PinTaskPluginEndpoint() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ generation := pluginruntime.DefaultRegistry.Generation()
+ if generation == nil {
+ c.Next()
+ return
+ }
+
+ modelRequest, err := getModelFromRequest(c)
+ if err != nil {
+ if _, _, protocolPath := pluginruntime.LookupHostProtocolOperation(c.Request.Method, c.Request.URL.Path); protocolPath {
+ abortWithOpenAiMessage(c, http.StatusBadRequest, "Invalid task protocol request")
+ return
+ }
+ c.Next()
+ return
+ }
+ c.Set(contextKeyTaskPluginEndpointModel, *modelRequest)
+ claimedModel := modelRequest.Model
+ if strings.TrimSpace(claimedModel) == "" {
+ c.Next()
+ return
+ }
+ binding, found := generation.LookupEndpoint(c.Request.Method, c.Request.URL.Path, claimedModel)
+ if !found || binding.Plugin == nil {
+ c.Next()
+ return
+ }
+ candidates := generation.LookupEndpointCandidates(c.Request.Method, c.Request.URL.Path, claimedModel)
+ if len(candidates) == 0 {
+ candidates = []pluginruntime.ProtocolBinding{binding}
+ }
+ if definition, known := pluginruntime.HostProtocol(binding.Protocol); known && len(definition.DefinedModes()) > 0 {
+ stream, background := jsonBodyBoolFlags(c)
+ required := make([]string, 0, 2)
+ if stream {
+ required = append(required, "stream")
+ }
+ if background {
+ required = append(required, "background")
+ }
+ if !stream && !background {
+ required = append(required, "sync")
+ }
+ unfiltered := candidates
+ filtered := make([]pluginruntime.ProtocolBinding, 0, len(candidates))
+ for _, candidate := range candidates {
+ if candidate.Plugin == nil {
+ continue
+ }
+ supported := true
+ for _, mode := range required {
+ if !candidate.Plugin.Meta.ProtocolSupports(candidate.Protocol, mode) {
+ supported = false
+ break
+ }
+ }
+ if supported {
+ filtered = append(filtered, candidate)
+ }
+ }
+ if len(filtered) == 0 {
+ abortWithOpenAiMessage(c, http.StatusBadRequest, unsupportedProtocolFormMessage(unfiltered, binding.Protocol, stream, background))
+ return
+ }
+ candidates = filtered
+ binding = candidates[0]
+ }
+
+ pin := pluginruntime.PinnedPlugin{Generation: generation, Plugin: binding.Plugin}
+ pinnedEndpoint := pluginruntime.PinnedEndpoint{
+ Generation: generation,
+ Plugin: binding.Plugin,
+ Protocol: binding.Protocol,
+ Operation: binding.Operation,
+ Model: claimedModel,
+ Candidates: candidates,
+ }
+ c.Set(pluginruntime.ContextKeyPinnedPlugin, pin)
+ c.Set(pluginruntime.ContextKeyPinnedEndpoint, pinnedEndpoint)
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=endpoint event=claimed generation=%d plugin=%q version=%q method=%q protocol=%q model=%q",
+ generation.Number,
+ binding.Plugin.Meta.Key,
+ binding.Plugin.Meta.Version,
+ binding.Operation.Methods[0],
+ binding.Protocol,
+ claimedModel,
+ )
+ c.Next()
+ }
+}
+
+func jsonBodyBoolFlags(c *gin.Context) (stream, background bool) {
+ storage, err := common.GetBodyStorage(c)
+ if err != nil {
+ return false, false
+ }
+ requestBody, err := storage.Bytes()
+ if err != nil {
+ return false, false
+ }
+ values := gjson.GetManyBytes(requestBody, "stream", "background")
+ return values[0].Type == gjson.True, values[1].Type == gjson.True
+}
+
+func unsupportedProtocolFormMessage(candidates []pluginruntime.ProtocolBinding, protocol string, stream, background bool) string {
+ supports := func(mode string) bool {
+ for _, candidate := range candidates {
+ if candidate.Plugin != nil && candidate.Plugin.Meta.ProtocolSupports(protocol, mode) {
+ return true
+ }
+ }
+ return false
+ }
+ if stream && !supports("stream") {
+ if supports("background") {
+ return `Streaming is not supported for this model. Set "stream": false, or use "background": true and retrieve the response later.`
+ }
+ return `Streaming is not supported for this model. Set "stream": false.`
+ }
+ if background && !supports("background") {
+ return `Background mode is not supported for this model. Remove "background": true.`
+ }
+ forms := make([]string, 0, 2)
+ if supports("stream") {
+ forms = append(forms, `"stream": true`)
+ }
+ if supports("background") {
+ forms = append(forms, `"background": true`)
+ }
+ message := "Synchronous non-streaming requests are not supported for this model."
+ if len(forms) == 0 {
+ return message
+ }
+ return message + " Set " + strings.Join(forms, " or ") + "."
+}
+
+// TaskPluginEndpointOnly applies middleware only after a shared endpoint has
+// been claimed. This preserves the original middleware chain for unclaimed
+// video requests while enforcing the plugin route protections on claimed ones.
+func TaskPluginEndpointOnly(handler gin.HandlerFunc) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if _, exists := c.Get(pluginruntime.ContextKeyPinnedEndpoint); !exists {
+ c.Next()
+ return
+ }
+ handler(c)
+ }
+}
+
+// PrepareTaskPluginEndpoint normalizes a claimed shared request through the
+// deterministic parser pinned before distribution. A shared-model request can
+// later rebind to another declared legacy provider from the same generation.
+func PrepareTaskPluginEndpoint() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedEndpoint)
+ pinned, ok := pinnedValue.(pluginruntime.PinnedEndpoint)
+ if !exists {
+ c.Next()
+ return
+ }
+ if !ok || pinned.Generation == nil || pinned.Plugin == nil {
+ abortWithOpenAiMessage(c, http.StatusInternalServerError, "Task protocol request failed")
+ return
+ }
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=endpoint event=prepare_start generation=%d plugin=%q protocol=%q claimed_model=%q",
+ pinned.Generation.Number,
+ pinned.Plugin.Meta.Key,
+ pinned.Protocol,
+ pinned.Model,
+ )
+ if !pluginruntime.SupportsHostProtocol(pinned.Protocol) {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=protocol_check reason=unsupported_protocol",
+ pinned.Generation.Number,
+ pinned.Plugin.Meta.Key,
+ )
+ abortWithOpenAiMessage(c, http.StatusNotImplemented, "Task protocol bridge is not available")
+ return
+ }
+ requestContext, err := buildTaskPluginRouteRequest(c)
+ if err != nil {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=request_decode reason=invalid_request",
+ pinned.Generation.Number,
+ pinned.Plugin.Meta.Key,
+ )
+ status := http.StatusBadRequest
+ if errors.Is(err, errTaskPluginUnsupportedMediaType) {
+ status = http.StatusUnsupportedMediaType
+ }
+ abortWithOpenAiMessage(c, status, err.Error())
+ return
+ }
+ bodyObject, _ := requestContext.Body.(map[string]any)
+ bodyKind, _ := bodyObject["kind"].(string)
+ allowedBody := false
+ for _, allowed := range pinned.Operation.BodyKinds {
+ if bodyKind == string(allowed) {
+ allowedBody = true
+ break
+ }
+ }
+ if !allowedBody {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=request_decode reason=body_kind_mismatch body_kind=%q",
+ pinned.Generation.Number,
+ pinned.Plugin.Meta.Key,
+ bodyKind,
+ )
+ detail := "unsupported request body for this operation"
+ if len(pinned.Operation.BodyKinds) == 1 && pinned.Operation.BodyKinds[0] == pluginruntime.BodyJSON {
+ detail = "this route requires a JSON body"
+ }
+ abortWithOpenAiMessage(c, http.StatusUnsupportedMediaType, detail)
+ return
+ }
+ stream := false
+ if body, bodyOK := requestContext.Body.(map[string]any); bodyOK && body["kind"] == string(pluginruntime.BodyJSON) {
+ requestBody, _ := body["value"].(map[string]any)
+ if streamValue, present := requestBody["stream"]; present {
+ stream, ok = streamValue.(bool)
+ if !ok {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=request_decode reason=invalid_stream_flag",
+ pinned.Generation.Number,
+ pinned.Plugin.Meta.Key,
+ )
+ abortWithOpenAiMessage(c, http.StatusBadRequest, "stream must be a boolean")
+ return
+ }
+ }
+ }
+ protocolContext := pluginruntime.ProtocolRequestContext{
+ RouteRequestContext: requestContext,
+ Protocol: pinned.Protocol,
+ Operation: pinned.Operation.Name,
+ Model: pinned.Model,
+ Stream: stream,
+ }
+ c.Set(pluginruntime.ContextKeyProtocolRequest, protocolContext)
+ hookStarted := time.Now()
+ // Parsing belongs to the durable task submission path. A client
+ // disconnect only stops the later Responses observation.
+ resolvedValue, callErr := pinned.Plugin.Engine.CallPathWithAdmissionTimeout(
+ context.WithoutCancel(c.Request.Context()),
+ pluginruntime.DefaultCallTimeout,
+ "protocols",
+ []string{pinned.Protocol, "decodeRequest"},
+ protocolContext.JSValue(),
+ )
+ if callErr != nil {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=parse_request reason=hook_failed err=%q elapsed_ms=%d",
+ pinned.Generation.Number,
+ pinned.Plugin.Meta.Key,
+ callErr.Error(),
+ time.Since(hookStarted).Milliseconds(),
+ )
+ detail := taskPluginHookDetail(callErr)
+ if detail == "" {
+ detail = "Invalid task protocol request"
+ }
+ abortWithOpenAiMessage(c, http.StatusBadRequest, detail)
+ return
+ }
+ resolved, ok := resolvedValue.(map[string]any)
+ if !ok {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=parse_request reason=result_not_object elapsed_ms=%d",
+ pinned.Generation.Number,
+ pinned.Plugin.Meta.Key,
+ time.Since(hookStarted).Milliseconds(),
+ )
+ abortWithOpenAiMessage(c, http.StatusBadRequest, taskPluginInvalidRouteResult)
+ return
+ }
+ if kind, _ := resolved["kind"].(string); kind != string(pluginruntime.RouteTypeSubmit) {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=parse_request reason=unsupported_kind",
+ pinned.Generation.Number,
+ pinned.Plugin.Meta.Key,
+ )
+ abortWithOpenAiMessage(c, http.StatusBadRequest, taskPluginInvalidRouteResult)
+ return
+ }
+ resolvedModel, ok := resolved["model"].(string)
+ if !ok || strings.TrimSpace(resolvedModel) == "" {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=parse_request reason=invalid_model",
+ pinned.Generation.Number,
+ pinned.Plugin.Meta.Key,
+ )
+ abortWithOpenAiMessage(c, http.StatusBadRequest, "decoded request is missing a model")
+ return
+ }
+ modelOwned := slices.Contains(pinned.Plugin.Meta.Models, resolvedModel)
+ if !modelOwned || resolvedModel != pinned.Model {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=parse_request reason=resolved_model_not_owned claimed_model=%q resolved_model=%q",
+ pinned.Generation.Number,
+ pinned.Plugin.Meta.Key,
+ pinned.Model,
+ resolvedModel,
+ )
+ abortWithOpenAiMessage(c, http.StatusBadRequest, fmt.Sprintf("model %q is not served by this plugin", resolvedModel))
+ return
+ }
+
+ action := ""
+ if resolvedAction, present := resolved["action"]; present {
+ action, ok = resolvedAction.(string)
+ if !ok {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=parse_request reason=invalid_action",
+ pinned.Generation.Number,
+ pinned.Plugin.Meta.Key,
+ )
+ abortWithOpenAiMessage(c, http.StatusBadRequest, taskPluginInvalidRouteResult)
+ return
+ }
+ }
+ _, bodyReplaced := resolved["requestBody"]
+ if normalizedBody, present := resolved["requestBody"]; present {
+ requestContext.RequestBody = normalizedBody
+ }
+ c.Set(pluginruntime.ContextKeyRouteRequest, requestContext)
+ c.Set("task_request", requestContext.RequestBody)
+ c.Set("resolved_task_model", resolvedModel)
+ c.Set("expected_task_plugin_key", pinned.Plugin.Meta.Key)
+ c.Set("task_plugin_key", pinned.Plugin.Meta.Key)
+ c.Set("platform", pinned.Plugin.Meta.Key)
+ service.AppendTaskPluginIdentityFilter(c, pinned.Plugin.Meta.Key)
+ c.Set("relay_mode", relayconstant.RelayModeVideoSubmit)
+ if strings.TrimSpace(action) != "" {
+ c.Set("task_action", action)
+ }
+ if intentErr := applyOriginTaskIntent(c, resolved, pinned.Plugin.Meta); intentErr != nil {
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=origin_task reason=%s",
+ pinned.Generation.Number,
+ pinned.Plugin.Meta.Key,
+ intentErr.Code,
+ )
+ abortWithOpenAiMessage(c, intentErr.StatusCode, intentErr.Message, types.ErrorCode(intentErr.Code))
+ return
+ }
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=endpoint event=prepared generation=%d plugin=%q protocol=%q claimed_model=%q resolved_model=%q action_present=%t stream=%t request_body_replaced=%t elapsed_ms=%d",
+ pinned.Generation.Number,
+ pinned.Plugin.Meta.Key,
+ pinned.Protocol,
+ pinned.Model,
+ resolvedModel,
+ action != "",
+ stream,
+ bodyReplaced,
+ time.Since(hookStarted).Milliseconds(),
+ )
+ c.Next()
+ }
+}
+
+func buildTaskPluginRouteRequest(c *gin.Context) (pluginruntime.RouteRequestContext, error) {
+ requestContext := pluginruntime.RouteRequestContext{
+ Path: c.Request.URL.Path,
+ Method: c.Request.Method,
+ Params: make(map[string]string, len(c.Params)),
+ Query: make(map[string][]string),
+ Body: map[string]any{"kind": string(pluginruntime.BodyNone)},
+ }
+ for _, param := range c.Params {
+ requestContext.Params[param.Key] = param.Value
+ }
+ for key, values := range c.Request.URL.Query() {
+ requestContext.Query[key] = append([]string(nil), values...)
+ }
+
+ contentTypes := c.Request.Header.Values("Content-Type")
+ contentType := strings.TrimSpace(c.GetHeader("Content-Type"))
+ if len(contentTypes) > 1 {
+ canonical := ""
+ for _, value := range contentTypes {
+ mediaType, params, parseErr := mime.ParseMediaType(value)
+ if parseErr != nil {
+ return requestContext, parseErr
+ }
+ current := mime.FormatMediaType(strings.ToLower(mediaType), params)
+ if canonical != "" && current != canonical {
+ return requestContext, fmt.Errorf("conflicting Content-Type headers")
+ }
+ canonical = current
+ }
+ contentType = canonical
+ }
+ if contentType == "" || c.Request.ContentLength == 0 {
+ return requestContext, nil
+ }
+ mediaType, mediaParams, err := mime.ParseMediaType(contentType)
+ if err != nil {
+ return requestContext, err
+ }
+ switch {
+ case mediaType == "application/json" || strings.HasSuffix(mediaType, "+json"):
+ storage, storageErr := common.GetBodyStorage(c)
+ if storageErr != nil {
+ return requestContext, storageErr
+ }
+ raw, bytesErr := storage.Bytes()
+ if bytesErr != nil {
+ return requestContext, bytesErr
+ }
+ if !utf8.Valid(raw) {
+ return requestContext, fmt.Errorf("JSON body must be valid UTF-8")
+ }
+ var value any
+ if err = common.Unmarshal(raw, &value); err != nil {
+ return requestContext, err
+ }
+ requestContext.Body = map[string]any{"kind": string(pluginruntime.BodyJSON), "value": value}
+ case mediaType == "application/x-www-form-urlencoded":
+ storage, storageErr := common.GetBodyStorage(c)
+ if storageErr != nil {
+ return requestContext, storageErr
+ }
+ raw, bytesErr := storage.Bytes()
+ if bytesErr != nil {
+ return requestContext, bytesErr
+ }
+ if !utf8.Valid(raw) {
+ return requestContext, fmt.Errorf("form body must be valid UTF-8")
+ }
+ values, parseErr := url.ParseQuery(string(raw))
+ if parseErr != nil {
+ return requestContext, parseErr
+ }
+ if err = validateTaskPluginFields(values); err != nil {
+ return requestContext, err
+ }
+ fields := make(map[string][]string, len(values))
+ for field, values := range values {
+ fields[field] = append([]string(nil), values...)
+ }
+ requestContext.Body = map[string]any{"kind": string(pluginruntime.BodyForm), "fields": fields}
+ case mediaType == "multipart/form-data":
+ boundary := mediaParams["boundary"]
+ if boundary == "" {
+ return requestContext, fmt.Errorf("multipart boundary is required")
+ }
+ storage, storageErr := common.GetBodyStorage(c)
+ if storageErr != nil {
+ return requestContext, storageErr
+ }
+ raw, bytesErr := storage.Bytes()
+ if bytesErr != nil {
+ return requestContext, bytesErr
+ }
+ reader := multipart.NewReader(bytes.NewReader(raw), boundary)
+ partCount := 0
+ fileCount := 0
+ fieldCount := 0
+ fileLimitMB := constant.MaxFileDownloadMB
+ if fileLimitMB <= 0 {
+ fileLimitMB = 64
+ }
+ for {
+ part, nextErr := reader.NextPart()
+ if nextErr == io.EOF {
+ break
+ }
+ if nextErr != nil {
+ return requestContext, nextErr
+ }
+ partCount++
+ if partCount > maxTaskPluginMultipartParts {
+ part.Close()
+ return requestContext, fmt.Errorf("multipart body exceeds %d parts", maxTaskPluginMultipartParts)
+ }
+ name := part.FormName()
+ if !utf8.ValidString(name) || len(name) == 0 || len(name) > maxTaskPluginFieldNameBytes {
+ part.Close()
+ return requestContext, fmt.Errorf("invalid multipart field name")
+ }
+ partMediaType, _, partMediaErr := mime.ParseMediaType(part.Header.Get("Content-Type"))
+ if partMediaErr != nil && part.Header.Get("Content-Type") != "" {
+ part.Close()
+ return requestContext, fmt.Errorf("invalid multipart part Content-Type")
+ }
+ if strings.HasPrefix(strings.ToLower(partMediaType), "multipart/") {
+ part.Close()
+ return requestContext, fmt.Errorf("nested multipart is not supported")
+ }
+ filename := part.FileName()
+ if filename == "" {
+ fieldCount++
+ if fieldCount > maxTaskPluginFormFields {
+ part.Close()
+ return requestContext, fmt.Errorf("request body exceeds %d fields", maxTaskPluginFormFields)
+ }
+ value, readErr := io.ReadAll(io.LimitReader(part, maxTaskPluginFieldValueBytes+1))
+ part.Close()
+ if readErr != nil {
+ return requestContext, readErr
+ }
+ if len(value) > maxTaskPluginFieldValueBytes {
+ return requestContext, fmt.Errorf("request field %q exceeds %d bytes", name, maxTaskPluginFieldValueBytes)
+ }
+ if !utf8.Valid(value) {
+ return requestContext, fmt.Errorf("request field %q must be valid UTF-8", name)
+ }
+ continue
+ }
+ fileCount++
+ if fileCount > maxTaskPluginFiles {
+ part.Close()
+ return requestContext, fmt.Errorf("multipart body exceeds %d files", maxTaskPluginFiles)
+ }
+ if !utf8.ValidString(filename) || len(filename) > maxTaskPluginFilenameBytes {
+ part.Close()
+ return requestContext, fmt.Errorf("invalid multipart filename")
+ }
+ written, copyErr := io.Copy(io.Discard, io.LimitReader(part, (int64(fileLimitMB)<<20)+1))
+ part.Close()
+ if copyErr != nil {
+ return requestContext, copyErr
+ }
+ if written > int64(fileLimitMB)<<20 {
+ return requestContext, fmt.Errorf("multipart file exceeds %d MB", fileLimitMB)
+ }
+ }
+ form, parseErr := common.ParseMultipartFormReusable(c)
+ if parseErr != nil {
+ return requestContext, parseErr
+ }
+ defer form.RemoveAll()
+ if err = validateTaskPluginFields(form.Value); err != nil {
+ return requestContext, err
+ }
+ partCount = 0
+ fileCount = 0
+ for _, values := range form.Value {
+ partCount += len(values)
+ }
+ for _, headers := range form.File {
+ partCount += len(headers)
+ fileCount += len(headers)
+ }
+ if partCount > maxTaskPluginMultipartParts {
+ return requestContext, fmt.Errorf("multipart body exceeds %d parts", maxTaskPluginMultipartParts)
+ }
+ if fileCount > maxTaskPluginFiles {
+ return requestContext, fmt.Errorf("multipart body exceeds %d files", maxTaskPluginFiles)
+ }
+ textFields := make(map[string][]string, len(form.Value))
+ for field, values := range form.Value {
+ textFields[field] = append([]string(nil), values...)
+ }
+ files := make([]map[string]any, 0)
+ for field, headers := range form.File {
+ if !utf8.ValidString(field) || len(field) > maxTaskPluginFieldNameBytes {
+ return requestContext, fmt.Errorf("invalid multipart file field name")
+ }
+ for _, header := range headers {
+ if !utf8.ValidString(header.Filename) || len(header.Filename) > maxTaskPluginFilenameBytes {
+ return requestContext, fmt.Errorf("invalid multipart filename")
+ }
+ partMediaType, _, mediaErr := mime.ParseMediaType(header.Header.Get("Content-Type"))
+ if mediaErr != nil && header.Header.Get("Content-Type") != "" {
+ return requestContext, fmt.Errorf("invalid multipart part Content-Type")
+ }
+ if strings.HasPrefix(strings.ToLower(partMediaType), "multipart/") {
+ return requestContext, fmt.Errorf("nested multipart is not supported")
+ }
+ if header.Size < 0 || header.Size > int64(fileLimitMB)<<20 {
+ return requestContext, fmt.Errorf("multipart file exceeds %d MB", fileLimitMB)
+ }
+ ref := "request_file:" + field
+ files = append(files, map[string]any{"ref": ref, "field": field, "filename": header.Filename, "mimeType": header.Header.Get("Content-Type"), "size": header.Size})
+ }
+ }
+ requestContext.Files = files
+ requestContext.Body = map[string]any{"kind": string(pluginruntime.BodyMultipart), "fields": textFields, "files": files}
+ default:
+ return requestContext, fmt.Errorf("%w %q", errTaskPluginUnsupportedMediaType, mediaType)
+ }
+ return requestContext, nil
+}
+
+func validateTaskPluginFields(fields url.Values) error {
+ fieldCount := 0
+ for name, values := range fields {
+ if !utf8.ValidString(name) || len(name) == 0 || len(name) > maxTaskPluginFieldNameBytes {
+ return fmt.Errorf("invalid request field name")
+ }
+ for _, value := range values {
+ fieldCount++
+ if fieldCount > maxTaskPluginFormFields {
+ return fmt.Errorf("request body exceeds %d fields", maxTaskPluginFormFields)
+ }
+ if !utf8.ValidString(value) {
+ return fmt.Errorf("request field %q must be valid UTF-8", name)
+ }
+ if len(value) > maxTaskPluginFieldValueBytes {
+ return fmt.Errorf("request field %q exceeds %d bytes", name, maxTaskPluginFieldValueBytes)
+ }
+ }
+ }
+ return nil
+}
+
+const (
+ maxOriginTaskIDs = 16
+ maxOriginTaskIDLen = 128
+)
+
+type originTaskIntentError struct {
+ Code string
+ Message string
+ StatusCode int
+}
+
+// taskPluginLegacyPlatforms lists the Task.Platform values a plugin owns: its
+// key (plugin-era tasks) plus every numeric legacy channel type its driver
+// can drive (pre-plugin tasks, e.g. sora tasks submitted on OpenAI-type
+// channels stored Platform "1").
+func taskPluginLegacyPlatforms(meta pluginruntime.Meta) []constant.TaskPlatform {
+ platforms := []constant.TaskPlatform{constant.TaskPlatform(meta.Key)}
+ // The sunoapi plugin (adapter for the Suno-API proxy project) was renamed
+ // from "suno"; historical rows carry that named Platform value, which
+ // predates the numeric channel-type convention below.
+ if meta.Key == "sunoapi" {
+ platforms = append(platforms, constant.TaskPlatformSuno)
+ }
+ for _, channelType := range meta.ChannelTypes {
+ if channelType <= 0 || channelType == constant.ChannelTypeTaskPlugin {
+ continue
+ }
+ platform := constant.TaskPlatform(strconv.Itoa(channelType))
+ if slices.Contains(platforms, platform) {
+ continue
+ }
+ platforms = append(platforms, platform)
+ }
+ return platforms
+}
+
+func applyOriginTaskIntent(c *gin.Context, intent map[string]any, meta pluginruntime.Meta) *originTaskIntentError {
+ raw, present := intent["originTaskIds"]
+ if !present {
+ return nil
+ }
+ var values []any
+ switch typed := raw.(type) {
+ case []any:
+ values = typed
+ case []string:
+ values = make([]any, len(typed))
+ for i, id := range typed {
+ values[i] = id
+ }
+ default:
+ return &originTaskIntentError{Code: "invalid_origin_task_ids", Message: "origin task ids are invalid", StatusCode: http.StatusBadRequest}
+ }
+ if len(values) > maxOriginTaskIDs {
+ return &originTaskIntentError{Code: "invalid_origin_task_ids", Message: "origin task ids are invalid", StatusCode: http.StatusBadRequest}
+ }
+ ids := make([]string, 0, len(values))
+ seen := make(map[string]struct{}, len(values))
+ for _, value := range values {
+ id, ok := value.(string)
+ if !ok {
+ return &originTaskIntentError{Code: "invalid_origin_task_ids", Message: "origin task ids are invalid", StatusCode: http.StatusBadRequest}
+ }
+ id = strings.TrimSpace(id)
+ if id == "" || utf8.RuneCountInString(id) > maxOriginTaskIDLen {
+ return &originTaskIntentError{Code: "invalid_origin_task_ids", Message: "origin task ids are invalid", StatusCode: http.StatusBadRequest}
+ }
+ if _, exists := seen[id]; exists {
+ continue
+ }
+ seen[id] = struct{}{}
+ ids = append(ids, id)
+ }
+ if len(ids) == 0 {
+ return nil
+ }
+
+ userID := common.GetContextKeyInt(c, constant.ContextKeyUserId)
+ platforms := taskPluginLegacyPlatforms(meta)
+ allowedPlatform := make(map[constant.TaskPlatform]struct{}, len(platforms))
+ for _, platform := range platforms {
+ allowedPlatform[platform] = struct{}{}
+ }
+
+ tasks := make([]*model.Task, 0, len(ids))
+ channelID := 0
+ for _, id := range ids {
+ task, exist, err := model.GetByTaskId(userID, id)
+ if err != nil {
+ return &originTaskIntentError{Code: "origin_task_not_found", Message: "origin task not found or not owned by you", StatusCode: http.StatusInternalServerError}
+ }
+ if !exist || task == nil {
+ return &originTaskIntentError{Code: "origin_task_not_found", Message: "origin task not found or not owned by you", StatusCode: http.StatusBadRequest}
+ }
+ if _, allowed := allowedPlatform[task.Platform]; !allowed {
+ return &originTaskIntentError{Code: "origin_task_platform_mismatch", Message: "origin task does not belong to this plugin", StatusCode: http.StatusBadRequest}
+ }
+ if channelID == 0 {
+ channelID = task.ChannelId
+ } else if task.ChannelId != channelID {
+ return &originTaskIntentError{Code: "origin_task_channel_conflict", Message: "origin tasks must belong to the same channel", StatusCode: http.StatusBadRequest}
+ }
+ tasks = append(tasks, task)
+ }
+
+ channel, err := model.CacheGetChannel(channelID)
+ if err != nil || channel == nil || channel.Status != common.ChannelStatusEnabled {
+ return &originTaskIntentError{Code: "origin_task_channel_disabled", Message: "origin task channel is disabled", StatusCode: http.StatusBadRequest}
+ }
+ service.GetChannelConstraints(c).AddPin(dto.ChannelPin{
+ ChannelId: channel.Id,
+ Source: dto.PinSourceOriginTask,
+ Rank: dto.PinRankOriginTask,
+ RetryMode: dto.PinRetrySameChannel,
+ })
+ common.SetContextKey(c, constant.ContextKeyOriginTasks, tasks)
+ return nil
+}
+
+func resolvedTaskPluginIDs(value any) ([]string, bool) {
+ values, ok := value.([]any)
+ if !ok || len(values) > 100 {
+ return nil, false
+ }
+ taskIDs := make([]string, len(values))
+ for index, value := range values {
+ taskID, stringOK := value.(string)
+ if !stringOK || strings.TrimSpace(taskID) == "" {
+ return nil, false
+ }
+ taskIDs[index] = taskID
+ }
+ return taskIDs, true
+}
+
+func renderTaskPluginQuery(
+ c *gin.Context,
+ pinned pluginruntime.PinnedRoute,
+ requestContext pluginruntime.RouteRequestContext,
+ taskIDs []string,
+ renderer string,
+ multiple bool,
+) {
+ generation := uint64(0)
+ if pinned.Generation != nil {
+ generation = pinned.Generation.Number
+ }
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=query event=lookup_start generation=%d plugin=%q renderer=%q requested=%d multiple=%t",
+ generation,
+ pinned.Plugin.Meta.Key,
+ renderer,
+ len(taskIDs),
+ multiple,
+ )
+ userID := common.GetContextKeyInt(c, constant.ContextKeyUserId)
+ platforms := taskPluginLegacyPlatforms(pinned.Plugin.Meta)
+ tasks, err := model.GetByTaskIdsForPlatforms(userID, platforms, taskIDs)
+ if err != nil {
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=query event=lookup_failed generation=%d plugin=%q reason=database_error requested=%d",
+ generation,
+ pinned.Plugin.Meta.Key,
+ len(taskIDs),
+ )
+ abortTaskPluginRouteError(c, http.StatusInternalServerError)
+ return
+ }
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=query event=lookup_complete generation=%d plugin=%q requested=%d found=%d",
+ generation,
+ pinned.Plugin.Meta.Key,
+ len(taskIDs),
+ len(tasks),
+ )
+ tasksByID := make(map[string]*model.Task, len(tasks))
+ for _, task := range tasks {
+ tasksByID[task.TaskID] = task
+ }
+ views := make([]map[string]any, 0, len(taskIDs))
+ for _, taskID := range taskIDs {
+ task := tasksByID[taskID]
+ if task == nil {
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=query event=lookup_failed generation=%d plugin=%q reason=task_not_found requested=%d found=%d",
+ generation,
+ pinned.Plugin.Meta.Key,
+ len(taskIDs),
+ len(tasks),
+ )
+ abortTaskPluginRouteError(c, http.StatusNotFound)
+ return
+ }
+ view, viewErr := service.BuildTaskPluginView(task)
+ if viewErr != nil {
+ abortTaskPluginRouteError(c, http.StatusInternalServerError)
+ return
+ }
+ var viewValue map[string]any
+ encoded, marshalErr := common.Marshal(view)
+ if marshalErr != nil {
+ abortTaskPluginRouteError(c, http.StatusInternalServerError)
+ return
+ }
+ if unmarshalErr := common.Unmarshal(encoded, &viewValue); unmarshalErr != nil {
+ abortTaskPluginRouteError(c, http.StatusInternalServerError)
+ return
+ }
+ views = append(views, viewValue)
+ }
+
+ var rendererInput any = views
+ if !multiple {
+ rendererInput = views[0]
+ }
+ renderStarted := time.Now()
+ result, err := pinned.Plugin.Engine.CallPath(c.Request.Context(), "native", []string{renderer}, requestContext.JSValue(), rendererInput)
+ if err != nil {
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=query event=render_failed generation=%d plugin=%q renderer=%q reason=hook_failed elapsed_ms=%d",
+ generation,
+ pinned.Plugin.Meta.Key,
+ renderer,
+ time.Since(renderStarted).Milliseconds(),
+ )
+ abortTaskPluginRouteError(c, http.StatusInternalServerError)
+ return
+ }
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=query event=render_complete generation=%d plugin=%q renderer=%q task_count=%d elapsed_ms=%d",
+ generation,
+ pinned.Plugin.Meta.Key,
+ renderer,
+ len(views),
+ time.Since(renderStarted).Milliseconds(),
+ )
+ c.Abort()
+ c.JSON(http.StatusOK, result)
+}
+
+// RespondTaskPluginError gives a pinned plugin a sanitized error DTO and writes
+// its native error body. The host-provided status remains authoritative.
+func RespondTaskPluginError(c *gin.Context, taskErr *dto.TaskError) bool {
+ if taskErr == nil {
+ return false
+ }
+ pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedRoute)
+ pinned, ok := pinnedValue.(pluginruntime.PinnedRoute)
+ if !exists || !ok || pinned.Plugin == nil {
+ return false
+ }
+ sanitized := sanitizedTaskPluginError(taskErr.StatusCode, taskErr.Message)
+ requestID := c.GetString(common.RequestIdKey)
+ hasRenderer, err := pinned.Plugin.Engine.HasCallablePath(c.Request.Context(), "native", "error")
+ requestValue, exists := c.Get(pluginruntime.ContextKeyRouteRequest)
+ requestContext, ok := requestValue.(pluginruntime.RouteRequestContext)
+ if err == nil && hasRenderer && exists && ok {
+ body, callErr := pinned.Plugin.Engine.CallMember(c.Request.Context(), "native", "error", requestContext.JSValue(), map[string]any{
+ "code": sanitized.Code,
+ "message": sanitized.Message,
+ "httpStatus": sanitized.HTTPStatus,
+ "retryable": sanitized.Retryable,
+ "requestId": requestID,
+ })
+ if callErr == nil {
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=route event=error_rendered plugin=%q renderer=plugin status=%d code=%q",
+ pinned.Plugin.Meta.Key,
+ sanitized.HTTPStatus,
+ sanitized.Code,
+ )
+ c.JSON(sanitized.HTTPStatus, body)
+ return true
+ }
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=route event=error_renderer_failed plugin=%q reason=hook_failed status=%d err=%q",
+ pinned.Plugin.Meta.Key,
+ sanitized.HTTPStatus,
+ callErr.Error(),
+ )
+ }
+ logger.LogWarn(
+ c,
+ "task_plugin subsystem=route event=error_rendered plugin=%q renderer=host_fallback status=%d code=%q",
+ pinned.Plugin.Meta.Key,
+ sanitized.HTTPStatus,
+ sanitized.Code,
+ )
+ message := sanitized.Message
+ if requestID != "" {
+ message = common.MessageWithRequestId(sanitized.Message, requestID)
+ }
+ c.JSON(sanitized.HTTPStatus, &dto.TaskError{
+ Code: sanitized.Code,
+ Message: message,
+ StatusCode: sanitized.HTTPStatus,
+ })
+ return true
+}
+
+func abortTaskPluginRouteError(c *gin.Context, status int) {
+ abortTaskPluginRouteErrorDetail(c, status, "")
+}
+
+func abortTaskPluginRouteErrorDetail(c *gin.Context, status int, detail string) {
+ taskErr := sanitizedTaskPluginError(status, detail)
+ c.Abort()
+ if RespondTaskPluginError(c, &dto.TaskError{Code: taskErr.Code, Message: detail, StatusCode: taskErr.HTTPStatus}) {
+ return
+ }
+ message := taskErr.Message
+ if requestID := c.GetString(common.RequestIdKey); requestID != "" {
+ message = common.MessageWithRequestId(taskErr.Message, requestID)
+ }
+ c.JSON(taskErr.HTTPStatus, &dto.TaskError{
+ Code: taskErr.Code,
+ Message: message,
+ StatusCode: taskErr.HTTPStatus,
+ })
+}
+
+func sanitizedTaskPluginError(status int, detail string) dto.TaskPluginError {
+ var taskErr dto.TaskPluginError
+ switch status {
+ case http.StatusBadRequest:
+ taskErr = dto.TaskPluginError{Code: "invalid_request", Message: "Invalid request", HTTPStatus: status}
+ case http.StatusUnauthorized:
+ taskErr = dto.TaskPluginError{Code: "authentication_error", Message: "Authentication failed", HTTPStatus: status}
+ case http.StatusForbidden:
+ taskErr = dto.TaskPluginError{Code: "permission_denied", Message: "Access denied", HTTPStatus: status}
+ case http.StatusNotFound:
+ taskErr = dto.TaskPluginError{Code: "task_not_found", Message: "Task not found", HTTPStatus: status}
+ case http.StatusConflict:
+ taskErr = dto.TaskPluginError{Code: "request_conflict", Message: "Request conflict", HTTPStatus: status}
+ case http.StatusTooManyRequests:
+ taskErr = dto.TaskPluginError{Code: "rate_limit_exceeded", Message: "Too many requests", HTTPStatus: status, Retryable: true}
+ default:
+ if status < 400 || status > 599 {
+ status = http.StatusInternalServerError
+ }
+ if status < 500 {
+ taskErr = dto.TaskPluginError{Code: "invalid_request", Message: "Invalid request", HTTPStatus: status}
+ } else {
+ taskErr = dto.TaskPluginError{Code: "server_error", Message: "Task request failed", HTTPStatus: status, Retryable: status >= 500}
+ }
+ }
+ if detail != "" && taskErr.HTTPStatus < 500 {
+ taskErr.Message = detail
+ }
+ return taskErr
+}
+
+func taskPluginHookDetail(err error) string {
+ var hookErr *pluginruntime.HookError
+ if errors.As(err, &hookErr) {
+ return hookErr.Message
+ }
+ return ""
+}
+
+func logTaskPluginChannelDecision(c *gin.Context, channel *model.Channel, modelName, event, reason string) {
+ expectedPlugin := c.GetString("expected_task_plugin_key")
+ if expectedPlugin == "" {
+ return
+ }
+ generation := uint64(0)
+ if pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedPlugin); exists {
+ if pinned, ok := pinnedValue.(pluginruntime.PinnedPlugin); ok && pinned.Generation != nil {
+ generation = pinned.Generation.Number
+ }
+ }
+ if channel == nil {
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=distribution event=%s generation=%d plugin=%q model=%q reason=%q",
+ event,
+ generation,
+ expectedPlugin,
+ modelName,
+ reason,
+ )
+ return
+ }
+ identityMode := "legacy_channel_type"
+ if channel.Type == constant.ChannelTypeTaskPlugin {
+ identityMode = "type59_setting"
+ }
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=distribution event=%s generation=%d plugin=%q model=%q channel_id=%d channel_type=%d identity_mode=%q reason=%q",
+ event,
+ generation,
+ expectedPlugin,
+ modelName,
+ channel.Id,
+ channel.Type,
+ identityMode,
+ reason,
+ )
+}
+
+func PrepareTaskPluginSubmit() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ pluginKey := strings.TrimSpace(c.Param("key"))
+ generation := pluginruntime.DefaultRegistry.Generation()
+ plugin, ok := generation.Get(pluginKey)
+ if !ok {
+ c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": "task plugin not found", "type": "invalid_request_error"}})
+ return
+ }
+ c.Set(pluginruntime.ContextKeyPinnedPlugin, pluginruntime.PinnedPlugin{
+ Generation: generation,
+ Plugin: plugin,
+ })
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=route event=legacy_entry_pinned generation=%d plugin=%q version=%q",
+ generation.Number,
+ plugin.Meta.Key,
+ plugin.Meta.Version,
+ )
+ var requestBody map[string]any
+ if err := common.UnmarshalBodyReusable(c, &requestBody); err != nil {
+ c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": err.Error(), "type": "invalid_request_error"}})
+ return
+ }
+ modelName, _ := requestBody["model"].(string)
+ if strings.TrimSpace(modelName) == "" {
+ c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": "model is required", "type": "invalid_request_error"}})
+ return
+ }
+ c.Set("task_request", requestBody)
+ c.Set("resolved_task_model", modelName)
+ c.Set("expected_task_plugin_key", pluginKey)
+ service.AppendTaskPluginIdentityFilter(c, pluginKey)
+ c.Set("relay_mode", relayconstant.RelayModeVideoSubmit)
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=route event=resolved generation=%d plugin=%q kind=submit model=%q distribute=true entry=legacy",
+ generation.Number,
+ plugin.Meta.Key,
+ modelName,
+ )
+ c.Next()
+ }
+}
diff --git a/middleware/task_plugin_origin_task_test.go b/middleware/task_plugin_origin_task_test.go
new file mode 100644
index 000000000000..1cbc7e7d85d1
--- /dev/null
+++ b/middleware/task_plugin_origin_task_test.go
@@ -0,0 +1,500 @@
+package middleware
+
+import (
+ "bytes"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/dto"
+ appI18n "github.com/QuantumNous/new-api/i18n"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/QuantumNous/new-api/relay"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/gin-gonic/gin"
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+func setupOriginTaskDB(t *testing.T) {
+ t.Helper()
+ previousDB := model.DB
+ previousType := common.MainDatabaseType()
+ database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, database.AutoMigrate(&model.Task{}, &model.Channel{}))
+ model.DB = database
+ common.SetMainDatabaseType(common.DatabaseTypeSQLite)
+ t.Cleanup(func() {
+ model.DB = previousDB
+ common.SetMainDatabaseType(previousType)
+ })
+}
+
+func insertOriginTaskChannel(t *testing.T, status int) *model.Channel {
+ t.Helper()
+ channel := &model.Channel{
+ Name: "origin-channel",
+ Key: "sk-origin",
+ Status: status,
+ Type: constant.ChannelTypeDoubaoVideo,
+ }
+ require.NoError(t, model.DB.Create(channel).Error)
+ return channel
+}
+
+func insertOriginOwnedTask(t *testing.T, taskID string, userID, channelID int, platform constant.TaskPlatform) *model.Task {
+ t.Helper()
+ task := &model.Task{
+ TaskID: taskID,
+ UserId: userID,
+ ChannelId: channelID,
+ Platform: platform,
+ Action: "text_to_video",
+ Status: model.TaskStatusSuccess,
+ PrivateData: model.TaskPrivateData{
+ UpstreamTaskID: "upstream-" + taskID,
+ },
+ }
+ data, err := common.Marshal(map[string]any{"id": "upstream-" + taskID})
+ require.NoError(t, err)
+ task.Data = data
+ require.NoError(t, model.DB.Create(task).Error)
+ return task
+}
+
+func originTaskTestContext(userID int) *gin.Context {
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/vendor/jobs", nil)
+ common.SetContextKey(c, constant.ContextKeyUserId, userID)
+ return c
+}
+
+func resolvedOriginPin(c *gin.Context) (int, bool) {
+ pin, found, _ := service.GetChannelConstraints(c).ResolvedPin()
+ if !found {
+ return 0, false
+ }
+ return pin.ChannelId, true
+}
+
+func TestApplyOriginTaskIntent(t *testing.T) {
+ setupOriginTaskDB(t)
+ enabled := insertOriginTaskChannel(t, common.ChannelStatusEnabled)
+ otherEnabled := insertOriginTaskChannel(t, common.ChannelStatusEnabled)
+ disabled := insertOriginTaskChannel(t, common.ChannelStatusManuallyDisabled)
+ insertOriginOwnedTask(t, "task-own", 7, enabled.Id, "origin-plugin")
+ insertOriginOwnedTask(t, "task-own-b", 7, enabled.Id, "origin-plugin")
+ insertOriginOwnedTask(t, "task-other-channel", 7, otherEnabled.Id, "origin-plugin")
+ insertOriginOwnedTask(t, "task-foreign", 8, enabled.Id, "origin-plugin")
+ insertOriginOwnedTask(t, "task-wrong-platform", 7, enabled.Id, "other-plugin")
+ insertOriginOwnedTask(t, "task-legacy", 7, enabled.Id, constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeDoubaoVideo)))
+ insertOriginOwnedTask(t, "task-disabled", 7, disabled.Id, "origin-plugin")
+
+ tooMany := make([]any, 17)
+ for i := range tooMany {
+ tooMany[i] = fmt.Sprintf("task-%d", i)
+ }
+
+ tests := []struct {
+ name string
+ userID int
+ intent map[string]any
+ channelType int
+ wantCode string
+ wantPinned int
+ wantIDs []string
+ }{
+ {
+ name: "valid single origin id pins channel",
+ userID: 7,
+ intent: map[string]any{"originTaskIds": []any{"task-own"}},
+ wantPinned: enabled.Id,
+ wantIDs: []string{"task-own"},
+ },
+ {
+ name: "dedupes preserving first order",
+ userID: 7,
+ intent: map[string]any{"originTaskIds": []any{"task-own", " task-own ", "task-own-b"}},
+ wantPinned: enabled.Id,
+ wantIDs: []string{"task-own", "task-own-b"},
+ },
+ {
+ name: "legacy platform matches channelType",
+ userID: 7,
+ intent: map[string]any{"originTaskIds": []any{"task-legacy"}},
+ channelType: constant.ChannelTypeDoubaoVideo,
+ wantPinned: enabled.Id,
+ wantIDs: []string{"task-legacy"},
+ },
+ {
+ name: "unknown id",
+ userID: 7,
+ intent: map[string]any{"originTaskIds": []any{"task-missing"}},
+ wantCode: "origin_task_not_found",
+ },
+ {
+ name: "other user's task is not found",
+ userID: 7,
+ intent: map[string]any{"originTaskIds": []any{"task-foreign"}},
+ wantCode: "origin_task_not_found",
+ },
+ {
+ name: "platform mismatch",
+ userID: 7,
+ intent: map[string]any{"originTaskIds": []any{"task-wrong-platform"}},
+ wantCode: "origin_task_platform_mismatch",
+ },
+ {
+ name: "two ids on different channels",
+ userID: 7,
+ intent: map[string]any{"originTaskIds": []any{"task-own", "task-other-channel"}},
+ wantCode: "origin_task_channel_conflict",
+ },
+ {
+ name: "disabled channel",
+ userID: 7,
+ intent: map[string]any{"originTaskIds": []any{"task-disabled"}},
+ wantCode: "origin_task_channel_disabled",
+ },
+ {
+ name: "more than 16 ids",
+ userID: 7,
+ intent: map[string]any{"originTaskIds": tooMany},
+ wantCode: "invalid_origin_task_ids",
+ },
+ {
+ name: "non-array originTaskIds",
+ userID: 7,
+ intent: map[string]any{"originTaskIds": "task-own"},
+ wantCode: "invalid_origin_task_ids",
+ },
+ {
+ name: "empty string entry",
+ userID: 7,
+ intent: map[string]any{"originTaskIds": []any{"task-own", " "}},
+ wantCode: "invalid_origin_task_ids",
+ },
+ }
+
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ c := originTaskTestContext(testCase.userID)
+ intentErr := applyOriginTaskIntent(c, testCase.intent, jsplugin.Meta{Key: "origin-plugin", ChannelTypes: []int{testCase.channelType}})
+ if testCase.wantCode != "" {
+ require.NotNil(t, intentErr)
+ assert.Equal(t, testCase.wantCode, intentErr.Code)
+ assert.Equal(t, http.StatusBadRequest, intentErr.StatusCode)
+ _, pinned := resolvedOriginPin(c)
+ assert.False(t, pinned)
+ return
+ }
+ require.Nil(t, intentErr)
+ pinnedID, ok := resolvedOriginPin(c)
+ require.True(t, ok)
+ assert.Equal(t, testCase.wantPinned, pinnedID)
+ tasks, ok := common.GetContextKeyType[[]*model.Task](c, constant.ContextKeyOriginTasks)
+ require.True(t, ok)
+ require.Len(t, tasks, len(testCase.wantIDs))
+ for i, wantID := range testCase.wantIDs {
+ assert.Equal(t, wantID, tasks[i].TaskID)
+ }
+ })
+ }
+}
+
+func TestApplyOriginTaskIntentAbsentAndEmptyAreNoop(t *testing.T) {
+ setupOriginTaskDB(t)
+ c := originTaskTestContext(7)
+ require.Nil(t, applyOriginTaskIntent(c, map[string]any{}, jsplugin.Meta{Key: "origin-plugin"}))
+ require.Nil(t, applyOriginTaskIntent(c, map[string]any{"originTaskIds": []any{}}, jsplugin.Meta{Key: "origin-plugin"}))
+ _, pinned := resolvedOriginPin(c)
+ assert.False(t, pinned)
+}
+
+func TestApplyOriginTaskAffinitySetsLockedChannel(t *testing.T) {
+ setupOriginTaskDB(t)
+ channel := insertOriginTaskChannel(t, common.ChannelStatusEnabled)
+ insertOriginOwnedTask(t, "task-lock", 7, channel.Id, "origin-plugin")
+
+ c := originTaskTestContext(7)
+ require.Nil(t, applyOriginTaskIntent(c, map[string]any{"originTaskIds": []any{"task-lock"}}, jsplugin.Meta{Key: "origin-plugin"}))
+
+ info := &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
+ taskErr := relay.ApplyOriginTaskAffinity(c, info)
+ require.Nil(t, taskErr)
+ locked, ok := info.LockedChannel.(*model.Channel)
+ require.True(t, ok)
+ require.NotNil(t, locked)
+ assert.Equal(t, channel.Id, locked.Id)
+ require.Len(t, info.OriginTasks, 1)
+ assert.Equal(t, "task-lock", info.OriginTasks[0].TaskID)
+ assert.Equal(t, "upstream-task-lock", info.OriginTasks[0].UpstreamTaskID)
+ assert.Equal(t, "text_to_video", info.OriginTasks[0].Action)
+ assert.Equal(t, string(model.TaskStatusSuccess), info.OriginTasks[0].Status)
+}
+
+func TestPrepareTaskPluginRoutePinsOriginTaskChannel(t *testing.T) {
+ setupOriginTaskDB(t)
+ channel := insertOriginTaskChannel(t, common.ChannelStatusEnabled)
+ insertOriginOwnedTask(t, "task-route", 7, channel.Id, "origin-route")
+ plugin := compileTaskRoutePlugin(t, `
+export const meta = {
+ apiVersion: 1, key: "origin-route", name: "Origin", version: "1.0.0",
+ author: {name: "Test"},
+ models: ["resolved-model"], fetchMode: "per_task",
+ routes: [{method: "POST", path: "/vendor/jobs", type: "submit", decode: "decodeJob", render: "jobCreated"}],
+};
+export const native = {
+ decodeJob: function() { return {kind: "submit", model: "resolved-model", originTaskIds: ["task-route"], requestBody: {prompt: "ok"}}; },
+ jobCreated: function(ctx, task) { return task; },
+};
+export function buildSubmitRequest() { return {url: "https://example.com"}; }
+export function parseSubmitResponse() { return {taskId: "one"}; }
+export function buildQueryRequest() { return {url: "https://example.com"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+`)
+ reached := false
+ router := gin.New()
+ router.POST("/vendor/jobs", pinTaskPluginRoute(plugin, 0), func(c *gin.Context) {
+ common.SetContextKey(c, constant.ContextKeyUserId, 7)
+ c.Next()
+ }, PrepareTaskPluginRoute(), func(c *gin.Context) {
+ reached = true
+ pinnedID, ok := resolvedOriginPin(c)
+ require.True(t, ok)
+ assert.Equal(t, channel.Id, pinnedID)
+ c.Status(http.StatusNoContent)
+ })
+ request := httptest.NewRequest(http.MethodPost, "/vendor/jobs", strings.NewReader(`{"model":"resolved-model"}`))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, request)
+ assert.True(t, reached)
+ assert.Equal(t, http.StatusNoContent, recorder.Code)
+}
+
+func TestPrepareTaskPluginRouteRejectsUnknownOriginTask(t *testing.T) {
+ setupOriginTaskDB(t)
+ plugin := compileTaskRoutePlugin(t, `
+export const meta = {
+ apiVersion: 1, key: "origin-route-missing", name: "Origin", version: "1.0.0",
+ author: {name: "Test"},
+ models: ["resolved-model"], fetchMode: "per_task",
+ routes: [{method: "POST", path: "/vendor/jobs", type: "submit", decode: "decodeJob", render: "jobCreated"}],
+};
+export const native = {
+ decodeJob: function() { return {kind: "submit", model: "resolved-model", originTaskIds: ["missing"], requestBody: {}}; },
+ jobCreated: function(ctx, task) { return task; },
+};
+export function buildSubmitRequest() { return {url: "https://example.com"}; }
+export function parseSubmitResponse() { return {taskId: "one"}; }
+export function buildQueryRequest() { return {url: "https://example.com"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+`)
+ reached := false
+ router := gin.New()
+ router.POST("/vendor/jobs", pinTaskPluginRoute(plugin, 0), func(c *gin.Context) {
+ common.SetContextKey(c, constant.ContextKeyUserId, 7)
+ c.Next()
+ }, PrepareTaskPluginRoute(), func(c *gin.Context) { reached = true })
+ request := httptest.NewRequest(http.MethodPost, "/vendor/jobs", strings.NewReader(`{"model":"resolved-model"}`))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, request)
+ assert.False(t, reached)
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+}
+
+func TestPrepareTaskPluginEndpointPinsOriginTaskChannel(t *testing.T) {
+ setupOriginTaskDB(t)
+ channel := insertOriginTaskChannel(t, common.ChannelStatusEnabled)
+ insertOriginOwnedTask(t, "task-endpoint", 7, channel.Id, "origin-endpoint")
+ const key = "origin-endpoint"
+ _, err := jsplugin.DefaultRegistry.Register(taskProtocolPluginSource(
+ key,
+ "1.0.0",
+ `["claimed-model"]`,
+ "/v1/responses",
+ `return {model: ctx.model, originTaskIds: ["task-endpoint"], requestBody: {prompt: "ok"}};`,
+ ), jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, jsplugin.DefaultRegistry.Unregister(key)) })
+
+ reached := false
+ router := gin.New()
+ router.POST("/v1/responses", func(c *gin.Context) {
+ common.SetContextKey(c, constant.ContextKeyUserId, 7)
+ c.Next()
+ }, PinTaskPluginEndpoint(), PrepareTaskPluginEndpoint(), func(c *gin.Context) {
+ reached = true
+ pinnedID, ok := resolvedOriginPin(c)
+ require.True(t, ok)
+ assert.Equal(t, channel.Id, pinnedID)
+ c.Status(http.StatusNoContent)
+ })
+ request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"claimed-model","input":"hello"}`))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, request)
+ assert.True(t, reached)
+ assert.Equal(t, http.StatusNoContent, recorder.Code)
+}
+
+func TestPrepareTaskPluginEndpointRejectsUnknownOriginTask(t *testing.T) {
+ setupOriginTaskDB(t)
+ const key = "origin-endpoint-missing"
+ _, err := jsplugin.DefaultRegistry.Register(taskProtocolPluginSource(
+ key,
+ "1.0.0",
+ `["claimed-model"]`,
+ "/v1/responses",
+ `return {model: ctx.model, originTaskIds: ["missing"], requestBody: {prompt: "ok"}};`,
+ ), jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, jsplugin.DefaultRegistry.Unregister(key)) })
+
+ reached := false
+ router := gin.New()
+ router.POST("/v1/responses", func(c *gin.Context) {
+ common.SetContextKey(c, constant.ContextKeyUserId, 7)
+ c.Next()
+ }, PinTaskPluginEndpoint(), PrepareTaskPluginEndpoint(), func(c *gin.Context) { reached = true })
+ request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"claimed-model","input":"hello"}`))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, request)
+ assert.False(t, reached)
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ assert.Contains(t, recorder.Body.String(), "origin_task_not_found")
+}
+
+func TestDistributeHonorsOriginTaskChannelPin(t *testing.T) {
+ require.NoError(t, appI18n.Init())
+ setupOriginTaskDB(t)
+ channel := insertOriginTaskChannel(t, common.ChannelStatusEnabled)
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/vendor/jobs", strings.NewReader(`{}`))
+ c.Request.Header.Set("Content-Type", "application/json")
+ c.Set("resolved_task_model", "resolved-model")
+ service.GetChannelConstraints(c).AddPin(dto.ChannelPin{
+ ChannelId: channel.Id,
+ Source: dto.PinSourceOriginTask,
+ Rank: dto.PinRankOriginTask,
+ RetryMode: dto.PinRetrySameChannel,
+ })
+
+ nextCalled := false
+ handler := Distribute()
+ handler(c)
+ if !c.IsAborted() {
+ nextCalled = true
+ }
+ assert.True(t, nextCalled)
+ assert.Equal(t, channel.Id, common.GetContextKeyInt(c, constant.ContextKeyChannelId))
+}
+
+func TestDistributeTokenPinBeatsOriginPin(t *testing.T) {
+ require.NoError(t, appI18n.Init())
+ setupOriginTaskDB(t)
+ tokenChannel := insertOriginTaskChannel(t, common.ChannelStatusEnabled)
+ originChannel := insertOriginTaskChannel(t, common.ChannelStatusEnabled)
+
+ var warnBuf bytes.Buffer
+ previousWriter := gin.DefaultErrorWriter
+ gin.DefaultErrorWriter = &warnBuf
+ t.Cleanup(func() { gin.DefaultErrorWriter = previousWriter })
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/vendor/jobs", strings.NewReader(`{}`))
+ c.Request.Header.Set("Content-Type", "application/json")
+ c.Set("resolved_task_model", "resolved-model")
+ constraints := service.GetChannelConstraints(c)
+ constraints.AddPin(dto.ChannelPin{
+ ChannelId: tokenChannel.Id,
+ Source: dto.PinSourceToken,
+ Rank: dto.PinRankToken,
+ RetryMode: dto.PinRetrySingleAttempt,
+ })
+ constraints.AddPin(dto.ChannelPin{
+ ChannelId: originChannel.Id,
+ Source: dto.PinSourceOriginTask,
+ Rank: dto.PinRankOriginTask,
+ RetryMode: dto.PinRetrySameChannel,
+ })
+ nextCalled := false
+ Distribute()(c)
+ if !c.IsAborted() {
+ nextCalled = true
+ }
+ assert.True(t, nextCalled)
+ assert.Equal(t, tokenChannel.Id, common.GetContextKeyInt(c, constant.ContextKeyChannelId))
+ warn := warnBuf.String()
+ assert.Contains(t, warn, "winning_source=token")
+ assert.Contains(t, warn, fmt.Sprintf("winning_channel_id=%d", tokenChannel.Id))
+ assert.Contains(t, warn, "overridden_source=origin_task")
+ assert.Contains(t, warn, fmt.Sprintf("overridden_channel_id=%d", originChannel.Id))
+}
+
+func TestDistributePinViolatingIdentityFilterErrors(t *testing.T) {
+ require.NoError(t, appI18n.Init())
+ setupOriginTaskDB(t)
+ channel := insertOriginTaskChannel(t, common.ChannelStatusEnabled)
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/vendor/jobs", strings.NewReader(`{}`))
+ c.Request.Header.Set("Content-Type", "application/json")
+ c.Set("resolved_task_model", "resolved-model")
+ c.Set("expected_task_plugin_key", "alpha")
+ service.GetChannelConstraints(c).AddPin(dto.ChannelPin{
+ ChannelId: channel.Id,
+ Source: dto.PinSourceOriginTask,
+ Rank: dto.PinRankOriginTask,
+ RetryMode: dto.PinRetrySameChannel,
+ })
+
+ Distribute()(c)
+ assert.True(t, c.IsAborted())
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ assert.Contains(t, recorder.Body.String(), string(dto.FilterTaskPluginIdentity))
+}
+
+func TestApplyChannelPinLocksOnlySameChannelRetry(t *testing.T) {
+ setupOriginTaskDB(t)
+ channel := insertOriginTaskChannel(t, common.ChannelStatusEnabled)
+ insertOriginOwnedTask(t, "task-lock-mode", 7, channel.Id, "origin-plugin")
+
+ c := originTaskTestContext(7)
+ require.Nil(t, applyOriginTaskIntent(c, map[string]any{"originTaskIds": []any{"task-lock-mode"}}, jsplugin.Meta{Key: "origin-plugin"}))
+
+ info := &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
+ require.Nil(t, relay.ApplyChannelPin(c, info))
+ locked, ok := info.LockedChannel.(*model.Channel)
+ require.True(t, ok)
+ assert.Equal(t, channel.Id, locked.Id)
+
+ tokenOnly := originTaskTestContext(7)
+ service.GetChannelConstraints(tokenOnly).AddPin(dto.ChannelPin{
+ ChannelId: channel.Id,
+ Source: dto.PinSourceToken,
+ Rank: dto.PinRankToken,
+ RetryMode: dto.PinRetrySingleAttempt,
+ })
+ tokenInfo := &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
+ require.Nil(t, relay.ApplyChannelPin(tokenOnly, tokenInfo))
+ assert.Nil(t, tokenInfo.LockedChannel)
+}
diff --git a/middleware/task_plugin_test.go b/middleware/task_plugin_test.go
new file mode 100644
index 000000000000..c2e8cbfc14af
--- /dev/null
+++ b/middleware/task_plugin_test.go
@@ -0,0 +1,1698 @@
+package middleware
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "net/textproto"
+ "strings"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/dto"
+ appI18n "github.com/QuantumNous/new-api/i18n"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ builtinplugins "github.com/QuantumNous/new-api/plugins"
+ "github.com/gin-gonic/gin"
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+const genericTaskPluginSource = `
+export const meta = {apiVersion: 1, key: "generic-entry-test", name: "Generic", version: "1.0.0", author: {name: "Test"}, models: ["doc"], fetchMode: "per_task"};
+export function buildSubmitRequest() { return {url: "https://example.com"}; }
+export function parseSubmitResponse() { return {taskId: "one"}; }
+export function buildQueryRequest() { return {url: "https://example.com"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+`
+
+func TestPrepareTaskPluginSubmitRejectsMissingModel(t *testing.T) {
+ _, err := jsplugin.DefaultRegistry.Register(genericTaskPluginSource, jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("generic-entry-test") })
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Params = gin.Params{{Key: "key", Value: "generic-entry-test"}}
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/tasks/generic-entry-test", strings.NewReader(`{"prompt":"x"}`))
+ c.Request.Header.Set("Content-Type", "application/json")
+ PrepareTaskPluginSubmit()(c)
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ assert.Contains(t, recorder.Body.String(), "model is required")
+}
+
+func TestPrepareTaskPluginRouteUsesCanonicalContextAndResolvedSubmit(t *testing.T) {
+ plugin := compileTaskRoutePlugin(t, `
+export const meta = {
+ apiVersion: 1, key: "route-submit-test", name: "Submit", version: "1.0.0",
+ author: {name: "Test"},
+ models: ["resolved-model"], fetchMode: "per_task",
+ routes: [{method: "POST", path: "/vendor/jobs/:category", type: "submit", action: "static-action", decode: "decodeJob", render: "jobCreated"}],
+};
+export const native = {
+decodeJob: function(ctx) {
+ if (ctx.path !== "/vendor/jobs/video" || ctx.method !== "POST") throw new Error("bad path");
+ if (ctx.params.category !== "video") throw new Error("bad params");
+ if (ctx.query.tag.length !== 2 || ctx.query.tag[0] !== "first" || ctx.query.tag[1] !== "second") throw new Error("bad query");
+ if (ctx.body.kind !== "json" || !Array.isArray(ctx.body.value) || ctx.body.value[0] !== "prompt") throw new Error("bad body");
+ return {kind: "submit", model: "resolved-model", action: "resolved-action", requestBody: {prompt: "normalized"}};
+},
+jobCreated: function(ctx, task) { return task; },
+};
+export function buildSubmitRequest() { return {url: "https://example.com"}; }
+export function parseSubmitResponse() { return {taskId: "one"}; }
+export function buildQueryRequest() { return {url: "https://example.com"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+`)
+
+ router := gin.New()
+ reachedSubmit := false
+ router.POST("/vendor/jobs/:category", pinTaskPluginRoute(plugin, 0), PrepareTaskPluginRoute(), func(c *gin.Context) {
+ reachedSubmit = true
+ requestContext, ok := c.MustGet(jsplugin.ContextKeyRouteRequest).(jsplugin.RouteRequestContext)
+ require.True(t, ok)
+ assert.Equal(t, map[string]any{"prompt": "normalized"}, requestContext.RequestBody)
+ assert.Equal(t, "resolved-model", c.GetString("resolved_task_model"))
+ assert.Equal(t, "resolved-action", c.GetString("task_action"))
+ assert.Equal(t, "route-submit-test", c.GetString("expected_task_plugin_key"))
+ assert.Equal(t, "route-submit-test", c.GetString("task_plugin_key"))
+ assert.Equal(t, "route-submit-test", c.GetString("platform"))
+ c.Status(http.StatusNoContent)
+ })
+ request := httptest.NewRequest(http.MethodPost, "/vendor/jobs/video?tag=first&tag=second", strings.NewReader(`["prompt",2]`))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, request)
+
+ assert.True(t, reachedSubmit)
+ assert.Equal(t, http.StatusNoContent, recorder.Code)
+}
+
+func TestPrepareTaskPluginNativeRouteRejectsMultipartBeforeDecoder(t *testing.T) {
+ plugin := compileTaskRoutePlugin(t, `
+export const meta = {
+ apiVersion: 1, key: "route-multipart-test", name: "Multipart", version: "1.0.0",
+ author: {name: "Test"},
+ models: ["multipart-model"], fetchMode: "per_task",
+ routes: [{method: "POST", path: "/vendor/uploads", type: "submit", decode: "decodeUpload", render: "created"}],
+};
+export const native = {decodeUpload: function() { throw new Error("decoder must not run"); }, created: function(ctx, task) { return task; }};
+export function buildSubmitRequest() { return {url: "https://example.com"}; }
+export function parseSubmitResponse() { return {taskId: "one"}; }
+export function buildQueryRequest() { return {url: "https://example.com"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+`)
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ require.NoError(t, writer.WriteField("caption", "hello"))
+ require.NoError(t, writer.WriteField("tag", "one"))
+ require.NoError(t, writer.WriteField("tag", "two"))
+ file, err := writer.CreateFormFile("media", "clip.bin")
+ require.NoError(t, err)
+ _, err = file.Write([]byte("opaque-file"))
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+
+ router := gin.New()
+ router.POST("/vendor/uploads", pinTaskPluginRoute(plugin, 0), PrepareTaskPluginRoute(), func(c *gin.Context) {
+ c.Status(http.StatusNoContent)
+ })
+ request := httptest.NewRequest(http.MethodPost, "/vendor/uploads", bytes.NewReader(body.Bytes()))
+ request.Header.Set("Content-Type", writer.FormDataContentType())
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, request)
+
+ assert.Equal(t, http.StatusUnsupportedMediaType, recorder.Code)
+}
+
+func TestPrepareTaskPluginRouteModelScope(t *testing.T) {
+ pluginSource := `
+export const meta = {
+ apiVersion: 1, key: "route-model-scope-test", name: "Scoped", version: "1.0.0",
+ author: {name: "Test"},
+ models: ["gpt-5.5", "gpt-5.6"], fetchMode: "per_task",
+ routes: [{method: "POST", path: "/vendor/batch", type: "submit", models: ["gpt-5.5"], decode: "decodeBatch", render: "batchCreated"}],
+};
+export const native = {
+ decodeBatch: function(ctx) { return {kind: "submit", model: ctx.body.value.model, requestBody: ctx.body.value}; },
+ batchCreated: function(ctx, task) { return task; },
+};
+export function buildSubmitRequest() { return {url: "https://example.com"}; }
+export function parseSubmitResponse() { return {taskId: "one"}; }
+export function buildQueryRequest() { return {url: "https://example.com"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+`
+ tests := []struct {
+ name string
+ body string
+ wantStatus int
+ wantResolved bool
+ }{
+ {name: "listed model passes to decode", body: `{"model":"gpt-5.5","input":"x"}`, wantStatus: http.StatusNoContent, wantResolved: true},
+ {name: "unlisted model rejected before JS", body: `{"model":"gpt-5.6","input":"x"}`, wantStatus: http.StatusBadRequest},
+ {name: "missing model rejected before JS", body: `{"input":"x"}`, wantStatus: http.StatusBadRequest},
+ {name: "non-string model rejected before JS", body: `{"model":7,"input":"x"}`, wantStatus: http.StatusBadRequest},
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ decodeRan := false
+ source := pluginSource
+ if !testCase.wantResolved {
+ // The core invariant: rejected requests never reach the JS engine.
+ source = strings.Replace(source,
+ `decodeBatch: function(ctx) { return {kind: "submit", model: ctx.body.value.model, requestBody: ctx.body.value}; },`,
+ `decodeBatch: function() { throw new Error("decoder must not run"); },`, 1)
+ }
+ plugin := compileTaskRoutePlugin(t, source)
+ router := gin.New()
+ router.POST("/vendor/batch", pinTaskPluginRoute(plugin, 0), PrepareTaskPluginRoute(), func(c *gin.Context) {
+ decodeRan = true
+ assert.Equal(t, "gpt-5.5", c.GetString("resolved_task_model"))
+ c.Status(http.StatusNoContent)
+ })
+ request := httptest.NewRequest(http.MethodPost, "/vendor/batch", strings.NewReader(testCase.body))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, request)
+
+ assert.Equal(t, testCase.wantStatus, recorder.Code)
+ assert.Equal(t, testCase.wantResolved, decodeRan)
+ })
+ }
+}
+
+func TestPrepareTaskPluginRouteRejectsResolvedModelOutsideRouteScope(t *testing.T) {
+ plugin := compileTaskRoutePlugin(t, `
+export const meta = {
+ apiVersion: 1, key: "route-resolved-scope-test", name: "Scoped", version: "1.0.0",
+ author: {name: "Test"},
+ models: ["gpt-5.5", "gpt-5.6"], fetchMode: "per_task",
+ routes: [{method: "POST", path: "/vendor/batch", type: "submit", models: ["gpt-5.5"], decode: "decodeBatch", render: "batchCreated"}],
+};
+export const native = {
+ decodeBatch: function() { return {kind: "submit", model: "gpt-5.6", requestBody: {model: "gpt-5.6"}}; },
+ batchCreated: function(ctx, task) { return task; },
+};
+export function buildSubmitRequest() { return {url: "https://example.com"}; }
+export function parseSubmitResponse() { return {taskId: "one"}; }
+export function buildQueryRequest() { return {url: "https://example.com"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+`)
+ reached := false
+ router := gin.New()
+ router.POST("/vendor/batch", pinTaskPluginRoute(plugin, 0), PrepareTaskPluginRoute(), func(c *gin.Context) {
+ reached = true
+ c.Status(http.StatusNoContent)
+ })
+ request := httptest.NewRequest(http.MethodPost, "/vendor/batch", strings.NewReader(`{"model":"gpt-5.5"}`))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, request)
+
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ assert.False(t, reached, "decode may run, but an out-of-scope resolved model must not continue into submit")
+}
+
+func TestPrepareTaskPluginRouteWithoutModelScopeIsUnrestricted(t *testing.T) {
+ plugin := compileTaskRoutePlugin(t, `
+export const meta = {
+ apiVersion: 1, key: "route-unscoped-test", name: "Unscoped", version: "1.0.0",
+ author: {name: "Test"},
+ models: ["gpt-5.5", "gpt-5.6"], fetchMode: "per_task",
+ routes: [{method: "POST", path: "/vendor/batch", type: "submit", decode: "decodeBatch", render: "batchCreated"}],
+};
+export const native = {
+ decodeBatch: function(ctx) { return {kind: "submit", model: ctx.body.value.model, requestBody: ctx.body.value}; },
+ batchCreated: function(ctx, task) { return task; },
+};
+export function buildSubmitRequest() { return {url: "https://example.com"}; }
+export function parseSubmitResponse() { return {taskId: "one"}; }
+export function buildQueryRequest() { return {url: "https://example.com"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+`)
+ router := gin.New()
+ router.POST("/vendor/batch", pinTaskPluginRoute(plugin, 0), PrepareTaskPluginRoute(), func(c *gin.Context) {
+ c.Status(http.StatusNoContent)
+ })
+ request := httptest.NewRequest(http.MethodPost, "/vendor/batch", strings.NewReader(`{"model":"gpt-5.6"}`))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, request)
+
+ assert.Equal(t, http.StatusNoContent, recorder.Code)
+}
+
+func TestBuildTaskPluginRouteRequestBodyUnion(t *testing.T) {
+ tests := []struct {
+ name string
+ contentType string
+ body string
+ kind jsplugin.BodyKind
+ assertBody func(*testing.T, map[string]any)
+ }{
+ {name: "none", kind: jsplugin.BodyNone},
+ {name: "json", contentType: "application/problem+json; charset=utf-8", body: `{"model":"m"}`, kind: jsplugin.BodyJSON, assertBody: func(t *testing.T, body map[string]any) {
+ assert.Equal(t, map[string]any{"model": "m"}, body["value"])
+ }},
+ {name: "form preserves repeated values", contentType: "application/x-www-form-urlencoded", body: "tag=one&tag=two", kind: jsplugin.BodyForm, assertBody: func(t *testing.T, body map[string]any) {
+ assert.Equal(t, []string{"one", "two"}, body["fields"].(map[string][]string)["tag"])
+ }},
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/body", strings.NewReader(testCase.body))
+ if testCase.contentType != "" {
+ c.Request.Header.Set("Content-Type", testCase.contentType)
+ }
+ requestContext, err := buildTaskPluginRouteRequest(c)
+ require.NoError(t, err)
+ decodedBody := requestContext.Body.(map[string]any)
+ assert.Equal(t, string(testCase.kind), decodedBody["kind"])
+ if testCase.assertBody != nil {
+ testCase.assertBody(t, decodedBody)
+ }
+ })
+ }
+
+ var multipartBody bytes.Buffer
+ writer := multipart.NewWriter(&multipartBody)
+ require.NoError(t, writer.WriteField("tag", "one"))
+ require.NoError(t, writer.WriteField("tag", "two"))
+ file, err := writer.CreateFormFile("input", "image.png")
+ require.NoError(t, err)
+ _, err = file.Write([]byte("file bytes stay in Go"))
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/body", bytes.NewReader(multipartBody.Bytes()))
+ c.Request.Header.Set("Content-Type", writer.FormDataContentType())
+ requestContext, err := buildTaskPluginRouteRequest(c)
+ require.NoError(t, err)
+ decodedBody := requestContext.Body.(map[string]any)
+ assert.Equal(t, string(jsplugin.BodyMultipart), decodedBody["kind"])
+ assert.Equal(t, []string{"one", "two"}, decodedBody["fields"].(map[string][]string)["tag"])
+ files := decodedBody["files"].([]map[string]any)
+ require.Len(t, files, 1)
+ assert.Equal(t, "image.png", files[0]["filename"])
+ assert.NotContains(t, fmt.Sprint(files[0]), "file bytes stay in Go")
+}
+
+func TestBuildTaskPluginRouteRequestRejectsUnsafeBodies(t *testing.T) {
+ tests := []struct {
+ name string
+ contentType string
+ body []byte
+ errorText string
+ }{
+ {name: "invalid json UTF-8", contentType: "application/json", body: []byte{'{', '"', 'x', '"', ':', '"', 0xff, '"', '}'}, errorText: "valid UTF-8"},
+ {name: "invalid form UTF-8", contentType: "application/x-www-form-urlencoded", body: []byte("x=%FF"), errorText: "valid UTF-8"},
+ {name: "too many repeated form fields", contentType: "application/x-www-form-urlencoded", body: []byte(strings.Repeat("x=v&", maxTaskPluginFormFields) + "x=v"), errorText: "exceeds 256 fields"},
+ {name: "oversized form field", contentType: "application/x-www-form-urlencoded", body: []byte("x=" + strings.Repeat("a", maxTaskPluginFieldValueBytes+1)), errorText: "exceeds 1048576 bytes"},
+ {name: "missing multipart boundary", contentType: "multipart/form-data", body: []byte("body"), errorText: "boundary is required"},
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/body", bytes.NewReader(testCase.body))
+ c.Request.Header.Set("Content-Type", testCase.contentType)
+ _, err := buildTaskPluginRouteRequest(c)
+ require.ErrorContains(t, err, testCase.errorText)
+ })
+ }
+
+ t.Run("conflicting Content-Type", func(t *testing.T) {
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/body", strings.NewReader(`{}`))
+ c.Request.Header["Content-Type"] = []string{"application/json", "application/x-www-form-urlencoded"}
+ _, err := buildTaskPluginRouteRequest(c)
+ require.ErrorContains(t, err, "conflicting Content-Type")
+ })
+
+ t.Run("oversized total body", func(t *testing.T) {
+ previous := constant.MaxRequestBodyMB
+ constant.MaxRequestBodyMB = 1
+ t.Cleanup(func() { constant.MaxRequestBodyMB = previous })
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/body", strings.NewReader(strings.Repeat(" ", (1<<20)+1)))
+ c.Request.Header.Set("Content-Type", "application/json")
+ _, err := buildTaskPluginRouteRequest(c)
+ require.ErrorContains(t, err, "request body exceeds 1 MB")
+ })
+
+ multipartCases := []struct {
+ name string
+ build func(*testing.T, *multipart.Writer)
+ errorText string
+ }{
+ {name: "too many parts", build: func(t *testing.T, writer *multipart.Writer) {
+ for i := 0; i <= maxTaskPluginMultipartParts; i++ {
+ require.NoError(t, writer.WriteField("field", "value"))
+ }
+ }, errorText: "exceeds 256 parts"},
+ {name: "too many files", build: func(t *testing.T, writer *multipart.Writer) {
+ for i := 0; i <= maxTaskPluginFiles; i++ {
+ _, err := writer.CreateFormFile("file", fmt.Sprintf("%d.bin", i))
+ require.NoError(t, err)
+ }
+ }, errorText: "exceeds 32 files"},
+ {name: "invalid UTF-8 field", build: func(t *testing.T, writer *multipart.Writer) {
+ part, err := writer.CreateFormField("field")
+ require.NoError(t, err)
+ _, err = part.Write([]byte{0xff})
+ require.NoError(t, err)
+ }, errorText: "valid UTF-8"},
+ {name: "oversized multipart field", build: func(t *testing.T, writer *multipart.Writer) {
+ part, err := writer.CreateFormField("field")
+ require.NoError(t, err)
+ _, err = part.Write([]byte(strings.Repeat("a", maxTaskPluginFieldValueBytes+1)))
+ require.NoError(t, err)
+ }, errorText: "exceeds 1048576 bytes"},
+ {name: "nested multipart", build: func(t *testing.T, writer *multipart.Writer) {
+ header := make(textproto.MIMEHeader)
+ header.Set("Content-Disposition", `form-data; name="nested"`)
+ header.Set("Content-Type", "multipart/mixed; boundary=inner")
+ _, err := writer.CreatePart(header)
+ require.NoError(t, err)
+ }, errorText: "nested multipart"},
+ {name: "invalid UTF-8 filename", build: func(t *testing.T, writer *multipart.Writer) {
+ header := make(textproto.MIMEHeader)
+ header.Set("Content-Disposition", "form-data; name=\"file\"; filename=\""+string([]byte{0xff})+"\"")
+ _, err := writer.CreatePart(header)
+ require.NoError(t, err)
+ }, errorText: "invalid multipart"},
+ {name: "injected disposition name", build: func(t *testing.T, writer *multipart.Writer) {
+ header := make(textproto.MIMEHeader)
+ header.Set("Content-Disposition", "form-data; name=\"prompt"+"\r\n"+"X-Injected: yes\"")
+ part, err := writer.CreatePart(header)
+ require.NoError(t, err)
+ _, err = part.Write([]byte("hello"))
+ require.NoError(t, err)
+ }, errorText: "invalid multipart field name"},
+ {name: "injected disposition filename", build: func(t *testing.T, writer *multipart.Writer) {
+ header := make(textproto.MIMEHeader)
+ header.Set("Content-Disposition", "form-data; name=\"file\"; filename=\"safe.png"+"\r\n"+"X-Injected: yes\"")
+ part, err := writer.CreatePart(header)
+ require.NoError(t, err)
+ _, err = part.Write([]byte("file"))
+ require.NoError(t, err)
+ }, errorText: "invalid multipart"},
+ }
+ for _, testCase := range multipartCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ testCase.build(t, writer)
+ require.NoError(t, writer.Close())
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/body", bytes.NewReader(body.Bytes()))
+ c.Request.Header.Set("Content-Type", writer.FormDataContentType())
+ _, err := buildTaskPluginRouteRequest(c)
+ require.ErrorContains(t, err, testCase.errorText)
+ })
+ }
+
+ t.Run("oversized multipart file", func(t *testing.T) {
+ previous := constant.MaxFileDownloadMB
+ constant.MaxFileDownloadMB = 1
+ t.Cleanup(func() { constant.MaxFileDownloadMB = previous })
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ file, err := writer.CreateFormFile("file", "large.bin")
+ require.NoError(t, err)
+ _, err = file.Write(bytes.Repeat([]byte{'x'}, (1<<20)+1))
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/body", bytes.NewReader(body.Bytes()))
+ c.Request.Header.Set("Content-Type", writer.FormDataContentType())
+ _, err = buildTaskPluginRouteRequest(c)
+ require.ErrorContains(t, err, "multipart file exceeds 1 MB")
+ })
+}
+
+func TestPrepareTaskPluginEndpointPinsGenerationBeforeParseAndDistribution(t *testing.T) {
+ const key = "endpoint-pin-test"
+ firstSource := taskProtocolPluginSource(
+ key,
+ "1.0.0",
+ `["claimed-model"]`,
+ "/v1/responses",
+ `if (ctx.protocol !== "openai_responses" || ctx.operation !== "create" || ctx.model !== "claimed-model" || ctx.path !== "/v1/responses" || ctx.stream !== false) throw new Error("bad context");
+ if (ctx.query.trace[0] !== "one" || ctx.requestBody.prompt !== "hello") throw new Error("bad request");
+ ctx.requestBody.prompt = "plugin-local-mutation";
+ return {model: ctx.model, action: "first-action", requestBody: {prompt: "normalized"}};`,
+ )
+ first, err := jsplugin.DefaultRegistry.Register(firstSource, jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, jsplugin.DefaultRegistry.Unregister(key)) })
+
+ router := gin.New()
+ reachedDistribution := false
+ router.POST(
+ "/v1/responses",
+ PinTaskPluginEndpoint(),
+ func(c *gin.Context) {
+ _, updateErr := jsplugin.DefaultRegistry.Register(taskProtocolPluginSource(
+ key,
+ "2.0.0",
+ `["claimed-model"]`,
+ "/v1/responses",
+ `return {model: ctx.model, action: "second-action"};`,
+ ), jsplugin.Options{})
+ require.NoError(t, updateErr)
+ c.Next()
+ },
+ PrepareTaskPluginEndpoint(),
+ func(c *gin.Context) {
+ reachedDistribution = true
+ pinned := c.MustGet(jsplugin.ContextKeyPinnedEndpoint).(jsplugin.PinnedEndpoint)
+ assert.Same(t, first, pinned.Plugin)
+ assert.Equal(t, "claimed-model", pinned.Model)
+ assert.Equal(t, "first-action", c.GetString("task_action"))
+ assert.Equal(t, "claimed-model", c.GetString("resolved_task_model"))
+ assert.Equal(t, key, c.GetString("expected_task_plugin_key"))
+ assert.Equal(t, key, c.GetString("platform"))
+
+ protocolRequest := c.MustGet(jsplugin.ContextKeyProtocolRequest).(jsplugin.ProtocolRequestContext)
+ assert.Equal(t, map[string]any{"kind": "json", "value": map[string]any{"model": "claimed-model", "prompt": "hello"}}, protocolRequest.Body)
+ assert.False(t, protocolRequest.Stream)
+ routeRequest := c.MustGet(jsplugin.ContextKeyRouteRequest).(jsplugin.RouteRequestContext)
+ assert.Equal(t, map[string]any{"prompt": "normalized"}, routeRequest.RequestBody)
+
+ current, found := jsplugin.DefaultRegistry.Get(key)
+ require.True(t, found)
+ assert.NotSame(t, first, current)
+ c.Status(http.StatusNoContent)
+ },
+ )
+ request := httptest.NewRequest(
+ http.MethodPost,
+ "/v1/responses?trace=one",
+ strings.NewReader(`{"model":"claimed-model","prompt":"hello"}`),
+ )
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, request)
+
+ assert.True(t, reachedDistribution)
+ assert.Equal(t, http.StatusNoContent, recorder.Code)
+}
+
+func TestPrepareTaskPluginEndpointClientDisconnectDoesNotCancelParseHook(t *testing.T) {
+ const key = "endpoint-detached-parse-test"
+ _, err := jsplugin.DefaultRegistry.Register(taskProtocolPluginSource(
+ key,
+ "1.0.0",
+ `["claimed-model"]`,
+ "/v1/responses",
+ `return {model: "claimed-model"};`,
+ ), jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, jsplugin.DefaultRegistry.Unregister(key)) })
+
+ router := gin.New()
+ reachedDistribution := false
+ router.POST(
+ "/v1/responses",
+ PinTaskPluginEndpoint(),
+ func(c *gin.Context) {
+ requestContext, cancel := context.WithCancel(c.Request.Context())
+ cancel()
+ c.Request = c.Request.WithContext(requestContext)
+ c.Next()
+ },
+ PrepareTaskPluginEndpoint(),
+ func(c *gin.Context) {
+ reachedDistribution = true
+ assert.Equal(t, "claimed-model", c.GetString("resolved_task_model"))
+ c.Status(http.StatusNoContent)
+ },
+ )
+ request := httptest.NewRequest(
+ http.MethodPost,
+ "/v1/responses",
+ strings.NewReader(`{"model":"claimed-model"}`),
+ )
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, request)
+
+ assert.True(t, reachedDistribution)
+ assert.Equal(t, http.StatusNoContent, recorder.Code)
+}
+
+func TestPrepareTaskPluginEndpointUsesStrictOriginalStreamFlag(t *testing.T) {
+ const key = "endpoint-stream-test"
+ _, err := jsplugin.DefaultRegistry.Register(taskProtocolPluginSource(
+ key,
+ "1.0.0",
+ `["claimed-model"]`,
+ "/v1/responses",
+ `if (ctx.stream !== true) throw new Error("stream mode was not preserved");
+ return {model: "claimed-model", requestBody: {stream: false}};`,
+ ), jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, jsplugin.DefaultRegistry.Unregister(key)) })
+
+ router := gin.New()
+ reachedDistribution := false
+ router.POST(
+ "/v1/responses",
+ PinTaskPluginEndpoint(),
+ PrepareTaskPluginEndpoint(),
+ func(c *gin.Context) {
+ reachedDistribution = true
+ protocolRequest := c.MustGet(jsplugin.ContextKeyProtocolRequest).(jsplugin.ProtocolRequestContext)
+ assert.True(t, protocolRequest.Stream)
+ normalizedRequest := c.MustGet(jsplugin.ContextKeyRouteRequest).(jsplugin.RouteRequestContext)
+ assert.Equal(t, map[string]any{"stream": false}, normalizedRequest.RequestBody)
+ c.Status(http.StatusNoContent)
+ },
+ )
+ request := httptest.NewRequest(
+ http.MethodPost,
+ "/v1/responses",
+ strings.NewReader(`{"model":"claimed-model","stream":true}`),
+ )
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, request)
+
+ assert.True(t, reachedDistribution)
+ assert.Equal(t, http.StatusNoContent, recorder.Code)
+
+ for _, invalid := range []string{`null`, `"true"`, `1`, `"yes"`} {
+ request = httptest.NewRequest(
+ http.MethodPost,
+ "/v1/responses",
+ strings.NewReader(`{"model":"claimed-model","stream":`+invalid+`}`),
+ )
+ request.Header.Set("Content-Type", "application/json")
+ recorder = httptest.NewRecorder()
+ router.ServeHTTP(recorder, request)
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ }
+}
+
+func TestTaskPluginEndpointMissPreservesOrdinaryRequestBody(t *testing.T) {
+ const key = "endpoint-miss-test"
+ _, err := jsplugin.DefaultRegistry.Register(taskProtocolPluginSource(
+ key,
+ "1.0.0",
+ `["claimed-model"]`,
+ "/v1/responses",
+ `return {model: "claimed-model"};`,
+ ), jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, jsplugin.DefaultRegistry.Unregister(key)) })
+
+ router := gin.New()
+ router.POST(
+ "/v1/responses",
+ PinTaskPluginEndpoint(),
+ PrepareTaskPluginEndpoint(),
+ func(c *gin.Context) {
+ _, pinned := c.Get(jsplugin.ContextKeyPinnedEndpoint)
+ assert.False(t, pinned)
+ var body map[string]any
+ require.NoError(t, common.UnmarshalBodyReusable(c, &body))
+ assert.Equal(t, "ordinary-model", body["model"])
+ c.Status(http.StatusNoContent)
+ },
+ )
+ request := httptest.NewRequest(
+ http.MethodPost,
+ "/v1/responses",
+ strings.NewReader(`{"model":"ordinary-model","input":"hello"}`),
+ )
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, request)
+
+ assert.Equal(t, http.StatusNoContent, recorder.Code)
+}
+
+func TestTaskPluginEndpointUsesOneCanonicalModelForDuplicateJSONKeys(t *testing.T) {
+ require.NoError(t, appI18n.Init())
+ const key = "endpoint-duplicate-model-test"
+ _, err := jsplugin.DefaultRegistry.Register(taskProtocolPluginSource(
+ key,
+ "1.0.0",
+ `["claimed-model"]`,
+ "/v1/responses",
+ `if (ctx.requestBody.model !== "claimed-model") throw new Error("noncanonical model");
+ return {model: ctx.requestBody.model};`,
+ ), jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, jsplugin.DefaultRegistry.Unregister(key)) })
+
+ tests := []struct {
+ name string
+ body string
+ }{
+ {
+ name: "claimed model first",
+ body: `{"model":"claimed-model","model":"ordinary-model"}`,
+ },
+ {
+ name: "claimed model second",
+ body: `{"model":"ordinary-model","model":"claimed-model"}`,
+ },
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ reachedDownstream := false
+ router := gin.New()
+ router.POST(
+ "/v1/responses",
+ PinTaskPluginEndpoint(),
+ PrepareTaskPluginEndpoint(),
+ func(c *gin.Context) {
+ reachedDownstream = true
+ c.Status(http.StatusNoContent)
+ },
+ )
+ request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(testCase.body))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, request)
+
+ assert.False(t, reachedDownstream)
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ })
+ }
+}
+
+func TestTaskPluginEndpointOnlyPreservesConditionalMiddlewareSemantics(t *testing.T) {
+ tests := []struct {
+ name string
+ claimed bool
+ wrappedAborts bool
+ expectedWrapped int
+ expectedDownstream bool
+ expectedStatus int
+ }{
+ {
+ name: "unclaimed skips wrapper",
+ expectedDownstream: true,
+ expectedStatus: http.StatusNoContent,
+ },
+ {
+ name: "claimed invokes wrapper",
+ claimed: true,
+ expectedWrapped: 1,
+ expectedDownstream: true,
+ expectedStatus: http.StatusNoContent,
+ },
+ {
+ name: "claimed wrapper aborts",
+ claimed: true,
+ wrappedAborts: true,
+ expectedWrapped: 1,
+ expectedStatus: http.StatusTooManyRequests,
+ },
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ wrappedCalls := 0
+ reachedDownstream := false
+ router := gin.New()
+ router.POST(
+ "/v1/videos",
+ func(c *gin.Context) {
+ if testCase.claimed {
+ c.Set(jsplugin.ContextKeyPinnedEndpoint, jsplugin.PinnedEndpoint{})
+ }
+ c.Next()
+ },
+ TaskPluginEndpointOnly(func(c *gin.Context) {
+ wrappedCalls++
+ if testCase.wrappedAborts {
+ c.AbortWithStatus(http.StatusTooManyRequests)
+ return
+ }
+ c.Next()
+ }),
+ func(c *gin.Context) {
+ reachedDownstream = true
+ c.Status(http.StatusNoContent)
+ },
+ )
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/videos", nil))
+
+ assert.Equal(t, testCase.expectedWrapped, wrappedCalls)
+ assert.Equal(t, testCase.expectedDownstream, reachedDownstream)
+ assert.Equal(t, testCase.expectedStatus, recorder.Code)
+ })
+ }
+}
+
+func TestPrepareTaskPluginEndpointRejectsModelDriftBeforeDistribution(t *testing.T) {
+ const key = "endpoint-drift-test"
+ _, err := jsplugin.DefaultRegistry.Register(taskProtocolPluginSource(
+ key,
+ "1.0.0",
+ `["claimed-model"]`,
+ "/v1/responses",
+ `return {model: "outside-model"};`,
+ ), jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, jsplugin.DefaultRegistry.Unregister(key)) })
+
+ reachedDistribution := false
+ router := gin.New()
+ router.POST(
+ "/v1/responses",
+ PinTaskPluginEndpoint(),
+ PrepareTaskPluginEndpoint(),
+ func(c *gin.Context) {
+ reachedDistribution = true
+ c.Status(http.StatusNoContent)
+ },
+ )
+ request := httptest.NewRequest(
+ http.MethodPost,
+ "/v1/responses",
+ strings.NewReader(`{"model":"claimed-model"}`),
+ )
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, request)
+
+ assert.False(t, reachedDistribution)
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ var payload map[string]any
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &payload))
+ errObj, _ := payload["error"].(map[string]any)
+ require.NotNil(t, errObj)
+ assert.Contains(t, fmt.Sprint(errObj["message"]), `model "outside-model" is not served by this plugin`)
+}
+
+func TestPrepareTaskPluginEndpointAcceptsRegisteredVideoMultipartBody(t *testing.T) {
+ const key = "endpoint-multipart-test"
+ _, err := jsplugin.DefaultRegistry.Register(taskProtocolPluginSource(
+ key,
+ "1.0.0",
+ `["video-model"]`,
+ "/v1/videos",
+ `if (ctx.body.kind !== "multipart" || ctx.body.fields.prompt[0] !== "hello") throw new Error("bad prompt");
+ if (ctx.body.files.length !== 1 || ctx.body.files[0].field !== "input_reference") throw new Error("bad file ref");
+ return {model: ctx.model, requestBody: {prompt: ctx.body.fields.prompt[0]}};`,
+ ), jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, jsplugin.DefaultRegistry.Unregister(key)) })
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ require.NoError(t, writer.WriteField("model", "video-model"))
+ require.NoError(t, writer.WriteField("prompt", "hello"))
+ file, err := writer.CreateFormFile("input_reference", "reference.bin")
+ require.NoError(t, err)
+ _, err = file.Write([]byte("opaque-video-reference"))
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+
+ router := gin.New()
+ reachedDistribution := false
+ router.POST(
+ "/v1/videos",
+ PinTaskPluginEndpoint(),
+ PrepareTaskPluginEndpoint(),
+ func(c *gin.Context) {
+ reachedDistribution = true
+ c.Status(http.StatusNoContent)
+ },
+ )
+ request := httptest.NewRequest(http.MethodPost, "/v1/videos", bytes.NewReader(body.Bytes()))
+ request.Header.Set("Content-Type", writer.FormDataContentType())
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, request)
+
+ assert.True(t, reachedDistribution)
+ assert.Equal(t, http.StatusNoContent, recorder.Code)
+}
+
+func TestVideoGenerationsIsNotClaimedByOpenAIVideoProtocol(t *testing.T) {
+ const key = "endpoint-video-gen-test"
+ _, err := jsplugin.DefaultRegistry.Register(taskProtocolPluginSource(
+ key,
+ "1.0.0",
+ `["generation-model"]`,
+ "/v1/video/generations",
+ `return {model: ctx.requestBody.model, action: "generate"};`,
+ ), jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, jsplugin.DefaultRegistry.Unregister(key)) })
+
+ router := gin.New()
+ reachedDistribution := false
+ router.POST(
+ "/v1/video/generations",
+ PinTaskPluginEndpoint(),
+ PrepareTaskPluginEndpoint(),
+ func(c *gin.Context) {
+ reachedDistribution = true
+ c.Status(http.StatusNoContent)
+ },
+ )
+ request := httptest.NewRequest(
+ http.MethodPost,
+ "/v1/video/generations",
+ strings.NewReader(`{"model":"generation-model"}`),
+ )
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, request)
+
+ assert.True(t, reachedDistribution)
+ assert.Equal(t, http.StatusNoContent, recorder.Code)
+}
+
+func TestPrepareTaskPluginRouteKeepsRawBinaryOpaque(t *testing.T) {
+ plugin := compileTaskRoutePlugin(t, `
+export const meta = {
+ apiVersion: 1, key: "route-binary-test", name: "Binary", version: "1.0.0",
+ author: {name: "Test"},
+ models: ["binary-model"], fetchMode: "per_task",
+ routes: [{method: "POST", path: "/vendor/binary", type: "submit", decode: "decodeBinary", render: "created"}],
+};
+export const native = {decodeBinary: function() { throw new Error("decoder must not run"); }, created: function(ctx, task) { return task; }};
+export function buildSubmitRequest() { return {url: "https://example.com"}; }
+export function parseSubmitResponse() { return {taskId: "one"}; }
+export function buildQueryRequest() { return {url: "https://example.com"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+`)
+ router := gin.New()
+ router.POST("/vendor/binary", pinTaskPluginRoute(plugin, 0), PrepareTaskPluginRoute(), func(c *gin.Context) {
+ body, err := io.ReadAll(c.Request.Body)
+ require.NoError(t, err)
+ assert.Equal(t, []byte{0, 1, 2, 3}, body)
+ c.Status(http.StatusNoContent)
+ })
+ request := httptest.NewRequest(http.MethodPost, "/vendor/binary", bytes.NewReader([]byte{0, 1, 2, 3}))
+ request.Header.Set("Content-Type", "application/octet-stream")
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, request)
+
+ assert.Equal(t, http.StatusUnsupportedMediaType, recorder.Code)
+}
+
+func TestPrepareTaskPluginDynamicQueryPreservesOrderAndSkipsNextHandlers(t *testing.T) {
+ setupTaskPluginRouteDB(t)
+ plugin := compileTaskRoutePlugin(t, `
+export const meta = {
+ apiVersion: 1, key: "route-query-test", name: "Query", version: "1.0.0",
+ author: {name: "Test"},
+ models: ["query-model"], fetchMode: "per_task",
+ routes: [{method: "POST", path: "/vendor/query", type: "dynamic", decode: "decodeQuery", render: "renderQuery"}],
+};
+export const native = {
+ decodeQuery: function() { return {kind: "query", taskIds: ["task-b", "task-a", "task-b"]}; },
+ renderQuery: function(ctx, tasks) {
+ return {ids: tasks.map(function(task) { return task.task_id; }), keys: Object.keys(tasks[0]).sort(), data: tasks[0].data};
+ },
+};
+export function buildSubmitRequest() { return {url: "https://example.com"}; }
+export function parseSubmitResponse() { return {taskId: "one"}; }
+export function buildQueryRequest() { return {url: "https://example.com"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+`)
+ insertTaskPluginRouteTask(t, &model.Task{
+ TaskID: "task-a", UserId: 7, Platform: constant.TaskPlatform("route-query-test"),
+ Status: model.TaskStatusSuccess, Progress: "100%", CreatedAt: 10,
+ })
+ taskData, err := common.Marshal(map[string]any{
+ "task_id": "private-upstream-id",
+ "nested": map[string]any{"url": "https://upstream.invalid/tasks/private-upstream-id"},
+ })
+ require.NoError(t, err)
+ insertTaskPluginRouteTask(t, &model.Task{
+ TaskID: "task-b", UserId: 7, Platform: constant.TaskPlatform("route-query-test"),
+ Status: model.TaskStatusInProgress, Progress: "50%", CreatedAt: 20, Data: taskData,
+ ChannelId: 999, Quota: 12345, PrivateData: model.TaskPrivateData{UpstreamTaskID: "private-upstream-id", Key: "secret"},
+ })
+
+ nextHandlerCalled := false
+ router := gin.New()
+ router.POST("/vendor/query",
+ pinTaskPluginRoute(plugin, 0),
+ func(c *gin.Context) {
+ c.Set("id", 7)
+ c.Next()
+ },
+ PrepareTaskPluginRoute(),
+ func(c *gin.Context) {
+ nextHandlerCalled = true
+ c.Status(http.StatusTeapot)
+ },
+ )
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodPost, "/vendor/query", strings.NewReader(`{}`))
+ request.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(recorder, request)
+
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ assert.False(t, nextHandlerCalled)
+ var response map[string]any
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.Equal(t, []any{"task-b", "task-a", "task-b"}, response["ids"])
+ data, ok := response["data"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "task-b", data["task_id"])
+ nested, ok := data["nested"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "https://upstream.invalid/tasks/private-upstream-id", nested["url"])
+ keys, ok := response["keys"].([]any)
+ require.True(t, ok)
+ assert.NotContains(t, keys, "user_id")
+ assert.NotContains(t, keys, "channel_id")
+ assert.NotContains(t, keys, "quota")
+ assert.NotContains(t, keys, "private_data")
+ assert.NotContains(t, recorder.Body.String(), "secret")
+}
+
+func TestPrepareTaskPluginDynamicDecoderRejectsRendererField(t *testing.T) {
+ plugin := compileTaskRoutePlugin(t, `
+export const meta = {apiVersion:1,key:"dynamic-renderer",name:"Dynamic",version:"1.0.0",author:{name:"Test"},models:["model"],fetchMode:"per_task",routes:[{method:"POST",path:"/vendor/query",type:"dynamic",decode:"decode",render:"show"}]};
+export const native = {decode:function(){return {kind:"query",taskIds:[],renderer:"legacy"};},show:function(){return {};}};
+export function buildSubmitRequest(){return {}} export function parseSubmitResponse(){return {taskId:"one"}} export function buildQueryRequest(){return {}} export function parseTaskResult(){return {status:"SUCCESS"}}
+`)
+ router := gin.New()
+ reached := false
+ router.POST("/vendor/query", pinTaskPluginRoute(plugin, 0), PrepareTaskPluginRoute(), func(c *gin.Context) { reached = true })
+ request := httptest.NewRequest(http.MethodPost, "/vendor/query", strings.NewReader(`{}`))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, request)
+
+ assert.False(t, reached)
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+}
+
+func TestPrepareTaskPluginSubmitDecoderRejectsRendererField(t *testing.T) {
+ plugin := compileTaskRoutePlugin(t, `
+export const meta = {apiVersion:1,key:"submit-renderer",name:"Submit",version:"1.0.0",author:{name:"Test"},models:["model"],fetchMode:"per_task",routes:[{method:"POST",path:"/vendor/submit",type:"submit",decode:"decode",render:"created"}]};
+export const native = {decode:function(){return {kind:"submit",model:"model",requestBody:{},renderer:"legacy"};},created:function(){return {};}};
+export function buildSubmitRequest(){return {}} export function parseSubmitResponse(){return {taskId:"one"}} export function buildQueryRequest(){return {}} export function parseTaskResult(){return {status:"SUCCESS"}}
+`)
+ router := gin.New()
+ reached := false
+ router.POST("/vendor/submit", pinTaskPluginRoute(plugin, 0), PrepareTaskPluginRoute(), func(c *gin.Context) { reached = true })
+ request := httptest.NewRequest(http.MethodPost, "/vendor/submit", strings.NewReader(`{}`))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, request)
+
+ assert.False(t, reached)
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+}
+
+func TestPrepareTaskPluginStaticQueryHidesTaskExistenceAndSanitizesErrors(t *testing.T) {
+ setupTaskPluginRouteDB(t)
+ plugin := compileTaskRoutePlugin(t, `
+export const meta = {
+ apiVersion: 1, key: "route-static-query-test", name: "Static Query", version: "1.0.0",
+ author: {name: "Test"},
+ channelTypes: [651], models: ["query-model"], fetchMode: "per_task",
+ routes: [{method: "GET", path: "/vendor/jobs/:id", type: "query", taskIdParam: "id", render: "status"}],
+};
+export function buildSubmitRequest() { return {url: "https://example.com"}; }
+export function parseSubmitResponse() { return {taskId: "one"}; }
+export function buildQueryRequest() { return {url: "https://example.com"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+export const native = {status: function(ctx, task) { return {id: task.task_id}; }, error: function(ctx, error) {
+ return {vendor_error: {code: error.code, message: error.message, status: error.httpStatus, retryable: error.retryable}};
+}};
+`)
+ insertTaskPluginRouteTask(t, &model.Task{
+ TaskID: "foreign-task", UserId: 8, Platform: constant.TaskPlatform("route-static-query-test"),
+ })
+ insertTaskPluginRouteTask(t, &model.Task{
+ TaskID: "wrong-platform", UserId: 7, Platform: constant.TaskPlatform("another-plugin"),
+ })
+ insertTaskPluginRouteTask(t, &model.Task{
+ TaskID: "wrong-legacy-platform", UserId: 7, Platform: constant.TaskPlatform("652"),
+ })
+ insertTaskPluginRouteTask(t, &model.Task{
+ TaskID: "legacy-task", UserId: 7, Platform: constant.TaskPlatform("651"),
+ })
+
+ router := gin.New()
+ router.GET("/vendor/jobs/:id",
+ pinTaskPluginRoute(plugin, 0),
+ func(c *gin.Context) {
+ c.Set("id", 7)
+ c.Next()
+ },
+ PrepareTaskPluginRoute(),
+ )
+
+ legacyRecorder := httptest.NewRecorder()
+ router.ServeHTTP(legacyRecorder, httptest.NewRequest(http.MethodGet, "/vendor/jobs/legacy-task", nil))
+ assert.Equal(t, http.StatusOK, legacyRecorder.Code)
+ assert.JSONEq(t, `{"id":"legacy-task"}`, legacyRecorder.Body.String())
+
+ var firstBody string
+ for _, taskID := range []string{"missing-task", "foreign-task", "wrong-platform", "wrong-legacy-platform"} {
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/vendor/jobs/"+taskID, nil))
+ assert.Equal(t, http.StatusNotFound, recorder.Code)
+ if firstBody == "" {
+ firstBody = recorder.Body.String()
+ } else {
+ assert.JSONEq(t, firstBody, recorder.Body.String())
+ }
+ assert.Contains(t, recorder.Body.String(), `"code":"task_not_found"`)
+ assert.NotContains(t, recorder.Body.String(), taskID)
+ }
+}
+
+func TestRespondTaskPluginErrorNeverExposesInternalDetails(t *testing.T) {
+ plugin := compileTaskRoutePlugin(t, `
+export const meta = {
+ apiVersion: 1, key: "route-error-test", name: "Error", version: "1.0.0",
+ author: {name: "Test"},
+ models: ["error-model"], fetchMode: "per_task",
+};
+export function buildSubmitRequest() { return {url: "https://example.com"}; }
+export function parseSubmitResponse() { return {taskId: "one"}; }
+export function buildQueryRequest() { return {url: "https://example.com"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+export const native = {error: function(ctx, error) {
+ return {path: ctx.path, code: error.code, message: error.message, status: error.httpStatus, retryable: error.retryable, keys: Object.keys(error).sort()};
+}};
+`)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/vendor/failure", strings.NewReader(`{"credential":"do-not-copy"}`))
+ c.Set(jsplugin.ContextKeyPinnedRoute, jsplugin.PinnedRoute{Plugin: plugin})
+ c.Set(jsplugin.ContextKeyRouteRequest, jsplugin.RouteRequestContext{
+ Path: "/vendor/failure", Method: http.MethodPost, Params: map[string]string{}, Query: map[string][]string{},
+ })
+
+ handled := RespondTaskPluginError(c, &dto.TaskError{
+ Code: "upstream_credential_failure",
+ Message: "https://user:password@upstream.invalid?token=secret",
+ Data: map[string]any{"authorization": "Bearer secret"},
+ StatusCode: http.StatusBadGateway,
+ Error: assert.AnError,
+ })
+
+ assert.True(t, handled)
+ assert.Equal(t, http.StatusBadGateway, recorder.Code)
+ assert.JSONEq(t, `{
+ "path": "/vendor/failure",
+ "code": "server_error",
+ "message": "Task request failed",
+ "status": 502,
+ "retryable": true,
+ "keys": ["code", "httpStatus", "message", "requestId", "retryable"]
+ }`, recorder.Body.String())
+ assert.NotContains(t, recorder.Body.String(), "upstream.invalid")
+ assert.NotContains(t, recorder.Body.String(), "secret")
+ assert.NotContains(t, recorder.Body.String(), "password")
+}
+
+func TestTaskPluginErrorFallbackIsSanitized(t *testing.T) {
+ for _, testCase := range []struct {
+ name string
+ renderHook string
+ }{
+ {name: "missing hook"},
+ {name: "throwing hook", renderHook: `export const native = {error: function() { throw new Error("renderer secret"); }};`},
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ plugin := compileTaskRoutePlugin(t, genericTaskPluginSource+"\n"+testCase.renderHook)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/vendor/failure", nil)
+ c.Set(common.RequestIdKey, "fallback-req")
+ c.Set(jsplugin.ContextKeyPinnedRoute, jsplugin.PinnedRoute{Plugin: plugin})
+ c.Set(jsplugin.ContextKeyRouteRequest, jsplugin.RouteRequestContext{
+ Path: "/vendor/failure", Method: http.MethodPost,
+ Params: map[string]string{}, Query: map[string][]string{},
+ })
+
+ abortWithOpenAiMessage(c, http.StatusBadGateway, "https://user:password@upstream.invalid?token=secret")
+
+ assert.Equal(t, http.StatusBadGateway, recorder.Code)
+ assert.JSONEq(t, `{"code":"server_error","message":"Task request failed (request id: fallback-req)","data":null}`, recorder.Body.String())
+ assert.NotContains(t, recorder.Body.String(), "upstream.invalid")
+ assert.NotContains(t, recorder.Body.String(), "renderer secret")
+ assert.NotContains(t, recorder.Body.String(), "password")
+ })
+ }
+}
+
+func TestResolvedTaskPluginIDsEnforcesPublicQueryContract(t *testing.T) {
+ valid, ok := resolvedTaskPluginIDs([]any{"task-a", "task-b", "task-a"})
+ require.True(t, ok)
+ assert.Equal(t, []string{"task-a", "task-b", "task-a"}, valid)
+
+ empty, ok := resolvedTaskPluginIDs([]any{})
+ require.True(t, ok)
+ assert.Empty(t, empty)
+
+ tooMany := make([]any, 101)
+ for index := range tooMany {
+ tooMany[index] = "task"
+ }
+ for _, invalid := range []any{
+ []any{"task-a", ""},
+ []any{"task-a", " "},
+ []any{"task-a", 2},
+ tooMany,
+ "task-a",
+ } {
+ _, ok = resolvedTaskPluginIDs(invalid)
+ assert.False(t, ok)
+ }
+}
+
+func TestSunoFetchEmptyIDsReturnsSuccessfulEmptyArray(t *testing.T) {
+ source, err := builtinplugins.Source("sunoapi")
+ require.NoError(t, err)
+ plugin := compileTaskRoutePlugin(t, source)
+
+ nextHandlerCalled := false
+ router := gin.New()
+ router.POST(
+ "/suno/fetch",
+ pinTaskPluginRoute(plugin, 1),
+ PrepareTaskPluginRoute(),
+ func(c *gin.Context) {
+ nextHandlerCalled = true
+ c.Status(http.StatusTeapot)
+ },
+ )
+ request := httptest.NewRequest(http.MethodPost, "/suno/fetch", strings.NewReader(`{"ids":[]}`))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, request)
+
+ assert.False(t, nextHandlerCalled)
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ assert.JSONEq(t, `{"code":"success","message":"","data":[]}`, recorder.Body.String())
+}
+
+func TestPrepareTaskPluginRouteSurfacesDecodeHookMessage(t *testing.T) {
+ plugin := compileTaskRoutePlugin(t, `
+export const meta = {
+ apiVersion: 1, key: "route-decode-detail-test", name: "Decode", version: "1.0.0",
+ author: {name: "Test"},
+ models: ["detail-model"], fetchMode: "per_task",
+ routes: [{method: "POST", path: "/vendor/jobs", type: "submit", decode: "createTask", render: "created"}],
+};
+export const native = {createTask: function() { throw new Error("model is required"); }, created: function(ctx, task) { return task; }};
+export function buildSubmitRequest() { return {url: "https://example.com"}; }
+export function parseSubmitResponse() { return {taskId: "one"}; }
+export function buildQueryRequest() { return {url: "https://example.com"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+`)
+ router := gin.New()
+ router.POST("/vendor/jobs", pinTaskPluginRoute(plugin, 0), PrepareTaskPluginRoute(), func(c *gin.Context) {
+ c.Status(http.StatusNoContent)
+ })
+ request := httptest.NewRequest(http.MethodPost, "/vendor/jobs", strings.NewReader(`{"prompt":"x"}`))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, request)
+
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ var body dto.TaskError
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &body))
+ assert.Equal(t, "invalid_request", body.Code)
+ assert.Equal(t, "model is required", body.Message)
+}
+
+func TestPrepareTaskPluginRouteNativeErrorReceivesHookMessage(t *testing.T) {
+ plugin := compileTaskRoutePlugin(t, `
+export const meta = {
+ apiVersion: 1, key: "route-error-detail-test", name: "Error", version: "1.0.0",
+ author: {name: "Test"},
+ models: ["detail-model"], fetchMode: "per_task",
+ routes: [{method: "POST", path: "/vendor/jobs", type: "submit", decode: "createTask", render: "created"}],
+};
+export const native = {
+ createTask: function() { throw new Error("model is required"); },
+ created: function(ctx, task) { return task; },
+ error: function(ctx, error) { return {code: error.code, message: error.message, requestId: error.requestId}; },
+};
+export function buildSubmitRequest() { return {url: "https://example.com"}; }
+export function parseSubmitResponse() { return {taskId: "one"}; }
+export function buildQueryRequest() { return {url: "https://example.com"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+`)
+ router := gin.New()
+ router.Use(func(c *gin.Context) {
+ c.Set(common.RequestIdKey, "native-error-req")
+ c.Next()
+ })
+ router.POST("/vendor/jobs", pinTaskPluginRoute(plugin, 0), PrepareTaskPluginRoute(), func(c *gin.Context) {
+ c.Status(http.StatusNoContent)
+ })
+ request := httptest.NewRequest(http.MethodPost, "/vendor/jobs", strings.NewReader(`{"prompt":"x"}`))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, request)
+
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ assert.JSONEq(t, `{"code":"invalid_request","message":"model is required","requestId":"native-error-req"}`, recorder.Body.String())
+}
+
+func TestPrepareTaskPluginRouteRejectsNonObjectResultWithFixedMessage(t *testing.T) {
+ plugin := compileTaskRoutePlugin(t, `
+export const meta = {
+ apiVersion: 1, key: "route-result-object-test", name: "Result", version: "1.0.0",
+ author: {name: "Test"},
+ models: ["detail-model"], fetchMode: "per_task",
+ routes: [{method: "POST", path: "/vendor/jobs", type: "submit", decode: "createTask", render: "created"}],
+};
+export const native = {createTask: function() { return "not-an-object"; }, created: function(ctx, task) { return task; }};
+export function buildSubmitRequest() { return {url: "https://example.com"}; }
+export function parseSubmitResponse() { return {taskId: "one"}; }
+export function buildQueryRequest() { return {url: "https://example.com"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+`)
+ router := gin.New()
+ router.POST("/vendor/jobs", pinTaskPluginRoute(plugin, 0), PrepareTaskPluginRoute(), func(c *gin.Context) {
+ c.Status(http.StatusNoContent)
+ })
+ request := httptest.NewRequest(http.MethodPost, "/vendor/jobs", strings.NewReader(`{"model":"detail-model"}`))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, request)
+
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ var body dto.TaskError
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &body))
+ assert.Equal(t, "invalid_request", body.Code)
+ assert.Equal(t, "plugin returned an invalid route result", body.Message)
+}
+
+func TestPrepareTaskPluginRouteSurfacesRequestDecodeDetail(t *testing.T) {
+ plugin := compileTaskRoutePlugin(t, `
+export const meta = {
+ apiVersion: 1, key: "route-decode-body-test", name: "Decode", version: "1.0.0",
+ author: {name: "Test"},
+ models: ["detail-model"], fetchMode: "per_task",
+ routes: [{method: "POST", path: "/vendor/jobs", type: "submit", decode: "createTask", render: "created"}],
+};
+export const native = {createTask: function() { throw new Error("decoder must not run"); }, created: function(ctx, task) { return task; }};
+export function buildSubmitRequest() { return {url: "https://example.com"}; }
+export function parseSubmitResponse() { return {taskId: "one"}; }
+export function buildQueryRequest() { return {url: "https://example.com"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+`)
+ router := gin.New()
+ router.POST("/vendor/jobs", pinTaskPluginRoute(plugin, 0), PrepareTaskPluginRoute(), func(c *gin.Context) {
+ c.Status(http.StatusNoContent)
+ })
+ request := httptest.NewRequest(http.MethodPost, "/vendor/jobs", strings.NewReader(`{}`))
+ request.Header["Content-Type"] = []string{"application/json", "application/x-www-form-urlencoded"}
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, request)
+
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ var body dto.TaskError
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &body))
+ assert.Equal(t, "invalid_request", body.Code)
+ assert.Contains(t, body.Message, "conflicting Content-Type")
+}
+
+func TestSanitizedTaskPluginErrorIgnoresDetailOn5xx(t *testing.T) {
+ got := sanitizedTaskPluginError(http.StatusInternalServerError, "database secret")
+ assert.Equal(t, "server_error", got.Code)
+ assert.Equal(t, "Task request failed", got.Message)
+ assert.Equal(t, http.StatusInternalServerError, got.HTTPStatus)
+
+ got = sanitizedTaskPluginError(http.StatusBadGateway, "https://user:password@upstream.invalid")
+ assert.Equal(t, "server_error", got.Code)
+ assert.Equal(t, "Task request failed", got.Message)
+}
+
+func TestTaskPluginErrorFallbackMessageIncludesRequestID(t *testing.T) {
+ plugin := compileTaskRoutePlugin(t, genericTaskPluginSource)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/vendor/failure", nil)
+ c.Set(common.RequestIdKey, "req-fallback-1")
+ c.Set(jsplugin.ContextKeyPinnedRoute, jsplugin.PinnedRoute{Plugin: plugin})
+ c.Set(jsplugin.ContextKeyRouteRequest, jsplugin.RouteRequestContext{
+ Path: "/vendor/failure", Method: http.MethodPost,
+ Params: map[string]string{}, Query: map[string][]string{},
+ })
+
+ abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, "model is required")
+
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ var body dto.TaskError
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &body))
+ assert.Equal(t, "invalid_request", body.Code)
+ assert.Equal(t, "model is required (request id: req-fallback-1)", body.Message)
+ assert.True(t, strings.HasSuffix(body.Message, "(request id: req-fallback-1)"))
+}
+
+func TestPrepareTaskPluginEndpointSurfacesDecodeHookMessage(t *testing.T) {
+ const key = "endpoint-decode-detail-test"
+ _, err := jsplugin.DefaultRegistry.Register(taskProtocolPluginSource(
+ key,
+ "1.0.0",
+ `["claimed-model"]`,
+ "/v1/responses",
+ `throw new Error("model is required");`,
+ ), jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, jsplugin.DefaultRegistry.Unregister(key)) })
+
+ router := gin.New()
+ router.POST("/v1/responses", PinTaskPluginEndpoint(), PrepareTaskPluginEndpoint(), func(c *gin.Context) {
+ c.Status(http.StatusNoContent)
+ })
+ request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"claimed-model"}`))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, request)
+
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ assert.Contains(t, recorder.Body.String(), "model is required")
+ assert.NotContains(t, recorder.Body.String(), "Invalid task protocol request")
+}
+
+func TestPinTaskPluginEndpointRejectsUnsupportedRequestForms(t *testing.T) {
+ setupTaskPluginRouteDB(t)
+ tests := []struct {
+ name string
+ key string
+ supports string
+ hooks string
+ body string
+ message string
+ }{
+ {
+ name: "stream against sync and background",
+ key: "form-gate-final-only",
+ supports: `["sync", "background"]`,
+ hooks: `renderFinal: function() { return {}; }`,
+ body: `{"model":"form-gate-model","stream":true}`,
+ message: `Streaming is not supported for this model. Set "stream": false, or use "background": true and retrieve the response later.`,
+ },
+ {
+ name: "sync against stream only",
+ key: "form-gate-stream-only",
+ supports: `["stream"]`,
+ hooks: `renderEvents: function() { return {events: [], done: false}; }`,
+ body: `{"model":"form-gate-model"}`,
+ message: `Synchronous non-streaming requests are not supported for this model. Set "stream": true.`,
+ },
+ {
+ name: "background against stream and sync",
+ key: "form-gate-no-background",
+ supports: `["stream", "sync"]`,
+ hooks: `renderEvents: function() { return {events: [], done: false}; }, renderFinal: function() { return {}; }`,
+ body: `{"model":"form-gate-model","background":true}`,
+ message: `Background mode is not supported for this model. Remove "background": true.`,
+ },
+ {
+ name: "background plus stream reports stream first",
+ key: "form-gate-no-stream",
+ supports: `["sync", "background"]`,
+ hooks: `renderFinal: function() { return {}; }`,
+ body: `{"model":"form-gate-model","stream":true,"background":true}`,
+ message: `Streaming is not supported for this model. Set "stream": false, or use "background": true and retrieve the response later.`,
+ },
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ _, err := jsplugin.DefaultRegistry.Register(taskResponsesPluginSource(
+ testCase.key, 0, `["form-gate-model"]`, testCase.supports, testCase.hooks, `return {model: ctx.model};`,
+ ), jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, jsplugin.DefaultRegistry.Unregister(testCase.key)) })
+
+ reachedPrepare := false
+ quotaConsumed := false
+ router := gin.New()
+ router.POST("/v1/responses", PinTaskPluginEndpoint(), PrepareTaskPluginEndpoint(), func(c *gin.Context) {
+ reachedPrepare = true
+ quotaConsumed = true
+ require.NoError(t, model.DB.Create(&model.Task{TaskID: "should-not-exist", UserId: 1}).Error)
+ c.Status(http.StatusNoContent)
+ })
+ request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(testCase.body))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, request)
+
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ var payload map[string]any
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &payload))
+ errObj, ok := payload["error"].(map[string]any)
+ require.True(t, ok)
+ message, _ := errObj["message"].(string)
+ assert.Contains(t, message, testCase.message)
+ assert.False(t, reachedPrepare)
+ assert.False(t, quotaConsumed)
+ var count int64
+ require.NoError(t, model.DB.Model(&model.Task{}).Count(&count).Error)
+ assert.Zero(t, count)
+ })
+ }
+}
+
+func TestPinTaskPluginEndpointMalformedStreamStillFailsInPrepare(t *testing.T) {
+ const key = "form-gate-malformed-stream"
+ _, err := jsplugin.DefaultRegistry.Register(taskResponsesPluginSource(
+ key, 0, `["form-gate-bool-model"]`, `["sync", "background"]`,
+ `renderFinal: function() { return {}; }`,
+ `return {model: ctx.model};`,
+ ), jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, jsplugin.DefaultRegistry.Unregister(key)) })
+
+ reachedNext := false
+ router := gin.New()
+ router.POST("/v1/responses", PinTaskPluginEndpoint(), PrepareTaskPluginEndpoint(), func(c *gin.Context) {
+ reachedNext = true
+ c.Status(http.StatusNoContent)
+ })
+ request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"form-gate-bool-model","stream":"yes"}`))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, request)
+
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ assert.Contains(t, recorder.Body.String(), "stream must be a boolean")
+ assert.False(t, reachedNext)
+}
+
+func TestPinTaskPluginEndpointMovesParserToSurvivingSharedCandidate(t *testing.T) {
+ streamOnly, err := jsplugin.DefaultRegistry.Register(taskResponsesPluginSource(
+ "alpha-stream", constant.ChannelTypeReplicate, `["shared-form-model"]`, `["stream"]`,
+ `renderEvents: function() { return {events: [], done: false}; }`,
+ `return {model: ctx.model, action: "stream-parser"};`,
+ ), jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, jsplugin.DefaultRegistry.Unregister("alpha-stream")) })
+ full, err := jsplugin.DefaultRegistry.Register(taskResponsesPluginSource(
+ "bravo-full", constant.ChannelTypeCodex, `["shared-form-model"]`, `["stream", "sync", "background"]`,
+ `renderEvents: function() { return {events: [], done: false}; }, renderFinal: function() { return {}; }`,
+ `return {model: ctx.model, action: "full-parser"};`,
+ ), jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, jsplugin.DefaultRegistry.Unregister("bravo-full")) })
+
+ generation := jsplugin.DefaultRegistry.Generation()
+ unfiltered := generation.LookupEndpointCandidates("POST", "/v1/responses", "shared-form-model")
+ require.Len(t, unfiltered, 2)
+ assert.Equal(t, "alpha-stream", unfiltered[0].Plugin.Meta.Key)
+
+ t.Run("sync moves pin to second candidate", func(t *testing.T) {
+ var pinned jsplugin.PinnedEndpoint
+ var action string
+ router := gin.New()
+ router.POST("/v1/responses", PinTaskPluginEndpoint(), PrepareTaskPluginEndpoint(), func(c *gin.Context) {
+ pinned = c.MustGet(jsplugin.ContextKeyPinnedEndpoint).(jsplugin.PinnedEndpoint)
+ action = c.GetString("task_action")
+ c.Status(http.StatusNoContent)
+ })
+ request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"shared-form-model"}`))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, request)
+
+ assert.Equal(t, http.StatusNoContent, recorder.Code)
+ assert.Same(t, full, pinned.Plugin)
+ require.Len(t, pinned.Candidates, 1)
+ assert.Same(t, full, pinned.Candidates[0].Plugin)
+ assert.Equal(t, "full-parser", action)
+ })
+
+ t.Run("stream keeps first candidate", func(t *testing.T) {
+ var pinned jsplugin.PinnedEndpoint
+ var action string
+ router := gin.New()
+ router.POST("/v1/responses", PinTaskPluginEndpoint(), PrepareTaskPluginEndpoint(), func(c *gin.Context) {
+ pinned = c.MustGet(jsplugin.ContextKeyPinnedEndpoint).(jsplugin.PinnedEndpoint)
+ action = c.GetString("task_action")
+ c.Status(http.StatusNoContent)
+ })
+ request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"shared-form-model","stream":true}`))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, request)
+
+ assert.Equal(t, http.StatusNoContent, recorder.Code)
+ assert.Same(t, streamOnly, pinned.Plugin)
+ require.Len(t, pinned.Candidates, 2)
+ assert.Same(t, streamOnly, pinned.Candidates[0].Plugin)
+ assert.Equal(t, "stream-parser", action)
+ })
+}
+
+func compileTaskRoutePlugin(t *testing.T, source string) *jsplugin.LoadedPlugin {
+ t.Helper()
+ plugin, err := jsplugin.CompilePlugin(source, jsplugin.Options{})
+ require.NoError(t, err)
+ return plugin
+}
+
+func taskResponsesPluginSource(key string, channelType int, models, supports, hooks, parseRequestBody string) string {
+ channelField := ""
+ if channelType > 0 {
+ channelField = fmt.Sprintf("channelTypes: [%d],", channelType)
+ }
+ return fmt.Sprintf(`
+export const meta = {
+ apiVersion: 1,
+ key: %q,
+ name: %q,
+ version: "1.0.0",
+ author: {name: "Test"},
+ %s
+ models: %s,
+ fetchMode: "per_task",
+ protocols: [{name: "openai_responses", supports: %s}],
+};
+export function buildSubmitRequest() { return {url: "https://example.com"}; }
+export function parseSubmitResponse() { return {taskId: "one"}; }
+export function buildQueryRequest() { return {url: "https://example.com"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+export function listArtifacts() { return []; }
+export function buildContentRequest() { throw new Error("artifact_not_found"); }
+export const protocols = {openai_responses: {
+ decodeRequest: function(ctx) {
+ ctx.requestBody = ctx.body.value;
+ const decode = function() { %s };
+ const result = decode();
+ if (!result.kind) result.kind = "submit";
+ return result;
+ },
+ %s
+}};
+`, key, key, channelField, models, supports, parseRequestBody, hooks)
+}
+
+func taskProtocolPluginSource(key, version, models, endpoint, parseRequestBody string) string {
+ protocol := "openai_responses"
+ protocolClaim := `{name: "openai_responses", supports: ["stream", "sync", "background"]}`
+ presenters := `renderEvents: function() { return {events: [], done: false}; }, renderFinal: function() { return {}; },`
+ if endpoint == "/v1/videos" {
+ protocol = "openai_video"
+ protocolClaim = `"openai_video"`
+ presenters = `render: function() { return {}; },`
+ }
+ return fmt.Sprintf(`
+export const meta = {
+ apiVersion: 1,
+ key: %q,
+ name: %q,
+ version: %q,
+ author: {name: "Test"},
+ models: %s,
+ fetchMode: "per_task",
+ protocols: [%s],
+};
+export function buildSubmitRequest() { return {url: "https://example.com"}; }
+export function parseSubmitResponse() { return {taskId: "one"}; }
+export function buildQueryRequest() { return {url: "https://example.com"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+export function listArtifacts() { return []; }
+export function buildContentRequest() { throw new Error("artifact_not_found"); }
+export const protocols = {
+ %s: {
+ decodeRequest: function(ctx) {
+ ctx.requestBody = ctx.body.value;
+ if (ctx.body.kind === "multipart") {
+ ctx.requestBody = {};
+ for (const key of Object.keys(ctx.body.fields || {})) ctx.requestBody[key] = ctx.body.fields[key][0];
+ }
+ const decode = function() { %s };
+ const result = decode();
+ if (!result.kind) result.kind = "submit";
+ return result;
+ },
+ %s
+ },
+};
+`, key, key, version, models, protocolClaim, protocol, parseRequestBody, presenters)
+}
+
+func pinTaskPluginRoute(plugin *jsplugin.LoadedPlugin, routeIndex int) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.Set(jsplugin.ContextKeyPinnedRoute, jsplugin.PinnedRoute{Plugin: plugin, Route: plugin.Meta.Routes[routeIndex]})
+ c.Next()
+ }
+}
+
+func setupTaskPluginRouteDB(t *testing.T) {
+ t.Helper()
+ previousDB := model.DB
+ previousType := common.MainDatabaseType()
+ database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, database.AutoMigrate(&model.Task{}))
+ model.DB = database
+ common.SetMainDatabaseType(common.DatabaseTypeSQLite)
+ t.Cleanup(func() {
+ model.DB = previousDB
+ common.SetMainDatabaseType(previousType)
+ })
+}
+
+func insertTaskPluginRouteTask(t *testing.T, task *model.Task) {
+ t.Helper()
+ require.NoError(t, model.DB.Create(task).Error)
+}
diff --git a/middleware/trusted_proxies.go b/middleware/trusted_proxies.go
index bab8b646ba14..5470829283a3 100644
--- a/middleware/trusted_proxies.go
+++ b/middleware/trusted_proxies.go
@@ -1,51 +1,20 @@
package middleware
import (
- "errors"
- "fmt"
"log"
"os"
- "strings"
+ "github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
)
-var defaultTrustedProxyCIDRs = []string{
- "127.0.0.0/8",
- "::1",
- "10.0.0.0/8",
- "172.16.0.0/12",
- "192.168.0.0/16",
- "fc00::/7",
-}
-
func ConfigureTrustedProxies(engine *gin.Engine) error {
- rawTrustedProxies := strings.TrimSpace(os.Getenv("TRUSTED_PROXIES"))
- if rawTrustedProxies == "" {
- log.Print("WARNING: TRUSTED_PROXIES is unset or blank; trusting loopback, RFC 1918, and IPv6 ULA proxy addresses for compatibility. Set TRUSTED_PROXIES=none to trust no proxies, or configure explicit proxy IPs/CIDRs to replace these defaults.")
- return engine.SetTrustedProxies(defaultTrustedProxyCIDRs)
- }
- if strings.EqualFold(rawTrustedProxies, "none") {
- return engine.SetTrustedProxies(nil)
+ trustedProxies, usedDefaults, err := common.ResolveTrustedProxies(os.Getenv("TRUSTED_PROXIES"))
+ if err != nil {
+ return err
}
-
- parts := strings.Split(rawTrustedProxies, ",")
- trustedProxies := make([]string, 0, len(parts))
- for _, part := range parts {
- trustedProxy := strings.TrimSpace(part)
- if trustedProxy == "" {
- continue
- }
- if strings.EqualFold(trustedProxy, "none") {
- return errors.New("TRUSTED_PROXIES=none must be used alone")
- }
- trustedProxies = append(trustedProxies, trustedProxy)
- }
- if len(trustedProxies) == 0 {
- return errors.New("TRUSTED_PROXIES does not contain an IP address or CIDR")
- }
- if err := engine.SetTrustedProxies(trustedProxies); err != nil {
- return fmt.Errorf("invalid TRUSTED_PROXIES: %w", err)
+ if usedDefaults {
+ log.Print("WARNING: TRUSTED_PROXIES is unset or blank; trusting loopback, RFC 1918, and IPv6 ULA proxy addresses for compatibility. Set TRUSTED_PROXIES=none to trust no proxies, or configure explicit proxy IPs/CIDRs to replace these defaults.")
}
- return nil
+ return common.ConfigureTrustedProxies(engine, trustedProxies)
}
diff --git a/middleware/utils.go b/middleware/utils.go
index a4c981d1b61a..73ad9e4b3b67 100644
--- a/middleware/utils.go
+++ b/middleware/utils.go
@@ -4,7 +4,9 @@ import (
"fmt"
"github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
+ pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
)
@@ -15,13 +17,20 @@ func abortWithOpenAiMessage(c *gin.Context, statusCode int, message string, code
codeStr = string(code[0])
}
userId := c.GetInt("id")
- c.JSON(statusCode, gin.H{
- "error": gin.H{
- "message": common.MessageWithRequestId(message, c.GetString(common.RequestIdKey)),
- "type": "new_api_error",
- "code": codeStr,
- },
- })
+ _, preparedPluginRoute := c.Get(pluginruntime.ContextKeyRouteRequest)
+ if !preparedPluginRoute || !RespondTaskPluginError(c, &dto.TaskError{
+ Code: codeStr,
+ Message: message,
+ StatusCode: statusCode,
+ }) {
+ c.JSON(statusCode, gin.H{
+ "error": gin.H{
+ "message": common.MessageWithRequestId(message, c.GetString(common.RequestIdKey)),
+ "type": "new_api_error",
+ "code": codeStr,
+ },
+ })
+ }
c.Abort()
logger.LogError(c.Request.Context(), fmt.Sprintf("user %d | %s", userId, message))
}
diff --git a/model/ability.go b/model/ability.go
index d950a6adbfc4..05b8a1717c5c 100644
--- a/model/ability.go
+++ b/model/ability.go
@@ -3,12 +3,12 @@ package model
import (
"errors"
"fmt"
+ "sort"
"strings"
"sync"
"github.com/QuantumNous/new-api/common"
- "github.com/QuantumNous/new-api/constant"
- "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/dto"
"github.com/samber/lo"
"gorm.io/gorm"
@@ -105,23 +105,40 @@ func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) {
return channelQuery, nil
}
-func GetChannel(group string, model string, retry int, requestPath string) (*Channel, error) {
+func GetChannel(
+ group string,
+ model string,
+ retry int,
+ filters []dto.ChannelFilter,
+) (*Channel, error) {
var abilities []Ability
-
- var err error = nil
- channelQuery, err := getChannelQuery(group, model, retry)
+ err := DB.Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true).Order("priority DESC, weight DESC").Find(&abilities).Error
if err != nil {
return nil, err
}
- if common.UsingMainDatabase(common.DatabaseTypeSQLite) || common.UsingMainDatabase(common.DatabaseTypePostgreSQL) {
- err = channelQuery.Order("weight DESC").Find(&abilities).Error
- } else {
- err = channelQuery.Order("weight DESC").Find(&abilities).Error
- }
- if err != nil {
- return nil, err
+ abilities = filterAbilitiesByConstraints(abilities, model, filters)
+ if len(abilities) > 0 {
+ priorities := make([]int64, 0)
+ seen := make(map[int64]bool)
+ for _, ability := range abilities {
+ priority := int64(0)
+ if ability.Priority != nil {
+ priority = *ability.Priority
+ }
+ if !seen[priority] {
+ seen[priority] = true
+ priorities = append(priorities, priority)
+ }
+ }
+ sort.Slice(priorities, func(i, j int) bool { return priorities[i] > priorities[j] })
+ if retry >= len(priorities) {
+ retry = len(priorities) - 1
+ }
+ targetPriority := priorities[retry]
+ abilities = lo.Filter(abilities, func(ability Ability, _ int) bool {
+ return ability.Priority == nil && targetPriority == 0 || ability.Priority != nil && *ability.Priority == targetPriority
+ })
}
- abilities = filterAbilitiesByRequestPathAndModel(abilities, requestPath, model)
channel := Channel{}
if len(abilities) > 0 {
// Randomly choose one
@@ -146,14 +163,12 @@ func GetChannel(group string, model string, retry int, requestPath string) (*Cha
return &channel, err
}
-// filterAbilitiesByRequestPathAndModel restricts candidates by request path and
-// model for the DB (non-memory-cache) selection path. Only Advanced Custom
-// (type 58) channels are path-checked: kept only when one of their routes matches
-// requestPath and model; all other channel types always pass. When requestPath is
-// empty, filtering is skipped.
-func filterAbilitiesByRequestPathAndModel(abilities []Ability, requestPath string, model string) []Ability {
- if requestPath == "" || len(abilities) == 0 {
- return abilities
+// filterAbilitiesByConstraints applies the same ChannelSatisfiesFilters
+// predicate used by the memory-cache path. A failed channel lookup fails
+// closed when a task-plugin identity is required and fails open otherwise.
+func filterAbilitiesByConstraints(abilities []Ability, modelName string, filters []dto.ChannelFilter) []Ability {
+ if len(abilities) == 0 {
+ return nil
}
channelIds := make([]int, 0, len(abilities))
@@ -168,31 +183,36 @@ func filterAbilitiesByRequestPathAndModel(abilities []Ability, requestPath strin
var channels []*Channel
if err := DB.Where("id IN ?", channelIds).Find(&channels).Error; err != nil {
- // On error, fall back to unfiltered candidates to avoid blocking selection
+ if identityFilterRequiresKey(filters) {
+ return nil
+ }
return abilities
}
- advancedConfigs := make(map[int]*dto.AdvancedCustomConfig)
+ channelsByID := make(map[int]*Channel, len(channels))
for _, channel := range channels {
- if channel.Type == constant.ChannelTypeAdvancedCustom {
- advancedConfigs[channel.Id] = channel.GetOtherSettings().AdvancedCustom
- }
+ channelsByID[channel.Id] = channel
}
filtered := make([]Ability, 0, len(abilities))
for _, ability := range abilities {
- config, isAdvancedCustom := advancedConfigs[ability.ChannelId]
- if !isAdvancedCustom {
- filtered = append(filtered, ability)
- continue
- }
- if config != nil && config.SupportsPathForModel(requestPath, model) {
+ channel := channelsByID[ability.ChannelId]
+ if ok, _ := ChannelSatisfiesFilters(channel, modelName, filters); ok {
filtered = append(filtered, ability)
}
}
return filtered
}
+func identityFilterRequiresKey(filters []dto.ChannelFilter) bool {
+ for _, filter := range filters {
+ if filter.Kind == dto.FilterTaskPluginIdentity && filter.TaskPluginKey != "" {
+ return true
+ }
+ }
+ return false
+}
+
func (channel *Channel) AddAbilities(tx *gorm.DB) error {
models_ := strings.Split(channel.Models, ",")
groups_ := strings.Split(channel.Group, ",")
diff --git a/model/channel.go b/model/channel.go
index 0f8cdb101ec8..397e94289b28 100644
--- a/model/channel.go
+++ b/model/channel.go
@@ -419,6 +419,15 @@ func SearchChannels(keyword string, group string, model string, idSort bool, sor
return channels, nil
}
+// GetChannelById loads a channel directly from the database, bypassing the
+// in-memory channel cache.
+//
+// WARNING: do NOT call this on request hot paths (middleware, distribution,
+// relay submit/retry, polling). Every call is a synchronous DB query and will
+// not see cache-only state. Use CacheGetChannel instead: it serves from the
+// in-memory cache and falls back to this function automatically when
+// MemoryCacheEnabled is false. Direct use is appropriate only where fresh DB
+// state is required, e.g. admin CRUD, channel testing, or cache (re)building.
func GetChannelById(id int, selectAll bool) (*Channel, error) {
channel := &Channel{Id: id}
var err error = nil
@@ -510,7 +519,7 @@ func (channel *Channel) GetBaseURL() string {
}
url := *channel.BaseURL
if url == "" {
- url = constant.ChannelBaseURLs[channel.Type]
+ url = constant.GetChannelBaseURL(channel.Type)
}
return url
}
diff --git a/model/channel_cache.go b/model/channel_cache.go
index 86c594384d50..97b80cac447c 100644
--- a/model/channel_cache.go
+++ b/model/channel_cache.go
@@ -12,7 +12,8 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
- "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/dto"
+ kitdto "github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/ratio_setting"
)
@@ -20,7 +21,7 @@ var group2model2channels map[string]map[string][]int // enabled channel
var channelsIDM map[int]*Channel // all channels include disabled
// channel2advancedCustomConfig caches parsed Advanced Custom (type 58) configs so
// path-aware selection avoids re-parsing JSON per request. Refreshed on full sync.
-var channel2advancedCustomConfig map[int]*dto.AdvancedCustomConfig
+var channel2advancedCustomConfig map[int]*kitdto.AdvancedCustomConfig
var channelSyncLock sync.RWMutex
func InitChannelCache() {
@@ -29,7 +30,7 @@ func InitChannelCache() {
return
}
newChannelId2channel := make(map[int]*Channel)
- newChannel2advancedCustomConfig := make(map[int]*dto.AdvancedCustomConfig)
+ newChannel2advancedCustomConfig := make(map[int]*kitdto.AdvancedCustomConfig)
var channels []*Channel
DB.Find(&channels)
for _, channel := range channels {
@@ -111,22 +112,27 @@ func SyncChannelCache(frequency int) {
}
}
-func GetRandomSatisfiedChannel(group string, model string, retry int, requestPath string) (*Channel, error) {
+func GetRandomSatisfiedChannel(
+ group string,
+ model string,
+ retry int,
+ filters []dto.ChannelFilter,
+) (*Channel, error) {
// if memory cache is disabled, get channel directly from database
if !common.MemoryCacheEnabled {
- return GetChannel(group, model, retry, requestPath)
+ return GetChannel(group, model, retry, filters)
}
channelSyncLock.RLock()
defer channelSyncLock.RUnlock()
// First, try to find channels with the exact model name.
- channels := filterChannelsByRequestPathAndModel(group2model2channels[group][model], requestPath, model)
+ channels, _ := filterCandidateIDs(group2model2channels[group][model], model, filters)
// If no channels found, try to find channels with the normalized model name.
if len(channels) == 0 {
normalizedModel := ratio_setting.FormatMatchingModelName(model)
- channels = filterChannelsByRequestPathAndModel(group2model2channels[group][normalizedModel], requestPath, model)
+ channels, _ = filterCandidateIDs(group2model2channels[group][normalizedModel], model, filters)
}
if len(channels) == 0 {
@@ -208,34 +214,6 @@ func GetRandomSatisfiedChannel(group string, model string, retry int, requestPat
return nil, errors.New("channel not found")
}
-// filterChannelsByRequestPathAndModel restricts candidates by request path and
-// model. Only Advanced Custom (type 58) channels are path-checked: they are kept
-// only when one of their configured routes matches requestPath and model. All
-// other channel types always pass. When requestPath is empty, filtering is skipped.
-// Caller must hold channelSyncLock (read lock). The cached slice is never mutated.
-func filterChannelsByRequestPathAndModel(channels []int, requestPath string, model string) []int {
- if requestPath == "" || len(channels) == 0 {
- return channels
- }
- filtered := make([]int, 0, len(channels))
- for _, channelId := range channels {
- channel, ok := channelsIDM[channelId]
- if !ok {
- // keep it so the downstream consistency error is raised as before
- filtered = append(filtered, channelId)
- continue
- }
- if channel.Type != constant.ChannelTypeAdvancedCustom {
- filtered = append(filtered, channelId)
- continue
- }
- if config := channel2advancedCustomConfig[channelId]; config != nil && config.SupportsPathForModel(requestPath, model) {
- filtered = append(filtered, channelId)
- }
- }
- return filtered
-}
-
func CacheGetChannel(id int) (*Channel, error) {
if !common.MemoryCacheEnabled {
return GetChannelById(id, true)
@@ -311,7 +289,7 @@ func CacheUpdateChannel(channel *Channel) {
}
channelsIDM[channel.Id] = channel
if channel2advancedCustomConfig == nil {
- channel2advancedCustomConfig = make(map[int]*dto.AdvancedCustomConfig)
+ channel2advancedCustomConfig = make(map[int]*kitdto.AdvancedCustomConfig)
}
delete(channel2advancedCustomConfig, channel.Id)
if channel.Type == constant.ChannelTypeAdvancedCustom {
diff --git a/model/channel_constraint.go b/model/channel_constraint.go
new file mode 100644
index 000000000000..2986cf709eae
--- /dev/null
+++ b/model/channel_constraint.go
@@ -0,0 +1,108 @@
+package model
+
+import (
+ "slices"
+
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/dto"
+)
+
+var filterEvalOrder = []dto.ChannelFilterKind{
+ dto.FilterRequestPath,
+ dto.FilterTaskPluginIdentity,
+}
+
+// ChannelSatisfiesFilters reports whether ch passes every filter.
+// On false, it returns the kind of the first violated filter (request_path
+// then task_plugin_identity) for error attribution.
+func ChannelSatisfiesFilters(ch *Channel, modelName string, filters []dto.ChannelFilter) (bool, dto.ChannelFilterKind) {
+ if ch == nil {
+ return false, ""
+ }
+ for _, kind := range filterEvalOrder {
+ for _, filter := range filters {
+ if filter.Kind != kind {
+ continue
+ }
+ if !channelMatchesFilter(ch, modelName, filter) {
+ return false, kind
+ }
+ }
+ }
+ return true, ""
+}
+
+// filterCandidateIDs applies filters to a cached candidate id list.
+// Caller must hold channelSyncLock (read lock). The input slice is never mutated.
+// A missing id in channelsIDM is kept for request_path (downstream consistency
+// error) and dropped for task_plugin_identity, matching the previous filters.
+func filterCandidateIDs(ids []int, modelName string, filters []dto.ChannelFilter) (kept []int, emptiedBy dto.ChannelFilterKind) {
+ if len(ids) == 0 {
+ return ids, ""
+ }
+ kept = ids
+ for _, kind := range filterEvalOrder {
+ kindFilters := filtersByKind(filters, kind)
+ if len(kindFilters) == 0 {
+ continue
+ }
+ next := make([]int, 0, len(kept))
+ for _, id := range kept {
+ channel, exists := channelsIDM[id]
+ if candidatePassesKindFilters(channel, exists, modelName, kind, kindFilters) {
+ next = append(next, id)
+ }
+ }
+ if len(kept) > 0 && len(next) == 0 {
+ return next, kind
+ }
+ kept = next
+ }
+ return kept, ""
+}
+
+func filtersByKind(filters []dto.ChannelFilter, kind dto.ChannelFilterKind) []dto.ChannelFilter {
+ var matched []dto.ChannelFilter
+ for _, filter := range filters {
+ if filter.Kind == kind {
+ matched = append(matched, filter)
+ }
+ }
+ return matched
+}
+
+func candidatePassesKindFilters(ch *Channel, exists bool, modelName string, kind dto.ChannelFilterKind, filters []dto.ChannelFilter) bool {
+ if kind == dto.FilterRequestPath && !exists {
+ return true
+ }
+ if !exists || ch == nil {
+ return false
+ }
+ for _, filter := range filters {
+ if !channelMatchesFilter(ch, modelName, filter) {
+ return false
+ }
+ }
+ return true
+}
+
+func channelMatchesFilter(ch *Channel, modelName string, filter dto.ChannelFilter) bool {
+ switch filter.Kind {
+ case dto.FilterRequestPath:
+ if filter.RequestPath == "" {
+ return true
+ }
+ if ch.Type != constant.ChannelTypeAdvancedCustom {
+ return true
+ }
+ config := ch.GetOtherSettings().AdvancedCustom
+ return config != nil && config.SupportsPathForModel(filter.RequestPath, modelName)
+ case dto.FilterTaskPluginIdentity:
+ if ch.Type == constant.ChannelTypeTaskPlugin {
+ return filter.TaskPluginKey != "" && ch.GetSetting().TaskPluginKey == filter.TaskPluginKey
+ }
+ return filter.TaskPluginKey == "" || slices.Contains(filter.TaskPluginChannelTypes, ch.Type)
+ default:
+ return true
+ }
+}
diff --git a/model/channel_constraint_test.go b/model/channel_constraint_test.go
new file mode 100644
index 000000000000..3afe6d4154a2
--- /dev/null
+++ b/model/channel_constraint_test.go
@@ -0,0 +1,218 @@
+package model
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/dto"
+ kitdto "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFilterCandidateIDs(t *testing.T) {
+ alphaSetting := `{"task_plugin_key":"alpha"}`
+ betaSetting := `{"task_plugin_key":"beta"}`
+ alpha := &Channel{Id: 900001, Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Setting: &alphaSetting}
+ beta := &Channel{Id: 900002, Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Setting: &betaSetting}
+ ordinary := &Channel{Id: 900003, Type: constant.ChannelTypeOpenAI, Status: common.ChannelStatusEnabled}
+ kling := &Channel{Id: 900004, Type: constant.ChannelTypeKling, Status: common.ChannelStatusEnabled}
+ jimeng := &Channel{Id: 900005, Type: constant.ChannelTypeJimeng, Status: common.ChannelStatusEnabled}
+ matchingCustom := &Channel{Id: 900010, Type: constant.ChannelTypeAdvancedCustom, Status: common.ChannelStatusEnabled}
+ matchingCustom.SetOtherSettings(kitdto.ChannelOtherSettings{
+ AdvancedCustom: &kitdto.AdvancedCustomConfig{
+ Routes: []kitdto.AdvancedCustomRoute{{
+ IncomingPath: "/v1/chat/completions",
+ Models: []string{"gpt-4"},
+ }},
+ },
+ })
+ otherCustom := &Channel{Id: 900011, Type: constant.ChannelTypeAdvancedCustom, Status: common.ChannelStatusEnabled}
+ otherCustom.SetOtherSettings(kitdto.ChannelOtherSettings{
+ AdvancedCustom: &kitdto.AdvancedCustomConfig{
+ Routes: []kitdto.AdvancedCustomRoute{{
+ IncomingPath: "/v1/responses",
+ Models: []string{"gpt-4"},
+ }},
+ },
+ })
+
+ pathFilter := dto.ChannelFilter{Kind: dto.FilterRequestPath, RequestPath: "/v1/chat/completions"}
+ emptyPathFilter := dto.ChannelFilter{Kind: dto.FilterRequestPath, RequestPath: ""}
+
+ tests := []struct {
+ name string
+ ids []int
+ modelName string
+ filters []dto.ChannelFilter
+ wantKept []int
+ wantEmpty dto.ChannelFilterKind
+ }{
+ {
+ name: "identity keeps matching type-59 key",
+ ids: []int{900001, 900002},
+ modelName: "shared",
+ filters: identityFilters("alpha", nil),
+ wantKept: []int{900001},
+ },
+ {
+ name: "identity empty key drops all type-59",
+ ids: []int{900001, 900002},
+ modelName: "shared",
+ filters: identityFilters("", nil),
+ wantKept: []int{},
+ wantEmpty: dto.FilterTaskPluginIdentity,
+ },
+ {
+ name: "identity empty key keeps ordinary channel",
+ ids: []int{900003},
+ modelName: "ordinary",
+ filters: identityFilters("", nil),
+ wantKept: []int{900003},
+ },
+ {
+ name: "identity keeps matching legacy type",
+ ids: []int{900004, 900005},
+ modelName: "legacy",
+ filters: identityFilters("legacy-alpha", []int{constant.ChannelTypeKling}),
+ wantKept: []int{900004},
+ },
+ {
+ name: "identity keeps all listed legacy types",
+ ids: []int{900004, 900005},
+ modelName: "legacy",
+ filters: identityFilters("legacy-alpha", []int{constant.ChannelTypeKling, constant.ChannelTypeJimeng}),
+ wantKept: []int{900004, 900005},
+ },
+ {
+ name: "identity keyed with no types drops legacy",
+ ids: []int{900004, 900005},
+ modelName: "legacy",
+ filters: identityFilters("legacy-alpha", nil),
+ wantKept: []int{},
+ wantEmpty: dto.FilterTaskPluginIdentity,
+ },
+ {
+ name: "identity drops missing cache entry",
+ ids: []int{900004, 999999},
+ modelName: "legacy",
+ filters: identityFilters("legacy-alpha", []int{constant.ChannelTypeKling}),
+ wantKept: []int{900004},
+ },
+ {
+ name: "empty request path is a passthrough including missing ids",
+ ids: []int{900003, 900010, 999999},
+ modelName: "gpt-4",
+ filters: []dto.ChannelFilter{emptyPathFilter},
+ wantKept: []int{900003, 900010, 999999},
+ },
+ {
+ name: "request path keeps missing cache entry for consistency",
+ ids: []int{900003, 999999},
+ modelName: "gpt-4",
+ filters: []dto.ChannelFilter{pathFilter},
+ wantKept: []int{900003, 999999},
+ },
+ {
+ name: "request path keeps matching type-58 and ordinary",
+ ids: []int{900003, 900010, 900011},
+ modelName: "gpt-4",
+ filters: []dto.ChannelFilter{pathFilter},
+ wantKept: []int{900003, 900010},
+ },
+ {
+ name: "request path empties when only unmatched type-58 remains",
+ ids: []int{900011},
+ modelName: "gpt-4",
+ filters: []dto.ChannelFilter{pathFilter},
+ wantKept: []int{},
+ wantEmpty: dto.FilterRequestPath,
+ },
+ {
+ name: "intersection attributes empty set to identity after path keeps candidates",
+ ids: []int{900001, 900010},
+ modelName: "gpt-4",
+ filters: []dto.ChannelFilter{pathFilter, identityFilters("missing", nil)[0]},
+ wantKept: []int{},
+ wantEmpty: dto.FilterTaskPluginIdentity,
+ },
+ {
+ name: "intersection attributes empty set to path when path runs first",
+ ids: []int{900011},
+ modelName: "gpt-4",
+ filters: []dto.ChannelFilter{identityFilters("", nil)[0], pathFilter},
+ wantKept: []int{},
+ wantEmpty: dto.FilterRequestPath,
+ },
+ }
+
+ channelSyncLock.Lock()
+ previous := channelsIDM
+ channelsIDM = map[int]*Channel{
+ 900001: alpha,
+ 900002: beta,
+ 900003: ordinary,
+ 900004: kling,
+ 900005: jimeng,
+ 900010: matchingCustom,
+ 900011: otherCustom,
+ }
+ t.Cleanup(func() {
+ channelsIDM = previous
+ channelSyncLock.Unlock()
+ })
+
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ kept, emptiedBy := filterCandidateIDs(testCase.ids, testCase.modelName, testCase.filters)
+ if testCase.wantKept == nil {
+ assert.Nil(t, kept)
+ } else {
+ assert.Equal(t, testCase.wantKept, kept)
+ }
+ assert.Equal(t, testCase.wantEmpty, emptiedBy)
+ })
+ }
+}
+
+func TestChannelSatisfiesFilters(t *testing.T) {
+ alphaSetting := `{"task_plugin_key":"alpha"}`
+ alpha := &Channel{Id: 1, Type: constant.ChannelTypeTaskPlugin, Setting: &alphaSetting}
+ ordinary := &Channel{Id: 2, Type: constant.ChannelTypeOpenAI}
+ custom := &Channel{Id: 3, Type: constant.ChannelTypeAdvancedCustom}
+ custom.SetOtherSettings(kitdto.ChannelOtherSettings{
+ AdvancedCustom: &kitdto.AdvancedCustomConfig{
+ Routes: []kitdto.AdvancedCustomRoute{{
+ IncomingPath: "/v1/chat/completions",
+ Models: []string{"gpt-4"},
+ }},
+ },
+ })
+
+ ok, kind := ChannelSatisfiesFilters(nil, "gpt-4", nil)
+ assert.False(t, ok)
+ assert.Equal(t, dto.ChannelFilterKind(""), kind)
+
+ ok, kind = ChannelSatisfiesFilters(alpha, "shared", identityFilters("alpha", nil))
+ require.True(t, ok)
+ assert.Equal(t, dto.ChannelFilterKind(""), kind)
+
+ ok, kind = ChannelSatisfiesFilters(alpha, "shared", identityFilters("beta", nil))
+ assert.False(t, ok)
+ assert.Equal(t, dto.FilterTaskPluginIdentity, kind)
+
+ ok, kind = ChannelSatisfiesFilters(ordinary, "gpt-4", []dto.ChannelFilter{{
+ Kind: dto.FilterRequestPath,
+ RequestPath: "/v1/chat/completions",
+ }})
+ require.True(t, ok)
+ assert.Equal(t, dto.ChannelFilterKind(""), kind)
+
+ ok, kind = ChannelSatisfiesFilters(custom, "gpt-4", []dto.ChannelFilter{{
+ Kind: dto.FilterRequestPath,
+ RequestPath: "/v1/responses",
+ }})
+ assert.False(t, ok)
+ assert.Equal(t, dto.FilterRequestPath, kind)
+}
diff --git a/model/log.go b/model/log.go
index 1d2b38fc7c1c..ea313589309a 100644
--- a/model/log.go
+++ b/model/log.go
@@ -121,6 +121,8 @@ func formatUserLogs(logs []*Log, startIdx int) {
if otherMap != nil {
// Remove admin-only debug fields.
delete(otherMap, "admin_info")
+ // Remove diagnostics reserved for root.
+ delete(otherMap, "root_info")
// Remove operation-audit details (operator/route info), admin-only.
delete(otherMap, "audit_info")
// delete(otherMap, "reject_reason")
@@ -131,6 +133,19 @@ func formatUserLogs(logs []*Log, startIdx int) {
assignDisplayLogIds(logs, startIdx)
}
+// FormatAdminLogs removes root-only diagnostics while retaining operational
+// admin_info. Root callers must not pass their results through this formatter.
+func FormatAdminLogs(logs []*Log) {
+ for i := range logs {
+ otherMap, _ := common.StrToMap(logs[i].Other)
+ if otherMap == nil {
+ continue
+ }
+ delete(otherMap, "root_info")
+ logs[i].Other = common.MapToJsonStr(otherMap)
+ }
+}
+
func GetLogByTokenId(tokenId int) (logs []*Log, err error) {
order := "id desc"
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
diff --git a/model/log_format_test.go b/model/log_format_test.go
index f580dda637af..2d22c5ab02b1 100644
--- a/model/log_format_test.go
+++ b/model/log_format_test.go
@@ -5,6 +5,7 @@ import (
"github.com/QuantumNous/new-api/common"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -33,3 +34,50 @@ func TestFormatUserLogsStripsQuotaSaturation(t *testing.T) {
// Non-admin billing fields remain visible.
require.Contains(t, parsed, "model_price")
}
+
+func TestTaskPluginLogVisibilityIsRoleSeparated(t *testing.T) {
+ other := common.MapToJsonStr(map[string]interface{}{
+ "model_price": 1.25,
+ "admin_info": map[string]interface{}{
+ "task_plugin": map[string]interface{}{
+ "key": "document-parser",
+ "name": "Document Parser",
+ "version": "1.2.3",
+ },
+ },
+ "root_info": map[string]interface{}{
+ "upstream_task_id": "upstream-private",
+ "task_plugin": map[string]interface{}{
+ "generation": 42,
+ },
+ },
+ })
+
+ t.Run("user", func(t *testing.T) {
+ logs := []*Log{{Other: other}}
+ formatUserLogs(logs, 0)
+
+ parsed, err := common.StrToMap(logs[0].Other)
+ require.NoError(t, err)
+ assert.NotContains(t, parsed, "admin_info")
+ assert.NotContains(t, parsed, "root_info")
+ assert.Equal(t, 1.25, parsed["model_price"])
+ })
+
+ t.Run("admin", func(t *testing.T) {
+ logs := []*Log{{Other: other}}
+ FormatAdminLogs(logs)
+
+ parsed, err := common.StrToMap(logs[0].Other)
+ require.NoError(t, err)
+ assert.Contains(t, parsed, "admin_info")
+ assert.NotContains(t, parsed, "root_info")
+ })
+
+ t.Run("root", func(t *testing.T) {
+ parsed, err := common.StrToMap(other)
+ require.NoError(t, err)
+ assert.Contains(t, parsed, "admin_info")
+ assert.Contains(t, parsed, "root_info")
+ })
+}
diff --git a/model/main.go b/model/main.go
index cd1569db31eb..4b72871fec5c 100644
--- a/model/main.go
+++ b/model/main.go
@@ -323,6 +323,7 @@ func migrateDB() error {
&TopUp{},
&QuotaData{},
&Task{},
+ &TaskPlugin{},
&Model{},
&Vendor{},
&PrefillGroup{},
diff --git a/model/option.go b/model/option.go
index d78706537a80..5d7dfbb06d8e 100644
--- a/model/option.go
+++ b/model/option.go
@@ -6,6 +6,8 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/config"
"github.com/QuantumNous/new-api/setting/operation_setting"
@@ -52,6 +54,13 @@ func InitOptionMap() {
common.OptionMap["DisplayTokenStatEnabled"] = strconv.FormatBool(common.DisplayTokenStatEnabled)
common.OptionMap["DrawingEnabled"] = strconv.FormatBool(common.DrawingEnabled)
common.OptionMap["TaskEnabled"] = strconv.FormatBool(common.TaskEnabled)
+ common.OptionMap["TaskPluginEnabled"] = strconv.FormatBool(constant.TaskPluginEnabled)
+ jsplugin.DefaultRegistry.SetEnabled(constant.TaskPluginEnabled)
+ common.OptionMap["TaskPluginOverrideEnabled"] = strconv.FormatBool(constant.TaskPluginOverrideEnabled)
+ jsplugin.DefaultRegistry.SetOverrideEnabled(constant.TaskPluginOverrideEnabled)
+ common.OptionMap[setting.TaskPluginMarketplaceSourcesKey] = setting.TaskPluginMarketplaceSources2JsonString()
+ common.OptionMap[setting.TaskPluginDisabledFactoryKeysKey] = "[]"
+ jsplugin.DefaultRegistry.SetDisabledFactoryKeys(nil)
common.OptionMap["DataExportEnabled"] = strconv.FormatBool(common.DataExportEnabled)
common.OptionMap["ChannelDisableThreshold"] = strconv.FormatFloat(common.ChannelDisableThreshold, 'f', -1, 64)
common.OptionMap["EmailDomainRestrictionEnabled"] = strconv.FormatBool(common.EmailDomainRestrictionEnabled)
@@ -73,6 +82,7 @@ func InitOptionMap() {
common.OptionMap["SystemName"] = common.SystemName
common.OptionMap["Logo"] = common.Logo
common.OptionMap["ServerAddress"] = ""
+ common.OptionMap["TaskPublicAddress"] = system_setting.TaskPublicAddress
common.OptionMap["WorkerUrl"] = system_setting.WorkerUrl
common.OptionMap["WorkerValidKey"] = system_setting.WorkerValidKey
common.OptionMap["WorkerAllowHttpImageRequestEnabled"] = strconv.FormatBool(system_setting.WorkerAllowHttpImageRequestEnabled)
@@ -352,6 +362,12 @@ func updateOptionMap(key string, value string) (err error) {
common.DrawingEnabled = boolValue
case "TaskEnabled":
common.TaskEnabled = boolValue
+ case "TaskPluginEnabled":
+ constant.TaskPluginEnabled = boolValue
+ jsplugin.DefaultRegistry.SetEnabled(boolValue)
+ case "TaskPluginOverrideEnabled":
+ constant.TaskPluginOverrideEnabled = boolValue
+ jsplugin.DefaultRegistry.SetOverrideEnabled(boolValue)
case "DataExportEnabled":
common.DataExportEnabled = boolValue
case "DefaultCollapseSidebar":
@@ -394,6 +410,9 @@ func updateOptionMap(key string, value string) (err error) {
ratio_setting.SetExposeRatioEnabled(boolValue)
}
}
+ if key == setting.TaskPluginDisabledFactoryKeysKey {
+ jsplugin.DefaultRegistry.SetDisabledFactoryKeys(setting.ParseTaskPluginDisabledFactoryKeys(value))
+ }
switch key {
case "EmailDomainWhitelist":
common.EmailDomainWhitelist = strings.Split(value, ",")
@@ -410,6 +429,8 @@ func updateOptionMap(key string, value string) (err error) {
common.SMTPToken = value
case "ServerAddress":
system_setting.ServerAddress = value
+ case "TaskPublicAddress":
+ system_setting.TaskPublicAddress = value
case "WorkerUrl":
system_setting.WorkerUrl = value
case "WorkerValidKey":
diff --git a/model/option_task_plugin_test.go b/model/option_task_plugin_test.go
new file mode 100644
index 000000000000..9a78a6fa41fb
--- /dev/null
+++ b/model/option_task_plugin_test.go
@@ -0,0 +1,92 @@
+package model
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/QuantumNous/new-api/setting"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTaskPluginEnabledOptionUpdatesRegistry(t *testing.T) {
+ originalEnabled := constant.TaskPluginEnabled
+ originalMap := common.OptionMap
+ common.OptionMap = map[string]string{}
+ const key = "option-master-off"
+ source := `
+export const meta = {apiVersion: 1, key: "option-master-off", name: "Option Master", version: "1.0.0", author: {name: "Test"}, models: ["option-master-model"], fetchMode: "per_task"};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+`
+ _, err := jsplugin.DefaultRegistry.RegisterFactory(source, jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ constant.TaskPluginEnabled = originalEnabled
+ jsplugin.DefaultRegistry.SetEnabled(originalEnabled)
+ common.OptionMap = originalMap
+ })
+
+ _, ok := jsplugin.DefaultRegistry.Get(key)
+ require.True(t, ok)
+
+ require.NoError(t, updateOptionMap("TaskPluginEnabled", "false"))
+
+ assert.False(t, constant.TaskPluginEnabled)
+ assert.Equal(t, "false", common.OptionMap["TaskPluginEnabled"])
+ _, ok = jsplugin.DefaultRegistry.Get(key)
+ assert.False(t, ok)
+
+ require.NoError(t, updateOptionMap("TaskPluginEnabled", "true"))
+ _, ok = jsplugin.DefaultRegistry.Get(key)
+ assert.True(t, ok)
+}
+
+func TestTaskPluginOverrideEnabledOptionUpdatesRuntimeSwitch(t *testing.T) {
+ originalEnabled := constant.TaskPluginOverrideEnabled
+ originalMap := common.OptionMap
+ common.OptionMap = map[string]string{}
+ t.Cleanup(func() {
+ constant.TaskPluginOverrideEnabled = originalEnabled
+ jsplugin.DefaultRegistry.SetOverrideEnabled(originalEnabled)
+ common.OptionMap = originalMap
+ })
+
+ require.NoError(t, updateOptionMap("TaskPluginOverrideEnabled", "false"))
+
+ assert.False(t, constant.TaskPluginOverrideEnabled)
+ assert.Equal(t, "false", common.OptionMap["TaskPluginOverrideEnabled"])
+}
+
+func TestTaskPluginDisabledFactoryKeysOptionUpdatesRegistry(t *testing.T) {
+ originalMap := common.OptionMap
+ common.OptionMap = map[string]string{}
+ const key = "option-factory-off"
+ source := `
+export const meta = {apiVersion: 1, key: "option-factory-off", name: "Option Factory", version: "1.0.0", author: {name: "Test"}, models: ["option-factory-model"], fetchMode: "per_task"};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+`
+ _, err := jsplugin.DefaultRegistry.RegisterFactory(source, jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ jsplugin.DefaultRegistry.SetDisabledFactoryKeys(nil)
+ common.OptionMap = originalMap
+ })
+
+ _, ok := jsplugin.DefaultRegistry.Get(key)
+ require.True(t, ok)
+
+ require.NoError(t, updateOptionMap(setting.TaskPluginDisabledFactoryKeysKey, `["option-factory-off"]`))
+
+ assert.Equal(t, `["option-factory-off"]`, common.OptionMap[setting.TaskPluginDisabledFactoryKeysKey])
+ _, ok = jsplugin.DefaultRegistry.Get(key)
+ assert.False(t, ok)
+ assert.Equal(t, []string{key}, jsplugin.DefaultRegistry.Snapshot().DisabledFactory)
+}
diff --git a/model/pricing.go b/model/pricing.go
index 6dfbfe7aa9f7..9d9f5c50e38c 100644
--- a/model/pricing.go
+++ b/model/pricing.go
@@ -2,6 +2,7 @@ package model
import (
"fmt"
+ "maps"
"strings"
"sync"
@@ -9,6 +10,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
@@ -16,26 +18,28 @@ import (
)
type Pricing struct {
- ModelName string `json:"model_name"`
- Description string `json:"description,omitempty"`
- Icon string `json:"icon,omitempty"`
- Tags string `json:"tags,omitempty"`
- VendorID int `json:"vendor_id,omitempty"`
- QuotaType int `json:"quota_type"`
- ModelRatio float64 `json:"model_ratio"`
- ModelPrice float64 `json:"model_price"`
- OwnerBy string `json:"owner_by"`
- CompletionRatio float64 `json:"completion_ratio"`
- CacheRatio *float64 `json:"cache_ratio,omitempty"`
- CreateCacheRatio *float64 `json:"create_cache_ratio,omitempty"`
- ImageRatio *float64 `json:"image_ratio,omitempty"`
- AudioRatio *float64 `json:"audio_ratio,omitempty"`
- AudioCompletionRatio *float64 `json:"audio_completion_ratio,omitempty"`
- EnableGroup []string `json:"enable_groups"`
- SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
- BillingMode string `json:"billing_mode,omitempty"`
- BillingExpr string `json:"billing_expr,omitempty"`
- PricingVersion string `json:"pricing_version,omitempty"`
+ ModelName string `json:"model_name"`
+ Description string `json:"description,omitempty"`
+ Icon string `json:"icon,omitempty"`
+ Tags string `json:"tags,omitempty"`
+ VendorID int `json:"vendor_id,omitempty"`
+ QuotaType int `json:"quota_type"`
+ ModelRatio float64 `json:"model_ratio"`
+ ModelPrice float64 `json:"model_price"`
+ OwnerBy string `json:"owner_by"`
+ CompletionRatio float64 `json:"completion_ratio"`
+ CacheRatio *float64 `json:"cache_ratio,omitempty"`
+ CreateCacheRatio *float64 `json:"create_cache_ratio,omitempty"`
+ ImageRatio *float64 `json:"image_ratio,omitempty"`
+ AudioRatio *float64 `json:"audio_ratio,omitempty"`
+ AudioCompletionRatio *float64 `json:"audio_completion_ratio,omitempty"`
+ EnableGroup []string `json:"enable_groups"`
+ SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
+ BillingMode string `json:"billing_mode,omitempty"`
+ BillingExpr string `json:"billing_expr,omitempty"`
+ BillingUsageSchema map[string]jsplugin.UsageFieldSchema `json:"billing_usage_schema,omitempty"`
+ BillingUsageExamples []jsplugin.UsageExample `json:"billing_usage_examples,omitempty"`
+ PricingVersion string `json:"pricing_version,omitempty"`
}
type PricingVendor struct {
@@ -355,6 +359,7 @@ func updatePricing() {
}
pricingMap = make([]Pricing, 0)
+ pluginGeneration := jsplugin.DefaultRegistry.Generation()
for model, groups := range modelGroupsMap {
pricing := Pricing{
ModelName: model,
@@ -406,6 +411,27 @@ func updatePricing() {
pricing.BillingExpr = expr
}
}
+ if plugin, ok := pluginGeneration.GetByModel(model); ok && len(plugin.Meta.UsageSchema) > 0 {
+ pricing.BillingUsageSchema = make(map[string]jsplugin.UsageFieldSchema, len(plugin.Meta.UsageSchema))
+ for key, field := range plugin.Meta.UsageSchema {
+ field.Enum = append([]string(nil), field.Enum...)
+ field.Description = maps.Clone(field.Description)
+ pricing.BillingUsageSchema[key] = field
+ }
+ if len(plugin.Meta.UsageExamples) > 0 {
+ pricing.BillingUsageExamples = make([]jsplugin.UsageExample, len(plugin.Meta.UsageExamples))
+ for index, example := range plugin.Meta.UsageExamples {
+ facts := make(map[string]any, len(example.Facts))
+ for key, value := range example.Facts {
+ facts[key] = value
+ }
+ pricing.BillingUsageExamples[index] = jsplugin.UsageExample{
+ Label: example.Label,
+ Facts: facts,
+ }
+ }
+ }
+ }
pricingMap = append(pricingMap, pricing)
}
diff --git a/model/pricing_usage_schema_test.go b/model/pricing_usage_schema_test.go
new file mode 100644
index 000000000000..593bc1df55f8
--- /dev/null
+++ b/model/pricing_usage_schema_test.go
@@ -0,0 +1,69 @@
+package model
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func pricingUsagePluginSource(version, usageSchema string) string {
+ return fmt.Sprintf(`
+export const meta = {
+ apiVersion: 1, key: "pricing-usage-probe", name: "Pricing Usage Probe", version: %q, author: {name: "Test"},
+ models: ["pricing-usage-model"], fetchMode: "per_task", usageSchema: %s
+};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+`, version, usageSchema)
+}
+
+func TestPricingCarriesTaskUsageSchemaAndRefreshesWithPluginGeneration(t *testing.T) {
+ resetPricingEndpointTestTables(t)
+ const pluginKey = "pricing-usage-probe"
+ initialSource := pricingUsagePluginSource("1.0.0", `{
+ seconds: {type: "number", unit: "second", description: "Estimated duration."}
+}`)
+ _, err := jsplugin.DefaultRegistry.Register(initialSource, jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(pluginKey) })
+
+ insertPricingEndpointChannel(t, 901, constant.ChannelTypeTaskPlugin, dto.ChannelOtherSettings{})
+ insertPricingEndpointAbility(t, 901, "pricing-usage-model")
+ insertPricingEndpointAbility(t, 901, "ordinary-model")
+
+ initialPricing := pricingByModel(GetPricing())
+ require.Contains(t, initialPricing, "pricing-usage-model")
+ require.Contains(t, initialPricing, "ordinary-model")
+ assert.Equal(t, "second", initialPricing["pricing-usage-model"].BillingUsageSchema["seconds"].Unit)
+ assert.Equal(t, "Estimated duration.", initialPricing["pricing-usage-model"].BillingUsageSchema["seconds"].Description["en"])
+ assert.Nil(t, initialPricing["ordinary-model"].BillingUsageSchema)
+
+ updatedSource := pricingUsagePluginSource("1.1.0", `{
+ seconds: {type: "number", unit: "second", description: "Measured duration."},
+ clips: {type: "number", unit: "count", description: "Generated clip count."}
+}`)
+ _, err = jsplugin.DefaultRegistry.Register(updatedSource, jsplugin.Options{})
+ require.NoError(t, err)
+ lastGetPricingTime = time.Now().Add(-2 * time.Minute)
+
+ refreshedPricing := pricingByModel(GetPricing())
+ require.Len(t, refreshedPricing["pricing-usage-model"].BillingUsageSchema, 2)
+ assert.Equal(t, "Measured duration.", refreshedPricing["pricing-usage-model"].BillingUsageSchema["seconds"].Description["en"])
+ assert.Equal(t, "count", refreshedPricing["pricing-usage-model"].BillingUsageSchema["clips"].Unit)
+}
+
+func pricingByModel(pricings []Pricing) map[string]Pricing {
+ result := make(map[string]Pricing, len(pricings))
+ for _, pricing := range pricings {
+ result[pricing.ModelName] = pricing
+ }
+ return result
+}
diff --git a/model/task.go b/model/task.go
index 9a1783589a04..5263c5481180 100644
--- a/model/task.go
+++ b/model/task.go
@@ -2,12 +2,14 @@ package model
import (
"bytes"
+ "context"
"database/sql/driver"
"encoding/json"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/pkg/billingexpr"
commonRelay "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
)
@@ -17,7 +19,7 @@ type TaskStatus string
func (t TaskStatus) ToVideoStatus() string {
var status string
switch t {
- case TaskStatusQueued, TaskStatusSubmitted:
+ case TaskStatusNotStart, TaskStatusQueued, TaskStatusSubmitted:
status = dto.VideoStatusQueued
case TaskStatusInProgress:
status = dto.VideoStatusInProgress
@@ -104,22 +106,53 @@ type TaskPrivateData struct {
Key string `json:"key,omitempty"`
UpstreamTaskID string `json:"upstream_task_id,omitempty"` // 上游真实 task ID
ResultURL string `json:"result_url,omitempty"` // 任务成功后的结果 URL(视频地址等)
+ // Execution records safe, immutable request provenance. It lives next to
+ // other private task state so public task DTOs cannot expose it by accident.
+ Execution *TaskExecutionSnapshot `json:"execution,omitempty"`
// 计费上下文:用于异步退款/差额结算(轮询阶段读取)
BillingSource string `json:"billing_source,omitempty"` // "wallet" 或 "subscription"
SubscriptionId int `json:"subscription_id,omitempty"` // 订阅 ID,用于订阅退款
TokenId int `json:"token_id,omitempty"` // 令牌 ID,用于令牌额度退款
NodeName string `json:"node_name,omitempty"` // 发起任务的节点名,轮询结算阶段据此归属日志而非最后查询节点
BillingContext *TaskBillingContext `json:"billing_context,omitempty"` // 计费参数快照(用于轮询阶段重新计算)
+ // ResponsesBackground records that the openai_responses create request
+ // asked for background:true. Every task is durable and survives client
+ // disconnect regardless; this only echoes the protocol-level request
+ // attribute back on retrieval snapshots.
+ ResponsesBackground bool `json:"responses_background,omitempty"`
+}
+
+type TaskExecutionSnapshot struct {
+ RequestID string `json:"request_id,omitempty"`
+ RequestPath string `json:"request_path,omitempty"`
+ TaskPlugin *TaskPluginSnapshot `json:"task_plugin,omitempty"`
+}
+
+// TaskPluginSnapshot contains credential-free identity only. Plugin source,
+// request/response payloads, and channel secrets must never be added here.
+type TaskPluginSnapshot struct {
+ Key string `json:"key"`
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Author *TaskPluginAuthorSnapshot `json:"author,omitempty"`
+ APIVersion int `json:"api_version"`
+ Generation uint64 `json:"generation"`
+}
+
+type TaskPluginAuthorSnapshot struct {
+ Name string `json:"name"`
+ URL string `json:"url,omitempty"`
}
// TaskBillingContext 记录任务提交时的计费参数,以便轮询阶段可以重新计算额度。
type TaskBillingContext struct {
- ModelPrice float64 `json:"model_price,omitempty"` // 模型单价
- GroupRatio float64 `json:"group_ratio,omitempty"` // 分组倍率
- ModelRatio float64 `json:"model_ratio,omitempty"` // 模型倍率
- OtherRatios map[string]float64 `json:"other_ratios,omitempty"` // 附加倍率(时长、分辨率等)
- OriginModelName string `json:"origin_model_name,omitempty"` // 模型名称,必须为OriginModelName
- PerCallBilling bool `json:"per_call_billing,omitempty"` // 按次计费:跳过轮询阶段的差额结算
+ ModelPrice float64 `json:"model_price,omitempty"` // 模型单价
+ GroupRatio float64 `json:"group_ratio,omitempty"` // 分组倍率
+ ModelRatio float64 `json:"model_ratio,omitempty"` // 模型倍率
+ OtherRatios map[string]float64 `json:"other_ratios,omitempty"` // 附加倍率(时长、分辨率等)
+ OriginModelName string `json:"origin_model_name,omitempty"` // 模型名称,必须为OriginModelName
+ PerCallBilling bool `json:"per_call_billing,omitempty"` // 按次计费:跳过轮询阶段的差额结算
+ TieredSnapshot *billingexpr.BillingSnapshot `json:"tiered_snapshot,omitempty"`
}
// GetUpstreamTaskID 获取上游真实 task ID(用于与 provider 通信)
@@ -334,6 +367,38 @@ func HasUnfinishedSyncTasks() bool {
return err == nil && id != 0
}
+func GetByOnlyTaskId(taskId string) (*Task, bool, error) {
+ if taskId == "" {
+ return nil, false, nil
+ }
+ var task *Task
+ var err error
+ err = DB.Where("task_id = ?", taskId).First(&task).Error
+ exist, err := RecordExist(err)
+ if err != nil {
+ return nil, false, err
+ }
+ return task, exist, err
+}
+
+// GetUniqueByOnlyTaskId resolves a public task identifier only when exactly one
+// row owns it. Historical task identifiers were not globally unique, so
+// capability-based reads must fail closed instead of selecting an arbitrary
+// tenant's row.
+func GetUniqueByOnlyTaskId(taskId string) (*Task, bool, error) {
+ if taskId == "" {
+ return nil, false, nil
+ }
+ var tasks []*Task
+ if err := DB.Where("task_id = ?", taskId).Order("id").Limit(2).Find(&tasks).Error; err != nil {
+ return nil, false, err
+ }
+ if len(tasks) != 1 {
+ return nil, false, nil
+ }
+ return tasks[0], true, nil
+}
+
func GetByTaskId(userId int, taskId string) (*Task, bool, error) {
if taskId == "" {
return nil, false, nil
@@ -349,24 +414,44 @@ func GetByTaskId(userId int, taskId string) (*Task, bool, error) {
return task, exist, err
}
-func GetByTaskIds(userId int, taskIds []any) ([]*Task, error) {
- if len(taskIds) == 0 {
+func GetByTaskIdsForPlatforms(userID int, platforms []constant.TaskPlatform, taskIDs []string) ([]*Task, error) {
+ if len(platforms) == 0 || len(taskIDs) == 0 {
return nil, nil
}
- var task []*Task
- var err error
- err = DB.Where("user_id = ? and task_id in (?)", userId, taskIds).
- Find(&task).Error
+ var tasks []*Task
+ err := DB.
+ Where("user_id = ? AND platform IN ? AND task_id IN ?", userID, platforms, taskIDs).
+ Find(&tasks).Error
if err != nil {
return nil, err
}
- return task, nil
+ return tasks, nil
+}
+
+// GetTaskForProtocolObservation reloads one public task through the ownership
+// boundary used by long-lived plugin protocol observers. A missing task,
+// foreign user, and wrong plugin platform are deliberately indistinguishable.
+func GetTaskForProtocolObservation(ctx context.Context, userID int, platform constant.TaskPlatform, taskID string) (*Task, bool, error) {
+ if taskID == "" {
+ return nil, false, nil
+ }
+ var task Task
+ err := DB.WithContext(ctx).
+ Where("user_id = ? AND platform = ? AND task_id = ?", userID, platform, taskID).
+ First(&task).Error
+ exists, err := RecordExist(err)
+ if err != nil || !exists {
+ return nil, exists, err
+ }
+ return &task, true, nil
}
func (Task *Task) Insert() error {
- var err error
- err = DB.Create(Task).Error
- return err
+ return Task.InsertWithContext(context.Background())
+}
+
+func (Task *Task) InsertWithContext(ctx context.Context) error {
+ return DB.WithContext(ctx).Create(Task).Error
}
type taskSnapshot struct {
@@ -514,7 +599,12 @@ func (t *Task) ToOpenAIVideo() *dto.OpenAIVideo {
openAIVideo.Model = t.Properties.OriginModelName
openAIVideo.SetProgressStr(t.Progress)
openAIVideo.CreatedAt = t.CreatedAt
- openAIVideo.CompletedAt = t.UpdatedAt
- openAIVideo.SetMetadata("url", t.GetResultURL())
+ if t.Status == TaskStatusSuccess {
+ if t.FinishTime != 0 {
+ openAIVideo.CompletedAt = t.FinishTime
+ } else {
+ openAIVideo.CompletedAt = t.UpdatedAt
+ }
+ }
return openAIVideo
}
diff --git a/model/task_cas_test.go b/model/task_cas_test.go
index a53804d3baab..e8fc09835281 100644
--- a/model/task_cas_test.go
+++ b/model/task_cas_test.go
@@ -1,6 +1,7 @@
package model
import (
+ "context"
"encoding/json"
"os"
"sync"
@@ -8,6 +9,7 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -99,6 +101,40 @@ func insertTask(t *testing.T, task *Task) {
require.NoError(t, DB.Create(task).Error)
}
+func TestGetTaskForProtocolObservationScopesOwnerAndPlatform(t *testing.T) {
+ truncateTables(t)
+ task := &Task{
+ TaskID: "task_protocol_scope",
+ UserId: 7,
+ Platform: "plugin-a",
+ Status: TaskStatusInProgress,
+ }
+ insertTask(t, task)
+
+ got, exists, err := GetTaskForProtocolObservation(context.Background(), 7, "plugin-a", task.TaskID)
+ require.NoError(t, err)
+ require.True(t, exists)
+ assert.Equal(t, task.ID, got.ID)
+
+ for _, query := range []struct {
+ userID int
+ platform string
+ }{
+ {userID: 8, platform: "plugin-a"},
+ {userID: 7, platform: "plugin-b"},
+ } {
+ got, exists, err = GetTaskForProtocolObservation(context.Background(), query.userID, constant.TaskPlatform(query.platform), task.TaskID)
+ require.NoError(t, err)
+ assert.False(t, exists)
+ assert.Nil(t, got)
+ }
+
+ cancelled, cancel := context.WithCancel(context.Background())
+ cancel()
+ _, _, err = GetTaskForProtocolObservation(cancelled, 7, "plugin-a", task.TaskID)
+ require.ErrorIs(t, err, context.Canceled)
+}
+
// ---------------------------------------------------------------------------
// Snapshot / Equal — pure logic tests (no DB)
// ---------------------------------------------------------------------------
diff --git a/model/task_openai_video_test.go b/model/task_openai_video_test.go
new file mode 100644
index 000000000000..5c429b5fc030
--- /dev/null
+++ b/model/task_openai_video_test.go
@@ -0,0 +1,93 @@
+package model
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTaskToOpenAIVideoDoesNotExposeResultURL(t *testing.T) {
+ task := &Task{
+ TaskID: "task_public",
+ Status: TaskStatusSuccess,
+ Progress: "100%",
+ CreatedAt: 10,
+ UpdatedAt: 20,
+ Properties: Properties{
+ OriginModelName: "video-model",
+ },
+ PrivateData: TaskPrivateData{
+ ResultURL: "https://upstream.example/video.mp4?signature=secret",
+ },
+ }
+
+ video := task.ToOpenAIVideo()
+
+ assert.Equal(t, "task_public", video.ID)
+ assert.Equal(t, "video", video.Object)
+ assert.Equal(t, "completed", video.Status)
+ assert.Nil(t, video.Metadata)
+
+ encoded, err := common.Marshal(video)
+ require.NoError(t, err)
+ assert.NotContains(t, string(encoded), "upstream.example")
+ assert.NotContains(t, string(encoded), "signature")
+}
+
+func TestTaskToOpenAIVideoStatusAndCompletedAt(t *testing.T) {
+ tests := []struct {
+ name string
+ task Task
+ wantStatus string
+ wantCompletedAt int64
+ wantCompletedAtJSON bool
+ }{
+ {
+ name: "not start maps to queued",
+ task: Task{TaskID: "t1", Status: TaskStatusNotStart, CreatedAt: 10, UpdatedAt: 20},
+ wantStatus: "queued",
+ wantCompletedAt: 0,
+ },
+ {
+ name: "in progress omits completed at",
+ task: Task{TaskID: "t2", Status: TaskStatusInProgress, CreatedAt: 10, UpdatedAt: 20, FinishTime: 30},
+ wantStatus: "in_progress",
+ wantCompletedAt: 0,
+ },
+ {
+ name: "success uses finish time",
+ task: Task{TaskID: "t3", Status: TaskStatusSuccess, CreatedAt: 10, UpdatedAt: 20, FinishTime: 30},
+ wantStatus: "completed",
+ wantCompletedAt: 30,
+ wantCompletedAtJSON: true,
+ },
+ {
+ name: "success falls back to updated at",
+ task: Task{TaskID: "t4", Status: TaskStatusSuccess, CreatedAt: 10, UpdatedAt: 20},
+ wantStatus: "completed",
+ wantCompletedAt: 20,
+ wantCompletedAtJSON: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ video := tt.task.ToOpenAIVideo()
+ assert.Equal(t, tt.wantStatus, video.Status)
+ assert.Equal(t, tt.wantCompletedAt, video.CompletedAt)
+
+ encoded, err := common.Marshal(video)
+ require.NoError(t, err)
+ var fields map[string]any
+ require.NoError(t, common.Unmarshal(encoded, &fields))
+ if tt.wantCompletedAtJSON {
+ require.Contains(t, fields, "completed_at")
+ assert.Equal(t, float64(tt.wantCompletedAt), fields["completed_at"])
+ } else {
+ assert.NotContains(t, fields, "completed_at")
+ }
+ })
+ }
+}
diff --git a/model/task_plugin.go b/model/task_plugin.go
new file mode 100644
index 000000000000..53cee5739cbe
--- /dev/null
+++ b/model/task_plugin.go
@@ -0,0 +1,233 @@
+package model
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "sort"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+type TaskPluginChannelRef struct {
+ Id int `json:"id"`
+ Name string `json:"name"`
+}
+
+func GetTaskPluginUsage(key string) ([]TaskPluginChannelRef, int64, error) {
+ var channels []Channel
+ if err := DB.Where("type = ? AND status = ?", constant.ChannelTypeTaskPlugin, common.ChannelStatusEnabled).Find(&channels).Error; err != nil {
+ return nil, 0, err
+ }
+ refs := make([]TaskPluginChannelRef, 0)
+ for _, channel := range channels {
+ if channel.GetSetting().TaskPluginKey == key {
+ refs = append(refs, TaskPluginChannelRef{Id: channel.Id, Name: channel.Name})
+ }
+ }
+ var inFlight int64
+ err := DB.Model(&Task{}).Where("platform = ? AND status NOT IN ?", key, []TaskStatus{TaskStatusSuccess, TaskStatusFailure}).Count(&inFlight).Error
+ return refs, inFlight, err
+}
+
+type TaskPlugin struct {
+ Id int64 `json:"id"`
+ Key string `json:"key" gorm:"size:128;not null;uniqueIndex:uk_task_plugin_key_version,priority:1"`
+ APIVersion int `json:"api_version" gorm:"not null"`
+ Version string `json:"version" gorm:"size:64;not null;uniqueIndex:uk_task_plugin_key_version,priority:2"`
+ Source string `json:"source" gorm:"type:text;not null"`
+ SourceHash string `json:"source_hash" gorm:"size:64;not null"`
+ Enabled bool `json:"enabled" gorm:"not null"`
+ Active bool `json:"active" gorm:"not null;index"`
+ CreatedAt int64 `json:"created_at" gorm:"not null"`
+ Remark string `json:"remark" gorm:"type:text"`
+}
+
+func SaveTaskPlugin(plugin *TaskPlugin) error {
+ return DB.Transaction(func(tx *gorm.DB) error {
+ var existing TaskPlugin
+ err := tx.Where(&TaskPlugin{Key: plugin.Key, Version: plugin.Version}).First(&existing).Error
+ if err == nil {
+ if existing.SourceHash != plugin.SourceHash {
+ return errors.New("plugin key and version already exist with different source")
+ }
+ if err = tx.Model(&existing).Updates(map[string]any{"enabled": plugin.Enabled, "remark": plugin.Remark}).Error; err != nil {
+ return err
+ }
+ existing.Enabled = plugin.Enabled
+ existing.Remark = plugin.Remark
+ *plugin = existing
+ return nil
+ }
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
+ plugin.CreatedAt = time.Now().Unix()
+ var count int64
+ if err = tx.Model(&TaskPlugin{}).Where(&TaskPlugin{Key: plugin.Key, Active: true}).Count(&count).Error; err != nil {
+ return err
+ }
+ plugin.Active = count == 0
+ return tx.Create(plugin).Error
+ })
+}
+
+func ListTaskPluginVersions(key string) ([]TaskPlugin, error) {
+ var plugins []TaskPlugin
+ err := DB.Where(&TaskPlugin{Key: key}).Order("created_at DESC, id DESC").Find(&plugins).Error
+ return plugins, err
+}
+
+func ListTaskPlugins() ([]TaskPlugin, error) {
+ var plugins []TaskPlugin
+ err := DB.
+ Order(clause.OrderByColumn{Column: clause.Column{Name: "key"}}).
+ Order(clause.OrderByColumn{Column: clause.Column{Name: "created_at"}, Desc: true}).
+ Order(clause.OrderByColumn{Column: clause.Column{Name: "id"}, Desc: true}).
+ Find(&plugins).Error
+ return plugins, err
+}
+
+func GetTaskPluginVersion(key, version string) (*TaskPlugin, error) {
+ var plugin TaskPlugin
+ query := DB.Where(&TaskPlugin{Key: key})
+ if version == "" {
+ query = query.Where(&TaskPlugin{Active: true})
+ } else {
+ query = query.Where(&TaskPlugin{Version: version})
+ }
+ if err := query.First(&plugin).Error; err != nil {
+ return nil, err
+ }
+ return &plugin, nil
+}
+
+func ListActiveTaskPlugins() ([]TaskPlugin, error) {
+ snapshot, err := GetTaskPluginSyncSnapshot()
+ return snapshot.Plugins, err
+}
+
+type TaskPluginSyncSnapshot struct {
+ Plugins []TaskPlugin
+ Revision string
+}
+
+// GetTaskPluginSyncSnapshot returns the enabled override set together with a
+// deterministic revision of every active database override. Nodes can compare
+// the revision even though their local routing-generation counters differ.
+func GetTaskPluginSyncSnapshot() (TaskPluginSyncSnapshot, error) {
+ var activePlugins []TaskPlugin
+ if err := DB.Where(&TaskPlugin{Active: true}).
+ Order(clause.OrderByColumn{Column: clause.Column{Name: "key"}}).
+ Order(clause.OrderByColumn{Column: clause.Column{Name: "version"}}).
+ Order(clause.OrderByColumn{Column: clause.Column{Name: "id"}}).
+ Find(&activePlugins).Error; err != nil {
+ return TaskPluginSyncSnapshot{}, err
+ }
+
+ type revisionEntry struct {
+ Key string `json:"key"`
+ APIVersion int `json:"api_version"`
+ Version string `json:"version"`
+ SourceHash string `json:"source_hash"`
+ Enabled bool `json:"enabled"`
+ }
+ entries := make([]revisionEntry, 0, len(activePlugins))
+ enabledPlugins := make([]TaskPlugin, 0, len(activePlugins))
+ for _, plugin := range activePlugins {
+ entries = append(entries, revisionEntry{
+ Key: plugin.Key,
+ APIVersion: plugin.APIVersion,
+ Version: plugin.Version,
+ SourceHash: plugin.SourceHash,
+ Enabled: plugin.Enabled,
+ })
+ if plugin.Enabled {
+ enabledPlugins = append(enabledPlugins, plugin)
+ }
+ }
+ sort.Slice(entries, func(i, j int) bool {
+ if entries[i].Key != entries[j].Key {
+ return entries[i].Key < entries[j].Key
+ }
+ return entries[i].Version < entries[j].Version
+ })
+ payload, err := common.Marshal(entries)
+ if err != nil {
+ return TaskPluginSyncSnapshot{}, err
+ }
+ digest := sha256.Sum256(payload)
+ return TaskPluginSyncSnapshot{
+ Plugins: enabledPlugins,
+ Revision: hex.EncodeToString(digest[:]),
+ }, nil
+}
+
+func ActivateTaskPlugin(key, version string) error {
+ return DB.Transaction(func(tx *gorm.DB) error {
+ var target TaskPlugin
+ if err := tx.Where(&TaskPlugin{Key: key, Version: version}).First(&target).Error; err != nil {
+ return err
+ }
+ if err := tx.Model(&TaskPlugin{}).Where(&TaskPlugin{Key: key}).Update("active", false).Error; err != nil {
+ return err
+ }
+ return tx.Model(&target).Updates(map[string]any{"active": true, "enabled": true}).Error
+ })
+}
+
+func SetTaskPluginEnabled(key string, enabled bool) error {
+ result := DB.Model(&TaskPlugin{}).Where(&TaskPlugin{Key: key, Active: true}).Update("enabled", enabled)
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected == 0 {
+ return gorm.ErrRecordNotFound
+ }
+ return nil
+}
+
+type TaskPluginDeleteResult struct {
+ DeletedActive bool
+ Promoted *TaskPlugin
+}
+
+func DeleteTaskPluginVersion(key, version string) (TaskPluginDeleteResult, error) {
+ result := TaskPluginDeleteResult{}
+ err := DB.Transaction(func(tx *gorm.DB) error {
+ var plugin TaskPlugin
+ if err := lockForUpdate(tx).Where(&TaskPlugin{Key: key, Version: version}).First(&plugin).Error; err != nil {
+ return err
+ }
+ result.DeletedActive = plugin.Active
+ if err := tx.Delete(&plugin).Error; err != nil {
+ return err
+ }
+ if !plugin.Active {
+ return nil
+ }
+
+ var promoted TaskPlugin
+ err := lockForUpdate(tx).
+ Where(&TaskPlugin{Key: key}).
+ Order("created_at DESC, id DESC").
+ First(&promoted).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if err = tx.Model(&promoted).Update("active", true).Error; err != nil {
+ return err
+ }
+ promoted.Active = true
+ result.Promoted = &promoted
+ return nil
+ })
+ return result, err
+}
diff --git a/model/task_plugin_channel_select_test.go b/model/task_plugin_channel_select_test.go
new file mode 100644
index 000000000000..abadb78bec34
--- /dev/null
+++ b/model/task_plugin_channel_select_test.go
@@ -0,0 +1,58 @@
+package model
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/dto"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTaskPluginChannelSelectionFiltersBothCachePaths(t *testing.T) {
+ truncateTables(t)
+ priority := int64(0)
+ weight := uint(1)
+ baseURL := "https://example.com"
+ alphaSetting := `{"task_plugin_key":"alpha"}`
+ betaSetting := `{"task_plugin_key":"beta"}`
+ channels := []Channel{
+ {Id: 900001, Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Name: "alpha", Models: "shared", Group: "default", Priority: &priority, Weight: &weight, BaseURL: &baseURL, Setting: &alphaSetting},
+ {Id: 900002, Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Name: "beta", Models: "shared", Group: "default", Priority: &priority, Weight: &weight, BaseURL: &baseURL, Setting: &betaSetting},
+ {Id: 900003, Type: constant.ChannelTypeOpenAI, Status: common.ChannelStatusEnabled, Name: "ordinary", Models: "ordinary", Group: "default", Priority: &priority, Weight: &weight},
+ {Id: 900004, Type: constant.ChannelTypeKling, Status: common.ChannelStatusEnabled, Name: "legacy-alpha", Models: "legacy", Group: "default", Priority: &priority, Weight: &weight},
+ {Id: 900005, Type: constant.ChannelTypeJimeng, Status: common.ChannelStatusEnabled, Name: "legacy-beta", Models: "legacy", Group: "default", Priority: &priority, Weight: &weight},
+ }
+ for i := range channels {
+ require.NoError(t, channels[i].Insert())
+ }
+
+ selected, err := GetChannel("default", "shared", 0, identityFilters("alpha", nil))
+ require.NoError(t, err)
+ require.NotNil(t, selected)
+ assert.Equal(t, "alpha", selected.Name)
+ selected, err = GetChannel("default", "shared", 0, identityFilters("", nil))
+ require.NoError(t, err)
+ assert.Nil(t, selected)
+ selected, err = GetChannel("default", "ordinary", 0, identityFilters("", nil))
+ require.NoError(t, err)
+ require.NotNil(t, selected)
+ assert.Equal(t, "ordinary", selected.Name)
+ selected, err = GetChannel("default", "legacy", 0, identityFilters("legacy-alpha", []int{constant.ChannelTypeKling}))
+ require.NoError(t, err)
+ require.NotNil(t, selected)
+ assert.Equal(t, "legacy-alpha", selected.Name)
+ selected, err = GetChannel("default", "legacy", 0, identityFilters("legacy-alpha", []int{constant.ChannelTypeKling, constant.ChannelTypeJimeng}))
+ require.NoError(t, err)
+ require.NotNil(t, selected)
+ assert.Contains(t, []string{"legacy-alpha", "legacy-beta"}, selected.Name)
+}
+
+func identityFilters(key string, channelTypes []int) []dto.ChannelFilter {
+ return []dto.ChannelFilter{{
+ Kind: dto.FilterTaskPluginIdentity,
+ TaskPluginKey: key,
+ TaskPluginChannelTypes: channelTypes,
+ }}
+}
diff --git a/model/task_plugin_test.go b/model/task_plugin_test.go
new file mode 100644
index 000000000000..842038fdac74
--- /dev/null
+++ b/model/task_plugin_test.go
@@ -0,0 +1,168 @@
+package model
+
+import (
+ "testing"
+
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+ "gorm.io/gorm/utils/tests"
+)
+
+func setupTaskPluginModelTest(t *testing.T) {
+ t.Helper()
+ originalDB := DB
+ t.Cleanup(func() { DB = originalDB })
+ var err error
+ DB, err = gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, DB.AutoMigrate(&TaskPlugin{}))
+}
+
+func TestTaskPluginVersionActivationAndSourceImmutability(t *testing.T) {
+ setupTaskPluginModelTest(t)
+
+ v1 := TaskPlugin{Key: "mock", APIVersion: 1, Version: "1.0.0", Source: "v1", SourceHash: "hash-v1", Enabled: true}
+ require.NoError(t, SaveTaskPlugin(&v1))
+ assert.True(t, v1.Active)
+
+ v2 := TaskPlugin{Key: "mock", APIVersion: 1, Version: "2.0.0", Source: "v2", SourceHash: "hash-v2", Enabled: true}
+ require.NoError(t, SaveTaskPlugin(&v2))
+ assert.False(t, v2.Active)
+ require.NoError(t, ActivateTaskPlugin("mock", "2.0.0"))
+
+ active, err := ListActiveTaskPlugins()
+ require.NoError(t, err)
+ require.Len(t, active, 1)
+ assert.Equal(t, "2.0.0", active[0].Version)
+
+ conflict := TaskPlugin{Key: "mock", APIVersion: 1, Version: "2.0.0", Source: "changed", SourceHash: "different", Enabled: true}
+ err = SaveTaskPlugin(&conflict)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "different source")
+
+ require.NoError(t, SetTaskPluginEnabled("mock", false))
+ active, err = ListActiveTaskPlugins()
+ require.NoError(t, err)
+ assert.Empty(t, active)
+
+ all, err := ListTaskPlugins()
+ require.NoError(t, err)
+ assert.Len(t, all, 2)
+ deleteResult, err := DeleteTaskPluginVersion("mock", "2.0.0")
+ require.NoError(t, err)
+ assert.True(t, deleteResult.DeletedActive)
+ require.NotNil(t, deleteResult.Promoted)
+ assert.Equal(t, "1.0.0", deleteResult.Promoted.Version)
+ versions, err := ListTaskPluginVersions("mock")
+ require.NoError(t, err)
+ require.Len(t, versions, 1)
+ assert.Equal(t, "1.0.0", versions[0].Version)
+ assert.True(t, versions[0].Active)
+}
+
+func TestDeleteActiveTaskPluginPromotesNewestRemainingVersion(t *testing.T) {
+ setupTaskPluginModelTest(t)
+
+ plugins := []*TaskPlugin{
+ {Key: "promote", APIVersion: 1, Version: "1.0.0", Source: "v1", SourceHash: "hash-v1", Enabled: true},
+ {Key: "promote", APIVersion: 1, Version: "2.0.0", Source: "v2", SourceHash: "hash-v2", Enabled: false},
+ {Key: "promote", APIVersion: 1, Version: "3.0.0", Source: "v3", SourceHash: "hash-v3", Enabled: true},
+ {Key: "promote", APIVersion: 1, Version: "4.0.0", Source: "v4", SourceHash: "hash-v4", Enabled: true},
+ }
+ for _, plugin := range plugins {
+ require.NoError(t, SaveTaskPlugin(plugin))
+ }
+ require.NoError(t, DB.Model(plugins[1]).Update("created_at", 200).Error)
+ require.NoError(t, DB.Model(plugins[2]).Update("created_at", 100).Error)
+ require.NoError(t, DB.Model(plugins[3]).Update("created_at", 100).Error)
+
+ deleteResult, err := DeleteTaskPluginVersion("promote", "1.0.0")
+ require.NoError(t, err)
+ assert.True(t, deleteResult.DeletedActive)
+ require.NotNil(t, deleteResult.Promoted)
+ assert.Equal(t, "2.0.0", deleteResult.Promoted.Version)
+ assert.False(t, deleteResult.Promoted.Enabled)
+
+ deleteResult, err = DeleteTaskPluginVersion("promote", "2.0.0")
+ require.NoError(t, err)
+ assert.True(t, deleteResult.DeletedActive)
+ require.NotNil(t, deleteResult.Promoted)
+ assert.Equal(t, "4.0.0", deleteResult.Promoted.Version)
+
+ active, err := GetTaskPluginVersion("promote", "")
+ require.NoError(t, err)
+ assert.Equal(t, "4.0.0", active.Version)
+}
+
+func TestTaskPluginSyncSnapshotRevisionTracksDesiredRuntimeState(t *testing.T) {
+ setupTaskPluginModelTest(t)
+
+ empty, err := GetTaskPluginSyncSnapshot()
+ require.NoError(t, err)
+ assert.Empty(t, empty.Plugins)
+ require.NotEmpty(t, empty.Revision)
+
+ v1 := TaskPlugin{
+ Key: "revision-probe", APIVersion: 1, Version: "1.0.0",
+ Source: "v1", SourceHash: "hash-v1", Enabled: true,
+ }
+ require.NoError(t, SaveTaskPlugin(&v1))
+ v1Snapshot, err := GetTaskPluginSyncSnapshot()
+ require.NoError(t, err)
+ require.Len(t, v1Snapshot.Plugins, 1)
+ assert.NotEqual(t, empty.Revision, v1Snapshot.Revision)
+
+ v2 := TaskPlugin{
+ Key: "revision-probe", APIVersion: 1, Version: "2.0.0",
+ Source: "v2", SourceHash: "hash-v2", Enabled: true,
+ }
+ require.NoError(t, SaveTaskPlugin(&v2))
+ inactiveAdded, err := GetTaskPluginSyncSnapshot()
+ require.NoError(t, err)
+ assert.Equal(t, v1Snapshot.Revision, inactiveAdded.Revision)
+
+ require.NoError(t, DB.Model(&v1).Update("remark", "operator note").Error)
+ remarkChanged, err := GetTaskPluginSyncSnapshot()
+ require.NoError(t, err)
+ assert.Equal(t, v1Snapshot.Revision, remarkChanged.Revision)
+
+ require.NoError(t, ActivateTaskPlugin("revision-probe", "2.0.0"))
+ v2Snapshot, err := GetTaskPluginSyncSnapshot()
+ require.NoError(t, err)
+ require.Len(t, v2Snapshot.Plugins, 1)
+ assert.Equal(t, "2.0.0", v2Snapshot.Plugins[0].Version)
+ assert.NotEqual(t, v1Snapshot.Revision, v2Snapshot.Revision)
+
+ require.NoError(t, SetTaskPluginEnabled("revision-probe", false))
+ disabled, err := GetTaskPluginSyncSnapshot()
+ require.NoError(t, err)
+ assert.Empty(t, disabled.Plugins)
+ assert.NotEqual(t, v2Snapshot.Revision, disabled.Revision)
+}
+
+func TestTaskPluginOrderSQLQuotesMySQLKeyColumn(t *testing.T) {
+ db, err := gorm.Open(tests.DummyDialector{}, &gorm.Config{DryRun: true})
+ require.NoError(t, err)
+
+ var sqls []string
+ require.NoError(t, db.Callback().Query().After("gorm:query").Register("test:capture_task_plugin_sql", func(tx *gorm.DB) {
+ sqls = append(sqls, tx.Statement.SQL.String())
+ }))
+
+ originalDB := DB
+ t.Cleanup(func() { DB = originalDB })
+ DB = db
+
+ _, err = ListTaskPlugins()
+ require.NoError(t, err)
+ _, err = GetTaskPluginSyncSnapshot()
+ require.NoError(t, err)
+
+ require.Len(t, sqls, 2)
+ for _, sql := range sqls {
+ assert.Contains(t, sql, "`key`")
+ assert.NotRegexp(t, `(?i)ORDER BY[[:space:]]+key([[:space:],]|$)`, sql)
+ }
+}
diff --git a/pkg/billingexpr/compile.go b/pkg/billingexpr/compile.go
index 72a22189f6d9..4421ce9bc09b 100644
--- a/pkg/billingexpr/compile.go
+++ b/pkg/billingexpr/compile.go
@@ -109,10 +109,11 @@ func usesRequestProbe(node ast.Node) bool {
}
type cachedEntry struct {
- prog *vm.Program
- usedVars map[string]bool
- requestRules []RequestRuleTrace
- version int
+ prog *vm.Program
+ usedVars map[string]bool
+ usedUsageKeys map[string]bool
+ requestRules []RequestRuleTrace
+ version int
}
var (
@@ -137,6 +138,7 @@ var compileEnvPrototypeV1 = map[string]interface{}{
"_trace_int": func(int, bool, int) int { return 1 },
"header": func(string) string { return "" },
"param": func(string) interface{} { return nil },
+ "u": func(string) interface{} { return nil },
"has": func(interface{}, string) bool { return false },
"hour": func(string) int { return 0 },
"minute": func(string) int { return 0 },
@@ -196,10 +198,11 @@ func compileEntryFromCacheByHash(exprStr, hash string) (*cachedEntry, error) {
}
entry := &cachedEntry{
- prog: prog,
- usedVars: extractUsedVars(prog),
- requestRules: patcher.requestRules,
- version: version,
+ prog: prog,
+ usedVars: extractUsedVars(prog),
+ usedUsageKeys: extractUsedUsageKeys(prog),
+ requestRules: patcher.requestRules,
+ version: version,
}
cacheMu.Lock()
if len(cache) >= maxCacheSize {
@@ -244,6 +247,27 @@ func extractUsedVars(prog *vm.Program) map[string]bool {
return vars
}
+func extractUsedUsageKeys(prog *vm.Program) map[string]bool {
+ keys := make(map[string]bool)
+ ast.Find(prog.Node(), func(node ast.Node) bool {
+ call, ok := node.(*ast.CallNode)
+ if !ok || len(call.Arguments) != 1 {
+ return false
+ }
+ callee, ok := call.Callee.(*ast.IdentifierNode)
+ if !ok || callee.Value != "u" {
+ return false
+ }
+ literal, ok := call.Arguments[0].(*ast.StringNode)
+ if !ok {
+ return false
+ }
+ keys[strings.TrimSpace(literal.Value)] = true
+ return false
+ })
+ return keys
+}
+
// UsedVars returns the set of identifier names referenced by an expression.
// The result is cached alongside the compiled program. Returns nil for empty input.
func UsedVars(exprStr string) map[string]bool {
@@ -271,6 +295,33 @@ func UsedVars(exprStr string) map[string]bool {
return nil
}
+// UsedUsageKeys returns literal keys referenced by u("...") calls. Calls with
+// dynamic arguments are intentionally omitted because they cannot be
+// validated statically.
+func UsedUsageKeys(exprStr string) map[string]bool {
+ if exprStr == "" {
+ return nil
+ }
+ hash := ExprHashString(exprStr)
+ cacheMu.RLock()
+ if entry, ok := cache[hash]; ok {
+ cacheMu.RUnlock()
+ return entry.usedUsageKeys
+ }
+ cacheMu.RUnlock()
+
+ if _, err := compileFromCacheByHash(exprStr, hash); err != nil {
+ return nil
+ }
+ cacheMu.RLock()
+ entry, ok := cache[hash]
+ cacheMu.RUnlock()
+ if ok {
+ return entry.usedUsageKeys
+ }
+ return nil
+}
+
// InvalidateCache clears the compiled-expression cache.
// Called when billing rules are updated.
func InvalidateCache() {
diff --git a/pkg/billingexpr/compile_usage_test.go b/pkg/billingexpr/compile_usage_test.go
new file mode 100644
index 000000000000..7a92e327d41f
--- /dev/null
+++ b/pkg/billingexpr/compile_usage_test.go
@@ -0,0 +1,21 @@
+package billingexpr_test
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/pkg/billingexpr"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestUsedUsageKeysExtractsLiteralCallsAndSkipsDynamicArguments(t *testing.T) {
+ expression := `tier("base", u(" seconds ") * 0.4 + (u("clips") > 0 ? u("clips") * 0.1 : 0)) + (u(header("usage-key")) == nil ? 0 : 0)`
+
+ keys := billingexpr.UsedUsageKeys(expression)
+
+ assert.Equal(t, map[string]bool{"seconds": true, "clips": true}, keys)
+}
+
+func TestUsedUsageKeysReturnsNilForEmptyOrInvalidExpressions(t *testing.T) {
+ assert.Nil(t, billingexpr.UsedUsageKeys(""))
+ assert.Nil(t, billingexpr.UsedUsageKeys(`tier("broken",`))
+}
diff --git a/pkg/billingexpr/expr.md b/pkg/billingexpr/expr.md
index f192c74ee6a6..f6e789214732 100644
--- a/pkg/billingexpr/expr.md
+++ b/pkg/billingexpr/expr.md
@@ -144,6 +144,85 @@ The usage-log UI treats `request_rules` as the authoritative rule list and rende
---
+## Task Usage Expressions
+
+Task plugins can expose validated, provider-specific billing facts through
+`meta.usageSchema`. Expressions read those facts with `u("key")`. A literal key
+must be declared by the plugin schema before the expression can be saved.
+Numeric facts are finite, non-negative values in the declared canonical unit
+(`second`, `count`, `token`, or `credit`); enum facts are exact strings from the
+declared value list. The schema description is display-only metadata and never
+affects evaluation. `token` is the host unit for upstream billing tokens (for
+example doubao `usage.completion_tokens`). `credit` is the host unit for vendor
+resource-pack units (for example kling `final_unit_deduction`). Both share the
+int32 quota bound, not the 3600-second / 128-count limits.
+
+Task usage billing has a deliberately different conversion rule from token
+billing:
+
+```
+task quota = expression output in USD * QuotaPerUnit * groupRatio
+token quota = expression output in $/1M tokens / 1,000,000 * QuotaPerUnit * groupRatio
+```
+
+In other words, a task expression already returns the request's dollar cost.
+For example, `u("seconds") * 0.4` means $0.40 per second. Engine semantics do
+not divide task output by one million.
+
+The visual editor generates, and public pricing displays recognize, exactly
+these canonical task shapes. Expressions outside these shapes remain valid in
+raw mode but fall back to the special-expression display:
+
+```
+# Flat unit pricing
+tier("base", u("seconds") * 0.4)
+
+# Enum tiers (conditions may combine enum comparisons with &&)
+u("mode") == "pro"
+ ? tier("pro", u("seconds") * 0.8)
+ : tier("std", u("seconds") * 0.4)
+
+# Optional constant plus multiple numeric usage terms
+tier("base", 0.1 + u("seconds") * 0.4 + u("clips") * 0.05)
+
+# Upstream token overlay (doubao Seedance tokens)
+# The editor takes a $/1M token input and emits the / 1000000 literal.
+# Engine semantics are unchanged: the expression still returns USD.
+tier("base", u("tokens") * 9.8 / 1000000)
+
+# Vendor credit overlay (kling resource-pack units)
+# The coefficient is the real $/credit price; no /1M scale.
+tier("base", u("units") * 0.14)
+```
+
+The tier body is an optional non-negative constant plus one or more
+`u("") * ` terms. Token-unit fields use the
+canonical scaled shape `u("") * / 1000000`.
+Credit, second, and count fields keep the bare `u("") * `
+shape. Tier conditions are equality checks
+between declared enum fields and values, optionally joined by `&&`, with
+chained ternaries following the same ordering rules as token tiers. Numeric
+range tiers are not part of the current canonical shape. Request rules after
+`|||` remain orthogonal and use the same syntax as token expressions.
+
+Before saving, the host compiles every expression, rejects literal `u()` keys
+that the selected task plugin did not declare, and smoke-tests usage vectors.
+Every numeric field is exercised at 0, 1, and its host-owned unit ceiling
+(`second` 3600, `count` 128, `token`/`credit` int32 max). Enum values are exercised as a
+Cartesian product. Smoke vectors are capped at
+64, reducing oversized enum dimensions to their first and last values. Every
+evaluated result must be finite and non-negative.
+
+Submission evaluates the expression with request-derived usage facts and
+freezes both the expression and those facts in the billing snapshot. On
+completion, the host overlays completion facts on the frozen submission facts
+key by key, so measured values replace estimates while facts omitted by the
+completion hook retain their submission values. The same expression is then
+evaluated again; a changed fact can therefore produce a settlement delta and a
+different matched tier. Evaluation failure keeps the pre-consumed charge.
+
+---
+
## Architecture
### Data Flow
diff --git a/pkg/billingexpr/run.go b/pkg/billingexpr/run.go
index 397099d557d8..0078781ed10c 100644
--- a/pkg/billingexpr/run.go
+++ b/pkg/billingexpr/run.go
@@ -102,6 +102,12 @@ func runProgram(prog *vm.Program, requestRules []RequestRuleTrace, params TokenP
}
return result.Value()
},
+ "u": func(name string) interface{} {
+ if request.Usage == nil {
+ return nil
+ }
+ return request.Usage[strings.TrimSpace(name)]
+ },
"has": func(source interface{}, substr string) bool {
if source == nil || substr == "" {
return false
diff --git a/pkg/billingexpr/settle.go b/pkg/billingexpr/settle.go
index a1c3267686f9..a8d330cf433c 100644
--- a/pkg/billingexpr/settle.go
+++ b/pkg/billingexpr/settle.go
@@ -6,6 +6,9 @@ import "github.com/QuantumNous/new-api/common"
// expression version. This is the central dispatch point for future versions
// that may use a different conversion formula.
func quotaConversion(exprOutput float64, snap *BillingSnapshot) float64 {
+ if snap.TaskUsageBilling {
+ return exprOutput * snap.QuotaPerUnit
+ }
switch snap.ExprVersion {
default: // v1: coefficients are $/1M tokens prices
return exprOutput / 1_000_000 * snap.QuotaPerUnit
diff --git a/pkg/billingexpr/task_usage_test.go b/pkg/billingexpr/task_usage_test.go
new file mode 100644
index 000000000000..1236a72b20f3
--- /dev/null
+++ b/pkg/billingexpr/task_usage_test.go
@@ -0,0 +1,19 @@
+package billingexpr
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTaskUsageExpressionUsesFactsAndTaskQuotaConversion(t *testing.T) {
+ expression := `tier("1080p", u("seconds") * (u("resolution") == "1080p" ? 0.4 : 0.2))`
+ cost, trace, err := RunExprWithRequest(expression, TokenParams{}, RequestInput{Usage: map[string]any{"seconds": 10.0, "resolution": "1080p"}})
+ require.NoError(t, err)
+ assert.Equal(t, 4.0, cost)
+ assert.Equal(t, "1080p", trace.MatchedTier)
+ result, err := ComputeTieredQuotaWithRequest(&BillingSnapshot{ExprString: expression, ExprHash: ExprHashString(expression), GroupRatio: 2, QuotaPerUnit: 500000, ExprVersion: 1, TaskUsageBilling: true}, TokenParams{}, RequestInput{Usage: map[string]any{"seconds": 10.0, "resolution": "1080p"}})
+ require.NoError(t, err)
+ assert.Equal(t, 4_000_000, result.ActualQuotaAfterGroup)
+}
diff --git a/pkg/billingexpr/types.go b/pkg/billingexpr/types.go
index a67119036c47..3bfe6192c1b4 100644
--- a/pkg/billingexpr/types.go
+++ b/pkg/billingexpr/types.go
@@ -10,6 +10,7 @@ import (
type RequestInput struct {
Headers map[string]string
Body []byte
+ Usage map[string]any
}
// TokenParams holds all token dimensions passed into an Expr evaluation.
@@ -47,18 +48,20 @@ type TraceResult struct {
// auto-group retry and settlement. It is fully serializable and contains no
// compiled program pointers.
type BillingSnapshot struct {
- BillingMode string `json:"billing_mode"`
- ModelName string `json:"model_name"`
- ExprString string `json:"expr_string"`
- ExprHash string `json:"expr_hash"`
- GroupRatio float64 `json:"group_ratio"`
- EstimatedPromptTokens int `json:"estimated_prompt_tokens"`
- EstimatedCompletionTokens int `json:"estimated_completion_tokens"`
- EstimatedQuotaBeforeGroup float64 `json:"estimated_quota_before_group"`
- EstimatedQuotaAfterGroup int `json:"estimated_quota_after_group"`
- EstimatedTier string `json:"estimated_tier"`
- QuotaPerUnit float64 `json:"quota_per_unit"`
- ExprVersion int `json:"expr_version"`
+ BillingMode string `json:"billing_mode"`
+ ModelName string `json:"model_name"`
+ ExprString string `json:"expr_string"`
+ ExprHash string `json:"expr_hash"`
+ GroupRatio float64 `json:"group_ratio"`
+ EstimatedPromptTokens int `json:"estimated_prompt_tokens"`
+ EstimatedCompletionTokens int `json:"estimated_completion_tokens"`
+ EstimatedQuotaBeforeGroup float64 `json:"estimated_quota_before_group"`
+ EstimatedQuotaAfterGroup int `json:"estimated_quota_after_group"`
+ EstimatedTier string `json:"estimated_tier"`
+ QuotaPerUnit float64 `json:"quota_per_unit"`
+ ExprVersion int `json:"expr_version"`
+ TaskUsageBilling bool `json:"task_usage_billing,omitempty"`
+ UsageFacts map[string]any `json:"usage_facts,omitempty"`
}
// TieredResult holds everything needed after running tiered settlement.
diff --git a/pkg/jsplugin/cli.go b/pkg/jsplugin/cli.go
new file mode 100644
index 000000000000..594ac1b38f0d
--- /dev/null
+++ b/pkg/jsplugin/cli.go
@@ -0,0 +1,59 @@
+package jsplugin
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+)
+
+// RunCLI implements the `new-api plugin` subcommand: linting a plugin source
+// and replaying a golden fixture against it. It returns a process exit code.
+func RunCLI(args []string, stdout, stderr io.Writer) int {
+ if len(args) < 2 {
+ fmt.Fprintln(stderr, "usage: new-api plugin lint | new-api plugin test --fixture ")
+ return 2
+ }
+ command, sourcePath := args[0], args[1]
+ source, err := os.ReadFile(sourcePath)
+ if err != nil {
+ fmt.Fprintf(stderr, "read plugin: %v\n", err)
+ return 1
+ }
+
+ switch command {
+ case "lint":
+ if len(args) != 2 {
+ fmt.Fprintln(stderr, "usage: new-api plugin lint ")
+ return 2
+ }
+ plugin, compileErr := NewRegistry().Register(string(source), Options{Key: sourcePath, Version: "lint"})
+ if compileErr != nil {
+ fmt.Fprintf(stderr, "plugin lint failed: %v\n", compileErr)
+ return 1
+ }
+ fmt.Fprintf(stdout, "plugin %s@%s is valid\n", plugin.Meta.Key, plugin.Meta.Version)
+ return 0
+ case "test":
+ if len(args) != 4 || args[2] != "--fixture" || strings.TrimSpace(args[3]) == "" {
+ fmt.Fprintln(stderr, "usage: new-api plugin test --fixture ")
+ return 2
+ }
+ fixture, readErr := os.ReadFile(args[3])
+ if readErr != nil {
+ fmt.Fprintf(stderr, "read fixture: %v\n", readErr)
+ return 1
+ }
+ report, replayErr := ReplayFixture(context.Background(), string(source), fixture)
+ if replayErr != nil {
+ fmt.Fprintf(stderr, "plugin fixture failed after %d/%d cases: %v\n", report.Passed, report.Total, replayErr)
+ return 1
+ }
+ fmt.Fprintf(stdout, "plugin fixture passed: %d/%d cases\n", report.Passed, report.Total)
+ return 0
+ default:
+ fmt.Fprintf(stderr, "unknown plugin command %q\n", command)
+ return 2
+ }
+}
diff --git a/pkg/jsplugin/cli_test.go b/pkg/jsplugin/cli_test.go
new file mode 100644
index 000000000000..b437504c929c
--- /dev/null
+++ b/pkg/jsplugin/cli_test.go
@@ -0,0 +1,38 @@
+package jsplugin
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPluginCLI(t *testing.T) {
+ tempDir := t.TempDir()
+ pluginPath := filepath.Join(tempDir, "fixture.js")
+ fixturePath := filepath.Join(tempDir, "fixture.json")
+ require.NoError(t, os.WriteFile(pluginPath, []byte(cliFixturePluginSource), 0o600))
+ require.NoError(t, os.WriteFile(fixturePath, []byte(`{"unixNow":42,"cases":[{"hook":"value","args":[],"expected":42}]}`), 0o600))
+
+ var stdout bytes.Buffer
+ var stderr bytes.Buffer
+ assert.Equal(t, 0, RunCLI([]string{"lint", pluginPath}, &stdout, &stderr))
+ assert.Contains(t, stdout.String(), "plugin cli-fixture@1.0.0 is valid")
+ assert.Empty(t, stderr.String())
+
+ stdout.Reset()
+ assert.Equal(t, 0, RunCLI([]string{"test", pluginPath, "--fixture", fixturePath}, &stdout, &stderr))
+ assert.Contains(t, stdout.String(), "1/1 cases")
+}
+
+const cliFixturePluginSource = `
+export const meta = { apiVersion: 1, key: "cli-fixture", name: "CLI Fixture", version: "1.0.0", author: {name: "Test"}, channelTypes: [1003], models: ["fixture-model"], fetchMode: "per_task" };
+export function buildSubmitRequest(ctx) { return {url: ctx.baseUrl}; }
+export function parseSubmitResponse(ctx, resp) { return {taskId: "task"}; }
+export function buildQueryRequest(ctx) { return {url: ctx.baseUrl}; }
+export function parseTaskResult(ctx, body) { return body; }
+export function value() { return utils.unixNow(); }
+`
diff --git a/pkg/jsplugin/engine.go b/pkg/jsplugin/engine.go
new file mode 100644
index 000000000000..8c3999b8d0be
--- /dev/null
+++ b/pkg/jsplugin/engine.go
@@ -0,0 +1,579 @@
+package jsplugin
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/QuantumNous/new-api/logger"
+ "github.com/grafana/sobek"
+ "github.com/grafana/sobek/parser"
+)
+
+const (
+ DefaultCallTimeout = 5 * time.Second
+ DefaultConcurrency = 8
+)
+
+var ErrCallAdmissionTimeout = errors.New("plugin call admission timed out")
+
+const hookErrorMessageLimit = 512
+
+// HookError reports a JavaScript exception thrown by a plugin hook. Message
+// is the sanitized JS error message with engine prefixes stripped; it is safe
+// to surface to API callers.
+type HookError struct {
+ Hook string
+ Message string
+ wrapped error
+}
+
+func (e *HookError) Error() string {
+ if e == nil || e.wrapped == nil {
+ return "plugin hook failed"
+ }
+ return e.wrapped.Error()
+}
+
+func (e *HookError) Unwrap() error {
+ if e == nil {
+ return nil
+ }
+ return e.wrapped
+}
+
+func newHookError(hook, rawMessage string, wrapped error) *HookError {
+ var b strings.Builder
+ b.Grow(len(rawMessage))
+ count := 0
+ for _, r := range rawMessage {
+ if count >= hookErrorMessageLimit {
+ break
+ }
+ if r < 0x20 || (r >= 0x80 && r <= 0x9F) {
+ r = ' '
+ }
+ b.WriteRune(r)
+ count++
+ }
+ message := b.String()
+ if message == "" {
+ message = "plugin hook failed"
+ }
+ return &HookError{Hook: hook, Message: message, wrapped: wrapped}
+}
+
+func hookErrorFromException(hook string, exc *sobek.Exception, wrapped error) (hookErr *HookError) {
+ // Reading message/toString executes plugin getters, which can throw again
+ // and panic sobek. By this point the caller's recover is already consumed,
+ // so a second panic would crash the process; fall back to a blank message.
+ defer func() {
+ if recover() != nil {
+ hookErr = newHookError(hook, "", wrapped)
+ }
+ }()
+ raw := ""
+ if exc != nil {
+ if val := exc.Value(); val != nil && !sobek.IsUndefined(val) && !sobek.IsNull(val) {
+ gotMessage := false
+ if obj, ok := val.(*sobek.Object); ok {
+ if msg := obj.Get("message"); msg != nil && !sobek.IsUndefined(msg) && !sobek.IsNull(msg) {
+ raw = msg.String()
+ gotMessage = true
+ }
+ }
+ if !gotMessage {
+ if exported, ok := val.Export().(string); ok {
+ raw = exported
+ } else {
+ raw = val.String()
+ }
+ }
+ }
+ }
+ return newHookError(hook, raw, wrapped)
+}
+
+var forbiddenSyntax = regexp.MustCompile(`(?m)(^|[^A-Za-z0-9_$])(async|await|import)([^A-Za-z0-9_$]|$)`)
+
+type Options struct {
+ Key string
+ Version string
+ Timeout time.Duration
+ Concurrency int
+ Now func() time.Time
+ Log func(string)
+}
+
+type Engine struct {
+ key string
+ version string
+ timeout time.Duration
+ now func() time.Time
+ log func(string)
+ module *sobek.SourceTextModuleRecord
+ pool sync.Pool
+ semaphore chan struct{}
+}
+
+type runtimeInstance struct {
+ runtime *sobek.Runtime
+ module sobek.ModuleInstance
+ logContext *runtimeLogContext
+}
+
+type runtimeLogContext struct {
+ context context.Context
+}
+
+// Compile performs upload-time syntax checks and compiles an ESM plugin once.
+// All Sobek-specific module and runtime handling is intentionally kept here.
+func Compile(source string, options Options) (*Engine, error) {
+ if match := forbiddenSyntax.FindString(sourceWithoutCommentsAndStrings(source)); match != "" {
+ return nil, fmt.Errorf("unsupported plugin syntax %q: plugins must be synchronous and cannot import modules", strings.TrimSpace(match))
+ }
+
+ resolve := func(_ interface{}, specifier string) (sobek.ModuleRecord, error) {
+ return nil, fmt.Errorf("plugin imports are disabled: %s", specifier)
+ }
+ // Plugin source is untrusted; without this option a sourceMappingURL
+ // comment makes the parser read arbitrary server files via os.ReadFile.
+ module, err := sobek.ParseModule(options.Key+".js", source, resolve, parser.WithDisableSourceMaps)
+ if err != nil {
+ return nil, fmt.Errorf("compile plugin: %w", err)
+ }
+ if err = module.Link(); err != nil {
+ return nil, fmt.Errorf("link plugin: %w", err)
+ }
+
+ timeout := options.Timeout
+ if timeout <= 0 {
+ timeout = DefaultCallTimeout
+ }
+ concurrency := options.Concurrency
+ if concurrency <= 0 {
+ concurrency = DefaultConcurrency
+ }
+ now := options.Now
+ if now == nil {
+ now = time.Now
+ }
+
+ engine := &Engine{
+ key: options.Key,
+ version: options.Version,
+ timeout: timeout,
+ now: now,
+ log: options.Log,
+ module: module,
+ semaphore: make(chan struct{}, concurrency),
+ }
+ instance, err := engine.newRuntime(context.Background())
+ if err != nil {
+ return nil, err
+ }
+ instance.logContext.context = nil
+ engine.pool.Put(instance)
+ return engine, nil
+}
+
+// Export returns one module export without exposing Sobek values outside the
+// engine boundary. It is used for declarative exports such as meta.
+func (e *Engine) Export(ctx context.Context, exportName string) (result any, err error) {
+ select {
+ case e.semaphore <- struct{}{}:
+ defer func() { <-e.semaphore }()
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+
+ instance, err := e.getRuntime(ctx)
+ if err != nil {
+ return nil, err
+ }
+ reusable := true
+ defer func() {
+ instance.runtime.ClearInterrupt()
+ instance.logContext.context = nil
+ if reusable {
+ e.pool.Put(instance)
+ }
+ }()
+ timedOut := errors.New("plugin export timed out")
+ timer := time.AfterFunc(e.timeout, func() { instance.runtime.Interrupt(timedOut) })
+ stopContext := context.AfterFunc(ctx, func() { instance.runtime.Interrupt(ctx.Err()) })
+ defer stopContext()
+ defer timer.Stop()
+ defer func() {
+ if recovered := recover(); recovered != nil {
+ reusable = false
+ switch value := recovered.(type) {
+ case *sobek.InterruptedError:
+ err = fmt.Errorf("plugin %s@%s export %s interrupted: %v", e.key, e.version, exportName, value.Value())
+ case *sobek.Exception:
+ err = fmt.Errorf("plugin %s@%s export %s failed: %v", e.key, e.version, exportName, value)
+ default:
+ panic(recovered)
+ }
+ }
+ }()
+ value := instance.module.GetBindingValue(exportName)
+ if value == nil || sobek.IsUndefined(value) {
+ return nil, fmt.Errorf("plugin export %q not found", exportName)
+ }
+ return value.Export(), nil
+}
+
+// HasExport reports whether a module export exists. Optional contract hooks
+// should be detected with this method instead of relying on engine errors.
+func (e *Engine) HasExport(ctx context.Context, exportName string) (bool, error) {
+ select {
+ case e.semaphore <- struct{}{}:
+ defer func() { <-e.semaphore }()
+ case <-ctx.Done():
+ return false, ctx.Err()
+ }
+ instance, err := e.getRuntime(ctx)
+ if err != nil {
+ return false, err
+ }
+ defer func() {
+ instance.logContext.context = nil
+ e.pool.Put(instance)
+ }()
+ value := instance.module.GetBindingValue(exportName)
+ return value != nil && !sobek.IsUndefined(value), nil
+}
+
+// HasCallablePath reports whether an exported value, or a nested member below
+// it, exists and is callable.
+func (e *Engine) HasCallablePath(ctx context.Context, exportName string, members ...string) (found bool, err error) {
+ select {
+ case e.semaphore <- struct{}{}:
+ defer func() { <-e.semaphore }()
+ case <-ctx.Done():
+ return false, ctx.Err()
+ }
+ instance, err := e.getRuntime(ctx)
+ if err != nil {
+ return false, err
+ }
+ reusable := true
+ defer func() {
+ instance.runtime.ClearInterrupt()
+ instance.logContext.context = nil
+ if reusable {
+ e.pool.Put(instance)
+ }
+ }()
+ timedOut := errors.New("plugin inspection timed out")
+ timer := time.AfterFunc(e.timeout, func() { instance.runtime.Interrupt(timedOut) })
+ stopContext := context.AfterFunc(ctx, func() { instance.runtime.Interrupt(ctx.Err()) })
+ defer stopContext()
+ defer timer.Stop()
+ defer func() {
+ if recovered := recover(); recovered != nil {
+ reusable = false
+ hookName := strings.Join(append([]string{exportName}, members...), ".")
+ switch value := recovered.(type) {
+ case *sobek.InterruptedError:
+ err = fmt.Errorf("plugin %s@%s hook %s inspection interrupted: %v", e.key, e.version, hookName, value.Value())
+ case *sobek.Exception:
+ err = fmt.Errorf("plugin %s@%s hook %s inspection failed: %v", e.key, e.version, hookName, value)
+ default:
+ panic(recovered)
+ }
+ }
+ }()
+ value, _, found := resolveExportPath(instance, exportName, members)
+ if !found {
+ return false, nil
+ }
+ _, callable := sobek.AssertFunction(value)
+ return callable, nil
+}
+
+// Call invokes one named module export and returns its JSON-compatible value.
+func (e *Engine) Call(ctx context.Context, exportName string, args ...any) (result any, err error) {
+ return e.call(ctx, 0, exportName, nil, args...)
+}
+
+// CallMember invokes a function stored on an exported object, such as a
+// renderer in the renderers export.
+func (e *Engine) CallMember(ctx context.Context, exportName, memberName string, args ...any) (result any, err error) {
+ return e.call(ctx, 0, exportName, []string{memberName}, args...)
+}
+
+// CallPath invokes a function nested below an exported object. It is used for
+// protocol hooks such as protocols.openai_responses.renderEvents.
+func (e *Engine) CallPath(ctx context.Context, exportName string, members []string, args ...any) (result any, err error) {
+ return e.call(ctx, 0, exportName, members, args...)
+}
+
+// CallPathWithAdmissionTimeout gives long-lived observers a separate bound for
+// waiting on JavaScript capacity. Once admitted, the hook receives the
+// engine's full execution timeout instead of inheriting time already spent in
+// the semaphore queue.
+func (e *Engine) CallPathWithAdmissionTimeout(
+ ctx context.Context,
+ admissionTimeout time.Duration,
+ exportName string,
+ members []string,
+ args ...any,
+) (result any, err error) {
+ return e.call(ctx, admissionTimeout, exportName, members, args...)
+}
+
+func (e *Engine) call(
+ ctx context.Context,
+ admissionTimeout time.Duration,
+ exportName string,
+ members []string,
+ args ...any,
+) (result any, err error) {
+ if err = e.acquireCallSlot(ctx, admissionTimeout); err != nil {
+ return nil, err
+ }
+ defer func() { <-e.semaphore }()
+
+ instance, err := e.getRuntime(ctx)
+ if err != nil {
+ return nil, err
+ }
+ reusable := true
+ defer func() {
+ instance.runtime.ClearInterrupt()
+ instance.logContext.context = nil
+ if reusable {
+ e.pool.Put(instance)
+ }
+ }()
+
+ hookName := strings.Join(append([]string{exportName}, members...), ".")
+ timedOut := errors.New("plugin call timed out")
+ timer := time.AfterFunc(e.timeout, func() { instance.runtime.Interrupt(timedOut) })
+ stopContext := context.AfterFunc(ctx, func() { instance.runtime.Interrupt(ctx.Err()) })
+ defer stopContext()
+ defer timer.Stop()
+ defer func() {
+ if recovered := recover(); recovered != nil {
+ reusable = false
+ switch value := recovered.(type) {
+ case *sobek.InterruptedError:
+ err = fmt.Errorf("plugin %s@%s hook %s interrupted: %v", e.key, e.version, hookName, value.Value())
+ case *sobek.Exception:
+ wrapped := fmt.Errorf("plugin %s@%s hook %s failed: %v", e.key, e.version, hookName, value)
+ err = hookErrorFromException(hookName, value, wrapped)
+ default:
+ panic(recovered)
+ }
+ }
+ }()
+
+ value, resolvedHookName, found := resolveExportPath(instance, exportName, members)
+ hookName = resolvedHookName
+ if !found {
+ if len(members) == 0 {
+ return nil, fmt.Errorf("plugin export %q not found", exportName)
+ }
+ return nil, fmt.Errorf("plugin hook %q not found", hookName)
+ }
+ if value == nil || sobek.IsUndefined(value) {
+ return nil, fmt.Errorf("plugin export %q not found", exportName)
+ }
+ callable, ok := sobek.AssertFunction(value)
+ if !ok {
+ return nil, fmt.Errorf("plugin hook %q is not a function", hookName)
+ }
+
+ callArgs := make([]sobek.Value, len(args))
+ for i, arg := range args {
+ callArgs[i] = instance.runtime.ToValue(arg)
+ }
+
+ value, err = callable(sobek.Undefined(), callArgs...)
+ if err != nil {
+ var interrupted *sobek.InterruptedError
+ if errors.As(err, &interrupted) {
+ reusable = false
+ return nil, fmt.Errorf("plugin %s@%s hook %s failed: %w", e.key, e.version, hookName, err)
+ }
+ wrapped := fmt.Errorf("plugin %s@%s hook %s failed: %w", e.key, e.version, hookName, err)
+ var exc *sobek.Exception
+ if errors.As(err, &exc) {
+ return nil, hookErrorFromException(hookName, exc, wrapped)
+ }
+ return nil, wrapped
+ }
+ return value.Export(), nil
+}
+
+func (e *Engine) acquireCallSlot(ctx context.Context, admissionTimeout time.Duration) error {
+ if admissionTimeout <= 0 {
+ select {
+ case e.semaphore <- struct{}{}:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ }
+
+ timer := time.NewTimer(admissionTimeout)
+ defer timer.Stop()
+ select {
+ case e.semaphore <- struct{}{}:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-timer.C:
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ return fmt.Errorf("%w: plugin %s@%s", ErrCallAdmissionTimeout, e.key, e.version)
+ }
+}
+
+func resolveExportPath(instance *runtimeInstance, exportName string, members []string) (sobek.Value, string, bool) {
+ value := instance.module.GetBindingValue(exportName)
+ hookName := exportName
+ if value == nil || sobek.IsUndefined(value) || sobek.IsNull(value) {
+ return nil, hookName, false
+ }
+ for _, member := range members {
+ hookName += "." + member
+ object := value.ToObject(instance.runtime)
+ own := false
+ for _, name := range object.GetOwnPropertyNames() {
+ if name == member {
+ own = true
+ break
+ }
+ }
+ if !own {
+ return nil, hookName, false
+ }
+ value = object.Get(member)
+ if value == nil || sobek.IsUndefined(value) || sobek.IsNull(value) {
+ return nil, hookName, false
+ }
+ }
+ return value, hookName, true
+}
+
+func (e *Engine) getRuntime(ctx context.Context) (*runtimeInstance, error) {
+ if pooled := e.pool.Get(); pooled != nil {
+ instance := pooled.(*runtimeInstance)
+ instance.logContext.context = ctx
+ return instance, nil
+ }
+ return e.newRuntime(ctx)
+}
+
+func (e *Engine) newRuntime(ctx context.Context) (instance *runtimeInstance, err error) {
+ runtime := sobek.New()
+ logContext := &runtimeLogContext{context: ctx}
+ logOutput := e.log
+ if logOutput == nil {
+ logOutput = func(message string) {
+ logger.LogDebug(logContext.context, "task_plugin subsystem=runtime event=console message=%q", message)
+ }
+ }
+ if err := injectGlobals(runtime, func() string {
+ return fmt.Sprintf("[plugin:%s@%s]", e.key, e.version)
+ }, e.now, logOutput); err != nil {
+ return nil, fmt.Errorf("inject plugin utils: %w", err)
+ }
+ timedOut := errors.New("plugin initialization timed out")
+ timer := time.AfterFunc(e.timeout, func() { runtime.Interrupt(timedOut) })
+ defer timer.Stop()
+ defer func() {
+ if recovered := recover(); recovered != nil {
+ if interrupted, ok := recovered.(*sobek.InterruptedError); ok {
+ instance = nil
+ err = fmt.Errorf("initialize plugin %s@%s: %v", e.key, e.version, interrupted.Value())
+ return
+ }
+ panic(recovered)
+ }
+ }()
+ promise := runtime.CyclicModuleRecordEvaluate(e.module, func(_ interface{}, specifier string) (sobek.ModuleRecord, error) {
+ return nil, fmt.Errorf("plugin imports are disabled: %s", specifier)
+ })
+ if promise.State() != sobek.PromiseStateFulfilled {
+ return nil, fmt.Errorf("evaluate plugin: %v", promise.Result().Export())
+ }
+ return &runtimeInstance{
+ runtime: runtime,
+ module: runtime.GetModuleInstance(e.module),
+ logContext: logContext,
+ }, nil
+}
+
+func sourceWithoutCommentsAndStrings(source string) string {
+ var output strings.Builder
+ output.Grow(len(source))
+ quote := byte(0)
+ escaped := false
+ lineComment := false
+ blockComment := false
+ for i := 0; i < len(source); i++ {
+ current := source[i]
+ next := byte(0)
+ if i+1 < len(source) {
+ next = source[i+1]
+ }
+ if lineComment {
+ if current == '\n' {
+ lineComment = false
+ output.WriteByte('\n')
+ } else {
+ output.WriteByte(' ')
+ }
+ continue
+ }
+ if blockComment {
+ if current == '*' && next == '/' {
+ blockComment = false
+ output.WriteString(" ")
+ i++
+ } else {
+ output.WriteByte(' ')
+ }
+ continue
+ }
+ if quote != 0 {
+ output.WriteByte(' ')
+ if escaped {
+ escaped = false
+ } else if current == '\\' {
+ escaped = true
+ } else if current == quote {
+ quote = 0
+ }
+ continue
+ }
+ if current == '/' && next == '/' {
+ lineComment = true
+ output.WriteString(" ")
+ i++
+ continue
+ }
+ if current == '/' && next == '*' {
+ blockComment = true
+ output.WriteString(" ")
+ i++
+ continue
+ }
+ if current == '\'' || current == '"' || current == '`' {
+ quote = current
+ output.WriteByte(' ')
+ continue
+ }
+ output.WriteByte(current)
+ }
+ return output.String()
+}
diff --git a/pkg/jsplugin/engine_test.go b/pkg/jsplugin/engine_test.go
new file mode 100644
index 000000000000..6f15eec6ed69
--- /dev/null
+++ b/pkg/jsplugin/engine_test.go
@@ -0,0 +1,372 @@
+package jsplugin
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+ "unicode/utf8"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestEngineCallsESMExportWithInjectedUtils(t *testing.T) {
+ t.Parallel()
+ logs := make([]string, 0, 1)
+ engine, err := Compile(`
+export function sign(ctx) {
+ console.log("called", ctx.name);
+ return {
+ now: utils.unixNow(),
+ digest: utils.hmacSHA256(ctx.message, ctx.secret),
+ encoded: utils.base64(ctx.message),
+ };
+}
+export const meta = { apiVersion: 1, key: "mock" };
+`, Options{
+ Key: "mock", Version: "1.0.0",
+ Now: func() time.Time { return time.Unix(1234, 0) },
+ Log: func(message string) { logs = append(logs, message) },
+ })
+ require.NoError(t, err)
+
+ result, err := engine.Call(context.Background(), "sign", map[string]any{
+ "name": "fixture", "message": "hello", "secret": "secret",
+ })
+ require.NoError(t, err)
+ assert.Equal(t, map[string]any{
+ "now": int64(1234), "digest": "88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b", "encoded": "aGVsbG8=",
+ }, result)
+ assert.Equal(t, []string{"[plugin:mock@1.0.0] called fixture"}, logs)
+
+ meta, err := engine.Export(context.Background(), "meta")
+ require.NoError(t, err)
+ assert.Equal(t, map[string]any{"apiVersion": int64(1), "key": "mock"}, meta)
+}
+
+func TestEngineConsoleLogUsesDebugLoggerAndRequestContext(t *testing.T) {
+ previousDebug := common.DebugEnabled
+ common.DebugEnabled = false
+ t.Cleanup(func() { common.DebugEnabled = previousDebug })
+
+ var output bytes.Buffer
+ common.LogWriterMu.Lock()
+ previousWriter := gin.DefaultErrorWriter
+ gin.DefaultErrorWriter = &output
+ common.LogWriterMu.Unlock()
+ t.Cleanup(func() {
+ common.LogWriterMu.Lock()
+ gin.DefaultErrorWriter = previousWriter
+ common.LogWriterMu.Unlock()
+ })
+
+ plugin, err := CompilePlugin(`
+export const meta = {
+ apiVersion: 1,
+ key: "console-debug",
+ name: "Console debug",
+ version: "1.2.3",
+ author: {name: "Test"},
+ models: ["debug-model"],
+ fetchMode: "per_task",
+};
+export function run(label) {
+ console.log("checkpoint", label);
+ return true;
+}
+export function buildSubmitRequest() { return {url: "https://example.com"}; }
+export function parseSubmitResponse() { return {taskId: "one"}; }
+export function buildQueryRequest() { return {url: "https://example.com"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+`, Options{})
+ require.NoError(t, err)
+ engine := plugin.Engine
+
+ disabledContext := context.WithValue(context.Background(), common.RequestIdKey, "plugin-console-disabled")
+ _, err = engine.Call(disabledContext, "run", "disabled")
+ require.NoError(t, err)
+ assert.Empty(t, output.String())
+
+ common.DebugEnabled = true
+ contextA := context.WithValue(context.Background(), common.RequestIdKey, "plugin-console-request-a")
+ _, err = engine.Call(contextA, "run", "context-a")
+ require.NoError(t, err)
+ logA := output.String()
+ output.Reset()
+
+ contextB := context.WithValue(context.Background(), common.RequestIdKey, "plugin-console-request-b")
+ _, err = engine.Call(contextB, "run", "context-b")
+ require.NoError(t, err)
+ logB := output.String()
+ output.Reset()
+
+ _, err = engine.Call(context.Background(), "run", "background")
+ require.NoError(t, err)
+ logBackground := output.String()
+
+ assert.Contains(t, logA, "plugin-console-request-a")
+ assert.NotContains(t, logA, "plugin-console-request-b")
+ assert.Contains(t, logA, "task_plugin subsystem=runtime event=console")
+ assert.Contains(t, logA, "[plugin:console-debug@1.2.3] checkpoint context-a")
+ assert.NotContains(t, logA, "disabled")
+
+ assert.Contains(t, logB, "plugin-console-request-b")
+ assert.NotContains(t, logB, "plugin-console-request-a")
+ assert.Contains(t, logB, "[plugin:console-debug@1.2.3] checkpoint context-b")
+
+ assert.Contains(t, logBackground, "| SYSTEM |")
+ assert.NotContains(t, logBackground, "plugin-console-request-a")
+ assert.NotContains(t, logBackground, "plugin-console-request-b")
+ assert.Contains(t, logBackground, "[plugin:console-debug@1.2.3] checkpoint background")
+}
+
+func TestCompileRejectsAsynchronousAndImportedPlugins(t *testing.T) {
+ t.Parallel()
+ for name, source := range map[string]string{
+ "async": `export async function run() {}`,
+ "static import": `import value from "dependency"; export function run() { return value; }`,
+ "dynamic import": `export function run() { return import("dependency"); }`,
+ "top-level await": `const value = await work(); export function run() { return value; }`,
+ } {
+ t.Run(name, func(t *testing.T) {
+ _, err := Compile(source, Options{Key: "invalid"})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unsupported plugin syntax")
+ })
+ }
+
+ _, err := Compile(`export function run() { return "import async await"; }`, Options{Key: "valid"})
+ require.NoError(t, err)
+}
+
+func TestCompileIgnoresSourceMapDirectives(t *testing.T) {
+ t.Parallel()
+ // A sourceMappingURL comment must stay inert. Sobek's default loader
+ // os.ReadFiles the referenced server path during Compile and turns any
+ // load failure into a compile error, so an unresolvable path compiling
+ // cleanly proves the loader is disabled.
+ engine, err := Compile("export function run() { return 1; }\n//# sourceMappingURL=/nonexistent/leak-probe.map\n", Options{Key: "sourcemap"})
+ require.NoError(t, err)
+
+ value, err := engine.Call(context.Background(), "run")
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), value)
+}
+
+func TestEngineInterruptsLongRunningHook(t *testing.T) {
+ t.Parallel()
+ engine, err := Compile(`export function run() { while (true) {} }`, Options{
+ Key: "loop", Version: "1", Timeout: 20 * time.Millisecond,
+ })
+ require.NoError(t, err)
+
+ _, err = engine.Call(context.Background(), "run")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "timed out")
+ var hookErr *HookError
+ assert.False(t, errors.As(err, &hookErr), "timeouts must not be HookError")
+}
+
+func TestEngineHookErrorExtractsSanitizedJSMessage(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ source string
+ wantMessage string
+ wantLen int
+ }{
+ {
+ name: "Error object",
+ source: `export function run() { throw new Error("model is required"); }`,
+ wantMessage: "model is required",
+ },
+ {
+ name: "raw string throw",
+ source: `export function run() { throw "raw string"; }`,
+ wantMessage: "raw string",
+ },
+ {
+ name: "truncates to 512 runes",
+ source: `export function run() { throw new Error("x".repeat(2000)); }`,
+ wantLen: 512,
+ wantMessage: strings.Repeat("x", 512),
+ },
+ {
+ name: "scrubs control characters",
+ source: "export function run() { throw new Error(\"line1\\nline2\\x1b[31mred\"); }",
+ wantMessage: "line1 line2 [31mred",
+ },
+ {
+ name: "throwing message getter falls back without crashing",
+ source: `export function run() { throw {get message() { throw {get message() { return "deep"; }}; }}; }`,
+ wantMessage: "plugin hook failed",
+ },
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ t.Parallel()
+ engine, err := Compile(testCase.source, Options{Key: "diag", Version: "1.0.0"})
+ require.NoError(t, err)
+
+ _, err = engine.Call(context.Background(), "run")
+ require.Error(t, err)
+
+ var hookErr *HookError
+ require.True(t, errors.As(err, &hookErr))
+ assert.Equal(t, "run", hookErr.Hook)
+ assert.Equal(t, testCase.wantMessage, hookErr.Message)
+ if testCase.wantLen > 0 {
+ assert.Equal(t, testCase.wantLen, utf8.RuneCountInString(hookErr.Message))
+ }
+ assert.Contains(t, hookErr.Error(), "plugin diag@1.0.0")
+ assert.NotContains(t, hookErr.Message, "Error:")
+ assert.NotContains(t, hookErr.Message, "plugin diag@")
+ })
+ }
+}
+
+func TestEngineReportsProtocolAdmissionTimeoutSeparately(t *testing.T) {
+ engine, err := Compile(`
+export const protocols = {
+ responses: {renderEvents: function() { return {events: [], done: false}; }},
+};
+`, Options{Key: "admission", Version: "1", Concurrency: 1})
+ require.NoError(t, err)
+
+ engine.semaphore <- struct{}{}
+ _, err = engine.CallPathWithAdmissionTimeout(
+ context.Background(),
+ time.Nanosecond,
+ "protocols",
+ []string{"responses", "renderEvents"},
+ )
+ <-engine.semaphore
+
+ require.ErrorIs(t, err, ErrCallAdmissionTimeout)
+ result, err := engine.CallPathWithAdmissionTimeout(
+ context.Background(),
+ time.Second,
+ "protocols",
+ []string{"responses", "renderEvents"},
+ )
+ require.NoError(t, err)
+ assert.Equal(t, map[string]any{"events": []any{}, "done": false}, result)
+}
+
+func TestEngineExportInterruptsLongRunningGetter(t *testing.T) {
+ t.Parallel()
+ engine, err := Compile(`
+export const meta = {
+ apiVersion: 1,
+ get name() { while (true) {} },
+};
+`, Options{Key: "meta-loop", Version: "1.0.0", Timeout: 20 * time.Millisecond})
+ require.NoError(t, err)
+
+ _, err = engine.Export(context.Background(), "meta")
+ require.ErrorContains(t, err, "export meta interrupted")
+}
+
+func TestEngineExportReturnsThrownGetterError(t *testing.T) {
+ t.Parallel()
+ engine, err := Compile(`
+export const meta = {
+ apiVersion: 1,
+ get name() { throw new Error("getter failed"); },
+};
+`, Options{Key: "meta-throw", Version: "1.0.0"})
+ require.NoError(t, err)
+
+ _, err = engine.Export(context.Background(), "meta")
+ require.ErrorContains(t, err, "export meta failed")
+ assert.Contains(t, err.Error(), "getter failed")
+}
+
+func TestEngineNestedHooksRequireOwnProperties(t *testing.T) {
+ t.Parallel()
+ engine, err := Compile(`
+const inheritedRenderers = {
+ inherited: function(value) { return value; },
+ constructor: function(value) { return value; },
+ toString: function(value) { return value; },
+ ["__proto__"]: function(value) { return value; },
+};
+export const renderers = Object.create(inheritedRenderers);
+renderers.own = function(value) { return {id: value.id}; };
+
+const inheritedProtocol = {
+ renderFinal: function(value) { return value; },
+};
+export const protocols = {
+ responses: Object.create(inheritedProtocol),
+};
+`, Options{Key: "own-hooks", Version: "1.0.0"})
+ require.NoError(t, err)
+
+ found, err := engine.HasCallablePath(context.Background(), "renderers", "own")
+ require.NoError(t, err)
+ assert.True(t, found)
+ result, err := engine.CallMember(context.Background(), "renderers", "own", map[string]any{"id": "task-1"})
+ require.NoError(t, err)
+ assert.Equal(t, map[string]any{"id": "task-1"}, result)
+
+ for _, member := range []string{"inherited", "constructor", "toString", "__proto__"} {
+ t.Run(member, func(t *testing.T) {
+ found, err := engine.HasCallablePath(context.Background(), "renderers", member)
+ require.NoError(t, err)
+ assert.False(t, found)
+
+ _, err = engine.CallMember(context.Background(), "renderers", member)
+ require.ErrorContains(t, err, "not found")
+ })
+ }
+
+ found, err = engine.HasCallablePath(context.Background(), "protocols", "responses", "renderFinal")
+ require.NoError(t, err)
+ assert.False(t, found)
+ _, err = engine.CallPath(context.Background(), "protocols", []string{"responses", "renderFinal"})
+ require.ErrorContains(t, err, "not found")
+}
+
+func TestCompileInterruptsLongRunningInitialization(t *testing.T) {
+ t.Parallel()
+ _, err := Compile(`while (true) {}; export function run() {}`, Options{
+ Key: "loop", Version: "1", Timeout: 20 * time.Millisecond,
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "initialization timed out")
+}
+
+func TestValidateRequestURL(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ requestURL string
+ baseURL string
+ allowedHosts []string
+ wantError string
+ }{
+ {name: "same host", requestURL: "https://api.example.com/v1/task", baseURL: "https://api.example.com/v1"},
+ {name: "default port", requestURL: "https://api.example.com:443/v1/task", baseURL: "https://api.example.com"},
+ {name: "approved host", requestURL: "https://upload.example.com/task", baseURL: "https://api.example.com", allowedHosts: []string{"upload.example.com"}},
+ {name: "subdomain is not implicit", requestURL: "https://evil.api.example.com/task", baseURL: "https://api.example.com", wantError: "not allowed"},
+ {name: "userinfo trick", requestURL: "https://api.example.com@evil.example/task", baseURL: "https://api.example.com", wantError: "not allowed"},
+ {name: "relative URL", requestURL: "/v1/task", baseURL: "https://api.example.com", wantError: "absolute"},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ err := ValidateRequestURL(test.requestURL, test.baseURL, test.allowedHosts)
+ if test.wantError == "" {
+ require.NoError(t, err)
+ return
+ }
+ require.Error(t, err)
+ assert.True(t, strings.Contains(err.Error(), test.wantError), err.Error())
+ })
+ }
+}
diff --git a/pkg/jsplugin/fixture.go b/pkg/jsplugin/fixture.go
new file mode 100644
index 000000000000..22ea65fd2432
--- /dev/null
+++ b/pkg/jsplugin/fixture.go
@@ -0,0 +1,116 @@
+package jsplugin
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "reflect"
+ "strings"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+)
+
+// Fixture describes deterministic calls into a plugin. Arguments and expected
+// values stay as JSON so fixtures remain portable across engine implementations.
+type Fixture struct {
+ UnixNow *int64 `json:"unixNow"`
+ Cases []FixtureCase `json:"cases"`
+}
+
+type FixtureCase struct {
+ Name string `json:"name"`
+ Hook string `json:"hook"`
+ Member string `json:"member,omitempty"`
+ Path []string `json:"path,omitempty"`
+ Args []json.RawMessage `json:"args"`
+ Expected json.RawMessage `json:"expected"`
+ ExpectedError string `json:"expectedError,omitempty"`
+}
+
+type FixtureReport struct {
+ Total int
+ Passed int
+}
+
+// ReplayFixture compiles a plugin and runs every fixture case in declaration
+// order. unixNow is fixed by the fixture to keep signing and timestamp hooks
+// reproducible.
+func ReplayFixture(ctx context.Context, source string, data []byte) (FixtureReport, error) {
+ var fixture Fixture
+ if err := common.Unmarshal(data, &fixture); err != nil {
+ return FixtureReport{}, fmt.Errorf("decode fixture: %w", err)
+ }
+ if len(fixture.Cases) == 0 {
+ return FixtureReport{}, fmt.Errorf("fixture must contain at least one case")
+ }
+
+ options := Options{Key: "fixture", Version: "fixture"}
+ if fixture.UnixNow != nil {
+ fixed := time.Unix(*fixture.UnixNow, 0)
+ options.Now = func() time.Time { return fixed }
+ }
+ plugin, err := NewRegistry().Register(source, options)
+ if err != nil {
+ return FixtureReport{}, fmt.Errorf("compile plugin: %w", err)
+ }
+
+ report := FixtureReport{Total: len(fixture.Cases)}
+ for index, testCase := range fixture.Cases {
+ caseName := strings.TrimSpace(testCase.Name)
+ if caseName == "" {
+ caseName = fmt.Sprintf("case %d", index+1)
+ }
+ if strings.TrimSpace(testCase.Hook) == "" {
+ return report, fmt.Errorf("%s: hook is required", caseName)
+ }
+
+ args := make([]any, len(testCase.Args))
+ for argumentIndex, raw := range testCase.Args {
+ if err = common.Unmarshal(raw, &args[argumentIndex]); err != nil {
+ return report, fmt.Errorf("%s: decode argument %d: %w", caseName, argumentIndex+1, err)
+ }
+ }
+ var result any
+ if testCase.Member != "" && len(testCase.Path) > 0 {
+ return report, fmt.Errorf("%s: member and path are mutually exclusive", caseName)
+ }
+ if len(testCase.Path) > 0 {
+ result, err = plugin.Engine.CallPath(ctx, testCase.Hook, testCase.Path, args...)
+ } else if testCase.Member == "" {
+ result, err = plugin.Engine.Call(ctx, testCase.Hook, args...)
+ } else {
+ result, err = plugin.Engine.CallMember(ctx, testCase.Hook, testCase.Member, args...)
+ }
+ if testCase.ExpectedError != "" {
+ if err == nil || !strings.Contains(err.Error(), testCase.ExpectedError) {
+ return report, fmt.Errorf("%s: expected error containing %q, got %v", caseName, testCase.ExpectedError, err)
+ }
+ report.Passed++
+ continue
+ }
+ if err != nil {
+ return report, fmt.Errorf("%s: %w", caseName, err)
+ }
+ if len(testCase.Expected) == 0 {
+ return report, fmt.Errorf("%s: expected is required when expectedError is empty", caseName)
+ }
+ var expected any
+ if err = common.Unmarshal(testCase.Expected, &expected); err != nil {
+ return report, fmt.Errorf("%s: decode expected value: %w", caseName, err)
+ }
+ actualData, marshalErr := common.Marshal(result)
+ if marshalErr != nil {
+ return report, fmt.Errorf("%s: encode actual value: %w", caseName, marshalErr)
+ }
+ var actual any
+ if err = common.Unmarshal(actualData, &actual); err != nil {
+ return report, fmt.Errorf("%s: normalize actual value: %w", caseName, err)
+ }
+ if !reflect.DeepEqual(expected, actual) {
+ return report, fmt.Errorf("%s: result mismatch: expected %s, got %s", caseName, testCase.Expected, actualData)
+ }
+ report.Passed++
+ }
+ return report, nil
+}
diff --git a/pkg/jsplugin/fixture_test.go b/pkg/jsplugin/fixture_test.go
new file mode 100644
index 000000000000..f0f2dbb4110c
--- /dev/null
+++ b/pkg/jsplugin/fixture_test.go
@@ -0,0 +1,49 @@
+package jsplugin
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const fixturePlugin = `
+export const meta = { apiVersion: 1, key: "fixture", name: "Fixture", version: "1.0.0", author: {name: "Test"}, channelTypes: [1002], models: ["fixture-model"], fetchMode: "per_task", protocols: [{name: "openai_responses", supports: ["sync", "background"]}] };
+export function buildSubmitRequest(ctx) { return {url: ctx.baseUrl + "/submit"}; }
+export function parseSubmitResponse(ctx, resp) { return {taskId: resp.body.id}; }
+export function buildQueryRequest(ctx) { return {url: ctx.baseUrl + "/task"}; }
+export function parseTaskResult(ctx, body) { return body; }
+export function stamp(value) { return {value: value, now: utils.unixNow()}; }
+export function fail() { throw new Error("fixture failure"); }
+export const native = { compact: function(value) { return {id: value.task_id}; } };
+export const protocols = { openai_responses: {
+ decodeRequest: function(value) { return {kind: "submit", model: value.model}; },
+ renderFinal: function(ctx, task) { return task; }
+} };
+`
+
+func TestReplayFixture(t *testing.T) {
+ t.Parallel()
+ report, err := ReplayFixture(context.Background(), fixturePlugin, []byte(`{
+ "unixNow": 1700000000,
+ "cases": [
+ {"name":"deterministic time","hook":"stamp","args":["ok"],"expected":{"value":"ok","now":1700000000}},
+ {"name":"member call","hook":"native","member":"compact","args":[{"task_id":"task-1"}],"expected":{"id":"task-1"}},
+ {"name":"nested path call","hook":"protocols","path":["openai_responses","decodeRequest"],"args":[{"model":"video-1"}],"expected":{"kind":"submit","model":"video-1"}},
+ {"name":"expected failure","hook":"fail","args":[],"expectedError":"fixture failure"}
+ ]
+}`))
+ require.NoError(t, err)
+ assert.Equal(t, FixtureReport{Total: 4, Passed: 4}, report)
+}
+
+func TestReplayFixtureReportsMismatch(t *testing.T) {
+ t.Parallel()
+ report, err := ReplayFixture(context.Background(), fixturePlugin, []byte(`{
+ "cases": [{"name":"wrong output","hook":"stamp","args":["ok"],"expected":{"value":"different"}}]
+}`))
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "wrong output: result mismatch")
+ assert.Equal(t, FixtureReport{Total: 1}, report)
+}
diff --git a/pkg/jsplugin/protocol_supports_test.go b/pkg/jsplugin/protocol_supports_test.go
new file mode 100644
index 000000000000..a24c7b2f75ac
--- /dev/null
+++ b/pkg/jsplugin/protocol_supports_test.go
@@ -0,0 +1,245 @@
+package jsplugin
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ responsesDecodeOnly = `export const protocols = {openai_responses: {
+ decodeRequest: function(ctx) { return ctx; }
+ }};`
+ responsesDecodeEvents = `export const protocols = {openai_responses: {
+ decodeRequest: function(ctx) { return ctx; },
+ renderEvents: function() { return {events: [], state: null, done: false}; }
+ }};`
+ responsesDecodeFinal = `export const protocols = {openai_responses: {
+ decodeRequest: function(ctx) { return ctx; },
+ renderFinal: function(ctx, task) { return task; }
+ }};`
+ responsesDecodeBoth = `export const protocols = {openai_responses: {
+ decodeRequest: function(ctx) { return ctx; },
+ renderEvents: function() { return {events: [], state: null, done: false}; },
+ renderFinal: function(ctx, task) { return task; }
+ }};`
+ videoProtocolExport = `export const protocols = {openai_video: {
+ decodeRequest: function(ctx) { return ctx; },
+ render: function(ctx, task) { return task; }
+ }};
+ export function listArtifacts() { return []; }
+ export function buildContentRequest() { return {}; }`
+)
+
+func TestProtocolSupportsLoadErrors(t *testing.T) {
+ tests := []struct {
+ name string
+ protocols string
+ exports string
+ err string
+ }{
+ {
+ name: "bare string",
+ protocols: `["openai_responses"]`,
+ exports: responsesDecodeBoth,
+ err: `plugin acme protocol "openai_responses" must declare supports; replace the bare string with {name: "openai_responses", supports: [...]} choosing from "stream", "sync", "background"`,
+ },
+ {
+ name: "object without supports",
+ protocols: `[{name: "openai_responses"}]`,
+ exports: responsesDecodeBoth,
+ err: `plugin acme protocol "openai_responses" must declare supports; add supports: [...] choosing from "stream", "sync", "background"`,
+ },
+ {
+ name: "supports sync but only renderEvents",
+ protocols: `[{name: "openai_responses", supports: ["sync"]}]`,
+ exports: responsesDecodeEvents,
+ err: `plugin acme protocol "openai_responses" supports "sync" but does not export protocols.openai_responses.renderFinal; implement it or declare supports: ["stream"]`,
+ },
+ {
+ name: "supports sync with only decodeRequest",
+ protocols: `[{name: "openai_responses", supports: ["sync"]}]`,
+ exports: responsesDecodeOnly,
+ err: `plugin acme protocol "openai_responses" supports "sync" but does not export protocols.openai_responses.renderFinal; implement it`,
+ },
+ {
+ name: "supports stream but also exports renderFinal",
+ protocols: `[{name: "openai_responses", supports: ["stream"]}]`,
+ exports: responsesDecodeBoth,
+ err: `plugin acme protocol "openai_responses" exports protocols.openai_responses.renderFinal but no supported mode uses it; add "sync" or "background" to supports or remove the hook`,
+ },
+ {
+ name: "supports sync and background but also exports renderEvents",
+ protocols: `[{name: "openai_responses", supports: ["sync", "background"]}]`,
+ exports: responsesDecodeBoth,
+ err: `plugin acme protocol "openai_responses" exports protocols.openai_responses.renderEvents but no supported mode uses it; add "stream" to supports or remove the hook`,
+ },
+ {
+ name: "empty supports",
+ protocols: `[{name: "openai_responses", supports: []}]`,
+ exports: responsesDecodeBoth,
+ err: `plugin acme protocol "openai_responses" supports must contain at least one of "stream", "sync", "background"`,
+ },
+ {
+ name: "duplicate supports",
+ protocols: `[{name: "openai_responses", supports: ["stream", "stream"]}]`,
+ exports: responsesDecodeBoth,
+ err: `plugin acme protocol "openai_responses" supports must be unique`,
+ },
+ {
+ name: "retrieve is not a mode",
+ protocols: `[{name: "openai_responses", supports: ["retrieve"]}]`,
+ exports: responsesDecodeBoth,
+ err: `plugin acme protocol "openai_responses" has no mode "retrieve"; retrieval of a created response is always available and is never declared`,
+ },
+ {
+ name: "openai_video forbids supports",
+ protocols: `[{name: "openai_video", supports: ["stream"]}]`,
+ exports: videoProtocolExport,
+ err: `plugin acme protocol "openai_video" does not define modes; supports is not allowed`,
+ },
+ {
+ name: "unknown protocol forbids supports",
+ protocols: `[{name: "openai_custom", supports: ["stream"]}]`,
+ err: `plugin acme protocol "openai_custom" does not define modes; supports is not allowed`,
+ },
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ _, err := compileProtocolPlugin(t, "acme", `["model"]`, testCase.protocols, testCase.exports)
+ require.ErrorContains(t, err, testCase.err)
+ })
+ }
+}
+
+func TestProtocolSupportsHappyPaths(t *testing.T) {
+ tests := []struct {
+ name string
+ models string
+ protocols string
+ exports string
+ wantProtocols []ProtocolClaim
+ }{
+ {
+ name: "stream only with renderEvents",
+ models: `["model"]`,
+ protocols: `[{name: "openai_responses", supports: ["stream"]}]`,
+ exports: responsesDecodeEvents,
+ wantProtocols: []ProtocolClaim{
+ {Name: "openai_responses", Supports: []string{"stream"}, objectForm: true},
+ },
+ },
+ {
+ name: "sync and background with renderFinal",
+ models: `["model"]`,
+ protocols: `[{name: "openai_responses", supports: ["sync", "background"]}]`,
+ exports: responsesDecodeFinal,
+ wantProtocols: []ProtocolClaim{
+ {Name: "openai_responses", Supports: []string{"sync", "background"}, objectForm: true},
+ },
+ },
+ {
+ name: "all modes normalize to table order",
+ models: `["model"]`,
+ protocols: `[{name: "openai_responses", supports: ["background", "stream", "sync"]}]`,
+ exports: responsesDecodeBoth,
+ wantProtocols: []ProtocolClaim{
+ {Name: "openai_responses", Supports: []string{"stream", "sync", "background"}, objectForm: true},
+ },
+ },
+ {
+ name: "openai_video object without supports",
+ models: `["gpt-5.5", "gpt-5.6"]`,
+ protocols: `[{name: "openai_video", models: ["gpt-5.5"]}]`,
+ exports: videoProtocolExport,
+ wantProtocols: []ProtocolClaim{
+ {Name: "openai_video", Models: []string{"gpt-5.5"}, objectForm: true},
+ },
+ },
+ {
+ name: "bare openai_video",
+ models: `["model"]`,
+ protocols: `["openai_video"]`,
+ exports: videoProtocolExport,
+ wantProtocols: []ProtocolClaim{
+ {Name: "openai_video"},
+ },
+ },
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ plugin, err := compileProtocolPlugin(t, "acme", testCase.models, testCase.protocols, testCase.exports)
+ require.NoError(t, err)
+ require.Equal(t, testCase.wantProtocols, plugin.Meta.Protocols)
+ })
+ }
+}
+
+func TestCloneMetaDeepCopiesSupports(t *testing.T) {
+ registry := NewRegistry()
+ _, err := registry.Register(protocolPluginSource(
+ "acme",
+ `["model"]`,
+ `[{name: "openai_responses", supports: ["stream", "sync", "background"]}]`,
+ responsesDecodeBoth,
+ ), Options{})
+ require.NoError(t, err)
+
+ snapshot := registry.Snapshot()
+ require.Len(t, snapshot.Override, 1)
+ require.Len(t, snapshot.Override[0].Protocols, 1)
+ snapshot.Override[0].Protocols[0].Supports[0] = "mutated"
+
+ plugin, ok := registry.Get("acme")
+ require.True(t, ok)
+ assert.Equal(t, []string{"stream", "sync", "background"}, plugin.Meta.Protocols[0].Supports)
+}
+
+func TestMetaProtocolSupports(t *testing.T) {
+ streamOnly, err := compileProtocolPlugin(t, "acme", `["model"]`,
+ `[{name: "openai_responses", supports: ["stream"]}]`, responsesDecodeEvents)
+ require.NoError(t, err)
+ assert.True(t, streamOnly.Meta.ProtocolSupports("openai_responses", "stream"))
+ assert.False(t, streamOnly.Meta.ProtocolSupports("openai_responses", "sync"))
+ assert.False(t, streamOnly.Meta.ProtocolSupports("openai_responses", "background"))
+ assert.False(t, streamOnly.Meta.ProtocolSupports("openai_responses", "retrieve"))
+ assert.False(t, streamOnly.Meta.ProtocolSupports("openai_video", "stream"))
+ assert.False(t, streamOnly.Meta.ProtocolSupports("missing", "stream"))
+
+ video, err := compileProtocolPlugin(t, "acme-video", `["model"]`, `["openai_video"]`, videoProtocolExport)
+ require.NoError(t, err)
+ assert.False(t, video.Meta.ProtocolSupports("openai_video", "stream"))
+}
+
+func TestProtocolClaimMarshalEmitsSupportsInTableOrder(t *testing.T) {
+ plugin, err := compileProtocolPlugin(t, "acme", `["model"]`,
+ `[{name: "openai_responses", supports: ["background", "sync", "stream"]}]`, responsesDecodeBoth)
+ require.NoError(t, err)
+ encoded, err := common.Marshal(plugin.Meta.Protocols[0])
+ require.NoError(t, err)
+ assert.Equal(t, `{"name":"openai_responses","supports":["stream","sync","background"]}`, string(encoded))
+}
+
+func compileProtocolPlugin(t *testing.T, key, models, protocols, exports string) (*LoadedPlugin, error) {
+ t.Helper()
+ return CompilePlugin(protocolPluginSource(key, models, protocols, exports), Options{})
+}
+
+func protocolPluginSource(key, models, protocols, exports string) string {
+ return fmt.Sprintf(`
+export const meta = {
+ apiVersion: 1, key: %q, name: %q, version: "1.0.0",
+ author: {name: "Test"},
+ models: %s, fetchMode: "per_task",
+ protocols: %s,
+};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+%s
+`, key, key, models, protocols, exports)
+}
diff --git a/pkg/jsplugin/registry.go b/pkg/jsplugin/registry.go
new file mode 100644
index 000000000000..e16b7041b422
--- /dev/null
+++ b/pkg/jsplugin/registry.go
@@ -0,0 +1,1765 @@
+package jsplugin
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "maps"
+ "math"
+ "net/url"
+ "regexp"
+ "slices"
+ "sort"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+ "unicode"
+ "unicode/utf8"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/logger"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/relaykit/dto"
+)
+
+const APIVersion1 = 1
+
+const (
+ maxLocalizedTextLocales = 16
+ maxMetaDescriptionRunes = 512
+ maxUsageFieldDescriptionRunes = 256
+)
+
+var pluginKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
+var pluginVersionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`)
+var localeTagPattern = regexp.MustCompile(`^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$`)
+
+// LocalizedText is locale-keyed display copy. Plugin source may use a bare
+// string (normalized to {"en": s}) or a map that must include "en". API
+// responses always emit an object.
+type LocalizedText map[string]string
+
+func (t LocalizedText) MarshalJSON() ([]byte, error) {
+ if t == nil {
+ return common.Marshal(map[string]string{})
+ }
+ return common.Marshal(map[string]string(t))
+}
+
+func (t *LocalizedText) UnmarshalJSON(data []byte) error {
+ trimmed := strings.TrimSpace(string(data))
+ if trimmed == "" || trimmed == "null" {
+ *t = nil
+ return nil
+ }
+ switch trimmed[0] {
+ case '"':
+ var text string
+ if err := common.Unmarshal(data, &text); err != nil {
+ return err
+ }
+ *t = LocalizedText{"en": text}
+ return nil
+ case '{':
+ var object map[string]string
+ if err := common.Unmarshal(data, &object); err != nil {
+ return err
+ }
+ *t = LocalizedText(object)
+ return nil
+ default:
+ return fmt.Errorf("localized text must be a string or object")
+ }
+}
+
+type Meta struct {
+ APIVersion int `json:"apiVersion"`
+ Key string `json:"key"`
+ Name string `json:"name"`
+ Icon string `json:"icon,omitempty"`
+ Description LocalizedText `json:"description,omitempty"`
+ Version string `json:"version"`
+ Author AuthorMeta `json:"author"`
+ ChannelTypes []int `json:"channelTypes,omitempty"`
+ Models []string `json:"models"`
+ FetchMode string `json:"fetchMode"`
+ AllowedHosts []string `json:"allowedHosts"`
+ Routes []Route `json:"routes"`
+ Protocols []ProtocolClaim `json:"protocols"`
+ UsageSchema map[string]UsageFieldSchema `json:"usageSchema,omitempty"`
+ UsageExamples []UsageExample `json:"usageExamples,omitempty"`
+ Auth AuthMeta `json:"auth"`
+}
+
+// ProtocolSupports reports whether the named protocol claim includes mode.
+func (m Meta) ProtocolSupports(protocol, mode string) bool {
+ for _, claim := range m.Protocols {
+ if claim.Name == protocol {
+ return slices.Contains(claim.Supports, mode)
+ }
+ }
+ return false
+}
+
+// UsageExample is a display-only pricing sample: a labeled complete vector
+// over usageSchema. It never participates in billing.
+type UsageExample struct {
+ Label string `json:"label"`
+ Facts map[string]any `json:"facts"`
+}
+
+type AuthorMeta struct {
+ Name string `json:"name"`
+ URL string `json:"url,omitempty"`
+}
+
+type AuthMeta struct {
+ Type string `json:"type"`
+}
+
+// UsageFieldSchema declares how one usage fact is validated before it can
+// influence billing. Numeric facts use one of the host-owned canonical units;
+// boolean facts are flags; enum facts constrain non-numeric pricing selectors.
+type UsageFieldSchema struct {
+ Type string `json:"type,omitempty"`
+ Unit string `json:"unit,omitempty"`
+ Enum []string `json:"enum,omitempty"`
+ Description LocalizedText `json:"description,omitempty"`
+}
+
+type LoadedPlugin struct {
+ Meta Meta
+ Engine *Engine
+}
+
+// RegistrySnapshot is a read-only copy of the metadata currently stored in
+// each registry layer.
+type RegistrySnapshot struct {
+ Factory []Meta
+ Override []Meta
+ DisabledFactory []string
+}
+
+type PreparedRoutingGeneration struct {
+ Generation *RoutingGeneration
+ Errors map[string]string
+}
+
+type RoutingRebuildOutcome struct {
+ Status string `json:"status"`
+ AttemptedAt time.Time `json:"attempted_at"`
+ Generation uint64 `json:"generation"`
+ Error string `json:"error,omitempty"`
+}
+
+type RoutingStatus struct {
+ Generation *RoutingGeneration
+ LastRebuild RoutingRebuildOutcome
+ Errors map[string]string
+}
+
+type RoutingGenerationPreparer func(candidate, current *RoutingGeneration) (PreparedRoutingGeneration, error)
+
+type Registry struct {
+ mu sync.RWMutex
+ factory map[string]*LoadedPlugin
+ override map[string]*LoadedPlugin
+ activeOverride map[string]*LoadedPlugin
+ disabledFactory map[string]struct{}
+ masterEnabled atomic.Bool
+ overrideEnabled atomic.Bool
+ generation atomic.Pointer[RoutingGeneration]
+ preparer RoutingGenerationPreparer
+ routingErrors map[string]string
+ lastRebuildErr string
+ lastRebuild RoutingRebuildOutcome
+}
+
+func NewRegistry() *Registry {
+ registry := &Registry{
+ factory: make(map[string]*LoadedPlugin),
+ override: make(map[string]*LoadedPlugin),
+ activeOverride: make(map[string]*LoadedPlugin),
+ routingErrors: make(map[string]string),
+ }
+ registry.masterEnabled.Store(true)
+ registry.overrideEnabled.Store(true)
+ generation, _ := buildRoutingGeneration(registry.factory, registry.override, true, 0)
+ registry.generation.Store(generation)
+ registry.lastRebuild = RoutingRebuildOutcome{
+ Status: "success",
+ AttemptedAt: generation.PublishedAt,
+ Generation: generation.Number,
+ }
+ return registry
+}
+
+var DefaultRegistry = NewRegistry()
+
+func (r *Registry) Register(source string, options Options) (*LoadedPlugin, error) {
+ return r.register(source, options, false)
+}
+
+func (r *Registry) RegisterFactory(source string, options Options) (*LoadedPlugin, error) {
+ return r.register(source, options, true)
+}
+
+func (r *Registry) register(source string, options Options, factory bool) (*LoadedPlugin, error) {
+ plugin, err := CompilePlugin(source, options)
+ if err != nil {
+ return nil, err
+ }
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ factoryPlugins := clonePluginMap(r.factory)
+ overridePlugins := clonePluginMap(r.override)
+ if factory {
+ factoryPlugins[plugin.Meta.Key] = plugin
+ } else {
+ overridePlugins[plugin.Meta.Key] = plugin
+ }
+ enabled := r.overrideEnabled.Load()
+ generation, routingErrors, err := r.prepareGeneration(filterDisabledFactory(factoryPlugins, r.disabledFactory), overridePlugins, enabled, false, nil)
+ if err != nil {
+ r.recordRebuildFailure(err)
+ return nil, err
+ }
+ if rejection := routingErrors[plugin.Meta.Key]; rejection != "" {
+ r.recordRebuildFailure(errors.New(rejection))
+ return nil, fmt.Errorf("%s", rejection)
+ }
+ r.factory = factoryPlugins
+ r.override = overridePlugins
+ r.publishGeneration(generation, routingErrors, r.resolveActiveOverrides(generation, overridePlugins, enabled))
+ return plugin, nil
+}
+
+// CompilePlugin validates a plugin without publishing it. Callers that refresh
+// multiple plugins use this together with ReplaceOverrides so readers observe a
+// single generation transition.
+func CompilePlugin(source string, options Options) (*LoadedPlugin, error) {
+ engine, err := Compile(source, options)
+ if err != nil {
+ return nil, err
+ }
+ value, err := engine.Export(context.Background(), "meta")
+ if err != nil {
+ return nil, err
+ }
+ meta, err := decodeMeta(value)
+ if err != nil {
+ return nil, err
+ }
+ if err = normalizeV1Meta(&meta); err != nil {
+ return nil, err
+ }
+ engine.key = meta.Key
+ engine.version = meta.Version
+ requiredHooks := []string{"buildSubmitRequest", "parseSubmitResponse", "parseTaskResult"}
+ if meta.FetchMode == "batch" {
+ requiredHooks = append(requiredHooks, "buildBatchQueryRequest", "parseBatchResult")
+ } else {
+ requiredHooks = append(requiredHooks, "buildQueryRequest")
+ }
+ for _, hook := range requiredHooks {
+ has, hasErr := engine.HasCallablePath(context.Background(), hook)
+ if hasErr != nil {
+ return nil, hasErr
+ }
+ if !has {
+ return nil, fmt.Errorf("plugin %s is missing required export %q", meta.Key, hook)
+ }
+ }
+ artifactHooks := make(map[string]bool, 2)
+ for _, hook := range []string{"listArtifacts", "buildContentRequest"} {
+ exported, exportErr := engine.HasExport(context.Background(), hook)
+ if exportErr != nil {
+ return nil, exportErr
+ }
+ if !exported {
+ continue
+ }
+ callable, callableErr := engine.HasCallablePath(context.Background(), hook)
+ if callableErr != nil {
+ return nil, callableErr
+ }
+ if !callable {
+ return nil, fmt.Errorf("plugin %s export %q is not a function", meta.Key, hook)
+ }
+ artifactHooks[hook] = true
+ }
+ if artifactHooks["listArtifacts"] != artifactHooks["buildContentRequest"] {
+ return nil, fmt.Errorf("plugin %s must export listArtifacts and buildContentRequest together", meta.Key)
+ }
+ for _, route := range meta.Routes {
+ for kind, member := range map[string]string{"decode": route.Decode, "render": route.Render} {
+ if member == "" {
+ continue
+ }
+ has, hasErr := engine.HasCallablePath(context.Background(), "native", member)
+ if hasErr != nil {
+ return nil, hasErr
+ }
+ if !has {
+ return nil, fmt.Errorf("plugin %s route %s %s references missing native %s %q", meta.Key, route.Method, route.Path, kind, member)
+ }
+ }
+ }
+ for _, claim := range meta.Protocols {
+ protocol := claim.Name
+ definition, _ := HostProtocol(protocol)
+ required := make(map[string]struct{})
+ allowed := make(map[string]struct{})
+ modeHookUsers := make(map[string][]string)
+ for _, operation := range definition.Operations {
+ for _, hook := range operation.RequiredProtocolMembers {
+ required[hook] = struct{}{}
+ allowed[hook] = struct{}{}
+ }
+ for _, mode := range operation.Modes {
+ allowed[mode.Hook] = struct{}{}
+ if !slices.Contains(modeHookUsers[mode.Hook], mode.Name) {
+ modeHookUsers[mode.Hook] = append(modeHookUsers[mode.Hook], mode.Name)
+ }
+ if slices.Contains(claim.Supports, mode.Name) {
+ required[mode.Hook] = struct{}{}
+ }
+ }
+ for _, hook := range operation.RequiredDriverHooks {
+ has, hasErr := engine.HasCallablePath(context.Background(), hook)
+ if hasErr != nil {
+ return nil, hasErr
+ }
+ if !has {
+ return nil, fmt.Errorf("plugin %s protocol %q is missing driver hook %q", meta.Key, protocol, hook)
+ }
+ }
+ }
+ requiredHooks := make([]string, 0, len(required))
+ for hook := range required {
+ requiredHooks = append(requiredHooks, hook)
+ }
+ sort.Strings(requiredHooks)
+ for _, hook := range requiredHooks {
+ has, hasErr := engine.HasCallablePath(context.Background(), "protocols", protocol, hook)
+ if hasErr != nil {
+ return nil, hasErr
+ }
+ if !has {
+ if users := modeHookUsers[hook]; len(users) > 0 {
+ mentioned := ""
+ for _, name := range claim.Supports {
+ if slices.Contains(users, name) {
+ mentioned = name
+ break
+ }
+ }
+ suggested := make([]string, 0)
+ for _, mode := range definition.DefinedModes() {
+ exported, exportedErr := engine.HasCallablePath(context.Background(), "protocols", protocol, mode.Hook)
+ if exportedErr != nil {
+ return nil, exportedErr
+ }
+ if exported && !slices.Contains(suggested, mode.Name) {
+ suggested = append(suggested, mode.Name)
+ }
+ }
+ message := fmt.Sprintf("plugin %s protocol %q supports %q but does not export protocols.%s.%s; implement it", meta.Key, protocol, mentioned, protocol, hook)
+ if len(suggested) > 0 {
+ message += fmt.Sprintf(" or declare supports: [%s]", quotedJoin(suggested, ", "))
+ }
+ return nil, errors.New(message)
+ }
+ return nil, fmt.Errorf("plugin %s protocol %q is missing hook %q", meta.Key, protocol, hook)
+ }
+ }
+ seenModeHook := make(map[string]struct{})
+ for _, operation := range definition.Operations {
+ for _, mode := range operation.Modes {
+ if _, seen := seenModeHook[mode.Hook]; seen {
+ continue
+ }
+ seenModeHook[mode.Hook] = struct{}{}
+ if _, need := required[mode.Hook]; need {
+ continue
+ }
+ has, hasErr := engine.HasCallablePath(context.Background(), "protocols", protocol, mode.Hook)
+ if hasErr != nil {
+ return nil, hasErr
+ }
+ if has {
+ return nil, fmt.Errorf("plugin %s protocol %q exports protocols.%s.%s but no supported mode uses it; add %s to supports or remove the hook", meta.Key, protocol, protocol, mode.Hook, quotedJoin(modeHookUsers[mode.Hook], " or "))
+ }
+ }
+ }
+ protocolValue, exportErr := engine.Export(context.Background(), "protocols")
+ if exportErr != nil {
+ return nil, exportErr
+ }
+ protocolObject, ok := protocolValue.(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("plugin %s export protocols must be an object", meta.Key)
+ }
+ implementation, ok := protocolObject[protocol].(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("plugin %s protocol %q must be an object", meta.Key, protocol)
+ }
+ for member := range implementation {
+ if _, accepted := allowed[member]; !accepted {
+ return nil, fmt.Errorf("plugin %s protocol %q has unsupported member %q", meta.Key, protocol, member)
+ }
+ }
+ }
+ if protocolsValue, exportErr := engine.Export(context.Background(), "protocols"); exportErr == nil {
+ if protocolsObject, ok := protocolsValue.(map[string]any); ok {
+ claimed := make(map[string]struct{}, len(meta.Protocols))
+ for _, claim := range meta.Protocols {
+ claimed[claim.Name] = struct{}{}
+ }
+ for name := range protocolsObject {
+ if _, ok := claimed[name]; !ok {
+ return nil, fmt.Errorf("plugin %s implements unclaimed protocol %q", meta.Key, name)
+ }
+ }
+ }
+ }
+ for _, removed := range []string{"resolveRequest", "renderError", "renderers"} {
+ has, e := engine.HasExport(context.Background(), removed)
+ if e != nil {
+ return nil, e
+ }
+ if has {
+ return nil, fmt.Errorf("plugin %s export %q is no longer supported", meta.Key, removed)
+ }
+ }
+ return &LoadedPlugin{Meta: meta, Engine: engine}, nil
+}
+
+func (r *Registry) Get(platform string) (*LoadedPlugin, bool) {
+ return r.Generation().Get(platform)
+}
+
+func (r *Registry) GetByChannelType(channelType int) (*LoadedPlugin, bool) {
+ return r.Generation().GetByChannelType(channelType)
+}
+
+// Enabled reports the master switch position. When false the published
+// routing generation contains no plugins regardless of the other layers.
+func (r *Registry) Enabled() bool {
+ return r.masterEnabled.Load()
+}
+
+func (r *Registry) SetEnabled(enabled bool) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.masterEnabled.Load() == enabled {
+ return
+ }
+ previous := r.masterEnabled.Load()
+ r.masterEnabled.Store(enabled)
+ overrideEnabled := r.overrideEnabled.Load()
+ var retainCurrent map[string]struct{}
+ if enabled && overrideEnabled {
+ retainCurrent = pluginMapKeys(r.override)
+ }
+ generation, routingErrors, err := r.prepareGeneration(filterDisabledFactory(r.factory, r.disabledFactory), r.override, overrideEnabled, true, retainCurrent)
+ if err != nil {
+ r.masterEnabled.Store(previous)
+ r.recordRebuildFailure(err)
+ return
+ }
+ r.publishGeneration(generation, routingErrors, r.resolveActiveOverrides(generation, r.override, overrideEnabled))
+}
+
+func (r *Registry) SetOverrideEnabled(enabled bool) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.overrideEnabled.Load() == enabled {
+ return
+ }
+ var retainCurrent map[string]struct{}
+ if enabled {
+ retainCurrent = pluginMapKeys(r.override)
+ }
+ generation, routingErrors, err := r.prepareGeneration(filterDisabledFactory(r.factory, r.disabledFactory), r.override, enabled, true, retainCurrent)
+ if err != nil {
+ r.recordRebuildFailure(err)
+ return
+ }
+ r.overrideEnabled.Store(enabled)
+ r.publishGeneration(generation, routingErrors, r.resolveActiveOverrides(generation, r.override, enabled))
+}
+
+func (r *Registry) SetDisabledFactoryKeys(keys []string) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ next := make(map[string]struct{}, len(keys))
+ for _, key := range keys {
+ key = strings.TrimSpace(key)
+ if key == "" {
+ continue
+ }
+ next[key] = struct{}{}
+ }
+ if len(next) == len(r.disabledFactory) {
+ same := true
+ for key := range next {
+ if _, ok := r.disabledFactory[key]; !ok {
+ same = false
+ break
+ }
+ }
+ if same {
+ return
+ }
+ }
+
+ enabled := r.overrideEnabled.Load()
+ var retainCurrent map[string]struct{}
+ if enabled {
+ retainCurrent = pluginMapKeys(r.override)
+ }
+ generation, routingErrors, err := r.prepareGeneration(filterDisabledFactory(r.factory, next), r.override, enabled, true, retainCurrent)
+ if err != nil {
+ r.recordRebuildFailure(err)
+ return
+ }
+ r.disabledFactory = next
+ r.publishGeneration(generation, routingErrors, r.resolveActiveOverrides(generation, r.override, enabled))
+}
+
+func (r *Registry) Unregister(key string) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if _, exists := r.override[key]; !exists {
+ return nil
+ }
+ overridePlugins := clonePluginMap(r.override)
+ delete(overridePlugins, key)
+ enabled := r.overrideEnabled.Load()
+ var retainCurrent map[string]struct{}
+ if enabled {
+ retainCurrent = pluginMapKeys(overridePlugins)
+ }
+ generation, routingErrors, err := r.prepareGeneration(filterDisabledFactory(r.factory, r.disabledFactory), overridePlugins, enabled, true, retainCurrent)
+ if err != nil {
+ r.recordRebuildFailure(err)
+ return err
+ }
+ r.override = overridePlugins
+ r.publishGeneration(generation, routingErrors, r.resolveActiveOverrides(generation, overridePlugins, enabled))
+ return nil
+}
+
+// ReplaceOverrides atomically publishes a complete override layer.
+func (r *Registry) ReplaceOverrides(plugins []*LoadedPlugin) error {
+ overridePlugins := make(map[string]*LoadedPlugin, len(plugins))
+ for _, plugin := range plugins {
+ if plugin == nil {
+ return fmt.Errorf("cannot publish a nil plugin")
+ }
+ if _, exists := overridePlugins[plugin.Meta.Key]; exists {
+ return fmt.Errorf("duplicate override plugin key %q", plugin.Meta.Key)
+ }
+ overridePlugins[plugin.Meta.Key] = plugin
+ }
+
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if samePluginMap(r.override, overridePlugins) {
+ return nil
+ }
+ enabled := r.overrideEnabled.Load()
+ var retainCurrent map[string]struct{}
+ if enabled {
+ retainCurrent = pluginMapKeys(overridePlugins)
+ }
+ generation, routingErrors, err := r.prepareGeneration(filterDisabledFactory(r.factory, r.disabledFactory), overridePlugins, enabled, true, retainCurrent)
+ if err != nil {
+ r.recordRebuildFailure(err)
+ return err
+ }
+ r.override = overridePlugins
+ r.publishGeneration(generation, routingErrors, r.resolveActiveOverrides(generation, overridePlugins, enabled))
+ return nil
+}
+
+func (r *Registry) Generation() *RoutingGeneration {
+ return r.generation.Load()
+}
+
+func (r *Registry) OverridePlugins() map[string]*LoadedPlugin {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ return clonePluginMap(r.override)
+}
+
+func (r *Registry) ActiveOverridePlugins() map[string]*LoadedPlugin {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ return clonePluginMap(r.activeOverride)
+}
+
+func (r *Registry) SetGenerationPreparer(preparer RoutingGenerationPreparer) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ previous := r.preparer
+ r.preparer = preparer
+ enabled := r.overrideEnabled.Load()
+ var retainCurrent map[string]struct{}
+ if enabled {
+ retainCurrent = pluginMapKeys(r.override)
+ }
+ generation, routingErrors, err := r.prepareGeneration(filterDisabledFactory(r.factory, r.disabledFactory), r.override, enabled, true, retainCurrent)
+ if err != nil {
+ r.preparer = previous
+ r.recordRebuildFailure(err)
+ return err
+ }
+ r.publishGeneration(generation, routingErrors, r.resolveActiveOverrides(generation, r.override, enabled))
+ return nil
+}
+
+func (r *Registry) RoutingErrors() map[string]string {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ errorsCopy := make(map[string]string, len(r.routingErrors))
+ maps.Copy(errorsCopy, r.routingErrors)
+ return errorsCopy
+}
+
+func (r *Registry) LastRebuildError() string {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ return r.lastRebuildErr
+}
+
+func (r *Registry) LastRebuildOutcome() RoutingRebuildOutcome {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ return r.lastRebuild
+}
+
+func (r *Registry) RoutingStatus() RoutingStatus {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ errorsCopy := make(map[string]string, len(r.routingErrors))
+ maps.Copy(errorsCopy, r.routingErrors)
+ return RoutingStatus{
+ Generation: r.generation.Load(),
+ LastRebuild: r.lastRebuild,
+ Errors: errorsCopy,
+ }
+}
+
+func (r *Registry) prepareGeneration(
+ factory, override map[string]*LoadedPlugin,
+ enabled, tolerateConflicts bool,
+ retainCurrent map[string]struct{},
+) (*RoutingGeneration, map[string]string, error) {
+ if !r.masterEnabled.Load() {
+ factory = map[string]*LoadedPlugin{}
+ override = map[string]*LoadedPlugin{}
+ }
+ current := r.generation.Load()
+ number := uint64(1)
+ if current != nil {
+ number = current.Number + 1
+ }
+ var (
+ generation *RoutingGeneration
+ routingErrors map[string]string
+ err error
+ )
+ if tolerateConflicts {
+ generation, routingErrors, err = buildRoutingGenerationAdmitting(factory, override, enabled, number, current, retainCurrent)
+ } else {
+ generation, err = buildRoutingGeneration(factory, override, enabled, number)
+ routingErrors = make(map[string]string)
+ }
+ if err != nil {
+ return nil, nil, err
+ }
+ if !tolerateConflicts {
+ // Both runtime switch positions must remain publishable so toggling the
+ // override layer never exposes an invalid generation.
+ if _, err = buildRoutingGeneration(factory, override, !enabled, number); err != nil {
+ return nil, nil, err
+ }
+ }
+ if r.preparer != nil {
+ prepared, prepareErr := r.preparer(generation, current)
+ if prepareErr != nil {
+ return nil, nil, prepareErr
+ }
+ if prepared.Generation == nil {
+ return nil, nil, fmt.Errorf("routing generation preparer returned a nil generation")
+ }
+ if prepared.Generation.Number != generation.Number {
+ return nil, nil, fmt.Errorf("routing generation preparer changed generation number from %d to %d", generation.Number, prepared.Generation.Number)
+ }
+ generation = prepared.Generation
+ maps.Copy(routingErrors, prepared.Errors)
+ }
+ return generation, routingErrors, nil
+}
+
+func (r *Registry) publishGeneration(
+ generation *RoutingGeneration,
+ routingErrors map[string]string,
+ activeOverride map[string]*LoadedPlugin,
+) {
+ previous := r.generation.Load()
+ var previousNumber uint64
+ if previous != nil {
+ previousNumber = previous.Number
+ }
+ r.routingErrors = routingErrors
+ r.activeOverride = activeOverride
+ r.lastRebuildErr = ""
+ status := "success"
+ if len(routingErrors) > 0 {
+ status = "partial"
+ }
+ r.lastRebuild = RoutingRebuildOutcome{
+ Status: status,
+ AttemptedAt: time.Now(),
+ Generation: generation.Number,
+ }
+ r.generation.Store(generation)
+ logger.LogDebug(
+ context.Background(),
+ "task_plugin subsystem=registry event=publish previous_generation=%d generation=%d status=%q plugins=%d routes=%d endpoint_bindings=%d channel_types=%d active_overrides=%d rejected=%d",
+ previousNumber,
+ generation.Number,
+ status,
+ len(generation.plugins),
+ len(generation.routes),
+ len(generation.protocolIndex),
+ len(generation.byChannelType),
+ len(activeOverride),
+ len(routingErrors),
+ )
+ if len(routingErrors) > 0 {
+ keys := make([]string, 0, len(routingErrors))
+ for key := range routingErrors {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ for _, key := range keys {
+ logger.LogDebug(
+ context.Background(),
+ "task_plugin subsystem=registry event=plugin_rejected generation=%d plugin=%q reason=generation_admission_failed admission_reason=%q",
+ generation.Number,
+ key,
+ taskPluginRoutingDebugReason(routingErrors[key]),
+ )
+ }
+ }
+}
+
+func (r *Registry) recordRebuildFailure(err error) {
+ r.lastRebuildErr = err.Error()
+ generation := r.generation.Load()
+ var generationNumber uint64
+ if generation != nil {
+ generationNumber = generation.Number
+ }
+ r.lastRebuild = RoutingRebuildOutcome{
+ Status: "failed",
+ AttemptedAt: time.Now(),
+ Generation: generationNumber,
+ Error: err.Error(),
+ }
+ logger.LogDebug(
+ context.Background(),
+ "task_plugin subsystem=registry event=publish_failed retained_generation=%d retained_generation_active=true reason=%q",
+ generationNumber,
+ taskPluginRoutingDebugReason(err.Error()),
+ )
+}
+
+func taskPluginRoutingDebugReason(message string) string {
+ lower := strings.ToLower(message)
+ switch {
+ case strings.Contains(lower, "channeltype"), strings.Contains(lower, "channel type"):
+ return "channel_type_conflict"
+ case strings.Contains(lower, "endpoint"):
+ return "endpoint_conflict"
+ case strings.Contains(lower, "inner gin"), strings.Contains(lower, "rebuilding public routes"):
+ return "inner_router_build_failed"
+ case strings.Contains(lower, "trusted prox"):
+ return "trusted_proxy_configuration_failed"
+ case strings.Contains(lower, "route"):
+ return "route_conflict"
+ case strings.Contains(lower, "nil generation"), strings.Contains(lower, "generation number"):
+ return "invalid_prepared_generation"
+ default:
+ return "generation_rebuild_failed"
+ }
+}
+
+func (r *Registry) Snapshot() RegistrySnapshot {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ snapshot := RegistrySnapshot{
+ Factory: make([]Meta, 0, len(r.factory)),
+ Override: make([]Meta, 0, len(r.override)),
+ DisabledFactory: make([]string, 0, len(r.disabledFactory)),
+ }
+ for _, plugin := range r.factory {
+ snapshot.Factory = append(snapshot.Factory, cloneMeta(plugin.Meta))
+ }
+ for _, plugin := range r.override {
+ snapshot.Override = append(snapshot.Override, cloneMeta(plugin.Meta))
+ }
+ for key := range r.disabledFactory {
+ snapshot.DisabledFactory = append(snapshot.DisabledFactory, key)
+ }
+ sort.Slice(snapshot.Factory, func(i, j int) bool { return snapshot.Factory[i].Key < snapshot.Factory[j].Key })
+ sort.Slice(snapshot.Override, func(i, j int) bool { return snapshot.Override[i].Key < snapshot.Override[j].Key })
+ sort.Strings(snapshot.DisabledFactory)
+ return snapshot
+}
+
+func cloneMeta(meta Meta) Meta {
+ meta.ChannelTypes = append([]int(nil), meta.ChannelTypes...)
+ meta.Models = append([]string(nil), meta.Models...)
+ meta.AllowedHosts = append([]string(nil), meta.AllowedHosts...)
+ meta.Routes = append([]Route(nil), meta.Routes...)
+ for index := range meta.Routes {
+ meta.Routes[index].Models = append([]string(nil), meta.Routes[index].Models...)
+ }
+ meta.Protocols = append([]ProtocolClaim(nil), meta.Protocols...)
+ for index := range meta.Protocols {
+ meta.Protocols[index].Models = append([]string(nil), meta.Protocols[index].Models...)
+ meta.Protocols[index].Supports = append([]string(nil), meta.Protocols[index].Supports...)
+ }
+ if meta.Description != nil {
+ meta.Description = maps.Clone(meta.Description)
+ }
+ if meta.UsageSchema != nil {
+ usageSchema := make(map[string]UsageFieldSchema, len(meta.UsageSchema))
+ for key, field := range meta.UsageSchema {
+ if field.Enum != nil {
+ field.Enum = append([]string{}, field.Enum...)
+ }
+ if field.Description != nil {
+ field.Description = maps.Clone(field.Description)
+ }
+ usageSchema[key] = field
+ }
+ meta.UsageSchema = usageSchema
+ }
+ meta.UsageExamples = cloneUsageExamples(meta.UsageExamples)
+ return meta
+}
+
+func cloneUsageExamples(examples []UsageExample) []UsageExample {
+ if examples == nil {
+ return nil
+ }
+ cloned := make([]UsageExample, len(examples))
+ for index, example := range examples {
+ cloned[index] = UsageExample{Label: example.Label}
+ if example.Facts == nil {
+ continue
+ }
+ facts := make(map[string]any, len(example.Facts))
+ maps.Copy(facts, example.Facts)
+ cloned[index].Facts = facts
+ }
+ return cloned
+}
+
+func filterDisabledFactory(factory map[string]*LoadedPlugin, disabled map[string]struct{}) map[string]*LoadedPlugin {
+ if len(disabled) == 0 {
+ return factory
+ }
+ filtered := make(map[string]*LoadedPlugin, len(factory))
+ for key, plugin := range factory {
+ if _, skip := disabled[key]; skip {
+ continue
+ }
+ filtered[key] = plugin
+ }
+ return filtered
+}
+
+func clonePluginMap(source map[string]*LoadedPlugin) map[string]*LoadedPlugin {
+ clone := make(map[string]*LoadedPlugin, len(source))
+ maps.Copy(clone, source)
+ return clone
+}
+
+func samePluginMap(left, right map[string]*LoadedPlugin) bool {
+ if len(left) != len(right) {
+ return false
+ }
+ for key, plugin := range left {
+ if right[key] != plugin {
+ return false
+ }
+ }
+ return true
+}
+
+func pluginMapKeys(plugins map[string]*LoadedPlugin) map[string]struct{} {
+ keys := make(map[string]struct{}, len(plugins))
+ for key := range plugins {
+ keys[key] = struct{}{}
+ }
+ return keys
+}
+
+func (r *Registry) resolveActiveOverrides(
+ generation *RoutingGeneration,
+ override map[string]*LoadedPlugin,
+ enabled bool,
+) map[string]*LoadedPlugin {
+ active := make(map[string]*LoadedPlugin)
+ if !enabled {
+ return active
+ }
+ for _, plugin := range generation.plugins {
+ desired, hasOverride := override[plugin.Meta.Key]
+ if !hasOverride {
+ continue
+ }
+ if plugin == desired || plugin == r.activeOverride[plugin.Meta.Key] {
+ active[plugin.Meta.Key] = plugin
+ }
+ }
+ return active
+}
+
+func decodeMeta(value any) (Meta, error) {
+ object, ok := value.(map[string]any)
+ if !ok {
+ return Meta{}, fmt.Errorf("plugin meta must be an object")
+ }
+ for field := range object {
+ switch field {
+ case "apiVersion", "key", "name", "icon", "description", "version", "author", "channelTypes", "channelType", "compatibleChannelTypes", "models", "fetchMode", "allowedHosts", "routes", "protocols", "usageSchema", "usageExamples", "auth", "endpoints", "submitPaths", "actions":
+ default:
+ return Meta{}, fmt.Errorf("plugin meta has unknown field %q", field)
+ }
+ }
+ meta := Meta{}
+ var err error
+ meta.APIVersion, err = integerMetaField(object, "apiVersion")
+ if err != nil {
+ return Meta{}, err
+ }
+ if meta.Key, err = stringMetaField(object, "key"); err != nil {
+ return Meta{}, err
+ }
+ if meta.Name, err = stringMetaField(object, "name"); err != nil {
+ return Meta{}, err
+ }
+ if meta.Icon, err = stringMetaField(object, "icon"); err != nil {
+ return Meta{}, err
+ }
+ meta.Icon = strings.TrimSpace(meta.Icon)
+ if meta.Description, err = localizedTextMetaField(object, "description", maxMetaDescriptionRunes); err != nil {
+ return Meta{}, err
+ }
+ if meta.Version, err = stringMetaField(object, "version"); err != nil {
+ return Meta{}, err
+ }
+ author, ok := object["author"].(map[string]any)
+ if !ok {
+ return Meta{}, fmt.Errorf("plugin meta author must be an object")
+ }
+ for field := range author {
+ if field != "name" && field != "url" {
+ return Meta{}, fmt.Errorf("plugin meta author has unknown field %q", field)
+ }
+ }
+ if meta.Author.Name, err = stringMetaField(author, "name"); err != nil {
+ return Meta{}, err
+ }
+ if rawURL, exists := author["url"]; exists {
+ meta.Author.URL, ok = rawURL.(string)
+ if !ok {
+ return Meta{}, fmt.Errorf("plugin meta author field %q must be a string", "url")
+ }
+ }
+ if _, exists := object["channelType"]; exists {
+ return Meta{}, fmt.Errorf("plugin meta channelType is no longer supported; declare channelTypes instead")
+ }
+ if _, exists := object["compatibleChannelTypes"]; exists {
+ return Meta{}, fmt.Errorf("plugin meta compatibleChannelTypes is no longer supported; declare channelTypes instead")
+ }
+ meta.ChannelTypes, err = integerSliceMetaField(object, "channelTypes")
+ if err != nil {
+ return Meta{}, err
+ }
+ if meta.FetchMode, err = stringMetaField(object, "fetchMode"); err != nil {
+ return Meta{}, err
+ }
+ meta.Models, err = strictStringSlice(object, "models")
+ if err != nil {
+ return Meta{}, err
+ }
+ meta.AllowedHosts, err = strictStringSlice(object, "allowedHosts")
+ if err != nil {
+ return Meta{}, err
+ }
+ meta.Routes, err = decodeRoutes(object["routes"])
+ if err != nil {
+ return Meta{}, err
+ }
+ if _, exists := object["endpoints"]; exists {
+ return Meta{}, fmt.Errorf("plugin meta endpoints is no longer supported; declare protocols by name")
+ }
+ meta.Protocols, err = decodeProtocolClaims(object, "protocols")
+ if err != nil {
+ return Meta{}, err
+ }
+ if usageSchema, exists := object["usageSchema"]; exists {
+ meta.UsageSchema, err = decodeUsageSchema(usageSchema)
+ if err != nil {
+ return Meta{}, err
+ }
+ }
+ if usageExamples, exists := object["usageExamples"]; exists {
+ meta.UsageExamples, err = decodeUsageExamples(usageExamples)
+ if err != nil {
+ return Meta{}, err
+ }
+ }
+ for _, removedField := range []string{"submitPaths", "actions"} {
+ if _, exists := object[removedField]; exists {
+ return Meta{}, fmt.Errorf("plugin meta %s is no longer supported; declare routes instead", removedField)
+ }
+ }
+ switch auth := object["auth"].(type) {
+ case nil:
+ case string:
+ meta.Auth.Type = auth
+ case map[string]any:
+ for key := range auth {
+ if key != "type" {
+ return Meta{}, fmt.Errorf("plugin meta auth has unknown field %q", key)
+ }
+ }
+ meta.Auth.Type, err = stringMetaField(auth, "type")
+ if err != nil {
+ return Meta{}, err
+ }
+ default:
+ return Meta{}, fmt.Errorf("plugin meta auth must be a string or object")
+ }
+ meta.Auth.Type = strings.TrimSpace(meta.Auth.Type)
+ if meta.Auth.Type == "vertex_oauth" {
+ meta.Auth.Type = "oauth2_jwt"
+ }
+ if meta.Auth.Type != "" && meta.Auth.Type != "none" && meta.Auth.Type != "api_key" && meta.Auth.Type != "oauth2_jwt" {
+ return Meta{}, fmt.Errorf("unsupported plugin auth type %q", meta.Auth.Type)
+ }
+ if meta.APIVersion != APIVersion1 {
+ return Meta{}, fmt.Errorf("unsupported plugin apiVersion %d", meta.APIVersion)
+ }
+ if strings.TrimSpace(meta.Key) == "" || strings.TrimSpace(meta.Name) == "" || strings.TrimSpace(meta.Version) == "" {
+ return Meta{}, fmt.Errorf("plugin meta key, name, and version are required")
+ }
+ if len(meta.Key) > 30 {
+ return Meta{}, fmt.Errorf("plugin meta key must not exceed 30 characters")
+ }
+ return meta, nil
+}
+
+// ValidateV1Meta applies the metadata constraints published in
+// docs/plugin-api/v1.schema.json to administrator uploads.
+func ValidateV1Meta(meta Meta) error {
+ meta = cloneMeta(meta)
+ return normalizeV1Meta(&meta)
+}
+
+func normalizeV1Meta(meta *Meta) error {
+ if meta.APIVersion != APIVersion1 {
+ return fmt.Errorf("unsupported plugin apiVersion %d", meta.APIVersion)
+ }
+ if strings.TrimSpace(meta.Name) == "" {
+ return fmt.Errorf("plugin meta name is required")
+ }
+ meta.Icon = strings.TrimSpace(meta.Icon)
+ if meta.Icon != "" {
+ if utf8.RuneCountInString(meta.Icon) > 128 {
+ return fmt.Errorf("plugin meta icon must not exceed 128 characters")
+ }
+ for _, character := range meta.Icon {
+ if unicode.IsControl(character) {
+ return fmt.Errorf("plugin meta icon must not contain control characters")
+ }
+ }
+ }
+ if err := validateLocalizedText(meta.Description, "description", maxMetaDescriptionRunes); err != nil {
+ return err
+ }
+ meta.Author.Name = strings.TrimSpace(meta.Author.Name)
+ if meta.Author.Name == "" {
+ return fmt.Errorf("plugin meta author name is required")
+ }
+ meta.Author.URL = strings.TrimSpace(meta.Author.URL)
+ if meta.Author.URL != "" {
+ parsedURL, err := url.Parse(meta.Author.URL)
+ if err != nil || parsedURL.Host == "" || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
+ return fmt.Errorf("plugin meta author url must be an absolute HTTP(S) URL")
+ }
+ }
+ if !pluginKeyPattern.MatchString(meta.Key) {
+ return fmt.Errorf("plugin meta key must match %s", pluginKeyPattern)
+ }
+ if len(meta.Key) > 30 {
+ return fmt.Errorf("plugin meta key must not exceed 30 characters")
+ }
+ if !pluginVersionPattern.MatchString(meta.Version) {
+ return fmt.Errorf("plugin meta version must be semver")
+ }
+ if meta.FetchMode != "per_task" && meta.FetchMode != "batch" {
+ return fmt.Errorf("plugin meta fetchMode must be per_task or batch")
+ }
+ if len(meta.Models) == 0 {
+ return fmt.Errorf("plugin meta models must contain at least one model")
+ }
+ seenChannelTypes := make(map[int]struct{}, len(meta.ChannelTypes))
+ for _, channelType := range meta.ChannelTypes {
+ if channelType <= 0 {
+ return fmt.Errorf("plugin meta channelTypes must contain positive channel types")
+ }
+ if channelType == constant.ChannelTypeTaskPlugin {
+ return fmt.Errorf("plugin meta channelTypes must not contain the task plugin channel type")
+ }
+ if _, duplicate := seenChannelTypes[channelType]; duplicate {
+ return fmt.Errorf("plugin meta channelTypes must be unique")
+ }
+ seenChannelTypes[channelType] = struct{}{}
+ }
+ models := make(map[string]struct{}, len(meta.Models))
+ for _, model := range meta.Models {
+ if strings.TrimSpace(model) == "" || strings.TrimSpace(model) != model {
+ return fmt.Errorf("plugin meta models must contain non-empty canonical names")
+ }
+ if _, exists := models[model]; exists {
+ return fmt.Errorf("plugin meta models must be unique")
+ }
+ models[model] = struct{}{}
+ }
+ hosts := make(map[string]struct{}, len(meta.AllowedHosts))
+ for _, host := range meta.AllowedHosts {
+ if strings.TrimSpace(host) == "" || strings.ContainsAny(host, "/:?#") {
+ return fmt.Errorf("plugin meta allowedHosts must contain hostnames without schemes, ports, or paths")
+ }
+ if _, exists := hosts[host]; exists {
+ return fmt.Errorf("plugin meta allowedHosts must be unique")
+ }
+ hosts[host] = struct{}{}
+ }
+ routeKeys := make(map[string]struct{}, len(meta.Routes))
+ for index := range meta.Routes {
+ if err := validateRoute(&meta.Routes[index]); err != nil {
+ return err
+ }
+ for _, model := range meta.Routes[index].Models {
+ if _, exists := models[model]; !exists {
+ return fmt.Errorf("plugin route %s %s model %q is not declared in plugin meta models", meta.Routes[index].Method, meta.Routes[index].Path, model)
+ }
+ }
+ shape, err := routePathShape(meta.Routes[index].Path)
+ if err != nil {
+ return err
+ }
+ key := meta.Routes[index].Method + " " + shape
+ if _, exists := routeKeys[key]; exists {
+ return fmt.Errorf("plugin meta routes contain duplicate route %s %s", meta.Routes[index].Method, meta.Routes[index].Path)
+ }
+ routeKeys[key] = struct{}{}
+ }
+ protocols := make(map[string]struct{}, len(meta.Protocols))
+ for index := range meta.Protocols {
+ claim := &meta.Protocols[index]
+ definition, known := HostProtocol(claim.Name)
+ modes := definition.DefinedModes()
+ if len(modes) > 0 {
+ modeNames := make([]string, len(modes))
+ for modeIndex, mode := range modes {
+ modeNames[modeIndex] = mode.Name
+ }
+ choosingFrom := quotedJoin(modeNames, ", ")
+ if claim.Supports == nil {
+ if claim.objectForm {
+ return fmt.Errorf("plugin %s protocol %q must declare supports; add supports: [...] choosing from %s", meta.Key, claim.Name, choosingFrom)
+ }
+ return fmt.Errorf("plugin %s protocol %q must declare supports; replace the bare string with {name: %q, supports: [...]} choosing from %s", meta.Key, claim.Name, claim.Name, choosingFrom)
+ }
+ if len(claim.Supports) == 0 {
+ return fmt.Errorf("plugin %s protocol %q supports must contain at least one of %s", meta.Key, claim.Name, choosingFrom)
+ }
+ seenSupports := make(map[string]struct{}, len(claim.Supports))
+ for _, support := range claim.Supports {
+ if _, duplicate := seenSupports[support]; duplicate {
+ return fmt.Errorf("plugin %s protocol %q supports must be unique", meta.Key, claim.Name)
+ }
+ seenSupports[support] = struct{}{}
+ if !slices.Contains(modeNames, support) {
+ if support == "retrieve" {
+ return fmt.Errorf("plugin %s protocol %q has no mode %q; retrieval of a created response is always available and is never declared", meta.Key, claim.Name, support)
+ }
+ return fmt.Errorf("plugin %s protocol %q has no mode %q", meta.Key, claim.Name, support)
+ }
+ }
+ claim.Supports = orderProtocolSupports(claim.Name, claim.Supports)
+ } else if claim.Supports != nil {
+ return fmt.Errorf("plugin %s protocol %q does not define modes; supports is not allowed", meta.Key, claim.Name)
+ }
+ if !known {
+ return fmt.Errorf("plugin meta protocol %q is unknown", claim.Name)
+ }
+ if _, duplicate := protocols[claim.Name]; duplicate {
+ return fmt.Errorf("plugin meta protocols must be unique")
+ }
+ protocols[claim.Name] = struct{}{}
+ if err := validateModelScope(claim.Models, fmt.Sprintf("protocol %q", claim.Name)); err != nil {
+ return err
+ }
+ for _, model := range claim.Models {
+ if _, exists := models[model]; !exists {
+ return fmt.Errorf("plugin protocol %q model %q is not declared in plugin meta models", claim.Name, model)
+ }
+ }
+ }
+ for name, field := range meta.UsageSchema {
+ if strings.TrimSpace(name) == "" || strings.TrimSpace(name) != name {
+ return fmt.Errorf("plugin meta usageSchema keys must be non-empty canonical names")
+ }
+ if err := validateUsageFieldSchema(name, field); err != nil {
+ return err
+ }
+ }
+ if err := validateUsageExamples(meta.UsageSchema, meta.UsageExamples); err != nil {
+ return err
+ }
+ return nil
+}
+
+func decodeUsageSchema(value any) (map[string]UsageFieldSchema, error) {
+ if value == nil {
+ return nil, fmt.Errorf("plugin meta usageSchema must be an object")
+ }
+ object, ok := value.(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("plugin meta usageSchema must be an object")
+ }
+ schema := make(map[string]UsageFieldSchema, len(object))
+ for name, rawField := range object {
+ fieldObject, ok := rawField.(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("plugin meta usageSchema field %q must be an object", name)
+ }
+ for key := range fieldObject {
+ switch key {
+ case "type", "unit", "enum", "description":
+ default:
+ return nil, fmt.Errorf("plugin meta usageSchema field %q has unknown property %q", name, key)
+ }
+ }
+ field := UsageFieldSchema{}
+ var err error
+ if field.Type, err = stringMetaField(fieldObject, "type"); err != nil {
+ return nil, err
+ }
+ if field.Unit, err = stringMetaField(fieldObject, "unit"); err != nil {
+ return nil, err
+ }
+ if field.Description, err = localizedTextMetaField(fieldObject, "description", maxUsageFieldDescriptionRunes); err != nil {
+ return nil, err
+ }
+ if _, exists := fieldObject["enum"]; exists {
+ if field.Enum, err = strictStringSlice(fieldObject, "enum"); err != nil {
+ return nil, err
+ }
+ }
+ if err = validateUsageFieldSchema(name, field); err != nil {
+ return nil, err
+ }
+ schema[name] = field
+ }
+ return schema, nil
+}
+
+func validateUsageFieldSchema(name string, field UsageFieldSchema) error {
+ if err := validateLocalizedText(field.Description, fmt.Sprintf("usageSchema field %q description", name), maxUsageFieldDescriptionRunes); err != nil {
+ return err
+ }
+ if field.Enum != nil {
+ if field.Type != "" || field.Unit != "" {
+ return fmt.Errorf("plugin meta usageSchema field %q cannot combine enum with type or unit", name)
+ }
+ if len(field.Enum) == 0 {
+ return fmt.Errorf("plugin meta usageSchema field %q enum must contain at least one value", name)
+ }
+ values := make(map[string]struct{}, len(field.Enum))
+ for _, value := range field.Enum {
+ if _, exists := values[value]; exists {
+ return fmt.Errorf("plugin meta usageSchema field %q enum values must be unique", name)
+ }
+ values[value] = struct{}{}
+ }
+ return nil
+ }
+ if field.Type == "boolean" {
+ if field.Unit != "" {
+ return fmt.Errorf("plugin meta usageSchema field %q cannot combine boolean with unit", name)
+ }
+ return nil
+ }
+ if field.Type != "number" {
+ return fmt.Errorf("plugin meta usageSchema field %q type must be number or boolean", name)
+ }
+ if field.Unit != "second" && field.Unit != "count" && field.Unit != "token" && field.Unit != "credit" {
+ return fmt.Errorf("plugin meta usageSchema field %q unit must be second, count, token, or credit", name)
+ }
+ return nil
+}
+
+const maxUsageExamples = 16
+const maxUsageExampleLabelRunes = 48
+
+func decodeUsageExamples(value any) ([]UsageExample, error) {
+ if value == nil {
+ return nil, fmt.Errorf("plugin meta usageExamples must be an array")
+ }
+ items, ok := value.([]any)
+ if !ok {
+ return nil, fmt.Errorf("plugin meta usageExamples must be an array")
+ }
+ if len(items) > maxUsageExamples {
+ return nil, fmt.Errorf("plugin meta usageExamples must not exceed %d entries", maxUsageExamples)
+ }
+ examples := make([]UsageExample, 0, len(items))
+ for index, item := range items {
+ object, ok := item.(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("plugin meta usageExamples[%d] must be an object", index)
+ }
+ for key := range object {
+ if key != "label" && key != "facts" {
+ return nil, fmt.Errorf("plugin meta usageExamples[%d] has unknown field %q", index, key)
+ }
+ }
+ label, err := stringMetaField(object, "label")
+ if err != nil {
+ return nil, fmt.Errorf("plugin meta usageExamples[%d] %w", index, err)
+ }
+ rawFacts, exists := object["facts"]
+ if !exists || rawFacts == nil {
+ return nil, fmt.Errorf("plugin meta usageExamples[%d] facts must be an object", index)
+ }
+ facts, ok := rawFacts.(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("plugin meta usageExamples[%d] facts must be an object", index)
+ }
+ examples = append(examples, UsageExample{Label: label, Facts: facts})
+ }
+ return examples, nil
+}
+
+func usageSchemaHasTokenUnit(schema map[string]UsageFieldSchema) bool {
+ for _, field := range schema {
+ if field.Type == "number" && field.Unit == "token" {
+ return true
+ }
+ }
+ return false
+}
+
+func validateUsageExamples(schema map[string]UsageFieldSchema, examples []UsageExample) error {
+ if len(examples) == 0 {
+ if usageSchemaHasTokenUnit(schema) {
+ return fmt.Errorf("plugin meta usageExamples is required when usageSchema declares a token unit")
+ }
+ return nil
+ }
+ if len(schema) == 0 {
+ return fmt.Errorf("plugin meta usageExamples requires usageSchema")
+ }
+ if len(examples) > maxUsageExamples {
+ return fmt.Errorf("plugin meta usageExamples must not exceed %d entries", maxUsageExamples)
+ }
+ for index := range examples {
+ label := strings.TrimSpace(examples[index].Label)
+ if label == "" {
+ return fmt.Errorf("plugin meta usageExamples[%d] label is required", index)
+ }
+ if utf8.RuneCountInString(label) > maxUsageExampleLabelRunes {
+ return fmt.Errorf("plugin meta usageExamples[%d] label must not exceed %d characters", index, maxUsageExampleLabelRunes)
+ }
+ examples[index].Label = label
+ if examples[index].Facts == nil {
+ return fmt.Errorf("plugin meta usageExamples[%d] facts must be an object", index)
+ }
+ for key := range schema {
+ if _, exists := examples[index].Facts[key]; !exists {
+ return fmt.Errorf("plugin meta usageExamples[%d] facts missing key %q", index, key)
+ }
+ }
+ for key, value := range examples[index].Facts {
+ field, declared := schema[key]
+ if !declared {
+ return fmt.Errorf("plugin meta usageExamples[%d] facts has undeclared key %q", index, key)
+ }
+ if err := validateUsageExampleValue(value, field); err != nil {
+ return fmt.Errorf("plugin meta usageExamples[%d] facts field %q %s", index, key, err.Error())
+ }
+ }
+ }
+ return nil
+}
+
+func validateUsageExampleValue(value any, field UsageFieldSchema) error {
+ if len(field.Enum) > 0 {
+ text, ok := value.(string)
+ if !ok {
+ return fmt.Errorf("enum is not an allowed value")
+ }
+ if slices.Contains(field.Enum, text) {
+ return nil
+ }
+ return fmt.Errorf("enum is not an allowed value")
+ }
+ if field.Type == "boolean" {
+ if _, ok := value.(bool); !ok {
+ return fmt.Errorf("must be a boolean")
+ }
+ return nil
+ }
+ number, ok := usageExampleNumber(value)
+ if !ok {
+ return fmt.Errorf("must be a finite non-negative number")
+ }
+ if math.IsNaN(number) || math.IsInf(number, 0) || number < 0 {
+ return fmt.Errorf("must be a finite non-negative number")
+ }
+ limit := float64(relaycommon.MaxTaskDurationSeconds)
+ if field.Unit == "count" {
+ limit = float64(dto.MaxImageN)
+ } else if field.Unit == "token" || field.Unit == "credit" {
+ limit = float64(common.MaxQuota)
+ }
+ if number > limit {
+ return fmt.Errorf("exceeds the host limit")
+ }
+ return nil
+}
+
+func usageExampleNumber(value any) (float64, bool) {
+ switch number := value.(type) {
+ case float64:
+ return number, true
+ case int64:
+ return float64(number), true
+ case int:
+ return float64(number), true
+ default:
+ return 0, false
+ }
+}
+
+func decodeRoutes(value any) ([]Route, error) {
+ if value == nil {
+ return []Route{}, nil
+ }
+ items, ok := value.([]any)
+ if !ok {
+ return nil, fmt.Errorf("plugin meta routes must be an array")
+ }
+ routes := make([]Route, 0, len(items))
+ for index, item := range items {
+ object, ok := item.(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("plugin meta route %d must be an object", index)
+ }
+ if _, exists := object["renderer"]; exists {
+ return nil, fmt.Errorf("plugin meta route %d field renderer is no longer supported", index)
+ }
+ for key := range object {
+ switch key {
+ case "method", "path", "type", "action", "decode", "render", "taskIdParam", "models":
+ default:
+ return nil, fmt.Errorf("plugin meta route %d has unknown field %q", index, key)
+ }
+ }
+ route := Route{}
+ var err error
+ if route.Method, err = stringMetaField(object, "method"); err != nil {
+ return nil, err
+ }
+ if route.Path, err = stringMetaField(object, "path"); err != nil {
+ return nil, err
+ }
+ routeType, err := stringMetaField(object, "type")
+ if err != nil {
+ return nil, err
+ }
+ route.Type = RouteType(routeType)
+ if route.Action, err = stringMetaField(object, "action"); err != nil {
+ return nil, err
+ }
+ if route.Decode, err = stringMetaField(object, "decode"); err != nil {
+ return nil, err
+ }
+ if route.Render, err = stringMetaField(object, "render"); err != nil {
+ return nil, err
+ }
+ if route.TaskIDParam, err = stringMetaField(object, "taskIdParam"); err != nil {
+ return nil, err
+ }
+ if _, exists := object["models"]; exists {
+ if route.Models, err = strictStringSlice(object, "models"); err != nil {
+ return nil, err
+ }
+ if len(route.Models) == 0 {
+ return nil, fmt.Errorf("plugin meta route %d models must contain at least one model", index)
+ }
+ }
+ routes = append(routes, route)
+ }
+ return routes, nil
+}
+
+// decodeProtocolClaims accepts both protocol entry shapes: a bare protocol
+// name string (binds every meta.models entry) and an object {name, models,
+// supports}. An absent key is empty; a present null or non-array is rejected.
+// The supports key is decoded only when present so an absent key stays nil.
+func decodeProtocolClaims(object map[string]any, name string) ([]ProtocolClaim, error) {
+ value, exists := object[name]
+ if !exists {
+ return []ProtocolClaim{}, nil
+ }
+ items, ok := value.([]any)
+ if !ok {
+ return nil, fmt.Errorf("plugin meta %s must be an array", name)
+ }
+ claims := make([]ProtocolClaim, 0, len(items))
+ for index, item := range items {
+ switch entry := item.(type) {
+ case string:
+ claims = append(claims, ProtocolClaim{Name: entry})
+ case map[string]any:
+ for key := range entry {
+ switch key {
+ case "name", "models", "supports":
+ default:
+ return nil, fmt.Errorf("plugin meta protocol %d has unknown field %q", index, key)
+ }
+ }
+ claim := ProtocolClaim{objectForm: true}
+ var err error
+ if claim.Name, err = stringMetaField(entry, "name"); err != nil {
+ return nil, err
+ }
+ if _, exists := entry["models"]; exists {
+ if claim.Models, err = strictStringSlice(entry, "models"); err != nil {
+ return nil, err
+ }
+ if len(claim.Models) == 0 {
+ return nil, fmt.Errorf("plugin meta protocol %d models must contain at least one model", index)
+ }
+ }
+ if _, exists := entry["supports"]; exists {
+ if claim.Supports, err = strictStringSlice(entry, "supports"); err != nil {
+ return nil, err
+ }
+ claim.Supports = orderProtocolSupports(claim.Name, claim.Supports)
+ }
+ claims = append(claims, claim)
+ default:
+ return nil, fmt.Errorf("plugin meta protocol %d must be a string or an object", index)
+ }
+ }
+ return claims, nil
+}
+
+func integerMetaField(object map[string]any, name string) (int, error) {
+ value, exists := object[name]
+ if !exists {
+ return 0, nil
+ }
+ switch number := value.(type) {
+ case int64:
+ converted := int(number)
+ if int64(converted) != number {
+ return 0, fmt.Errorf("plugin meta %s is outside the supported integer range", name)
+ }
+ return converted, nil
+ case float64:
+ if math.IsNaN(number) || math.IsInf(number, 0) || math.Trunc(number) != number {
+ return 0, fmt.Errorf("plugin meta %s must be an integer", name)
+ }
+ converted := int(number)
+ if float64(converted) != number {
+ return 0, fmt.Errorf("plugin meta %s is outside the supported integer range", name)
+ }
+ return converted, nil
+ default:
+ return 0, fmt.Errorf("plugin meta %s must be an integer", name)
+ }
+}
+
+func integerSliceMetaField(object map[string]any, name string) ([]int, error) {
+ value, exists := object[name]
+ if !exists {
+ return nil, nil
+ }
+ items, ok := value.([]any)
+ if !ok {
+ return nil, fmt.Errorf("plugin meta %s must be an array of integers", name)
+ }
+ numbers := make([]int, 0, len(items))
+ for index, item := range items {
+ element := map[string]any{name: item}
+ number, err := integerMetaField(element, name)
+ if err != nil {
+ return nil, fmt.Errorf("plugin meta %s element %d must be an integer", name, index+1)
+ }
+ numbers = append(numbers, number)
+ }
+ return numbers, nil
+}
+
+func stringMetaField(object map[string]any, name string) (string, error) {
+ value, exists := object[name]
+ if !exists {
+ return "", nil
+ }
+ text, ok := value.(string)
+ if !ok {
+ return "", fmt.Errorf("plugin meta %s must be a string", name)
+ }
+ return text, nil
+}
+
+func localizedTextMetaField(object map[string]any, name string, maxRunes int) (LocalizedText, error) {
+ value, exists := object[name]
+ if !exists {
+ return nil, nil
+ }
+ var text LocalizedText
+ switch typed := value.(type) {
+ case string:
+ text = LocalizedText{"en": typed}
+ case map[string]any:
+ text = make(LocalizedText, len(typed))
+ for locale, raw := range typed {
+ item, ok := raw.(string)
+ if !ok {
+ return nil, fmt.Errorf("plugin meta %s locale %q must be a string", name, locale)
+ }
+ text[locale] = item
+ }
+ default:
+ return nil, fmt.Errorf("plugin meta %s must be a string or object", name)
+ }
+ if err := validateLocalizedText(text, name, maxRunes); err != nil {
+ return nil, err
+ }
+ return text, nil
+}
+
+func validateLocalizedText(text LocalizedText, name string, maxRunes int) error {
+ if text == nil {
+ return nil
+ }
+ if len(text) > maxLocalizedTextLocales {
+ return fmt.Errorf("plugin meta %s must not exceed %d locales", name, maxLocalizedTextLocales)
+ }
+ canonical := make(map[string]string, len(text))
+ for locale, value := range text {
+ if !localeTagPattern.MatchString(locale) {
+ return fmt.Errorf("plugin meta %s has invalid locale %q", name, locale)
+ }
+ canonicalLocale := canonicalLocaleTag(locale)
+ if _, duplicate := canonical[canonicalLocale]; duplicate {
+ return fmt.Errorf("plugin meta %s has duplicate locale %q", name, canonicalLocale)
+ }
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ return fmt.Errorf("plugin meta %s value for %q must be a non-empty string", name, locale)
+ }
+ for _, character := range trimmed {
+ if unicode.IsControl(character) {
+ return fmt.Errorf("plugin meta %s value for %q must not contain control characters", name, locale)
+ }
+ }
+ if utf8.RuneCountInString(trimmed) > maxRunes {
+ return fmt.Errorf("plugin meta %s must not exceed %d characters", name, maxRunes)
+ }
+ canonical[canonicalLocale] = trimmed
+ }
+ if strings.TrimSpace(canonical["en"]) == "" {
+ return fmt.Errorf("plugin meta %s must include a non-empty \"en\" value", name)
+ }
+ for locale := range text {
+ delete(text, locale)
+ }
+ for locale, value := range canonical {
+ text[locale] = value
+ }
+ return nil
+}
+
+// canonicalLocaleTag applies BCP-47 case conventions so lookups can use
+// exact matching: language lowercase, 2-letter region uppercase, 4-letter
+// script title case (zh-tw -> zh-TW, EN -> en, zh-hans -> zh-Hans).
+func canonicalLocaleTag(tag string) string {
+ parts := strings.Split(tag, "-")
+ parts[0] = strings.ToLower(parts[0])
+ for index := 1; index < len(parts); index++ {
+ switch len(parts[index]) {
+ case 2:
+ parts[index] = strings.ToUpper(parts[index])
+ case 4:
+ lowered := strings.ToLower(parts[index])
+ parts[index] = strings.ToUpper(lowered[:1]) + lowered[1:]
+ default:
+ parts[index] = strings.ToLower(parts[index])
+ }
+ }
+ return strings.Join(parts, "-")
+}
+
+func quotedJoin(items []string, sep string) string {
+ parts := make([]string, len(items))
+ for index, item := range items {
+ parts[index] = fmt.Sprintf("%q", item)
+ }
+ return strings.Join(parts, sep)
+}
+
+func strictStringSlice(object map[string]any, name string) ([]string, error) {
+ value, exists := object[name]
+ if !exists {
+ return []string{}, nil
+ }
+ items, ok := value.([]any)
+ if !ok {
+ return nil, fmt.Errorf("plugin meta %s must be an array of strings", name)
+ }
+ result := make([]string, 0, len(items))
+ for _, item := range items {
+ text, ok := item.(string)
+ if !ok {
+ return nil, fmt.Errorf("plugin meta %s must be an array of strings", name)
+ }
+ result = append(result, text)
+ }
+ return result, nil
+}
diff --git a/pkg/jsplugin/registry_disabled_factory_test.go b/pkg/jsplugin/registry_disabled_factory_test.go
new file mode 100644
index 000000000000..3aa8cd951c49
--- /dev/null
+++ b/pkg/jsplugin/registry_disabled_factory_test.go
@@ -0,0 +1,109 @@
+package jsplugin
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRegistrySetDisabledFactoryKeysHidesFactoryPlugin(t *testing.T) {
+ registry := NewRegistry()
+ _, err := registry.RegisterFactory(routingTestPluginSource(
+ "factory-off",
+ 50,
+ `["factory-off-model"]`,
+ `routes: [
+ {method: "POST", path: "/vendor/factory-off", type: "submit", action: "generate", decode: "decodeVideo", render: "videoCreated"}
+ ],
+ protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
+ `export const native = {
+ decodeVideo: function(ctx) { return {kind: "submit", model: "factory-off-model", requestBody: ctx.body.value}; },
+ videoCreated: function(ctx, task) { return task; }
+ };
+ `+routingProtocolExport("openai_responses"),
+ ), Options{})
+ require.NoError(t, err)
+
+ _, ok := registry.Get("factory-off")
+ require.True(t, ok)
+ generation := registry.Generation()
+ before := generation.Number
+ _, ok = generation.LookupDeclaredRoute("POST", "/vendor/factory-off")
+ require.True(t, ok)
+ _, ok = generation.LookupEndpoint("POST", "/v1/responses", "factory-off-model")
+ require.True(t, ok)
+
+ registry.SetDisabledFactoryKeys([]string{"factory-off"})
+ _, ok = registry.Get("factory-off")
+ assert.False(t, ok)
+ generation = registry.Generation()
+ assert.Greater(t, generation.Number, before)
+ _, ok = generation.LookupDeclaredRoute("POST", "/vendor/factory-off")
+ assert.False(t, ok)
+ _, ok = generation.LookupEndpoint("POST", "/v1/responses", "factory-off-model")
+ assert.False(t, ok)
+ for _, plugin := range generation.Plugins() {
+ assert.NotEqual(t, "factory-off", plugin.Meta.Key)
+ }
+ for _, route := range generation.Routes() {
+ assert.NotEqual(t, "factory-off", route.Plugin.Meta.Key)
+ }
+
+ snapshot := registry.Snapshot()
+ require.Len(t, snapshot.Factory, 1)
+ assert.Equal(t, "factory-off", snapshot.Factory[0].Key)
+ assert.Equal(t, []string{"factory-off"}, snapshot.DisabledFactory)
+
+ registry.SetOverrideEnabled(false)
+ _, ok = registry.Get("factory-off")
+ assert.False(t, ok)
+
+ registry.SetDisabledFactoryKeys(nil)
+ plugin, ok := registry.Get("factory-off")
+ require.True(t, ok)
+ assert.Equal(t, "factory-off", plugin.Meta.Key)
+ assert.Empty(t, registry.Snapshot().DisabledFactory)
+ _, ok = registry.Generation().LookupDeclaredRoute("POST", "/vendor/factory-off")
+ assert.True(t, ok)
+}
+
+func TestRegistrySetDisabledFactoryKeysLeavesEnabledOverrideServing(t *testing.T) {
+ registry := NewRegistry()
+ require.NoError(t, registerTestPlugin(registry, "1.0.0-factory", true))
+ require.NoError(t, registerTestPlugin(registry, "1.0.0-override", false))
+
+ registry.SetDisabledFactoryKeys([]string{"test"})
+ plugin, ok := registry.Get("test")
+ require.True(t, ok)
+ assert.Equal(t, "1.0.0-override", plugin.Meta.Version)
+
+ snapshot := registry.Snapshot()
+ require.Len(t, snapshot.Factory, 1)
+ assert.Equal(t, "1.0.0-factory", snapshot.Factory[0].Version)
+ assert.Equal(t, []string{"test"}, snapshot.DisabledFactory)
+}
+
+func TestRegistrySetDisabledFactoryKeysEmptySetDoesNotBumpGeneration(t *testing.T) {
+ registry := NewRegistry()
+ require.NoError(t, registerTestPlugin(registry, "1.0.0-factory", true))
+ before := registry.Generation().Number
+
+ registry.SetDisabledFactoryKeys(nil)
+ assert.Equal(t, before, registry.Generation().Number)
+ registry.SetDisabledFactoryKeys([]string{})
+ assert.Equal(t, before, registry.Generation().Number)
+ registry.SetDisabledFactoryKeys([]string{"", " "})
+ assert.Equal(t, before, registry.Generation().Number)
+}
+
+func TestRegistrySnapshotDisabledFactoryIsSorted(t *testing.T) {
+ registry := NewRegistry()
+ require.NoError(t, registerTestPlugin(registry, "1.0.0", true))
+ _, err := registry.RegisterFactory(routingTestPluginSource("other", 0, `["other-model"]`, "", ""), Options{})
+ require.NoError(t, err)
+
+ registry.SetDisabledFactoryKeys([]string{"other", "test"})
+ assert.Equal(t, []string{"other", "test"}, registry.Snapshot().DisabledFactory)
+ require.Len(t, registry.Snapshot().Factory, 2)
+}
diff --git a/pkg/jsplugin/registry_master_enabled_test.go b/pkg/jsplugin/registry_master_enabled_test.go
new file mode 100644
index 000000000000..c7232786f073
--- /dev/null
+++ b/pkg/jsplugin/registry_master_enabled_test.go
@@ -0,0 +1,147 @@
+package jsplugin
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRegistrySetEnabledHidesFactoryAndOverride(t *testing.T) {
+ registry := NewRegistry()
+ _, err := registry.RegisterFactory(routingTestPluginSource(
+ "master-factory",
+ 50,
+ `["master-factory-model"]`,
+ `routes: [
+ {method: "POST", path: "/vendor/master-factory", type: "submit", action: "generate", decode: "decodeVideo", render: "videoCreated"}
+ ],
+ protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
+ `export const native = {
+ decodeVideo: function(ctx) { return {kind: "submit", model: "master-factory-model", requestBody: ctx.body.value}; },
+ videoCreated: function(ctx, task) { return task; }
+ };
+ `+routingProtocolExport("openai_responses"),
+ ), Options{})
+ require.NoError(t, err)
+ _, err = registry.Register(routingTestPluginSource(
+ "master-override",
+ 51,
+ `["master-override-model"]`,
+ `routes: [
+ {method: "POST", path: "/vendor/master-override", type: "submit", action: "generate", decode: "decodeVideo", render: "videoCreated"}
+ ],
+ protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
+ `export const native = {
+ decodeVideo: function(ctx) { return {kind: "submit", model: "master-override-model", requestBody: ctx.body.value}; },
+ videoCreated: function(ctx, task) { return task; }
+ };
+ `+routingProtocolExport("openai_responses"),
+ ), Options{})
+ require.NoError(t, err)
+
+ _, ok := registry.Get("master-factory")
+ require.True(t, ok)
+ _, ok = registry.Get("master-override")
+ require.True(t, ok)
+ before := registry.Generation().Number
+
+ registry.SetEnabled(false)
+ _, ok = registry.Get("master-factory")
+ assert.False(t, ok)
+ _, ok = registry.Get("master-override")
+ assert.False(t, ok)
+ generation := registry.Generation()
+ assert.Greater(t, generation.Number, before)
+ assert.Empty(t, generation.Plugins())
+ assert.Empty(t, generation.Routes())
+ _, ok = generation.LookupDeclaredRoute("POST", "/vendor/master-factory")
+ assert.False(t, ok)
+ _, ok = generation.LookupDeclaredRoute("POST", "/vendor/master-override")
+ assert.False(t, ok)
+ _, ok = generation.LookupEndpoint("POST", "/v1/responses", "master-factory-model")
+ assert.False(t, ok)
+ _, ok = generation.LookupEndpoint("POST", "/v1/responses", "master-override-model")
+ assert.False(t, ok)
+
+ snapshot := registry.Snapshot()
+ require.Len(t, snapshot.Factory, 1)
+ assert.Equal(t, "master-factory", snapshot.Factory[0].Key)
+ require.Len(t, snapshot.Override, 1)
+ assert.Equal(t, "master-override", snapshot.Override[0].Key)
+}
+
+func TestRegistryMutationsWhileDisabledDoNotResurrectEndpoints(t *testing.T) {
+ registry := NewRegistry()
+ require.NoError(t, registerTestPlugin(registry, "1.0.0-factory", true))
+ registry.SetEnabled(false)
+ _, ok := registry.Get("test")
+ require.False(t, ok)
+
+ require.NoError(t, registerTestPlugin(registry, "1.0.0-override", false))
+ registry.SetDisabledFactoryKeys([]string{"other"})
+ _, ok = registry.Get("test")
+ assert.False(t, ok)
+ assert.Empty(t, registry.Generation().Plugins())
+
+ override := registry.OverridePlugins()
+ require.Contains(t, override, "test")
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{override["test"]}))
+ _, ok = registry.Get("test")
+ assert.False(t, ok)
+ assert.Empty(t, registry.Generation().Plugins())
+}
+
+func TestRegistrySetEnabledTrueRestoresFactoryAndOverride(t *testing.T) {
+ registry := NewRegistry()
+ require.NoError(t, registerTestPlugin(registry, "1.0.0-factory", true))
+ registry.SetEnabled(false)
+ require.NoError(t, registerTestPlugin(registry, "1.0.0-override", false))
+ _, ok := registry.Get("test")
+ require.False(t, ok)
+
+ registry.SetEnabled(true)
+ plugin, ok := registry.Get("test")
+ require.True(t, ok)
+ assert.Equal(t, "1.0.0-override", plugin.Meta.Version)
+ snapshot := registry.Snapshot()
+ require.Len(t, snapshot.Factory, 1)
+ require.Len(t, snapshot.Override, 1)
+}
+
+func TestRegistrySetEnabledNoOpDoesNotBumpGeneration(t *testing.T) {
+ registry := NewRegistry()
+ require.NoError(t, registerTestPlugin(registry, "1.0.0", true))
+ before := registry.Generation().Number
+
+ registry.SetEnabled(true)
+ assert.Equal(t, before, registry.Generation().Number)
+}
+
+func TestRegistryMasterSwitchIsOrthogonalToLayerFlags(t *testing.T) {
+ registry := NewRegistry()
+ require.NoError(t, registerTestPlugin(registry, "1.0.0-factory", true))
+ require.NoError(t, registerTestPlugin(registry, "1.0.0-override", false))
+
+ registry.SetOverrideEnabled(false)
+ plugin, ok := registry.Get("test")
+ require.True(t, ok)
+ assert.Equal(t, "1.0.0-factory", plugin.Meta.Version)
+
+ registry.SetDisabledFactoryKeys([]string{"test"})
+ _, ok = registry.Get("test")
+ assert.False(t, ok)
+
+ registry.SetEnabled(false)
+ _, ok = registry.Get("test")
+ assert.False(t, ok)
+
+ registry.SetEnabled(true)
+ _, ok = registry.Get("test")
+ assert.False(t, ok)
+
+ registry.SetDisabledFactoryKeys(nil)
+ plugin, ok = registry.Get("test")
+ require.True(t, ok)
+ assert.Equal(t, "1.0.0-factory", plugin.Meta.Version)
+}
diff --git a/pkg/jsplugin/registry_test.go b/pkg/jsplugin/registry_test.go
new file mode 100644
index 000000000000..bf85e5411d9b
--- /dev/null
+++ b/pkg/jsplugin/registry_test.go
@@ -0,0 +1,861 @@
+package jsplugin
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRegistryOverrideTakesPrecedenceOverFactory(t *testing.T) {
+ registry := NewRegistry()
+ require.NoError(t, registerTestPlugin(registry, "1.0.0-factory", true))
+ require.NoError(t, registerTestPlugin(registry, "1.0.0-override", false))
+
+ plugin, ok := registry.Get("test")
+ require.True(t, ok)
+ assert.Equal(t, "1.0.0-override", plugin.Meta.Version)
+}
+
+func TestRegistryUnregisterFallsBackToFactory(t *testing.T) {
+ registry := NewRegistry()
+ require.NoError(t, registerTestPlugin(registry, "1.0.0-factory", true))
+ require.NoError(t, registerTestPlugin(registry, "1.0.0-override", false))
+
+ registry.Unregister("test")
+
+ plugin, ok := registry.Get("test")
+ require.True(t, ok)
+ assert.Equal(t, "1.0.0-factory", plugin.Meta.Version)
+}
+
+func TestRegistryDisabledOverrideFallsBackToFactoryAndCanBeRestored(t *testing.T) {
+ registry := NewRegistry()
+ require.NoError(t, registerTestPlugin(registry, "1.0.0-factory", true))
+ require.NoError(t, registerTestPlugin(registry, "1.0.0-override", false))
+
+ registry.SetOverrideEnabled(false)
+ plugin, ok := registry.Get("test")
+ require.True(t, ok)
+ assert.Equal(t, "1.0.0-factory", plugin.Meta.Version)
+
+ registry.SetOverrideEnabled(true)
+ plugin, ok = registry.Get("test")
+ require.True(t, ok)
+ assert.Equal(t, "1.0.0-override", plugin.Meta.Version)
+}
+
+func TestRegistrySnapshotSeparatesLayersWithoutExposingEntries(t *testing.T) {
+ registry := NewRegistry()
+ require.NoError(t, registerTestPlugin(registry, "1.0.0-factory", true))
+ require.NoError(t, registerTestPlugin(registry, "1.0.0-override", false))
+
+ snapshot := registry.Snapshot()
+
+ require.Len(t, snapshot.Factory, 1)
+ require.Len(t, snapshot.Override, 1)
+ assert.Equal(t, "1.0.0-factory", snapshot.Factory[0].Version)
+ assert.Equal(t, "1.0.0-override", snapshot.Override[0].Version)
+ snapshot.Override[0].Version = "changed"
+ plugin, ok := registry.Get("test")
+ require.True(t, ok)
+ assert.Equal(t, "1.0.0-override", plugin.Meta.Version)
+}
+
+func TestRegistryRejectsPluginKeyLongerThanTaskPlatformColumn(t *testing.T) {
+ source := `export const meta = {apiVersion: 1, key: "1234567890123456789012345678901", name: "Long", version: "1", author: {name: "Test"}};`
+ _, err := NewRegistry().Register(source, Options{})
+ require.ErrorContains(t, err, "must not exceed 30 characters")
+}
+
+func TestValidateV1MetaEnforcesTaskPluginKeyLength(t *testing.T) {
+ meta := Meta{APIVersion: 1, Key: strings.Repeat("a", 30), Name: "Test", Version: "1.0.0", Author: AuthorMeta{Name: "Test"}, Models: []string{"model"}, FetchMode: "per_task"}
+ require.NoError(t, ValidateV1Meta(meta))
+
+ meta.Key += "a"
+ require.ErrorContains(t, ValidateV1Meta(meta), "must not exceed 30 characters")
+}
+
+func TestRegistryDecodesAndValidatesIcon(t *testing.T) {
+ absent, err := CompilePlugin(routingTestPluginSource("icon-absent", 0, `["model"]`, "", ""), Options{})
+ require.NoError(t, err)
+ assert.Empty(t, absent.Meta.Icon)
+
+ accepted, err := CompilePlugin(routingTestPluginSource("icon-ok", 0, `["model"]`, `icon: "Sora.Color",`, ""), Options{})
+ require.NoError(t, err)
+ assert.Equal(t, "Sora.Color", accepted.Meta.Icon)
+
+ _, err = NewRegistry().Register(routingTestPluginSource("icon-long", 0, `["model"]`, `icon: "`+strings.Repeat("a", 129)+`",`, ""), Options{})
+ require.ErrorContains(t, err, "must not exceed 128 characters")
+
+ _, err = NewRegistry().Register(`
+export const meta = {
+ apiVersion: 1, key: "icon-type", name: "Icon", version: "1.0.0", author: {name: "Test"},
+ models: ["model"], fetchMode: "per_task", icon: 1
+};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+`, Options{})
+ require.ErrorContains(t, err, "must be a string")
+
+ _, err = NewRegistry().Register(routingTestPluginSource("icon-control", 0, `["model"]`, "icon: \"Sora\\u0000.Color\",", ""), Options{})
+ require.ErrorContains(t, err, "must not contain control characters")
+}
+
+func TestRegistryRequiresValidPluginAuthor(t *testing.T) {
+ missing := strings.Replace(
+ routingTestPluginSource("missing-author", 0, `["model"]`, "", ""),
+ `author: {name: "Test"},`,
+ "",
+ 1,
+ )
+ _, err := CompilePlugin(missing, Options{})
+ require.ErrorContains(t, err, "author must be an object")
+
+ meta := Meta{
+ APIVersion: 1,
+ Key: "author-url",
+ Name: "Author URL",
+ Version: "1.0.0",
+ Author: AuthorMeta{Name: "Test", URL: "ftp://example.com/profile"},
+ Models: []string{"model"},
+ FetchMode: "per_task",
+ }
+ require.ErrorContains(t, ValidateV1Meta(meta), "absolute HTTP(S)")
+ meta.Author.URL = "https://example.com/profile"
+ require.NoError(t, ValidateV1Meta(meta))
+}
+
+func TestRegistryRequiresArtifactHooksAsPair(t *testing.T) {
+ for _, hook := range []string{"listArtifacts", "buildContentRequest"} {
+ t.Run(hook, func(t *testing.T) {
+ source := routingTestPluginSource(
+ "artifact-hook-pair",
+ 0,
+ `["model"]`,
+ "",
+ "export function "+hook+"() { return []; }",
+ )
+ _, err := CompilePlugin(source, Options{})
+ require.ErrorContains(t, err, "must export listArtifacts and buildContentRequest together")
+ })
+ }
+}
+
+func TestRegistryRejectsRemovedNativeRoutingFields(t *testing.T) {
+ for _, field := range []string{"submitPaths", "actions"} {
+ t.Run(field, func(t *testing.T) {
+ source := `
+export const meta = {
+ apiVersion: 1, key: "removed-field", name: "Removed", version: "1.0.0", author: {name: "Test"},
+ models: ["model"], fetchMode: "per_task", ` + field + `: []
+};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+`
+ _, err := NewRegistry().Register(source, Options{})
+ require.ErrorContains(t, err, "declare routes instead")
+ })
+ }
+}
+
+func TestRegistryDecodesAndValidatesUsageSchema(t *testing.T) {
+ withoutSchema, err := CompilePlugin(
+ routingTestPluginSource("usage-schema-absent", 0, `["model"]`, "", ""),
+ Options{},
+ )
+ require.NoError(t, err)
+ assert.Nil(t, withoutSchema.Meta.UsageSchema)
+
+ valid := routingTestPluginSource(
+ "usage-schema",
+ 0,
+ `["model"]`,
+ `usageSchema: {
+ duration: {type: "number", unit: "second", description: "Generated video duration."},
+ count: {type: "number", unit: "count"},
+ tokens: {type: "number", unit: "token", description: "Upstream billing tokens."},
+ credits: {type: "number", unit: "credit", description: "Vendor resource-pack units."},
+ mode: {enum: ["std", "pro"], description: "Provider quality tier."},
+ generate_audio: {type: "boolean", description: "Whether audio is generated."},
+ },
+ usageExamples: [{label: "std · 1s", facts: {duration: 1, count: 1, tokens: 1, credits: 1, mode: "std", generate_audio: true}}],`,
+ "",
+ )
+ plugin, err := CompilePlugin(valid, Options{})
+ require.NoError(t, err)
+ assert.Equal(t, "number", plugin.Meta.UsageSchema["duration"].Type)
+ assert.Equal(t, "second", plugin.Meta.UsageSchema["duration"].Unit)
+ assert.Equal(t, LocalizedText{"en": "Generated video duration."}, plugin.Meta.UsageSchema["duration"].Description)
+ assert.Equal(t, "number", plugin.Meta.UsageSchema["count"].Type)
+ assert.Equal(t, "count", plugin.Meta.UsageSchema["count"].Unit)
+ assert.Equal(t, "number", plugin.Meta.UsageSchema["tokens"].Type)
+ assert.Equal(t, "token", plugin.Meta.UsageSchema["tokens"].Unit)
+ assert.Equal(t, LocalizedText{"en": "Upstream billing tokens."}, plugin.Meta.UsageSchema["tokens"].Description)
+ assert.Equal(t, "number", plugin.Meta.UsageSchema["credits"].Type)
+ assert.Equal(t, "credit", plugin.Meta.UsageSchema["credits"].Unit)
+ assert.Equal(t, LocalizedText{"en": "Vendor resource-pack units."}, plugin.Meta.UsageSchema["credits"].Description)
+ assert.Equal(t, []string{"std", "pro"}, plugin.Meta.UsageSchema["mode"].Enum)
+ assert.Equal(t, LocalizedText{"en": "Provider quality tier."}, plugin.Meta.UsageSchema["mode"].Description)
+ assert.Equal(t, "boolean", plugin.Meta.UsageSchema["generate_audio"].Type)
+ assert.Equal(t, LocalizedText{"en": "Whether audio is generated."}, plugin.Meta.UsageSchema["generate_audio"].Description)
+ require.Len(t, plugin.Meta.UsageExamples, 1)
+ assert.Equal(t, "std · 1s", plugin.Meta.UsageExamples[0].Label)
+ assert.Equal(t, int64(1), plugin.Meta.UsageExamples[0].Facts["tokens"])
+
+ tests := []struct {
+ name string
+ declaration string
+ expectedError string
+ }{
+ {
+ name: "unsupported numeric unit",
+ declaration: `{type: "number", unit: "minute"}`,
+ expectedError: "unit must be second, count, token, or credit",
+ },
+ {
+ name: "boolean cannot mix unit",
+ declaration: `{type: "boolean", unit: "second"}`,
+ expectedError: "cannot combine boolean with unit",
+ },
+ {
+ name: "enum cannot mix numeric shape",
+ declaration: `{type: "number", unit: "second", enum: ["std"]}`,
+ expectedError: "cannot combine enum with type or unit",
+ },
+ {
+ name: "enum values must be unique",
+ declaration: `{enum: ["std", "std"]}`,
+ expectedError: "enum values must be unique",
+ },
+ {
+ name: "enum must not be empty",
+ declaration: `{enum: []}`,
+ expectedError: "enum must contain at least one value",
+ },
+ {
+ name: "empty enum cannot be hidden in numeric shape",
+ declaration: `{type: "number", unit: "second", enum: []}`,
+ expectedError: "cannot combine enum with type or unit",
+ },
+ {
+ name: "unknown property",
+ declaration: `{type: "number", unit: "second", maximum: 5}`,
+ expectedError: `unknown property "maximum"`,
+ },
+ {
+ name: "description must be a string or object",
+ declaration: `{type: "number", unit: "second", description: 5}`,
+ expectedError: "description must be a string or object",
+ },
+ {
+ name: "description is bounded",
+ declaration: `{type: "number", unit: "second", description: "` + strings.Repeat("x", 257) + `"}`,
+ expectedError: "description must not exceed 256 characters",
+ },
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ source := routingTestPluginSource(
+ "invalid-usage-schema",
+ 0,
+ `["model"]`,
+ `usageSchema: {value: `+testCase.declaration+`},`,
+ "",
+ )
+ _, err := CompilePlugin(source, Options{})
+ require.ErrorContains(t, err, testCase.expectedError)
+ })
+ }
+
+ for _, testCase := range []struct {
+ name string
+ metaFields string
+ expectedError string
+ }{
+ {
+ name: "explicit null",
+ metaFields: `usageSchema: null,`,
+ expectedError: "usageSchema must be an object",
+ },
+ {
+ name: "leading whitespace in key",
+ metaFields: `usageSchema: {" duration": {type: "number", unit: "second"}},`,
+ expectedError: "keys must be non-empty canonical names",
+ },
+ {
+ name: "trailing whitespace in key",
+ metaFields: `usageSchema: {"duration ": {type: "number", unit: "second"}},`,
+ expectedError: "keys must be non-empty canonical names",
+ },
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ source := routingTestPluginSource(
+ "invalid-usage-schema",
+ 0,
+ `["model"]`,
+ testCase.metaFields,
+ "",
+ )
+ _, err := CompilePlugin(source, Options{})
+ require.ErrorContains(t, err, testCase.expectedError)
+ })
+ }
+
+ t.Run("second-only schema may omit usageExamples", func(t *testing.T) {
+ plugin, err := CompilePlugin(
+ routingTestPluginSource(
+ "usage-examples-optional",
+ 0,
+ `["model"]`,
+ `usageSchema: {seconds: {type: "number", unit: "second"}},`,
+ "",
+ ),
+ Options{},
+ )
+ require.NoError(t, err)
+ assert.Empty(t, plugin.Meta.UsageExamples)
+ })
+
+ t.Run("ValidateV1Meta preserves explicit empty enum presence", func(t *testing.T) {
+ meta := Meta{
+ APIVersion: 1,
+ Key: "invalid-usage-schema",
+ Name: "Invalid Usage Schema",
+ Version: "1.0.0",
+ Author: AuthorMeta{Name: "Test"},
+ Models: []string{"model"},
+ FetchMode: "per_task",
+ UsageSchema: map[string]UsageFieldSchema{
+ "duration": {Type: "number", Unit: "second", Enum: []string{}},
+ },
+ }
+ require.ErrorContains(t, ValidateV1Meta(meta), "cannot combine enum with type or unit")
+ })
+}
+
+func TestRegistryValidatesUsageExamples(t *testing.T) {
+ tokenSchema := `usageSchema: {tokens: {type: "number", unit: "token"}, mode: {enum: ["std", "pro"]}},`
+ validExample := `{label: "std · 1 token", facts: {tokens: 1, mode: "std"}}`
+
+ for _, testCase := range []struct {
+ name string
+ metaFields string
+ expectedError string
+ }{
+ {
+ name: "missing schema key",
+ metaFields: tokenSchema + `usageExamples: [{label: "std", facts: {tokens: 1}}],`,
+ expectedError: `facts missing key "mode"`,
+ },
+ {
+ name: "undeclared facts key",
+ metaFields: tokenSchema + `usageExamples: [{label: "std", facts: {tokens: 1, mode: "std", extra: 1}}],`,
+ expectedError: `undeclared key "extra"`,
+ },
+ {
+ name: "enum value must be declared",
+ metaFields: tokenSchema + `usageExamples: [{label: "ultra", facts: {tokens: 1, mode: "ultra"}}],`,
+ expectedError: "enum is not an allowed value",
+ },
+ {
+ name: "token unit requires at least one example",
+ metaFields: `usageSchema: {tokens: {type: "number", unit: "token"}},`,
+ expectedError: "usageExamples is required when usageSchema declares a token unit",
+ },
+ {
+ name: "cap is 16 examples",
+ metaFields: tokenSchema + `usageExamples: [` + strings.Repeat(validExample+",", 16) + validExample + `],`,
+ expectedError: "must not exceed 16 entries",
+ },
+ {
+ name: "label must be non-empty",
+ metaFields: tokenSchema + `usageExamples: [{label: " ", facts: {tokens: 1, mode: "std"}}],`,
+ expectedError: "label is required",
+ },
+ {
+ name: "label is bounded",
+ metaFields: tokenSchema + `usageExamples: [{label: "` + strings.Repeat("x", 49) + `", facts: {tokens: 1, mode: "std"}}],`,
+ expectedError: "label must not exceed 48 characters",
+ },
+ {
+ name: "usageExamples requires usageSchema",
+ metaFields: `usageExamples: [{label: "std", facts: {tokens: 1}}],`,
+ expectedError: "usageExamples requires usageSchema",
+ },
+ {
+ name: "token value must stay within the int32 bound",
+ metaFields: tokenSchema + `usageExamples: [{label: "overflow", facts: {tokens: 2147483648, mode: "std"}}],`,
+ expectedError: "exceeds the host limit",
+ },
+ {
+ name: "second value must stay within the duration bound",
+ metaFields: `usageSchema: {seconds: {type: "number", unit: "second"}}, usageExamples: [{label: "too long", facts: {seconds: 3601}}],`,
+ expectedError: "exceeds the host limit",
+ },
+ {
+ name: "negative number is rejected",
+ metaFields: tokenSchema + `usageExamples: [{label: "neg", facts: {tokens: -1, mode: "std"}}],`,
+ expectedError: "finite non-negative number",
+ },
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ source := routingTestPluginSource(
+ "invalid-usage-examples",
+ 0,
+ `["model"]`,
+ testCase.metaFields,
+ "",
+ )
+ _, err := CompilePlugin(source, Options{})
+ require.ErrorContains(t, err, testCase.expectedError)
+ })
+ }
+
+ t.Run("accepts a complete token example vector", func(t *testing.T) {
+ plugin, err := CompilePlugin(
+ routingTestPluginSource(
+ "valid-usage-examples",
+ 0,
+ `["model"]`,
+ tokenSchema+`usageExamples: [`+validExample+`],`,
+ "",
+ ),
+ Options{},
+ )
+ require.NoError(t, err)
+ require.Len(t, plugin.Meta.UsageExamples, 1)
+ assert.Equal(t, "std · 1 token", plugin.Meta.UsageExamples[0].Label)
+ assert.Equal(t, "std", plugin.Meta.UsageExamples[0].Facts["mode"])
+ })
+
+ t.Run("ValidateV1Meta rejects a token schema without examples", func(t *testing.T) {
+ meta := Meta{
+ APIVersion: 1,
+ Key: "token-examples",
+ Name: "Token Examples",
+ Version: "1.0.0",
+ Author: AuthorMeta{Name: "Test"},
+ Models: []string{"model"},
+ FetchMode: "per_task",
+ UsageSchema: map[string]UsageFieldSchema{
+ "tokens": {Type: "number", Unit: "token"},
+ },
+ }
+ require.ErrorContains(t, ValidateV1Meta(meta), "usageExamples is required when usageSchema declares a token unit")
+ meta.UsageExamples = []UsageExample{{Label: "1 token", Facts: map[string]any{"tokens": 1}}}
+ require.NoError(t, ValidateV1Meta(meta))
+ })
+}
+
+func TestRegistryFindsEffectivePluginByBuiltInChannelType(t *testing.T) {
+ registry := NewRegistry()
+ _, err := registry.RegisterFactory(`
+export const meta = {apiVersion:1,key:"test",name:"Test",version:"1.0.0",author:{name:"Test"},channelTypes:[1001],models:["test-model"],fetchMode:"per_task"};
+export function buildSubmitRequest(){return {}} export function parseSubmitResponse(){return {}} export function buildQueryRequest(){return {}} export function parseTaskResult(){return {}}
+`, Options{})
+ require.NoError(t, err)
+ plugin, ok := registry.GetByChannelType(1001)
+ require.True(t, ok)
+ assert.Equal(t, "test", plugin.Meta.Key)
+}
+
+func TestTaskPluginRoutingDebugReasonDoesNotExposeRawFailure(t *testing.T) {
+ for _, testCase := range []struct {
+ name string
+ message string
+ expected string
+ }{
+ {name: "channel type", message: "channelType 80 conflicts with plugin secret", expected: "channel_type_conflict"},
+ {name: "endpoint", message: `endpoint https://secret.invalid/?key=hidden conflicts`, expected: "endpoint_conflict"},
+ {name: "inner router", message: "inner Gin registration panic: private route", expected: "inner_router_build_failed"},
+ {name: "route", message: `route /private/path conflicts`, expected: "route_conflict"},
+ {name: "fallback", message: `database https://secret.invalid/?key=hidden`, expected: "generation_rebuild_failed"},
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ reason := taskPluginRoutingDebugReason(testCase.message)
+ assert.Equal(t, testCase.expected, reason)
+ assert.NotContains(t, reason, "secret")
+ assert.NotContains(t, reason, "hidden")
+ })
+ }
+}
+
+func registerTestPlugin(registry *Registry, version string, factory bool) error {
+ source := `
+export const meta = {apiVersion: 1, key: "test", name: "Test", version: "` + version + `", author: {name: "Test"}, models: ["test-model"], fetchMode: "per_task"};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+`
+ if factory {
+ _, err := registry.RegisterFactory(source, Options{})
+ return err
+ }
+ _, err := registry.Register(source, Options{})
+ return err
+}
+
+func TestRegistryValidatesChannelTypes(t *testing.T) {
+ base := Meta{
+ APIVersion: 1,
+ Key: "compat",
+ Name: "Compat",
+ Version: "1.0.0",
+ Author: AuthorMeta{Name: "Test"},
+ Models: []string{"model"},
+ FetchMode: "per_task",
+ }
+ for _, testCase := range []struct {
+ name string
+ channelTypes []int
+ wantErr string
+ }{
+ {name: "valid list", channelTypes: []int{55, 1}},
+ {name: "empty list", channelTypes: nil},
+ {name: "zero rejected", channelTypes: []int{0}, wantErr: "positive channel types"},
+ {name: "negative rejected", channelTypes: []int{-1}, wantErr: "positive channel types"},
+ {name: "task plugin type rejected", channelTypes: []int{constant.ChannelTypeTaskPlugin}, wantErr: "task plugin channel type"},
+ {name: "duplicates rejected", channelTypes: []int{1, 1}, wantErr: "must be unique"},
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ meta := base
+ meta.ChannelTypes = testCase.channelTypes
+ err := ValidateV1Meta(meta)
+ if testCase.wantErr == "" {
+ require.NoError(t, err)
+ return
+ }
+ require.ErrorContains(t, err, testCase.wantErr)
+ })
+ }
+
+ for _, testCase := range []struct {
+ name string
+ metaFields string
+ expectedError string
+ }{
+ {
+ name: "removed channelType field",
+ metaFields: `channelType: 55,`,
+ expectedError: "channelType is no longer supported; declare channelTypes instead",
+ },
+ {
+ name: "removed compatibleChannelTypes field",
+ metaFields: `compatibleChannelTypes: [1],`,
+ expectedError: "compatibleChannelTypes is no longer supported; declare channelTypes instead",
+ },
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ source := fmt.Sprintf(`
+export const meta = {
+ apiVersion: 1, key: "legacy-field", name: "Legacy", version: "1.0.0",
+ author: {name: "Test"}, models: ["model"], fetchMode: "per_task", %s
+};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+`, testCase.metaFields)
+ _, err := CompilePlugin(source, Options{})
+ require.ErrorContains(t, err, testCase.expectedError)
+ })
+ }
+}
+
+func TestLocalizedTextContract(t *testing.T) {
+ validMeta := func() Meta {
+ return Meta{
+ APIVersion: 1,
+ Key: "localized-text",
+ Name: "Localized Text",
+ Version: "1.0.0",
+ Author: AuthorMeta{Name: "Test"},
+ Models: []string{"model"},
+ FetchMode: "per_task",
+ }
+ }
+
+ t.Run("bare string meta description normalizes to en", func(t *testing.T) {
+ plugin, err := CompilePlugin(
+ routingTestPluginSource("localized-text", 0, `["model"]`, `description: "Video generation via the vendor API",`, ""),
+ Options{},
+ )
+ require.NoError(t, err)
+ assert.Equal(t, LocalizedText{"en": "Video generation via the vendor API"}, plugin.Meta.Description)
+ })
+
+ t.Run("bare string usage field description normalizes to en", func(t *testing.T) {
+ plugin, err := CompilePlugin(
+ routingTestPluginSource(
+ "localized-text",
+ 0,
+ `["model"]`,
+ `usageSchema: {seconds: {type: "number", unit: "second", description: "Generated media duration."}},`,
+ "",
+ ),
+ Options{},
+ )
+ require.NoError(t, err)
+ assert.Equal(t, LocalizedText{"en": "Generated media duration."}, plugin.Meta.UsageSchema["seconds"].Description)
+ })
+
+ t.Run("map form is accepted when en is present", func(t *testing.T) {
+ plugin, err := CompilePlugin(
+ routingTestPluginSource(
+ "localized-text",
+ 0,
+ `["model"]`,
+ `description: {en: "Video generation via the vendor API", zh: "通过厂商接口生成视频"},
+ usageSchema: {seconds: {type: "number", unit: "second", description: {en: "Generated media duration.", "zh-TW": "產生的媒體時長"}}},`,
+ "",
+ ),
+ Options{},
+ )
+ require.NoError(t, err)
+ assert.Equal(t, LocalizedText{"en": "Video generation via the vendor API", "zh": "通过厂商接口生成视频"}, plugin.Meta.Description)
+ assert.Equal(t, LocalizedText{"en": "Generated media duration.", "zh-TW": "產生的媒體時長"}, plugin.Meta.UsageSchema["seconds"].Description)
+ })
+
+ t.Run("trim is written back", func(t *testing.T) {
+ plugin, err := CompilePlugin(
+ routingTestPluginSource("localized-text", 0, `["model"]`, `description: " Video generation via the vendor API ",`, ""),
+ Options{},
+ )
+ require.NoError(t, err)
+ assert.Equal(t, LocalizedText{"en": "Video generation via the vendor API"}, plugin.Meta.Description)
+ })
+
+ t.Run("locale tags are canonicalized to BCP-47 casing", func(t *testing.T) {
+ plugin, err := CompilePlugin(
+ routingTestPluginSource(
+ "localized-text",
+ 0,
+ `["model"]`,
+ `description: {EN: "Video generation via the vendor API", "zh-tw": "透過廠商介面產生影片", "zh-hans": "通过厂商接口生成视频"},`,
+ "",
+ ),
+ Options{},
+ )
+ require.NoError(t, err)
+ assert.Equal(t, LocalizedText{
+ "en": "Video generation via the vendor API",
+ "zh-TW": "透過廠商介面產生影片",
+ "zh-Hans": "通过厂商接口生成视频",
+ }, plugin.Meta.Description)
+ })
+
+ for _, testCase := range []struct {
+ name string
+ metaFields string
+ expectedError string
+ }{
+ {
+ name: "map missing en",
+ metaFields: `description: {zh: "通过厂商接口生成视频"},`,
+ expectedError: `must include a non-empty "en" value`,
+ },
+ {
+ name: "en is whitespace",
+ metaFields: `description: {en: " ", zh: "通过厂商接口生成视频"},`,
+ expectedError: `value for "en" must be a non-empty string`,
+ },
+ {
+ name: "chinese locale key",
+ metaFields: `description: {en: "Video generation via the vendor API", "英文": "通过厂商接口生成视频"},`,
+ expectedError: `invalid locale "英文"`,
+ },
+ {
+ name: "empty locale key",
+ metaFields: `description: {en: "Video generation via the vendor API", "": "through the vendor API"},`,
+ expectedError: `invalid locale ""`,
+ },
+ {
+ name: "oversized locale key",
+ metaFields: `description: {en: "Video generation via the vendor API", toolonglocalekey123456: "through the vendor API"},`,
+ expectedError: `invalid locale "toolonglocalekey123456"`,
+ },
+ {
+ name: "case-variant duplicate locale",
+ metaFields: `description: {en: "Video generation via the vendor API", EN: "duplicate"},`,
+ expectedError: `duplicate locale "en"`,
+ },
+ {
+ name: "meta description exceeds 512 runes",
+ metaFields: `description: "` + strings.Repeat("x", 513) + `",`,
+ expectedError: "description must not exceed 512 characters",
+ },
+ {
+ name: "usage field description exceeds 256 runes",
+ metaFields: `usageSchema: {seconds: {type: "number", unit: "second", description: "` + strings.Repeat("x", 257) + `"}},`,
+ expectedError: "description must not exceed 256 characters",
+ },
+ {
+ name: "control character",
+ metaFields: `description: "Video\u0000 generation via the vendor API",`,
+ expectedError: "must not contain control characters",
+ },
+ {
+ name: "more than 16 locales",
+ metaFields: `description: {en:"a",aa:"a",ab:"a",af:"a",ak:"a",am:"a",an:"a",ar:"a",as:"a",av:"a",ay:"a",az:"a",ba:"a",be:"a",bg:"a",bh:"a",bi:"a"},`,
+ expectedError: "must not exceed 16 locales",
+ },
+ {
+ name: "description number",
+ metaFields: `description: 1,`,
+ expectedError: "description must be a string or object",
+ },
+ {
+ name: "description array",
+ metaFields: `description: ["Video generation via the vendor API"],`,
+ expectedError: "description must be a string or object",
+ },
+ {
+ name: "unknown meta field is still rejected",
+ metaFields: `description: "Video generation via the vendor API", extra: true,`,
+ expectedError: `unknown field "extra"`,
+ },
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ _, err := CompilePlugin(
+ routingTestPluginSource("localized-text", 0, `["model"]`, testCase.metaFields, ""),
+ Options{},
+ )
+ require.ErrorContains(t, err, testCase.expectedError)
+ })
+ }
+
+ t.Run("boundary rune lengths and 16 locales are accepted", func(t *testing.T) {
+ plugin, err := CompilePlugin(
+ routingTestPluginSource(
+ "localized-text",
+ 0,
+ `["model"]`,
+ `description: {en:"a",aa:"a",ab:"a",af:"a",ak:"a",am:"a",an:"a",ar:"a",as:"a",av:"a",ay:"a",az:"a",ba:"a",be:"a",bg:"a",bh:"a"},
+ usageSchema: {seconds: {type: "number", unit: "second", description: "`+strings.Repeat("x", 256)+`"}},`,
+ "",
+ ),
+ Options{},
+ )
+ require.NoError(t, err)
+ assert.Len(t, plugin.Meta.Description, 16)
+ assert.Equal(t, 256, len([]rune(plugin.Meta.UsageSchema["seconds"].Description["en"])))
+
+ plugin, err = CompilePlugin(
+ routingTestPluginSource("localized-text-max", 0, `["model"]`, `description: "`+strings.Repeat("x", 512)+`",`, ""),
+ Options{},
+ )
+ require.NoError(t, err)
+ assert.Equal(t, 512, len([]rune(plugin.Meta.Description["en"])))
+ })
+
+ for _, testCase := range []struct {
+ name string
+ mutate func(*Meta)
+ expectedError string
+ }{
+ {
+ name: "ValidateV1Meta map missing en",
+ mutate: func(meta *Meta) {
+ meta.Description = LocalizedText{"zh": "通过厂商接口生成视频"}
+ },
+ expectedError: `must include a non-empty "en" value`,
+ },
+ {
+ name: "ValidateV1Meta blank en",
+ mutate: func(meta *Meta) {
+ meta.Description = LocalizedText{"en": " "}
+ },
+ expectedError: `value for "en" must be a non-empty string`,
+ },
+ {
+ name: "ValidateV1Meta invalid locale",
+ mutate: func(meta *Meta) {
+ meta.Description = LocalizedText{"en": "Video generation via the vendor API", "英文": "通过厂商接口生成视频"}
+ },
+ expectedError: `invalid locale "英文"`,
+ },
+ {
+ name: "ValidateV1Meta usage field too long",
+ mutate: func(meta *Meta) {
+ meta.UsageSchema = map[string]UsageFieldSchema{
+ "seconds": {Type: "number", Unit: "second", Description: LocalizedText{"en": strings.Repeat("x", 257)}},
+ }
+ },
+ expectedError: "description must not exceed 256 characters",
+ },
+ {
+ name: "ValidateV1Meta control character",
+ mutate: func(meta *Meta) {
+ meta.Description = LocalizedText{"en": "Video\u0000 generation via the vendor API"}
+ },
+ expectedError: "must not contain control characters",
+ },
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ meta := validMeta()
+ testCase.mutate(&meta)
+ require.ErrorContains(t, ValidateV1Meta(meta), testCase.expectedError)
+ })
+ }
+
+ t.Run("MarshalJSON of Meta description is an object", func(t *testing.T) {
+ meta := validMeta()
+ meta.Description = LocalizedText{"en": "Video generation via the vendor API", "zh": "通过厂商接口生成视频"}
+ encoded, err := common.Marshal(meta)
+ require.NoError(t, err)
+ var raw map[string]any
+ require.NoError(t, common.Unmarshal(encoded, &raw))
+ object, ok := raw["description"].(map[string]any)
+ require.True(t, ok, "API description must be an object, got %T", raw["description"])
+ assert.Equal(t, "Video generation via the vendor API", object["en"])
+ assert.Equal(t, "通过厂商接口生成视频", object["zh"])
+ })
+
+ t.Run("UnmarshalJSON accepts string and object", func(t *testing.T) {
+ var fromString LocalizedText
+ require.NoError(t, common.Unmarshal([]byte(`"Video generation via the vendor API"`), &fromString))
+ assert.Equal(t, LocalizedText{"en": "Video generation via the vendor API"}, fromString)
+
+ var fromObject LocalizedText
+ require.NoError(t, common.Unmarshal([]byte(`{"en":"Video generation via the vendor API","zh":"通过厂商接口生成视频"}`), &fromObject))
+ assert.Equal(t, LocalizedText{"en": "Video generation via the vendor API", "zh": "通过厂商接口生成视频"}, fromObject)
+
+ encoded, err := common.Marshal(fromString)
+ require.NoError(t, err)
+ assert.Equal(t, `{"en":"Video generation via the vendor API"}`, string(encoded))
+ })
+
+ t.Run("cloneMeta deep-copies localized text", func(t *testing.T) {
+ registry := NewRegistry()
+ _, err := registry.Register(
+ routingTestPluginSource(
+ "localized-text",
+ 0,
+ `["model"]`,
+ `description: {en: "Video generation via the vendor API", zh: "通过厂商接口生成视频"},
+ usageSchema: {seconds: {type: "number", unit: "second", description: {en: "Generated media duration."}}},`,
+ "",
+ ),
+ Options{},
+ )
+ require.NoError(t, err)
+ snapshot := registry.Snapshot()
+ require.Len(t, snapshot.Override, 1)
+ snapshot.Override[0].Description["en"] = "changed"
+ snapshot.Override[0].UsageSchema["seconds"].Description["en"] = "changed"
+ plugin, ok := registry.Get("localized-text")
+ require.True(t, ok)
+ assert.Equal(t, "Video generation via the vendor API", plugin.Meta.Description["en"])
+ assert.Equal(t, "Generated media duration.", plugin.Meta.UsageSchema["seconds"].Description["en"])
+ })
+}
diff --git a/pkg/jsplugin/request.go b/pkg/jsplugin/request.go
new file mode 100644
index 000000000000..c21cd0576306
--- /dev/null
+++ b/pkg/jsplugin/request.go
@@ -0,0 +1,41 @@
+package jsplugin
+
+import (
+ "fmt"
+ "net"
+ "net/url"
+ "strings"
+)
+
+// ValidateRequestURL prevents plugins from directing a channel credential to
+// hosts other than the configured base URL or an administrator-approved host.
+func ValidateRequestURL(requestURL, baseURL string, allowedHosts []string) error {
+ request, err := url.Parse(requestURL)
+ if err != nil || request.Scheme == "" || request.Host == "" {
+ return fmt.Errorf("plugin request URL must be absolute")
+ }
+ base, err := url.Parse(baseURL)
+ if err != nil || base.Host == "" {
+ return fmt.Errorf("channel base URL is invalid")
+ }
+ requestHost := canonicalHost(request)
+ if requestHost == canonicalHost(base) {
+ return nil
+ }
+ for _, allowed := range allowedHosts {
+ allowedURL, parseErr := url.Parse("https://" + strings.TrimSpace(allowed))
+ if parseErr == nil && requestHost == canonicalHost(allowedURL) {
+ return nil
+ }
+ }
+ return fmt.Errorf("plugin request host %q is not allowed", request.Host)
+}
+
+func canonicalHost(value *url.URL) string {
+ host := strings.ToLower(value.Hostname())
+ port := value.Port()
+ if port == "" || port == "80" && value.Scheme == "http" || port == "443" && value.Scheme == "https" {
+ return host
+ }
+ return net.JoinHostPort(host, port)
+}
diff --git a/pkg/jsplugin/routing.go b/pkg/jsplugin/routing.go
new file mode 100644
index 000000000000..3faac1b1fe9a
--- /dev/null
+++ b/pkg/jsplugin/routing.go
@@ -0,0 +1,931 @@
+package jsplugin
+
+import (
+ "fmt"
+ "maps"
+ "net/http"
+ "regexp"
+ "slices"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/QuantumNous/new-api/constant"
+)
+
+type RouteType string
+
+const (
+ RouteTypeSubmit RouteType = "submit"
+ RouteTypeQuery RouteType = "query"
+ RouteTypeDynamic RouteType = "dynamic"
+)
+
+type Route struct {
+ Method string `json:"method"`
+ Path string `json:"path"`
+ Type RouteType `json:"type"`
+ Action string `json:"action,omitempty"`
+ Decode string `json:"decode,omitempty"`
+ Render string `json:"render,omitempty"`
+ TaskIDParam string `json:"taskIdParam,omitempty"`
+ // Models restricts this route to the listed models. The host matches the
+ // canonical top-level "model" body field before any JS hook runs; empty
+ // means unrestricted. Must be a subset of meta.models.
+ Models []string `json:"models,omitempty"`
+}
+
+// ProtocolClaim is one entry of meta.protocols. Models narrows the protocol's
+// endpoint bindings to a subset of meta.models; empty binds every model.
+// Supports names the request forms a mode-bearing protocol accepts; decode
+// and normalize rewrite it into host-table order.
+type ProtocolClaim struct {
+ Name string `json:"name"`
+ Models []string `json:"models,omitempty"`
+ Supports []string `json:"supports,omitempty"`
+ objectForm bool
+}
+
+// ProtocolMode is one client request form a host protocol operation accepts
+// and the plugin hook that implements it.
+type ProtocolMode struct {
+ Name string
+ Hook string
+}
+
+type BodyKind string
+
+const (
+ BodyNone BodyKind = "none"
+ BodyJSON BodyKind = "json"
+ BodyForm BodyKind = "form"
+ BodyMultipart BodyKind = "multipart"
+)
+
+type HostProtocolOperation struct {
+ Name string
+ Methods []string
+ Path string
+ BodyKinds []BodyKind
+ ModelField string
+ RequiredProtocolMembers []string
+ Modes []ProtocolMode
+ RequiredDriverHooks []string
+}
+
+type HostProtocolDefinition struct {
+ Name string
+ Operations []HostProtocolOperation
+}
+
+var hostProtocols = []HostProtocolDefinition{
+ {Name: "openai_responses", Operations: []HostProtocolOperation{
+ {Name: "create", Methods: []string{http.MethodPost}, Path: "/v1/responses", BodyKinds: []BodyKind{BodyJSON}, ModelField: "model", RequiredProtocolMembers: []string{"decodeRequest"}, Modes: []ProtocolMode{{Name: "stream", Hook: "renderEvents"}, {Name: "sync", Hook: "renderFinal"}, {Name: "background", Hook: "renderFinal"}}},
+ {Name: "retrieve", Methods: []string{http.MethodGet}, Path: "/v1/responses/:response_id", BodyKinds: []BodyKind{BodyNone}},
+ }},
+ {Name: "openai_video", Operations: []HostProtocolOperation{
+ {Name: "create", Methods: []string{http.MethodPost}, Path: "/v1/videos", BodyKinds: []BodyKind{BodyJSON, BodyMultipart}, ModelField: "model", RequiredProtocolMembers: []string{"decodeRequest"}},
+ {Name: "retrieve", Methods: []string{http.MethodGet}, Path: "/v1/videos/:task_id", BodyKinds: []BodyKind{BodyNone}, RequiredProtocolMembers: []string{"render"}},
+ {Name: "content", Methods: []string{http.MethodGet, http.MethodHead}, Path: "/v1/videos/:task_id/content", BodyKinds: []BodyKind{BodyNone}, RequiredDriverHooks: []string{"listArtifacts", "buildContentRequest"}},
+ }},
+}
+
+func HostProtocol(name string) (HostProtocolDefinition, bool) {
+ for _, definition := range hostProtocols {
+ if definition.Name == name {
+ return definition, true
+ }
+ }
+ return HostProtocolDefinition{}, false
+}
+
+func HostProtocols() []HostProtocolDefinition {
+ definitions := make([]HostProtocolDefinition, len(hostProtocols))
+ for index, definition := range hostProtocols {
+ definitions[index] = definition
+ definitions[index].Operations = append([]HostProtocolOperation(nil), definition.Operations...)
+ for operationIndex := range definitions[index].Operations {
+ operation := &definitions[index].Operations[operationIndex]
+ operation.Methods = append([]string(nil), operation.Methods...)
+ operation.BodyKinds = append([]BodyKind(nil), operation.BodyKinds...)
+ operation.RequiredProtocolMembers = append([]string(nil), operation.RequiredProtocolMembers...)
+ operation.Modes = append([]ProtocolMode(nil), operation.Modes...)
+ operation.RequiredDriverHooks = append([]string(nil), operation.RequiredDriverHooks...)
+ }
+ }
+ return definitions
+}
+
+// DefinedModes returns each distinct mode on the protocol in host-table order.
+func (d HostProtocolDefinition) DefinedModes() []ProtocolMode {
+ seen := make(map[string]struct{})
+ modes := make([]ProtocolMode, 0)
+ for _, operation := range d.Operations {
+ for _, mode := range operation.Modes {
+ if _, exists := seen[mode.Name]; exists {
+ continue
+ }
+ seen[mode.Name] = struct{}{}
+ modes = append(modes, mode)
+ }
+ }
+ return modes
+}
+
+func orderProtocolSupports(protocol string, supports []string) []string {
+ if len(supports) == 0 {
+ return supports
+ }
+ definition, ok := HostProtocol(protocol)
+ if !ok {
+ return supports
+ }
+ rank := make(map[string]int)
+ for index, mode := range definition.DefinedModes() {
+ rank[mode.Name] = index
+ }
+ ordered := append([]string(nil), supports...)
+ slices.SortStableFunc(ordered, func(left, right string) int {
+ leftRank, leftKnown := rank[left]
+ rightRank, rightKnown := rank[right]
+ switch {
+ case leftKnown && rightKnown:
+ if leftRank < rightRank {
+ return -1
+ }
+ if leftRank > rightRank {
+ return 1
+ }
+ return 0
+ case leftKnown:
+ return -1
+ case rightKnown:
+ return 1
+ default:
+ return 0
+ }
+ })
+ return ordered
+}
+
+func LookupHostProtocolOperation(method, path string) (string, HostProtocolOperation, bool) {
+ method = strings.ToUpper(strings.TrimSpace(method))
+ for _, definition := range hostProtocols {
+ for _, operation := range definition.Operations {
+ if operation.Path != path || operation.ModelField == "" {
+ continue
+ }
+ if slices.Contains(operation.Methods, method) {
+ return definition.Name, operation, true
+ }
+ }
+ }
+ return "", HostProtocolOperation{}, false
+}
+
+type RouteBinding struct {
+ Plugin *LoadedPlugin
+ Route Route
+}
+
+type ProtocolBinding struct {
+ Plugin *LoadedPlugin
+ Protocol string
+ Operation HostProtocolOperation
+ Model string
+}
+
+const (
+ ContextKeyPinnedPlugin = "task_plugin_pinned_plugin"
+ ContextKeyPinnedRoute = "task_plugin_pinned_route"
+ ContextKeyPinnedEndpoint = "task_plugin_pinned_endpoint"
+ ContextKeyRouteRequest = "task_plugin_route_request"
+ ContextKeyProtocolRequest = "task_plugin_protocol_request"
+)
+
+type PinnedPlugin struct {
+ Generation *RoutingGeneration
+ Plugin *LoadedPlugin
+}
+
+type PinnedRoute struct {
+ Generation *RoutingGeneration
+ Plugin *LoadedPlugin
+ Route Route
+}
+
+// PinnedEndpoint carries the exact generation and endpoint candidates selected
+// before distribution. Plugin initially names the deterministic request parser;
+// distribution may rebind it to another candidate from the same generation
+// when multiple legacy providers expose the same model.
+type PinnedEndpoint struct {
+ Generation *RoutingGeneration
+ Plugin *LoadedPlugin
+ Protocol string
+ Operation HostProtocolOperation
+ Model string
+ Candidates []ProtocolBinding
+}
+
+// RouteRequestContext is the canonical request view exposed to declarative
+// routing hooks. RequestBody contains decoded JSON or multipart text fields;
+// raw binary and multipart file bytes remain host-owned.
+type RouteRequestContext struct {
+ Path string `json:"path"`
+ Method string `json:"method"`
+ Params map[string]string `json:"params"`
+ Query map[string][]string `json:"query"`
+ Body any `json:"body"`
+ Files []map[string]any `json:"-"`
+ RequestBody any `json:"-"`
+}
+
+func (r RouteRequestContext) JSValue() map[string]any {
+ params := make(map[string]string, len(r.Params))
+ for key, value := range r.Params {
+ params[key] = value
+ }
+ query := make(map[string][]string, len(r.Query))
+ for key, values := range r.Query {
+ query[key] = append([]string(nil), values...)
+ }
+ return map[string]any{
+ "path": r.Path,
+ "method": r.Method,
+ "params": params,
+ "query": query,
+ "body": clonePluginRequestValue(r.Body),
+ }
+}
+
+func clonePluginRequestValue(value any) any {
+ switch typed := value.(type) {
+ case map[string]any:
+ cloned := make(map[string]any, len(typed))
+ for key, item := range typed {
+ cloned[key] = clonePluginRequestValue(item)
+ }
+ return cloned
+ case []any:
+ cloned := make([]any, len(typed))
+ for index, item := range typed {
+ cloned[index] = clonePluginRequestValue(item)
+ }
+ return cloned
+ case []string:
+ return append([]string(nil), typed...)
+ case map[string][]string:
+ cloned := make(map[string][]string, len(typed))
+ for key, values := range typed {
+ cloned[key] = append([]string(nil), values...)
+ }
+ return cloned
+ case []map[string]any:
+ cloned := make([]map[string]any, len(typed))
+ for index, item := range typed {
+ cloned[index] = clonePluginRequestValue(item).(map[string]any)
+ }
+ return cloned
+ default:
+ return value
+ }
+}
+
+type ProtocolRequestContext struct {
+ RouteRequestContext
+ Protocol string `json:"protocol"`
+ Operation string `json:"operation"`
+ Model string `json:"model"`
+ Stream bool `json:"stream"`
+}
+
+func (p ProtocolRequestContext) JSValue() map[string]any {
+ value := p.RouteRequestContext.JSValue()
+ value["protocol"] = p.Protocol
+ value["operation"] = p.Operation
+ value["model"] = p.Model
+ value["stream"] = p.Stream
+ return value
+}
+
+// SupportsHostProtocol reports whether the current host release has a
+// concrete wire/state machine for an otherwise valid manifest endpoint.
+func SupportsHostProtocol(protocol string) bool { _, ok := HostProtocol(protocol); return ok }
+
+// RoutingGeneration is an immutable, request-pinnable view of all effective
+// plugins and their deterministic routing indexes.
+type RoutingGeneration struct {
+ Number uint64
+ PublishedAt time.Time
+
+ byKey map[string]*LoadedPlugin
+ byModel map[string]*LoadedPlugin
+ byChannelType map[int]*LoadedPlugin
+ routeIndex map[string]RouteBinding
+ protocolIndex map[string][]ProtocolBinding
+ plugins []*LoadedPlugin
+ routes []RouteBinding
+ runtime http.Handler
+ retainCurrent map[string]struct{}
+}
+
+var (
+ routeMethodPattern = regexp.MustCompile(`^(GET|POST|PUT|PATCH|DELETE)$`)
+ pathNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
+ staticSegment = regexp.MustCompile(`^[A-Za-z0-9._~-]+$`)
+ memberNamePattern = regexp.MustCompile(`^[A-Za-z_$][A-Za-z0-9_$]*$`)
+)
+
+var reservedRouteNamespaces = []string{
+ "/api",
+ "/assets",
+ "/setup",
+ "/v1/tasks",
+ "/console",
+ "/login",
+ "/forbidden",
+ "/sign-in",
+ "/sign-up",
+ "/forgot-password",
+ "/oauth",
+ "/otp",
+ "/register",
+ "/reset",
+ "/privacy-policy",
+ "/user-agreement",
+ "/about",
+ "/pricing",
+ "/rankings",
+ "/user",
+ "/401",
+ "/403",
+ "/404",
+ "/500",
+ "/503",
+ "/chat2link",
+ "/system-settings",
+ "/channels",
+ "/chat",
+ "/dashboard",
+ "/errors",
+ "/keys",
+ "/models",
+ "/playground",
+ "/profile",
+ "/redemption-codes",
+ "/subscriptions",
+ "/system-info",
+ "/task-plugins",
+ "/usage-logs",
+ "/users",
+ "/wallet",
+}
+
+func (g *RoutingGeneration) Get(key string) (*LoadedPlugin, bool) {
+ if g == nil {
+ return nil, false
+ }
+ plugin, ok := g.byKey[key]
+ return plugin, ok
+}
+
+func (g *RoutingGeneration) GetByChannelType(channelType int) (*LoadedPlugin, bool) {
+ if g == nil || channelType == 0 || channelType == constant.ChannelTypeTaskPlugin {
+ return nil, false
+ }
+ plugin, ok := g.byChannelType[channelType]
+ return plugin, ok
+}
+
+// GetByModel returns the deterministic effective plugin metadata used for a
+// model-level host concern such as billing. When multiple providers expose the
+// same model name, the first plugin in generation order owns that shared
+// metadata view.
+func (g *RoutingGeneration) GetByModel(model string) (*LoadedPlugin, bool) {
+ if g == nil || model == "" {
+ return nil, false
+ }
+ plugin, ok := g.byModel[model]
+ return plugin, ok
+}
+
+// LookupDeclaredRoute resolves a manifest path declaration. It does not match
+// an incoming concrete URL; runtime matching is delegated to Gin.
+func (g *RoutingGeneration) LookupDeclaredRoute(method, path string) (RouteBinding, bool) {
+ if g == nil {
+ return RouteBinding{}, false
+ }
+ normalizedMethod, err := normalizeRouteMethod(method)
+ if err != nil {
+ return RouteBinding{}, false
+ }
+ shape, err := routePathShape(path)
+ if err != nil {
+ return RouteBinding{}, false
+ }
+ binding, ok := g.routeIndex[normalizedMethod+" "+shape]
+ return binding, ok
+}
+
+func (g *RoutingGeneration) LookupEndpoint(method, path, model string) (ProtocolBinding, bool) {
+ if g == nil {
+ return ProtocolBinding{}, false
+ }
+ normalizedMethod, err := normalizeRouteMethod(method)
+ if err != nil {
+ return ProtocolBinding{}, false
+ }
+ bindings := g.protocolIndex[endpointIndexKey(normalizedMethod, path, model)]
+ if len(bindings) == 0 {
+ return ProtocolBinding{}, false
+ }
+ return bindings[0], true
+}
+
+// LookupEndpointCandidates returns every legacy provider implementation that
+// can serve one shared model endpoint. Candidate order is deterministic and
+// the first binding is the parser used before channel distribution.
+func (g *RoutingGeneration) LookupEndpointCandidates(method, path, model string) []ProtocolBinding {
+ if g == nil {
+ return nil
+ }
+ normalizedMethod, err := normalizeRouteMethod(method)
+ if err != nil {
+ return nil
+ }
+ bindings := g.protocolIndex[endpointIndexKey(normalizedMethod, path, model)]
+ return append([]ProtocolBinding(nil), bindings...)
+}
+
+func (g *RoutingGeneration) Plugins() []*LoadedPlugin {
+ if g == nil {
+ return nil
+ }
+ return append([]*LoadedPlugin(nil), g.plugins...)
+}
+
+func (g *RoutingGeneration) Routes() []RouteBinding {
+ if g == nil {
+ return nil
+ }
+ return append([]RouteBinding(nil), g.routes...)
+}
+
+// RuntimeHandler is the inner router built for this exact generation. It is
+// published in the same atomic pointer as the routing indexes.
+func (g *RoutingGeneration) RuntimeHandler() http.Handler {
+ if g == nil {
+ return nil
+ }
+ return g.runtime
+}
+
+func (g *RoutingGeneration) RetainsIncumbent(key string) bool {
+ if g == nil {
+ return false
+ }
+ _, retained := g.retainCurrent[key]
+ return retained
+}
+
+// RebuildWithPlugins creates a generation from the supplied exact plugin
+// pointers. It is used when a rejected hot update must retain the incumbent
+// runtime object for that key.
+func (g *RoutingGeneration) RebuildWithPlugins(plugins []*LoadedPlugin) (*RoutingGeneration, error) {
+ if g == nil {
+ return nil, fmt.Errorf("cannot rebuild a nil routing generation")
+ }
+ byKey := make(map[string]*LoadedPlugin, len(plugins))
+ for _, plugin := range plugins {
+ if plugin == nil {
+ return nil, fmt.Errorf("cannot rebuild routing generation with a nil plugin")
+ }
+ if _, exists := g.byKey[plugin.Meta.Key]; !exists {
+ return nil, fmt.Errorf("plugin %q is not present in routing generation %d", plugin.Meta.Key, g.Number)
+ }
+ if _, duplicate := byKey[plugin.Meta.Key]; duplicate {
+ return nil, fmt.Errorf("plugin %q appears more than once in routing generation rebuild", plugin.Meta.Key)
+ }
+ byKey[plugin.Meta.Key] = plugin
+ }
+ rebuilt, err := buildRoutingGeneration(byKey, nil, false, g.Number)
+ if err != nil {
+ return nil, err
+ }
+ rebuilt.PublishedAt = g.PublishedAt
+ rebuilt.retainCurrent = cloneStringSet(g.retainCurrent)
+ return rebuilt, nil
+}
+
+// WithRuntime returns a shallow immutable copy carrying the prepared inner
+// handler. Callers use it before publication; published generations must not
+// be mutated.
+func (g *RoutingGeneration) WithRuntime(handler http.Handler) *RoutingGeneration {
+ if g == nil {
+ return nil
+ }
+ prepared := *g
+ prepared.runtime = handler
+ return &prepared
+}
+
+func cloneStringSet(source map[string]struct{}) map[string]struct{} {
+ if len(source) == 0 {
+ return nil
+ }
+ clone := make(map[string]struct{}, len(source))
+ for key := range source {
+ clone[key] = struct{}{}
+ }
+ return clone
+}
+
+func normalizeRouteMethod(method string) (string, error) {
+ method = strings.ToUpper(strings.TrimSpace(method))
+ if !routeMethodPattern.MatchString(method) {
+ return "", fmt.Errorf("plugin route method %q is not supported", method)
+ }
+ return method, nil
+}
+
+// NormalizeRoutePath validates the canonical path syntax used by plugin route
+// declarations. A trailing slash is allowed and remains significant.
+func NormalizeRoutePath(routePath string) (string, error) {
+ if routePath == "" || routePath[0] != '/' {
+ return "", fmt.Errorf("plugin route path must start with /")
+ }
+ if routePath == "/" {
+ return "", fmt.Errorf("plugin route path / is reserved")
+ }
+ if strings.ContainsAny(routePath, "?#%") {
+ return "", fmt.Errorf("plugin route path %q must not contain a query, fragment, or percent-encoding", routePath)
+ }
+ if strings.Contains(routePath, "//") {
+ return "", fmt.Errorf("plugin route path %q must not contain empty segments", routePath)
+ }
+
+ segments := strings.Split(strings.TrimPrefix(routePath, "/"), "/")
+ seenNames := make(map[string]struct{})
+ for index, segment := range segments {
+ if segment == "" && index == len(segments)-1 {
+ continue
+ }
+ if segment == "." || segment == ".." {
+ return "", fmt.Errorf("plugin route path %q must not contain dot segments", routePath)
+ }
+ if after, ok := strings.CutPrefix(segment, ":"); ok {
+ name := after
+ if !pathNamePattern.MatchString(name) {
+ return "", fmt.Errorf("plugin route path %q has invalid parameter %q", routePath, segment)
+ }
+ if _, exists := seenNames[name]; exists {
+ return "", fmt.Errorf("plugin route path %q repeats parameter %q", routePath, name)
+ }
+ seenNames[name] = struct{}{}
+ continue
+ }
+ if after, ok := strings.CutPrefix(segment, "*"); ok {
+ name := after
+ if index != len(segments)-1 || !pathNamePattern.MatchString(name) {
+ return "", fmt.Errorf("plugin route path %q has an invalid catch-all segment", routePath)
+ }
+ if _, exists := seenNames[name]; exists {
+ return "", fmt.Errorf("plugin route path %q repeats parameter %q", routePath, name)
+ }
+ seenNames[name] = struct{}{}
+ continue
+ }
+ if !staticSegment.MatchString(segment) {
+ return "", fmt.Errorf("plugin route path %q has invalid segment %q", routePath, segment)
+ }
+ }
+ return routePath, nil
+}
+
+func routePathShape(routePath string) (string, error) {
+ normalized, err := NormalizeRoutePath(routePath)
+ if err != nil {
+ return "", err
+ }
+ segments := strings.Split(strings.TrimPrefix(normalized, "/"), "/")
+ for index, segment := range segments {
+ if strings.HasPrefix(segment, ":") {
+ segments[index] = ":"
+ } else if strings.HasPrefix(segment, "*") {
+ segments[index] = "*"
+ }
+ }
+ return "/" + strings.Join(segments, "/"), nil
+}
+
+func endpointIndexKey(method, path, model string) string {
+ return method + "\x00" + path + "\x00" + model
+}
+
+func intersectingReservedNamespace(routePath string) (string, bool) {
+ for _, namespace := range reservedRouteNamespaces {
+ if routePatternIntersectsNamespace(routePath, namespace) {
+ return namespace, true
+ }
+ }
+ return "", false
+}
+
+func routePatternIntersectsNamespace(routePath, namespace string) bool {
+ routeSegments := strings.Split(strings.Trim(strings.TrimPrefix(routePath, "/"), "/"), "/")
+ namespaceSegments := strings.Split(strings.TrimPrefix(namespace, "/"), "/")
+ for index, namespaceSegment := range namespaceSegments {
+ if index >= len(routeSegments) {
+ return false
+ }
+ routeSegment := routeSegments[index]
+ if strings.HasPrefix(routeSegment, "*") {
+ return true
+ }
+ if !strings.HasPrefix(routeSegment, ":") && routeSegment != namespaceSegment {
+ return false
+ }
+ }
+ return true
+}
+
+func validateRoute(route *Route) error {
+ if route.Method != strings.ToUpper(strings.TrimSpace(route.Method)) {
+ return fmt.Errorf("plugin route method %q must use canonical uppercase spelling", route.Method)
+ }
+ method, err := normalizeRouteMethod(route.Method)
+ if err != nil {
+ return err
+ }
+ route.Method = method
+ route.Path, err = NormalizeRoutePath(route.Path)
+ if err != nil {
+ return err
+ }
+ if namespace, reserved := intersectingReservedNamespace(route.Path); reserved {
+ return fmt.Errorf("plugin route path %q intersects reserved namespace %s", route.Path, namespace)
+ }
+ switch route.Type {
+ case RouteTypeSubmit:
+ if route.Decode == "" || route.Render == "" || route.TaskIDParam != "" {
+ return fmt.Errorf("submit route %s %s must declare decode and render and must not declare taskIdParam", route.Method, route.Path)
+ }
+ case RouteTypeQuery:
+ if route.Decode != "" || strings.TrimSpace(route.Render) == "" {
+ return fmt.Errorf("query route %s %s must declare render and must not declare decode", route.Method, route.Path)
+ }
+ if route.Action != "" {
+ return fmt.Errorf("query route %s %s must not declare action", route.Method, route.Path)
+ }
+ if route.TaskIDParam == "" {
+ route.TaskIDParam = "task_id"
+ }
+ if !pathNamePattern.MatchString(route.TaskIDParam) {
+ return fmt.Errorf("query route %s %s has invalid taskIdParam %q", route.Method, route.Path, route.TaskIDParam)
+ }
+ if !pathHasParameter(route.Path, route.TaskIDParam) {
+ return fmt.Errorf("query route %s %s must contain :%s", route.Method, route.Path, route.TaskIDParam)
+ }
+ case RouteTypeDynamic:
+ if route.Decode == "" || route.Render == "" || route.TaskIDParam != "" {
+ return fmt.Errorf("dynamic route %s %s must declare decode and render and must not declare taskIdParam", route.Method, route.Path)
+ }
+ default:
+ return fmt.Errorf("plugin route %s %s has unsupported type %q", route.Method, route.Path, route.Type)
+ }
+ if route.Decode != "" && !memberNamePattern.MatchString(route.Decode) {
+ return fmt.Errorf("plugin route %s %s has invalid decode %q", route.Method, route.Path, route.Decode)
+ }
+ if route.Render != "" && !memberNamePattern.MatchString(route.Render) {
+ return fmt.Errorf("plugin route %s %s has invalid render %q", route.Method, route.Path, route.Render)
+ }
+ if strings.TrimSpace(route.Action) != route.Action {
+ return fmt.Errorf("plugin route %s %s action must not have surrounding whitespace", route.Method, route.Path)
+ }
+ if len(route.Models) > 0 {
+ if route.Type == RouteTypeQuery {
+ return fmt.Errorf("query route %s %s must not declare models", route.Method, route.Path)
+ }
+ if err := validateModelScope(route.Models, fmt.Sprintf("route %s %s", route.Method, route.Path)); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// validateModelScope enforces the shared rules for route.models and
+// per-protocol models entries: non-empty canonical names, no duplicates.
+func validateModelScope(models []string, subject string) error {
+ seen := make(map[string]struct{}, len(models))
+ for _, model := range models {
+ if strings.TrimSpace(model) == "" || strings.TrimSpace(model) != model {
+ return fmt.Errorf("plugin %s models must contain non-empty canonical names", subject)
+ }
+ if _, duplicate := seen[model]; duplicate {
+ return fmt.Errorf("plugin %s models must be unique", subject)
+ }
+ seen[model] = struct{}{}
+ }
+ return nil
+}
+
+func pathHasParameter(routePath, name string) bool {
+ return slices.Contains(strings.Split(routePath, "/"), ":"+name)
+}
+
+func ResolveRouteAction(route Route, resolvedAction string) string {
+ if strings.TrimSpace(resolvedAction) != "" {
+ return resolvedAction
+ }
+ return route.Action
+}
+
+func buildRoutingGeneration(factory, override map[string]*LoadedPlugin, overrideEnabled bool, number uint64) (*RoutingGeneration, error) {
+ effective := effectivePlugins(factory, override, overrideEnabled)
+ return buildRoutingGenerationFromPlugins(effective, number)
+}
+
+func buildRoutingGenerationAdmitting(
+ factory, override map[string]*LoadedPlugin,
+ overrideEnabled bool,
+ number uint64,
+ current *RoutingGeneration,
+ retainCurrent map[string]struct{},
+) (*RoutingGeneration, map[string]string, error) {
+ candidates := effectivePlugins(factory, override, overrideEnabled)
+ accepted := make(map[string]*LoadedPlugin, len(candidates))
+ currentByKey := make(map[string]*LoadedPlugin)
+ if current != nil {
+ for _, plugin := range current.plugins {
+ currentByKey[plugin.Meta.Key] = plugin
+ }
+ }
+ generation, err := buildRoutingGenerationFromPlugins(accepted, number)
+ if err != nil {
+ return nil, nil, fmt.Errorf("initialize routing generation: %w", err)
+ }
+
+ unchangedKeys := make([]string, 0)
+ changedKeys := make([]string, 0)
+ newKeys := make([]string, 0)
+ for key, candidate := range candidates {
+ incumbent, exists := currentByKey[key]
+ switch {
+ case exists && candidate == incumbent:
+ unchangedKeys = append(unchangedKeys, key)
+ case exists:
+ changedKeys = append(changedKeys, key)
+ default:
+ newKeys = append(newKeys, key)
+ }
+ }
+ sort.Strings(unchangedKeys)
+ sort.Strings(changedKeys)
+ sort.Strings(newKeys)
+
+ routingErrors := make(map[string]string)
+ orderedKeys := append(unchangedKeys, changedKeys...)
+ orderedKeys = append(orderedKeys, newKeys...)
+ rejectedKeys := make([]string, 0)
+ for _, key := range orderedKeys {
+ candidate := candidates[key]
+ accepted[key] = candidate
+ trial, trialErr := buildRoutingGenerationFromPlugins(accepted, number)
+ if trialErr == nil {
+ generation = trial
+ continue
+ }
+ delete(accepted, key)
+ routingErrors[key] = fmt.Sprintf("plugin %s rejected from routing generation: %v", key, trialErr)
+ rejectedKeys = append(rejectedKeys, key)
+ }
+
+ for _, key := range rejectedKeys {
+ candidate := candidates[key]
+ incumbent, hasIncumbent := currentByKey[key]
+ _, mayRetain := retainCurrent[key]
+ if !hasIncumbent || !mayRetain || incumbent == candidate {
+ continue
+ }
+ accepted[key] = incumbent
+ fallback, fallbackErr := buildRoutingGenerationFromPlugins(accepted, number)
+ if fallbackErr == nil {
+ generation = fallback
+ continue
+ }
+ delete(accepted, key)
+ }
+ generation.retainCurrent = cloneStringSet(retainCurrent)
+ return generation, routingErrors, nil
+}
+
+func effectivePlugins(factory, override map[string]*LoadedPlugin, overrideEnabled bool) map[string]*LoadedPlugin {
+ effective := make(map[string]*LoadedPlugin, len(factory)+len(override))
+ maps.Copy(effective, factory)
+ if overrideEnabled {
+ maps.Copy(effective, override)
+ }
+ return effective
+}
+
+func buildRoutingGenerationFromPlugins(effective map[string]*LoadedPlugin, number uint64) (*RoutingGeneration, error) {
+ keys := make([]string, 0, len(effective))
+ for key := range effective {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+
+ generation := &RoutingGeneration{
+ Number: number,
+ PublishedAt: time.Now(),
+ byKey: make(map[string]*LoadedPlugin, len(effective)),
+ byModel: make(map[string]*LoadedPlugin),
+ byChannelType: make(map[int]*LoadedPlugin),
+ routeIndex: make(map[string]RouteBinding),
+ protocolIndex: make(map[string][]ProtocolBinding),
+ plugins: make([]*LoadedPlugin, 0, len(effective)),
+ }
+ for _, key := range keys {
+ plugin := effective[key]
+ generation.byKey[key] = plugin
+ generation.plugins = append(generation.plugins, plugin)
+ for _, model := range plugin.Meta.Models {
+ if _, exists := generation.byModel[model]; !exists {
+ generation.byModel[model] = plugin
+ }
+ }
+
+ for _, channelType := range plugin.Meta.ChannelTypes {
+ if channelType == 0 || channelType == constant.ChannelTypeTaskPlugin {
+ continue
+ }
+ if other, exists := generation.byChannelType[channelType]; exists {
+ return nil, fmt.Errorf("plugin %s channelType %d conflicts with plugin %s", plugin.Meta.Key, channelType, other.Meta.Key)
+ }
+ generation.byChannelType[channelType] = plugin
+ }
+
+ for _, route := range plugin.Meta.Routes {
+ shape, err := routePathShape(route.Path)
+ if err != nil {
+ return nil, err
+ }
+ indexKey := route.Method + " " + shape
+ if other, exists := generation.routeIndex[indexKey]; exists {
+ return nil, fmt.Errorf("plugin %s route %s %s conflicts with plugin %s route %s", plugin.Meta.Key, route.Method, route.Path, other.Plugin.Meta.Key, other.Route.Path)
+ }
+ binding := RouteBinding{Plugin: plugin, Route: route}
+ generation.routeIndex[indexKey] = binding
+ generation.routes = append(generation.routes, binding)
+ }
+
+ for _, claim := range plugin.Meta.Protocols {
+ definition, _ := HostProtocol(claim.Name)
+ boundModels := plugin.Meta.Models
+ if len(claim.Models) > 0 {
+ boundModels = claim.Models
+ }
+ for _, operation := range definition.Operations {
+ if operation.ModelField == "" {
+ continue
+ }
+ for _, method := range operation.Methods {
+ for _, model := range boundModels {
+ indexKey := endpointIndexKey(method, operation.Path, model)
+ bindings := generation.protocolIndex[indexKey]
+ if len(bindings) > 0 {
+ other := bindings[0]
+ legacyProviders := len(plugin.Meta.ChannelTypes) > 0 && len(other.Plugin.Meta.ChannelTypes) > 0
+ if !legacyProviders || claim.Name != other.Protocol {
+ return nil, fmt.Errorf("plugin %s protocol %s %s model %q conflicts with plugin %s", plugin.Meta.Key, method, operation.Path, model, other.Plugin.Meta.Key)
+ }
+ }
+ generation.protocolIndex[indexKey] = append(bindings, ProtocolBinding{Plugin: plugin, Protocol: claim.Name, Operation: operation, Model: model})
+ }
+ }
+ }
+ }
+ }
+ return generation, nil
+}
+
+// PreflightRoutingConflict reports whether admitting candidate into the
+// current generation would collide on a channel type, native route, or
+// protocol-model binding. A same-key entry is replaced first so re-uploading
+// a plugin (or overriding a factory built-in) does not self-conflict.
+func PreflightRoutingConflict(current *RoutingGeneration, candidate *LoadedPlugin) error {
+ if candidate == nil {
+ return fmt.Errorf("cannot preflight a nil plugin")
+ }
+ effective := make(map[string]*LoadedPlugin)
+ number := uint64(0)
+ if current != nil {
+ for _, plugin := range current.Plugins() {
+ effective[plugin.Meta.Key] = plugin
+ }
+ number = current.Number
+ }
+ effective[candidate.Meta.Key] = candidate
+ _, err := buildRoutingGenerationFromPlugins(effective, number)
+ return err
+}
diff --git a/pkg/jsplugin/routing_test.go b/pkg/jsplugin/routing_test.go
new file mode 100644
index 000000000000..d187c0e975db
--- /dev/null
+++ b/pkg/jsplugin/routing_test.go
@@ -0,0 +1,1046 @@
+package jsplugin
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRegistryIndexesNativeRoutesAndProtocolsInOneGeneration(t *testing.T) {
+ registry := NewRegistry()
+ plugin, err := CompilePlugin(routingTestPluginSource(
+ "routing-alpha",
+ 50,
+ `["video-alpha"]`,
+ `routes: [
+ {method: "POST", path: "/vendor/videos", type: "submit", action: "generate", decode: "decodeVideo", render: "videoCreated"},
+ {method: "GET", path: "/vendor/videos/:task_id", type: "query", render: "videoStatus"}
+ ],
+ protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
+ `export const native = {
+ decodeVideo: function(ctx) { return {kind: "submit", model: "video-alpha", requestBody: ctx.body.value}; },
+ videoCreated: function(ctx, task) { return task; },
+ videoStatus: function(ctx, task) { return task; }
+ };
+ export const protocols = {openai_responses: {
+ decodeRequest: function(ctx) { return {kind: "submit", model: ctx.model, requestBody: ctx.body.value}; },
+ renderEvents: function() { return {events: [], state: null, done: false}; },
+ renderFinal: function(ctx, task) { return task; }
+ }};`,
+ ), Options{})
+ require.NoError(t, err)
+
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{plugin}))
+ generation := registry.Generation()
+ assert.Equal(t, uint64(1), generation.Number)
+
+ byKey, ok := generation.Get("routing-alpha")
+ require.True(t, ok)
+ assert.Same(t, plugin, byKey)
+ byType, ok := generation.GetByChannelType(50)
+ require.True(t, ok)
+ assert.Same(t, plugin, byType)
+ byModel, ok := generation.GetByModel("video-alpha")
+ require.True(t, ok)
+ assert.Same(t, plugin, byModel)
+
+ route, ok := generation.LookupDeclaredRoute("get", "/vendor/videos/:id")
+ require.True(t, ok)
+ assert.Equal(t, "routing-alpha", route.Plugin.Meta.Key)
+ assert.Equal(t, "task_id", route.Route.TaskIDParam)
+
+ endpoint, ok := generation.LookupEndpoint("POST", "/v1/responses", "video-alpha")
+ require.True(t, ok)
+ assert.Equal(t, "openai_responses", endpoint.Protocol)
+ assert.Equal(t, []ProtocolBinding{endpoint}, generation.LookupEndpointCandidates("POST", "/v1/responses", "video-alpha"))
+}
+
+func TestRegistryIndexesSharedEndpointCandidatesForDistinctLegacyProviders(t *testing.T) {
+ registry := NewRegistry()
+ gemini := mustCompileRoutingPlugin(t, "gemini-provider", 24, `["shared-video"]`,
+ `protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
+ routingProtocolExport("openai_responses"))
+ vertex := mustCompileRoutingPlugin(t, "vertex-provider", 41, `["shared-video"]`,
+ `protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
+ routingProtocolExport("openai_responses"))
+
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{vertex, gemini}))
+ candidates := registry.Generation().LookupEndpointCandidates("POST", "/v1/responses", "shared-video")
+ require.Len(t, candidates, 2)
+ assert.Equal(t, "gemini-provider", candidates[0].Plugin.Meta.Key)
+ assert.Equal(t, "vertex-provider", candidates[1].Plugin.Meta.Key)
+ assert.Empty(t, registry.RoutingErrors())
+}
+
+func TestSupportsRegisteredHostProtocols(t *testing.T) {
+ assert.True(t, SupportsHostProtocol("openai_responses"))
+ assert.True(t, SupportsHostProtocol("openai_video"))
+ assert.False(t, SupportsHostProtocol("plugin_owned_wire"))
+}
+
+func TestLookupHostProtocolOperationExcludesRetrieveWithoutModelField(t *testing.T) {
+ _, _, ok := LookupHostProtocolOperation(http.MethodGet, "/v1/responses/:response_id")
+ assert.False(t, ok)
+ _, _, ok = LookupHostProtocolOperation(http.MethodPost, "/v1/responses")
+ assert.True(t, ok)
+}
+
+func TestPerProtocolModelsNarrowEndpointBindings(t *testing.T) {
+ registry := NewRegistry()
+ plugin := mustCompileRoutingPlugin(t, "narrow-protocol", 50, `["gpt-5.5", "gpt-5.6"]`,
+ `protocols: [{name: "openai_responses", models: ["gpt-5.5"], supports: ["stream", "sync", "background"]}],`,
+ routingProtocolExport("openai_responses"))
+
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{plugin}))
+ generation := registry.Generation()
+
+ bound, ok := generation.LookupEndpoint("POST", "/v1/responses", "gpt-5.5")
+ require.True(t, ok)
+ assert.Same(t, plugin, bound.Plugin)
+ _, ok = generation.LookupEndpoint("POST", "/v1/responses", "gpt-5.6")
+ assert.False(t, ok, "model outside the protocol claim must fall through to the Go relay")
+ assert.Empty(t, generation.LookupEndpointCandidates("POST", "/v1/responses", "gpt-5.6"))
+}
+
+func TestPerProtocolModelsAllowDisjointPluginsOnSharedProtocol(t *testing.T) {
+ registry := NewRegistry()
+ left := mustCompileRoutingPlugin(t, "disjoint-left", 0, `["model-a"]`,
+ `protocols: [{name: "openai_responses", models: ["model-a"], supports: ["stream", "sync", "background"]}],`,
+ routingProtocolExport("openai_responses"))
+ right := mustCompileRoutingPlugin(t, "disjoint-right", 0, `["model-b"]`,
+ `protocols: [{name: "openai_responses", models: ["model-b"], supports: ["stream", "sync", "background"]}],`,
+ routingProtocolExport("openai_responses"))
+
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{left, right}))
+ generation := registry.Generation()
+ assert.Empty(t, registry.RoutingErrors())
+
+ boundA, ok := generation.LookupEndpoint("POST", "/v1/responses", "model-a")
+ require.True(t, ok)
+ assert.Same(t, left, boundA.Plugin)
+ boundB, ok := generation.LookupEndpoint("POST", "/v1/responses", "model-b")
+ require.True(t, ok)
+ assert.Same(t, right, boundB.Plugin)
+}
+
+func TestPreflightRoutingConflict(t *testing.T) {
+ first := mustCompileRoutingPlugin(t, "preflight-first", 90, `["model-a"]`, "", "")
+ second := mustCompileRoutingPlugin(t, "preflight-second", 90, `["model-b"]`, "", "")
+ replacement := mustCompileRoutingPlugin(t, "preflight-first", 90, `["model-a-v2"]`, "", "")
+
+ require.NoError(t, PreflightRoutingConflict(nil, first), "a lone candidate against a nil generation must be admitted")
+
+ registry := NewRegistry()
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{first}))
+ current := registry.Generation()
+
+ err := PreflightRoutingConflict(current, second)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "preflight-first")
+ assert.Contains(t, err.Error(), "channelType 90 conflicts")
+
+ require.NoError(t, PreflightRoutingConflict(current, replacement), "same-key re-upload must replace rather than self-conflict")
+
+ left := mustCompileRoutingPlugin(t, "preflight-left", 0, `["shared-model"]`,
+ `protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
+ routingProtocolExport("openai_responses"))
+ right := mustCompileRoutingPlugin(t, "preflight-right", 0, `["shared-model"]`,
+ `protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
+ routingProtocolExport("openai_responses"))
+ protocolRegistry := NewRegistry()
+ require.NoError(t, protocolRegistry.ReplaceOverrides([]*LoadedPlugin{left}))
+ protocolErr := PreflightRoutingConflict(protocolRegistry.Generation(), right)
+ require.Error(t, protocolErr)
+ assert.Contains(t, protocolErr.Error(), "preflight-left")
+ assert.Contains(t, protocolErr.Error(), `model "shared-model" conflicts`)
+}
+
+func TestPerProtocolModelsStillConflictOnOverlap(t *testing.T) {
+ registry := NewRegistry()
+ first := mustCompileRoutingPlugin(t, "overlap-first", 0, `["shared-model"]`,
+ `protocols: [{name: "openai_responses", models: ["shared-model"], supports: ["stream", "sync", "background"]}],`,
+ routingProtocolExport("openai_responses"))
+ second := mustCompileRoutingPlugin(t, "overlap-second", 0, `["shared-model"]`,
+ `protocols: [{name: "openai_responses", models: ["shared-model"], supports: ["stream", "sync", "background"]}],`,
+ routingProtocolExport("openai_responses"))
+
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{first, second}))
+ routingErrors := registry.RoutingErrors()
+ require.Len(t, routingErrors, 1, "one of two overlapping claims must be rejected from the generation")
+ for _, message := range routingErrors {
+ assert.Contains(t, message, "conflicts")
+ }
+ binding, ok := registry.Generation().LookupEndpoint("POST", "/v1/responses", "shared-model")
+ require.True(t, ok)
+ candidates := registry.Generation().LookupEndpointCandidates("POST", "/v1/responses", "shared-model")
+ assert.Len(t, candidates, 1)
+ assert.NotNil(t, binding.Plugin)
+}
+
+func TestProtocolClaimDecodeAndValidation(t *testing.T) {
+ tests := []struct {
+ name string
+ models string
+ metaFields string
+ exports string
+ errContains string
+ wantProtocols []ProtocolClaim
+ }{
+ {
+ name: "string and object entries mix",
+ models: `["gpt-5.5", "gpt-5.6"]`,
+ metaFields: `protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}, {name: "openai_video", models: ["gpt-5.5"]}],`,
+ exports: `export const protocols = {
+ openai_responses: {
+ decodeRequest: function(ctx) { return ctx; },
+ renderEvents: function() { return {events: [], state: null, done: false}; },
+ renderFinal: function(ctx, task) { return task; }
+ },
+ openai_video: {
+ decodeRequest: function(ctx) { return ctx; },
+ render: function(ctx, task) { return task; }
+ }
+ };
+ export function listArtifacts() { return []; }
+ export function buildContentRequest() { return {}; }`,
+ wantProtocols: []ProtocolClaim{
+ {Name: "openai_responses", Supports: []string{"stream", "sync", "background"}, objectForm: true},
+ {Name: "openai_video", Models: []string{"gpt-5.5"}, objectForm: true},
+ },
+ },
+ {
+ name: "absent protocols is fine",
+ models: `["gpt-5.5"]`,
+ wantProtocols: []ProtocolClaim{},
+ },
+ {
+ name: "object with unknown field",
+ models: `["gpt-5.5"]`,
+ metaFields: `protocols: [{name: "openai_responses", models: ["gpt-5.5"], extra: true}],`,
+ errContains: `unknown field "extra"`,
+ },
+ {
+ name: "model outside meta models",
+ models: `["gpt-5.5"]`,
+ metaFields: `protocols: [{name: "openai_responses", models: ["gpt-9.9"], supports: ["stream", "sync", "background"]}],`,
+ errContains: "is not declared in plugin meta models",
+ },
+ {
+ name: "duplicate models in claim",
+ models: `["gpt-5.5"]`,
+ metaFields: `protocols: [{name: "openai_responses", models: ["gpt-5.5", "gpt-5.5"], supports: ["stream", "sync", "background"]}],`,
+ errContains: "models must be unique",
+ },
+ {
+ name: "blank model in claim",
+ models: `["gpt-5.5"]`,
+ metaFields: `protocols: [{name: "openai_responses", models: [" "], supports: ["stream", "sync", "background"]}],`,
+ errContains: "non-empty canonical names",
+ },
+ {
+ name: "empty models rejected",
+ models: `["gpt-5.5"]`,
+ metaFields: `protocols: [{name: "openai_responses", models: []}],`,
+ errContains: "models must contain at least one model",
+ },
+ {
+ name: "explicit null protocols",
+ models: `["gpt-5.5"]`,
+ metaFields: `protocols: null,`,
+ errContains: "must be an array",
+ },
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ exports := testCase.exports
+ if exports == "" && testCase.errContains != "" {
+ exports = routingProtocolExport("openai_responses")
+ }
+ plugin, err := CompilePlugin(
+ routingTestPluginSource("claim-decode", 0, testCase.models, testCase.metaFields, exports),
+ Options{},
+ )
+ if testCase.errContains != "" {
+ require.ErrorContains(t, err, testCase.errContains)
+ return
+ }
+ require.NoError(t, err)
+ require.Equal(t, testCase.wantProtocols, plugin.Meta.Protocols)
+ })
+ }
+}
+
+func TestRouteModelsDecodeAndValidation(t *testing.T) {
+ routeExports := `export const native = {
+ decodeJob: function(ctx) { return {kind: "submit", model: "gpt-5.5", requestBody: ctx.body.value}; },
+ jobCreated: function(ctx, task) { return task; },
+ jobStatus: function(ctx, task) { return task; }
+ };`
+ tests := []struct {
+ name string
+ models string
+ metaFields string
+ errContains string
+ }{
+ {
+ name: "submit route with models",
+ models: `["gpt-5.5", "gpt-5.6"]`,
+ metaFields: `routes: [{method: "POST", path: "/v1/batch", type: "submit", models: ["gpt-5.5"], decode: "decodeJob", render: "jobCreated"}],`,
+ },
+ {
+ name: "query route rejects models",
+ models: `["gpt-5.5"]`,
+ metaFields: `routes: [{method: "GET", path: "/v1/batch/:task_id", type: "query", models: ["gpt-5.5"], render: "jobStatus"}],`,
+ errContains: "must not declare models",
+ },
+ {
+ name: "model outside meta models",
+ models: `["gpt-5.5"]`,
+ metaFields: `routes: [{method: "POST", path: "/v1/batch", type: "submit", models: ["gpt-9.9"], decode: "decodeJob", render: "jobCreated"}],`,
+ errContains: "is not declared in plugin meta models",
+ },
+ {
+ name: "duplicate models",
+ models: `["gpt-5.5"]`,
+ metaFields: `routes: [{method: "POST", path: "/v1/batch", type: "submit", models: ["gpt-5.5", "gpt-5.5"], decode: "decodeJob", render: "jobCreated"}],`,
+ errContains: "models must be unique",
+ },
+ {
+ name: "blank model entry",
+ models: `["gpt-5.5"]`,
+ metaFields: `routes: [{method: "POST", path: "/v1/batch", type: "submit", models: [" "], decode: "decodeJob", render: "jobCreated"}],`,
+ errContains: "non-empty canonical names",
+ },
+ {
+ name: "empty models rejected",
+ models: `["gpt-5.5"]`,
+ metaFields: `routes: [{method: "POST", path: "/v1/batch", type: "submit", models: [], decode: "decodeJob", render: "jobCreated"}],`,
+ errContains: "models must contain at least one model",
+ },
+ {
+ name: "query route empty models rejected",
+ models: `["gpt-5.5"]`,
+ metaFields: `routes: [{method: "GET", path: "/v1/batch/:task_id", type: "query", models: [], render: "jobStatus"}],`,
+ errContains: "models must contain at least one model",
+ },
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ plugin, err := CompilePlugin(
+ routingTestPluginSource("route-models", 0, testCase.models, testCase.metaFields, routeExports),
+ Options{},
+ )
+ if testCase.errContains != "" {
+ require.ErrorContains(t, err, testCase.errContains)
+ return
+ }
+ require.NoError(t, err)
+ require.Len(t, plugin.Meta.Routes, 1)
+ assert.Equal(t, []string{"gpt-5.5"}, plugin.Meta.Routes[0].Models)
+ })
+ }
+}
+
+func TestRouteRequestContextClonesFormAndMultipartValuesPerDecoder(t *testing.T) {
+ tests := []struct {
+ name string
+ body any
+ mutate func(map[string]any)
+ }{
+ {
+ name: "form fields",
+ body: map[string]any{"kind": "form", "fields": map[string][]string{"prompt": {"original"}}},
+ mutate: func(value map[string]any) {
+ value["body"].(map[string]any)["fields"].(map[string][]string)["prompt"][0] = "mutated"
+ },
+ },
+ {
+ name: "multipart files",
+ body: map[string]any{"kind": "multipart", "files": []map[string]any{{"ref": "request_file:image", "filename": "safe.png"}}},
+ mutate: func(value map[string]any) {
+ value["body"].(map[string]any)["files"].([]map[string]any)[0]["filename"] = "mutated.png"
+ },
+ },
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ request := RouteRequestContext{Body: testCase.body}
+ first := request.JSValue()
+ testCase.mutate(first)
+ second := request.JSValue()
+ assert.NotEqual(t, first, second)
+ })
+ }
+}
+
+func TestRegistryNoOpMutationsKeepCurrentGeneration(t *testing.T) {
+ registry := NewRegistry()
+ plugin := mustCompileRoutingPlugin(t, "stable-generation", 0, `["model"]`, "", "")
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{plugin}))
+ current := registry.Generation()
+
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{plugin}))
+ assert.Same(t, current, registry.Generation())
+
+ registry.SetOverrideEnabled(true)
+ assert.Same(t, current, registry.Generation())
+
+ require.NoError(t, registry.Unregister("missing"))
+ assert.Same(t, current, registry.Generation())
+}
+
+func TestAdjacentNodeGenerationsKeepPinnedPluginExecutable(t *testing.T) {
+ compileVersion := func(version string) *LoadedPlugin {
+ source := fmt.Sprintf(`
+export const meta = {
+ apiVersion: 1, key: "adjacent-node", name: "Adjacent", version: %q,
+ author: {name: "Test"},
+ models: ["model"], fetchMode: "per_task"
+};
+export function buildSubmitRequest() { return {version: %q}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+`, version, version)
+ plugin, err := CompilePlugin(source, Options{})
+ require.NoError(t, err)
+ return plugin
+ }
+
+ nodeA := NewRegistry()
+ nodeB := NewRegistry()
+ nodeAV1 := compileVersion("1.0.0")
+ nodeBV1 := compileVersion("1.0.0")
+ require.NoError(t, nodeA.ReplaceOverrides([]*LoadedPlugin{nodeAV1}))
+ require.NoError(t, nodeB.ReplaceOverrides([]*LoadedPlugin{nodeBV1}))
+
+ nodeAV2 := compileVersion("2.0.0")
+ require.NoError(t, nodeA.ReplaceOverrides([]*LoadedPlugin{nodeAV2}))
+ assert.Equal(t, nodeB.Generation().Number+1, nodeA.Generation().Number)
+
+ pinned := PinnedPlugin{Generation: nodeB.Generation(), Plugin: nodeBV1}
+ nodeBV2 := compileVersion("2.0.0")
+ require.NoError(t, nodeB.ReplaceOverrides([]*LoadedPlugin{nodeBV2}))
+
+ oldResult, err := pinned.Plugin.Engine.Call(context.Background(), "buildSubmitRequest")
+ require.NoError(t, err)
+ oldObject, ok := oldResult.(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "1.0.0", oldObject["version"])
+ current, ok := nodeB.Get("adjacent-node")
+ require.True(t, ok)
+ newResult, err := current.Engine.Call(context.Background(), "buildSubmitRequest")
+ require.NoError(t, err)
+ newObject, ok := newResult.(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "2.0.0", newObject["version"])
+ assert.Equal(t, uint64(1), pinned.Generation.Number)
+ assert.Equal(t, uint64(2), nodeB.Generation().Number)
+}
+
+func TestRegistryReportsPartialAndFailedRebuildOutcomes(t *testing.T) {
+ registry := NewRegistry()
+ alpha := mustCompileRoutingPlugin(t, "outcome-alpha", 601, `["alpha"]`, "", "")
+ beta := mustCompileRoutingPlugin(t, "outcome-beta", 601, `["beta"]`, "", "")
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{alpha, beta}))
+
+ partial := registry.LastRebuildOutcome()
+ assert.Equal(t, "partial", partial.Status)
+ assert.Equal(t, registry.Generation().Number, partial.Generation)
+ assert.Empty(t, partial.Error)
+
+ err := registry.SetGenerationPreparer(func(_, _ *RoutingGeneration) (PreparedRoutingGeneration, error) {
+ return PreparedRoutingGeneration{}, fmt.Errorf("runtime rebuild unavailable")
+ })
+ require.ErrorContains(t, err, "runtime rebuild unavailable")
+ failed := registry.LastRebuildOutcome()
+ assert.Equal(t, "failed", failed.Status)
+ assert.Equal(t, registry.Generation().Number, failed.Generation)
+ assert.Contains(t, failed.Error, "runtime rebuild unavailable")
+
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{alpha}))
+ success := registry.LastRebuildOutcome()
+ assert.Equal(t, "success", success.Status)
+ assert.Equal(t, registry.Generation().Number, success.Generation)
+ assert.Empty(t, success.Error)
+}
+
+func TestRegistryExcludesConflictingPluginWithoutBlockingGeneration(t *testing.T) {
+ tests := []struct {
+ name string
+ first *LoadedPlugin
+ second *LoadedPlugin
+ expectedError string
+ }{
+ {
+ name: "legacy channel type",
+ first: mustCompileRoutingPlugin(t, "channel-alpha", 50, `["alpha"]`, "", ""),
+ second: mustCompileRoutingPlugin(t, "channel-beta", 50, `["beta"]`, "", ""),
+ expectedError: "channelType 50 conflicts",
+ },
+ {
+ name: "overlapping channelTypes entry",
+ first: mustCompileRoutingPlugin(t, "channel-alpha", 0, `["alpha"]`, `channelTypes: [55, 1],`, ""),
+ second: mustCompileRoutingPlugin(t, "channel-beta", 0, `["beta"]`, `channelTypes: [1],`, ""),
+ expectedError: "channelType 1 conflicts",
+ },
+ {
+ name: "route shape",
+ first: mustCompileRoutingPlugin(t, "route-alpha", 0, `["alpha"]`,
+ `routes: [{method: "GET", path: "/vendor/jobs/:task_id", type: "query", render: "status"}],`,
+ `export const native = {status: function(ctx, task) { return task; }};`),
+ second: mustCompileRoutingPlugin(t, "route-beta", 0, `["beta"]`,
+ `routes: [{method: "GET", path: "/vendor/jobs/:id", type: "query", render: "status", taskIdParam: "id"}],`,
+ `export const native = {status: function(ctx, task) { return task; }};`),
+ expectedError: "route GET /vendor/jobs/:id conflicts",
+ },
+ {
+ name: "endpoint model ownership",
+ first: mustCompileRoutingPlugin(t, "endpoint-alpha", 0, `["shared-model"]`,
+ `protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
+ routingProtocolExport("openai_responses")),
+ second: mustCompileRoutingPlugin(t, "endpoint-beta", 0, `["shared-model"]`,
+ `protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
+ routingProtocolExport("openai_responses")),
+ expectedError: `model "shared-model" conflicts`,
+ },
+ }
+
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ registry := NewRegistry()
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{testCase.first}))
+ before := registry.Generation()
+
+ err := registry.ReplaceOverrides([]*LoadedPlugin{testCase.first, testCase.second})
+
+ require.NoError(t, err)
+ assert.Equal(t, before.Number+1, registry.Generation().Number)
+ _, exists := registry.Get(testCase.second.Meta.Key)
+ assert.False(t, exists)
+ assert.Contains(t, registry.RoutingErrors()[testCase.second.Meta.Key], testCase.expectedError)
+ })
+ }
+}
+
+func TestRegistryRetainsIncumbentWhenUpdatedPluginConflicts(t *testing.T) {
+ registry := NewRegistry()
+ incumbent := mustCompileRoutingPlugin(t, "incumbent", 70, `["model-v1"]`, "", "")
+ other := mustCompileRoutingPlugin(t, "other", 71, `["other-model"]`, "", "")
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{incumbent, other}))
+
+ conflictingUpdate := mustCompileRoutingPlugin(t, "incumbent", 71, `["model-v2"]`, "", "")
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{conflictingUpdate, other}))
+
+ active, ok := registry.Get("incumbent")
+ require.True(t, ok)
+ assert.Same(t, incumbent, active)
+ assert.Same(t, conflictingUpdate, registry.OverridePlugins()["incumbent"])
+ assert.Same(t, incumbent, registry.ActiveOverridePlugins()["incumbent"])
+ require.Contains(t, registry.RoutingErrors(), "incumbent")
+
+ fixedUpdate := mustCompileRoutingPlugin(t, "incumbent", 72, `["model-v2"]`, "", "")
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{fixedUpdate, other}))
+ active, ok = registry.Get("incumbent")
+ require.True(t, ok)
+ assert.Same(t, fixedUpdate, active)
+ assert.NotContains(t, registry.RoutingErrors(), "incumbent")
+}
+
+func TestRegistryAdmitsNewConflictsInDeterministicKeyOrder(t *testing.T) {
+ registry := NewRegistry()
+ alpha := mustCompileRoutingPlugin(t, "alpha", 80, `["alpha-model"]`, "", "")
+ beta := mustCompileRoutingPlugin(t, "beta", 80, `["beta-model"]`, "", "")
+
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{beta, alpha}))
+
+ active, ok := registry.Get("alpha")
+ require.True(t, ok)
+ assert.Same(t, alpha, active)
+ _, betaActive := registry.Get("beta")
+ assert.False(t, betaActive)
+ assert.Contains(t, registry.RoutingErrors()["beta"], "channelType 80 conflicts")
+}
+
+func TestRegistryPublishesHealthyUpdateAlongsideRejectedUpdate(t *testing.T) {
+ registry := NewRegistry()
+ healthyV1 := mustCompileRoutingPlugin(t, "healthy", 81, `["healthy-v1"]`, "", "")
+ offenderV1 := mustCompileRoutingPlugin(t, "offender", 82, `["offender-v1"]`, "", "")
+ owner := mustCompileRoutingPlugin(t, "owner", 83, `["owner"]`, "", "")
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{healthyV1, offenderV1, owner}))
+
+ healthyV2 := mustCompileRoutingPlugin(t, "healthy", 84, `["healthy-v2"]`, "", "")
+ offenderV2 := mustCompileRoutingPlugin(t, "offender", 83, `["offender-v2"]`, "", "")
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{healthyV2, offenderV2, owner}))
+
+ activeHealthy, ok := registry.Get("healthy")
+ require.True(t, ok)
+ assert.Same(t, healthyV2, activeHealthy)
+ activeOffender, ok := registry.Get("offender")
+ require.True(t, ok)
+ assert.Same(t, offenderV1, activeOffender)
+ assert.Contains(t, registry.RoutingErrors()["offender"], "channelType 83 conflicts")
+}
+
+func TestRegistryAdmitsInterdependentUpdatesAsOneGeneration(t *testing.T) {
+ tests := []struct {
+ name string
+ firstType int
+ secondType int
+ }{
+ {name: "one update frees the type needed by another", firstType: 102, secondType: 103},
+ {name: "two plugins swap types", firstType: 102, secondType: 101},
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ registry := NewRegistry()
+ firstV1 := mustCompileRoutingPlugin(t, "dependent-first", 101, `["first-v1"]`, "", "")
+ secondV1 := mustCompileRoutingPlugin(t, "dependent-second", 102, `["second-v1"]`, "", "")
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{firstV1, secondV1}))
+
+ firstV2 := mustCompileRoutingPlugin(t, "dependent-first", testCase.firstType, `["first-v2"]`, "", "")
+ secondV2 := mustCompileRoutingPlugin(t, "dependent-second", testCase.secondType, `["second-v2"]`, "", "")
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{firstV2, secondV2}))
+
+ activeFirst, ok := registry.Get("dependent-first")
+ require.True(t, ok)
+ assert.Same(t, firstV2, activeFirst)
+ activeSecond, ok := registry.Get("dependent-second")
+ require.True(t, ok)
+ assert.Same(t, secondV2, activeSecond)
+ assert.Empty(t, registry.RoutingErrors())
+ })
+ }
+}
+
+func TestRejectedUpdateDoesNotRestoreIncumbentAheadOfHealthyPeer(t *testing.T) {
+ registry := NewRegistry()
+ alphaV1 := mustCompileRoutingPlugin(t, "fallback-alpha", 111, `["alpha-v1"]`, "", "")
+ betaV1 := mustCompileRoutingPlugin(t, "fallback-beta", 112, `["beta-v1"]`, "", "")
+ owner := mustCompileRoutingPlugin(t, "fallback-owner", 113, `["owner"]`, "", "")
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{alphaV1, betaV1, owner}))
+
+ alphaV2 := mustCompileRoutingPlugin(t, "fallback-alpha", 113, `["alpha-v2"]`, "", "")
+ betaV2 := mustCompileRoutingPlugin(t, "fallback-beta", 111, `["beta-v2"]`, "", "")
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{alphaV2, betaV2, owner}))
+
+ _, alphaActive := registry.Get("fallback-alpha")
+ assert.False(t, alphaActive)
+ activeBeta, ok := registry.Get("fallback-beta")
+ require.True(t, ok)
+ assert.Same(t, betaV2, activeBeta)
+ activeOwner, ok := registry.Get("fallback-owner")
+ require.True(t, ok)
+ assert.Same(t, owner, activeOwner)
+ assert.Contains(t, registry.RoutingErrors()["fallback-alpha"], "channelType 113 conflicts")
+ assert.NotContains(t, registry.RoutingErrors(), "fallback-beta")
+}
+
+func TestRemovingOverrideNeverRetainsRemovedIncumbent(t *testing.T) {
+ registry := NewRegistry()
+ override := mustCompileRoutingPlugin(t, "fallback", 92, `["override"]`, "", "")
+ owner := mustCompileRoutingPlugin(t, "owner", 91, `["owner"]`, "", "")
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{override, owner}))
+ factory, err := registry.RegisterFactory(routingTestPluginSource("fallback", 91, `["factory"]`, "", ""), Options{})
+ require.NoError(t, err)
+
+ require.NoError(t, registry.Unregister("fallback"))
+
+ _, fallbackActive := registry.Get("fallback")
+ assert.False(t, fallbackActive)
+ ownerActive, ok := registry.Get("owner")
+ require.True(t, ok)
+ assert.Same(t, owner, ownerActive)
+ assert.NotContains(t, registry.OverridePlugins(), "fallback")
+ assert.NotContains(t, registry.ActiveOverridePlugins(), "fallback")
+ assert.Contains(t, registry.RoutingErrors()["fallback"], "channelType 91 conflicts")
+
+ require.NoError(t, registry.Unregister("owner"))
+ fallbackPlugin, ok := registry.Get("fallback")
+ require.True(t, ok)
+ assert.Same(t, factory, fallbackPlugin)
+}
+
+func TestRejectedNewOverrideRetainsFactoryIncumbent(t *testing.T) {
+ registry := NewRegistry()
+ factorySource := routingTestPluginSource("factory-fallback", 121, `["factory"]`, "", "")
+ factory, err := registry.RegisterFactory(factorySource, Options{})
+ require.NoError(t, err)
+ owner := mustCompileRoutingPlugin(t, "factory-owner", 122, `["owner"]`, "", "")
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{owner}))
+
+ conflictingOverride := mustCompileRoutingPlugin(t, "factory-fallback", 122, `["override"]`, "", "")
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{conflictingOverride, owner}))
+
+ active, ok := registry.Get("factory-fallback")
+ require.True(t, ok)
+ assert.Same(t, factory, active)
+ assert.Same(t, conflictingOverride, registry.OverridePlugins()["factory-fallback"])
+ assert.NotContains(t, registry.ActiveOverridePlugins(), "factory-fallback")
+ assert.Contains(t, registry.RoutingErrors()["factory-fallback"], "channelType 122 conflicts")
+}
+
+func TestDisablingOverridesPublishesFactoryInsteadOfRetainingOverride(t *testing.T) {
+ registry := NewRegistry()
+ factorySource := routingTestPluginSource("switchable", 93, `["factory"]`, "", "")
+ factory, err := registry.RegisterFactory(factorySource, Options{})
+ require.NoError(t, err)
+ override := mustCompileRoutingPlugin(t, "switchable", 94, `["override"]`, "", "")
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{override}))
+
+ registry.SetOverrideEnabled(false)
+
+ active, ok := registry.Get("switchable")
+ require.True(t, ok)
+ assert.Same(t, factory, active)
+ assert.Same(t, override, registry.OverridePlugins()["switchable"])
+ assert.Empty(t, registry.ActiveOverridePlugins())
+}
+
+func TestGenericChannelTypesDoNotCreateLegacyIdentityConflicts(t *testing.T) {
+ for _, channelType := range []int{0, constant.ChannelTypeTaskPlugin} {
+ t.Run(fmt.Sprintf("channel_%d", channelType), func(t *testing.T) {
+ registry := NewRegistry()
+ first := mustCompileRoutingPlugin(t, "generic-alpha", channelType, `["alpha"]`, "", "")
+ second := mustCompileRoutingPlugin(t, "generic-beta", channelType, `["beta"]`, "", "")
+
+ require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{first, second}))
+ _, found := registry.GetByChannelType(channelType)
+ assert.False(t, found)
+ })
+ }
+}
+
+func TestDeclarativeRouteValidationProtectsNamespacesAndCanonicalSyntax(t *testing.T) {
+ tests := []struct {
+ name string
+ route Route
+ expectedError string
+ }{
+ {
+ name: "core API",
+ route: Route{Method: "POST", Path: "/api/plugin", Type: RouteTypeSubmit},
+ expectedError: "reserved namespace /api",
+ },
+ {
+ name: "generic task management",
+ route: Route{Method: "GET", Path: "/v1/tasks/:task_id", Type: RouteTypeQuery, Render: "native"},
+ expectedError: "reserved namespace /v1/tasks",
+ },
+ {
+ name: "SPA root",
+ route: Route{Method: "POST", Path: "/console/jobs", Type: RouteTypeSubmit},
+ expectedError: "reserved namespace /console",
+ },
+ {
+ name: "dynamic root can claim reserved subtree",
+ route: Route{Method: "POST", Path: "/:root/jobs", Type: RouteTypeSubmit},
+ expectedError: "intersects reserved namespace",
+ },
+ {
+ name: "dynamic v1 namespace can claim task management",
+ route: Route{Method: "POST", Path: "/v1/:namespace", Type: RouteTypeSubmit},
+ expectedError: "reserved namespace /v1/tasks",
+ },
+ {
+ name: "root catch-all can claim reserved subtree",
+ route: Route{Method: "POST", Path: "/*rest", Type: RouteTypeSubmit},
+ expectedError: "intersects reserved namespace",
+ },
+ {
+ name: "repeated slash",
+ route: Route{Method: "POST", Path: "/vendor//jobs", Type: RouteTypeSubmit},
+ expectedError: "empty segments",
+ },
+ {
+ name: "noncanonical method",
+ route: Route{Method: " post ", Path: "/vendor/jobs", Type: RouteTypeSubmit},
+ expectedError: "canonical uppercase",
+ },
+ {
+ name: "non-terminal catch-all",
+ route: Route{Method: "POST", Path: "/vendor/*rest/jobs", Type: RouteTypeSubmit},
+ expectedError: "invalid catch-all",
+ },
+ {
+ name: "query id mismatch",
+ route: Route{Method: "GET", Path: "/vendor/jobs/:id", Type: RouteTypeQuery, Render: "native"},
+ expectedError: "must contain :task_id",
+ },
+ {
+ name: "query declares decoder",
+ route: Route{Method: "GET", Path: "/vendor/jobs/:task_id", Type: RouteTypeQuery, Decode: "decode", Render: "show"},
+ expectedError: "must not declare decode",
+ },
+ {
+ name: "submit missing presenter",
+ route: Route{Method: "POST", Path: "/vendor/jobs", Type: RouteTypeSubmit, Decode: "decode"},
+ expectedError: "must declare decode and render",
+ },
+ {
+ name: "dynamic missing decoder",
+ route: Route{Method: "POST", Path: "/vendor/query", Type: RouteTypeDynamic, Render: "show"},
+ expectedError: "must declare decode and render",
+ },
+ {
+ name: "dynamic missing presenter",
+ route: Route{Method: "POST", Path: "/vendor/query", Type: RouteTypeDynamic, Decode: "decode"},
+ expectedError: "must declare decode and render",
+ },
+ {
+ name: "query declares action",
+ route: Route{Method: "GET", Path: "/vendor/jobs/:task_id", Type: RouteTypeQuery, Render: "show", Action: "retrieve"},
+ expectedError: "must not declare action",
+ },
+ {
+ name: "submit declares task id parameter",
+ route: Route{Method: "POST", Path: "/vendor/jobs", Type: RouteTypeSubmit, Decode: "decode", Render: "created", TaskIDParam: "task_id"},
+ expectedError: "must not declare taskIdParam",
+ },
+ {
+ name: "dynamic declares task id parameter",
+ route: Route{Method: "POST", Path: "/vendor/query", Type: RouteTypeDynamic, Decode: "decode", Render: "show", TaskIDParam: "task_id"},
+ expectedError: "must not declare taskIdParam",
+ },
+ }
+
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ meta := Meta{APIVersion: 1, Key: "validation", Name: "Validation", Version: "1.0.0", Author: AuthorMeta{Name: "Test"}, Models: []string{"model"}, FetchMode: "per_task", Routes: []Route{testCase.route}}
+ require.ErrorContains(t, ValidateV1Meta(meta), testCase.expectedError)
+ })
+ }
+}
+
+func TestRemovedEndpointsAndProtocolHooksAreValidatedAtCompileTime(t *testing.T) {
+ removed := routingTestPluginSource(
+ "removed-endpoint",
+ 0,
+ `["model"]`,
+ `endpoints: [{method: "POST", path: "/v1/chat/completions", protocol: "chat"}],`,
+ routingProtocolExport("chat"),
+ )
+ _, err := CompilePlugin(removed, Options{})
+ require.ErrorContains(t, err, "endpoints is no longer supported")
+
+ unknown := routingTestPluginSource("unknown-protocol", 0, `["model"]`, `protocols: ["chat"],`, ``)
+ _, err = CompilePlugin(unknown, Options{})
+ require.ErrorContains(t, err, `protocol "chat" is unknown`)
+
+ missingHook := routingTestPluginSource(
+ "bad-protocol",
+ 0,
+ `["model"]`,
+ `protocols: ["openai_responses"],`,
+ `export const protocols = {openai_responses: {
+ decodeRequest: function() { return {}; },
+ renderEvents: function() { return {events: [], done: false}; }
+ }};`,
+ )
+ _, err = CompilePlugin(missingHook, Options{})
+ require.ErrorContains(t, err, `plugin bad-protocol protocol "openai_responses" must declare supports; replace the bare string with {name: "openai_responses", supports: [...]} choosing from "stream", "sync", "background"`)
+
+ for _, removedExport := range []string{"renderers", "renderError", "resolveRequest"} {
+ t.Run("removed export "+removedExport, func(t *testing.T) {
+ source := routingTestPluginSource("removed-export", 0, `["model"]`, "", `export const `+removedExport+` = {};`)
+ _, compileErr := CompilePlugin(source, Options{})
+ require.ErrorContains(t, compileErr, `export "`+removedExport+`" is no longer supported`)
+ })
+ }
+
+ t.Run("removed route renderer", func(t *testing.T) {
+ source := routingTestPluginSource(
+ "removed-route-renderer", 0, `["model"]`,
+ `routes: [{method:"GET",path:"/vendor/:task_id",type:"query",render:"show",renderer:"legacy"}],`,
+ `export const native = {show: function(ctx, task) { return task; }};`,
+ )
+ _, compileErr := CompilePlugin(source, Options{})
+ require.ErrorContains(t, compileErr, "field renderer is no longer supported")
+ })
+}
+
+func TestRegistryRejectsPrototypeInheritedHooks(t *testing.T) {
+ for _, member := range []string{"constructor", "toString", "__proto__"} {
+ t.Run("native "+member, func(t *testing.T) {
+ source := routingTestPluginSource(
+ "inherited-renderer",
+ 0,
+ `["model"]`,
+ fmt.Sprintf(`routes: [{method: "GET", path: "/vendor/jobs/:task_id", type: "query", render: %q}],`, member),
+ `const nativePrototype = {
+ constructor: function(task) { return task; },
+ toString: function(task) { return task; },
+ ["__proto__"]: function(task) { return task; },
+ };
+ export const native = Object.create(nativePrototype);`,
+ )
+
+ _, err := CompilePlugin(source, Options{})
+ require.ErrorContains(t, err, fmt.Sprintf(`references missing native render %q`, member))
+ })
+ }
+
+ t.Run("protocol object", func(t *testing.T) {
+ source := routingTestPluginSource(
+ "inherited-protocol",
+ 0,
+ `["model"]`,
+ `protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
+ `const protocol = {
+ decodeRequest: function(ctx) { return ctx; },
+ renderEvents: function() { return {events: [], state: null, done: false}; },
+ renderFinal: function(ctx, task) { return task; },
+ };
+ export const protocols = Object.create({openai_responses: protocol});`,
+ )
+
+ _, err := CompilePlugin(source, Options{})
+ require.ErrorContains(t, err, `protocol "openai_responses" is missing hook "decodeRequest"`)
+ })
+
+ t.Run("protocol hook", func(t *testing.T) {
+ source := routingTestPluginSource(
+ "inherited-protocol-hook",
+ 0,
+ `["model"]`,
+ `protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
+ `const protocolPrototype = {
+ decodeRequest: function(ctx) { return ctx; },
+ renderEvents: function() { return {events: [], state: null, done: false}; },
+ renderFinal: function(ctx, task) { return task; },
+ };
+ export const protocols = {openai_responses: Object.create(protocolPrototype)};`,
+ )
+
+ _, err := CompilePlugin(source, Options{})
+ require.ErrorContains(t, err, `protocol "openai_responses" is missing hook "decodeRequest"`)
+ })
+}
+
+func TestMetaDecoderRejectsLossyOrUnknownRoutingFields(t *testing.T) {
+ tests := []struct {
+ name string
+ metaFields string
+ expectedError string
+ }{
+ {
+ name: "fractional channel type",
+ metaFields: `channelTypes: [50.5],`,
+ expectedError: "channelTypes element 1 must be an integer",
+ },
+ {
+ name: "non-string model",
+ metaFields: `models: ["model", 2],`,
+ expectedError: "models must be an array of strings",
+ },
+ {
+ name: "unknown route field",
+ metaFields: `routes: [{method: "POST", path: "/vendor/jobs", type: "submit", auth: false}],`,
+ expectedError: `unknown field "auth"`,
+ },
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ source := fmt.Sprintf(`
+export const meta = {
+ apiVersion: 1, key: "strict-meta", name: "Strict", version: "1.0.0",
+ author: {name: "Test"},
+ models: ["model"], fetchMode: "per_task", %s
+};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+export function resolveRequest() { return {kind: "submit", model: "model"}; }
+`, testCase.metaFields)
+ _, err := CompilePlugin(source, Options{})
+ require.ErrorContains(t, err, testCase.expectedError)
+ })
+ }
+}
+
+func TestProtocolHookInspectionIsDeadlineBounded(t *testing.T) {
+ source := `
+export const meta = {
+ apiVersion: 1, key: "getter-timeout", name: "Getter", version: "1.0.0",
+ author: {name: "Test"},
+ models: ["model"], fetchMode: "per_task",
+ protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],
+};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+export const protocols = {
+ get openai_responses() { while (true) {} }
+};
+`
+ _, err := CompilePlugin(source, Options{Timeout: 20 * time.Millisecond})
+ require.ErrorContains(t, err, "inspection interrupted")
+}
+
+func TestReservedNamespaceChecksUseSegmentBoundaries(t *testing.T) {
+ meta := Meta{
+ APIVersion: 1,
+ Key: "apiary",
+ Name: "Apiary",
+ Version: "1.0.0",
+ Author: AuthorMeta{Name: "Test"},
+ Models: []string{"model"},
+ FetchMode: "per_task",
+ Routes: []Route{{Method: "POST", Path: "/apiary/jobs", Type: RouteTypeSubmit, Decode: "decode", Render: "render"}},
+ }
+ require.NoError(t, ValidateV1Meta(meta))
+}
+
+func TestResolveRouteActionPrefersResolvedAction(t *testing.T) {
+ route := Route{Action: "manifest-action"}
+ assert.Equal(t, "hook-action", ResolveRouteAction(route, "hook-action"))
+ assert.Equal(t, "manifest-action", ResolveRouteAction(route, ""))
+}
+
+func mustCompileRoutingPlugin(t *testing.T, key string, channelType int, models, metaFields, exports string) *LoadedPlugin {
+ t.Helper()
+ plugin, err := CompilePlugin(routingTestPluginSource(key, channelType, models, metaFields, exports), Options{})
+ require.NoError(t, err)
+ return plugin
+}
+
+func routingTestPluginSource(key string, channelType int, models, metaFields, exports string) string {
+ channelTypesField := ""
+ if channelType > 0 && channelType != constant.ChannelTypeTaskPlugin {
+ channelTypesField = fmt.Sprintf("channelTypes: [%d],", channelType)
+ }
+ return fmt.Sprintf(`
+export const meta = {
+ apiVersion: 1,
+ key: %q,
+ name: %q,
+ version: "1.0.0",
+ author: {name: "Test"},
+ %s
+ models: %s,
+ fetchMode: "per_task",
+ %s
+};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+%s
+`, key, key, channelTypesField, models, metaFields, exports)
+}
+
+func routingProtocolExport(name string) string {
+ return fmt.Sprintf(`export const protocols = {%s: {
+ decodeRequest: function(ctx) { return ctx; },
+ renderEvents: function() { return {events: [], state: null, done: false}; },
+ renderFinal: function(ctx, task) { return task; }
+ }};`, name)
+}
diff --git a/pkg/jsplugin/utils.go b/pkg/jsplugin/utils.go
new file mode 100644
index 000000000000..2a0814c07df1
--- /dev/null
+++ b/pkg/jsplugin/utils.go
@@ -0,0 +1,139 @@
+package jsplugin
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "net/url"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/google/uuid"
+ "github.com/grafana/sobek"
+)
+
+type volcSignRequest struct {
+ Method string `json:"method"`
+ URL string `json:"url"`
+ Headers map[string]string `json:"headers"`
+ Body string `json:"body"`
+ AccessKey string `json:"accessKey"`
+ SecretKey string `json:"secretKey"`
+ Region string `json:"region"`
+ Service string `json:"service"`
+ Timestamp int64 `json:"timestamp"`
+}
+
+func injectGlobals(runtime *sobek.Runtime, identity func() string, now func() time.Time, logOutput func(string)) error {
+ utils := map[string]any{
+ "unixNow": func() int64 { return now().Unix() },
+ "jwtSignHS256": func(claims map[string]any, secret string) (string, error) {
+ return jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(claims)).SignedString([]byte(secret))
+ },
+ "hmacSHA256": func(message, secret string) string {
+ mac := hmac.New(sha256.New, []byte(secret))
+ _, _ = mac.Write([]byte(message))
+ return hex.EncodeToString(mac.Sum(nil))
+ },
+ "base64": func(value string) string { return base64.StdEncoding.EncodeToString([]byte(value)) },
+ "base64URL": func(value string) string {
+ return base64.RawURLEncoding.EncodeToString([]byte(value))
+ },
+ "base64URLDecode": func(value string) (string, error) {
+ decoded, err := base64.RawURLEncoding.DecodeString(value)
+ return string(decoded), err
+ },
+ "uuid": func() string { return uuid.NewString() },
+ "volcSignV4": func(request volcSignRequest) (map[string]string, error) {
+ return signVolcV4(request, now)
+ },
+ }
+ if err := runtime.Set("utils", utils); err != nil {
+ return err
+ }
+ console := runtime.NewObject()
+ if err := console.Set("log", func(call sobek.FunctionCall) sobek.Value {
+ parts := make([]string, len(call.Arguments))
+ for i, argument := range call.Arguments {
+ parts[i] = argument.String()
+ }
+ if logOutput != nil {
+ logOutput(identity() + " " + strings.Join(parts, " "))
+ }
+ return sobek.Undefined()
+ }); err != nil {
+ return err
+ }
+ return runtime.Set("console", console)
+}
+
+func signVolcV4(request volcSignRequest, now func() time.Time) (map[string]string, error) {
+ parsedURL, err := url.Parse(request.URL)
+ if err != nil || parsedURL.Host == "" {
+ return nil, fmt.Errorf("invalid Volcengine signing URL")
+ }
+ region := request.Region
+ if region == "" {
+ region = "cn-north-1"
+ }
+ service := request.Service
+ if service == "" {
+ service = "cv"
+ }
+ timestamp := now().UTC()
+ if request.Timestamp != 0 {
+ timestamp = time.Unix(request.Timestamp, 0).UTC()
+ }
+ xDate := timestamp.Format("20060102T150405Z")
+ shortDate := timestamp.Format("20060102")
+ bodyHash := sha256.Sum256([]byte(request.Body))
+ requestPath := parsedURL.EscapedPath()
+ if requestPath == "" {
+ requestPath = "/"
+ }
+
+ headers := make(map[string]string, len(request.Headers)+3)
+ for name, value := range request.Headers {
+ headers[strings.ToLower(name)] = strings.TrimSpace(value)
+ }
+ headers["host"] = parsedURL.Host
+ headers["x-date"] = xDate
+ headers["x-content-sha256"] = hex.EncodeToString(bodyHash[:])
+ keys := make([]string, 0, len(headers))
+ for name := range headers {
+ keys = append(keys, name)
+ }
+ sort.Strings(keys)
+ var canonicalHeaders strings.Builder
+ for _, name := range keys {
+ canonicalHeaders.WriteString(name)
+ canonicalHeaders.WriteByte(':')
+ canonicalHeaders.WriteString(headers[name])
+ canonicalHeaders.WriteByte('\n')
+ }
+ signedHeaders := strings.Join(keys, ";")
+ canonicalRequest := strings.Join([]string{
+ strings.ToUpper(request.Method), requestPath, parsedURL.Query().Encode(),
+ canonicalHeaders.String(), signedHeaders, hex.EncodeToString(bodyHash[:]),
+ }, "\n")
+ canonicalHash := sha256.Sum256([]byte(canonicalRequest))
+ scope := fmt.Sprintf("%s/%s/%s/request", shortDate, region, service)
+ stringToSign := fmt.Sprintf("HMAC-SHA256\n%s\n%s\n%s", xDate, scope, hex.EncodeToString(canonicalHash[:]))
+ sign := func(key []byte, value string) []byte {
+ mac := hmac.New(sha256.New, key)
+ _, _ = mac.Write([]byte(value))
+ return mac.Sum(nil)
+ }
+ signingKey := sign(sign(sign([]byte(request.SecretKey), shortDate), region), service)
+ signingKey = sign(signingKey, "request")
+ signature := hex.EncodeToString(sign(signingKey, stringToSign))
+ return map[string]string{
+ "Authorization": fmt.Sprintf("HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", request.AccessKey, scope, signedHeaders, signature),
+ "X-Date": xDate,
+ "X-Content-Sha256": hex.EncodeToString(bodyHash[:]),
+ }, nil
+}
diff --git a/plugins/.oxfmtrc.json b/plugins/.oxfmtrc.json
new file mode 100644
index 000000000000..ec9f7a34adbd
--- /dev/null
+++ b/plugins/.oxfmtrc.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "../web/node_modules/oxfmt/configuration_schema.json",
+ "endOfLine": "lf",
+ "insertFinalNewline": true,
+ "printWidth": 160,
+ "quoteProps": "as-needed",
+ "semi": true,
+ "singleQuote": false,
+ "tabWidth": 2,
+ "trailingComma": "es5"
+}
diff --git a/plugins/.oxlintrc.json b/plugins/.oxlintrc.json
new file mode 100644
index 000000000000..9cd2bc230cc9
--- /dev/null
+++ b/plugins/.oxlintrc.json
@@ -0,0 +1,22 @@
+{
+ "$schema": "../web/node_modules/oxlint/configuration_schema.json",
+ "plugins": ["oxc"],
+ "categories": {
+ "correctness": "error",
+ "suspicious": "warn"
+ },
+ "env": {
+ "builtin": true
+ },
+ "globals": {
+ "console": "readonly",
+ "utils": "readonly"
+ },
+ "rules": {
+ "eqeqeq": ["error", "always", { "null": "ignore" }],
+ "no-unused-vars": ["error", { "argsIgnorePattern": "^_", "caughtErrorsIgnorePattern": "^e$" }],
+ "no-underscore-dangle": ["warn", { "allow": ["__fileRef"] }],
+ "no-undef": "error",
+ "no-var": "error"
+ }
+}
diff --git a/plugins/alibaba_responses_test.go b/plugins/alibaba_responses_test.go
new file mode 100644
index 000000000000..35ac9889eb91
--- /dev/null
+++ b/plugins/alibaba_responses_test.go
@@ -0,0 +1,252 @@
+package plugins_test
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ builtinplugins "github.com/QuantumNous/new-api/plugins"
+ "github.com/QuantumNous/new-api/relay"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAlibabaResponsesProtocol(t *testing.T) {
+ source, err := builtinplugins.Source("alibaba")
+ require.NoError(t, err)
+ registry := jsplugin.NewRegistry()
+ plugin, err := registry.RegisterFactory(source, jsplugin.Options{Key: "alibaba"})
+ require.NoError(t, err)
+
+ t.Run("claims every Ali model", func(t *testing.T) {
+ for _, model := range plugin.Meta.Models {
+ binding, found := registry.Generation().LookupEndpoint("POST", "/v1/responses", model)
+ require.True(t, found, model)
+ assert.Same(t, plugin, binding.Plugin)
+ assert.Equal(t, "openai_responses", binding.Protocol)
+ }
+ })
+
+ t.Run("declares documented usage facts", func(t *testing.T) {
+ require.Len(t, plugin.Meta.UsageSchema, 2)
+ for _, key := range []string{"seconds", "resolution"} {
+ schema, exists := plugin.Meta.UsageSchema[key]
+ require.True(t, exists, key)
+ assert.NotEmpty(t, schema.Description, key)
+ }
+
+ value, callErr := plugin.Engine.Call(t.Context(), "extractUsage", map[string]any{
+ "model": "wan2.5-i2v-preview",
+ "upstreamModel": "wan2.5-i2v-preview",
+ "usagePurpose": "facts",
+ "requestBody": map[string]any{
+ "model": "wan2.5-i2v-preview",
+ "duration": 10,
+ "size": "1080p",
+ "image": "https://cdn.example/first.png",
+ },
+ })
+ require.NoError(t, callErr)
+ encoded, marshalErr := common.Marshal(value)
+ require.NoError(t, marshalErr)
+ var facts map[string]any
+ require.NoError(t, common.Unmarshal(encoded, &facts))
+ assert.Equal(t, map[string]any{"seconds": float64(10), "resolution": "1080P"}, facts)
+ })
+
+ t.Run("parses text input and options", func(t *testing.T) {
+ value, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, map[string]any{
+ "model": "wan2.7-t2v", "body": map[string]any{"kind": "json", "value": map[string]any{
+ "model": "wan2.7-t2v",
+ "input": "waves at sunset",
+ "size": "1280*720",
+ "duration": 6,
+ "metadata": map[string]any{"parameters": map[string]any{"watermark": true}},
+ }},
+ "stream": false,
+ })
+ require.NoError(t, callErr)
+ encoded, marshalErr := common.Marshal(value)
+ require.NoError(t, marshalErr)
+ var resolved map[string]any
+ require.NoError(t, common.Unmarshal(encoded, &resolved))
+
+ assert.Equal(t, map[string]any{
+ "kind": "submit",
+ "model": "wan2.7-t2v",
+ "action": "text_to_video",
+ "requestBody": map[string]any{
+ "model": "wan2.7-t2v",
+ "prompt": "waves at sunset",
+ "size": "1280*720",
+ "duration": float64(6),
+ "metadata": map[string]any{"parameters": map[string]any{"watermark": true}},
+ },
+ }, resolved)
+ })
+
+ t.Run("parses multimodal image input", func(t *testing.T) {
+ value, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, map[string]any{
+ "model": "wan2.7-i2v", "body": map[string]any{"kind": "json", "value": map[string]any{
+ "model": "wan2.7-i2v",
+ "input": []any{
+ map[string]any{
+ "role": "user",
+ "content": []any{
+ map[string]any{"type": "input_text", "text": "animate between frames"},
+ map[string]any{"type": "input_image", "image_url": "https://cdn.example/first.png"},
+ map[string]any{"type": "input_image", "image_url": map[string]any{"url": "https://cdn.example/last.png"}},
+ },
+ },
+ },
+ }},
+ "stream": true,
+ })
+ require.NoError(t, callErr)
+ encoded, marshalErr := common.Marshal(value)
+ require.NoError(t, marshalErr)
+ var resolved map[string]any
+ require.NoError(t, common.Unmarshal(encoded, &resolved))
+
+ assert.Equal(t, "wan2.7-i2v", resolved["model"])
+ assert.Equal(t, "image_to_video", resolved["action"])
+ requestBody, ok := resolved["requestBody"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "animate between frames", requestBody["prompt"])
+ assert.Equal(t, []any{"https://cdn.example/first.png", "https://cdn.example/last.png"}, requestBody["images"])
+ })
+
+ t.Run("accepts image-only i2v input", func(t *testing.T) {
+ value, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, map[string]any{
+ "model": "wan2.7-i2v", "body": map[string]any{"kind": "json", "value": map[string]any{
+ "model": "wan2.7-i2v",
+ "input": []any{
+ map[string]any{"type": "input_image", "image_url": "https://cdn.example/first.png"},
+ },
+ }},
+ "stream": false,
+ })
+ require.NoError(t, callErr)
+ encoded, marshalErr := common.Marshal(value)
+ require.NoError(t, marshalErr)
+ var resolved map[string]any
+ require.NoError(t, common.Unmarshal(encoded, &resolved))
+
+ assert.Equal(t, "image_to_video", resolved["action"])
+ requestBody, ok := resolved["requestBody"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "", requestBody["prompt"])
+ assert.Equal(t, []any{"https://cdn.example/first.png"}, requestBody["images"])
+ })
+
+ t.Run("rejects a request without input text", func(t *testing.T) {
+ _, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, map[string]any{
+ "model": "wan2.7-t2v", "body": map[string]any{"kind": "json", "value": map[string]any{"model": "wan2.7-t2v"}},
+ "stream": false,
+ })
+ require.ErrorContains(t, callErr, "input is required")
+ })
+
+ protocolContext := map[string]any{
+ "requestBody": map[string]any{"model": "wan2.7-t2v"},
+ "stream": true,
+ "artifacts": map[string]any{
+ "video": map[string]any{
+ "key": "video",
+ "type": "video",
+ "mimeType": "video/mp4",
+ "url": "https://gateway.example/v1/tasks/task_public/artifacts/video/content?access=host%2Bcapability%3D",
+ },
+ },
+ }
+ successTask := map[string]any{
+ "task_id": "task_public",
+ "status": "SUCCESS",
+ "progress": "100%",
+ "created_at": 10,
+ "updated_at": 20,
+ "data": map[string]any{
+ "output": map[string]any{
+ "video_url": "https://upstream.example/video.mp4?Expires=1&Signature=must-not-leak",
+ },
+ },
+ }
+
+ t.Run("renders stream semantics accepted by the host", func(t *testing.T) {
+ progressValue, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "renderEvents"}, protocolContext, map[string]any{
+ "task_id": "task_public",
+ "status": "IN_PROGRESS",
+ "progress": "42%",
+ })
+ require.NoError(t, callErr)
+ progressResult, decodeErr := relay.DecodePluginProtocolEventResult(progressValue, relay.DefaultPluginProtocolLimits())
+ require.NoError(t, decodeErr)
+ require.Len(t, progressResult.Events, 1)
+ require.NotNil(t, progressResult.Events[0].Progress)
+ assert.Equal(t, float64(42), *progressResult.Events[0].Progress)
+ assert.False(t, progressResult.Done)
+
+ value, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "renderEvents"}, protocolContext, successTask)
+ require.NoError(t, callErr)
+ result, decodeErr := relay.DecodePluginProtocolEventResult(value, relay.DefaultPluginProtocolLimits())
+ require.NoError(t, decodeErr)
+ require.Len(t, result.Events, 1)
+ assert.Equal(t, "output", result.Events[0].Type)
+ assert.True(t, result.Done)
+ var text string
+ require.NoError(t, common.Unmarshal(result.Events[0].Data, &text))
+ assert.Equal(t, ` `, text)
+ assert.NotContains(t, text, "upstream.example")
+
+ machine := relay.NewPluginResponsesMachine("task_public", "wan2.7-t2v", 10, relay.DefaultPluginProtocolLimits())
+ _, machineErr := machine.CreatedEvent()
+ require.NoError(t, machineErr)
+ wireEvents, machineErr := machine.ApplyTick(result, "SUCCESS")
+ require.NoError(t, machineErr)
+ require.NotEmpty(t, wireEvents)
+ assert.Equal(t, "response.completed", wireEvents[len(wireEvents)-1].Type)
+ })
+
+ t.Run("renders a valid non-stream response", func(t *testing.T) {
+ value, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "renderFinal"}, protocolContext, successTask)
+ require.NoError(t, callErr)
+ machine := relay.NewPluginResponsesMachine("task_public", "wan2.7-t2v", 10, relay.DefaultPluginProtocolLimits())
+ response, finalErr := machine.FinalResponse(value, "SUCCESS")
+ require.NoError(t, finalErr)
+ assert.Equal(t, "resp_public", response["id"])
+ assert.Equal(t, "completed", response["status"])
+
+ output, ok := response["output"].([]any)
+ require.True(t, ok)
+ require.Len(t, output, 1)
+ item, ok := output[0].(map[string]any)
+ require.True(t, ok)
+ content, ok := item["content"].([]any)
+ require.True(t, ok)
+ require.Len(t, content, 1)
+ part, ok := content[0].(map[string]any)
+ require.True(t, ok)
+ text, ok := part["text"].(string)
+ require.True(t, ok)
+ assert.Equal(t, ` `, text)
+ assert.NotContains(t, text, "upstream.example")
+ metadata, ok := response["metadata"].(map[string]string)
+ require.True(t, ok)
+ assert.Equal(t, "ali", metadata["vendor"])
+ })
+
+ t.Run("does not fall back to the upstream URL when the host artifact is absent", func(t *testing.T) {
+ _, callErr := plugin.Engine.CallPath(
+ t.Context(),
+ "protocols",
+ []string{"openai_responses", "renderFinal"},
+ map[string]any{
+ "requestBody": map[string]any{"model": "wan2.7-t2v"},
+ "stream": false,
+ },
+ successTask,
+ )
+ require.ErrorContains(t, callErr, "video artifact is unavailable")
+ assert.NotContains(t, callErr.Error(), "upstream.example")
+ })
+}
diff --git a/plugins/builtin_plugins_test.go b/plugins/builtin_plugins_test.go
new file mode 100644
index 000000000000..9c40c508a59d
--- /dev/null
+++ b/plugins/builtin_plugins_test.go
@@ -0,0 +1,123 @@
+package plugins
+
+import (
+ "io/fs"
+ "testing"
+
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBuiltInVendorPluginsDeclareNativeRoutesAndLegacyChannelTypes(t *testing.T) {
+ generation := jsplugin.DefaultRegistry.Generation()
+ require.NotNil(t, generation)
+
+ routes := []struct {
+ method string
+ path string
+ key string
+ routeType jsplugin.RouteType
+ action string
+ renderer string
+ }{
+ {"POST", "/kling/v1/videos/text2video", "kling", jsplugin.RouteTypeSubmit, "text_to_video", "taskCreated"},
+ {"POST", "/kling/v1/videos/image2video", "kling", jsplugin.RouteTypeSubmit, "image_to_video", "taskCreated"},
+ {"GET", "/kling/v1/videos/text2video/:task_id", "kling", jsplugin.RouteTypeQuery, "", "taskStatus"},
+ {"GET", "/kling/v1/videos/image2video/:task_id", "kling", jsplugin.RouteTypeQuery, "", "taskStatus"},
+ {"POST", "/jimeng/", "jimeng", jsplugin.RouteTypeDynamic, "", "renderTask"},
+ {"POST", "/suno/submit/:action", "sunoapi", jsplugin.RouteTypeSubmit, "", "renderSubmit"},
+ {"POST", "/suno/fetch", "sunoapi", jsplugin.RouteTypeDynamic, "", "renderTasks"},
+ {"GET", "/suno/fetch/:task_id", "sunoapi", jsplugin.RouteTypeQuery, "", "renderTask"},
+ {"POST", "/doubao/api/v3/contents/generations/tasks", "doubao", jsplugin.RouteTypeSubmit, "", "taskCreated"},
+ {"GET", "/doubao/api/v3/contents/generations/tasks/:task_id", "doubao", jsplugin.RouteTypeQuery, "", "taskStatus"},
+ }
+ for _, expected := range routes {
+ t.Run(expected.method+" "+expected.path, func(t *testing.T) {
+ binding, found := generation.LookupDeclaredRoute(expected.method, expected.path)
+ require.True(t, found)
+ require.Equal(t, expected.key, binding.Plugin.Meta.Key)
+ require.Equal(t, expected.routeType, binding.Route.Type)
+ require.Equal(t, expected.action, binding.Route.Action)
+ require.Equal(t, expected.renderer, binding.Route.Render)
+ })
+ }
+
+ channelTypes := []struct {
+ value int
+ key string
+ }{
+ {1, "sora"},
+ {36, "sunoapi"},
+ {45, "doubao"},
+ {50, "kling"},
+ {51, "jimeng"},
+ {54, "doubao"},
+ {55, "sora"},
+ }
+ for _, channelType := range channelTypes {
+ plugin, found := generation.GetByChannelType(channelType.value)
+ require.True(t, found)
+ require.Equal(t, channelType.key, plugin.Meta.Key)
+ }
+}
+
+func TestBuiltInTaskPluginResponsesAndUsageContracts(t *testing.T) {
+ expectedKeys := []string{"alibaba", "doubao", "google", "hailuo", "jimeng", "kling", "sora", "sunoapi", "vertex-ai", "vidu"}
+ generation := jsplugin.DefaultRegistry.Generation()
+ require.NotNil(t, generation)
+
+ entries, err := fs.ReadDir(taskPlugins, "tasks")
+ require.NoError(t, err)
+ actualKeys := make([]string, 0, len(entries))
+ for _, entry := range entries {
+ if entry.IsDir() {
+ actualKeys = append(actualKeys, entry.Name())
+ }
+ }
+ assert.Equal(t, expectedKeys, actualKeys)
+
+ for _, key := range expectedKeys {
+ t.Run(key, func(t *testing.T) {
+ _, found := generation.Get(key)
+ require.True(t, found, "factory plugin was excluded from the active generation")
+
+ source, sourceErr := Source(key)
+ require.NoError(t, sourceErr)
+ registry := jsplugin.NewRegistry()
+ plugin, registerErr := registry.RegisterFactory(source, jsplugin.Options{Key: key})
+ require.NoError(t, registerErr)
+
+ var responsesClaim jsplugin.ProtocolClaim
+ foundResponses := false
+ for _, claim := range plugin.Meta.Protocols {
+ if claim.Name == "openai_responses" {
+ responsesClaim = claim
+ foundResponses = true
+ break
+ }
+ }
+ require.True(t, foundResponses, "openai_responses claim must be present")
+ assert.Equal(t, []string{"stream", "sync", "background"}, responsesClaim.Supports)
+ for _, model := range plugin.Meta.Models {
+ binding, claimed := registry.Generation().LookupEndpoint("POST", "/v1/responses", model)
+ require.True(t, claimed, model)
+ assert.Same(t, plugin, binding.Plugin)
+ }
+ for _, hook := range []string{"decodeRequest", "renderEvents", "renderFinal"} {
+ callable, callableErr := plugin.Engine.HasCallablePath(t.Context(), "protocols", "openai_responses", hook)
+ require.NoError(t, callableErr)
+ assert.True(t, callable, hook)
+ }
+ for _, hook := range []string{"extractUsage", "extractUsageOnComplete"} {
+ callable, callableErr := plugin.Engine.HasExport(t.Context(), hook)
+ require.NoError(t, callableErr)
+ assert.True(t, callable, hook)
+ }
+ require.NotEmpty(t, plugin.Meta.UsageSchema)
+ for usageKey, schema := range plugin.Meta.UsageSchema {
+ assert.NotEmpty(t, schema.Description, usageKey)
+ }
+ })
+ }
+}
diff --git a/plugins/doubao_responses_test.go b/plugins/doubao_responses_test.go
new file mode 100644
index 000000000000..9c256865f4a1
--- /dev/null
+++ b/plugins/doubao_responses_test.go
@@ -0,0 +1,31 @@
+package plugins_test
+
+import "testing"
+
+func TestDoubaoResponsesProtocol(t *testing.T) {
+ testVideoResponsesProtocol(t, videoResponsesTestCase{
+ pluginKey: "doubao",
+ model: "doubao-seedance-2-0-260128",
+ requestBody: map[string]any{
+ "model": "doubao-seedance-2-0-260128",
+ "input": []any{map[string]any{"role": "user", "content": []any{
+ map[string]any{"type": "input_text", "text": "a running fox"},
+ map[string]any{"type": "input_image", "image_url": "https://cdn.example/frame.png"},
+ }}},
+ "seconds": 6,
+ "size": "1920x1080",
+ },
+ wantAction: "image_to_video",
+ wantRequest: map[string]any{
+ "model": "doubao-seedance-2-0-260128",
+ "prompt": "a running fox",
+ "images": []any{"https://cdn.example/frame.png"},
+ "seconds": float64(6),
+ "metadata": map[string]any{
+ "resolution": "1080p",
+ },
+ },
+ wantUsageKeys: []string{"resolution", "tokens", "video_input"},
+ wantVendorName: "doubao",
+ })
+}
diff --git a/plugins/embed.go b/plugins/embed.go
new file mode 100644
index 000000000000..fef506105663
--- /dev/null
+++ b/plugins/embed.go
@@ -0,0 +1,41 @@
+package plugins
+
+import (
+ "embed"
+ "fmt"
+ "io/fs"
+
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+)
+
+//go:embed tasks/*/plugin.js
+var taskPlugins embed.FS
+
+func init() {
+ entries, err := fs.ReadDir(taskPlugins, "tasks")
+ if err != nil {
+ panic(fmt.Sprintf("read embedded task plugins: %v", err))
+ }
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ key := entry.Name()
+ source, sourceErr := Source(key)
+ if sourceErr != nil {
+ panic(fmt.Sprintf("read embedded task plugin %s: %v", key, sourceErr))
+ }
+ if _, registerErr := jsplugin.DefaultRegistry.RegisterFactory(source, jsplugin.Options{Key: key}); registerErr != nil {
+ panic(fmt.Sprintf("register embedded task plugin %s: %v", key, registerErr))
+ }
+ }
+}
+
+// Source returns the embedded factory source for a task plugin key.
+func Source(key string) (string, error) {
+ source, err := taskPlugins.ReadFile("tasks/" + key + "/plugin.js")
+ if err != nil {
+ return "", err
+ }
+ return string(source), nil
+}
diff --git a/plugins/google_responses_test.go b/plugins/google_responses_test.go
new file mode 100644
index 000000000000..4304c064c6bf
--- /dev/null
+++ b/plugins/google_responses_test.go
@@ -0,0 +1,30 @@
+package plugins_test
+
+import "testing"
+
+func TestGoogleResponsesProtocol(t *testing.T) {
+ testVideoResponsesProtocol(t, videoResponsesTestCase{
+ pluginKey: "google",
+ model: "veo-3.1-fast-generate-preview",
+ requestBody: map[string]any{
+ "model": "veo-3.1-fast-generate-preview",
+ "input": []any{map[string]any{"role": "user", "content": []any{
+ map[string]any{"type": "input_text", "text": "animate this frame"},
+ map[string]any{"type": "input_image", "image_url": "data:image/png;base64,aGVsbG8="},
+ }}},
+ "seconds": 8,
+ "size": "1280x720",
+ },
+ wantAction: "image_to_video",
+ wantRequest: map[string]any{
+ "model": "veo-3.1-fast-generate-preview",
+ "prompt": "animate this frame",
+ "images": []any{"data:image/png;base64,aGVsbG8="},
+ "duration": float64(8),
+ "size": "1280x720",
+ "metadata": map[string]any{},
+ },
+ wantUsageKeys: []string{"resolution", "seconds"},
+ wantVendorName: "gemini",
+ })
+}
diff --git a/plugins/hailuo_responses_test.go b/plugins/hailuo_responses_test.go
new file mode 100644
index 000000000000..decc29a1d162
--- /dev/null
+++ b/plugins/hailuo_responses_test.go
@@ -0,0 +1,72 @@
+package plugins_test
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ builtinplugins "github.com/QuantumNous/new-api/plugins"
+ "github.com/QuantumNous/new-api/relay/channel"
+ taskplugin "github.com/QuantumNous/new-api/relay/channel/task/jsplugin"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestHailuoResponsesProtocol(t *testing.T) {
+ testVideoResponsesProtocol(t, videoResponsesTestCase{
+ pluginKey: "hailuo",
+ model: "MiniMax-Hailuo-2.3",
+ requestBody: map[string]any{
+ "model": "MiniMax-Hailuo-2.3",
+ "input": []any{map[string]any{"role": "user", "content": []any{
+ map[string]any{"type": "input_text", "text": "ocean at sunset"},
+ map[string]any{"type": "input_image", "image_url": "https://cdn.example/frame.png"},
+ }}},
+ "seconds": 10,
+ "size": "1920x1080",
+ },
+ wantAction: "image_to_video",
+ wantRequest: map[string]any{
+ "model": "MiniMax-Hailuo-2.3",
+ "prompt": "ocean at sunset",
+ "images": []any{"https://cdn.example/frame.png"},
+ "duration": float64(10),
+ "size": "1920x1080",
+ "metadata": map[string]any{"first_frame_image": "https://cdn.example/frame.png"},
+ },
+ wantUsageKeys: []string{"resolution", "seconds"},
+ wantVendorName: "hailuo",
+ })
+}
+
+func TestHailuoArtifactContentProxy(t *testing.T) {
+ source, err := builtinplugins.Source("hailuo")
+ require.NoError(t, err)
+ plugin, err := jsplugin.NewRegistry().RegisterFactory(source, jsplugin.Options{Key: "hailuo"})
+ require.NoError(t, err)
+ adaptor := taskplugin.New(plugin)
+ adaptor.Init(&relaycommon.RelayInfo{
+ ChannelMeta: &relaycommon.ChannelMeta{
+ ApiKey: "test-ak",
+ ChannelBaseUrl: "https://api.minimax.example",
+ },
+ })
+ data, err := common.Marshal(map[string]any{"file_id": "file/with space"})
+ require.NoError(t, err)
+ task := &model.Task{TaskID: "task-public", Status: model.TaskStatusSuccess, Data: data}
+
+ artifacts, err := adaptor.ListArtifacts(task)
+ require.NoError(t, err)
+ assert.Equal(t, []channel.TaskArtifact{{Key: "video", Type: "video", MimeType: "video/mp4"}}, artifacts)
+
+ descriptor, err := adaptor.BuildContentRequest(task, "video", channel.TaskArtifactClientRequest{Method: http.MethodHead})
+ require.NoError(t, err)
+ require.NotNil(t, descriptor)
+ assert.Equal(t, "https://api.minimax.example/v1/files/download?file_id=file%2Fwith%20space", descriptor.URL)
+ assert.Equal(t, http.MethodHead, descriptor.Method)
+ assert.Equal(t, map[string]string{"Accept": "video/*", "Authorization": "Bearer test-ak"}, descriptor.Headers)
+ assert.False(t, descriptor.Credentialless)
+}
diff --git a/plugins/jimeng_responses_test.go b/plugins/jimeng_responses_test.go
new file mode 100644
index 000000000000..4a94df44f06d
--- /dev/null
+++ b/plugins/jimeng_responses_test.go
@@ -0,0 +1,27 @@
+package plugins_test
+
+import "testing"
+
+func TestJimengResponsesProtocol(t *testing.T) {
+ testVideoResponsesProtocol(t, videoResponsesTestCase{
+ pluginKey: "jimeng",
+ model: "jimeng_vgfm_t2v_l20",
+ requestBody: map[string]any{
+ "model": "jimeng_vgfm_t2v_l20",
+ "input": "a paper boat on a river",
+ "seconds": 10,
+ "metadata": map[string]any{
+ "aspect_ratio": "16:9",
+ },
+ },
+ wantAction: "text_to_video",
+ wantRequest: map[string]any{
+ "model": "jimeng_vgfm_t2v_l20",
+ "prompt": "a paper boat on a river",
+ "duration": float64(10),
+ "metadata": map[string]any{"aspect_ratio": "16:9"},
+ },
+ wantUsageKeys: []string{"product", "seconds"},
+ wantVendorName: "jimeng",
+ })
+}
diff --git a/plugins/kling_responses_test.go b/plugins/kling_responses_test.go
new file mode 100644
index 000000000000..995c0ee9f7ce
--- /dev/null
+++ b/plugins/kling_responses_test.go
@@ -0,0 +1,31 @@
+package plugins_test
+
+import "testing"
+
+func TestKlingResponsesProtocol(t *testing.T) {
+ testVideoResponsesProtocol(t, videoResponsesTestCase{
+ pluginKey: "kling",
+ model: "kling-v2-master",
+ requestBody: map[string]any{
+ "model": "kling-v2-master",
+ "input": []any{map[string]any{"role": "user", "content": []any{
+ map[string]any{"type": "input_text", "text": "camera orbit"},
+ map[string]any{"type": "input_image", "image_url": "https://cdn.example/frame.png"},
+ }}},
+ "seconds": 10,
+ "metadata": map[string]any{
+ "mode": "pro",
+ },
+ },
+ wantAction: "image_to_video",
+ wantRequest: map[string]any{
+ "model": "kling-v2-master",
+ "prompt": "camera orbit",
+ "image": "https://cdn.example/frame.png",
+ "duration": float64(10),
+ "metadata": map[string]any{"mode": "pro"},
+ },
+ wantUsageKeys: []string{"units"},
+ wantVendorName: "kling",
+ })
+}
diff --git a/plugins/sora_responses_test.go b/plugins/sora_responses_test.go
new file mode 100644
index 000000000000..f7f02bc98d29
--- /dev/null
+++ b/plugins/sora_responses_test.go
@@ -0,0 +1,25 @@
+package plugins_test
+
+import "testing"
+
+func TestSoraResponsesProtocol(t *testing.T) {
+ testVideoResponsesProtocol(t, videoResponsesTestCase{
+ pluginKey: "sora",
+ model: "sora-2-pro",
+ requestBody: map[string]any{
+ "model": "sora-2-pro",
+ "input": "waves at sunset",
+ "seconds": 8,
+ "size": "1792x1024",
+ },
+ wantAction: "text_to_video",
+ wantRequest: map[string]any{
+ "model": "sora-2-pro",
+ "prompt": "waves at sunset",
+ "seconds": float64(8),
+ "size": "1792x1024",
+ },
+ wantUsageKeys: []string{"seconds", "size"},
+ wantVendorName: "sora",
+ })
+}
diff --git a/plugins/sunoapi_responses_test.go b/plugins/sunoapi_responses_test.go
new file mode 100644
index 000000000000..9a457369d315
--- /dev/null
+++ b/plugins/sunoapi_responses_test.go
@@ -0,0 +1,195 @@
+package plugins_test
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ builtinplugins "github.com/QuantumNous/new-api/plugins"
+ "github.com/QuantumNous/new-api/relay"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSunoResponsesProtocol(t *testing.T) {
+ source, err := builtinplugins.Source("sunoapi")
+ require.NoError(t, err)
+ registry := jsplugin.NewRegistry()
+ plugin, err := registry.RegisterFactory(source, jsplugin.Options{Key: "sunoapi"})
+ require.NoError(t, err)
+
+ t.Run("claims both Suno models", func(t *testing.T) {
+ for _, model := range plugin.Meta.Models {
+ binding, found := registry.Generation().LookupEndpoint("POST", "/v1/responses", model)
+ require.True(t, found, model)
+ assert.Same(t, plugin, binding.Plugin)
+ assert.Equal(t, "openai_responses", binding.Protocol)
+ }
+ })
+
+ t.Run("declares documented usage facts", func(t *testing.T) {
+ require.Len(t, plugin.Meta.UsageSchema, 2)
+ for _, key := range []string{"clips", "action"} {
+ schema, exists := plugin.Meta.UsageSchema[key]
+ require.True(t, exists, key)
+ assert.NotEmpty(t, schema.Description, key)
+ }
+ })
+
+ callProtocol := func(t *testing.T, hook string, args ...any) any {
+ t.Helper()
+ value, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", hook}, args...)
+ require.NoError(t, callErr)
+ return value
+ }
+ decodeMap := func(t *testing.T, value any) map[string]any {
+ t.Helper()
+ encoded, marshalErr := common.Marshal(value)
+ require.NoError(t, marshalErr)
+ var decoded map[string]any
+ require.NoError(t, common.Unmarshal(encoded, &decoded))
+ return decoded
+ }
+
+ t.Run("parses music and lyrics requests", func(t *testing.T) {
+ music := decodeMap(t, callProtocol(t, "decodeRequest", map[string]any{"model": "suno_music", "body": map[string]any{"kind": "json", "value": map[string]any{
+ "model": "suno_music", "input": "summer pop", "metadata": map[string]any{"title": "Sunset"},
+ }}}))
+ assert.Equal(t, "suno_music", music["model"])
+ assert.Equal(t, "MUSIC", music["action"])
+ assert.Equal(t, map[string]any{"gpt_description_prompt": "summer pop", "title": "Sunset"}, music["requestBody"])
+
+ lyrics := decodeMap(t, callProtocol(t, "decodeRequest", map[string]any{"model": "suno_lyrics", "body": map[string]any{"kind": "json", "value": map[string]any{
+ "model": "suno_lyrics", "input": "write about the sea",
+ }}}))
+ assert.Equal(t, "suno_lyrics", lyrics["model"])
+ assert.Equal(t, "LYRICS", lyrics["action"])
+ assert.Equal(t, map[string]any{"prompt": "write about the sea"}, lyrics["requestBody"])
+ })
+
+ t.Run("rejects malformed input", func(t *testing.T) {
+ _, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, map[string]any{
+ "model": "suno_music", "body": map[string]any{"kind": "json", "value": map[string]any{"model": "suno_music", "input": map[string]any{"text": "bad"}}},
+ })
+ require.ErrorContains(t, callErr, "input must be a string or array")
+ })
+
+ t.Run("extracts schema-declared usage", func(t *testing.T) {
+ value, callErr := plugin.Engine.Call(t.Context(), "extractUsage", map[string]any{
+ "model": "suno_music", "action": "MUSIC", "usagePurpose": "facts", "requestBody": map[string]any{},
+ })
+ require.NoError(t, callErr)
+ assert.Equal(t, map[string]any{"clips": int64(2), "action": "music"}, value)
+
+ value, callErr = plugin.Engine.Call(t.Context(), "extractUsageOnComplete", nil, map[string]any{}, []any{
+ map[string]any{"id": "song-1", "audio_url": "https://upstream.example/one.mp3"},
+ map[string]any{"id": "song-2", "audio_url": "https://upstream.example/two.mp3"},
+ })
+ require.NoError(t, callErr)
+ assert.Equal(t, map[string]any{"clips": int64(2), "action": "music"}, value)
+ })
+
+ const firstAudioKey = "audio-5fc0a0cd3367274b4b6de056fc754263f8726a704bb4814ffeb88495f22dad35"
+ const secondAudioKey = "audio-a9c04b840373f4ef4e8d80140b745c6f647819fa375bc34368cdccced7e2b455"
+ protocolContext := map[string]any{
+ "requestBody": map[string]any{"model": "suno_music"},
+ "stream": true,
+ "artifacts": map[string]any{
+ firstAudioKey: map[string]any{"key": firstAudioKey, "type": "audio", "url": "https://gateway.example/artifacts/song-1"},
+ secondAudioKey: map[string]any{"key": secondAudioKey, "type": "audio", "url": "https://gateway.example/artifacts/song-2"},
+ },
+ }
+ successTask := map[string]any{
+ "task_id": "task-public", "status": "SUCCESS", "progress": "100%", "created_at": 10, "updated_at": 20,
+ "data": []any{
+ map[string]any{"id": "song-1", "title": "First", "text": "First lyrics", "audio_url": "https://upstream.example/one.mp3"},
+ map[string]any{"id": "song-2", "title": "Second", "text": "Second lyrics", "audio_url": "https://upstream.example/two.mp3"},
+ },
+ }
+
+ t.Run("renders stream state transitions", func(t *testing.T) {
+ progressValue := callProtocol(t, "renderEvents", protocolContext, map[string]any{"status": "IN_PROGRESS", "progress": "40%"})
+ progress, decodeErr := relay.DecodePluginProtocolEventResult(progressValue, relay.DefaultPluginProtocolLimits())
+ require.NoError(t, decodeErr)
+ require.Len(t, progress.Events, 1)
+ require.NotNil(t, progress.Events[0].Progress)
+ assert.Equal(t, float64(40), *progress.Events[0].Progress)
+
+ duplicateValue := callProtocol(t, "renderEvents", protocolContext, map[string]any{"status": "IN_PROGRESS", "progress": "40%"}, map[string]any{"status": "IN_PROGRESS", "progress": float64(40)})
+ duplicate, decodeErr := relay.DecodePluginProtocolEventResult(duplicateValue, relay.DefaultPluginProtocolLimits())
+ require.NoError(t, decodeErr)
+ assert.Empty(t, duplicate.Events)
+
+ failureValue := callProtocol(t, "renderEvents", protocolContext, map[string]any{"status": "FAILURE", "fail_reason": "blocked"})
+ failure, decodeErr := relay.DecodePluginProtocolEventResult(failureValue, relay.DefaultPluginProtocolLimits())
+ require.NoError(t, decodeErr)
+ require.Len(t, failure.Events, 1)
+ assert.Equal(t, "error", failure.Events[0].Type)
+ assert.True(t, failure.Done)
+
+ successValue := callProtocol(t, "renderEvents", protocolContext, successTask)
+ success, decodeErr := relay.DecodePluginProtocolEventResult(successValue, relay.DefaultPluginProtocolLimits())
+ require.NoError(t, decodeErr)
+ require.Len(t, success.Events, 1)
+ var text string
+ require.NoError(t, common.Unmarshal(success.Events[0].Data, &text))
+ assert.Contains(t, text, "First lyrics")
+ assert.Contains(t, text, "gateway.example/artifacts/song-1")
+ assert.Contains(t, text, "gateway.example/artifacts/song-2")
+ assert.NotContains(t, text, "upstream.example")
+ assert.True(t, success.Done)
+ })
+
+ t.Run("renders one music message with lyrics and audio segments", func(t *testing.T) {
+ value := callProtocol(t, "renderFinal", protocolContext, successTask)
+ machine := relay.NewPluginResponsesMachine("task-public", "suno_music", 10, relay.DefaultPluginProtocolLimits())
+ response, finalErr := machine.FinalResponse(value, "SUCCESS")
+ require.NoError(t, finalErr)
+ output, ok := response["output"].([]any)
+ require.True(t, ok)
+ require.Len(t, output, 1)
+ message, ok := output[0].(map[string]any)
+ require.True(t, ok)
+ content, ok := message["content"].([]any)
+ require.True(t, ok)
+ require.Len(t, content, 3)
+ for index, expected := range []string{"First lyrics", "gateway.example/artifacts/song-1", "gateway.example/artifacts/song-2"} {
+ part, partOK := content[index].(map[string]any)
+ require.True(t, partOK)
+ text, textOK := part["text"].(string)
+ require.True(t, textOK)
+ assert.Contains(t, text, expected)
+ assert.NotContains(t, text, "upstream.example")
+ }
+ metadata, ok := response["metadata"].(map[string]string)
+ require.True(t, ok)
+ assert.Equal(t, "sunoapi", metadata["vendor"])
+ })
+
+ t.Run("renders lyrics without audio artifacts", func(t *testing.T) {
+ value := callProtocol(t, "renderFinal", map[string]any{"requestBody": map[string]any{"model": "suno_lyrics"}}, map[string]any{
+ "task_id": "lyrics-public", "status": "SUCCESS", "data": map[string]any{"id": "lyrics-1", "title": "Tide", "text": "Sea lyrics"},
+ })
+ machine := relay.NewPluginResponsesMachine("lyrics-public", "suno_lyrics", 10, relay.DefaultPluginProtocolLimits())
+ response, finalErr := machine.FinalResponse(value, "SUCCESS")
+ require.NoError(t, finalErr)
+ output, ok := response["output"].([]any)
+ require.True(t, ok)
+ message, ok := output[0].(map[string]any)
+ require.True(t, ok)
+ content, ok := message["content"].([]any)
+ require.True(t, ok)
+ require.Len(t, content, 1)
+ part, ok := content[0].(map[string]any)
+ require.True(t, ok)
+ assert.Contains(t, part["text"], "Sea lyrics")
+ })
+
+ t.Run("requires host audio artifacts", func(t *testing.T) {
+ _, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "renderFinal"}, map[string]any{
+ "requestBody": map[string]any{"model": "suno_music"},
+ }, successTask)
+ require.ErrorContains(t, callErr, "audio artifact is unavailable")
+ assert.NotContains(t, callErr.Error(), "upstream.example")
+ })
+}
diff --git a/plugins/tasks/alibaba/plugin.js b/plugins/tasks/alibaba/plugin.js
new file mode 100644
index 000000000000..04488e7a1abf
--- /dev/null
+++ b/plugins/tasks/alibaba/plugin.js
@@ -0,0 +1,457 @@
+export const meta = {
+ apiVersion: 1,
+ key: "alibaba",
+ name: "Alibaba Bailian",
+ icon: "Bailian.Color",
+ description: {
+ en: "Alibaba Cloud Bailian Wanxiang video generation (text-to-video and image-to-video)",
+ zh: "阿里云百炼万相视频生成(文生视频、图生视频)",
+ },
+ version: "1.0.0",
+ author: { name: "QuantumNous" },
+ channelTypes: [17],
+ models: [
+ "wan2.7-i2v",
+ "wan2.7-t2v",
+ "wan2.5-t2v-preview",
+ "wan2.5-i2v-preview",
+ "wan2.2-i2v-flash",
+ "wan2.2-i2v-plus",
+ "wanx2.1-i2v-plus",
+ "wanx2.1-i2v-turbo",
+ ],
+ fetchMode: "per_task",
+ usageSchema: {
+ seconds: {
+ type: "number",
+ unit: "second",
+ description: { en: "Requested video duration in seconds.", zh: "请求的视频时长,单位为秒。" },
+ },
+ resolution: {
+ enum: ["480P", "720P", "1080P"],
+ description: { en: "Requested output video resolution.", zh: "请求的输出视频分辨率。" },
+ },
+ },
+ routes: [
+ { method: "POST", path: "/ali/api/v1/services/aigc/video-generation/video-synthesis", type: "submit", decode: "createVideoTask", render: "taskCreated" },
+ { method: "GET", path: "/ali/api/v1/tasks/:task_id", type: "query", render: "taskStatus" },
+ ],
+ protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
+};
+
+function trimmed(value) {
+ return String(value || "").trim();
+}
+
+function firstImage(req) {
+ if (trimmed(req.image)) return trimmed(req.image);
+ for (const image of req.images || []) if (trimmed(image)) return trimmed(image);
+ return trimmed(req.input_reference);
+}
+
+function secondImage(req) {
+ let count = 0;
+ for (const image of req.images || []) {
+ if (!trimmed(image)) continue;
+ count++;
+ if (count === 2) return trimmed(image);
+ }
+ return "";
+}
+
+function normalizeResolution(value) {
+ let resolution = String(value || "").toUpperCase();
+ if (!resolution.endsWith("P")) resolution += "P";
+ return resolution;
+}
+
+function convert(ctx) {
+ const req = ctx.requestBody;
+ const upstreamModel = ctx.upstreamModel || req.model;
+ const input = { prompt: req.prompt || "" };
+ const image = firstImage(req);
+ if (image) input.img_url = image;
+ const parameters = { prompt_extend: true, duration: 5 };
+
+ if (req.size) {
+ if (String(req.model).includes("t2v") && !String(req.size).includes("*")) throw new Error("invalid size: " + req.size + ", example: 1920*1080");
+ if (String(req.size).includes("*")) parameters.size = req.size;
+ else parameters.resolution = normalizeResolution(req.size);
+ } else if (String(req.model).includes("t2v")) {
+ parameters.size = String(req.model).startsWith("wan2.5") || String(req.model).startsWith("wan2.2") ? "1920*1080" : "1280*720";
+ } else if (String(req.model).startsWith("wan2.6") || String(req.model).startsWith("wan2.5") || String(req.model).startsWith("wan2.2-i2v-plus")) {
+ parameters.resolution = "1080P";
+ } else {
+ parameters.resolution = "720P";
+ }
+
+ if (Number(req.duration) > 0) parameters.duration = Number(req.duration);
+ else if (req.seconds) {
+ const seconds = Number(req.seconds);
+ if (!Number.isInteger(seconds)) throw new Error("convert seconds to int failed");
+ parameters.duration = seconds > 0 ? seconds : 5;
+ }
+
+ const metadata = req.metadata || {};
+ Object.assign(input, metadata.input || {});
+ Object.assign(parameters, metadata.parameters || {});
+ const model = metadata.model === undefined ? upstreamModel : metadata.model;
+ if (model !== upstreamModel) throw new Error("can't change model with metadata");
+ const body = { model: model, input: input, parameters: parameters };
+
+ if (String(model).startsWith("wan2.7-i2v")) {
+ if (!Array.isArray(input.media) || input.media.length === 0) {
+ input.media = [];
+ const first = trimmed(input.first_frame_url) || trimmed(input.img_url) || firstImage(req);
+ const last = trimmed(input.last_frame_url) || secondImage(req);
+ if (first) input.media.push({ type: "first_frame", url: first });
+ if (last) input.media.push({ type: "last_frame", url: last });
+ if (trimmed(input.audio_url)) input.media.push({ type: "driving_audio", url: input.audio_url });
+ }
+ if (input.media.length === 0) throw new Error("wan2.7-i2v requires image, images, input_reference, or input.media");
+ delete input.img_url;
+ delete input.first_frame_url;
+ delete input.last_frame_url;
+ delete input.audio_url;
+ }
+ if (!parameters.prompt_extend) delete parameters.prompt_extend;
+ if (!parameters.watermark) delete parameters.watermark;
+ if (!parameters.seed) delete parameters.seed;
+ for (const key of ["resolution", "size"]) if (!parameters[key]) delete parameters[key];
+ return body;
+}
+
+function resolutionRatio(body) {
+ let resolution = body.parameters.size
+ ? {
+ "832*480": "480P",
+ "480*832": "480P",
+ "624*624": "480P",
+ "1280*720": "720P",
+ "720*1280": "720P",
+ "960*960": "720P",
+ "1088*832": "720P",
+ "832*1088": "720P",
+ "1920*1080": "1080P",
+ "1080*1920": "1080P",
+ "1440*1440": "1080P",
+ "1632*1248": "1080P",
+ "1248*1632": "1080P",
+ }[body.parameters.size]
+ : normalizeResolution(body.parameters.resolution);
+ const ratios = {
+ "wan2.6-i2v": { "720P": 1, "1080P": 1 / 0.6 },
+ "wan2.5-t2v-preview": { "480P": 1, "720P": 2, "1080P": 1 / 0.3 },
+ "wan2.2-t2v-plus": { "480P": 1, "1080P": 5 },
+ "wan2.5-i2v-preview": { "480P": 1, "720P": 2, "1080P": 1 / 0.3 },
+ "wan2.2-i2v-plus": { "480P": 1, "1080P": 5 },
+ "wan2.2-kf2v-flash": { "480P": 1, "720P": 2, "1080P": 4.8 },
+ "wan2.2-i2v-flash": { "480P": 1, "720P": 2 },
+ "wan2.2-s2v": { "480P": 1, "720P": 1.8 },
+ };
+ return ratios[body.model] ? { key: "resolution-" + resolution, value: ratios[body.model][resolution] } : null;
+}
+
+function responsesInput(req) {
+ const texts = [],
+ images = [];
+ const input = req.input;
+ if (typeof input === "string") texts.push(input);
+ else if (Array.isArray(input)) {
+ for (const item of input) {
+ if (typeof item === "string") {
+ texts.push(item);
+ continue;
+ }
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
+ const content = item.content === undefined ? [item] : Array.isArray(item.content) ? item.content : [item.content];
+ for (const part of content) {
+ if (typeof part === "string") {
+ texts.push(part);
+ continue;
+ }
+ if (!part || typeof part !== "object" || Array.isArray(part)) continue;
+ if (["input_text", "text"].includes(part.type) && typeof part.text === "string") texts.push(part.text);
+ if (["input_image", "image_url"].includes(part.type)) {
+ let image = part.image_url;
+ if (image && typeof image === "object") image = image.url;
+ if (trimmed(image)) images.push(trimmed(image));
+ }
+ }
+ }
+ }
+ return {
+ prompt: texts
+ .filter(function (text) {
+ return trimmed(text);
+ })
+ .join("\n"),
+ images: images,
+ };
+}
+
+function responsesVideoText(ctx) {
+ const artifact = ctx && ctx.artifacts && ctx.artifacts.video;
+ const url = trimmed(artifact && artifact.url);
+ if (!url) throw new Error("video artifact is unavailable");
+ const escaped = url.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">");
+ return ' ';
+}
+
+export function buildSubmitRequest(ctx) {
+ const body = convert(ctx);
+ return {
+ url: ctx.baseUrl + "/api/v1/services/aigc/video-generation/video-synthesis",
+ method: "POST",
+ headers: { Authorization: "Bearer " + ctx.apiKey, "Content-Type": "application/json", "X-DashScope-Async": "enable" },
+ body: body,
+ action: firstImage(ctx.requestBody) ? "image_to_video" : "text_to_video",
+ };
+}
+
+export function parseSubmitResponse(ctx, resp) {
+ const body = resp.body || {};
+ if (body.code) throw new Error(body.code + ": " + (body.message || ""));
+ if (!body.output || !body.output.task_id) throw new Error("task_id is empty");
+ return { taskId: body.output.task_id, taskData: body };
+}
+
+export function extractUsage(ctx) {
+ const body = convert(ctx);
+ if (ctx.usagePurpose === "billing_ratios") {
+ const ratios = { seconds: Math.min(Number(body.parameters.duration), 3600) };
+ const resolution = resolutionRatio(body);
+ if (resolution && resolution.value !== undefined) ratios[resolution.key] = resolution.value;
+ return ratios;
+ }
+ let resolution = body.parameters.size
+ ? {
+ "832*480": "480P",
+ "480*832": "480P",
+ "624*624": "480P",
+ "1280*720": "720P",
+ "720*1280": "720P",
+ "960*960": "720P",
+ "1088*832": "720P",
+ "832*1088": "720P",
+ "1920*1080": "1080P",
+ "1080*1920": "1080P",
+ "1440*1440": "1080P",
+ "1632*1248": "1080P",
+ "1248*1632": "1080P",
+ }[body.parameters.size]
+ : normalizeResolution(body.parameters.resolution);
+ if (!["480P", "720P", "1080P"].includes(resolution)) resolution = "720P";
+ return { seconds: Math.min(Number(body.parameters.duration), 3600), resolution: resolution };
+}
+
+export function extractUsageOnComplete(task, taskResult, body) {
+ const output = (body && body.output) || {};
+ const facts = {};
+ const seconds = Number(output.duration || output.duration_seconds || 0);
+ if (Number.isFinite(seconds) && seconds > 0) facts.seconds = Math.min(seconds, 3600);
+ const resolution = normalizeResolution(output.resolution || "");
+ if (["480P", "720P", "1080P"].includes(resolution)) facts.resolution = resolution;
+ return facts;
+}
+
+export function buildQueryRequest(ctx) {
+ return { url: ctx.baseUrl + "/api/v1/tasks/" + ctx.taskId, method: "GET", headers: { Authorization: "Bearer " + ctx.apiKey } };
+}
+
+export function parseTaskResult(ctx, body) {
+ const output = body.output || {};
+ if (output.task_status === "PENDING") return { status: "QUEUED" };
+ if (output.task_status === "RUNNING") return { status: "IN_PROGRESS" };
+ if (output.task_status === "SUCCEEDED") return { status: "SUCCESS", url: output.video_url || "" };
+ if (["FAILED", "CANCELED", "UNKNOWN"].includes(output.task_status)) {
+ let reason = body.message || "";
+ if (!reason && output.message) reason = "task failed, code: " + (output.code || "") + " , message: " + output.message;
+ if (!reason) reason = "task failed";
+ return { status: "FAILURE", reason: reason };
+ }
+ return { status: "QUEUED" };
+}
+
+function artifactData(ctx) {
+ const data = (ctx && ctx.data) || {};
+ if (data.data && typeof data.data === "object" && data.data.task_id && Object.prototype.hasOwnProperty.call(data.data, "data")) return data.data.data || {};
+ return data;
+}
+
+export function listArtifacts(task) {
+ const output = artifactData(task).output || {};
+ return task.status === "SUCCESS" && trimmed(output.video_url) ? [{ key: "video", type: "video" }] : [];
+}
+
+export function buildContentRequest(ctx) {
+ if (ctx.artifactKey !== "video") throw new Error("artifact_not_found");
+ const url = trimmed((artifactData(ctx).output || {}).video_url);
+ if (!url) throw new Error("artifact_not_found");
+ return { url: url, method: ctx.clientRequest.method, credentialless: true };
+}
+
+export const native = {
+ createVideoTask: function (ctx) {
+ if (!ctx.body || ctx.body.kind !== "json" || !ctx.body.value || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
+ const req = ctx.body.value,
+ input = req.input || {},
+ parameters = req.parameters || {};
+ return {
+ kind: "submit",
+ model: req.model,
+ action: input.img_url ? "image_to_video" : "text_to_video",
+ requestBody: {
+ model: req.model,
+ prompt: input.prompt || "",
+ image: input.img_url,
+ duration: parameters.duration,
+ size: parameters.size || parameters.resolution,
+ },
+ };
+ },
+ taskCreated: function (ctx, task) {
+ const data = task.data || {};
+ return { request_id: data.request_id || "", output: { task_id: task.task_id, task_status: "PENDING" } };
+ },
+ taskStatus: function (ctx, task) {
+ const data = task.data || {},
+ output = Object.assign({}, data.output || {}, { task_id: task.task_id });
+ return Object.assign({}, data, { output: output });
+ },
+ error: function (ctx, error) {
+ return { code: error.code, message: error.message, request_id: "" };
+ },
+};
+
+export const protocols = {
+ openai_responses: {
+ decodeRequest: function (ctx) {
+ if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
+ const req = ctx.body.value;
+ if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
+ const model = trimmed(ctx.model);
+ if (!model) throw new Error("model is required");
+ if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
+ const input = responsesInput(req);
+ const prompt = input.prompt || trimmed(req.prompt);
+ const requestBody = { model: model, prompt: prompt };
+ if (trimmed(req.image)) requestBody.image = trimmed(req.image);
+ if (req.images !== undefined && !Array.isArray(req.images)) throw new Error("images must be an array");
+ const images = [];
+ for (const image of req.images || []) if (trimmed(image) && !images.includes(trimmed(image))) images.push(trimmed(image));
+ for (const image of input.images) if (!images.includes(image)) images.push(image);
+ if (images.length) requestBody.images = images;
+ if (trimmed(req.input_reference)) requestBody.input_reference = trimmed(req.input_reference);
+ for (const key of ["size", "duration", "seconds"]) {
+ if (Object.prototype.hasOwnProperty.call(req, key)) requestBody[key] = req[key];
+ }
+ if (Object.prototype.hasOwnProperty.call(req, "metadata")) requestBody.metadata = req.metadata;
+ if (!prompt && (!model.includes("i2v") || !firstImage(requestBody))) throw new Error("input is required");
+ return { kind: "submit", model: model, action: firstImage(requestBody) ? "image_to_video" : "text_to_video", requestBody: requestBody };
+ },
+ renderEvents: function (ctx, task, previousState) {
+ const status = String(task.status || "UNKNOWN").toUpperCase();
+ const value = Number(String(task.progress || "").replace("%", ""));
+ const progress = Number.isFinite(value) && value >= 0 && value <= 100 ? value : null;
+ const state = { status: status, progress: progress };
+ if (status === "SUCCESS") {
+ const text = responsesVideoText(ctx);
+ const events = previousState && previousState.status === status ? [] : text ? [{ type: "output", data: text }] : [];
+ return { events: events, state: state, done: true };
+ }
+ if (status === "FAILURE") {
+ return { events: [{ type: "error", code: "task_failed", message: "task failed" }], state: state, done: true };
+ }
+ if (previousState && previousState.status === status && previousState.progress === progress) {
+ return { events: [], state: state, done: false };
+ }
+ const event = { type: "progress", message: status.toLowerCase() };
+ if (progress !== null) event.progress = progress;
+ return { events: [event], state: state, done: false };
+ },
+ renderFinal: function (ctx, _task) {
+ return {
+ output: [
+ {
+ type: "message",
+ status: "completed",
+ role: "assistant",
+ content: [
+ {
+ type: "output_text",
+ text: responsesVideoText(ctx),
+ annotations: [],
+ logprobs: [],
+ },
+ ],
+ },
+ ],
+ metadata: { vendor: "ali" },
+ };
+ },
+ },
+ openai_video: {
+ decodeRequest: function (ctx) {
+ let req;
+ if (ctx.body && ctx.body.kind === "json") req = ctx.body.value;
+ else if (ctx.body && ctx.body.kind === "multipart") {
+ if ((ctx.body.files || []).length) throw new Error("Alibaba requires image references to be URLs");
+ const first = function (name) {
+ const values = (ctx.body.fields || {})[name] || [];
+ if (values.length > 1) throw new Error(name + " must be provided once");
+ return values[0];
+ };
+ req = {};
+ const fields = ctx.body.fields || {};
+ for (const name of Object.keys(fields)) {
+ if (name === "images") req.images = fields[name] || [];
+ else req[name] = first(name);
+ }
+ if (req.metadata !== undefined) {
+ let parsed;
+ try {
+ parsed = JSON.parse(req.metadata);
+ } catch (e) {
+ throw new Error("metadata must be a JSON object string");
+ }
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("metadata must be a JSON object string");
+ req.metadata = parsed;
+ }
+ if (req.seconds !== undefined) req.seconds = Number(req.seconds);
+ else if (req.duration !== undefined) req.seconds = Number(req.duration);
+ if (req.duration !== undefined) req.duration = Number(req.duration);
+ } else throw new Error("JSON or multipart body required");
+ return {
+ kind: "submit",
+ model: ctx.model,
+ action: firstImage(req) ? "image_to_video" : "text_to_video",
+ requestBody: Object.assign({}, req, { model: ctx.model }),
+ };
+ },
+ render: function (ctx, task) {
+ const data = task.data || {},
+ outputData = data.output || {};
+ const statuses = {
+ PENDING: "queued",
+ RUNNING: "in_progress",
+ SUCCEEDED: "completed",
+ FAILED: "failed",
+ CANCELED: "failed",
+ UNKNOWN: "failed",
+ };
+ const output = {
+ id: task.task_id,
+ object: "video",
+ model: task.properties ? task.properties.origin_model_name || "" : "",
+ status: statuses[outputData.task_status] || "unknown",
+ progress: Number(String(task.progress || "0").replace("%", "")),
+ created_at: task.created_at,
+ completed_at: task.updated_at,
+ };
+ if (data.code) output.error = { code: data.code, message: data.message || "" };
+ else if (outputData.code) output.error = { code: outputData.code, message: outputData.message || "" };
+ return output;
+ },
+ },
+};
diff --git a/plugins/tasks/doubao/plugin.js b/plugins/tasks/doubao/plugin.js
new file mode 100644
index 000000000000..07afceaaf45f
--- /dev/null
+++ b/plugins/tasks/doubao/plugin.js
@@ -0,0 +1,501 @@
+export const meta = {
+ apiVersion: 1,
+ key: "doubao",
+ name: "Doubao Video",
+ icon: "Doubao.Color",
+ description: {
+ en: "Volcengine Doubao Seedance video generation (text-to-video, image-to-video, and video-to-video)",
+ zh: "火山引擎豆包 Seedance 视频生成(文生视频、图生视频、视频生视频)",
+ },
+ version: "1.0.0",
+ author: { name: "QuantumNous" },
+ channelTypes: [54, 45], // VolcEngine-type channels serve Ark video models with the same wire format
+ models: [
+ "doubao-seedance-1-0-pro-250528",
+ "doubao-seedance-1-0-lite-t2v",
+ "doubao-seedance-1-0-lite-i2v",
+ "doubao-seedance-1-5-pro-251215",
+ "doubao-seedance-2-0-260128",
+ "doubao-seedance-2-0-fast-260128",
+ "doubao-seedance-2-0-mini-260615",
+ "doubao-seedance-2-5-260628",
+ ],
+ fetchMode: "per_task",
+ usageSchema: {
+ tokens: {
+ type: "number",
+ unit: "token",
+ description: {
+ en: "Upstream billing tokens (estimated at submit, actual on completion).",
+ zh: "上游计费 token(提交时预估,完成后按实际值)。",
+ },
+ },
+ resolution: {
+ enum: ["480p", "720p", "1080p", "4k"],
+ description: {
+ en: "Output video resolution; Seedance token unit price varies by resolution tier.",
+ zh: "输出视频分辨率;Seedance token 单价随分辨率档位变化。",
+ },
+ },
+ video_input: {
+ enum: ["none", "video"],
+ description: {
+ en: "Whether the request includes reference video input; Seedance prices video-to-video tokens at a lower unit rate.",
+ zh: "请求是否包含参考视频输入;Seedance 对视频生视频 token 按更低单价计费。",
+ },
+ },
+ },
+ // Official Ark formula tokens = (input + output seconds) × W × H × 24 / 1024,
+ // 16:9 max-pixel sizes, cross-checked against Volcengine price examples.
+ usageExamples: [
+ { label: "480p · 5s", facts: { tokens: 48038, resolution: "480p", video_input: "none" } },
+ { label: "720p · 5s", facts: { tokens: 108000, resolution: "720p", video_input: "none" } },
+ { label: "1080p · 5s", facts: { tokens: 243000, resolution: "1080p", video_input: "none" } },
+ { label: "4k · 5s", facts: { tokens: 972000, resolution: "4k", video_input: "none" } },
+ { label: "720p · 10s", facts: { tokens: 216000, resolution: "720p", video_input: "none" } },
+ { label: "720p · 5s (+4s 输入视频)", facts: { tokens: 194400, resolution: "720p", video_input: "video" } },
+ ],
+ routes: [
+ { method: "POST", path: "/doubao/api/v3/contents/generations/tasks", type: "submit", decode: "createTask", render: "taskCreated" },
+ { method: "GET", path: "/doubao/api/v3/contents/generations/tasks/:task_id", type: "query", render: "taskStatus" },
+ ],
+ protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
+};
+
+function trimmed(value) {
+ return String(value || "").trim();
+}
+
+function draftTaskIds(content) {
+ const ids = [];
+ if (!Array.isArray(content)) return ids;
+ for (const item of content) {
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
+ if (item.type !== "draft_task") continue;
+ const draft = item.draft_task;
+ if (!draft || typeof draft !== "object" || Array.isArray(draft)) continue;
+ const id = trimmed(draft.id);
+ if (id) ids.push(id);
+ }
+ return ids;
+}
+
+function rewriteDraftTaskContent(content, originTasks) {
+ if (!Array.isArray(content)) return content;
+ return content.map(function (item) {
+ if (!item || typeof item !== "object" || Array.isArray(item) || item.type !== "draft_task") return item;
+ const draft = item.draft_task;
+ if (!draft || typeof draft !== "object" || Array.isArray(draft) || !trimmed(draft.id)) return item;
+ const publicId = trimmed(draft.id);
+ let upstream = "";
+ if (Array.isArray(originTasks)) {
+ for (const task of originTasks) {
+ if (task && task.taskId === publicId) {
+ upstream = trimmed(task.upstreamTaskId);
+ break;
+ }
+ }
+ }
+ if (!upstream) throw new Error("origin task is unavailable");
+ return Object.assign({}, item, { draft_task: Object.assign({}, draft, { id: upstream }) });
+ });
+}
+
+function normalizeResolution(value) {
+ const raw = trimmed(value).toLowerCase();
+ if (["480p", "720p", "1080p", "4k"].includes(raw)) return raw;
+ const parts = raw.replace("*", "x").split("x");
+ if (parts.length !== 2) return "720p";
+ const max = Math.max(Number(parts[0]), Number(parts[1]));
+ if (max >= 3840) return "4k";
+ if (max >= 1920) return "1080p";
+ if (max >= 1280) return "720p";
+ return "480p";
+}
+
+function hasVideo(content) {
+ return Array.isArray(content) && content.some((item) => item && (item.type === "video_url" || Object.prototype.hasOwnProperty.call(item, "video_url")));
+}
+
+// Max-pixel 16:9 dimensions per resolution tier. Used when ratio is absent or
+// adaptive so the submit-time estimate overestimates rather than underestimates.
+// Official Ark formula: tokens = seconds × width × height × 24 / 1024.
+// Video input duration is omitted; extractUsageOnComplete overlays the real bill.
+function resolutionMaxPixels(resolution) {
+ if (resolution === "480p") return [854, 480];
+ if (resolution === "1080p") return [1920, 1080];
+ if (resolution === "4k") return [3840, 2160];
+ return [1280, 720];
+}
+
+function estimateTokens(seconds, resolution) {
+ const dims = resolutionMaxPixels(resolution);
+ return (seconds * dims[0] * dims[1] * 24) / 1024;
+}
+
+function videoInputRatio(model, resolution, content) {
+ const video = hasVideo(content);
+ const res = trimmed(resolution).toLowerCase();
+ if (model === "doubao-seedance-2-5-260628") {
+ if (res === "1080p") return video ? 7.0 / 10.7 : 11.7 / 10.7;
+ return video ? 42 / 70 : 1;
+ }
+ if (model === "doubao-seedance-2-0-260128") {
+ if (res === "1080p") return video ? 31 / 46 : 51 / 46;
+ if (res === "4k") return video ? 16 / 46 : 26 / 46;
+ return video ? 28 / 46 : 1;
+ }
+ if (model === "doubao-seedance-2-0-fast-260128") return video ? 22 / 37 : 1;
+ if (model === "doubao-seedance-2-0-mini-260615") return video ? 14 / 23 : 1;
+ return 1;
+}
+
+function responsesInput(req) {
+ const texts = [],
+ images = [];
+ const input = req.input;
+ if (typeof input === "string") texts.push(input);
+ else if (Array.isArray(input)) {
+ for (const item of input) {
+ if (typeof item === "string") {
+ texts.push(item);
+ continue;
+ }
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
+ const content = item.content === undefined ? [item] : Array.isArray(item.content) ? item.content : [item.content];
+ for (const part of content) {
+ if (typeof part === "string") {
+ texts.push(part);
+ continue;
+ }
+ if (!part || typeof part !== "object" || Array.isArray(part)) continue;
+ if (["input_text", "text"].includes(part.type) && typeof part.text === "string") texts.push(part.text);
+ if (["input_image", "image_url"].includes(part.type)) {
+ let image = part.image_url;
+ if (image && typeof image === "object") image = image.url;
+ if (trimmed(image)) images.push(trimmed(image));
+ }
+ }
+ }
+ }
+ return {
+ prompt: texts
+ .filter(function (text) {
+ return trimmed(text);
+ })
+ .join("\n"),
+ images: images,
+ };
+}
+
+function responsesVideoText(ctx) {
+ const artifact = ctx && ctx.artifacts && ctx.artifacts.video;
+ const url = trimmed(artifact && artifact.url);
+ if (!url) throw new Error("video artifact is unavailable");
+ const escaped = url.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">");
+ return ' ';
+}
+
+export const native = {
+ createTask: function (ctx) {
+ if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
+ const body = ctx.body.value;
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("request body must be an object");
+ const model = trimmed(body.model);
+ if (!model) throw new Error("model is required");
+ if (body.content !== undefined && !Array.isArray(body.content)) throw new Error("content must be an array");
+ const content = Array.isArray(body.content) ? body.content : [];
+ const texts = [];
+ let hasReference = false;
+ for (const item of content) {
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
+ if (item.type === "text" && typeof item.text === "string") texts.push(item.text);
+ else hasReference = true;
+ }
+ if (!texts.length && !hasReference) throw new Error("content is required");
+ const requestBody = {
+ model: model,
+ prompt: texts
+ .filter(function (text) {
+ return trimmed(text);
+ })
+ .join("\n"),
+ metadata: body,
+ };
+ const seconds = Number(body.duration);
+ if (Number.isFinite(seconds) && seconds > 0) requestBody.seconds = seconds;
+ const intent = { kind: "submit", model: model, action: hasReference ? "image_to_video" : "text_to_video", requestBody: requestBody };
+ const originTaskIds = draftTaskIds(content);
+ if (originTaskIds.length) intent.originTaskIds = originTaskIds;
+ return intent;
+ },
+ taskCreated: function (ctx, task) {
+ const data = task.data && typeof task.data === "object" && !Array.isArray(task.data) ? task.data : {};
+ return Object.assign({}, data, { id: task.task_id });
+ },
+ taskStatus: function (ctx, task) {
+ if (task.data && typeof task.data === "object" && !Array.isArray(task.data)) return Object.assign({}, task.data, { id: task.task_id });
+ const statusMap = { NOT_START: "queued", SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "running", SUCCESS: "succeeded", FAILURE: "failed" };
+ const output = { id: task.task_id, status: statusMap[task.status] || "queued" };
+ if (task.fail_reason) output.error = { message: task.fail_reason };
+ return output;
+ },
+ error: function (ctx, error) {
+ return { error: { code: error.code, message: error.message } };
+ },
+};
+
+export function buildSubmitRequest(ctx) {
+ const req = ctx.requestBody;
+ const metadata = req.metadata || {};
+ const body = Object.assign({ model: req.model || "", content: [] }, metadata);
+ const imageContent = [];
+ const images = Array.isArray(req.images) ? req.images : [];
+ for (const url of images) imageContent.push({ type: "image_url", image_url: { url: url } });
+ const metadataContent = Array.isArray(body.content) ? body.content : [];
+ body.content = imageContent.concat(metadataContent).filter((item) => item && item.type !== "text");
+ const hasReference = body.content.length > 0;
+ if (trimmed(req.prompt) || !hasReference) body.content.push({ type: "text", text: req.prompt || "" });
+ if (Array.isArray(body.content)) body.content = rewriteDraftTaskContent(body.content, ctx.originTasks);
+ const seconds = Number.parseInt(req.seconds || "", 10);
+ if (seconds > 0) body.duration = seconds;
+ body.model = ctx.upstreamModel || body.model;
+ return {
+ url: ctx.baseUrl + "/api/v3/contents/generations/tasks",
+ method: "POST",
+ headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: "Bearer " + ctx.apiKey },
+ body: body,
+ action: hasReference ? "image_to_video" : "text_to_video",
+ rewriteModel: body.model,
+ };
+}
+
+export function parseSubmitResponse(ctx, resp) {
+ if (!resp.body || !resp.body.id) throw new Error("task_id is empty");
+ return { taskId: resp.body.id, taskData: resp.body };
+}
+
+export function extractUsage(ctx) {
+ const req = ctx.requestBody || {};
+ const metadata = req.metadata || {};
+ if (ctx.usagePurpose === "billing_ratios") {
+ const ratio = videoInputRatio(ctx.model, metadata.resolution, metadata.content);
+ return ratio === 1 ? null : { video_input_ratio: ratio };
+ }
+ let seconds = Number(req.seconds || req.duration || metadata.duration || 0);
+ if (!Number.isFinite(seconds) || seconds <= 0) {
+ const frames = Number(metadata.frames);
+ seconds = Number.isFinite(frames) && frames > 0 ? Math.floor(frames / 24) : 15;
+ }
+ if (seconds <= 0) seconds = 5;
+ seconds = Math.min(seconds, 3600);
+ const rawResolution = metadata.resolution || req.size;
+ const raw = trimmed(rawResolution).toLowerCase();
+ const recognized = ["480p", "720p", "1080p", "4k"].includes(raw) || raw.replace("*", "x").split("x").length === 2;
+ const resolution = recognized ? normalizeResolution(rawResolution) : "1080p";
+ return {
+ tokens: estimateTokens(seconds, resolution),
+ resolution: resolution,
+ video_input: hasVideo(metadata.content) ? "video" : "none",
+ };
+}
+
+export function buildQueryRequest(ctx) {
+ return {
+ url: ctx.baseUrl + "/api/v3/contents/generations/tasks/" + ctx.taskId,
+ method: "GET",
+ headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: "Bearer " + ctx.apiKey },
+ };
+}
+
+export function parseTaskResult(ctx, body) {
+ if (body.status === "pending" || body.status === "queued") return { status: "QUEUED", progress: "10%" };
+ if (body.status === "processing" || body.status === "running") return { status: "IN_PROGRESS", progress: "50%" };
+ if (body.status === "succeeded") {
+ const result = { status: "SUCCESS", progress: "100%", url: body.content && body.content.video_url ? body.content.video_url : "" };
+ const usage = body.usage || {};
+ const completionTokens = Number(usage.completion_tokens || 0);
+ const totalTokens = Number(usage.total_tokens || 0);
+ if (Number.isFinite(completionTokens) && completionTokens > 0) result.completionTokens = completionTokens;
+ if (Number.isFinite(totalTokens) && totalTokens > 0) result.totalTokens = totalTokens;
+ return result;
+ }
+ if (body.status === "failed" || body.status === "expired" || body.status === "cancelled") {
+ const reason = body.error && body.error.message ? body.error.message : body.status;
+ return { status: "FAILURE", progress: "100%", reason: reason };
+ }
+ return { status: "IN_PROGRESS", progress: "30%" };
+}
+
+function artifactData(ctx) {
+ const data = (ctx && ctx.data) || {};
+ if (data.data && typeof data.data === "object" && data.data.task_id && Object.prototype.hasOwnProperty.call(data.data, "data")) return data.data.data || {};
+ return data;
+}
+
+export function listArtifacts(task) {
+ if (task.status !== "SUCCESS") return [];
+ const content = artifactData(task).content || {};
+ const artifacts = [];
+ if (trimmed(content.video_url)) artifacts.push({ key: "video", type: "video" });
+ if (trimmed(content.last_frame_url)) artifacts.push({ key: "last_frame", type: "image", mimeType: "image/png" });
+ return artifacts;
+}
+
+export function buildContentRequest(ctx) {
+ const content = artifactData(ctx).content || {};
+ const urls = { video: content.video_url, last_frame: content.last_frame_url };
+ const url = trimmed(urls[ctx.artifactKey]);
+ if (!url) throw new Error("artifact_not_found");
+ return { url: url, method: ctx.clientRequest.method, credentialless: true };
+}
+
+export function extractUsageOnComplete(task, taskResult, body) {
+ if (!body || body.status !== "succeeded") return {};
+ const facts = {};
+ const usage = body.usage || {};
+ let tokens = Number(usage.completion_tokens);
+ if (!Number.isFinite(tokens) || tokens <= 0) tokens = Number(usage.total_tokens);
+ if (Number.isFinite(tokens) && tokens > 0) facts.tokens = tokens;
+ const content = body.content || {};
+ const resolution = trimmed(content.resolution || body.resolution).toLowerCase();
+ if (["480p", "720p", "1080p", "4k"].includes(resolution)) facts.resolution = resolution;
+ return facts;
+}
+
+export const protocols = {
+ openai_responses: {
+ decodeRequest: function (ctx) {
+ if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
+ const req = ctx.body.value;
+ if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
+ const model = trimmed(req.model);
+ if (!model) throw new Error("model is required");
+ if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
+ if (req.images !== undefined && !Array.isArray(req.images)) throw new Error("images must be an array");
+ if (req.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
+ throw new Error("metadata must be an object");
+ const input = responsesInput(req);
+ const prompt = input.prompt || trimmed(req.prompt);
+ const images = [];
+ for (const image of [req.image, req.input_reference].concat(req.images || [], input.images)) {
+ if (trimmed(image) && !images.includes(trimmed(image))) images.push(trimmed(image));
+ }
+ if (!prompt && images.length === 0) throw new Error("input is required");
+ const metadata = Object.assign({}, req.metadata || {});
+ if (Object.prototype.hasOwnProperty.call(req, "resolution")) metadata.resolution = req.resolution;
+ else if (req.size && !metadata.resolution) metadata.resolution = normalizeResolution(req.size);
+ const requestBody = { model: model, prompt: prompt, metadata: metadata };
+ if (images.length) requestBody.images = images;
+ if (Object.prototype.hasOwnProperty.call(req, "seconds")) requestBody.seconds = req.seconds;
+ else if (Object.prototype.hasOwnProperty.call(req, "duration")) requestBody.seconds = req.duration;
+ if (Object.prototype.hasOwnProperty.call(req, "size")) requestBody.size = req.size;
+ const intent = { kind: "submit", model: model, action: images.length ? "image_to_video" : "text_to_video", requestBody: requestBody };
+ const originTaskIds = draftTaskIds(metadata.content);
+ if (originTaskIds.length) intent.originTaskIds = originTaskIds;
+ return intent;
+ },
+ renderEvents: function (ctx, task, previousState) {
+ const status = String(task.status || "UNKNOWN").toUpperCase();
+ const value = Number(String(task.progress || "").replace("%", ""));
+ const progress = Number.isFinite(value) && value >= 0 && value <= 100 ? value : null;
+ const state = { status: status, progress: progress };
+ if (status === "SUCCESS") {
+ const text = responsesVideoText(ctx);
+ const events = previousState && previousState.status === status ? [] : [{ type: "output", data: text }];
+ return { events: events, state: state, done: true };
+ }
+ if (status === "FAILURE")
+ return { events: [{ type: "error", code: "task_failed", message: task.fail_reason || "task failed" }], state: state, done: true };
+ if (previousState && previousState.status === status && previousState.progress === progress) return { events: [], state: state, done: false };
+ const event = { type: "progress", message: status.toLowerCase() };
+ if (progress !== null) event.progress = progress;
+ return { events: [event], state: state, done: false };
+ },
+ renderFinal: function (ctx, _task) {
+ return {
+ output: [
+ {
+ type: "message",
+ status: "completed",
+ role: "assistant",
+ content: [{ type: "output_text", text: responsesVideoText(ctx), annotations: [], logprobs: [] }],
+ },
+ ],
+ metadata: { vendor: "doubao" },
+ };
+ },
+ },
+};
+
+const legacyRenderers = {
+ openai_video: function (task) {
+ const data = task.data || {};
+ const statusMap = { NOT_START: "queued", SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "in_progress", SUCCESS: "completed", FAILURE: "failed" };
+ const output = {
+ id: task.task_id,
+ object: "video",
+ model: task.properties ? task.properties.origin_model_name || "" : "",
+ status: statusMap[task.status] || "unknown",
+ progress: Number(String(task.progress || "0").replace("%", "")),
+ created_at: task.created_at,
+ completed_at: task.updated_at,
+ };
+ if (data.status === "failed") output.error = { message: data.error ? data.error.message || "" : "", code: data.error ? data.error.code || "" : "" };
+ return output;
+ },
+};
+
+protocols.openai_video = {
+ decodeRequest: function (ctx) {
+ if (!ctx.body || (ctx.body.kind !== "json" && ctx.body.kind !== "multipart")) throw new Error("JSON or multipart body required");
+ if (ctx.body.kind === "json") {
+ if (!ctx.body.value || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
+ const req = ctx.body.value;
+ const seconds = req.seconds === undefined ? req.duration : req.seconds;
+ if (seconds !== undefined && (!Number.isFinite(Number(seconds)) || Number(seconds) <= 0 || Number(seconds) > 3600))
+ throw new Error("seconds must be between 1 and 3600");
+ return {
+ kind: "submit",
+ model: ctx.model,
+ action: req.input_reference || req.image ? "image_to_video" : "text_to_video",
+ requestBody: Object.assign({}, req, { model: ctx.model }),
+ };
+ }
+ const first = function (name) {
+ const values = (ctx.body.fields || {})[name] || [];
+ if (values.length > 1) throw new Error(name + " must be provided once");
+ return values[0];
+ };
+ const req = {};
+ const fields = ctx.body.fields || {};
+ for (const name of Object.keys(fields)) {
+ req[name] = first(name);
+ }
+ if (req.metadata !== undefined) {
+ let parsed;
+ try {
+ parsed = JSON.parse(req.metadata);
+ } catch (e) {
+ throw new Error("metadata must be a JSON object string");
+ }
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("metadata must be a JSON object string");
+ req.metadata = parsed;
+ }
+ if ((ctx.body.files || []).length) throw new Error("Doubao requires image and video references to be URLs inside metadata.content");
+ if (req.seconds !== undefined) req.seconds = Number(req.seconds);
+ else if (req.duration !== undefined) req.seconds = Number(req.duration);
+ const seconds = req.seconds === undefined ? req.duration : req.seconds;
+ if (seconds !== undefined && (!Number.isFinite(Number(seconds)) || Number(seconds) <= 0 || Number(seconds) > 3600))
+ throw new Error("seconds must be between 1 and 3600");
+ return {
+ kind: "submit",
+ model: ctx.model,
+ action: req.input_reference || req.image ? "image_to_video" : "text_to_video",
+ requestBody: Object.assign({}, req, { model: ctx.model }),
+ };
+ },
+ render: function (ctx, task) {
+ return legacyRenderers.openai_video(task);
+ },
+};
diff --git a/plugins/tasks/google/plugin.js b/plugins/tasks/google/plugin.js
new file mode 100644
index 000000000000..2c3c04c1e80e
--- /dev/null
+++ b/plugins/tasks/google/plugin.js
@@ -0,0 +1,381 @@
+export const meta = {
+ apiVersion: 1,
+ key: "google",
+ name: "Google Veo (Gemini API)",
+ icon: "Gemini.Color",
+ description: {
+ en: "Google Veo video generation on the Gemini API (text-to-video and image-to-video)",
+ zh: "Google Veo 视频生成(文生视频、图生视频),Gemini API 版本",
+ },
+ version: "1.0.0",
+ author: { name: "QuantumNous" },
+ channelTypes: [24],
+ models: ["veo-3.0-generate-001", "veo-3.0-fast-generate-001", "veo-3.1-generate-preview", "veo-3.1-fast-generate-preview"],
+ fetchMode: "per_task",
+ usageSchema: {
+ seconds: {
+ type: "number",
+ unit: "second",
+ description: {
+ en: "Requested video duration in seconds. Allowed values: 4, 6, 8.",
+ zh: "请求的视频时长,单位为秒。允许值为 4、6、8。",
+ },
+ },
+ resolution: {
+ enum: ["720p", "1080p", "4k"],
+ description: {
+ en: "Requested video output resolution. Veo prices differ per resolution tier.",
+ zh: "请求的输出视频分辨率。Veo 各分辨率档位计费不同。",
+ },
+ },
+ },
+ usageExamples: [
+ { label: "8s × 720p", facts: { seconds: 8, resolution: "720p" } },
+ { label: "8s × 1080p", facts: { seconds: 8, resolution: "1080p" } },
+ { label: "8s × 4k", facts: { seconds: 8, resolution: "4k" } },
+ { label: "4s × 720p", facts: { seconds: 4, resolution: "720p" } },
+ ],
+ protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
+};
+
+function trimmed(value) {
+ return String(value || "").trim();
+}
+
+function responsesInput(req) {
+ const texts = [],
+ images = [];
+ const input = req.input;
+ if (typeof input === "string") texts.push(input);
+ else if (Array.isArray(input)) {
+ for (const item of input) {
+ if (typeof item === "string") {
+ texts.push(item);
+ continue;
+ }
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
+ const content = item.content === undefined ? [item] : Array.isArray(item.content) ? item.content : [item.content];
+ for (const part of content) {
+ if (typeof part === "string") {
+ texts.push(part);
+ continue;
+ }
+ if (!part || typeof part !== "object" || Array.isArray(part)) continue;
+ if (["input_text", "text"].includes(part.type) && typeof part.text === "string") texts.push(part.text);
+ if (["input_image", "image_url"].includes(part.type)) {
+ let image = part.image_url;
+ if (image && typeof image === "object") image = image.url;
+ if (trimmed(image)) images.push(trimmed(image));
+ }
+ }
+ }
+ }
+ return {
+ prompt: texts
+ .filter(function (text) {
+ return trimmed(text);
+ })
+ .join("\n"),
+ images: images,
+ };
+}
+
+function responsesVideoText(ctx) {
+ const artifact = ctx && ctx.artifacts && ctx.artifacts.video;
+ const url = trimmed(artifact && artifact.url);
+ if (!url) throw new Error("video artifact is unavailable");
+ const escaped = url.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">");
+ return ' ';
+}
+
+function sizeParts(size) {
+ const parts = String(size || "")
+ .toLowerCase()
+ .split("x");
+ if (parts.length !== 2) return null;
+ return [Number(parts[0]), Number(parts[1])];
+}
+
+function resolutionForSize(size) {
+ const parts = sizeParts(size);
+ if (!parts) return "720p";
+ const max = Math.max(parts[0], parts[1]);
+ if (max >= 3840) return "4k";
+ if (max >= 1920) return "1080p";
+ return "720p";
+}
+
+function aspectForSize(size) {
+ const parts = sizeParts(size);
+ if (!parts || parts[0] <= 0 || parts[1] <= 0) return "16:9";
+ return parts[1] > parts[0] ? "9:16" : "16:9";
+}
+
+function imageInput(value, files) {
+ if (value && typeof value === "object" && !Array.isArray(value) && value.__fileRef) {
+ let mime = value.mimeType || "";
+ if (!mime && files) {
+ for (const file of files) {
+ if (file.ref === value.__fileRef || file.field === "input_reference") {
+ mime = file.mimeType || "";
+ break;
+ }
+ }
+ }
+ return { inlineData: { mimeType: mime || "application/octet-stream", data: value } };
+ }
+ value = String(value || "").trim();
+ if (!value) return null;
+ if (value.startsWith("data:")) {
+ const comma = value.indexOf(",");
+ if (comma < 0 || !value.slice(comma + 1)) return null;
+ const mediaType = value.slice(5, comma).split(";")[0];
+ return { inlineData: { mimeType: mediaType || "application/octet-stream", data: value.slice(comma + 1) } };
+ }
+ // Raw base64 input is accepted by the Go adaptor. The common fixtures use
+ // PNG data; browser-free plugins cannot invoke net/http DetectContentType.
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
+ let mime = "application/octet-stream";
+ if (value.startsWith("iVBORw0KGgo")) mime = "image/png";
+ else if (value.startsWith("/9j/")) mime = "image/jpeg";
+ else if (value.startsWith("R0lGOD")) mime = "image/gif";
+ else if (value.startsWith("UklGR")) mime = "image/webp";
+ return { inlineData: { mimeType: mime, data: value } };
+}
+
+function converted(ctx) {
+ const req = ctx.requestBody || {};
+ const metadata = Object.assign({}, req.metadata || {});
+ const params = Object.assign({}, metadata);
+ if (Number(req.duration) > 0) params.durationSeconds = Number(req.duration);
+ if (!params.resolution && req.size) params.resolution = resolutionForSize(req.size);
+ if (!params.aspectRatio && req.size) params.aspectRatio = aspectForSize(req.size);
+ if (params.resolution) params.resolution = String(params.resolution).toLowerCase();
+ params.numberOfVideos = 1;
+ const instance = { prompt: req.prompt };
+ const image = imageInput((req.images || [])[0], ctx.files);
+ if (image) instance.image = image;
+ return { body: { instances: [instance], parameters: params }, action: image ? "image_to_video" : "text_to_video" };
+}
+
+function version(ctx) {
+ const settings = ctx.userSetting || {};
+ return settings.geminiVersion || "v1beta";
+}
+
+export function buildSubmitRequest(ctx) {
+ const result = converted(ctx);
+ return {
+ url: ctx.baseUrl + "/" + version(ctx) + "/models/" + ctx.upstreamModel + ":predictLongRunning",
+ method: "POST",
+ headers: { "Content-Type": "application/json", Accept: "application/json", "x-goog-api-key": ctx.apiKey },
+ body: result.body,
+ action: result.action,
+ };
+}
+
+export function parseSubmitResponse(ctx, resp) {
+ const body = resp.body || {};
+ if (!String(body.name || "").trim()) throw new Error("missing operation name");
+ const result = { taskId: utils.base64URL(body.name), taskData: body };
+ if (body.done && !(body.error && body.error.message)) {
+ const videos = ((body.response || {}).generateVideoResponse || {}).generatedVideos || [];
+ const uri = videos.length && videos[0].video ? videos[0].video.uri || "" : "";
+ result.immediate = { taskId: result.taskId, status: "SUCCESS", progress: "100%", remoteUrl: uri };
+ }
+ return result;
+}
+
+export function extractUsage(ctx) {
+ const req = ctx.requestBody || {};
+ const metadata = req.metadata || {};
+ let seconds = Number(req.duration);
+ if (!Number.isFinite(seconds) || seconds <= 0) seconds = 8;
+ const resolution = String(metadata.resolution || resolutionForSize(req.size) || "720p").toLowerCase();
+ return { seconds: seconds, resolution: resolution };
+}
+
+export function extractUsageOnComplete() {
+ return null;
+}
+
+export function buildQueryRequest(ctx) {
+ return {
+ url: ctx.baseUrl + "/v1beta/" + utils.base64URLDecode(ctx.taskId),
+ method: "GET",
+ headers: { Accept: "application/json", "x-goog-api-key": ctx.apiKey },
+ };
+}
+
+export function parseTaskResult(ctx, body) {
+ if (body.error && body.error.message) return { status: "FAILURE", progress: "100%", reason: body.error.message };
+ if (!body.done) return { status: "IN_PROGRESS", progress: "50%" };
+ const videos = ((body.response || {}).generateVideoResponse || {}).generatedVideos || [];
+ const uri = videos.length && videos[0].video ? videos[0].video.uri || "" : "";
+ return { taskId: utils.base64URL(body.name || ""), status: "SUCCESS", progress: "100%", remoteUrl: uri };
+}
+
+function artifactData(ctx) {
+ const data = (ctx && ctx.data) || {};
+ if (data.data && typeof data.data === "object" && data.data.task_id && Object.prototype.hasOwnProperty.call(data.data, "data")) return data.data.data || {};
+ return data;
+}
+
+function artifactVideoURL(ctx) {
+ const videos = ((artifactData(ctx).response || {}).generateVideoResponse || {}).generatedVideos || [];
+ return videos.length && videos[0].video ? String(videos[0].video.uri || "").trim() : "";
+}
+
+export function listArtifacts(task) {
+ return task.status === "SUCCESS" && artifactVideoURL(task) ? [{ key: "video", type: "video" }] : [];
+}
+
+export function buildContentRequest(ctx) {
+ if (ctx.artifactKey !== "video") throw new Error("artifact_not_found");
+ const url = artifactVideoURL(ctx);
+ if (!url) throw new Error("artifact_not_found");
+ return { url: url, method: ctx.clientRequest.method, headers: { "x-goog-api-key": ctx.apiKey } };
+}
+
+export const protocols = {
+ openai_responses: {
+ decodeRequest: function (ctx) {
+ if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
+ const req = ctx.body.value;
+ if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
+ const model = trimmed(req.model);
+ if (!model) throw new Error("model is required");
+ if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
+ if (req.images !== undefined && !Array.isArray(req.images)) throw new Error("images must be an array");
+ if (req.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
+ throw new Error("metadata must be an object");
+ const input = responsesInput(req);
+ const prompt = input.prompt || trimmed(req.prompt);
+ const images = [];
+ for (const image of [req.image, req.input_reference].concat(req.images || [], input.images)) {
+ if (trimmed(image) && !images.includes(trimmed(image))) images.push(trimmed(image));
+ }
+ if (!prompt && images.length === 0) throw new Error("input is required");
+ if (images.length && !imageInput(images[0])) throw new Error("input image must be a data URL or base64 value");
+ const metadata = Object.assign({}, req.metadata || {});
+ if (Object.prototype.hasOwnProperty.call(req, "resolution")) metadata.resolution = req.resolution;
+ const requestBody = { model: model, prompt: prompt, metadata: metadata };
+ if (images.length) requestBody.images = images;
+ if (Object.prototype.hasOwnProperty.call(req, "seconds")) requestBody.duration = req.seconds;
+ else if (Object.prototype.hasOwnProperty.call(req, "duration")) requestBody.duration = req.duration;
+ if (Object.prototype.hasOwnProperty.call(req, "size")) requestBody.size = req.size;
+ return { kind: "submit", model: model, action: images.length ? "image_to_video" : "text_to_video", requestBody: requestBody };
+ },
+ renderEvents: function (ctx, task, previousState) {
+ const status = String(task.status || "UNKNOWN").toUpperCase();
+ const value = Number(String(task.progress || "").replace("%", ""));
+ const progress = Number.isFinite(value) && value >= 0 && value <= 100 ? value : null;
+ const state = { status: status, progress: progress };
+ if (status === "SUCCESS") {
+ const text = responsesVideoText(ctx);
+ const events = previousState && previousState.status === status ? [] : [{ type: "output", data: text }];
+ return { events: events, state: state, done: true };
+ }
+ if (status === "FAILURE")
+ return { events: [{ type: "error", code: "task_failed", message: task.fail_reason || "task failed" }], state: state, done: true };
+ if (previousState && previousState.status === status && previousState.progress === progress) return { events: [], state: state, done: false };
+ const event = { type: "progress", message: status.toLowerCase() };
+ if (progress !== null) event.progress = progress;
+ return { events: [event], state: state, done: false };
+ },
+ renderFinal: function (ctx, _task) {
+ return {
+ output: [
+ {
+ type: "message",
+ status: "completed",
+ role: "assistant",
+ content: [{ type: "output_text", text: responsesVideoText(ctx), annotations: [], logprobs: [] }],
+ },
+ ],
+ metadata: { vendor: "gemini" },
+ };
+ },
+ },
+};
+
+const legacyRenderers = {
+ openai_video: function (task) {
+ const model = (task.properties || {}).origin_model_name || "veo-3.0-generate-001";
+ const statuses = { SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "in_progress", SUCCESS: "completed", FAILURE: "failed" };
+ const output = {
+ id: task.task_id,
+ object: "video",
+ model: model,
+ status: statuses[task.status] || "unknown",
+ progress: Number(String(task.progress || "0").replace("%", "")),
+ created_at: task.created_at,
+ };
+ if (Number(task.finish_time) > 0) output.completed_at = Number(task.finish_time);
+ else if (Number(task.updated_at) > 0) output.completed_at = Number(task.updated_at);
+ return output;
+ },
+};
+
+protocols.openai_video = {
+ decodeRequest: function (ctx) {
+ if (!ctx.body || (ctx.body.kind !== "json" && ctx.body.kind !== "multipart")) throw new Error("JSON or multipart body required");
+ let req;
+ let hasInputReferenceFile = false;
+ if (ctx.body.kind === "json") {
+ if (!ctx.body.value || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
+ req = Object.assign({}, ctx.body.value);
+ } else {
+ const first = function (name) {
+ const values = (ctx.body.fields || {})[name] || [];
+ if (values.length > 1) throw new Error(name + " must be provided once");
+ return values[0];
+ };
+ req = {};
+ const fields = ctx.body.fields || {};
+ for (const name of Object.keys(fields)) {
+ req[name] = first(name);
+ }
+ for (const file of ctx.body.files || []) {
+ if (file.field !== "input_reference") throw new Error("unexpected file field: " + file.field);
+ if (hasInputReferenceFile) throw new Error("input_reference must be provided once");
+ hasInputReferenceFile = true;
+ }
+ if (req.metadata !== undefined) {
+ let parsed;
+ try {
+ parsed = JSON.parse(req.metadata);
+ } catch {
+ throw new Error("metadata must be a JSON object string");
+ }
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("metadata must be a JSON object string");
+ req.metadata = parsed;
+ }
+ if (req.seconds !== undefined) req.seconds = Number(req.seconds);
+ else if (req.duration !== undefined) req.seconds = Number(req.duration);
+ }
+ const seconds = req.seconds === undefined ? req.duration : req.seconds;
+ if (seconds !== undefined) {
+ const n = Number(seconds);
+ if (n !== 4 && n !== 6 && n !== 8) throw new Error("seconds must be one of 4, 6, or 8");
+ req.duration = n;
+ }
+ const providedResolution = req.resolution !== undefined ? req.resolution : req.metadata && req.metadata.resolution;
+ if (providedResolution !== undefined && providedResolution !== "") {
+ const resolution = String(providedResolution).toLowerCase();
+ if (resolution !== "720p" && resolution !== "1080p" && resolution !== "4k") throw new Error("resolution must be one of 720p, 1080p, or 4k");
+ }
+ if (hasInputReferenceFile) {
+ req.images = [{ __fileRef: "request_file:input_reference", encoding: "base64", maxBytes: 20971520 }];
+ }
+ return {
+ kind: "submit",
+ model: ctx.model,
+ action: hasInputReferenceFile || req.input_reference || req.image ? "image_to_video" : "text_to_video",
+ requestBody: Object.assign({}, req, { model: ctx.model }),
+ };
+ },
+ render: function (ctx, task) {
+ return legacyRenderers.openai_video(task);
+ },
+};
diff --git a/plugins/tasks/hailuo/plugin.js b/plugins/tasks/hailuo/plugin.js
new file mode 100644
index 000000000000..e54b72438cc5
--- /dev/null
+++ b/plugins/tasks/hailuo/plugin.js
@@ -0,0 +1,402 @@
+export const meta = {
+ apiVersion: 1,
+ key: "hailuo",
+ name: "Hailuo Video",
+ icon: "Hailuo.Color",
+ description: {
+ en: "MiniMax Hailuo video generation (text-to-video and image-to-video)",
+ zh: "MiniMax 海螺视频生成(文生视频、图生视频)",
+ },
+ version: "1.0.0",
+ author: { name: "QuantumNous" },
+ channelTypes: [35],
+ models: [
+ "MiniMax-Hailuo-2.3",
+ "MiniMax-Hailuo-2.3-Fast",
+ "MiniMax-Hailuo-02",
+ "T2V-01-Director",
+ "T2V-01",
+ "I2V-01-Director",
+ "I2V-01-live",
+ "I2V-01",
+ "S2V-01",
+ ],
+ fetchMode: "per_task",
+ usageSchema: {
+ seconds: {
+ type: "number",
+ unit: "second",
+ description: {
+ en: "Requested video duration in seconds. Hailuo 2.3/02/2.3-Fast allow 6 or 10; 01-series allow 6.",
+ zh: "请求的视频时长,单位为秒。Hailuo 2.3/02/2.3-Fast 允许 6 或 10;01 系列允许 6。",
+ },
+ },
+ resolution: {
+ enum: ["512P", "768P", "720P", "1080P"],
+ description: { en: "Requested output video resolution.", zh: "请求的输出视频分辨率。" },
+ },
+ },
+ usageExamples: [
+ { label: "2.3/02 768P 6s", facts: { seconds: 6, resolution: "768P" } },
+ { label: "2.3/02 768P 10s", facts: { seconds: 10, resolution: "768P" } },
+ { label: "2.3/02 1080P 6s", facts: { seconds: 6, resolution: "1080P" } },
+ { label: "02 512P 6s", facts: { seconds: 6, resolution: "512P" } },
+ { label: "02 512P 10s", facts: { seconds: 10, resolution: "512P" } },
+ { label: "01-series 720P 6s", facts: { seconds: 6, resolution: "720P" } },
+ ],
+ protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
+};
+
+function trimmed(value) {
+ return String(value || "").trim();
+}
+
+function isModernHailuo(model) {
+ return model === "MiniMax-Hailuo-2.3" || model === "MiniMax-Hailuo-2.3-Fast" || model === "MiniMax-Hailuo-02";
+}
+
+function defaultResolution(model) {
+ if (model === "MiniMax-Hailuo-2.3" || model === "MiniMax-Hailuo-2.3-Fast" || model === "MiniMax-Hailuo-02") return "768P";
+ return "720P";
+}
+
+function resolutionFor(size, model) {
+ const value = String(size || "");
+ if (value.includes("1080")) return "1080P";
+ if (value.includes("768")) return "768P";
+ if (value.includes("720")) return isModernHailuo(model) ? "768P" : "720P";
+ if (value.includes("512")) return "512P";
+ return defaultResolution(model);
+}
+
+function outboundDuration(req) {
+ const n = Number(req && req.duration);
+ if (Number.isFinite(n) && n > 0) return n;
+ return 6;
+}
+
+function outboundResolution(req, model) {
+ if (req && req.resolution) return resolutionFor(req.resolution, model);
+ const metadata = (req && req.metadata) || {};
+ if (metadata.resolution) return resolutionFor(metadata.resolution, model);
+ if (req && req.size) return resolutionFor(req.size, model);
+ return defaultResolution(model);
+}
+
+function hasHailuoImage(req, hasInputReferenceFile) {
+ if (hasInputReferenceFile) return true;
+ const metadata = (req && req.metadata) || {};
+ return Boolean(
+ trimmed(req && req.input_reference) ||
+ trimmed(req && req.image) ||
+ (Array.isArray(req && req.images) && req.images.length) ||
+ metadata.first_frame_image ||
+ metadata.last_frame_image ||
+ metadata.subject_reference
+ );
+}
+
+// Older T2V-01*/I2V-01*/S2V-01 official tables disagree on 1080P support (research: 未验证).
+// Keep those models permissive: duration 6 only, resolution optional.
+function validateHailuoCombo(model, duration, resolution, hasImage) {
+ if (model === "MiniMax-Hailuo-2.3-Fast" && !hasImage) {
+ throw new Error("MiniMax-Hailuo-2.3-Fast supports image-to-video only");
+ }
+ if (!isModernHailuo(model)) {
+ if (duration !== undefined && Number(duration) !== 6) throw new Error(model + " duration must be 6");
+ return;
+ }
+ const n = duration === undefined ? 6 : Number(duration);
+ if (n !== 6 && n !== 10) throw new Error(model + " duration must be 6 or 10");
+ if (n === 10) {
+ if (model === "MiniMax-Hailuo-02" && hasImage) {
+ if (resolution !== "768P" && resolution !== "512P") throw new Error("MiniMax-Hailuo-02 duration 10 only allows resolution 768P or 512P");
+ return;
+ }
+ if (resolution !== "768P") throw new Error(model + " duration 10 only allows resolution 768P");
+ return;
+ }
+ const allowed = model === "MiniMax-Hailuo-02" && hasImage ? ["512P", "768P", "1080P"] : ["768P", "1080P"];
+ if (allowed.indexOf(resolution) < 0) throw new Error(model + " duration 6 only allows resolution " + allowed.join(" or "));
+}
+
+function responsesInput(req) {
+ const texts = [],
+ images = [];
+ const input = req.input;
+ if (typeof input === "string") texts.push(input);
+ else if (Array.isArray(input)) {
+ for (const item of input) {
+ if (typeof item === "string") {
+ texts.push(item);
+ continue;
+ }
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
+ const content = item.content === undefined ? [item] : Array.isArray(item.content) ? item.content : [item.content];
+ for (const part of content) {
+ if (typeof part === "string") {
+ texts.push(part);
+ continue;
+ }
+ if (!part || typeof part !== "object" || Array.isArray(part)) continue;
+ if (["input_text", "text"].includes(part.type) && typeof part.text === "string") texts.push(part.text);
+ if (["input_image", "image_url"].includes(part.type)) {
+ let image = part.image_url;
+ if (image && typeof image === "object") image = image.url;
+ if (trimmed(image)) images.push(trimmed(image));
+ }
+ }
+ }
+ }
+ return {
+ prompt: texts
+ .filter(function (text) {
+ return trimmed(text);
+ })
+ .join("\n"),
+ images: images,
+ };
+}
+
+function responsesVideoText(ctx) {
+ const artifact = ctx && ctx.artifacts && ctx.artifacts.video;
+ const url = trimmed(artifact && artifact.url);
+ if (!url) throw new Error("video artifact is unavailable");
+ const escaped = url.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">");
+ return ' ';
+}
+
+export function buildSubmitRequest(ctx) {
+ const req = ctx.requestBody || {};
+ const model = ctx.upstreamModel;
+ const metadata = req.metadata || {};
+ const body = {
+ model: model,
+ prompt: req.prompt || undefined,
+ duration: outboundDuration(req),
+ resolution: outboundResolution(req, model),
+ };
+ ["prompt_optimizer", "fast_pretreatment", "callback_url", "aigc_watermark", "first_frame_image", "last_frame_image", "subject_reference"].forEach(
+ function (key) {
+ if (metadata[key] !== undefined && metadata[key] !== null) body[key] = metadata[key];
+ }
+ );
+ return {
+ url: ctx.baseUrl + "/v1/video_generation",
+ method: "POST",
+ headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: "Bearer " + ctx.apiKey },
+ body: body,
+ action: hasHailuoImage(req, false) ? "image_to_video" : "text_to_video",
+ };
+}
+
+export function parseSubmitResponse(ctx, resp) {
+ const body = resp.body || {};
+ const base = body.base_resp || {};
+ if (base.status_code !== 0) throw new Error(base.status_msg || "hailuo submit failed");
+ if (!body.task_id) throw new Error("missing task_id");
+ return { taskId: body.task_id, taskData: body };
+}
+
+export function extractUsage(ctx) {
+ if (ctx.usagePurpose === "billing_ratios") return null;
+ const req = ctx.requestBody || {};
+ const model = ctx.upstreamModel || req.model;
+ return { seconds: outboundDuration(req), resolution: outboundResolution(req, model) };
+}
+
+export function buildQueryRequest(ctx) {
+ return {
+ url: ctx.baseUrl + "/v1/query/video_generation?task_id=" + encodeURIComponent(ctx.taskId),
+ method: "GET",
+ headers: { Accept: "application/json", Authorization: "Bearer " + ctx.apiKey },
+ };
+}
+
+export function parseTaskResult(ctx, body) {
+ const base = body.base_resp || {};
+ const statuses = { Preparing: "IN_PROGRESS", Queueing: "IN_PROGRESS", Processing: "IN_PROGRESS", Success: "SUCCESS", Fail: "FAILURE" };
+ const status = statuses[body.status] || "IN_PROGRESS";
+ const progress = status === "SUCCESS" || status === "FAILURE" ? "100%" : body.status === "Processing" ? "50%" : "30%";
+ const reason = base.status_code !== 0 ? base.status_msg || "" : status === "FAILURE" ? "task failed" : "";
+ return { code: base.status_code || 0, status: status, progress: progress, reason: reason };
+}
+
+function artifactData(ctx) {
+ const data = (ctx && ctx.data) || {};
+ if (data.data && typeof data.data === "object" && data.data.task_id && Object.prototype.hasOwnProperty.call(data.data, "data")) return data.data.data || {};
+ return data;
+}
+
+function artifactFileID(ctx) {
+ return trimmed(artifactData(ctx).file_id);
+}
+
+export function listArtifacts(task) {
+ return task.status === "SUCCESS" && artifactFileID(task) ? [{ key: "video", type: "video", mimeType: "video/mp4" }] : [];
+}
+
+export function buildContentRequest(ctx) {
+ if (ctx.artifactKey !== "video") throw new Error("artifact_not_found");
+ const fileID = artifactFileID(ctx);
+ if (!fileID) throw new Error("artifact_not_found");
+ return {
+ url: ctx.baseUrl + "/v1/files/download?file_id=" + encodeURIComponent(fileID),
+ method: ctx.clientRequest.method,
+ headers: { Accept: "video/*", Authorization: "Bearer " + ctx.apiKey },
+ };
+}
+
+export function extractUsageOnComplete(_task, _taskResult, body) {
+ const width = Number((body || {}).video_width || 0);
+ const height = Number((body || {}).video_height || 0);
+ if (!(width > 0) || !(height > 0)) return null;
+ return { resolution: resolutionFor(width + "x" + height, "") };
+}
+
+export const protocols = {
+ openai_responses: {
+ decodeRequest: function (ctx) {
+ if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
+ const req = ctx.body.value;
+ if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
+ const model = trimmed(req.model);
+ if (!model) throw new Error("model is required");
+ if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
+ if (req.images !== undefined && !Array.isArray(req.images)) throw new Error("images must be an array");
+ if (req.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
+ throw new Error("metadata must be an object");
+ const input = responsesInput(req);
+ const prompt = input.prompt || trimmed(req.prompt);
+ const images = [];
+ for (const image of [req.image, req.input_reference].concat(req.images || [], input.images)) {
+ if (trimmed(image) && !images.includes(trimmed(image))) images.push(trimmed(image));
+ }
+ if (!prompt && images.length === 0) throw new Error("input is required");
+ const metadata = Object.assign({}, req.metadata || {});
+ if (images.length && !metadata.first_frame_image) metadata.first_frame_image = images[0];
+ if (images.length > 1 && !metadata.last_frame_image) metadata.last_frame_image = images[1];
+ const requestBody = { model: model, prompt: prompt, metadata: metadata };
+ if (images.length) requestBody.images = images;
+ if (Object.prototype.hasOwnProperty.call(req, "seconds")) requestBody.duration = req.seconds;
+ else if (Object.prototype.hasOwnProperty.call(req, "duration")) requestBody.duration = req.duration;
+ if (Object.prototype.hasOwnProperty.call(req, "size")) requestBody.size = req.size;
+ else if (Object.prototype.hasOwnProperty.call(req, "resolution")) requestBody.size = req.resolution;
+ return { kind: "submit", model: model, action: images.length ? "image_to_video" : "text_to_video", requestBody: requestBody };
+ },
+ renderEvents: function (ctx, task, previousState) {
+ const status = String(task.status || "UNKNOWN").toUpperCase();
+ const value = Number(String(task.progress || "").replace("%", ""));
+ const progress = Number.isFinite(value) && value >= 0 && value <= 100 ? value : null;
+ const state = { status: status, progress: progress };
+ if (status === "SUCCESS") {
+ const text = responsesVideoText(ctx);
+ const events = previousState && previousState.status === status ? [] : [{ type: "output", data: text }];
+ return { events: events, state: state, done: true };
+ }
+ if (status === "FAILURE")
+ return { events: [{ type: "error", code: "task_failed", message: task.fail_reason || "task failed" }], state: state, done: true };
+ if (previousState && previousState.status === status && previousState.progress === progress) return { events: [], state: state, done: false };
+ const event = { type: "progress", message: status.toLowerCase() };
+ if (progress !== null) event.progress = progress;
+ return { events: [event], state: state, done: false };
+ },
+ renderFinal: function (ctx, _task) {
+ return {
+ output: [
+ {
+ type: "message",
+ status: "completed",
+ role: "assistant",
+ content: [{ type: "output_text", text: responsesVideoText(ctx), annotations: [], logprobs: [] }],
+ },
+ ],
+ metadata: { vendor: "hailuo" },
+ };
+ },
+ },
+};
+
+const legacyRenderers = {
+ openai_video: function (task) {
+ const statuses = { NOT_START: "queued", SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "in_progress", SUCCESS: "completed", FAILURE: "failed" };
+ const output = {
+ id: task.task_id,
+ object: "video",
+ model: task.properties && task.properties.origin_model_name ? task.properties.origin_model_name : "",
+ status: statuses[task.status] || "unknown",
+ progress: Number(String(task.progress || "0").replace("%", "")),
+ created_at: task.created_at,
+ };
+ if (task.updated_at) output.completed_at = task.updated_at;
+ if (task.data && task.data.base_resp && task.data.base_resp.status_code !== 0) {
+ output.error = { message: task.data.base_resp.status_msg, code: String(task.data.base_resp.status_code) };
+ }
+ return output;
+ },
+};
+
+protocols.openai_video = {
+ decodeRequest: function (ctx) {
+ if (!ctx.body || (ctx.body.kind !== "json" && ctx.body.kind !== "multipart")) throw new Error("JSON or multipart body required");
+ let req;
+ let hasInputReferenceFile = false;
+ if (ctx.body.kind === "json") {
+ if (!ctx.body.value || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
+ req = Object.assign({}, ctx.body.value);
+ } else {
+ const first = function (name) {
+ const values = (ctx.body.fields || {})[name] || [];
+ if (values.length > 1) throw new Error(name + " must be provided once");
+ return values[0];
+ };
+ req = {};
+ const fields = ctx.body.fields || {};
+ for (const name of Object.keys(fields)) {
+ req[name] = first(name);
+ }
+ for (const file of ctx.body.files || []) {
+ if (file.field !== "input_reference") throw new Error("unexpected file field: " + file.field);
+ if (hasInputReferenceFile) throw new Error("input_reference must be provided once");
+ hasInputReferenceFile = true;
+ }
+ if (req.metadata !== undefined) {
+ let parsed;
+ try {
+ parsed = JSON.parse(req.metadata);
+ } catch (e) {
+ throw new Error("metadata must be a JSON object string");
+ }
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("metadata must be a JSON object string");
+ req.metadata = parsed;
+ }
+ if (req.seconds !== undefined) req.seconds = Number(req.seconds);
+ else if (req.duration !== undefined) req.seconds = Number(req.duration);
+ }
+ const seconds = req.seconds === undefined ? req.duration : req.seconds;
+ if (seconds !== undefined) req.duration = Number(seconds);
+ if (hasInputReferenceFile) {
+ req.metadata = Object.assign({}, req.metadata || {}, {
+ first_frame_image: { __fileRef: "request_file:input_reference", encoding: "dataUrl", maxBytes: 20971520 },
+ });
+ } else {
+ const image = trimmed(req.input_reference || req.image);
+ if (image) {
+ req.metadata = Object.assign({}, req.metadata || {});
+ if (!req.metadata.first_frame_image) req.metadata.first_frame_image = image;
+ }
+ }
+ const hasImage = hasHailuoImage(req, hasInputReferenceFile);
+ const duration = req.duration === undefined ? undefined : Number(req.duration);
+ validateHailuoCombo(ctx.model, duration, outboundResolution(req, ctx.model), hasImage);
+ return {
+ kind: "submit",
+ model: ctx.model,
+ action: hasImage ? "image_to_video" : "text_to_video",
+ requestBody: Object.assign({}, req, { model: ctx.model }),
+ };
+ },
+ render: function (ctx, task) {
+ return legacyRenderers.openai_video(task);
+ },
+};
diff --git a/plugins/tasks/jimeng/plugin.js b/plugins/tasks/jimeng/plugin.js
new file mode 100644
index 000000000000..5aaa9ff8741a
--- /dev/null
+++ b/plugins/tasks/jimeng/plugin.js
@@ -0,0 +1,558 @@
+export const meta = {
+ apiVersion: 1,
+ key: "jimeng",
+ name: "Jimeng",
+ icon: "Jimeng.Color",
+ description: {
+ en: "Volcengine Jimeng video generation (text-to-video, image-to-video, and first-and-last-frame)",
+ zh: "火山引擎即梦视频生成(文生视频、图生视频、首尾帧)",
+ },
+ version: "1.0.0",
+ author: { name: "QuantumNous" },
+ channelTypes: [51],
+ models: ["jimeng_vgfm_t2v_l20"],
+ fetchMode: "per_task",
+ usageSchema: {
+ seconds: {
+ type: "number",
+ unit: "second",
+ description: {
+ en: "Requested video duration in seconds. S2.0 Pro is fixed at 5; 3.0 req_keys allow 5 or 10.",
+ zh: "请求的视频时长,单位为秒。S2.0 Pro 固定为 5;3.0 req_keys 允许 5 或 10。",
+ },
+ },
+ product: {
+ enum: ["s2_pro", "v30_720p", "v30_1080p", "v30_pro"],
+ description: { en: "Product tier derived from the final outbound req_key.", zh: "由最终出站 req_key 推导出的产品档位。" },
+ },
+ },
+ usageExamples: [
+ { label: "S2.0 Pro 5s", facts: { seconds: 5, product: "s2_pro" } },
+ { label: "3.0 720P 5s", facts: { seconds: 5, product: "v30_720p" } },
+ { label: "3.0 720P 10s", facts: { seconds: 10, product: "v30_720p" } },
+ { label: "3.0 1080P 5s", facts: { seconds: 5, product: "v30_1080p" } },
+ { label: "3.0 1080P 10s", facts: { seconds: 10, product: "v30_1080p" } },
+ { label: "3.0 Pro 5s", facts: { seconds: 5, product: "v30_pro" } },
+ { label: "3.0 Pro 10s", facts: { seconds: 10, product: "v30_pro" } },
+ ],
+ protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
+ routes: [{ method: "POST", path: "/jimeng/", type: "dynamic", decode: "decodeRequest", render: "renderTask" }],
+};
+
+function trimmed(value) {
+ return String(value || "").trim();
+}
+
+function responsesInput(req) {
+ const texts = [];
+ const input = req.input;
+ if (typeof input === "string") texts.push(input);
+ else if (Array.isArray(input)) {
+ for (const item of input) {
+ if (typeof item === "string") {
+ texts.push(item);
+ continue;
+ }
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
+ const content = item.content === undefined ? [item] : Array.isArray(item.content) ? item.content : [item.content];
+ for (const part of content) {
+ if (typeof part === "string") {
+ texts.push(part);
+ continue;
+ }
+ if (!part || typeof part !== "object" || Array.isArray(part)) continue;
+ if (["input_text", "text"].includes(part.type) && typeof part.text === "string") texts.push(part.text);
+ if (["input_image", "image_url"].includes(part.type)) throw new Error("Jimeng Responses supports text input only");
+ }
+ }
+ }
+ return texts
+ .filter(function (text) {
+ return trimmed(text);
+ })
+ .join("\n");
+}
+
+function responsesVideoText(ctx) {
+ const artifact = ctx && ctx.artifacts && ctx.artifacts.video;
+ const url = trimmed(artifact && artifact.url);
+ if (!url) throw new Error("video artifact is unavailable");
+ const escaped = url.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">");
+ return ' ';
+}
+
+function isRelay(apiKey) {
+ return apiKey.startsWith("sk-");
+}
+
+function imageValues(body) {
+ const images = [];
+ if (Array.isArray(body.image_urls)) {
+ body.image_urls.forEach(function (image) {
+ images.push(image);
+ });
+ }
+ if (Array.isArray(body.binary_data_base64)) {
+ body.binary_data_base64.forEach(function (image) {
+ images.push(image);
+ });
+ }
+ if (images.length > 0) return images;
+ if (Array.isArray(body.images)) {
+ body.images.forEach(function (image) {
+ images.push(image);
+ });
+ }
+ if (images.length === 0 && typeof body.image === "string" && body.image.trim() !== "") {
+ images.push(body.image);
+ }
+ return images;
+}
+
+function actionForImageCount(imageCount) {
+ if (imageCount > 1) return "first_tail_to_video";
+ if (imageCount === 1) return "image_to_video";
+ return "text_to_video";
+}
+
+function decodeNativeRequest(ctx) {
+ if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
+ const requestBody = ctx.body.value;
+ const query = ctx.query || {};
+ const actions = query.Action || [];
+ const action = actions.length ? actions[0] : "";
+ if (!action) throw new Error("Action query parameter is required");
+
+ const body = requestBody && typeof requestBody === "object" ? requestBody : {};
+ if (action === "CVSync2AsyncGetResult") {
+ if (typeof body.task_id !== "string" || body.task_id.trim() === "") {
+ throw new Error("task_id is required for CVSync2AsyncGetResult");
+ }
+ return { kind: "query", taskIds: [body.task_id] };
+ }
+ if (action !== "CVSync2AsyncSubmitTask") {
+ throw new Error("unsupported Jimeng Action");
+ }
+
+ const images = imageValues(body);
+ return {
+ kind: "submit",
+ model: typeof body.req_key === "string" ? body.req_key : "",
+ action: actionForImageCount(images.length),
+ requestBody: {
+ model: typeof body.req_key === "string" ? body.req_key : "",
+ prompt: typeof body.prompt === "string" ? body.prompt : "",
+ images: images,
+ metadata: body,
+ },
+ };
+}
+
+function endpoint(baseUrl, apiKey, action) {
+ return baseUrl + (isRelay(apiKey) ? "/jimeng/" : "/") + "?Action=" + action + "&Version=2022-08-31";
+}
+
+function requestHeaders(ctx, method, url, bodyText) {
+ const headers = { "Content-Type": "application/json", Accept: "application/json" };
+ if (isRelay(ctx.apiKey)) {
+ headers.Authorization = "Bearer " + ctx.apiKey;
+ return headers;
+ }
+ const parts = ctx.apiKey.split("|");
+ if (parts.length !== 2) throw new Error("invalid api key format for jimeng: expected 'ak|sk'");
+ const signed = utils.volcSignV4({
+ Method: method,
+ URL: url,
+ Headers: { "Content-Type": "application/json" },
+ Body: bodyText,
+ AccessKey: parts[0].trim(),
+ SecretKey: parts[1].trim(),
+ Region: "cn-north-1",
+ Service: "cv",
+ });
+ Object.keys(signed).forEach(function (key) {
+ headers[key] = signed[key];
+ });
+ return headers;
+}
+
+const ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"];
+const ASPECT_RATIO_VALUES = [
+ ["16:9", 16 / 9],
+ ["9:16", 9 / 16],
+ ["1:1", 1],
+ ["4:3", 4 / 3],
+ ["3:4", 3 / 4],
+ ["21:9", 21 / 9],
+];
+
+function isV3ReqKey(reqKey) {
+ return String(reqKey || "").includes("v30");
+}
+
+function convertedReqKey(reqKey, imageCount) {
+ if (reqKey === "jimeng_vgfm_t2v_l20" && imageCount > 0) return "jimeng_vgfm_i2v_l20";
+ if (!reqKey.includes("jimeng_v30")) return reqKey;
+ if (reqKey === "jimeng_v30_pro") return "jimeng_ti2v_v30_pro";
+ if (imageCount > 1) return reqKey.replace("jimeng_v30", "jimeng_i2v_first_tail_v30").replace(/p$/, "");
+ if (imageCount === 1) return reqKey.replace("jimeng_v30", "jimeng_i2v_first_v30").replace(/p$/, "");
+ return reqKey.replace("jimeng_v30", "jimeng_t2v_v30");
+}
+
+function productForReqKey(reqKey) {
+ const key = String(reqKey || "");
+ if (key.includes("vgfm") && key.endsWith("_l20")) return "s2_pro";
+ if (key === "jimeng_ti2v_v30_pro" || key.includes("v30_pro")) return "v30_pro";
+ if (key.includes("1080")) return "v30_1080p";
+ if (key.includes("v30")) return "v30_720p";
+ return "s2_pro";
+}
+
+function submitImageCount(req) {
+ const metadata = (req && req.metadata) || {};
+ const binaryCount = Array.isArray(metadata.binary_data_base64) ? metadata.binary_data_base64.length : 0;
+ const urlCount = Array.isArray(metadata.image_urls) ? metadata.image_urls.length : 0;
+ if (binaryCount + urlCount > 0) return binaryCount + urlCount;
+ return Array.isArray(req && req.images) ? req.images.length : 0;
+}
+
+function submitReqKey(ctx) {
+ const req = (ctx && ctx.requestBody) || {};
+ const metadata = req.metadata || {};
+ const base = metadata.req_key || (ctx && ctx.upstreamModel) || req.model || "";
+ return convertedReqKey(String(base), submitImageCount(req)) || "jimeng_vgfm_t2v_l20";
+}
+
+function outboundSeconds(req, reqKey) {
+ if (!isV3ReqKey(reqKey)) return 5;
+ const metadata = (req && req.metadata) || {};
+ if (Number(metadata.frames) === 241) return 10;
+ if (Number(metadata.frames) === 121) return 5;
+ const seconds = Number(req && req.duration);
+ return seconds === 10 ? 10 : 5;
+}
+
+function aspectRatioFromSize(size) {
+ const raw = String(size || "").trim();
+ if (ASPECT_RATIOS.indexOf(raw) >= 0) return raw;
+ const parts = raw.toLowerCase().split("x");
+ if (parts.length !== 2) return "";
+ const width = Number(parts[0]);
+ const height = Number(parts[1]);
+ if (!(width > 0) || !(height > 0)) return "";
+ const value = width / height;
+ let best = "";
+ let bestDiff = Infinity;
+ for (let i = 0; i < ASPECT_RATIO_VALUES.length; i++) {
+ const diff = Math.abs(value - ASPECT_RATIO_VALUES[i][1]);
+ if (diff < bestDiff) {
+ bestDiff = diff;
+ best = ASPECT_RATIO_VALUES[i][0];
+ }
+ }
+ return best;
+}
+
+function outboundAspectRatio(req) {
+ const metadata = (req && req.metadata) || {};
+ if (ASPECT_RATIOS.indexOf(metadata.aspect_ratio) >= 0) return metadata.aspect_ratio;
+ if (ASPECT_RATIOS.indexOf(req && req.aspect_ratio) >= 0) return req.aspect_ratio;
+ if (req && req.size) return aspectRatioFromSize(req.size);
+ return "";
+}
+
+function filePlaceholder(image) {
+ if (!image || typeof image !== "object" || Array.isArray(image) || !image.__fileRef) return image;
+ const placeholder = { __fileRef: image.__fileRef, encoding: image.encoding };
+ if (image.mimeType) placeholder.mimeType = image.mimeType;
+ if (image.maxBytes !== undefined && image.maxBytes !== null) placeholder.maxBytes = image.maxBytes;
+ return placeholder;
+}
+
+function queryReqKey(ctx) {
+ const data = (ctx && ctx.data) || {};
+ if (typeof data.req_key === "string" && data.req_key.trim()) return data.req_key.trim();
+ const req = (ctx && ctx.requestBody) || {};
+ if (typeof req.req_key === "string" && req.req_key.trim()) return req.req_key.trim();
+ if (ctx && ctx.action === "image_to_video") return "jimeng_vgfm_i2v_l20";
+ if (ctx && ctx.action === "first_tail_to_video") return "jimeng_i2v_first_tail_v30";
+ return "jimeng_vgfm_t2v_l20";
+}
+
+function validateSecondsForReqKey(reqKey, seconds) {
+ const n = Number(seconds);
+ if (isV3ReqKey(reqKey)) {
+ if (n !== 5 && n !== 10) throw new Error("seconds must be 5 or 10");
+ return n;
+ }
+ if (n !== 5) throw new Error("seconds must be 5");
+ return n;
+}
+
+function decodeImageCount(req, hasInputReferenceFile) {
+ if (hasInputReferenceFile) return Math.max(1, Array.isArray(req.images) ? req.images.length : 0);
+ if (Array.isArray(req.images) && req.images.length) return req.images.length;
+ if (trimmed(req.input_reference) || trimmed(req.image)) return 1;
+ return submitImageCount(req);
+}
+
+export function buildSubmitRequest(ctx) {
+ const req = ctx.requestBody || {};
+ const metadata = req.metadata || {};
+ const images = req.images || [];
+ const body = {
+ req_key: ctx.upstreamModel,
+ prompt: req.prompt || undefined,
+ seed: 0,
+ };
+ const aspectRatio = outboundAspectRatio(req);
+ if (aspectRatio) body.aspect_ratio = aspectRatio;
+ if (images.length) {
+ if (String(images[0]).startsWith("http")) body.image_urls = images;
+ else body.binary_data_base64 = images.map(filePlaceholder);
+ }
+ ["req_key", "binary_data_base64", "image_urls", "prompt", "seed", "aspect_ratio", "frames"].forEach(function (key) {
+ if (metadata[key] === undefined || metadata[key] === null) return;
+ if (key === "aspect_ratio" && !String(metadata[key]).trim()) return;
+ body[key] = metadata[key];
+ });
+ if (Array.isArray(body.binary_data_base64)) body.binary_data_base64 = body.binary_data_base64.map(filePlaceholder);
+ const binaryCount = Array.isArray(body.binary_data_base64) ? body.binary_data_base64.length : 0;
+ const urlCount = Array.isArray(body.image_urls) ? body.image_urls.length : 0;
+ const metadataImageCount = binaryCount + urlCount;
+ const imageCount = metadataImageCount > 0 ? metadataImageCount : images.length;
+ body.req_key = convertedReqKey(body.req_key, imageCount);
+ if (isV3ReqKey(body.req_key) && (body.frames === undefined || body.frames === null)) {
+ body.frames = outboundSeconds(req, body.req_key) === 10 ? 241 : 121;
+ }
+ if (!isV3ReqKey(body.req_key)) delete body.frames;
+
+ const ordered = { req_key: body.req_key };
+ if (body.binary_data_base64 && body.binary_data_base64.length) ordered.binary_data_base64 = body.binary_data_base64;
+ if (body.image_urls && body.image_urls.length) ordered.image_urls = body.image_urls;
+ if (body.prompt) ordered.prompt = body.prompt;
+ ordered.seed = body.seed;
+ if (body.aspect_ratio) ordered.aspect_ratio = body.aspect_ratio;
+ if (body.frames) ordered.frames = body.frames;
+ const url = endpoint(ctx.baseUrl, ctx.apiKey, "CVSync2AsyncSubmitTask");
+ const bodyText = JSON.stringify(ordered);
+ return {
+ url: url,
+ method: "POST",
+ headers: requestHeaders(ctx, "POST", url, bodyText),
+ body: bodyText,
+ action: actionForImageCount(imageCount),
+ };
+}
+
+export function parseSubmitResponse(ctx, resp) {
+ const body = resp.body || {};
+ if (body.code !== 10000) throw new Error(body.message || "jimeng submit failed");
+ if (!body.data || !body.data.task_id) throw new Error("missing task_id");
+ return { taskId: body.data.task_id, taskData: Object.assign({}, body, { req_key: submitReqKey(ctx) }) };
+}
+
+export function extractUsage(ctx) {
+ if (ctx.usagePurpose === "billing_ratios") return null;
+ const reqKey = submitReqKey(ctx);
+ return { seconds: outboundSeconds(ctx.requestBody || {}, reqKey), product: productForReqKey(reqKey) };
+}
+
+export function buildQueryRequest(ctx) {
+ const body = JSON.stringify({ req_key: queryReqKey(ctx), task_id: ctx.taskId });
+ const url = endpoint(ctx.baseUrl, ctx.apiKey, "CVSync2AsyncGetResult");
+ return { url: url, method: "POST", headers: requestHeaders(ctx, "POST", url, body), body: body };
+}
+
+export function parseTaskResult(ctx, body) {
+ const data = body.data || {};
+ let status = "";
+ let progress = "";
+ if (body.code !== 10000) {
+ status = "FAILURE";
+ progress = "100%";
+ }
+ if (data.status === "in_queue") {
+ status = "QUEUED";
+ progress = "10%";
+ } else if (data.status === "done") {
+ status = "SUCCESS";
+ progress = "100%";
+ }
+ const result = { code: body.code === 10000 ? 0 : body.code || 0, status: status, progress: progress, reason: body.code === 10000 ? "" : body.message || "" };
+ if (data.video_url) result.url = data.video_url;
+ return result;
+}
+
+function artifactData(ctx) {
+ const data = (ctx && ctx.data) || {};
+ if (data.data && typeof data.data === "object" && data.data.task_id && Object.prototype.hasOwnProperty.call(data.data, "data")) return data.data.data || {};
+ return data;
+}
+
+export function listArtifacts(task) {
+ const url = (artifactData(task).data || {}).video_url;
+ return task.status === "SUCCESS" && String(url || "").trim() ? [{ key: "video", type: "video" }] : [];
+}
+
+export function buildContentRequest(ctx) {
+ if (ctx.artifactKey !== "video") throw new Error("artifact_not_found");
+ const url = String((artifactData(ctx).data || {}).video_url || "").trim();
+ if (!url) throw new Error("artifact_not_found");
+ return { url: url, method: ctx.clientRequest.method, credentialless: true };
+}
+
+export function extractUsageOnComplete() {
+ return null;
+}
+
+export const protocols = {
+ openai_responses: {
+ decodeRequest: function (ctx) {
+ if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
+ const req = ctx.body.value;
+ if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
+ const model = trimmed(req.model);
+ if (!model) throw new Error("model is required");
+ if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
+ if (req.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
+ throw new Error("metadata must be an object");
+ const prompt = responsesInput(req) || trimmed(req.prompt);
+ if (!prompt) throw new Error("input is required");
+ const metadata = Object.assign({}, req.metadata || {});
+ delete metadata.binary_data_base64;
+ delete metadata.image_urls;
+ delete metadata.images;
+ delete metadata.image;
+ const requestBody = { model: model, prompt: prompt, metadata: metadata };
+ if (Object.prototype.hasOwnProperty.call(req, "seconds")) requestBody.duration = req.seconds;
+ else if (Object.prototype.hasOwnProperty.call(req, "duration")) requestBody.duration = req.duration;
+ return { kind: "submit", model: model, action: "text_to_video", requestBody: requestBody };
+ },
+ renderEvents: function (ctx, task, previousState) {
+ const status = String(task.status || "UNKNOWN").toUpperCase();
+ const value = Number(String(task.progress || "").replace("%", ""));
+ const progress = Number.isFinite(value) && value >= 0 && value <= 100 ? value : null;
+ const state = { status: status, progress: progress };
+ if (status === "SUCCESS") {
+ const text = responsesVideoText(ctx);
+ const events = previousState && previousState.status === status ? [] : [{ type: "output", data: text }];
+ return { events: events, state: state, done: true };
+ }
+ if (status === "FAILURE")
+ return { events: [{ type: "error", code: "task_failed", message: task.fail_reason || "task failed" }], state: state, done: true };
+ if (previousState && previousState.status === status && previousState.progress === progress) return { events: [], state: state, done: false };
+ const event = { type: "progress", message: status.toLowerCase() };
+ if (progress !== null) event.progress = progress;
+ return { events: [event], state: state, done: false };
+ },
+ renderFinal: function (ctx, _task) {
+ return {
+ output: [
+ {
+ type: "message",
+ status: "completed",
+ role: "assistant",
+ content: [{ type: "output_text", text: responsesVideoText(ctx), annotations: [], logprobs: [] }],
+ },
+ ],
+ metadata: { vendor: "jimeng" },
+ };
+ },
+ },
+};
+
+const legacyRenderers = {
+ openai_video: function (task) {
+ const statuses = { NOT_START: "queued", SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "in_progress", SUCCESS: "completed", FAILURE: "failed" };
+ const output = {
+ id: task.task_id,
+ object: "video",
+ model: "",
+ status: statuses[task.status] || "unknown",
+ progress: Number(String(task.progress || "0").replace("%", "")),
+ created_at: task.created_at,
+ };
+ if (task.updated_at) output.completed_at = task.updated_at;
+ if (task.data && task.data.code !== 10000) output.error = { message: task.data.message || "", code: String(task.data.code || 0) };
+ return output;
+ },
+ jimeng_native: function (tasks) {
+ const task = Array.isArray(tasks) ? tasks[0] : tasks;
+ const stored = task && task.data && typeof task.data === "object" ? task.data : {};
+ const response = Object.assign({}, stored);
+ const data = stored.data && typeof stored.data === "object" ? stored.data : {};
+ response.code = stored.code === undefined ? 10000 : stored.code;
+ response.data = Object.assign({}, data, { task_id: task.task_id });
+ return response;
+ },
+};
+
+protocols.openai_video = {
+ decodeRequest: function (ctx) {
+ if (!ctx.body || (ctx.body.kind !== "json" && ctx.body.kind !== "multipart")) throw new Error("JSON or multipart body required");
+ let req;
+ let hasInputReferenceFile = false;
+ if (ctx.body.kind === "json") {
+ if (!ctx.body.value || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
+ req = Object.assign({}, ctx.body.value);
+ } else {
+ const first = function (name) {
+ const values = (ctx.body.fields || {})[name] || [];
+ if (values.length > 1) throw new Error(name + " must be provided once");
+ return values[0];
+ };
+ req = {};
+ const fields = ctx.body.fields || {};
+ for (const name of Object.keys(fields)) {
+ req[name] = first(name);
+ }
+ for (const file of ctx.body.files || []) {
+ if (file.field !== "input_reference") throw new Error("unexpected file field: " + file.field);
+ if (hasInputReferenceFile) throw new Error("input_reference must be provided once");
+ hasInputReferenceFile = true;
+ }
+ if (req.metadata !== undefined) {
+ let parsed;
+ try {
+ parsed = JSON.parse(req.metadata);
+ } catch (e) {
+ throw new Error("metadata must be a JSON object string");
+ }
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("metadata must be a JSON object string");
+ req.metadata = parsed;
+ }
+ if (req.seconds !== undefined) req.seconds = Number(req.seconds);
+ else if (req.duration !== undefined) req.seconds = Number(req.duration);
+ }
+ if (hasInputReferenceFile) {
+ req.images = [{ __fileRef: "request_file:input_reference", encoding: "base64", maxBytes: 4928307 }];
+ } else {
+ const image = trimmed(req.input_reference || req.image);
+ if (image && (!Array.isArray(req.images) || req.images.length === 0)) req.images = [image];
+ }
+ const seconds = req.seconds === undefined ? req.duration : req.seconds;
+ if (seconds !== undefined) {
+ req.duration = validateSecondsForReqKey(convertedReqKey(String(ctx.model || req.model || ""), decodeImageCount(req, hasInputReferenceFile)), seconds);
+ }
+ return {
+ kind: "submit",
+ model: ctx.model,
+ action: actionForImageCount(decodeImageCount(req, hasInputReferenceFile)),
+ requestBody: Object.assign({}, req, { model: ctx.model }),
+ };
+ },
+ render: function (ctx, task) {
+ return legacyRenderers.openai_video(task);
+ },
+};
+
+export const native = {
+ decodeRequest: decodeNativeRequest,
+ renderTask: function (ctx, tasks) {
+ return legacyRenderers.jimeng_native(tasks);
+ },
+ error: function (ctx, error) {
+ return { code: error.httpStatus, message: error.message };
+ },
+};
diff --git a/plugins/tasks/kling/plugin.js b/plugins/tasks/kling/plugin.js
new file mode 100644
index 000000000000..74a9c11d8e3b
--- /dev/null
+++ b/plugins/tasks/kling/plugin.js
@@ -0,0 +1,479 @@
+export const meta = {
+ apiVersion: 1,
+ key: "kling",
+ name: "Kling",
+ icon: "Kling.Color",
+ description: {
+ en: "Kuaishou Kling video generation (text-to-video and image-to-video)",
+ zh: "快手可灵视频生成(文生视频、图生视频)",
+ },
+ version: "1.0.0",
+ author: { name: "QuantumNous" },
+ channelTypes: [50],
+ models: ["kling-v1", "kling-v1-6", "kling-v2-master"],
+ fetchMode: "per_task",
+ usageSchema: {
+ units: {
+ type: "number",
+ unit: "credit",
+ description: {
+ en: "Kling final unit deduction (estimated at submit, actual on completion).",
+ zh: "可灵最终单位消耗(提交时预估,完成后按实际值)。",
+ },
+ },
+ },
+ usageExamples: [
+ { label: "v1 std 5s", facts: { units: 1 } },
+ { label: "v1 pro 5s", facts: { units: 3.5 } },
+ { label: "v1-6 std 5s", facts: { units: 2 } },
+ { label: "v1-6 pro 10s", facts: { units: 7 } },
+ { label: "v2-master pro 5s", facts: { units: 10 } },
+ ],
+ protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
+ routes: [
+ { method: "POST", path: "/kling/v1/videos/text2video", type: "submit", action: "text_to_video", decode: "decodeSubmit", render: "taskCreated" },
+ { method: "POST", path: "/kling/v1/videos/image2video", type: "submit", action: "image_to_video", decode: "decodeSubmit", render: "taskCreated" },
+ { method: "GET", path: "/kling/v1/videos/text2video/:task_id", type: "query", render: "taskStatus" },
+ { method: "GET", path: "/kling/v1/videos/image2video/:task_id", type: "query", render: "taskStatus" },
+ ],
+};
+
+// Official unit consumption (units per output video second), not a currency price.
+// Source: https://kling.ai/dev/pricing
+const UNITS_PER_SECOND = {
+ "kling-v1": { std: 0.2, pro: 0.7 },
+ "kling-v1-6": { std: 0.4, pro: 0.7 },
+ "kling-v2-master": { pro: 2.0 },
+};
+
+function trimmed(value) {
+ return String(value || "").trim();
+}
+
+function responsesInput(req) {
+ const texts = [],
+ images = [];
+ const input = req.input;
+ if (typeof input === "string") texts.push(input);
+ else if (Array.isArray(input)) {
+ for (const item of input) {
+ if (typeof item === "string") {
+ texts.push(item);
+ continue;
+ }
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
+ const content = item.content === undefined ? [item] : Array.isArray(item.content) ? item.content : [item.content];
+ for (const part of content) {
+ if (typeof part === "string") {
+ texts.push(part);
+ continue;
+ }
+ if (!part || typeof part !== "object" || Array.isArray(part)) continue;
+ if (["input_text", "text"].includes(part.type) && typeof part.text === "string") texts.push(part.text);
+ if (["input_image", "image_url"].includes(part.type)) {
+ let image = part.image_url;
+ if (image && typeof image === "object") image = image.url;
+ if (trimmed(image)) images.push(trimmed(image));
+ }
+ }
+ }
+ }
+ return {
+ prompt: texts
+ .filter(function (text) {
+ return trimmed(text);
+ })
+ .join("\n"),
+ images: images,
+ };
+}
+
+function responsesVideoText(ctx) {
+ const artifact = ctx && ctx.artifacts && ctx.artifacts.video;
+ const url = trimmed(artifact && artifact.url);
+ if (!url) throw new Error("video artifact is unavailable");
+ const escaped = url.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">");
+ return ' ';
+}
+
+function isRelay(apiKey) {
+ return apiKey.startsWith("sk-");
+}
+
+function tokenFor(apiKey) {
+ if (isRelay(apiKey)) return apiKey;
+ const parts = apiKey.split("|");
+ if (parts.length !== 2) throw new Error("invalid api_key, required format is accessKey|secretKey");
+ const now = utils.unixNow();
+ return utils.jwtSignHS256({ iss: parts[0].trim(), exp: now + 1800, nbf: now - 5 }, parts[1].trim());
+}
+
+function pathFor(action) {
+ return action === "image_to_video" ? "/v1/videos/image2video" : "/v1/videos/text2video";
+}
+
+function urlFor(baseUrl, apiKey, action) {
+ return baseUrl + (isRelay(apiKey) ? "/kling" : "") + pathFor(action);
+}
+
+function aspectRatio(size) {
+ const ratios = { "1024x1024": "1:1", "512x512": "1:1", "1280x720": "16:9", "1920x1080": "16:9", "720x1280": "9:16", "1080x1920": "9:16" };
+ return ratios[size] || "1:1";
+}
+
+function submitModel(ctx, req) {
+ return (ctx && ctx.upstreamModel) || (ctx && ctx.model) || (req && req.model) || "kling-v1";
+}
+
+function resolveKlingMode(model, mode) {
+ const raw = trimmed(mode).toLowerCase();
+ if (model === "kling-v2-master") {
+ if (raw === "std") throw new Error("kling-v2-master does not support mode std");
+ if (raw && raw !== "pro") throw new Error("mode must be pro");
+ return "pro";
+ }
+ if (!raw) return "std";
+ if (raw !== "std" && raw !== "pro") throw new Error("mode must be std or pro");
+ return raw;
+}
+
+function perSecondRate(model, mode) {
+ const table = UNITS_PER_SECOND[model] || UNITS_PER_SECOND["kling-v1"];
+ if (table[mode] !== undefined) return table[mode];
+ if (table.pro !== undefined) return table.pro;
+ return table.std;
+}
+
+function estimateUnits(model, mode, durationSeconds) {
+ return perSecondRate(model, mode) * durationSeconds;
+}
+
+// Official current pages list 3–15s for new models; old-model duration "5"|"10"
+// is unverifiable (research 2026-08-27). Keep permissive positive integers up to
+// the host task duration bound.
+function validateKlingDuration(value) {
+ const n = Number(value);
+ if (!Number.isInteger(n) || n <= 0 || n > 3600) throw new Error("seconds must be a positive integer at most 3600");
+ return n;
+}
+
+function outboundDuration(req) {
+ const n = Number(req && req.duration);
+ if (Number.isFinite(n) && n > 0) return n;
+ const metadata = (req && req.metadata) || {};
+ const fromMeta = Number(metadata.duration);
+ if (Number.isFinite(fromMeta) && fromMeta > 0) return fromMeta;
+ return 5;
+}
+
+function outboundMode(req, model) {
+ const metadata = (req && req.metadata) || {};
+ return resolveKlingMode(model, (req && req.mode) || metadata.mode);
+}
+
+function hasKlingImage(req, hasInputReferenceFile) {
+ if (hasInputReferenceFile) return true;
+ const metadata = (req && req.metadata) || {};
+ if (req && req.image && typeof req.image === "object" && !Array.isArray(req.image) && req.image.__fileRef) return true;
+ return Boolean(trimmed(req && req.input_reference) || trimmed(req && req.image) || metadata.image || metadata.image_tail);
+}
+
+function filePlaceholder(image) {
+ if (!image || typeof image !== "object" || Array.isArray(image) || !image.__fileRef) return image;
+ const placeholder = { __fileRef: image.__fileRef, encoding: image.encoding };
+ if (image.mimeType) placeholder.mimeType = image.mimeType;
+ if (image.maxBytes !== undefined && image.maxBytes !== null) placeholder.maxBytes = image.maxBytes;
+ return placeholder;
+}
+
+function decodeNativeSubmit(ctx) {
+ if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
+ const body = ctx.body.value;
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("request body must be an object");
+ let model = typeof body.model_name === "string" ? body.model_name : "";
+ if (model === "") model = typeof body.model === "string" ? body.model : "";
+ if (!model.trim()) throw new Error("model is required");
+ return {
+ kind: "submit",
+ model: model,
+ requestBody: {
+ model: model,
+ prompt: typeof body.prompt === "string" ? body.prompt : "",
+ metadata: body,
+ },
+ };
+}
+
+export const native = {
+ decodeSubmit: decodeNativeSubmit,
+ taskCreated: function (ctx, task) {
+ const result = task.data || {},
+ data = result.data || {};
+ return Object.assign({}, result, { data: Object.assign({}, data, { task_id: task.task_id }) });
+ },
+ taskStatus: function (ctx, task) {
+ if (task.data && typeof task.data === "object" && !Array.isArray(task.data)) {
+ const result = task.data,
+ data = result.data && typeof result.data === "object" ? result.data : {};
+ return Object.assign({}, result, { data: Object.assign({}, data, { task_id: task.task_id }) });
+ }
+ const statusMap = { NOT_START: "submitted", SUBMITTED: "submitted", QUEUED: "submitted", IN_PROGRESS: "processing", SUCCESS: "succeed", FAILURE: "failed" };
+ return { code: 0, data: { task_id: task.task_id, task_status: statusMap[task.status] || "submitted", task_status_msg: task.fail_reason || "" } };
+ },
+ error: function (ctx, error) {
+ return { code: error.code, message: error.message };
+ },
+};
+
+export function buildSubmitRequest(ctx) {
+ const req = ctx.requestBody;
+ const metadata = req.metadata || {};
+ const inferredAction = req.image || metadata.image || metadata.image_tail ? "image_to_video" : "text_to_video";
+ const action = ctx.action === "text_to_video" || ctx.action === "image_to_video" ? ctx.action : inferredAction;
+ const model = ctx.upstreamModel || "kling-v1";
+ const body = Object.assign(
+ {
+ prompt: req.prompt,
+ image: req.image,
+ mode: outboundMode(req, model),
+ duration: String(req.duration || 5),
+ aspect_ratio: aspectRatio(req.size),
+ model_name: model,
+ model: model,
+ cfg_scale: 0.5,
+ },
+ metadata
+ );
+ body.mode = outboundMode({ mode: body.mode, metadata: metadata }, model);
+ if (body.image) body.image = filePlaceholder(body.image);
+ if (body.image_tail) body.image_tail = filePlaceholder(body.image_tail);
+ if (!body.prompt) delete body.prompt;
+ if (!body.image) delete body.image;
+ return {
+ url: urlFor(ctx.baseUrl, ctx.apiKey, action),
+ method: "POST",
+ headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: "Bearer " + tokenFor(ctx.apiKey), "User-Agent": "kling-sdk/1.0" },
+ body: body,
+ action: action,
+ };
+}
+
+export function parseSubmitResponse(ctx, resp) {
+ const result = resp.body || {};
+ if (result.code !== 0) throw new Error(result.message || "kling submit failed");
+ if (!result.data || !result.data.task_id) throw new Error("missing task_id");
+ return { taskId: result.data.task_id, taskData: result };
+}
+
+export function extractUsage(ctx) {
+ if (ctx.usagePurpose === "billing_ratios") return null;
+ const req = ctx.requestBody || {};
+ const model = submitModel(ctx, req);
+ const duration = outboundDuration(req);
+ const mode = outboundMode(req, model);
+ return { units: estimateUnits(model, mode, duration) };
+}
+
+export function buildQueryRequest(ctx) {
+ return {
+ url: urlFor(ctx.baseUrl, ctx.apiKey, ctx.action) + "/" + ctx.taskId,
+ method: "GET",
+ headers: { Accept: "application/json", Authorization: "Bearer " + tokenFor(ctx.apiKey), "User-Agent": "kling-sdk/1.0" },
+ };
+}
+
+export function parseTaskResult(ctx, body) {
+ const data = body.data || {};
+ const statuses = { submitted: "SUBMITTED", processing: "IN_PROGRESS", succeed: "SUCCESS", failed: "FAILURE" };
+ const status = statuses[data.task_status];
+ if (!status) throw new Error("unknown task status: " + data.task_status);
+ const videos = status === "SUCCESS" && data.task_result && data.task_result.videos ? data.task_result.videos : [];
+ const result = { code: body.code || 0, taskId: data.task_id, status: status, reason: data.task_status_msg || "" };
+ if (videos.length && videos[0].url) result.url = videos[0].url;
+ const units = Number.parseFloat(data.final_unit_deduction || "");
+ if (Number.isFinite(units) && units > 0) {
+ result.completionTokens = Math.ceil(units);
+ result.totalTokens = Math.ceil(units);
+ }
+ return result;
+}
+
+function artifactData(ctx) {
+ const data = (ctx && ctx.data) || {};
+ if (data.data && typeof data.data === "object" && data.data.task_id && Object.prototype.hasOwnProperty.call(data.data, "data")) return data.data.data || {};
+ return data;
+}
+
+function artifactVideoURL(ctx) {
+ const result = (artifactData(ctx).data || {}).task_result || {};
+ const videos = Array.isArray(result.videos) ? result.videos : [];
+ return videos.length ? String(videos[0].url || "").trim() : "";
+}
+
+export function listArtifacts(task) {
+ return task.status === "SUCCESS" && artifactVideoURL(task) ? [{ key: "video", type: "video" }] : [];
+}
+
+export function buildContentRequest(ctx) {
+ if (ctx.artifactKey !== "video") throw new Error("artifact_not_found");
+ const url = artifactVideoURL(ctx);
+ if (!url) throw new Error("artifact_not_found");
+ return { url: url, method: ctx.clientRequest.method, credentialless: true };
+}
+
+export function extractUsageOnComplete(_task, _taskResult, body) {
+ const data = (body && body.data) || {};
+ if (
+ !Object.prototype.hasOwnProperty.call(data, "final_unit_deduction") ||
+ data.final_unit_deduction === undefined ||
+ data.final_unit_deduction === null ||
+ data.final_unit_deduction === ""
+ ) {
+ return null;
+ }
+ const units = Number.parseFloat(data.final_unit_deduction);
+ if (!Number.isFinite(units)) return null;
+ return { units: units };
+}
+
+export const protocols = {
+ openai_responses: {
+ decodeRequest: function (ctx) {
+ if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
+ const req = ctx.body.value;
+ if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
+ const model = trimmed(ctx.model);
+ if (!model) throw new Error("model is required");
+ if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
+ if (req.images !== undefined && !Array.isArray(req.images)) throw new Error("images must be an array");
+ if (req.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
+ throw new Error("metadata must be an object");
+ const input = responsesInput(req);
+ const prompt = input.prompt || trimmed(req.prompt);
+ const images = [];
+ for (const image of [req.image, req.input_reference].concat(req.images || [], input.images)) {
+ if (trimmed(image) && !images.includes(trimmed(image))) images.push(trimmed(image));
+ }
+ if (!prompt && images.length === 0) throw new Error("input is required");
+ const metadata = Object.assign({}, req.metadata || {});
+ if (Object.prototype.hasOwnProperty.call(req, "mode")) metadata.mode = req.mode;
+ metadata.mode = resolveKlingMode(model, metadata.mode);
+ if (images.length > 1 && !metadata.image_tail) metadata.image_tail = images[1];
+ const requestBody = { model: model, prompt: prompt, metadata: metadata };
+ if (images.length) requestBody.image = images[0];
+ if (Object.prototype.hasOwnProperty.call(req, "seconds")) requestBody.duration = req.seconds;
+ else if (Object.prototype.hasOwnProperty.call(req, "duration")) requestBody.duration = req.duration;
+ if (Object.prototype.hasOwnProperty.call(req, "size")) requestBody.size = req.size;
+ return { kind: "submit", model: model, action: images.length ? "image_to_video" : "text_to_video", requestBody: requestBody };
+ },
+ renderEvents: function (ctx, task, previousState) {
+ const status = String(task.status || "UNKNOWN").toUpperCase();
+ const value = Number(String(task.progress || "").replace("%", ""));
+ const progress = Number.isFinite(value) && value >= 0 && value <= 100 ? value : null;
+ const state = { status: status, progress: progress };
+ if (status === "SUCCESS") {
+ const text = responsesVideoText(ctx);
+ const events = previousState && previousState.status === status ? [] : [{ type: "output", data: text }];
+ return { events: events, state: state, done: true };
+ }
+ if (status === "FAILURE")
+ return { events: [{ type: "error", code: "task_failed", message: task.fail_reason || "task failed" }], state: state, done: true };
+ if (previousState && previousState.status === status && previousState.progress === progress) return { events: [], state: state, done: false };
+ const event = { type: "progress", message: status.toLowerCase() };
+ if (progress !== null) event.progress = progress;
+ return { events: [event], state: state, done: false };
+ },
+ renderFinal: function (ctx, _task) {
+ return {
+ output: [
+ {
+ type: "message",
+ status: "completed",
+ role: "assistant",
+ content: [{ type: "output_text", text: responsesVideoText(ctx), annotations: [], logprobs: [] }],
+ },
+ ],
+ metadata: { vendor: "kling" },
+ };
+ },
+ },
+ openai_video: {
+ decodeRequest: function (ctx) {
+ if (!ctx.body || (ctx.body.kind !== "json" && ctx.body.kind !== "multipart")) throw new Error("JSON or multipart body required");
+ let req;
+ let hasInputReferenceFile = false;
+ if (ctx.body.kind === "json") {
+ if (!ctx.body.value || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
+ req = Object.assign({}, ctx.body.value);
+ } else {
+ const first = function (name) {
+ const values = (ctx.body.fields || {})[name] || [];
+ if (values.length > 1) throw new Error(name + " must be provided once");
+ return values[0];
+ };
+ req = {};
+ const fields = ctx.body.fields || {};
+ for (const name of Object.keys(fields)) {
+ req[name] = first(name);
+ }
+ for (const file of ctx.body.files || []) {
+ if (file.field !== "input_reference") throw new Error("unexpected file field: " + file.field);
+ if (hasInputReferenceFile) throw new Error("input_reference must be provided once");
+ hasInputReferenceFile = true;
+ }
+ if (req.metadata !== undefined) {
+ let parsed;
+ try {
+ parsed = JSON.parse(req.metadata);
+ } catch (e) {
+ throw new Error("metadata must be a JSON object string");
+ }
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("metadata must be a JSON object string");
+ req.metadata = parsed;
+ }
+ if (req.seconds !== undefined) req.seconds = Number(req.seconds);
+ else if (req.duration !== undefined) req.seconds = Number(req.duration);
+ }
+ const seconds = req.seconds === undefined ? req.duration : req.seconds;
+ if (seconds !== undefined) req.duration = validateKlingDuration(seconds);
+ else req.duration = 5;
+ if (hasInputReferenceFile) {
+ req.image = { __fileRef: "request_file:input_reference", encoding: "base64", maxBytes: 10485760 };
+ } else {
+ const image = trimmed(req.input_reference || req.image);
+ if (image) req.image = image;
+ }
+ const model = ctx.model || req.model || "kling-v1";
+ const metadata = req.metadata || {};
+ req.mode = resolveKlingMode(model, req.mode || metadata.mode);
+ const hasImage = hasKlingImage(req, hasInputReferenceFile);
+ return {
+ kind: "submit",
+ model: ctx.model,
+ action: hasImage ? "image_to_video" : "text_to_video",
+ requestBody: Object.assign({}, req, { model: ctx.model }),
+ };
+ },
+ render: function (ctx, task) {
+ const response = task.data || {};
+ const data = response.data || {};
+ const statusMap = { NOT_START: "queued", SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "in_progress", SUCCESS: "completed", FAILURE: "failed" };
+ const output = {
+ id: task.task_id,
+ object: "video",
+ model: "",
+ status: statusMap[task.status] || "unknown",
+ progress: Number(String(task.progress || "0").replace("%", "")),
+ created_at: data.created_at || 0,
+ };
+ if (data.updated_at) output.completed_at = data.updated_at;
+ const videos = data.task_result && data.task_result.videos ? data.task_result.videos : [];
+ if (videos.length) {
+ if (videos[0].duration) output.seconds = videos[0].duration;
+ }
+ if (response.code !== 0 && response.message) output.error = { message: response.message, code: String(response.code) };
+ if (data.task_status === "failed") output.error = { message: data.task_status_msg, code: "" };
+ return output;
+ },
+ },
+};
diff --git a/plugins/tasks/sora/plugin.js b/plugins/tasks/sora/plugin.js
new file mode 100644
index 000000000000..3d4fae3eed1d
--- /dev/null
+++ b/plugins/tasks/sora/plugin.js
@@ -0,0 +1,304 @@
+export const meta = {
+ apiVersion: 1,
+ key: "sora",
+ name: "Sora",
+ icon: "Sora.Color",
+ description: {
+ en: "OpenAI Sora video generation (text-to-video, image-to-video, and remix)",
+ zh: "OpenAI Sora 视频生成(文生视频、图生视频、remix)",
+ },
+ version: "1.0.0",
+ channelTypes: [55, 1], // OpenAI-type channels natively serve sora with the same wire format
+ author: { name: "QuantumNous" },
+ models: ["sora-2", "sora-2-pro"],
+ fetchMode: "per_task",
+ usageSchema: {
+ seconds: {
+ type: "number",
+ unit: "second",
+ description: { en: "Requested video duration in seconds.", zh: "请求的视频时长,单位为秒。" },
+ },
+ size: {
+ enum: ["720x1280", "1280x720", "1792x1024", "1024x1792"],
+ description: { en: "Requested output video dimensions.", zh: "请求的输出视频尺寸。" },
+ },
+ },
+ protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
+};
+
+function trimmed(value) {
+ return String(value || "").trim();
+}
+
+function responsesInput(req) {
+ const texts = [],
+ images = [];
+ const input = req.input;
+ if (typeof input === "string") texts.push(input);
+ else if (Array.isArray(input)) {
+ for (const item of input) {
+ if (typeof item === "string") {
+ texts.push(item);
+ continue;
+ }
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
+ const content = item.content === undefined ? [item] : Array.isArray(item.content) ? item.content : [item.content];
+ for (const part of content) {
+ if (typeof part === "string") {
+ texts.push(part);
+ continue;
+ }
+ if (!part || typeof part !== "object" || Array.isArray(part)) continue;
+ if (["input_text", "text"].includes(part.type) && typeof part.text === "string") texts.push(part.text);
+ if (["input_image", "image_url"].includes(part.type)) {
+ let image = part.image_url;
+ if (image && typeof image === "object") image = image.url;
+ if (trimmed(image)) images.push(trimmed(image));
+ }
+ }
+ }
+ }
+ return {
+ prompt: texts
+ .filter(function (text) {
+ return trimmed(text);
+ })
+ .join("\n"),
+ images: images,
+ };
+}
+
+function responsesVideoText(ctx) {
+ const artifact = ctx && ctx.artifacts && ctx.artifacts.video;
+ const url = trimmed(artifact && artifact.url);
+ if (!url) throw new Error("video artifact is unavailable");
+ const escaped = url.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">");
+ return ' ';
+}
+
+function requestValues(req, model) {
+ const values = Object.assign({}, req || {});
+ values.model = model;
+ return values;
+}
+
+export function buildSubmitRequest(ctx) {
+ const req = ctx.requestBody || {};
+ if (!String(req.prompt || "").trim()) throw new Error("field prompt is required");
+ const action = ctx.action === "remix" ? "remix" : ctx.action;
+ const headers = { Authorization: "Bearer " + ctx.apiKey };
+ if (action === "remix") {
+ headers["Content-Type"] = "application/json";
+ return { url: ctx.baseUrl + "/v1/videos/" + ctx.originTaskId + "/remix", method: "POST", headers, body: requestValues(req, ctx.upstreamModel), action };
+ }
+ if ((ctx.files || []).length) {
+ const parts = [];
+ const values = requestValues(req, ctx.upstreamModel);
+ for (const key of Object.keys(values)) {
+ if (values[key] !== undefined && values[key] !== null && typeof values[key] !== "object") parts.push({ name: key, value: values[key] });
+ }
+ if (values.metadata && typeof values.metadata === "object" && !Array.isArray(values.metadata)) {
+ parts.push({ name: "metadata", value: JSON.stringify(values.metadata) });
+ }
+ for (const file of ctx.files) parts.push({ name: file.field, fileRef: file.ref, filename: file.filename });
+ return { url: ctx.baseUrl + "/v1/videos", method: "POST", headers, bodyType: "multipart", parts };
+ }
+ headers["Content-Type"] = "application/json";
+ return { url: ctx.baseUrl + "/v1/videos", method: "POST", headers, body: requestValues(req, ctx.upstreamModel) };
+}
+
+export function parseSubmitResponse(ctx, resp) {
+ const body = resp.body || {};
+ const taskId = body.id || body.task_id;
+ if (!taskId) throw new Error("task_id is empty");
+ return { taskId, taskData: body };
+}
+
+export function extractUsage(ctx) {
+ if (ctx.action === "remix") return {};
+ const req = ctx.requestBody || {};
+ let seconds = Number(req.seconds || req.duration || 4);
+ if (!Number.isFinite(seconds) || seconds <= 0) seconds = 4;
+ return { seconds: Math.min(seconds, 3600), size: req.size || "720x1280" };
+}
+
+export function extractUsageOnComplete(task, taskResult, body) {
+ const facts = {};
+ const seconds = Number((body || {}).seconds || (body || {}).duration || 0);
+ if (Number.isFinite(seconds) && seconds > 0) facts.seconds = Math.min(seconds, 3600);
+ const size = trimmed((body || {}).size);
+ if (["720x1280", "1280x720", "1792x1024", "1024x1792"].includes(size)) facts.size = size;
+ return facts;
+}
+
+export function buildQueryRequest(ctx) {
+ return { url: ctx.baseUrl + "/v1/videos/" + ctx.taskId, method: "GET", headers: { Authorization: "Bearer " + ctx.apiKey } };
+}
+
+export function parseTaskResult(ctx, body) {
+ const statuses = {
+ queued: "QUEUED",
+ pending: "QUEUED",
+ processing: "IN_PROGRESS",
+ in_progress: "IN_PROGRESS",
+ completed: "SUCCESS",
+ failed: "FAILURE",
+ cancelled: "FAILURE",
+ };
+ const result = { status: statuses[body.status] || "UNKNOWN" };
+ if (body.progress > 0 && body.progress < 100) result.progress = body.progress + "%";
+ if (result.status === "FAILURE") result.reason = body.error && body.error.message ? body.error.message : "task failed";
+ return result;
+}
+
+export function listArtifacts(task) {
+ return task.status === "SUCCESS" ? [{ key: "video", type: "video" }] : [];
+}
+
+export function buildContentRequest(ctx) {
+ if (ctx.artifactKey !== "video") throw new Error("artifact_not_found");
+ return {
+ url: ctx.baseUrl + "/v1/videos/" + encodeURIComponent(ctx.upstreamTaskId) + "/content",
+ method: ctx.clientRequest.method,
+ headers: { Authorization: "Bearer " + ctx.apiKey },
+ };
+}
+
+export const protocols = {
+ openai_responses: {
+ decodeRequest: function (ctx) {
+ if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
+ const req = ctx.body.value;
+ if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
+ const model = trimmed(req.model);
+ if (!model) throw new Error("model is required");
+ if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
+ if (req.images !== undefined && !Array.isArray(req.images)) throw new Error("images must be an array");
+ if (req.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
+ throw new Error("metadata must be an object");
+ const input = responsesInput(req);
+ const prompt = input.prompt || trimmed(req.prompt);
+ if (!prompt) throw new Error("input is required");
+ const images = [];
+ for (const image of [req.image, req.input_reference].concat(req.images || [], input.images)) {
+ if (trimmed(image) && !images.includes(trimmed(image))) images.push(trimmed(image));
+ }
+ const requestBody = { model: model, prompt: prompt };
+ if (images.length) requestBody.input_reference = images[0];
+ if (Object.prototype.hasOwnProperty.call(req, "seconds")) requestBody.seconds = req.seconds;
+ else if (Object.prototype.hasOwnProperty.call(req, "duration")) requestBody.seconds = req.duration;
+ if (Object.prototype.hasOwnProperty.call(req, "size")) requestBody.size = req.size;
+ if (Object.prototype.hasOwnProperty.call(req, "metadata")) requestBody.metadata = req.metadata;
+ return { kind: "submit", model: model, action: images.length ? "image_to_video" : "text_to_video", requestBody: requestBody };
+ },
+ renderEvents: function (ctx, task, previousState) {
+ const status = String(task.status || "UNKNOWN").toUpperCase();
+ const value = Number(String(task.progress || "").replace("%", ""));
+ const progress = Number.isFinite(value) && value >= 0 && value <= 100 ? value : null;
+ const state = { status: status, progress: progress };
+ if (status === "SUCCESS") {
+ const text = responsesVideoText(ctx);
+ const events = previousState && previousState.status === status ? [] : [{ type: "output", data: text }];
+ return { events: events, state: state, done: true };
+ }
+ if (status === "FAILURE")
+ return { events: [{ type: "error", code: "task_failed", message: task.fail_reason || "task failed" }], state: state, done: true };
+ if (previousState && previousState.status === status && previousState.progress === progress) return { events: [], state: state, done: false };
+ const event = { type: "progress", message: status.toLowerCase() };
+ if (progress !== null) event.progress = progress;
+ return { events: [event], state: state, done: false };
+ },
+ renderFinal: function (ctx, _task) {
+ return {
+ output: [
+ {
+ type: "message",
+ status: "completed",
+ role: "assistant",
+ content: [{ type: "output_text", text: responsesVideoText(ctx), annotations: [], logprobs: [] }],
+ },
+ ],
+ metadata: { vendor: "sora" },
+ };
+ },
+ },
+};
+
+const legacyRenderers = {
+ openai_video: function (task) {
+ const statuses = { NOT_START: "queued", SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "in_progress", SUCCESS: "completed", FAILURE: "failed" };
+ const output = {
+ id: task.task_id,
+ object: "video",
+ model: (task.properties || {}).origin_model_name || "",
+ status: statuses[task.status] || "unknown",
+ progress: Number(String(task.progress || "0").replace("%", "")),
+ created_at: Number(task.created_at || 0),
+ };
+ const completedAt = Number(task.finished_at || task.updated_at || 0);
+ if (completedAt > 0) output.completed_at = completedAt;
+ if (task.status === "FAILURE") {
+ output.error = { code: "video_generation_failed", message: "The video generation task failed." };
+ }
+ return output;
+ },
+};
+
+protocols.openai_video = {
+ decodeRequest: function (ctx) {
+ if (!ctx.body || (ctx.body.kind !== "json" && ctx.body.kind !== "multipart")) throw new Error("JSON or multipart body required");
+ if (ctx.body.kind === "json") {
+ if (!ctx.body.value || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
+ const req = ctx.body.value;
+ const seconds = req.seconds === undefined ? req.duration : req.seconds;
+ if (seconds !== undefined && (!Number.isFinite(Number(seconds)) || Number(seconds) <= 0 || Number(seconds) > 3600))
+ throw new Error("seconds must be between 1 and 3600");
+ return {
+ kind: "submit",
+ model: ctx.model,
+ action: req.input_reference || req.image ? "image_to_video" : "text_to_video",
+ requestBody: Object.assign({}, req, { model: ctx.model }),
+ };
+ }
+ const first = function (name) {
+ const values = (ctx.body.fields || {})[name] || [];
+ if (values.length > 1) throw new Error(name + " must be provided once");
+ return values[0];
+ };
+ const req = {};
+ const fields = ctx.body.fields || {};
+ for (const name of Object.keys(fields)) {
+ req[name] = first(name);
+ }
+ let hasInputReferenceFile = false;
+ for (const file of ctx.body.files || []) {
+ if (file.field !== "input_reference") throw new Error("unexpected file field: " + file.field);
+ if (hasInputReferenceFile) throw new Error("input_reference must be provided once");
+ hasInputReferenceFile = true;
+ }
+ if (req.metadata !== undefined) {
+ let parsed;
+ try {
+ parsed = JSON.parse(req.metadata);
+ } catch (e) {
+ throw new Error("metadata must be a JSON object string");
+ }
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("metadata must be a JSON object string");
+ req.metadata = parsed;
+ }
+ if (req.seconds !== undefined) req.seconds = Number(req.seconds);
+ else if (req.duration !== undefined) req.seconds = Number(req.duration);
+ const seconds = req.seconds === undefined ? req.duration : req.seconds;
+ if (seconds !== undefined && (!Number.isFinite(Number(seconds)) || Number(seconds) <= 0 || Number(seconds) > 3600))
+ throw new Error("seconds must be between 1 and 3600");
+ return {
+ kind: "submit",
+ model: ctx.model,
+ action: hasInputReferenceFile || req.input_reference || req.image ? "image_to_video" : "text_to_video",
+ requestBody: Object.assign({}, req, { model: ctx.model }),
+ };
+ },
+ render: function (ctx, task) {
+ return legacyRenderers.openai_video(task);
+ },
+};
diff --git a/plugins/tasks/sunoapi/plugin.js b/plugins/tasks/sunoapi/plugin.js
new file mode 100644
index 000000000000..240009d5e712
--- /dev/null
+++ b/plugins/tasks/sunoapi/plugin.js
@@ -0,0 +1,336 @@
+// Unofficial plugin
+// https://github.com/Suno-API/Suno-API
+export const meta = {
+ apiVersion: 1,
+ key: "sunoapi",
+ name: "SunoAPI",
+ icon: "text",
+ description: {
+ en: "SunoAPI project music and lyrics generation",
+ zh: "SunoAPI 项目 音乐与歌词生成",
+ },
+ version: "1.0.0",
+ author: { name: "QuantumNous" },
+ channelTypes: [36],
+ models: ["suno_music", "suno_lyrics"],
+ fetchMode: "batch",
+ usageSchema: {
+ clips: {
+ type: "number",
+ unit: "count",
+ description: { en: "Number of generated music or lyrics clips.", zh: "生成的音乐或歌词片段数量。" },
+ },
+ action: { enum: ["music", "lyrics"], description: { en: "Suno generation action.", zh: "Suno 生成动作。" } },
+ },
+ protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }],
+ routes: [
+ { method: "POST", path: "/suno/submit/:action", type: "submit", decode: "decodeSubmit", render: "renderSubmit" },
+ { method: "POST", path: "/suno/fetch", type: "dynamic", decode: "decodeBatch", render: "renderTasks" },
+ { method: "GET", path: "/suno/fetch/:task_id", type: "query", render: "renderTask" },
+ ],
+};
+
+function trimmed(value) {
+ return String(value || "").trim();
+}
+
+function responsesText(req) {
+ const texts = [];
+ const input = req.input;
+ if (typeof input === "string") texts.push(input);
+ else if (Array.isArray(input)) {
+ for (const item of input) {
+ if (typeof item === "string") {
+ texts.push(item);
+ continue;
+ }
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
+ const content = item.content === undefined ? [item] : Array.isArray(item.content) ? item.content : [item.content];
+ for (const part of content) {
+ if (typeof part === "string") {
+ texts.push(part);
+ continue;
+ }
+ if (!part || typeof part !== "object" || Array.isArray(part)) continue;
+ if (["input_text", "text"].includes(part.type) && typeof part.text === "string") texts.push(part.text);
+ }
+ }
+ }
+ return texts
+ .filter(function (text) {
+ return trimmed(text);
+ })
+ .join("\n");
+}
+
+function actionName(ctx) {
+ return String((ctx.params || {}).action || ctx.action || "").toUpperCase();
+}
+
+function decodeNativeSubmit(ctx) {
+ if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
+ const action = actionName(ctx);
+ if (action !== "MUSIC" && action !== "LYRICS") throw new Error("invalid_action");
+ return {
+ kind: "submit",
+ model: action === "MUSIC" ? "suno_music" : "suno_lyrics",
+ action: action,
+ requestBody: ctx.body.value,
+ };
+}
+
+function decodeNativeBatch(ctx) {
+ if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
+ const body = ctx.body.value || {};
+ return { kind: "query", taskIds: Array.isArray(body.ids) ? body.ids : [] };
+}
+
+function validateAndNormalize(ctx) {
+ const body = Object.assign({}, ctx.requestBody || {});
+ if (body.make_instrumental === undefined) body.make_instrumental = false;
+ const action = actionName(ctx);
+ if (action === "MUSIC") {
+ if (!body.mv) body.mv = "chirp-v3-0";
+ } else if (action === "LYRICS") {
+ if (!body.prompt) throw new Error("prompt_empty");
+ } else {
+ throw new Error("invalid_action");
+ }
+ return { action: action, body: body };
+}
+
+export function buildSubmitRequest(ctx) {
+ const normalized = validateAndNormalize(ctx);
+ const incoming = ctx.requestHeaders || {};
+ return {
+ url: ctx.baseUrl + "/suno/submit/" + normalized.action,
+ method: "POST",
+ headers: {
+ "Content-Type": incoming["Content-Type"] || "",
+ Accept: incoming.Accept || "",
+ Authorization: "Bearer " + ctx.apiKey,
+ },
+ body: normalized.body,
+ action: normalized.action,
+ };
+}
+
+export function parseSubmitResponse(ctx, resp) {
+ const body = resp.body || {};
+ if (body.code !== "success") throw new Error(String(body.message || ""));
+ if (!body.data) throw new Error("task_id is empty");
+ // The native create presenter runs after persistence, so retaining the
+ // acknowledgement message is required to preserve Suno's submit envelope.
+ // The first pre-poll status view therefore contains this temporary message;
+ // the first provider poll replaces task.data with the normal Suno payload.
+ return { taskId: body.data, taskData: { message: String(body.message || "") } };
+}
+
+export function extractUsage(ctx) {
+ if (ctx.usagePurpose === "billing_ratios") return null;
+ const model = trimmed(ctx.model || (ctx.requestBody || {}).model).toLowerCase();
+ const action = actionName(ctx).toLowerCase() || (model === "suno_lyrics" ? "lyrics" : "music");
+ return { clips: action === "lyrics" ? 1 : 2, action: action };
+}
+
+export function buildBatchQueryRequest(ctx, taskIds) {
+ return {
+ url: ctx.baseUrl + "/suno/fetch",
+ method: "POST",
+ headers: { "Content-Type": "application/json", Authorization: "Bearer " + ctx.apiKey },
+ body: { ids: taskIds },
+ };
+}
+
+// Required v1 per-task hooks remain defined for contract compatibility. Suno's
+// host polling path uses the batch hooks below.
+export function buildQueryRequest(ctx) {
+ return buildBatchQueryRequest(ctx, (ctx.requestBody || {}).ids || []);
+}
+
+export function parseBatchResult(ctx, body) {
+ if (body.code !== "success") throw new Error(String(body.message || ""));
+ return (body.data || []).map(function (item) {
+ return {
+ taskId: item.task_id || "",
+ action: item.action || "",
+ status: item.status || "",
+ reason: item.fail_reason || "",
+ submitTime: item.submit_time || 0,
+ startTime: item.start_time || 0,
+ finishTime: item.finish_time || 0,
+ data: item.data,
+ };
+ });
+}
+
+export function parseTaskResult(ctx, body) {
+ const results = parseBatchResult(ctx, { code: 200, data: [body] });
+ return results.length ? results[0] : { status: "UNKNOWN" };
+}
+
+function artifactData(ctx) {
+ const data = (ctx && ctx.data) || [];
+ if (data.data && typeof data.data === "object" && data.data.task_id && Object.prototype.hasOwnProperty.call(data.data, "data")) {
+ const nested = data.data.data;
+ return Array.isArray(nested) ? nested : nested && typeof nested === "object" ? [nested] : [];
+ }
+ return Array.isArray(data) ? data : data && typeof data === "object" ? [data] : [];
+}
+
+function artifactKey(type, song) {
+ return type + "-" + utils.hmacSHA256(String(song.id), "new-api:suno:artifact-key");
+}
+
+export function listArtifacts(task) {
+ if (task.status !== "SUCCESS") return [];
+ const artifacts = [];
+ for (const song of artifactData(task)) {
+ if (!song || !String(song.id || "").trim()) continue;
+ if (String(song.audio_url || "").trim()) {
+ artifacts.push({ key: artifactKey("audio", song), type: "audio", mimeType: "audio/mpeg" });
+ }
+ if (String(song.image_url || "").trim()) {
+ artifacts.push({ key: artifactKey("cover", song), type: "image" });
+ }
+ }
+ return artifacts;
+}
+
+export function buildContentRequest(ctx) {
+ const song = artifactData(ctx).find(function (item) {
+ if (!item || !String(item.id || "").trim()) return false;
+ return artifactKey("audio", item) === ctx.artifactKey || artifactKey("cover", item) === ctx.artifactKey;
+ });
+ if (!song) throw new Error("artifact_not_found");
+ let url = "";
+ if (artifactKey("audio", song) === ctx.artifactKey) url = String(song.audio_url || "").trim();
+ if (artifactKey("cover", song) === ctx.artifactKey) url = String(song.image_url || "").trim();
+ if (!url) throw new Error("artifact_not_found");
+ return { url: url, method: ctx.clientRequest.method, credentialless: true };
+}
+
+export function extractUsageOnComplete(task, taskResult, body) {
+ const values = Array.isArray(body) ? body : body && typeof body === "object" ? [body] : [];
+ if (values.length === 0) return {};
+ const music = values.some(function (item) {
+ return item && (trimmed(item.audio_url) || trimmed(item.video_url));
+ });
+ return { clips: values.length, action: music ? "music" : "lyrics" };
+}
+
+function escapedAttribute(value) {
+ return trimmed(value).replace(/&/g, "&").replace(/"/g, """).replace(//g, ">");
+}
+
+function responseContent(ctx, task) {
+ const model = trimmed(ctx && ctx.requestBody && ctx.requestBody.model).toLowerCase();
+ const songs = artifactData(task);
+ const lyrics = [];
+ for (const song of songs) {
+ if (!song) continue;
+ const text = trimmed(song.text);
+ if (!text) continue;
+ const title = trimmed(song.title);
+ lyrics.push(title ? title + "\n" + text : text);
+ }
+ if (model === "suno_lyrics") {
+ return [{ type: "output_text", text: lyrics.join("\n\n") || "Lyrics generation completed.", annotations: [], logprobs: [] }];
+ }
+ const content = [{ type: "output_text", text: lyrics.join("\n\n") || "Music generation completed.", annotations: [], logprobs: [] }];
+ for (const song of songs) {
+ if (!song || !trimmed(song.audio_url)) continue;
+ const key = artifactKey("audio", song);
+ const artifact = ctx && ctx.artifacts && ctx.artifacts[key];
+ const url = trimmed(artifact && artifact.url);
+ if (!url) throw new Error("audio artifact is unavailable");
+ content.push({ type: "output_text", text: ' ', annotations: [], logprobs: [] });
+ }
+ return content;
+}
+
+function responseText(ctx, task) {
+ return responseContent(ctx, task)
+ .map(function (part) {
+ return part.text;
+ })
+ .join("\n\n");
+}
+
+export const protocols = {
+ openai_responses: {
+ decodeRequest: function (ctx) {
+ if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
+ const req = ctx.body.value;
+ if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
+ const model = trimmed(req.model);
+ if (model !== "suno_music" && model !== "suno_lyrics") throw new Error("model is required");
+ if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
+ if (req.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
+ throw new Error("metadata must be an object");
+ const input = responsesText(req);
+ const requestBody = Object.assign({}, req.metadata || {});
+ if (model === "suno_lyrics") {
+ if (!trimmed(requestBody.prompt)) requestBody.prompt = input || trimmed(req.prompt);
+ if (!trimmed(requestBody.prompt)) throw new Error("input is required");
+ return { kind: "submit", model: model, action: "LYRICS", requestBody: requestBody };
+ }
+ if (!trimmed(requestBody.gpt_description_prompt)) requestBody.gpt_description_prompt = input || trimmed(req.prompt);
+ if (!trimmed(requestBody.gpt_description_prompt) && !trimmed(requestBody.prompt)) throw new Error("input is required");
+ return { kind: "submit", model: model, action: "MUSIC", requestBody: requestBody };
+ },
+ renderEvents: function (ctx, task, previousState) {
+ const status = String(task.status || "UNKNOWN").toUpperCase();
+ const value = Number(String(task.progress || "").replace("%", ""));
+ const progress = Number.isFinite(value) && value >= 0 && value <= 100 ? value : null;
+ const state = { status: status, progress: progress };
+ if (status === "SUCCESS") {
+ const text = responseText(ctx, task);
+ const events = previousState && previousState.status === status ? [] : [{ type: "output", data: text }];
+ return { events: events, state: state, done: true };
+ }
+ if (status === "FAILURE")
+ return { events: [{ type: "error", code: "task_failed", message: task.fail_reason || "task failed" }], state: state, done: true };
+ if (previousState && previousState.status === status && previousState.progress === progress) return { events: [], state: state, done: false };
+ const event = { type: "progress", message: status.toLowerCase() };
+ if (progress !== null) event.progress = progress;
+ return { events: [event], state: state, done: false };
+ },
+ renderFinal: function (ctx, task) {
+ return { output: [{ type: "message", status: "completed", role: "assistant", content: responseContent(ctx, task) }], metadata: { vendor: "sunoapi" } };
+ },
+ },
+};
+
+function nativeTask(task) {
+ return {
+ created_at: task.created_at || 0,
+ updated_at: task.updated_at || 0,
+ task_id: task.task_id || "",
+ platform: task.platform || "sunoapi",
+ status: task.status || "",
+ fail_reason: task.fail_reason || "",
+ submit_time: task.created_at || 0,
+ finish_time: task.finished_at || 0,
+ progress: task.progress || "",
+ data: task.data === undefined ? null : task.data,
+ };
+}
+
+export const native = {
+ decodeSubmit: decodeNativeSubmit,
+ decodeBatch: decodeNativeBatch,
+ renderSubmit: function (ctx, task) {
+ const data = task.data && typeof task.data === "object" ? task.data : {};
+ return { code: "success", message: String(data.message || ""), data: String(task.task_id || "") };
+ },
+ renderTask: function (ctx, task) {
+ return { code: "success", message: "", data: nativeTask(task) };
+ },
+ renderTasks: function (ctx, tasks) {
+ return { code: "success", message: "", data: tasks.map(nativeTask) };
+ },
+ error: function (ctx, error) {
+ return { code: error.code, message: error.message, data: null };
+ },
+};
diff --git a/plugins/tasks/vertex-ai/plugin.js b/plugins/tasks/vertex-ai/plugin.js
new file mode 100644
index 000000000000..7970c0a2cfb7
--- /dev/null
+++ b/plugins/tasks/vertex-ai/plugin.js
@@ -0,0 +1,394 @@
+export const meta = {
+ apiVersion: 1,
+ key: "vertex-ai",
+ name: "Google Veo (Vertex AI)",
+ icon: "VertexAI.Color",
+ description: {
+ en: "Google Veo video generation on Vertex AI (text-to-video and image-to-video)",
+ zh: "Google Veo 视频生成(文生视频、图生视频),Vertex AI 版本",
+ },
+ version: "1.0.0",
+ channelTypes: [41],
+ author: { name: "QuantumNous" },
+ models: ["veo-3.0-generate-001", "veo-3.0-fast-generate-001", "veo-3.1-generate-preview", "veo-3.1-fast-generate-preview"],
+ fetchMode: "per_task",
+ auth: { type: "oauth2_jwt" },
+ usageSchema: {
+ seconds: {
+ type: "number",
+ unit: "second",
+ description: {
+ en: "Requested video duration in seconds. Allowed values: 4, 6, 8.",
+ zh: "请求的视频时长,单位为秒。允许值为 4、6、8。",
+ },
+ },
+ resolution: {
+ enum: ["720p", "1080p", "4k"],
+ description: { en: "Requested video output resolution.", zh: "请求的输出视频分辨率。" },
+ },
+ generate_audio: {
+ type: "boolean",
+ description: {
+ en: "Whether audio is generated. Default true. Audio and muted tiers have different prices.",
+ zh: "是否生成音频。默认为 true。有声与静音档位计费不同。",
+ },
+ },
+ },
+ usageExamples: [
+ { label: "8s 720p audio", facts: { seconds: 8, resolution: "720p", generate_audio: true } },
+ { label: "8s 720p muted", facts: { seconds: 8, resolution: "720p", generate_audio: false } },
+ { label: "8s 1080p audio", facts: { seconds: 8, resolution: "1080p", generate_audio: true } },
+ { label: "8s 4k audio", facts: { seconds: 8, resolution: "4k", generate_audio: true } },
+ ],
+ protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
+};
+
+function trimmed(value) {
+ return String(value || "").trim();
+}
+function responsesInput(req) {
+ const texts = [],
+ images = [];
+ const input = req.input;
+ if (typeof input === "string") texts.push(input);
+ else if (Array.isArray(input)) {
+ for (const item of input) {
+ if (typeof item === "string") {
+ texts.push(item);
+ continue;
+ }
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
+ const content = item.content === undefined ? [item] : Array.isArray(item.content) ? item.content : [item.content];
+ for (const part of content) {
+ if (typeof part === "string") {
+ texts.push(part);
+ continue;
+ }
+ if (!part || typeof part !== "object" || Array.isArray(part)) continue;
+ if (["input_text", "text"].includes(part.type) && typeof part.text === "string") texts.push(part.text);
+ if (["input_image", "image_url"].includes(part.type)) {
+ let image = part.image_url;
+ if (image && typeof image === "object") image = image.url;
+ if (trimmed(image)) images.push(trimmed(image));
+ }
+ }
+ }
+ }
+ return {
+ prompt: texts
+ .filter(function (text) {
+ return trimmed(text);
+ })
+ .join("\n"),
+ images: images,
+ };
+}
+
+function validImageInput(value) {
+ value = trimmed(value);
+ if (!value) return false;
+ if (value.startsWith("data:")) {
+ const comma = value.indexOf(",");
+ return comma >= 0 && Boolean(value.slice(comma + 1));
+ }
+ return /^[A-Za-z0-9+/]+={0,2}$/.test(value);
+}
+
+function sizeParts(size) {
+ const p = String(size || "")
+ .toLowerCase()
+ .split("x");
+ return p.length === 2 ? [Number(p[0]), Number(p[1])] : null;
+}
+function resolution(size) {
+ const p = sizeParts(size);
+ if (!p) return "720p";
+ const m = Math.max(p[0], p[1]);
+ return m >= 3840 ? "4k" : m >= 1920 ? "1080p" : "720p";
+}
+function aspect(size) {
+ const p = sizeParts(size);
+ return !p || p[0] <= 0 || p[1] <= 0 ? "16:9" : p[1] > p[0] ? "9:16" : "16:9";
+}
+function duration(req) {
+ let n = Number(req.duration);
+ if (!Number.isFinite(n) || n <= 0) n = 8;
+ return n;
+}
+function region(ctx, model) {
+ const setting = ctx.userSetting || {};
+ const configured = setting.vertexRegion || setting.apiVersion || "global";
+ if (configured && typeof configured === "object") return configured[model] || configured.default || "global";
+ return String(configured || "global");
+}
+function apiBase(baseUrl, project, location) {
+ const base = String(baseUrl || "").replace(/\/$/, "");
+ if (base) return base + (base.endsWith("/v1") ? "" : "/v1") + "/projects/" + project + "/locations/" + location;
+ return "https://" + (location === "global" ? "" : location + "-") + "aiplatform.googleapis.com/v1/projects/" + project + "/locations/" + location;
+}
+function modelURL(ctx, project, location, model, action) {
+ return apiBase(ctx.baseUrl, project, location) + "/publishers/google/models/" + model + ":" + action;
+}
+function decodeTaskId(value) {
+ return utils.base64URLDecode(value);
+}
+function operationPart(name, marker) {
+ const start = name.indexOf(marker);
+ if (start < 0) return "";
+ return name.slice(start + marker.length).split("/")[0];
+}
+function dataVideo(response) {
+ const videos = response.videos || [];
+ const first = videos[0] || {};
+ const data = first.bytesBase64Encoded || response.bytesBase64Encoded || response.video || "";
+ if (!data || String(data).startsWith("data:") || String(data).startsWith("http")) return data;
+ const enc = first.mimeType || first.encoding || response.encoding || "mp4";
+ return "data:" + (String(enc).includes("/") ? enc : "video/" + enc) + ";base64," + data;
+}
+
+export function buildSubmitRequest(ctx) {
+ if (ctx.authError) throw new Error(ctx.authError);
+ const req = ctx.requestBody || {},
+ metadata = Object.assign({}, req.metadata || {}),
+ model = ctx.upstreamModel || "veo-3.0-generate-001";
+ if (Number(req.duration) > 0) metadata.durationSeconds = Number(req.duration);
+ if (!metadata.resolution && req.size) metadata.resolution = resolution(req.size);
+ if (!metadata.aspectRatio && req.size) metadata.aspectRatio = aspect(req.size);
+ if (metadata.resolution) metadata.resolution = String(metadata.resolution).toLowerCase();
+ metadata.sampleCount = 1;
+ const instance = { prompt: req.prompt };
+ const image = (req.images || [])[0];
+ if (image && typeof image === "object" && !Array.isArray(image) && image.__fileRef) {
+ let mime = String(image.mimeType || "").toLowerCase();
+ if (mime !== "image/jpeg" && mime !== "image/png") {
+ const files = ctx.files || [];
+ for (const file of files) {
+ if (file.ref === image.__fileRef || file.field === "input_reference") {
+ mime = String(file.mimeType || "").toLowerCase();
+ break;
+ }
+ }
+ }
+ if (mime !== "image/jpeg" && mime !== "image/png") throw new Error("input image must be image/jpeg or image/png");
+ instance.image = { bytesBase64Encoded: image, mimeType: mime };
+ } else if (image) {
+ const text = String(image);
+ const comma = text.indexOf(",");
+ let mime = "";
+ let data = text;
+ if (text.startsWith("data:")) {
+ mime = text.slice(5, comma).split(";")[0].toLowerCase();
+ data = text.slice(comma + 1);
+ } else if (text.startsWith("iVBORw0KGgo")) mime = "image/png";
+ else if (text.startsWith("/9j/")) mime = "image/jpeg";
+ if (mime !== "image/jpeg" && mime !== "image/png") throw new Error("input image must be image/jpeg or image/png");
+ instance.image = { bytesBase64Encoded: data, mimeType: mime };
+ }
+ return {
+ url: modelURL(ctx, ctx.auth.projectId, region(ctx, model), model, "predictLongRunning"),
+ method: "POST",
+ headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: ctx.authHeader, "x-goog-user-project": ctx.auth.projectId },
+ body: { instances: [instance], parameters: metadata },
+ action: image ? "image_to_video" : "text_to_video",
+ };
+}
+
+export function parseSubmitResponse(ctx, resp) {
+ const body = resp.body || {};
+ if (!String(body.name || "").trim()) throw new Error("missing operation name");
+ return { taskId: utils.base64URL(body.name), taskData: body };
+}
+export function extractUsage(ctx) {
+ const req = ctx.requestBody || {};
+ const metadata = req.metadata || {};
+ return {
+ seconds: duration(req),
+ resolution: String(metadata.resolution || resolution(req.size) || "720p").toLowerCase(),
+ generate_audio: metadata.generateAudio !== false,
+ };
+}
+export function extractUsageOnComplete() {
+ return null;
+}
+export function buildQueryRequest(ctx) {
+ const name = decodeTaskId(ctx.taskId),
+ project = operationPart(name, "projects/"),
+ location = operationPart(name, "locations/"),
+ model = operationPart(name, "models/");
+ if (!project || !model) throw new Error("cannot extract project or model from operation name");
+ return {
+ url: modelURL(ctx, project, location || "us-central1", model, "fetchPredictOperation"),
+ method: "POST",
+ headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: ctx.authHeader, "x-goog-user-project": ctx.auth.projectId },
+ body: { operationName: name },
+ };
+}
+export function parseTaskResult(ctx, body) {
+ if (body.error && body.error.message) return { status: "FAILURE", progress: "100%", reason: body.error.message };
+ if (!body.done) return { status: "IN_PROGRESS", progress: "50%" };
+ const url = dataVideo(body.response || {});
+ return { status: "SUCCESS", progress: "100%", url: url, remoteUrl: url };
+}
+export function listArtifacts() {
+ return [];
+}
+export function buildContentRequest() {
+ throw new Error("artifact_not_found");
+}
+function completionMessage(ctx, task) {
+ const request = (ctx && ctx.requestBody) || {};
+ const model = trimmed(request.model) || trimmed((task.properties || {}).origin_model_name) || "veo-3.0-generate-001";
+ const status = String(task.status || "SUCCESS").toUpperCase();
+ return (
+ "Video generation for " +
+ model +
+ " completed (" +
+ duration(request) +
+ " seconds, status " +
+ status +
+ "). Retrieve the video through the native /v1/videos task flow."
+ );
+}
+
+export const protocols = {
+ openai_responses: {
+ decodeRequest: function (ctx) {
+ if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
+ const req = ctx.body.value;
+ if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
+ const model = trimmed(req.model);
+ if (!model) throw new Error("model is required");
+ if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
+ if (req.images !== undefined && !Array.isArray(req.images)) throw new Error("images must be an array");
+ if (req.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
+ throw new Error("metadata must be an object");
+ const input = responsesInput(req);
+ const prompt = input.prompt || trimmed(req.prompt);
+ const images = [];
+ for (const image of [req.image, req.input_reference].concat(req.images || [], input.images)) {
+ if (trimmed(image) && !images.includes(trimmed(image))) images.push(trimmed(image));
+ }
+ if (!prompt && images.length === 0) throw new Error("input is required");
+ if (images.length && !validImageInput(images[0])) throw new Error("input image must be a data URL or base64 value");
+ const metadata = Object.assign({}, req.metadata || {});
+ if (Object.prototype.hasOwnProperty.call(req, "resolution")) metadata.resolution = req.resolution;
+ const requestBody = { model: model, prompt: prompt, metadata: metadata };
+ if (images.length) requestBody.images = images;
+ if (Object.prototype.hasOwnProperty.call(req, "seconds")) requestBody.duration = req.seconds;
+ else if (Object.prototype.hasOwnProperty.call(req, "duration")) requestBody.duration = req.duration;
+ if (Object.prototype.hasOwnProperty.call(req, "size")) requestBody.size = req.size;
+ return { kind: "submit", model: model, action: images.length ? "image_to_video" : "text_to_video", requestBody: requestBody };
+ },
+ renderEvents: function (ctx, task, previousState) {
+ const status = String(task.status || "UNKNOWN").toUpperCase();
+ const value = Number(String(task.progress || "").replace("%", ""));
+ const progress = Number.isFinite(value) && value >= 0 && value <= 100 ? value : null;
+ const state = { status: status, progress: progress };
+ if (status === "SUCCESS") {
+ const events = previousState && previousState.status === status ? [] : [{ type: "output", data: completionMessage(ctx, task) }];
+ return { events: events, state: state, done: true };
+ }
+ if (status === "FAILURE")
+ return { events: [{ type: "error", code: "task_failed", message: task.fail_reason || "task failed" }], state: state, done: true };
+ if (previousState && previousState.status === status && previousState.progress === progress) return { events: [], state: state, done: false };
+ const event = { type: "progress", message: status.toLowerCase() };
+ if (progress !== null) event.progress = progress;
+ return { events: [event], state: state, done: false };
+ },
+ renderFinal: function (ctx, task) {
+ return {
+ output: [
+ {
+ type: "message",
+ status: "completed",
+ role: "assistant",
+ content: [{ type: "output_text", text: completionMessage(ctx, task), annotations: [], logprobs: [] }],
+ },
+ ],
+ metadata: { vendor: "vertex", artifact_mode: "native_videos" },
+ };
+ },
+ },
+};
+
+const legacyRenderers = {
+ openai_video: function (task) {
+ const model = (task.properties || {}).origin_model_name || "veo-3.0-generate-001";
+ const statuses = { NOT_START: "queued", SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "in_progress", SUCCESS: "completed", FAILURE: "failed" };
+ const out = {
+ id: task.task_id,
+ object: "video",
+ model,
+ status: statuses[task.status] || "unknown",
+ progress: Number(String(task.progress || "0").replace("%", "")),
+ created_at: task.created_at,
+ };
+ if (Number(task.updated_at) > 0) out.completed_at = Number(task.updated_at);
+ return out;
+ },
+};
+
+protocols.openai_video = {
+ decodeRequest: function (ctx) {
+ if (!ctx.body || (ctx.body.kind !== "json" && ctx.body.kind !== "multipart")) throw new Error("JSON or multipart body required");
+ let req;
+ let hasInputReferenceFile = false;
+ if (ctx.body.kind === "json") {
+ if (!ctx.body.value || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
+ req = Object.assign({}, ctx.body.value);
+ } else {
+ const first = function (name) {
+ const values = (ctx.body.fields || {})[name] || [];
+ if (values.length > 1) throw new Error(name + " must be provided once");
+ return values[0];
+ };
+ req = {};
+ const fields = ctx.body.fields || {};
+ for (const name of Object.keys(fields)) {
+ req[name] = first(name);
+ }
+ for (const file of ctx.body.files || []) {
+ if (file.field !== "input_reference") throw new Error("unexpected file field: " + file.field);
+ if (hasInputReferenceFile) throw new Error("input_reference must be provided once");
+ const mime = String(file.mimeType || "").toLowerCase();
+ if (mime !== "image/jpeg" && mime !== "image/png") throw new Error("input_reference must be image/jpeg or image/png");
+ hasInputReferenceFile = true;
+ }
+ if (req.metadata !== undefined) {
+ let parsed;
+ try {
+ parsed = JSON.parse(req.metadata);
+ } catch {
+ throw new Error("metadata must be a JSON object string");
+ }
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("metadata must be a JSON object string");
+ req.metadata = parsed;
+ }
+ if (req.seconds !== undefined) req.seconds = Number(req.seconds);
+ else if (req.duration !== undefined) req.seconds = Number(req.duration);
+ }
+ const seconds = req.seconds === undefined ? req.duration : req.seconds;
+ if (seconds !== undefined) {
+ const n = Number(seconds);
+ if (n !== 4 && n !== 6 && n !== 8) throw new Error("seconds must be one of 4, 6, or 8");
+ req.duration = n;
+ }
+ const providedResolution = req.resolution !== undefined ? req.resolution : req.metadata && req.metadata.resolution;
+ if (providedResolution !== undefined && providedResolution !== "") {
+ const value = String(providedResolution).toLowerCase();
+ if (value !== "720p" && value !== "1080p" && value !== "4k") throw new Error("resolution must be one of 720p, 1080p, or 4k");
+ }
+ if (hasInputReferenceFile) {
+ req.images = [{ __fileRef: "request_file:input_reference", encoding: "base64", maxBytes: 20971520 }];
+ }
+ return {
+ kind: "submit",
+ model: ctx.model,
+ action: hasInputReferenceFile || req.input_reference || req.image ? "image_to_video" : "text_to_video",
+ requestBody: Object.assign({}, req, { model: ctx.model }),
+ };
+ },
+ render: function (ctx, task) {
+ return legacyRenderers.openai_video(task);
+ },
+};
diff --git a/plugins/tasks/vidu/plugin.js b/plugins/tasks/vidu/plugin.js
new file mode 100644
index 000000000000..99fffbdf1839
--- /dev/null
+++ b/plugins/tasks/vidu/plugin.js
@@ -0,0 +1,443 @@
+export const meta = {
+ apiVersion: 1,
+ key: "vidu",
+ name: "Vidu",
+ icon: "Vidu.Color",
+ description: {
+ en: "Shengshu Vidu video generation (text-to-video, image-to-video, first-and-last-frame, and reference-to-video)",
+ zh: "生数 Vidu 视频生成(文生视频、图生视频、首尾帧、参考生视频)",
+ },
+ version: "1.0.0",
+ author: { name: "QuantumNous" },
+ channelTypes: [52],
+ models: ["viduq2", "viduq1", "vidu2.0", "vidu1.5"],
+ fetchMode: "per_task",
+ usageSchema: {
+ credits: {
+ type: "number",
+ unit: "credit",
+ description: { en: "estimated/actual Vidu credits", zh: "预估/实际的 Vidu 积分" },
+ },
+ duration: {
+ type: "number",
+ unit: "second",
+ description: { en: "Requested video duration in seconds.", zh: "请求的视频时长,单位为秒。" },
+ },
+ resolution: {
+ enum: ["360p", "540p", "720p", "1080p"],
+ description: { en: "Requested output video resolution.", zh: "请求的输出视频分辨率。" },
+ },
+ },
+ // credits is 0 in examples because this plugin does not estimate vendor credits;
+ // schema completeness requires the key. Actual credits arrive from upstream.
+ usageExamples: [
+ { label: "q2 5s 720p", facts: { credits: 0, duration: 5, resolution: "720p" } },
+ { label: "q1 5s 1080p", facts: { credits: 0, duration: 5, resolution: "1080p" } },
+ { label: "2.0 4s 360p", facts: { credits: 0, duration: 4, resolution: "360p" } },
+ { label: "2.0 4s 720p", facts: { credits: 0, duration: 4, resolution: "720p" } },
+ { label: "2.0 8s 720p", facts: { credits: 0, duration: 8, resolution: "720p" } },
+ ],
+ protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
+};
+
+const RESOLUTIONS = ["360p", "540p", "720p", "1080p"];
+
+function trimmed(value) {
+ return String(value || "").trim();
+}
+
+function isQ2Model(model) {
+ return String(model || "").indexOf("viduq2") === 0;
+}
+
+function defaultDuration(model) {
+ if (model === "vidu2.0") return 4;
+ return 5;
+}
+
+function defaultResolution(model) {
+ if (isQ2Model(model)) return "720p";
+ if (model === "vidu2.0") return "360p";
+ return "1080p";
+}
+
+function normalizeResolution(value, model) {
+ if (model === "viduq1") return "1080p";
+ const raw = trimmed(value).toLowerCase();
+ if (RESOLUTIONS.indexOf(raw) >= 0) return raw;
+ const parts = raw.replace("*", "x").split("x");
+ if (parts.length === 2) {
+ const width = Number(parts[0]);
+ const height = Number(parts[1]);
+ if (width > 0 && height > 0) {
+ const max = Math.max(width, height);
+ if (max >= 1920) return "1080p";
+ if (max >= 1280) return "720p";
+ if (max >= 960) return "540p";
+ return "360p";
+ }
+ }
+ return defaultResolution(model);
+}
+
+function outboundDuration(req, model) {
+ const n = Number(req && req.duration);
+ if (Number.isFinite(n) && n > 0) return n;
+ return defaultDuration(model);
+}
+
+function outboundResolution(req, model) {
+ if (req && req.resolution) return normalizeResolution(req.resolution, model);
+ const metadata = (req && req.metadata) || {};
+ if (metadata.resolution) return normalizeResolution(metadata.resolution, model);
+ if (req && req.size) return normalizeResolution(req.size, model);
+ return defaultResolution(model);
+}
+
+function hasViduImages(req, hasInputReferenceFile) {
+ if (hasInputReferenceFile) return true;
+ if (Array.isArray(req && req.images) && req.images.length) return true;
+ return Boolean(trimmed(req && req.input_reference) || trimmed(req && req.image));
+}
+
+function validateViduCombo(model, duration, resolution, hasImages) {
+ if (model === "vidu2.0" && !hasImages) {
+ throw new Error("vidu2.0 does not support text-to-video");
+ }
+ if (model === "viduq1") {
+ if (duration !== undefined && Number(duration) !== 5) throw new Error("viduq1 duration must be 5");
+ return;
+ }
+ if (model === "vidu2.0") {
+ const n = duration === undefined ? 4 : Number(duration);
+ if (n === 4) {
+ if (["360p", "720p", "1080p"].indexOf(resolution) < 0) throw new Error("vidu2.0 duration 4 only allows resolution 360p, 720p, or 1080p");
+ return;
+ }
+ if (n === 8) {
+ if (resolution !== "720p") throw new Error("vidu2.0 duration 8 only allows resolution 720p");
+ return;
+ }
+ throw new Error("vidu2.0 duration must be 4 or 8");
+ }
+ if (isQ2Model(model)) {
+ if (duration === undefined) return;
+ const n = Number(duration);
+ if (!Number.isInteger(n) || n < 1 || n > 10) throw new Error("viduq2 duration must be between 1 and 10");
+ return;
+ }
+ if (duration !== undefined) {
+ const n = Number(duration);
+ if (!Number.isInteger(n) || n <= 0 || n > 3600) throw new Error("seconds must be between 1 and 3600");
+ }
+}
+
+function filePlaceholder(image) {
+ if (!image || typeof image !== "object" || Array.isArray(image) || !image.__fileRef) return image;
+ const placeholder = { __fileRef: image.__fileRef, encoding: image.encoding };
+ if (image.mimeType) placeholder.mimeType = image.mimeType;
+ if (image.maxBytes !== undefined && image.maxBytes !== null) placeholder.maxBytes = image.maxBytes;
+ return placeholder;
+}
+
+function responsesInput(req) {
+ const texts = [],
+ images = [];
+ const input = req.input;
+ if (typeof input === "string") texts.push(input);
+ else if (Array.isArray(input)) {
+ for (const item of input) {
+ if (typeof item === "string") {
+ texts.push(item);
+ continue;
+ }
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
+ const content = item.content === undefined ? [item] : Array.isArray(item.content) ? item.content : [item.content];
+ for (const part of content) {
+ if (typeof part === "string") {
+ texts.push(part);
+ continue;
+ }
+ if (!part || typeof part !== "object" || Array.isArray(part)) continue;
+ if (["input_text", "text"].includes(part.type) && typeof part.text === "string") texts.push(part.text);
+ if (["input_image", "image_url"].includes(part.type)) {
+ let image = part.image_url;
+ if (image && typeof image === "object") image = image.url;
+ if (trimmed(image)) images.push(trimmed(image));
+ }
+ }
+ }
+ }
+ return {
+ prompt: texts
+ .filter(function (text) {
+ return trimmed(text);
+ })
+ .join("\n"),
+ images: images,
+ };
+}
+
+function responsesVideoText(ctx) {
+ const artifact = ctx && ctx.artifacts && ctx.artifacts.video;
+ const url = trimmed(artifact && artifact.url);
+ if (!url) throw new Error("video artifact is unavailable");
+ const escaped = url.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">");
+ return ' ';
+}
+
+function actionFor(req) {
+ if (req.metadata && req.metadata.action) {
+ const aliases = {
+ generate: "image_to_video",
+ textGenerate: "text_to_video",
+ firstTailGenerate: "first_tail_to_video",
+ referenceGenerate: "reference_to_video",
+ remixGenerate: "remix",
+ };
+ return aliases[req.metadata.action] || req.metadata.action;
+ }
+ if (!req.images || req.images.length === 0) return "text_to_video";
+ if (req.images.length === 2) return "first_tail_to_video";
+ if (req.images.length > 2) return "reference_to_video";
+ return "image_to_video";
+}
+
+function pathFor(action) {
+ if (action === "image_to_video") return "/img2video";
+ if (action === "first_tail_to_video") return "/start-end2video";
+ if (action === "reference_to_video") return "/reference2video";
+ return "/text2video";
+}
+
+export function buildSubmitRequest(ctx) {
+ const req = ctx.requestBody;
+ const action = actionFor(req);
+ const metadata = req.metadata || {};
+ let model = ctx.upstreamModel || "viduq1";
+ if (action === "reference_to_video" && model.includes("viduq2")) model = "viduq2";
+ const images = Array.isArray(req.images) ? req.images.map(filePlaceholder) : null;
+ const body = Object.assign(
+ {
+ model: model,
+ images: images,
+ prompt: req.prompt || null,
+ duration: outboundDuration(req, model),
+ resolution: outboundResolution(req, model),
+ movement_amplitude: "auto",
+ },
+ metadata
+ );
+ delete body.action;
+ if (!body.prompt) delete body.prompt;
+ if (!body.bgm) delete body.bgm;
+ if (Array.isArray(body.images)) body.images = body.images.map(filePlaceholder);
+ return {
+ url: ctx.baseUrl + "/ent/v2" + pathFor(action),
+ method: "POST",
+ headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: "Token " + ctx.apiKey },
+ body: body,
+ action: action,
+ };
+}
+
+export function parseSubmitResponse(ctx, resp) {
+ if (!resp.body || resp.body.state === "failed") throw new Error("task failed");
+ if (!resp.body.task_id) throw new Error("missing task_id");
+ return { taskId: resp.body.task_id, taskData: resp.body };
+}
+
+export function extractUsage(ctx) {
+ if (ctx.usagePurpose === "billing_ratios") return null;
+ const req = ctx.requestBody || {};
+ const model = ctx.upstreamModel || req.model;
+ return { duration: outboundDuration(req, model), resolution: outboundResolution(req, model) };
+}
+
+export function buildQueryRequest(ctx) {
+ return {
+ url: ctx.baseUrl + "/ent/v2/tasks/" + ctx.taskId + "/creations",
+ method: "GET",
+ headers: { Accept: "application/json", Authorization: "Token " + ctx.apiKey },
+ };
+}
+
+export function parseTaskResult(ctx, body) {
+ const statuses = { created: "SUBMITTED", queueing: "SUBMITTED", processing: "IN_PROGRESS", success: "SUCCESS", failed: "FAILURE" };
+ const status = statuses[body.state];
+ if (!status) throw new Error("unknown task state: " + body.state);
+ const url = body.creations && body.creations.length ? body.creations[0].url || "" : "";
+ const result = { status: status, reason: body.state === "failed" ? body.err_code || "" : "" };
+ if (url) result.url = url;
+ return result;
+}
+
+function artifactData(ctx) {
+ const data = (ctx && ctx.data) || {};
+ if (data.data && typeof data.data === "object" && data.data.task_id && Object.prototype.hasOwnProperty.call(data.data, "data")) return data.data.data || {};
+ return data;
+}
+
+function artifactVideoURL(ctx) {
+ const creations = artifactData(ctx).creations;
+ return Array.isArray(creations) && creations.length ? String(creations[0].url || "").trim() : "";
+}
+
+export function listArtifacts(task) {
+ return task.status === "SUCCESS" && artifactVideoURL(task) ? [{ key: "video", type: "video" }] : [];
+}
+
+export function buildContentRequest(ctx) {
+ if (ctx.artifactKey !== "video") throw new Error("artifact_not_found");
+ const url = artifactVideoURL(ctx);
+ if (!url) throw new Error("artifact_not_found");
+ return { url: url, method: ctx.clientRequest.method, credentialless: true };
+}
+
+export function extractUsageOnComplete(_task, _taskResult, body) {
+ if (!body || !Object.prototype.hasOwnProperty.call(body, "credits") || body.credits === undefined || body.credits === null) return null;
+ const credits = Number(body.credits);
+ if (!Number.isFinite(credits)) return null;
+ return { credits: credits };
+}
+
+export const protocols = {
+ openai_responses: {
+ decodeRequest: function (ctx) {
+ if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
+ const req = ctx.body.value;
+ if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
+ const model = trimmed(req.model);
+ if (!model) throw new Error("model is required");
+ if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
+ if (req.images !== undefined && !Array.isArray(req.images)) throw new Error("images must be an array");
+ if (req.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
+ throw new Error("metadata must be an object");
+ const input = responsesInput(req);
+ const prompt = input.prompt || trimmed(req.prompt);
+ const images = [];
+ for (const image of [req.image, req.input_reference].concat(req.images || [], input.images)) {
+ if (trimmed(image) && !images.includes(trimmed(image))) images.push(trimmed(image));
+ }
+ if (!prompt && images.length === 0) throw new Error("input is required");
+ const requestBody = { model: model, prompt: prompt };
+ if (images.length) requestBody.images = images;
+ if (Object.prototype.hasOwnProperty.call(req, "seconds")) requestBody.duration = req.seconds;
+ else if (Object.prototype.hasOwnProperty.call(req, "duration")) requestBody.duration = req.duration;
+ if (Object.prototype.hasOwnProperty.call(req, "size")) requestBody.size = req.size;
+ if (Object.prototype.hasOwnProperty.call(req, "metadata")) requestBody.metadata = req.metadata;
+ const duration = requestBody.duration === undefined ? undefined : Number(requestBody.duration);
+ validateViduCombo(model, duration, outboundResolution(requestBody, model), images.length > 0);
+ return { kind: "submit", model: model, action: actionFor(requestBody), requestBody: requestBody };
+ },
+ renderEvents: function (ctx, task, previousState) {
+ const status = String(task.status || "UNKNOWN").toUpperCase();
+ const value = Number(String(task.progress || "").replace("%", ""));
+ const progress = Number.isFinite(value) && value >= 0 && value <= 100 ? value : null;
+ const state = { status: status, progress: progress };
+ if (status === "SUCCESS") {
+ const text = responsesVideoText(ctx);
+ const events = previousState && previousState.status === status ? [] : [{ type: "output", data: text }];
+ return { events: events, state: state, done: true };
+ }
+ if (status === "FAILURE")
+ return { events: [{ type: "error", code: "task_failed", message: task.fail_reason || "task failed" }], state: state, done: true };
+ if (previousState && previousState.status === status && previousState.progress === progress) return { events: [], state: state, done: false };
+ const event = { type: "progress", message: status.toLowerCase() };
+ if (progress !== null) event.progress = progress;
+ return { events: [event], state: state, done: false };
+ },
+ renderFinal: function (ctx, _task) {
+ return {
+ output: [
+ {
+ type: "message",
+ status: "completed",
+ role: "assistant",
+ content: [{ type: "output_text", text: responsesVideoText(ctx), annotations: [], logprobs: [] }],
+ },
+ ],
+ metadata: { vendor: "vidu" },
+ };
+ },
+ },
+};
+
+const legacyRenderers = {
+ openai_video: function (task) {
+ const statusMap = { NOT_START: "queued", SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "in_progress", SUCCESS: "completed", FAILURE: "failed" };
+ const output = {
+ id: task.task_id,
+ object: "video",
+ model: "",
+ status: statusMap[task.status] || "unknown",
+ progress: Number(String(task.progress || "0").replace("%", "")),
+ created_at: task.created_at,
+ };
+ if (task.updated_at) output.completed_at = task.updated_at;
+ if (task.data && task.data.state === "failed" && task.data.err_code) output.error = { message: task.data.err_code, code: task.data.err_code };
+ return output;
+ },
+};
+
+protocols.openai_video = {
+ decodeRequest: function (ctx) {
+ if (!ctx.body || (ctx.body.kind !== "json" && ctx.body.kind !== "multipart")) throw new Error("JSON or multipart body required");
+ let req;
+ let hasInputReferenceFile = false;
+ if (ctx.body.kind === "json") {
+ if (!ctx.body.value || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
+ req = Object.assign({}, ctx.body.value);
+ } else {
+ const first = function (name) {
+ const values = (ctx.body.fields || {})[name] || [];
+ if (values.length > 1) throw new Error(name + " must be provided once");
+ return values[0];
+ };
+ req = {};
+ const fields = ctx.body.fields || {};
+ for (const name of Object.keys(fields)) {
+ req[name] = first(name);
+ }
+ for (const file of ctx.body.files || []) {
+ if (file.field !== "input_reference") throw new Error("unexpected file field: " + file.field);
+ if (hasInputReferenceFile) throw new Error("input_reference must be provided once");
+ hasInputReferenceFile = true;
+ }
+ if (req.metadata !== undefined) {
+ let parsed;
+ try {
+ parsed = JSON.parse(req.metadata);
+ } catch (e) {
+ throw new Error("metadata must be a JSON object string");
+ }
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("metadata must be a JSON object string");
+ req.metadata = parsed;
+ }
+ if (req.seconds !== undefined) req.seconds = Number(req.seconds);
+ else if (req.duration !== undefined) req.seconds = Number(req.duration);
+ }
+ const model = ctx.model || req.model;
+ const seconds = req.seconds === undefined ? req.duration : req.seconds;
+ if (seconds !== undefined) req.duration = Number(seconds);
+ else req.duration = defaultDuration(model);
+ if (hasInputReferenceFile) {
+ req.images = [{ __fileRef: "request_file:input_reference", encoding: "dataUrl", maxBytes: 15728640 }];
+ } else {
+ const image = trimmed(req.input_reference || req.image);
+ if (image && (!Array.isArray(req.images) || req.images.length === 0)) req.images = [image];
+ }
+ req.resolution = outboundResolution(req, model);
+ const hasImages = hasViduImages(req, hasInputReferenceFile);
+ validateViduCombo(model, req.duration, req.resolution, hasImages);
+ return {
+ kind: "submit",
+ model: ctx.model,
+ action: actionFor(req),
+ requestBody: Object.assign({}, req, { model: ctx.model }),
+ };
+ },
+ render: function (ctx, task) {
+ return legacyRenderers.openai_video(task);
+ },
+};
diff --git a/plugins/vertex_ai_responses_test.go b/plugins/vertex_ai_responses_test.go
new file mode 100644
index 000000000000..bbed8cfc11b8
--- /dev/null
+++ b/plugins/vertex_ai_responses_test.go
@@ -0,0 +1,203 @@
+package plugins_test
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ builtinplugins "github.com/QuantumNous/new-api/plugins"
+ "github.com/QuantumNous/new-api/relay"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestVertexAIResponsesProtocol(t *testing.T) {
+ source, err := builtinplugins.Source("vertex-ai")
+ require.NoError(t, err)
+ registry := jsplugin.NewRegistry()
+ plugin, err := registry.RegisterFactory(source, jsplugin.Options{Key: "vertex-ai"})
+ require.NoError(t, err)
+
+ t.Run("claims every model", func(t *testing.T) {
+ for _, model := range plugin.Meta.Models {
+ binding, found := registry.Generation().LookupEndpoint("POST", "/v1/responses", model)
+ require.True(t, found, model)
+ assert.Same(t, plugin, binding.Plugin)
+ assert.Equal(t, "openai_responses", binding.Protocol)
+ }
+ })
+
+ t.Run("shares models with Gemini without losing either provider", func(t *testing.T) {
+ candidates := jsplugin.DefaultRegistry.Generation().LookupEndpointCandidates("POST", "/v1/responses", "veo-3.0-generate-001")
+ require.Len(t, candidates, 2)
+ assert.Equal(t, "google", candidates[0].Plugin.Meta.Key)
+ assert.Equal(t, "vertex-ai", candidates[1].Plugin.Meta.Key)
+
+ request := map[string]any{"model": "veo-3.0-generate-001", "body": map[string]any{"kind": "json", "value": map[string]any{
+ "model": "veo-3.0-generate-001", "input": "waves", "seconds": 8, "size": "1280x720",
+ }}}
+ first, callErr := candidates[0].Plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, request)
+ require.NoError(t, callErr)
+ second, callErr := candidates[1].Plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, request)
+ require.NoError(t, callErr)
+ assert.Equal(t, decodePluginValue(t, first), decodePluginValue(t, second))
+ })
+
+ t.Run("declares documented usage facts", func(t *testing.T) {
+ require.Len(t, plugin.Meta.UsageSchema, 3)
+ for _, key := range []string{"seconds", "resolution", "generate_audio"} {
+ schema, exists := plugin.Meta.UsageSchema[key]
+ require.True(t, exists, key)
+ assert.NotEmpty(t, schema.Description, key)
+ }
+ assert.Equal(t, []string{"720p", "1080p", "4k"}, plugin.Meta.UsageSchema["resolution"].Enum)
+ assert.Equal(t, "boolean", plugin.Meta.UsageSchema["generate_audio"].Type)
+ })
+
+ callProtocol := func(t *testing.T, hook string, args ...any) any {
+ t.Helper()
+ value, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", hook}, args...)
+ require.NoError(t, callErr)
+ return value
+ }
+ decodeMap := func(t *testing.T, value any) map[string]any {
+ t.Helper()
+ encoded, marshalErr := common.Marshal(value)
+ require.NoError(t, marshalErr)
+ var decoded map[string]any
+ require.NoError(t, common.Unmarshal(encoded, &decoded))
+ return decoded
+ }
+
+ requestBody := map[string]any{
+ "model": "veo-3.1-fast-generate-preview",
+ "input": []any{map[string]any{"role": "user", "content": []any{
+ map[string]any{"type": "input_text", "text": "animate this frame"},
+ map[string]any{"type": "input_image", "image_url": "data:image/png;base64,aGVsbG8="},
+ }}},
+ "seconds": 8,
+ "size": "1920x1080",
+ "resolution": "1080P",
+ }
+
+ t.Run("parses Responses input", func(t *testing.T) {
+ resolved := decodeMap(t, callProtocol(t, "decodeRequest", map[string]any{"model": requestBody["model"], "body": map[string]any{"kind": "json", "value": requestBody}}))
+ assert.Equal(t, "veo-3.1-fast-generate-preview", resolved["model"])
+ assert.Equal(t, "image_to_video", resolved["action"])
+ assert.Equal(t, map[string]any{
+ "model": "veo-3.1-fast-generate-preview",
+ "prompt": "animate this frame",
+ "images": []any{"data:image/png;base64,aGVsbG8="},
+ "duration": float64(8),
+ "size": "1920x1080",
+ "metadata": map[string]any{"resolution": "1080P"},
+ }, resolved["requestBody"])
+ })
+
+ t.Run("rejects malformed input", func(t *testing.T) {
+ _, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, map[string]any{
+ "model": "veo-3.0-generate-001", "body": map[string]any{"kind": "json", "value": map[string]any{"model": "veo-3.0-generate-001", "input": map[string]any{"text": "bad"}}},
+ })
+ require.ErrorContains(t, callErr, "input must be a string or array")
+
+ _, callErr = plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, map[string]any{
+ "model": "veo-3.0-generate-001", "body": map[string]any{"kind": "json", "value": map[string]any{"model": "veo-3.0-generate-001", "input": []any{map[string]any{"type": "input_image", "image_url": "https://example.com/frame.png"}}}},
+ })
+ require.ErrorContains(t, callErr, "input image must be a data URL or base64 value")
+ })
+
+ t.Run("extracts schema-declared usage", func(t *testing.T) {
+ value, callErr := plugin.Engine.Call(t.Context(), "extractUsage", map[string]any{
+ "requestBody": map[string]any{"duration": 8, "size": "1920x1080", "metadata": map[string]any{}},
+ "usagePurpose": "facts",
+ })
+ require.NoError(t, callErr)
+ assert.Equal(t, map[string]any{"seconds": int64(8), "resolution": "1080p", "generate_audio": true}, value)
+
+ value, callErr = plugin.Engine.Call(t.Context(), "extractUsageOnComplete", nil, map[string]any{}, map[string]any{
+ "response": map[string]any{"videos": []any{map[string]any{"durationSeconds": 7, "resolution": "4K"}}},
+ })
+ require.NoError(t, callErr)
+ assert.Nil(t, value)
+ })
+
+ protocolContext := map[string]any{
+ "requestBody": map[string]any{"model": "veo-3.1-fast-generate-preview", "duration": 8},
+ "stream": true,
+ }
+ successTask := map[string]any{
+ "task_id": "task-public", "status": "SUCCESS", "progress": "100%", "created_at": 10, "updated_at": 20,
+ "data": map[string]any{"url": "data:video/mp4;base64,MUST_NOT_LEAK"},
+ }
+
+ t.Run("renders stream state transitions", func(t *testing.T) {
+ progressValue := callProtocol(t, "renderEvents", protocolContext, map[string]any{"status": "IN_PROGRESS", "progress": "42%"})
+ progress, decodeErr := relay.DecodePluginProtocolEventResult(progressValue, relay.DefaultPluginProtocolLimits())
+ require.NoError(t, decodeErr)
+ require.Len(t, progress.Events, 1)
+ require.NotNil(t, progress.Events[0].Progress)
+ assert.Equal(t, float64(42), *progress.Events[0].Progress)
+
+ duplicateValue := callProtocol(t, "renderEvents", protocolContext, map[string]any{"status": "IN_PROGRESS", "progress": "42%"}, map[string]any{"status": "IN_PROGRESS", "progress": float64(42)})
+ duplicate, decodeErr := relay.DecodePluginProtocolEventResult(duplicateValue, relay.DefaultPluginProtocolLimits())
+ require.NoError(t, decodeErr)
+ assert.Empty(t, duplicate.Events)
+ assert.False(t, duplicate.Done)
+
+ failureValue := callProtocol(t, "renderEvents", protocolContext, map[string]any{"status": "FAILURE", "fail_reason": "blocked"})
+ failure, decodeErr := relay.DecodePluginProtocolEventResult(failureValue, relay.DefaultPluginProtocolLimits())
+ require.NoError(t, decodeErr)
+ require.Len(t, failure.Events, 1)
+ assert.Equal(t, "error", failure.Events[0].Type)
+ assert.True(t, failure.Done)
+
+ successValue := callProtocol(t, "renderEvents", protocolContext, successTask)
+ success, decodeErr := relay.DecodePluginProtocolEventResult(successValue, relay.DefaultPluginProtocolLimits())
+ require.NoError(t, decodeErr)
+ require.Len(t, success.Events, 1)
+ assert.True(t, success.Done)
+ var text string
+ require.NoError(t, common.Unmarshal(success.Events[0].Data, &text))
+ assert.Contains(t, text, "veo-3.1-fast-generate-preview")
+ assert.Contains(t, text, "8 seconds")
+ assert.Contains(t, text, "/v1/videos")
+ assert.NotContains(t, text, "MUST_NOT_LEAK")
+ assert.NotContains(t, text, " 0 {
- aliReq.Parameters.Duration = req.Duration
- } else if req.Seconds != "" {
- seconds, err := strconv.Atoi(req.Seconds)
- if err != nil {
- return nil, errors.Wrap(err, "convert seconds to int failed")
- } else {
- aliReq.Parameters.Duration = seconds
- }
- }
- if aliReq.Parameters.Duration <= 0 {
- aliReq.Parameters.Duration = 5 // 默认5秒
- }
-
- // 从 metadata 中提取额外参数
- if req.Metadata != nil {
- if metadataBytes, err := common.Marshal(req.Metadata); err == nil {
- err = common.Unmarshal(metadataBytes, aliReq)
- if err != nil {
- return nil, errors.Wrap(err, "unmarshal metadata failed")
- }
- } else {
- return nil, errors.Wrap(err, "marshal metadata failed")
- }
- }
-
- if aliReq.Model != upstreamModel {
- return nil, errors.New("can't change model with metadata")
- }
-
- if err := normalizeWan27I2VInput(aliReq, req); err != nil {
- return nil, err
- }
-
- return aliReq, nil
-}
-
-// EstimateBilling 根据用户请求参数计算 OtherRatios(时长、分辨率等)。
-// 在 ValidateRequestAndSetAction 之后、价格计算之前调用。
-func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 {
- taskReq, err := relaycommon.GetTaskRequest(c)
- if err != nil {
- return nil
- }
-
- aliReq, err := a.convertToAliRequest(info, taskReq)
- if err != nil {
- return nil
- }
-
- // metadata can override Duration past standard request validation;
- // cap it because it is used as a billing multiplier.
- otherRatios := map[string]float64{
- "seconds": float64(min(aliReq.Parameters.Duration, relaycommon.MaxTaskDurationSeconds)),
- }
- ratios, err := ProcessAliOtherRatios(aliReq)
- if err != nil {
- return otherRatios
- }
- for k, v := range ratios {
- otherRatios[k] = v
- }
- return otherRatios
-}
-
-// DoRequest delegates to common helper
-func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
- return channel.DoTaskApiRequest(a, c, info, requestBody)
-}
-
-// DoResponse handles upstream response
-func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *taskdto.TaskError) {
- responseBody, err := io.ReadAll(resp.Body)
- if err != nil {
- taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
- return
- }
- _ = resp.Body.Close()
-
- // 解析阿里响应
- var aliResp AliVideoResponse
- if err := common.Unmarshal(responseBody, &aliResp); err != nil {
- taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError)
- return
- }
-
- // 检查错误
- if aliResp.Code != "" {
- taskErr = service.TaskErrorWrapper(fmt.Errorf("%s: %s", aliResp.Code, aliResp.Message), "ali_api_error", resp.StatusCode)
- return
- }
-
- if aliResp.Output.TaskID == "" {
- taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError)
- return
- }
-
- // 转换为 OpenAI 格式响应
- openAIResp := dto.NewOpenAIVideo()
- openAIResp.ID = info.PublicTaskID
- openAIResp.TaskID = info.PublicTaskID
- openAIResp.Model = c.GetString("model")
- if openAIResp.Model == "" && info != nil {
- openAIResp.Model = info.OriginModelName
- }
- openAIResp.Status = convertAliStatus(aliResp.Output.TaskStatus)
- openAIResp.CreatedAt = common.GetTimestamp()
-
- // 返回 OpenAI 格式
- c.JSON(http.StatusOK, openAIResp)
-
- return aliResp.Output.TaskID, responseBody, nil
-}
-
-// FetchTask 查询任务状态
-func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) {
- taskID, ok := body["task_id"].(string)
- if !ok {
- return nil, fmt.Errorf("invalid task_id")
- }
-
- uri := fmt.Sprintf("%s/api/v1/tasks/%s", baseUrl, taskID)
-
- req, err := http.NewRequest(http.MethodGet, uri, nil)
- if err != nil {
- return nil, err
- }
-
- req.Header.Set("Authorization", "Bearer "+key)
-
- client, err := service.GetHttpClientWithProxy(proxy)
- if err != nil {
- return nil, fmt.Errorf("new proxy http client failed: %w", err)
- }
- return client.Do(req)
-}
-
-func (a *TaskAdaptor) GetModelList() []string {
- return ModelList
-}
-
-func (a *TaskAdaptor) GetChannelName() string {
- return ChannelName
-}
-
-// ParseTaskResult 解析任务结果
-func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
- var aliResp AliVideoResponse
- if err := common.Unmarshal(respBody, &aliResp); err != nil {
- return nil, errors.Wrap(err, "unmarshal task result failed")
- }
-
- taskResult := relaycommon.TaskInfo{
- Code: 0,
- }
-
- // 状态映射
- switch aliResp.Output.TaskStatus {
- case "PENDING":
- taskResult.Status = model.TaskStatusQueued
- case "RUNNING":
- taskResult.Status = model.TaskStatusInProgress
- case "SUCCEEDED":
- taskResult.Status = model.TaskStatusSuccess
- // 阿里直接返回视频URL,不需要额外的代理端点
- taskResult.Url = aliResp.Output.VideoURL
- case "FAILED", "CANCELED", "UNKNOWN":
- taskResult.Status = model.TaskStatusFailure
- if aliResp.Message != "" {
- taskResult.Reason = aliResp.Message
- } else if aliResp.Output.Message != "" {
- taskResult.Reason = fmt.Sprintf("task failed, code: %s , message: %s", aliResp.Output.Code, aliResp.Output.Message)
- } else {
- taskResult.Reason = "task failed"
- }
- default:
- taskResult.Status = model.TaskStatusQueued
- }
-
- return &taskResult, nil
-}
-
-func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) ([]byte, error) {
- var aliResp AliVideoResponse
- if err := common.Unmarshal(task.Data, &aliResp); err != nil {
- return nil, errors.Wrap(err, "unmarshal ali response failed")
- }
-
- openAIResp := dto.NewOpenAIVideo()
- openAIResp.ID = task.TaskID
- openAIResp.Status = convertAliStatus(aliResp.Output.TaskStatus)
- openAIResp.Model = task.Properties.OriginModelName
- openAIResp.SetProgressStr(task.Progress)
- openAIResp.CreatedAt = task.CreatedAt
- openAIResp.CompletedAt = task.UpdatedAt
-
- // 设置视频URL(核心字段)
- openAIResp.SetMetadata("url", aliResp.Output.VideoURL)
-
- // 错误处理
- if aliResp.Code != "" {
- openAIResp.Error = &dto.OpenAIVideoError{
- Code: aliResp.Code,
- Message: aliResp.Message,
- }
- } else if aliResp.Output.Code != "" {
- openAIResp.Error = &dto.OpenAIVideoError{
- Code: aliResp.Output.Code,
- Message: aliResp.Output.Message,
- }
- }
-
- return common.Marshal(openAIResp)
-}
-
-func convertAliStatus(aliStatus string) string {
- switch aliStatus {
- case "PENDING":
- return dto.VideoStatusQueued
- case "RUNNING":
- return dto.VideoStatusInProgress
- case "SUCCEEDED":
- return dto.VideoStatusCompleted
- case "FAILED", "CANCELED", "UNKNOWN":
- return dto.VideoStatusFailed
- default:
- return dto.VideoStatusUnknown
- }
-}
diff --git a/relay/channel/task/ali/adaptor_test.go b/relay/channel/task/ali/adaptor_test.go
deleted file mode 100644
index a7c414bfabf1..000000000000
--- a/relay/channel/task/ali/adaptor_test.go
+++ /dev/null
@@ -1,172 +0,0 @@
-package ali
-
-import (
- "strings"
- "testing"
-
- "github.com/QuantumNous/new-api/common"
- relaycommon "github.com/QuantumNous/new-api/relay/common"
- "github.com/stretchr/testify/require"
-)
-
-func testRelayInfo() *relaycommon.RelayInfo {
- return &relaycommon.RelayInfo{
- ChannelMeta: &relaycommon.ChannelMeta{},
- }
-}
-
-func TestConvertToAliRequestWan27I2VBuildsMediaFromImage(t *testing.T) {
- adaptor := &TaskAdaptor{}
- req := relaycommon.TaskSubmitReq{
- Model: "wan2.7-i2v",
- Prompt: "animate the first frame",
- Image: "https://example.com/first.png",
- Size: "720p",
- Duration: 10,
- }
-
- aliReq, err := adaptor.convertToAliRequest(testRelayInfo(), req)
-
- require.NoError(t, err)
- require.Equal(t, "wan2.7-i2v", aliReq.Model)
- require.Equal(t, "720P", aliReq.Parameters.Resolution)
- require.Equal(t, 10, aliReq.Parameters.Duration)
- require.Equal(t, []AliVideoMedia{
- {Type: "first_frame", URL: "https://example.com/first.png"},
- }, aliReq.Input.Media)
- require.Empty(t, aliReq.Input.ImgURL)
-
- body, err := common.Marshal(aliReq)
- require.NoError(t, err)
- require.Contains(t, string(body), `"media"`)
- require.NotContains(t, string(body), `"img_url"`)
-}
-
-func TestConvertToAliRequestWan27I2VBuildsFirstAndLastFrameFromImages(t *testing.T) {
- adaptor := &TaskAdaptor{}
- req := relaycommon.TaskSubmitReq{
- Model: "wan2.7-i2v",
- Prompt: "interpolate between frames",
- Images: []string{
- "https://example.com/first.png",
- "https://example.com/last.png",
- },
- }
-
- aliReq, err := adaptor.convertToAliRequest(testRelayInfo(), req)
-
- require.NoError(t, err)
- require.Equal(t, []AliVideoMedia{
- {Type: "first_frame", URL: "https://example.com/first.png"},
- {Type: "last_frame", URL: "https://example.com/last.png"},
- }, aliReq.Input.Media)
-}
-
-func TestConvertToAliRequestWan27I2VPrefersImageBeforeImagesAndInputReference(t *testing.T) {
- adaptor := &TaskAdaptor{}
- req := relaycommon.TaskSubmitReq{
- Model: "wan2.7-i2v",
- Prompt: "use the direct image",
- Image: " https://example.com/direct.png ",
- Images: []string{"https://example.com/images-first.png", " https://example.com/images-last.png "},
- InputReference: "https://example.com/input-reference.png",
- }
-
- aliReq, err := adaptor.convertToAliRequest(testRelayInfo(), req)
-
- require.NoError(t, err)
- require.Equal(t, []AliVideoMedia{
- {Type: "first_frame", URL: "https://example.com/direct.png"},
- {Type: "last_frame", URL: "https://example.com/images-last.png"},
- }, aliReq.Input.Media)
-}
-
-func TestConvertToAliRequestWan27I2VFallsBackToFirstNonEmptyImage(t *testing.T) {
- adaptor := &TaskAdaptor{}
- req := relaycommon.TaskSubmitReq{
- Model: "wan2.7-i2v",
- Prompt: "skip blank images",
- Image: " ",
- Images: []string{
- " ",
- " https://example.com/first.png ",
- " https://example.com/last.png ",
- },
- InputReference: "https://example.com/input-reference.png",
- }
-
- aliReq, err := adaptor.convertToAliRequest(testRelayInfo(), req)
-
- require.NoError(t, err)
- require.Equal(t, []AliVideoMedia{
- {Type: "first_frame", URL: "https://example.com/first.png"},
- {Type: "last_frame", URL: "https://example.com/last.png"},
- }, aliReq.Input.Media)
-}
-
-func TestConvertToAliRequestWan27I2VKeepsExplicitMetadataMedia(t *testing.T) {
- adaptor := &TaskAdaptor{}
- req := relaycommon.TaskSubmitReq{
- Model: "wan2.7-i2v",
- Prompt: "continue the clip",
- Image: "https://example.com/direct.png",
- Images: []string{"https://example.com/images-first.png", "https://example.com/images-last.png"},
- InputReference: "https://example.com/input-reference.png",
- Metadata: map[string]interface{}{
- "input": map[string]interface{}{
- "media": []interface{}{
- map[string]interface{}{
- "type": "first_clip",
- "url": "https://example.com/input.mp4",
- },
- },
- },
- },
- }
-
- aliReq, err := adaptor.convertToAliRequest(testRelayInfo(), req)
-
- require.NoError(t, err)
- require.Equal(t, []AliVideoMedia{
- {Type: "first_clip", URL: "https://example.com/input.mp4"},
- }, aliReq.Input.Media)
- require.Empty(t, aliReq.Input.ImgURL)
-
- body, err := common.Marshal(aliReq)
- require.NoError(t, err)
- require.Contains(t, string(body), `"media"`)
- require.NotContains(t, string(body), `"img_url"`)
-}
-
-func TestConvertToAliRequestWan27I2VRequiresMedia(t *testing.T) {
- adaptor := &TaskAdaptor{}
- req := relaycommon.TaskSubmitReq{
- Model: "wan2.7-i2v",
- Prompt: "animate without a frame",
- }
-
- _, err := adaptor.convertToAliRequest(testRelayInfo(), req)
-
- require.Error(t, err)
- require.True(t, strings.Contains(err.Error(), "requires image"))
-}
-
-func TestConvertToAliRequestWan25I2VKeepsLegacyImgURL(t *testing.T) {
- adaptor := &TaskAdaptor{}
- req := relaycommon.TaskSubmitReq{
- Model: "wan2.5-i2v-preview",
- Prompt: "animate the first frame",
- Image: "https://example.com/first.png",
- }
-
- aliReq, err := adaptor.convertToAliRequest(testRelayInfo(), req)
-
- require.NoError(t, err)
- require.Equal(t, "https://example.com/first.png", aliReq.Input.ImgURL)
- require.Empty(t, aliReq.Input.Media)
-
- body, err := common.Marshal(aliReq)
- require.NoError(t, err)
- require.Contains(t, string(body), `"img_url"`)
- require.NotContains(t, string(body), `"media"`)
-}
diff --git a/relay/channel/task/ali/constants.go b/relay/channel/task/ali/constants.go
deleted file mode 100644
index 349f656058e0..000000000000
--- a/relay/channel/task/ali/constants.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package ali
-
-var ModelList = []string{
- "wan2.7-i2v", // 万相2.7图生视频(新input.media协议)
- "wan2.7-t2v", // 万相2.7文生视频
- "wan2.5-i2v-preview", // 万相2.5 preview(有声视频)推荐
- "wan2.2-i2v-flash", // 万相2.2极速版(无声视频)
- "wan2.2-i2v-plus", // 万相2.2专业版(无声视频)
- "wanx2.1-i2v-plus", // 万相2.1专业版(无声视频)
- "wanx2.1-i2v-turbo", // 万相2.1极速版(无声视频)
-}
-
-var ChannelName = "ali"
diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go
deleted file mode 100644
index 69302a676290..000000000000
--- a/relay/channel/task/doubao/adaptor.go
+++ /dev/null
@@ -1,372 +0,0 @@
-package doubao
-
-import (
- "bytes"
- "fmt"
- "io"
- "net/http"
- "strconv"
- "time"
-
- "github.com/QuantumNous/new-api/common"
-
- "github.com/QuantumNous/new-api/constant"
- taskdto "github.com/QuantumNous/new-api/dto"
- "github.com/QuantumNous/new-api/model"
- "github.com/QuantumNous/new-api/relay/channel"
- "github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
- relaycommon "github.com/QuantumNous/new-api/relay/common"
- "github.com/QuantumNous/new-api/relaykit/dto"
- "github.com/QuantumNous/new-api/service"
-
- "github.com/gin-gonic/gin"
- "github.com/pkg/errors"
- "github.com/samber/lo"
-)
-
-// ============================
-// Request / Response structures
-// ============================
-
-type ContentItem struct {
- Type string `json:"type,omitempty"`
- Text string `json:"text,omitempty"`
- ImageURL *MediaURL `json:"image_url,omitempty"`
- VideoURL *MediaURL `json:"video_url,omitempty"`
- AudioURL *MediaURL `json:"audio_url,omitempty"`
- Role string `json:"role,omitempty"`
-}
-
-type MediaURL struct {
- URL string `json:"url,omitempty"`
-}
-
-type requestPayload struct {
- Model string `json:"model"`
- Content []ContentItem `json:"content,omitempty"`
- CallbackURL string `json:"callback_url,omitempty"`
- ReturnLastFrame *dto.BoolValue `json:"return_last_frame,omitempty"`
- ServiceTier string `json:"service_tier,omitempty"`
- ExecutionExpiresAfter *dto.IntValue `json:"execution_expires_after,omitempty"`
- GenerateAudio *dto.BoolValue `json:"generate_audio,omitempty"`
- Draft *dto.BoolValue `json:"draft,omitempty"`
- Tools []struct {
- Type string `json:"type,omitempty"`
- } `json:"tools,omitempty"`
- SafetyIdentifier string `json:"safety_identifier,omitempty"`
- Priority *dto.IntValue `json:"priority,omitempty"`
- Resolution string `json:"resolution,omitempty"`
- Ratio string `json:"ratio,omitempty"`
- Duration *dto.IntValue `json:"duration,omitempty"`
- Frames *dto.IntValue `json:"frames,omitempty"`
- Seed *dto.IntValue `json:"seed,omitempty"`
- CameraFixed *dto.BoolValue `json:"camera_fixed,omitempty"`
- Watermark *dto.BoolValue `json:"watermark,omitempty"`
-}
-
-type responsePayload struct {
- ID string `json:"id"` // task_id
-}
-
-type responseTask struct {
- ID string `json:"id"`
- Model string `json:"model"`
- Status string `json:"status"`
- Content struct {
- VideoURL string `json:"video_url"`
- } `json:"content"`
- Seed int `json:"seed"`
- Resolution string `json:"resolution"`
- Duration int `json:"duration"`
- Ratio string `json:"ratio"`
- FramesPerSecond int `json:"framespersecond"`
- ServiceTier string `json:"service_tier"`
- Tools []struct {
- Type string `json:"type"`
- } `json:"tools"`
- Usage struct {
- CompletionTokens int `json:"completion_tokens"`
- TotalTokens int `json:"total_tokens"`
- ToolUsage struct {
- WebSearch int `json:"web_search"`
- } `json:"tool_usage"`
- } `json:"usage"`
- Error struct {
- Code string `json:"code"`
- Message string `json:"message"`
- } `json:"error"`
- CreatedAt int64 `json:"created_at"`
- UpdatedAt int64 `json:"updated_at"`
-}
-
-// ============================
-// Adaptor implementation
-// ============================
-
-type TaskAdaptor struct {
- taskcommon.BaseBilling
- ChannelType int
- apiKey string
- baseURL string
-}
-
-func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
- a.ChannelType = info.ChannelType
- a.baseURL = info.ChannelBaseUrl
- a.apiKey = info.ApiKey
-}
-
-// ValidateRequestAndSetAction parses body, validates fields and sets default action.
-func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *taskdto.TaskError) {
- // Accept only POST /v1/video/generations as "generate" action.
- return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate)
-}
-
-// BuildRequestURL constructs the upstream URL.
-func (a *TaskAdaptor) BuildRequestURL(_ *relaycommon.RelayInfo) (string, error) {
- return fmt.Sprintf("%s/api/v3/contents/generations/tasks", a.baseURL), nil
-}
-
-// BuildRequestHeader sets required headers.
-func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *relaycommon.RelayInfo) error {
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Accept", "application/json")
- req.Header.Set("Authorization", "Bearer "+a.apiKey)
- return nil
-}
-
-// EstimateBilling 根据请求 metadata 中的输出分辨率与是否包含视频输入,返回相对基准价的计费 OtherRatio。
-func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 {
- req, err := relaycommon.GetTaskRequest(c)
- if err != nil {
- return nil
- }
- hasVideo := hasVideoInMetadata(req.Metadata)
- resolution, _ := req.Metadata["resolution"].(string)
- ratio, ok := GetVideoInputRatio(info.OriginModelName, resolution, hasVideo)
- if !ok || ratio == 1.0 {
- return nil
- }
- return map[string]float64{"video_input": ratio}
-}
-
-// hasVideoInMetadata 直接检查 metadata 的 content 数组是否包含 video_url 条目,
-// 避免构建完整的上游 requestPayload。
-func hasVideoInMetadata(metadata map[string]interface{}) bool {
- if metadata == nil {
- return false
- }
- contentRaw, ok := metadata["content"]
- if !ok {
- return false
- }
- contentSlice, ok := contentRaw.([]interface{})
- if !ok {
- return false
- }
- for _, item := range contentSlice {
- itemMap, ok := item.(map[string]interface{})
- if !ok {
- continue
- }
- if itemMap["type"] == "video_url" {
- return true
- }
- if _, has := itemMap["video_url"]; has {
- return true
- }
- }
- return false
-}
-
-// BuildRequestBody converts request into Doubao specific format.
-func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
- req, err := relaycommon.GetTaskRequest(c)
- if err != nil {
- return nil, err
- }
-
- body, err := a.convertToRequestPayload(&req)
- if err != nil {
- return nil, errors.Wrap(err, "convert request payload failed")
- }
- if info.IsModelMapped {
- body.Model = info.UpstreamModelName
- } else {
- info.UpstreamModelName = body.Model
- }
- data, err := common.Marshal(body)
- if err != nil {
- return nil, err
- }
- return bytes.NewReader(data), nil
-}
-
-// DoRequest delegates to common helper.
-func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
- return channel.DoTaskApiRequest(a, c, info, requestBody)
-}
-
-// DoResponse handles upstream response, returns taskID etc.
-func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *taskdto.TaskError) {
- responseBody, err := io.ReadAll(resp.Body)
- if err != nil {
- taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
- return
- }
- _ = resp.Body.Close()
-
- // Parse Doubao response
- var dResp responsePayload
- if err := common.Unmarshal(responseBody, &dResp); err != nil {
- taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError)
- return
- }
-
- if dResp.ID == "" {
- taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError)
- return
- }
-
- ov := dto.NewOpenAIVideo()
- ov.ID = info.PublicTaskID
- ov.TaskID = info.PublicTaskID
- ov.CreatedAt = time.Now().Unix()
- ov.Model = info.OriginModelName
-
- c.JSON(http.StatusOK, ov)
- return dResp.ID, responseBody, nil
-}
-
-// FetchTask fetch task status
-func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) {
- taskID, ok := body["task_id"].(string)
- if !ok {
- return nil, fmt.Errorf("invalid task_id")
- }
-
- uri := fmt.Sprintf("%s/api/v3/contents/generations/tasks/%s", baseUrl, taskID)
-
- req, err := http.NewRequest(http.MethodGet, uri, nil)
- if err != nil {
- return nil, err
- }
-
- req.Header.Set("Accept", "application/json")
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Authorization", "Bearer "+key)
-
- client, err := service.GetHttpClientWithProxy(proxy)
- if err != nil {
- return nil, fmt.Errorf("new proxy http client failed: %w", err)
- }
- return client.Do(req)
-}
-
-func (a *TaskAdaptor) GetModelList() []string {
- return ModelList
-}
-
-func (a *TaskAdaptor) GetChannelName() string {
- return ChannelName
-}
-
-func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) {
- r := requestPayload{
- Model: req.Model,
- Content: []ContentItem{},
- }
-
- // Add images if present
- if req.HasImage() {
- for _, imgURL := range req.Images {
- r.Content = append(r.Content, ContentItem{
- Type: "image_url",
- ImageURL: &MediaURL{
- URL: imgURL,
- },
- })
- }
- }
-
- metadata := req.Metadata
- if err := taskcommon.UnmarshalMetadata(metadata, &r); err != nil {
- return nil, errors.Wrap(err, "unmarshal metadata failed")
- }
-
- if sec, _ := strconv.Atoi(req.Seconds); sec > 0 {
- r.Duration = lo.ToPtr(dto.IntValue(sec))
- }
-
- r.Content = lo.Reject(r.Content, func(c ContentItem, _ int) bool { return c.Type == "text" })
- r.Content = append(r.Content, ContentItem{
- Type: "text",
- Text: req.Prompt,
- })
-
- return &r, nil
-}
-
-func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
- resTask := responseTask{}
- if err := common.Unmarshal(respBody, &resTask); err != nil {
- return nil, errors.Wrap(err, "unmarshal task result failed")
- }
-
- taskResult := relaycommon.TaskInfo{
- Code: 0,
- }
-
- // Map Doubao status to internal status
- switch resTask.Status {
- case "pending", "queued":
- taskResult.Status = model.TaskStatusQueued
- taskResult.Progress = "10%"
- case "processing", "running":
- taskResult.Status = model.TaskStatusInProgress
- taskResult.Progress = "50%"
- case "succeeded":
- taskResult.Status = model.TaskStatusSuccess
- taskResult.Progress = "100%"
- taskResult.Url = resTask.Content.VideoURL
- // 解析 usage 信息用于按倍率计费
- taskResult.CompletionTokens = resTask.Usage.CompletionTokens
- taskResult.TotalTokens = resTask.Usage.TotalTokens
- case "failed":
- taskResult.Status = model.TaskStatusFailure
- taskResult.Progress = "100%"
- taskResult.Reason = resTask.Error.Message
- default:
- // Unknown status, treat as processing
- taskResult.Status = model.TaskStatusInProgress
- taskResult.Progress = "30%"
- }
-
- return &taskResult, nil
-}
-
-func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) {
- var dResp responseTask
- if err := common.Unmarshal(originTask.Data, &dResp); err != nil {
- return nil, errors.Wrap(err, "unmarshal doubao task data failed")
- }
-
- openAIVideo := dto.NewOpenAIVideo()
- openAIVideo.ID = originTask.TaskID
- openAIVideo.TaskID = originTask.TaskID
- openAIVideo.Status = originTask.Status.ToVideoStatus()
- openAIVideo.SetProgressStr(originTask.Progress)
- openAIVideo.SetMetadata("url", dResp.Content.VideoURL)
- openAIVideo.CreatedAt = originTask.CreatedAt
- openAIVideo.CompletedAt = originTask.UpdatedAt
- openAIVideo.Model = originTask.Properties.OriginModelName
-
- if dResp.Status == "failed" {
- openAIVideo.Error = &dto.OpenAIVideoError{
- Message: dResp.Error.Message,
- Code: dResp.Error.Code,
- }
- }
-
- return common.Marshal(openAIVideo)
-}
diff --git a/relay/channel/task/doubao/constants.go b/relay/channel/task/doubao/constants.go
deleted file mode 100644
index a2035fe2547c..000000000000
--- a/relay/channel/task/doubao/constants.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package doubao
-
-import "strings"
-
-var ModelList = []string{
- "doubao-seedance-1-0-pro-250528",
- "doubao-seedance-1-0-lite-t2v",
- "doubao-seedance-1-0-lite-i2v",
- "doubao-seedance-1-5-pro-251215",
- "doubao-seedance-2-0-260128",
- "doubao-seedance-2-0-fast-260128",
-}
-
-var ChannelName = "doubao-video"
-
-// videoPriceKey 价格表的键:输出分辨率档(is1080p/is4k 均为 false 即 480p/720p 基准档)、输入是否含视频。
-type videoPriceKey struct {
- is1080p bool
- is4k bool
- hasVideo bool
-}
-
-// videoPriceTable 各模型在不同 (输出分辨率档, 是否含视频输入) 下的单价(元/百万 token)。
-// 其中零值键 {480p/720p, 不含视频} 为基准价,等于管理员应配置的 ModelRatio;
-// 计费时取 实际单价/基准价 作为 OtherRatio。
-var videoPriceTable = map[string]map[videoPriceKey]float64{
- "doubao-seedance-2-0-260128": {
- {hasVideo: false}: 46.0,
- {hasVideo: true}: 28.0,
- {is1080p: true, hasVideo: false}: 51.0,
- {is1080p: true, hasVideo: true}: 31.0,
- {is4k: true, hasVideo: false}: 26.0,
- {is4k: true, hasVideo: true}: 16.0,
- },
- "doubao-seedance-2-0-fast-260128": {
- {hasVideo: false}: 37.0,
- {hasVideo: true}: 22.0,
- },
-}
-
-// GetVideoInputRatio 返回指定模型在给定输出分辨率/是否含视频输入下,相对基准价的计费倍率。
-// 第二个返回值表示该模型是否配置了价格表;倍率为 1.0 时调用方可忽略该 OtherRatio。
-func GetVideoInputRatio(modelName, resolution string, hasVideo bool) (float64, bool) {
- prices, ok := videoPriceTable[modelName]
- base := prices[videoPriceKey{}] // 零值键 = {480p/720p, 不含视频} 基准价
- if !ok || base <= 0 {
- return 0, false
- }
- res := strings.ToLower(strings.TrimSpace(resolution))
- price, ok := prices[videoPriceKey{is1080p: res == "1080p", is4k: res == "4k", hasVideo: hasVideo}]
- if !ok {
- // 未配置的组合(如 fast 无 1080p/4k,上游会自行报错)按基准价计费即可。
- return 1.0, true
- }
- return price / base, true
-}
diff --git a/relay/channel/task/gemini/adaptor.go b/relay/channel/task/gemini/adaptor.go
deleted file mode 100644
index 2b1bf8ed2576..000000000000
--- a/relay/channel/task/gemini/adaptor.go
+++ /dev/null
@@ -1,293 +0,0 @@
-package gemini
-
-import (
- "bytes"
- "fmt"
- "io"
- "net/http"
- "regexp"
- "strings"
- "time"
-
- "github.com/QuantumNous/new-api/common"
- "github.com/QuantumNous/new-api/constant"
- taskdto "github.com/QuantumNous/new-api/dto"
- "github.com/QuantumNous/new-api/model"
- "github.com/QuantumNous/new-api/relay/channel"
- taskcommon "github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
- relaycommon "github.com/QuantumNous/new-api/relay/common"
- "github.com/QuantumNous/new-api/relaykit/dto"
- "github.com/QuantumNous/new-api/service"
- "github.com/QuantumNous/new-api/setting/model_setting"
- "github.com/gin-gonic/gin"
- "github.com/pkg/errors"
-)
-
-// ============================
-// Adaptor implementation
-// ============================
-
-type TaskAdaptor struct {
- taskcommon.BaseBilling
- ChannelType int
- apiKey string
- baseURL string
-}
-
-func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
- a.ChannelType = info.ChannelType
- a.baseURL = info.ChannelBaseUrl
- a.apiKey = info.ApiKey
-}
-
-// ValidateRequestAndSetAction parses body, validates fields and sets default action.
-func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *taskdto.TaskError) {
- return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionTextGenerate)
-}
-
-// BuildRequestURL constructs the Gemini API predictLongRunning endpoint for Veo.
-func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
- modelName := info.UpstreamModelName
- version := model_setting.GetGeminiVersionSetting(modelName)
-
- return fmt.Sprintf(
- "%s/%s/models/%s:predictLongRunning",
- a.baseURL,
- version,
- modelName,
- ), nil
-}
-
-// BuildRequestHeader sets required headers.
-func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Accept", "application/json")
- req.Header.Set("x-goog-api-key", a.apiKey)
- return nil
-}
-
-// BuildRequestBody converts request into the Veo predictLongRunning format.
-func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
- v, ok := c.Get("task_request")
- if !ok {
- return nil, fmt.Errorf("request not found in context")
- }
- req, ok := v.(relaycommon.TaskSubmitReq)
- if !ok {
- return nil, fmt.Errorf("unexpected task_request type")
- }
-
- instance := VeoInstance{Prompt: req.Prompt}
- if img := ExtractMultipartImage(c, info); img != nil {
- instance.Image = img
- } else if len(req.Images) > 0 {
- if parsed := ParseImageInput(req.Images[0]); parsed != nil {
- instance.Image = parsed
- info.Action = constant.TaskActionGenerate
- }
- }
-
- params := &VeoParameters{}
- if err := taskcommon.UnmarshalMetadata(req.Metadata, params); err != nil {
- return nil, errors.Wrap(err, "unmarshal metadata failed")
- }
- if params.DurationSeconds == 0 && req.Duration > 0 {
- params.DurationSeconds = req.Duration
- }
- if params.Resolution == "" && req.Size != "" {
- params.Resolution = SizeToVeoResolution(req.Size)
- }
- if params.AspectRatio == "" && req.Size != "" {
- params.AspectRatio = SizeToVeoAspectRatio(req.Size)
- }
- params.Resolution = strings.ToLower(params.Resolution)
- params.SampleCount = 1
-
- body := VeoRequestPayload{
- Instances: []VeoInstance{instance},
- Parameters: params,
- }
-
- data, err := common.Marshal(body)
- if err != nil {
- return nil, err
- }
- return bytes.NewReader(data), nil
-}
-
-// DoRequest delegates to common helper.
-func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
- return channel.DoTaskApiRequest(a, c, info, requestBody)
-}
-
-// DoResponse handles upstream response, returns taskID etc.
-func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *taskdto.TaskError) {
- responseBody, err := io.ReadAll(resp.Body)
- if err != nil {
- return "", nil, service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
- }
- _ = resp.Body.Close()
-
- var s submitResponse
- if err := common.Unmarshal(responseBody, &s); err != nil {
- return "", nil, service.TaskErrorWrapper(err, "unmarshal_response_failed", http.StatusInternalServerError)
- }
- if strings.TrimSpace(s.Name) == "" {
- return "", nil, service.TaskErrorWrapper(fmt.Errorf("missing operation name"), "invalid_response", http.StatusInternalServerError)
- }
- taskID = taskcommon.EncodeLocalTaskID(s.Name)
- ov := dto.NewOpenAIVideo()
- ov.ID = info.PublicTaskID
- ov.TaskID = info.PublicTaskID
- ov.CreatedAt = time.Now().Unix()
- ov.Model = info.OriginModelName
- c.JSON(http.StatusOK, ov)
- return taskID, responseBody, nil
-}
-
-func (a *TaskAdaptor) GetModelList() []string {
- return []string{
- "veo-3.0-generate-001",
- "veo-3.0-fast-generate-001",
- "veo-3.1-generate-preview",
- "veo-3.1-fast-generate-preview",
- }
-}
-
-func (a *TaskAdaptor) GetChannelName() string {
- return "gemini"
-}
-
-// EstimateBilling returns OtherRatios based on durationSeconds and resolution.
-func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 {
- v, ok := c.Get("task_request")
- if !ok {
- return nil
- }
- req, ok := v.(relaycommon.TaskSubmitReq)
- if !ok {
- return nil
- }
-
- seconds := ResolveVeoDuration(req.Metadata, req.Duration, req.Seconds)
- resolution := ResolveVeoResolution(req.Metadata, req.Size)
- resRatio := VeoResolutionRatio(info.UpstreamModelName, resolution)
-
- return map[string]float64{
- "seconds": float64(seconds),
- "resolution": resRatio,
- }
-}
-
-// FetchTask polls task status via the Gemini operations GET endpoint.
-func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) {
- taskID, ok := body["task_id"].(string)
- if !ok {
- return nil, fmt.Errorf("invalid task_id")
- }
-
- upstreamName, err := taskcommon.DecodeLocalTaskID(taskID)
- if err != nil {
- return nil, fmt.Errorf("decode task_id failed: %w", err)
- }
-
- version := model_setting.GetGeminiVersionSetting("default")
- url := fmt.Sprintf("%s/%s/%s", baseUrl, version, upstreamName)
-
- req, err := http.NewRequest(http.MethodGet, url, nil)
- if err != nil {
- return nil, err
- }
-
- req.Header.Set("Accept", "application/json")
- req.Header.Set("x-goog-api-key", key)
-
- client, err := service.GetHttpClientWithProxy(proxy)
- if err != nil {
- return nil, fmt.Errorf("new proxy http client failed: %w", err)
- }
- return client.Do(req)
-}
-
-func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
- var op operationResponse
- if err := common.Unmarshal(respBody, &op); err != nil {
- return nil, fmt.Errorf("unmarshal operation response failed: %w", err)
- }
-
- ti := &relaycommon.TaskInfo{}
-
- if op.Error.Message != "" {
- ti.Status = model.TaskStatusFailure
- ti.Reason = op.Error.Message
- ti.Progress = "100%"
- return ti, nil
- }
-
- if !op.Done {
- ti.Status = model.TaskStatusInProgress
- ti.Progress = "50%"
- return ti, nil
- }
-
- ti.Status = model.TaskStatusSuccess
- ti.Progress = "100%"
-
- ti.TaskID = taskcommon.EncodeLocalTaskID(op.Name)
-
- if len(op.Response.GenerateVideoResponse.GeneratedVideos) > 0 {
- if uri := op.Response.GenerateVideoResponse.GeneratedVideos[0].Video.URI; uri != "" {
- ti.RemoteUrl = uri
- }
- }
-
- return ti, nil
-}
-
-func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) ([]byte, error) {
- upstreamTaskID := task.GetUpstreamTaskID()
- upstreamName, err := taskcommon.DecodeLocalTaskID(upstreamTaskID)
- if err != nil {
- upstreamName = ""
- }
- modelName := extractModelFromOperationName(upstreamName)
- if strings.TrimSpace(modelName) == "" {
- modelName = "veo-3.0-generate-001"
- }
-
- video := dto.NewOpenAIVideo()
- video.ID = task.TaskID
- video.Model = modelName
- video.Status = task.Status.ToVideoStatus()
- video.SetProgressStr(task.Progress)
- video.CreatedAt = task.CreatedAt
- if task.FinishTime > 0 {
- video.CompletedAt = task.FinishTime
- } else if task.UpdatedAt > 0 {
- video.CompletedAt = task.UpdatedAt
- }
-
- return common.Marshal(video)
-}
-
-// ============================
-// helpers
-// ============================
-
-var modelRe = regexp.MustCompile(`models/([^/]+)/operations/`)
-
-func extractModelFromOperationName(name string) string {
- if name == "" {
- return ""
- }
- if m := modelRe.FindStringSubmatch(name); len(m) == 2 {
- return m[1]
- }
- if idx := strings.Index(name, "models/"); idx >= 0 {
- s := name[idx+len("models/"):]
- if p := strings.Index(s, "/operations/"); p > 0 {
- return s[:p]
- }
- }
- return ""
-}
diff --git a/relay/channel/task/gemini/billing.go b/relay/channel/task/gemini/billing.go
deleted file mode 100644
index d3b981b909f1..000000000000
--- a/relay/channel/task/gemini/billing.go
+++ /dev/null
@@ -1,142 +0,0 @@
-package gemini
-
-import (
- "strconv"
- "strings"
-
- relaycommon "github.com/QuantumNous/new-api/relay/common"
-)
-
-// ParseVeoDurationSeconds extracts durationSeconds from metadata.
-// Returns 8 (Veo default) when not specified or invalid.
-func ParseVeoDurationSeconds(metadata map[string]any) int {
- if metadata == nil {
- return 8
- }
- v, ok := metadata["durationSeconds"]
- if !ok {
- return 8
- }
- switch n := v.(type) {
- case float64:
- if int(n) > 0 {
- return int(n)
- }
- case int:
- if n > 0 {
- return n
- }
- }
- return 8
-}
-
-// ParseVeoResolution extracts resolution from metadata.
-// Returns "720p" when not specified.
-func ParseVeoResolution(metadata map[string]any) string {
- if metadata == nil {
- return "720p"
- }
- v, ok := metadata["resolution"]
- if !ok {
- return "720p"
- }
- if s, ok := v.(string); ok && s != "" {
- return strings.ToLower(s)
- }
- return "720p"
-}
-
-// ResolveVeoDuration returns the effective duration in seconds.
-// Priority: metadata["durationSeconds"] > stdDuration > stdSeconds > default (8).
-// The result is capped because it is used as a billing multiplier and the
-// metadata path bypasses standard request validation.
-func ResolveVeoDuration(metadata map[string]any, stdDuration int, stdSeconds string) int {
- if metadata != nil {
- if _, exists := metadata["durationSeconds"]; exists {
- if d := ParseVeoDurationSeconds(metadata); d > 0 {
- return min(d, relaycommon.MaxTaskDurationSeconds)
- }
- }
- }
- if stdDuration > 0 {
- return min(stdDuration, relaycommon.MaxTaskDurationSeconds)
- }
- if s, err := strconv.Atoi(stdSeconds); err == nil && s > 0 {
- return min(s, relaycommon.MaxTaskDurationSeconds)
- }
- return 8
-}
-
-// ResolveVeoResolution returns the effective resolution string (lowercase).
-// Priority: metadata["resolution"] > SizeToVeoResolution(stdSize) > default ("720p").
-func ResolveVeoResolution(metadata map[string]any, stdSize string) string {
- if metadata != nil {
- if _, exists := metadata["resolution"]; exists {
- if r := ParseVeoResolution(metadata); r != "" {
- return r
- }
- }
- }
- if stdSize != "" {
- return SizeToVeoResolution(stdSize)
- }
- return "720p"
-}
-
-// SizeToVeoResolution converts a "WxH" size string to a Veo resolution label.
-func SizeToVeoResolution(size string) string {
- parts := strings.SplitN(strings.ToLower(size), "x", 2)
- if len(parts) != 2 {
- return "720p"
- }
- w, _ := strconv.Atoi(parts[0])
- h, _ := strconv.Atoi(parts[1])
- maxDim := w
- if h > maxDim {
- maxDim = h
- }
- if maxDim >= 3840 {
- return "4k"
- }
- if maxDim >= 1920 {
- return "1080p"
- }
- return "720p"
-}
-
-// SizeToVeoAspectRatio converts a "WxH" size string to a Veo aspect ratio.
-func SizeToVeoAspectRatio(size string) string {
- parts := strings.SplitN(strings.ToLower(size), "x", 2)
- if len(parts) != 2 {
- return "16:9"
- }
- w, _ := strconv.Atoi(parts[0])
- h, _ := strconv.Atoi(parts[1])
- if w <= 0 || h <= 0 {
- return "16:9"
- }
- if h > w {
- return "9:16"
- }
- return "16:9"
-}
-
-// VeoResolutionRatio returns the pricing multiplier for the given resolution.
-// Standard resolutions (720p, 1080p) return 1.0.
-// 4K returns a model-specific multiplier based on Google's official pricing.
-func VeoResolutionRatio(modelName, resolution string) float64 {
- if resolution != "4k" {
- return 1.0
- }
- // 4K multipliers derived from Vertex AI official pricing (video+audio base):
- // veo-3.1-generate: $0.60 / $0.40 = 1.5
- // veo-3.1-fast-generate: $0.35 / $0.15 ≈ 2.333
- // Veo 3.0 models do not support 4K; return 1.0 as fallback.
- if strings.Contains(modelName, "3.1-fast-generate") {
- return 2.333333
- }
- if strings.Contains(modelName, "3.1-generate") || strings.Contains(modelName, "3.1") {
- return 1.5
- }
- return 1.0
-}
diff --git a/relay/channel/task/gemini/dto.go b/relay/channel/task/gemini/dto.go
deleted file mode 100644
index 70a13feec4fa..000000000000
--- a/relay/channel/task/gemini/dto.go
+++ /dev/null
@@ -1,71 +0,0 @@
-package gemini
-
-// VeoImageInput represents an image input for Veo image-to-video.
-// Used by both Gemini and Vertex adaptors.
-type VeoImageInput struct {
- BytesBase64Encoded string `json:"bytesBase64Encoded"`
- MimeType string `json:"mimeType"`
-}
-
-// VeoInstance represents a single instance in the Veo predictLongRunning request.
-type VeoInstance struct {
- Prompt string `json:"prompt"`
- Image *VeoImageInput `json:"image,omitempty"`
- // TODO: support referenceImages (style/asset references, up to 3 images)
- // TODO: support lastFrame (first+last frame interpolation, Veo 3.1)
-}
-
-// VeoParameters represents the parameters block for Veo predictLongRunning.
-type VeoParameters struct {
- SampleCount int `json:"sampleCount"`
- DurationSeconds int `json:"durationSeconds,omitempty"`
- AspectRatio string `json:"aspectRatio,omitempty"`
- Resolution string `json:"resolution,omitempty"`
- NegativePrompt string `json:"negativePrompt,omitempty"`
- PersonGeneration string `json:"personGeneration,omitempty"`
- StorageUri string `json:"storageUri,omitempty"`
- CompressionQuality string `json:"compressionQuality,omitempty"`
- ResizeMode string `json:"resizeMode,omitempty"`
- Seed *int `json:"seed,omitempty"`
- GenerateAudio *bool `json:"generateAudio,omitempty"`
-}
-
-// VeoRequestPayload is the top-level request body for the Veo
-// predictLongRunning endpoint (used by both Gemini and Vertex).
-type VeoRequestPayload struct {
- Instances []VeoInstance `json:"instances"`
- Parameters *VeoParameters `json:"parameters,omitempty"`
-}
-
-type submitResponse struct {
- Name string `json:"name"`
-}
-
-type operationVideo struct {
- MimeType string `json:"mimeType"`
- BytesBase64Encoded string `json:"bytesBase64Encoded"`
- Encoding string `json:"encoding"`
-}
-
-type operationResponse struct {
- Name string `json:"name"`
- Done bool `json:"done"`
- Response struct {
- Type string `json:"@type"`
- RaiMediaFilteredCount int `json:"raiMediaFilteredCount"`
- Videos []operationVideo `json:"videos"`
- BytesBase64Encoded string `json:"bytesBase64Encoded"`
- Encoding string `json:"encoding"`
- Video string `json:"video"`
- GenerateVideoResponse struct {
- GeneratedVideos []struct {
- Video struct {
- URI string `json:"uri"`
- } `json:"video"`
- } `json:"generatedVideos"`
- } `json:"generateVideoResponse"`
- } `json:"response"`
- Error struct {
- Message string `json:"message"`
- } `json:"error"`
-}
diff --git a/relay/channel/task/gemini/image.go b/relay/channel/task/gemini/image.go
deleted file mode 100644
index da11b4721285..000000000000
--- a/relay/channel/task/gemini/image.go
+++ /dev/null
@@ -1,100 +0,0 @@
-package gemini
-
-import (
- "encoding/base64"
- "io"
- "net/http"
- "strings"
-
- "github.com/QuantumNous/new-api/constant"
- relaycommon "github.com/QuantumNous/new-api/relay/common"
- "github.com/gin-gonic/gin"
-)
-
-const maxVeoImageSize = 20 * 1024 * 1024 // 20 MB
-
-// ExtractMultipartImage reads the first `input_reference` file from a multipart
-// form upload and returns a VeoImageInput. Returns nil if no file is present.
-func ExtractMultipartImage(c *gin.Context, info *relaycommon.RelayInfo) *VeoImageInput {
- mf, err := c.MultipartForm()
- if err != nil {
- return nil
- }
- files, exists := mf.File["input_reference"]
- if !exists || len(files) == 0 {
- return nil
- }
- fh := files[0]
- if fh.Size > maxVeoImageSize {
- return nil
- }
- file, err := fh.Open()
- if err != nil {
- return nil
- }
- defer file.Close()
-
- fileBytes, err := io.ReadAll(file)
- if err != nil {
- return nil
- }
-
- mimeType := fh.Header.Get("Content-Type")
- if mimeType == "" || mimeType == "application/octet-stream" {
- mimeType = http.DetectContentType(fileBytes)
- }
-
- info.Action = constant.TaskActionGenerate
- return &VeoImageInput{
- BytesBase64Encoded: base64.StdEncoding.EncodeToString(fileBytes),
- MimeType: mimeType,
- }
-}
-
-// ParseImageInput parses an image string (data URI or raw base64) into a
-// VeoImageInput. Returns nil if the input is empty or invalid.
-// TODO: support downloading HTTP URL images and converting to base64
-func ParseImageInput(imageStr string) *VeoImageInput {
- imageStr = strings.TrimSpace(imageStr)
- if imageStr == "" {
- return nil
- }
-
- if strings.HasPrefix(imageStr, "data:") {
- return parseDataURI(imageStr)
- }
-
- raw, err := base64.StdEncoding.DecodeString(imageStr)
- if err != nil {
- return nil
- }
- return &VeoImageInput{
- BytesBase64Encoded: imageStr,
- MimeType: http.DetectContentType(raw),
- }
-}
-
-func parseDataURI(uri string) *VeoImageInput {
- // data:image/png;base64,iVBOR...
- rest := uri[len("data:"):]
- idx := strings.Index(rest, ",")
- if idx < 0 {
- return nil
- }
- meta := rest[:idx]
- b64 := rest[idx+1:]
- if b64 == "" {
- return nil
- }
-
- mimeType := "application/octet-stream"
- parts := strings.SplitN(meta, ";", 2)
- if len(parts) >= 1 && parts[0] != "" {
- mimeType = parts[0]
- }
-
- return &VeoImageInput{
- BytesBase64Encoded: b64,
- MimeType: mimeType,
- }
-}
diff --git a/relay/channel/task/hailuo/adaptor.go b/relay/channel/task/hailuo/adaptor.go
deleted file mode 100644
index af9f5c57c52a..000000000000
--- a/relay/channel/task/hailuo/adaptor.go
+++ /dev/null
@@ -1,303 +0,0 @@
-package hailuo
-
-import (
- "bytes"
- "fmt"
- "io"
- "net/http"
- "strconv"
- "strings"
- "time"
-
- "github.com/QuantumNous/new-api/common"
- "github.com/QuantumNous/new-api/model"
- "github.com/gin-gonic/gin"
- "github.com/pkg/errors"
-
- "github.com/QuantumNous/new-api/constant"
- taskdto "github.com/QuantumNous/new-api/dto"
- "github.com/QuantumNous/new-api/relay/channel"
- taskcommon "github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
- relaycommon "github.com/QuantumNous/new-api/relay/common"
- "github.com/QuantumNous/new-api/relaykit/dto"
- "github.com/QuantumNous/new-api/service"
-)
-
-// https://platform.minimaxi.com/docs/api-reference/video-generation-intro
-type TaskAdaptor struct {
- taskcommon.BaseBilling
- ChannelType int
- apiKey string
- baseURL string
-}
-
-func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
- a.ChannelType = info.ChannelType
- a.baseURL = info.ChannelBaseUrl
- a.apiKey = info.ApiKey
-}
-
-func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *taskdto.TaskError) {
- return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate)
-}
-
-func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
- return fmt.Sprintf("%s%s", a.baseURL, TextToVideoEndpoint), nil
-}
-
-func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Accept", "application/json")
- req.Header.Set("Authorization", "Bearer "+a.apiKey)
- return nil
-}
-
-func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
- v, exists := c.Get("task_request")
- if !exists {
- return nil, fmt.Errorf("request not found in context")
- }
- req, ok := v.(relaycommon.TaskSubmitReq)
- if !ok {
- return nil, fmt.Errorf("invalid request type in context")
- }
-
- body, err := a.convertToRequestPayload(&req, info)
- if err != nil {
- return nil, errors.Wrap(err, "convert request payload failed")
- }
-
- data, err := common.Marshal(body)
- if err != nil {
- return nil, err
- }
-
- return bytes.NewReader(data), nil
-}
-
-func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
- return channel.DoTaskApiRequest(a, c, info, requestBody)
-}
-
-func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *taskdto.TaskError) {
- responseBody, err := io.ReadAll(resp.Body)
- if err != nil {
- taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
- return
- }
- _ = resp.Body.Close()
-
- var hResp VideoResponse
- if err := common.Unmarshal(responseBody, &hResp); err != nil {
- taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError)
- return
- }
-
- if hResp.BaseResp.StatusCode != StatusSuccess {
- taskErr = service.TaskErrorWrapper(
- fmt.Errorf("hailuo api error: %s", hResp.BaseResp.StatusMsg),
- strconv.Itoa(hResp.BaseResp.StatusCode),
- http.StatusBadRequest,
- )
- return
- }
-
- ov := dto.NewOpenAIVideo()
- ov.ID = info.PublicTaskID
- ov.TaskID = info.PublicTaskID
- ov.CreatedAt = time.Now().Unix()
- ov.Model = info.OriginModelName
-
- c.JSON(http.StatusOK, ov)
- return hResp.TaskID, responseBody, nil
-}
-
-func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) {
- taskID, ok := body["task_id"].(string)
- if !ok {
- return nil, fmt.Errorf("invalid task_id")
- }
-
- uri := fmt.Sprintf("%s%s?task_id=%s", baseUrl, QueryTaskEndpoint, taskID)
-
- req, err := http.NewRequest(http.MethodGet, uri, nil)
- if err != nil {
- return nil, err
- }
-
- req.Header.Set("Accept", "application/json")
- req.Header.Set("Authorization", "Bearer "+key)
-
- client, err := service.GetHttpClientWithProxy(proxy)
- if err != nil {
- return nil, fmt.Errorf("new proxy http client failed: %w", err)
- }
- return client.Do(req)
-}
-
-func (a *TaskAdaptor) GetModelList() []string {
- return ModelList
-}
-
-func (a *TaskAdaptor) GetChannelName() string {
- return ChannelName
-}
-
-func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq, info *relaycommon.RelayInfo) (*VideoRequest, error) {
- modelConfig := GetModelConfig(info.UpstreamModelName)
- duration := DefaultDuration
- if req.Duration > 0 {
- duration = req.Duration
- }
- resolution := modelConfig.DefaultResolution
- if req.Size != "" {
- resolution = a.parseResolutionFromSize(req.Size, modelConfig)
- }
-
- videoRequest := &VideoRequest{
- Model: info.UpstreamModelName,
- Prompt: req.Prompt,
- Duration: &duration,
- Resolution: resolution,
- }
- if err := req.UnmarshalMetadata(&videoRequest); err != nil {
- return nil, errors.Wrap(err, "unmarshal metadata to video request failed")
- }
-
- return videoRequest, nil
-}
-
-func (a *TaskAdaptor) parseResolutionFromSize(size string, modelConfig ModelConfig) string {
- switch {
- case strings.Contains(size, "1080"):
- return Resolution1080P
- case strings.Contains(size, "768"):
- return Resolution768P
- case strings.Contains(size, "720"):
- return Resolution720P
- case strings.Contains(size, "512"):
- return Resolution512P
- default:
- return modelConfig.DefaultResolution
- }
-}
-
-func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
- resTask := QueryTaskResponse{}
- if err := common.Unmarshal(respBody, &resTask); err != nil {
- return nil, errors.Wrap(err, "unmarshal task result failed")
- }
-
- taskResult := relaycommon.TaskInfo{}
-
- if resTask.BaseResp.StatusCode == StatusSuccess {
- taskResult.Code = 0
- } else {
- taskResult.Code = resTask.BaseResp.StatusCode
- taskResult.Reason = resTask.BaseResp.StatusMsg
- taskResult.Status = model.TaskStatusFailure
- taskResult.Progress = "100%"
- }
-
- switch resTask.Status {
- case TaskStatusPreparing, TaskStatusQueueing, TaskStatusProcessing:
- taskResult.Status = model.TaskStatusInProgress
- taskResult.Progress = "30%"
- if resTask.Status == TaskStatusProcessing {
- taskResult.Progress = "50%"
- }
- case TaskStatusSuccess:
- taskResult.Status = model.TaskStatusSuccess
- taskResult.Progress = "100%"
- taskResult.Url = a.buildVideoURL(resTask.TaskID, resTask.FileID)
- case TaskStatusFailed:
- taskResult.Status = model.TaskStatusFailure
- taskResult.Progress = "100%"
- if taskResult.Reason == "" {
- taskResult.Reason = "task failed"
- }
- default:
- taskResult.Status = model.TaskStatusInProgress
- taskResult.Progress = "30%"
- }
-
- return &taskResult, nil
-}
-
-func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) {
- var hailuoResp QueryTaskResponse
- if err := common.Unmarshal(originTask.Data, &hailuoResp); err != nil {
- return nil, errors.Wrap(err, "unmarshal hailuo task data failed")
- }
-
- openAIVideo := originTask.ToOpenAIVideo()
- if hailuoResp.BaseResp.StatusCode != StatusSuccess {
- openAIVideo.Error = &dto.OpenAIVideoError{
- Message: hailuoResp.BaseResp.StatusMsg,
- Code: strconv.Itoa(hailuoResp.BaseResp.StatusCode),
- }
- }
-
- jsonData, err := common.Marshal(openAIVideo)
- if err != nil {
- return nil, errors.Wrap(err, "marshal openai video failed")
- }
-
- return jsonData, nil
-}
-
-func (a *TaskAdaptor) buildVideoURL(_, fileID string) string {
- if a.apiKey == "" || a.baseURL == "" {
- return ""
- }
-
- url := fmt.Sprintf("%s/v1/files/retrieve?file_id=%s", a.baseURL, fileID)
-
- req, err := http.NewRequest(http.MethodGet, url, nil)
- if err != nil {
- return ""
- }
-
- req.Header.Set("Accept", "application/json")
- req.Header.Set("Authorization", "Bearer "+a.apiKey)
-
- resp, err := service.GetHttpClient().Do(req)
- if err != nil {
- return ""
- }
- defer resp.Body.Close()
-
- responseBody, err := io.ReadAll(resp.Body)
- if err != nil {
- return ""
- }
-
- var retrieveResp RetrieveFileResponse
- if err := common.Unmarshal(responseBody, &retrieveResp); err != nil {
- return ""
- }
-
- if retrieveResp.BaseResp.StatusCode != StatusSuccess {
- return ""
- }
-
- return retrieveResp.File.DownloadURL
-}
-
-func contains(slice []string, item string) bool {
- for _, s := range slice {
- if s == item {
- return true
- }
- }
- return false
-}
-
-func containsInt(slice []int, item int) bool {
- for _, s := range slice {
- if s == item {
- return true
- }
- }
- return false
-}
diff --git a/relay/channel/task/hailuo/constants.go b/relay/channel/task/hailuo/constants.go
deleted file mode 100644
index 5e54086374f9..000000000000
--- a/relay/channel/task/hailuo/constants.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package hailuo
-
-const (
- ChannelName = "hailuo-video"
-)
-
-var ModelList = []string{
- "MiniMax-Hailuo-2.3",
- "MiniMax-Hailuo-2.3-Fast",
- "MiniMax-Hailuo-02",
- "T2V-01-Director",
- "T2V-01",
- "I2V-01-Director",
- "I2V-01-live",
- "I2V-01",
- "S2V-01",
-}
-
-const (
- TextToVideoEndpoint = "/v1/video_generation"
- QueryTaskEndpoint = "/v1/query/video_generation"
-)
-
-const (
- StatusSuccess = 0
- StatusRateLimit = 1002
- StatusAuthFailed = 1004
- StatusNoBalance = 1008
- StatusSensitive = 1026
- StatusParamError = 2013
- StatusInvalidKey = 2049
-)
-
-const (
- TaskStatusPreparing = "Preparing"
- TaskStatusQueueing = "Queueing"
- TaskStatusProcessing = "Processing"
- TaskStatusSuccess = "Success"
- TaskStatusFailed = "Fail"
-)
-
-const (
- Resolution512P = "512P"
- Resolution720P = "720P"
- Resolution768P = "768P"
- Resolution1080P = "1080P"
-)
-
-const (
- DefaultDuration = 6
- DefaultResolution = Resolution720P
-)
diff --git a/relay/channel/task/hailuo/models.go b/relay/channel/task/hailuo/models.go
deleted file mode 100644
index 09a97766f15d..000000000000
--- a/relay/channel/task/hailuo/models.go
+++ /dev/null
@@ -1,170 +0,0 @@
-package hailuo
-
-type SubjectReference struct {
- Type string `json:"type"` // Subject type, currently only supports "character"
- Image []string `json:"image"` // Array of subject reference images (currently only supports single image)
-}
-
-type VideoRequest struct {
- Model string `json:"model"`
- Prompt string `json:"prompt,omitempty"`
- PromptOptimizer *bool `json:"prompt_optimizer,omitempty"`
- FastPretreatment *bool `json:"fast_pretreatment,omitempty"`
- Duration *int `json:"duration,omitempty"`
- Resolution string `json:"resolution,omitempty"`
- CallbackURL string `json:"callback_url,omitempty"`
- AigcWatermark *bool `json:"aigc_watermark,omitempty"`
- FirstFrameImage string `json:"first_frame_image,omitempty"` // For image-to-video and start-end-to-video
- LastFrameImage string `json:"last_frame_image,omitempty"` // For start-end-to-video
- SubjectReference []SubjectReference `json:"subject_reference,omitempty"` // For subject-reference-to-video
-}
-
-type VideoResponse struct {
- TaskID string `json:"task_id"`
- BaseResp BaseResp `json:"base_resp"`
-}
-
-type BaseResp struct {
- StatusCode int `json:"status_code"`
- StatusMsg string `json:"status_msg"`
-}
-
-type QueryTaskRequest struct {
- TaskID string `json:"task_id"`
-}
-
-type QueryTaskResponse struct {
- TaskID string `json:"task_id"`
- Status string `json:"status"`
- FileID string `json:"file_id,omitempty"`
- VideoWidth int `json:"video_width,omitempty"`
- VideoHeight int `json:"video_height,omitempty"`
- BaseResp BaseResp `json:"base_resp"`
-}
-
-type ErrorInfo struct {
- StatusCode int `json:"status_code"`
- StatusMsg string `json:"status_msg"`
-}
-
-type TaskStatusInfo struct {
- TaskID string `json:"task_id"`
- Status string `json:"status"`
- FileID string `json:"file_id,omitempty"`
- VideoURL string `json:"video_url,omitempty"`
- ErrorCode int `json:"error_code,omitempty"`
- ErrorMsg string `json:"error_msg,omitempty"`
-}
-
-type ModelConfig struct {
- Name string
- DefaultResolution string
- SupportedDurations []int
- SupportedResolutions []string
- HasPromptOptimizer bool
- HasFastPretreatment bool
-}
-
-type RetrieveFileResponse struct {
- File FileObject `json:"file"`
- BaseResp BaseResp `json:"base_resp"`
-}
-
-type FileObject struct {
- FileID int64 `json:"file_id"`
- Bytes int64 `json:"bytes"`
- CreatedAt int64 `json:"created_at"`
- Filename string `json:"filename"`
- Purpose string `json:"purpose"`
- DownloadURL string `json:"download_url"`
-}
-
-func GetModelConfig(model string) ModelConfig {
- configs := map[string]ModelConfig{
- "MiniMax-Hailuo-2.3": {
- Name: "MiniMax-Hailuo-2.3",
- DefaultResolution: Resolution768P,
- SupportedDurations: []int{6, 10},
- SupportedResolutions: []string{Resolution768P, Resolution1080P},
- HasPromptOptimizer: true,
- HasFastPretreatment: true,
- },
- "MiniMax-Hailuo-2.3-Fast": {
- Name: "MiniMax-Hailuo-2.3-Fast",
- DefaultResolution: Resolution768P,
- SupportedDurations: []int{6, 10},
- SupportedResolutions: []string{Resolution768P, Resolution1080P},
- HasPromptOptimizer: true,
- HasFastPretreatment: true,
- },
- "MiniMax-Hailuo-02": {
- Name: "MiniMax-Hailuo-02",
- DefaultResolution: Resolution768P,
- SupportedDurations: []int{6, 10},
- SupportedResolutions: []string{Resolution512P, Resolution768P, Resolution1080P},
- HasPromptOptimizer: true,
- HasFastPretreatment: true,
- },
- "T2V-01-Director": {
- Name: "T2V-01-Director",
- DefaultResolution: Resolution768P,
- SupportedDurations: []int{6},
- SupportedResolutions: []string{Resolution768P, Resolution1080P},
- HasPromptOptimizer: true,
- HasFastPretreatment: false,
- },
- "T2V-01": {
- Name: "T2V-01",
- DefaultResolution: Resolution720P,
- SupportedDurations: []int{6},
- SupportedResolutions: []string{Resolution720P},
- HasPromptOptimizer: true,
- HasFastPretreatment: false,
- },
- "I2V-01-Director": {
- Name: "I2V-01-Director",
- DefaultResolution: Resolution720P,
- SupportedDurations: []int{6},
- SupportedResolutions: []string{Resolution720P, Resolution1080P},
- HasPromptOptimizer: true,
- HasFastPretreatment: false,
- },
- "I2V-01-live": {
- Name: "I2V-01-live",
- DefaultResolution: Resolution720P,
- SupportedDurations: []int{6},
- SupportedResolutions: []string{Resolution720P, Resolution1080P},
- HasPromptOptimizer: true,
- HasFastPretreatment: false,
- },
- "I2V-01": {
- Name: "I2V-01",
- DefaultResolution: Resolution720P,
- SupportedDurations: []int{6},
- SupportedResolutions: []string{Resolution720P, Resolution1080P},
- HasPromptOptimizer: true,
- HasFastPretreatment: false,
- },
- "S2V-01": {
- Name: "S2V-01",
- DefaultResolution: Resolution720P,
- SupportedDurations: []int{6},
- SupportedResolutions: []string{Resolution720P},
- HasPromptOptimizer: true,
- HasFastPretreatment: false,
- },
- }
-
- if config, exists := configs[model]; exists {
- return config
- }
-
- return ModelConfig{
- Name: model,
- DefaultResolution: DefaultResolution,
- SupportedDurations: []int{6},
- SupportedResolutions: []string{DefaultResolution},
- HasPromptOptimizer: true,
- HasFastPretreatment: false,
- }
-}
diff --git a/relay/channel/task/jimeng/adaptor.go b/relay/channel/task/jimeng/adaptor.go
deleted file mode 100644
index 5e788d3415b5..000000000000
--- a/relay/channel/task/jimeng/adaptor.go
+++ /dev/null
@@ -1,481 +0,0 @@
-package jimeng
-
-import (
- "bytes"
- "crypto/hmac"
- "crypto/sha256"
- "encoding/base64"
- "encoding/hex"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "sort"
- "strings"
- "time"
-
- "github.com/QuantumNous/new-api/common"
- "github.com/QuantumNous/new-api/model"
- "github.com/samber/lo"
-
- "github.com/gin-gonic/gin"
- "github.com/pkg/errors"
-
- "github.com/QuantumNous/new-api/constant"
- taskdto "github.com/QuantumNous/new-api/dto"
- "github.com/QuantumNous/new-api/relay/channel"
- taskcommon "github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
- relaycommon "github.com/QuantumNous/new-api/relay/common"
- "github.com/QuantumNous/new-api/relaykit/dto"
- "github.com/QuantumNous/new-api/service"
-)
-
-// ============================
-// Request / Response structures
-// ============================
-
-type requestPayload struct {
- ReqKey string `json:"req_key"`
- BinaryDataBase64 []string `json:"binary_data_base64,omitempty"`
- ImageUrls []string `json:"image_urls,omitempty"`
- Prompt string `json:"prompt,omitempty"`
- Seed int64 `json:"seed"`
- AspectRatio string `json:"aspect_ratio"`
- Frames int `json:"frames,omitempty"`
-}
-
-type responsePayload struct {
- Code int `json:"code"`
- Message string `json:"message"`
- RequestId string `json:"request_id"`
- Data struct {
- TaskID string `json:"task_id"`
- } `json:"data"`
-}
-
-type responseTask struct {
- Code int `json:"code"`
- Data struct {
- BinaryDataBase64 []interface{} `json:"binary_data_base64"`
- ImageUrls interface{} `json:"image_urls"`
- RespData string `json:"resp_data"`
- Status string `json:"status"`
- VideoUrl string `json:"video_url"`
- } `json:"data"`
- Message string `json:"message"`
- RequestId string `json:"request_id"`
- Status int `json:"status"`
- TimeElapsed string `json:"time_elapsed"`
-}
-
-const (
- // 即梦限制单个文件最大4.7MB https://www.volcengine.com/docs/85621/1747301
- MaxFileSize int64 = 4*1024*1024 + 700*1024 // 4.7MB (4MB + 724KB)
-)
-
-// ============================
-// Adaptor implementation
-// ============================
-
-type TaskAdaptor struct {
- taskcommon.BaseBilling
- ChannelType int
- accessKey string
- secretKey string
- baseURL string
-}
-
-func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
- a.ChannelType = info.ChannelType
- a.baseURL = info.ChannelBaseUrl
-
- // apiKey format: "access_key|secret_key"
- keyParts := strings.Split(info.ApiKey, "|")
- if len(keyParts) == 2 {
- a.accessKey = strings.TrimSpace(keyParts[0])
- a.secretKey = strings.TrimSpace(keyParts[1])
- }
-}
-
-// ValidateRequestAndSetAction parses body, validates fields and sets default action.
-func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *taskdto.TaskError) {
- return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate)
-}
-
-// BuildRequestURL constructs the upstream URL.
-func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
- if isNewAPIRelay(info.ApiKey) {
- return fmt.Sprintf("%s/jimeng/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31", a.baseURL), nil
- }
- return fmt.Sprintf("%s/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31", a.baseURL), nil
-}
-
-// BuildRequestHeader sets required headers.
-func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Accept", "application/json")
- if isNewAPIRelay(info.ApiKey) {
- req.Header.Set("Authorization", "Bearer "+info.ApiKey)
- } else {
- return a.signRequest(req, a.accessKey, a.secretKey)
- }
- return nil
-}
-
-func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
- v, exists := c.Get("task_request")
- if !exists {
- return nil, fmt.Errorf("request not found in context")
- }
- req, ok := v.(relaycommon.TaskSubmitReq)
- if !ok {
- return nil, fmt.Errorf("invalid request type in context")
- }
- // 支持openai sdk的图片上传方式
- if mf, err := c.MultipartForm(); err == nil {
- if files, exists := mf.File["input_reference"]; exists && len(files) > 0 {
- if len(files) == 1 {
- info.Action = constant.TaskActionGenerate
- } else if len(files) > 1 {
- info.Action = constant.TaskActionFirstTailGenerate
- }
-
- // 将上传的文件转换为base64格式
- var images []string
-
- for _, fileHeader := range files {
- // 检查文件大小
- if fileHeader.Size > MaxFileSize {
- return nil, fmt.Errorf("文件 %s 大小超过限制,最大允许 %d MB", fileHeader.Filename, MaxFileSize/(1024*1024))
- }
-
- file, err := fileHeader.Open()
- if err != nil {
- continue
- }
- fileBytes, err := io.ReadAll(file)
- file.Close()
- if err != nil {
- continue
- }
- // 将文件内容转换为base64
- base64Str := base64.StdEncoding.EncodeToString(fileBytes)
- images = append(images, base64Str)
- }
- req.Images = images
- }
- }
-
- body, err := a.convertToRequestPayload(&req, info)
- if err != nil {
- return nil, errors.Wrap(err, "convert request payload failed")
- }
- data, err := common.Marshal(body)
- if err != nil {
- return nil, err
- }
- return bytes.NewReader(data), nil
-}
-
-// DoRequest delegates to common helper.
-func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
- return channel.DoTaskApiRequest(a, c, info, requestBody)
-}
-
-// DoResponse handles upstream response, returns taskID etc.
-func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *taskdto.TaskError) {
- responseBody, err := io.ReadAll(resp.Body)
- if err != nil {
- taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
- return
- }
- _ = resp.Body.Close()
-
- // Parse Jimeng response
- var jResp responsePayload
- if err := common.Unmarshal(responseBody, &jResp); err != nil {
- taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError)
- return
- }
-
- if jResp.Code != 10000 {
- taskErr = service.TaskErrorWrapper(fmt.Errorf("%s", jResp.Message), fmt.Sprintf("%d", jResp.Code), http.StatusInternalServerError)
- return
- }
-
- ov := dto.NewOpenAIVideo()
- ov.ID = info.PublicTaskID
- ov.TaskID = info.PublicTaskID
- ov.CreatedAt = time.Now().Unix()
- ov.Model = info.OriginModelName
- c.JSON(http.StatusOK, ov)
- return jResp.Data.TaskID, responseBody, nil
-}
-
-// FetchTask fetch task status
-func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) {
- taskID, ok := body["task_id"].(string)
- if !ok {
- return nil, fmt.Errorf("invalid task_id")
- }
-
- uri := fmt.Sprintf("%s/?Action=CVSync2AsyncGetResult&Version=2022-08-31", baseUrl)
- if isNewAPIRelay(key) {
- uri = fmt.Sprintf("%s/jimeng/?Action=CVSync2AsyncGetResult&Version=2022-08-31", a.baseURL)
- }
- payload := map[string]string{
- "req_key": "jimeng_vgfm_t2v_l20", // This is fixed value from doc: https://www.volcengine.com/docs/85621/1544774
- "task_id": taskID,
- }
- payloadBytes, err := common.Marshal(payload)
- if err != nil {
- return nil, errors.Wrap(err, "marshal fetch task payload failed")
- }
-
- req, err := http.NewRequest(http.MethodPost, uri, bytes.NewBuffer(payloadBytes))
- if err != nil {
- return nil, err
- }
-
- req.Header.Set("Accept", "application/json")
- req.Header.Set("Content-Type", "application/json")
-
- if isNewAPIRelay(key) {
- req.Header.Set("Authorization", "Bearer "+key)
- } else {
- keyParts := strings.Split(key, "|")
- if len(keyParts) != 2 {
- return nil, fmt.Errorf("invalid api key format for jimeng: expected 'ak|sk'")
- }
- accessKey := strings.TrimSpace(keyParts[0])
- secretKey := strings.TrimSpace(keyParts[1])
-
- if err := a.signRequest(req, accessKey, secretKey); err != nil {
- return nil, errors.Wrap(err, "sign request failed")
- }
- }
- client, err := service.GetHttpClientWithProxy(proxy)
- if err != nil {
- return nil, fmt.Errorf("new proxy http client failed: %w", err)
- }
- return client.Do(req)
-}
-
-func (a *TaskAdaptor) GetModelList() []string {
- return []string{"jimeng_vgfm_t2v_l20"}
-}
-
-func (a *TaskAdaptor) GetChannelName() string {
- return "jimeng"
-}
-
-func (a *TaskAdaptor) signRequest(req *http.Request, accessKey, secretKey string) error {
- var bodyBytes []byte
- var err error
-
- if req.Body != nil {
- bodyBytes, err = io.ReadAll(req.Body)
- if err != nil {
- return errors.Wrap(err, "read request body failed")
- }
- _ = req.Body.Close()
- req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Rewind
- } else {
- bodyBytes = []byte{}
- }
-
- payloadHash := sha256.Sum256(bodyBytes)
- hexPayloadHash := hex.EncodeToString(payloadHash[:])
-
- t := time.Now().UTC()
- xDate := t.Format("20060102T150405Z")
- shortDate := t.Format("20060102")
-
- req.Header.Set("Host", req.URL.Host)
- req.Header.Set("X-Date", xDate)
- req.Header.Set("X-Content-Sha256", hexPayloadHash)
-
- // Sort and encode query parameters to create canonical query string
- queryParams := req.URL.Query()
- sortedKeys := make([]string, 0, len(queryParams))
- for k := range queryParams {
- sortedKeys = append(sortedKeys, k)
- }
- sort.Strings(sortedKeys)
- var queryParts []string
- for _, k := range sortedKeys {
- values := queryParams[k]
- sort.Strings(values)
- for _, v := range values {
- queryParts = append(queryParts, fmt.Sprintf("%s=%s", url.QueryEscape(k), url.QueryEscape(v)))
- }
- }
- canonicalQueryString := strings.Join(queryParts, "&")
-
- headersToSign := map[string]string{
- "host": req.URL.Host,
- "x-date": xDate,
- "x-content-sha256": hexPayloadHash,
- }
- if req.Header.Get("Content-Type") != "" {
- headersToSign["content-type"] = req.Header.Get("Content-Type")
- }
-
- var signedHeaderKeys []string
- for k := range headersToSign {
- signedHeaderKeys = append(signedHeaderKeys, k)
- }
- sort.Strings(signedHeaderKeys)
-
- var canonicalHeaders strings.Builder
- for _, k := range signedHeaderKeys {
- canonicalHeaders.WriteString(k)
- canonicalHeaders.WriteString(":")
- canonicalHeaders.WriteString(strings.TrimSpace(headersToSign[k]))
- canonicalHeaders.WriteString("\n")
- }
- signedHeaders := strings.Join(signedHeaderKeys, ";")
-
- canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s",
- req.Method,
- req.URL.Path,
- canonicalQueryString,
- canonicalHeaders.String(),
- signedHeaders,
- hexPayloadHash,
- )
-
- hashedCanonicalRequest := sha256.Sum256([]byte(canonicalRequest))
- hexHashedCanonicalRequest := hex.EncodeToString(hashedCanonicalRequest[:])
-
- region := "cn-north-1"
- serviceName := "cv"
- credentialScope := fmt.Sprintf("%s/%s/%s/request", shortDate, region, serviceName)
- stringToSign := fmt.Sprintf("HMAC-SHA256\n%s\n%s\n%s",
- xDate,
- credentialScope,
- hexHashedCanonicalRequest,
- )
-
- kDate := hmacSHA256([]byte(secretKey), []byte(shortDate))
- kRegion := hmacSHA256(kDate, []byte(region))
- kService := hmacSHA256(kRegion, []byte(serviceName))
- kSigning := hmacSHA256(kService, []byte("request"))
- signature := hex.EncodeToString(hmacSHA256(kSigning, []byte(stringToSign)))
-
- authorization := fmt.Sprintf("HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
- accessKey,
- credentialScope,
- signedHeaders,
- signature,
- )
- req.Header.Set("Authorization", authorization)
- return nil
-}
-
-func hmacSHA256(key []byte, data []byte) []byte {
- h := hmac.New(sha256.New, key)
- h.Write(data)
- return h.Sum(nil)
-}
-
-func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq, info *relaycommon.RelayInfo) (*requestPayload, error) {
- r := requestPayload{
- ReqKey: info.UpstreamModelName,
- Prompt: req.Prompt,
- }
-
- switch req.Duration {
- case 10:
- r.Frames = 241 // 24*10+1 = 241
- default:
- r.Frames = 121 // 24*5+1 = 121
- }
-
- // Handle one-of image_urls or binary_data_base64
- if req.HasImage() {
- if strings.HasPrefix(req.Images[0], "http") {
- r.ImageUrls = req.Images
- } else {
- r.BinaryDataBase64 = req.Images
- }
- }
- if err := taskcommon.UnmarshalMetadata(req.Metadata, &r); err != nil {
- return nil, errors.Wrap(err, "unmarshal metadata failed")
- }
-
- // 即梦视频3.0 ReqKey转换
- // https://www.volcengine.com/docs/85621/1792707
- imageLen := lo.Max([]int{len(req.Images), len(r.BinaryDataBase64), len(r.ImageUrls)})
- if strings.Contains(r.ReqKey, "jimeng_v30") {
- if r.ReqKey == "jimeng_v30_pro" {
- // 3.0 pro只有固定的jimeng_ti2v_v30_pro
- r.ReqKey = "jimeng_ti2v_v30_pro"
- } else if imageLen > 1 {
- // 多张图片:首尾帧生成
- r.ReqKey = strings.TrimSuffix(strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_tail_v30", 1), "p")
- } else if imageLen == 1 {
- // 单张图片:图生视频
- r.ReqKey = strings.TrimSuffix(strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_v30", 1), "p")
- } else {
- // 无图片:文生视频
- r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_t2v_v30", 1)
- }
- }
-
- return &r, nil
-}
-
-func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
- resTask := responseTask{}
- if err := common.Unmarshal(respBody, &resTask); err != nil {
- return nil, errors.Wrap(err, "unmarshal task result failed")
- }
- taskResult := relaycommon.TaskInfo{}
- if resTask.Code == 10000 {
- taskResult.Code = 0
- } else {
- taskResult.Code = resTask.Code // todo uni code
- taskResult.Reason = resTask.Message
- taskResult.Status = model.TaskStatusFailure
- taskResult.Progress = "100%"
- }
- switch resTask.Data.Status {
- case "in_queue":
- taskResult.Status = model.TaskStatusQueued
- taskResult.Progress = "10%"
- case "done":
- taskResult.Status = model.TaskStatusSuccess
- taskResult.Progress = "100%"
- }
- taskResult.Url = resTask.Data.VideoUrl
- return &taskResult, nil
-}
-
-func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) {
- var jimengResp responseTask
- if err := common.Unmarshal(originTask.Data, &jimengResp); err != nil {
- return nil, errors.Wrap(err, "unmarshal jimeng task data failed")
- }
-
- openAIVideo := dto.NewOpenAIVideo()
- openAIVideo.ID = originTask.TaskID
- openAIVideo.Status = originTask.Status.ToVideoStatus()
- openAIVideo.SetProgressStr(originTask.Progress)
- openAIVideo.SetMetadata("url", jimengResp.Data.VideoUrl)
- openAIVideo.CreatedAt = originTask.CreatedAt
- openAIVideo.CompletedAt = originTask.UpdatedAt
-
- if jimengResp.Code != 10000 {
- openAIVideo.Error = &dto.OpenAIVideoError{
- Message: jimengResp.Message,
- Code: fmt.Sprintf("%d", jimengResp.Code),
- }
- }
-
- return common.Marshal(openAIVideo)
-}
-
-func isNewAPIRelay(apiKey string) bool {
- return strings.HasPrefix(apiKey, "sk-")
-}
diff --git a/relay/channel/task/jsplugin/adaptor.go b/relay/channel/task/jsplugin/adaptor.go
new file mode 100644
index 000000000000..5a27413456ff
--- /dev/null
+++ b/relay/channel/task/jsplugin/adaptor.go
@@ -0,0 +1,1430 @@
+package jsplugin
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "maps"
+ "math"
+ "mime"
+ "mime/multipart"
+ "net/http"
+ "net/textproto"
+ "net/url"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/dto"
+ kitdto "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/logger"
+ "github.com/QuantumNous/new-api/model"
+ pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/QuantumNous/new-api/relay/channel"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/gin-gonic/gin"
+)
+
+type requestDescriptor struct {
+ URL string `json:"url"`
+ Method string `json:"method"`
+ Headers map[string]string `json:"headers"`
+ Body any `json:"body"`
+ Credentialless bool `json:"credentialless"`
+ Action string `json:"action"`
+ Model string `json:"model"`
+ RewriteModel string `json:"rewriteModel"`
+ BodyType string `json:"bodyType"`
+ Parts []requestPart `json:"parts"`
+}
+
+type requestPart struct {
+ Name string `json:"name"`
+ Value any `json:"value"`
+ FileRef string `json:"fileRef"`
+ Filename string `json:"filename"`
+}
+
+type submitResponse struct {
+ TaskID string `json:"taskId"`
+ TaskData any `json:"taskData"`
+ Immediate *taskResult `json:"immediate"`
+}
+type taskResult struct {
+ Code int `json:"code"`
+ TaskID string `json:"taskId"`
+ Status string `json:"status"`
+ Progress string `json:"progress"`
+ Reason string `json:"reason"`
+ URL string `json:"url"`
+ RemoteURL string `json:"remoteUrl"`
+ CompletionTokens float64 `json:"completionTokens"`
+ TotalTokens float64 `json:"totalTokens"`
+}
+
+var taskArtifactKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$`)
+
+const maxTaskArtifacts = 64
+
+type TaskAdaptor struct {
+ plugin *pluginruntime.LoadedPlugin
+ info *relaycommon.RelayInfo
+ submit *requestDescriptor
+ routeRequest *pluginruntime.RouteRequestContext
+ requestHeaders map[string]string
+ files []map[string]any
+}
+
+func New(plugin *pluginruntime.LoadedPlugin) *TaskAdaptor { return &TaskAdaptor{plugin: plugin} }
+func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { a.info = info }
+
+func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError {
+ if pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedEndpoint); exists {
+ if pinned, ok := pinnedValue.(pluginruntime.PinnedEndpoint); ok && pinned.Plugin == a.plugin {
+ if protocolValue, present := c.Get(pluginruntime.ContextKeyProtocolRequest); present {
+ if protocolContext, valid := protocolValue.(pluginruntime.ProtocolRequestContext); valid {
+ resolvedValue, callErr := a.plugin.Engine.CallPath(context.WithoutCancel(c.Request.Context()), "protocols", []string{pinned.Protocol, "decodeRequest"}, protocolContext.JSValue())
+ resolved, resolvedOK := resolvedValue.(map[string]any)
+ resolvedModel, modelOK := resolved["model"].(string)
+ if callErr != nil || !resolvedOK || !modelOK || resolvedModel != pinned.Model {
+ return service.TaskErrorWrapperLocal(fmt.Errorf("final task plugin decoder rejected the pinned model"), "plugin_request_invalid", http.StatusBadRequest)
+ }
+ if _, forbidden := resolved["renderer"]; forbidden {
+ return service.TaskErrorWrapperLocal(fmt.Errorf("decoder must not return renderer"), "plugin_request_invalid", http.StatusBadRequest)
+ }
+ if body, present := resolved["requestBody"]; present {
+ c.Set("task_request", body)
+ }
+ if action, valid := resolved["action"].(string); valid && strings.TrimSpace(action) != "" {
+ c.Set("task_action", action)
+ info.Action = action
+ }
+ }
+ }
+ }
+ }
+ if _, exists := c.Get("task_request"); !exists {
+ if taskErr := relaycommon.ValidateBasicTaskRequest(c, info, "image_to_video"); taskErr != nil {
+ return taskErr
+ }
+ }
+ if request, exists := c.Get("task_request"); exists {
+ if err := a.validateResolvedUsageRequest(request); err != nil {
+ return service.TaskErrorWrapperLocal(err, "plugin_usage_invalid", http.StatusBadRequest)
+ }
+ }
+ if _, err := a.buildSubmit(c, info); err != nil {
+ return service.TaskErrorWrapperLocal(err, "plugin_request_invalid", http.StatusBadRequest)
+ }
+ return nil
+}
+
+func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 {
+ ratios, err := a.EstimateBillingValidated(c, info)
+ if err != nil {
+ a.logRejectedUsage("extractUsage", err)
+ return nil
+ }
+ return ratios
+}
+
+func (a *TaskAdaptor) EstimateBillingValidated(c *gin.Context, info *relaycommon.RelayInfo) (map[string]float64, error) {
+ usageContext := a.submitContext(c, info)
+ usageContext["usagePurpose"] = "billing_ratios"
+ return a.usageRatios(c.Request.Context(), "extractUsage", usageContext)
+}
+
+func (a *TaskAdaptor) ExtractUsageFacts(c *gin.Context, info *relaycommon.RelayInfo) map[string]any {
+ facts, err := a.ExtractUsageFactsValidated(c, info)
+ if err != nil {
+ a.logRejectedUsage("extractUsage", err)
+ return nil
+ }
+ return facts
+}
+
+func (a *TaskAdaptor) ExtractUsageFactsValidated(c *gin.Context, info *relaycommon.RelayInfo) (map[string]any, error) {
+ if !a.hasHook(c.Request.Context(), "extractUsage") {
+ return nil, nil
+ }
+ usageContext := a.submitContext(c, info)
+ usageContext["usagePurpose"] = "facts"
+ value, err := a.plugin.Engine.Call(c.Request.Context(), "extractUsage", usageContext)
+ if err != nil {
+ return nil, fmt.Errorf("plugin usage hook failed")
+ }
+ if value == nil {
+ return nil, nil
+ }
+ facts, ok := value.(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("plugin usage hook must return an object")
+ }
+ if _, err = a.validatedUsageRatios(facts); err != nil {
+ return nil, err
+ }
+ return facts, nil
+}
+
+func (a *TaskAdaptor) AdjustBillingOnSubmit(info *relaycommon.RelayInfo, taskData []byte) map[string]float64 {
+ var data any
+ if err := common.Unmarshal(taskData, &data); err != nil {
+ data = string(taskData)
+ }
+ ratios, err := a.usageRatios(context.Background(), "extractUsageOnSubmit", a.submitContext(nil, info), data)
+ if err != nil {
+ a.logRejectedUsage("extractUsageOnSubmit", err)
+ return nil
+ }
+ return ratios
+}
+
+func (a *TaskAdaptor) AdjustBillingOnComplete(task *model.Task, result *relaycommon.TaskInfo) int {
+ if !a.hasHook(context.Background(), "extractUsageOnComplete") {
+ return 0
+ }
+ value, err := a.plugin.Engine.Call(context.Background(), "extractUsageOnComplete", jsonValue(task), jsonValue(result))
+ if err != nil {
+ return 0
+ }
+ a.applyCompletionUsageFacts(result, value)
+ return 0
+}
+
+func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
+ if a.submit == nil {
+ return "", fmt.Errorf("plugin submit request was not built")
+ }
+ return a.submit.URL, pluginruntime.ValidateRequestURL(a.submit.URL, info.ChannelBaseUrl, a.plugin.Meta.AllowedHosts)
+}
+
+func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *relaycommon.RelayInfo) error {
+ if a.submit == nil {
+ return fmt.Errorf("plugin submit request was not built")
+ }
+ for name, value := range a.submit.Headers {
+ req.Header.Set(name, value)
+ }
+ return nil
+}
+
+func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
+ descriptor, err := a.buildSubmit(c, info)
+ if err != nil {
+ return nil, err
+ }
+ if descriptor.BodyType == "multipart" {
+ form, parseErr := common.ParseMultipartFormReusable(c)
+ if parseErr != nil {
+ return nil, parseErr
+ }
+ defer form.RemoveAll()
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ for _, part := range descriptor.Parts {
+ if part.FileRef == "" {
+ header := make(textproto.MIMEHeader)
+ disposition := mime.FormatMediaType("form-data", map[string]string{"name": part.Name})
+ if disposition == "" {
+ return nil, fmt.Errorf("invalid multipart name")
+ }
+ header.Set("Content-Disposition", disposition)
+ destination, createErr := writer.CreatePart(header)
+ if createErr != nil {
+ return nil, createErr
+ }
+ if _, err = io.WriteString(destination, fmt.Sprint(part.Value)); err != nil {
+ return nil, err
+ }
+ continue
+ }
+ field := strings.TrimPrefix(part.FileRef, "request_file:")
+ files := form.File[field]
+ if len(files) == 0 {
+ return nil, fmt.Errorf("unknown file reference %q", part.FileRef)
+ }
+ file, openErr := files[0].Open()
+ if openErr != nil {
+ return nil, openErr
+ }
+ filename := part.Filename
+ if filename == "" {
+ filename = files[0].Filename
+ }
+ header := make(textproto.MIMEHeader)
+ disposition := mime.FormatMediaType("form-data", map[string]string{"name": part.Name, "filename": filename})
+ if disposition == "" {
+ file.Close()
+ return nil, fmt.Errorf("invalid multipart name or filename")
+ }
+ header.Set("Content-Disposition", disposition)
+ header.Set("Content-Type", files[0].Header.Get("Content-Type"))
+ destination, copyErr := writer.CreatePart(header)
+ if copyErr == nil {
+ _, copyErr = io.Copy(destination, file)
+ }
+ file.Close()
+ if copyErr != nil {
+ return nil, copyErr
+ }
+ }
+ if err = writer.Close(); err != nil {
+ return nil, err
+ }
+ c.Request.Header.Set("Content-Type", writer.FormDataContentType())
+ return bytes.NewReader(body.Bytes()), nil
+ }
+ if descriptor.Body == nil {
+ return nil, nil
+ }
+ if text, ok := descriptor.Body.(string); ok {
+ return strings.NewReader(text), nil
+ }
+ inlined, err := inlineJSONFilePlaceholders(c, descriptor.Body)
+ if err != nil {
+ return nil, err
+ }
+ body, err := common.Marshal(inlined)
+ if err != nil {
+ return nil, err
+ }
+ return bytes.NewReader(body), nil
+}
+
+func maxInlineFileBytes() int64 {
+ limitMB := constant.MaxFileDownloadMB
+ if limitMB <= 0 {
+ limitMB = 64
+ }
+ return int64(limitMB) << 20
+}
+
+func inlineJSONFilePlaceholders(c *gin.Context, body any) (any, error) {
+ cloned := jsonValue(body)
+ var form *multipart.Form
+ if c != nil && c.Request != nil && strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") {
+ parsed, parseErr := common.ParseMultipartFormReusable(c)
+ if parseErr != nil {
+ return nil, parseErr
+ }
+ form = parsed
+ defer form.RemoveAll()
+ }
+ limit := maxInlineFileBytes()
+ var total int64
+ return replaceJSONFilePlaceholders(cloned, form, limit, &total)
+}
+
+func replaceJSONFilePlaceholders(value any, form *multipart.Form, limit int64, total *int64) (any, error) {
+ switch typed := value.(type) {
+ case map[string]any:
+ if _, isPlaceholder := typed["__fileRef"]; isPlaceholder {
+ return encodeFilePlaceholder(typed, form, limit, total)
+ }
+ for key, item := range typed {
+ replaced, err := replaceJSONFilePlaceholders(item, form, limit, total)
+ if err != nil {
+ return nil, err
+ }
+ typed[key] = replaced
+ }
+ return typed, nil
+ case []any:
+ for index, item := range typed {
+ replaced, err := replaceJSONFilePlaceholders(item, form, limit, total)
+ if err != nil {
+ return nil, err
+ }
+ typed[index] = replaced
+ }
+ return typed, nil
+ default:
+ return value, nil
+ }
+}
+
+func encodeFilePlaceholder(placeholder map[string]any, form *multipart.Form, limit int64, total *int64) (string, error) {
+ for key := range placeholder {
+ switch key {
+ case "__fileRef", "encoding", "mimeType", "maxBytes":
+ default:
+ return "", fmt.Errorf("invalid file placeholder")
+ }
+ }
+ ref, _ := placeholder["__fileRef"].(string)
+ if strings.TrimSpace(ref) == "" {
+ return "", fmt.Errorf("unknown file reference %q", ref)
+ }
+ encoding, _ := placeholder["encoding"].(string)
+ if encoding != "base64" && encoding != "dataUrl" {
+ return "", fmt.Errorf("file placeholder encoding must be \"base64\" or \"dataUrl\"")
+ }
+ if form == nil {
+ return "", fmt.Errorf("unknown file reference %q", ref)
+ }
+ field := strings.TrimPrefix(ref, "request_file:")
+ files := form.File[field]
+ if len(files) == 0 {
+ return "", fmt.Errorf("unknown file reference %q", ref)
+ }
+ header := files[0]
+ maxBytes := limit
+ if raw, exists := placeholder["maxBytes"]; exists {
+ n, ok := usageNumber(raw, false)
+ if !ok || n <= 0 || n != math.Trunc(n) {
+ return "", fmt.Errorf("invalid file placeholder")
+ }
+ if int64(n) < maxBytes {
+ maxBytes = int64(n)
+ }
+ }
+ if header.Size > maxBytes {
+ return "", fmt.Errorf("file %q exceeds the %d byte limit", ref, maxBytes)
+ }
+ file, openErr := header.Open()
+ if openErr != nil {
+ return "", openErr
+ }
+ data, readErr := io.ReadAll(io.LimitReader(file, maxBytes+1))
+ file.Close()
+ if readErr != nil {
+ return "", readErr
+ }
+ if int64(len(data)) > maxBytes {
+ return "", fmt.Errorf("file %q exceeds the %d byte limit", ref, maxBytes)
+ }
+ if *total+int64(len(data)) > limit {
+ return "", fmt.Errorf("inlined files exceed the %d byte limit", limit)
+ }
+ *total += int64(len(data))
+ encoded := base64.StdEncoding.EncodeToString(data)
+ if encoding == "base64" {
+ return encoded, nil
+ }
+ mimeType := "application/octet-stream"
+ if override, ok := placeholder["mimeType"].(string); ok && strings.TrimSpace(override) != "" {
+ mimeType = override
+ } else if contentType := header.Header.Get("Content-Type"); contentType != "" {
+ mimeType = contentType
+ }
+ return "data:" + mimeType + ";base64," + encoded, nil
+}
+
+func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, body io.Reader) (*http.Response, error) {
+ if a.submit != nil && strings.TrimSpace(a.submit.Method) != "" {
+ originalMethod := c.Request.Method
+ c.Request.Method = strings.ToUpper(a.submit.Method)
+ defer func() { c.Request.Method = originalMethod }()
+ }
+ return channel.DoTaskApiRequest(a, c, info, body)
+}
+
+func (a *TaskAdaptor) ParseResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*channel.TaskSubmitResponse, *dto.TaskError) {
+ started := time.Now()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ logger.LogDebug(c, "task_plugin subsystem=adaptor event=parse_submit_failed plugin=%q stage=read_response reason=read_failed status=%d", a.plugin.Meta.Key, resp.StatusCode)
+ return nil, service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
+ }
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=adaptor event=submit_response_received plugin=%q status=%d body_bytes=%d",
+ a.plugin.Meta.Key,
+ resp.StatusCode,
+ len(body),
+ )
+ responseBody := any(string(body))
+ var decoded any
+ if common.Unmarshal(body, &decoded) == nil {
+ responseBody = decoded
+ }
+ headers := make(map[string][]string, len(resp.Header))
+ maps.Copy(headers, resp.Header)
+ value, err := a.plugin.Engine.Call(c.Request.Context(), "parseSubmitResponse", a.submitContext(c, info), map[string]any{"statusCode": resp.StatusCode, "headers": headers, "body": responseBody})
+ if err != nil {
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=adaptor event=parse_submit_failed plugin=%q stage=parse_submit_response reason=hook_failed status=%d elapsed_ms=%d",
+ a.plugin.Meta.Key,
+ resp.StatusCode,
+ time.Since(started).Milliseconds(),
+ )
+ return nil, service.TaskErrorWrapper(err, "plugin_submit_response_failed", http.StatusBadGateway)
+ }
+ if object, ok := value.(map[string]any); ok {
+ if _, forbidden := object["clientResponse"]; forbidden {
+ return nil, service.TaskErrorWrapperLocal(fmt.Errorf("parseSubmitResponse must not return clientResponse"), "plugin_submit_response_invalid", http.StatusBadGateway)
+ }
+ }
+ var parsed submitResponse
+ if err = convert(value, &parsed); err != nil || strings.TrimSpace(parsed.TaskID) == "" {
+ if err == nil {
+ err = fmt.Errorf("plugin returned an empty taskId")
+ }
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=adaptor event=parse_submit_failed plugin=%q stage=parse_submit_response reason=invalid_result status=%d elapsed_ms=%d",
+ a.plugin.Meta.Key,
+ resp.StatusCode,
+ time.Since(started).Milliseconds(),
+ )
+ return nil, service.TaskErrorWrapper(err, "plugin_submit_response_invalid", http.StatusBadGateway)
+ }
+ var taskData []byte
+ if parsed.TaskData != nil {
+ taskData, err = common.Marshal(parsed.TaskData)
+ if err != nil {
+ return nil, service.TaskErrorWrapper(err, "plugin_submit_response_invalid", http.StatusBadGateway)
+ }
+ }
+ var immediate *relaycommon.TaskInfo
+ if parsed.Immediate != nil {
+ immediate = &relaycommon.TaskInfo{Code: parsed.Immediate.Code, TaskID: parsed.Immediate.TaskID, Status: parsed.Immediate.Status, Progress: parsed.Immediate.Progress, Reason: parsed.Immediate.Reason, Url: parsed.Immediate.URL, RemoteUrl: parsed.Immediate.RemoteURL}
+ }
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=adaptor event=parse_submit_complete plugin=%q status=%d task_data_bytes=%d immediate=%t elapsed_ms=%d",
+ a.plugin.Meta.Key,
+ resp.StatusCode,
+ len(taskData),
+ immediate != nil,
+ time.Since(started).Milliseconds(),
+ )
+ return &channel.TaskSubmitResponse{
+ UpstreamTaskID: parsed.TaskID,
+ TaskData: taskData,
+ Immediate: immediate,
+ }, nil
+}
+
+func (a *TaskAdaptor) GetModelList() []string { return append([]string(nil), a.plugin.Meta.Models...) }
+func (a *TaskAdaptor) GetChannelName() string { return a.plugin.Meta.Name }
+func (a *TaskAdaptor) FetchMode() string { return a.plugin.Meta.FetchMode }
+
+func (a *TaskAdaptor) FetchBatchTasks(baseURL, key string, taskIDs []string, proxy string) (*http.Response, error) {
+ ctx := map[string]any{"baseUrl": baseURL}
+ auth, err := resolveAuth(a.plugin.Meta.Auth, key, proxy)
+ if err != nil {
+ return nil, err
+ }
+ ctx["auth"] = auth
+ ctx["authHeader"] = auth["authHeader"]
+ if a.plugin.Meta.Auth.Type == "" || a.plugin.Meta.Auth.Type == "none" || a.plugin.Meta.Auth.Type == "api_key" {
+ ctx["apiKey"] = key
+ }
+ value, err := a.plugin.Engine.Call(context.Background(), "buildBatchQueryRequest", ctx, taskIDs)
+ if err != nil {
+ return nil, err
+ }
+ return a.doFetchDescriptor(baseURL, proxy, value)
+}
+
+func (a *TaskAdaptor) FetchTask(baseURL, key string, body map[string]any, proxy string) (*http.Response, error) {
+ ctx := map[string]any{"taskId": body["task_id"], "action": body["action"], "requestBody": body, "baseUrl": baseURL}
+ auth, err := resolveAuth(a.plugin.Meta.Auth, key, proxy)
+ if err != nil {
+ return nil, err
+ }
+ ctx["auth"] = auth
+ ctx["authHeader"] = auth["authHeader"]
+ if a.plugin.Meta.Auth.Type == "" || a.plugin.Meta.Auth.Type == "none" || a.plugin.Meta.Auth.Type == "api_key" {
+ ctx["apiKey"] = key
+ }
+ hook := "buildQueryRequest"
+ if a.plugin.Meta.FetchMode == "batch" && a.hasHook(context.Background(), "buildBatchQueryRequest") {
+ hook = "buildBatchQueryRequest"
+ }
+ value, err := a.plugin.Engine.Call(context.Background(), hook, ctx)
+ if err != nil {
+ return nil, err
+ }
+ return a.doFetchDescriptor(baseURL, proxy, value)
+}
+
+func (a *TaskAdaptor) doFetchDescriptor(baseURL, proxy string, value any) (*http.Response, error) {
+ var descriptor requestDescriptor
+ if err := convert(value, &descriptor); err != nil {
+ return nil, err
+ }
+ if err := pluginruntime.ValidateRequestURL(descriptor.URL, baseURL, a.plugin.Meta.AllowedHosts); err != nil {
+ return nil, err
+ }
+ var requestBody io.Reader
+ if descriptor.Body != nil {
+ if bodyText, ok := descriptor.Body.(string); ok {
+ requestBody = strings.NewReader(bodyText)
+ } else {
+ encoded, marshalErr := common.Marshal(descriptor.Body)
+ if marshalErr != nil {
+ return nil, marshalErr
+ }
+ requestBody = bytes.NewReader(encoded)
+ }
+ }
+ method := strings.ToUpper(strings.TrimSpace(descriptor.Method))
+ if method == "" {
+ method = http.MethodGet
+ }
+ req, err := http.NewRequest(method, descriptor.URL, requestBody)
+ if err != nil {
+ return nil, err
+ }
+ for name, value := range descriptor.Headers {
+ req.Header.Set(name, value)
+ }
+ client, err := service.GetHttpClientWithProxy(proxy)
+ if err != nil {
+ return nil, err
+ }
+ started := time.Now()
+ resp, err := client.Do(req)
+ if err != nil {
+ logger.LogDebug(
+ context.Background(),
+ "task_plugin subsystem=adaptor event=query_request_failed plugin=%q method=%q reason=transport_error elapsed_ms=%d",
+ a.plugin.Meta.Key,
+ method,
+ time.Since(started).Milliseconds(),
+ )
+ return nil, err
+ }
+ logger.LogDebug(
+ context.Background(),
+ "task_plugin subsystem=adaptor event=query_response_received plugin=%q method=%q status=%d elapsed_ms=%d",
+ a.plugin.Meta.Key,
+ method,
+ resp.StatusCode,
+ time.Since(started).Milliseconds(),
+ )
+ return resp, nil
+}
+
+func (a *TaskAdaptor) ParseBatchResult(body []byte) (map[string]*service.BatchTaskResult, error) {
+ started := time.Now()
+ input := any(string(body))
+ var decoded any
+ if common.Unmarshal(body, &decoded) == nil {
+ input = decoded
+ }
+ value, err := a.plugin.Engine.Call(context.Background(), "parseBatchResult", map[string]any{}, input)
+ if err != nil {
+ logger.LogDebug(context.Background(), "task_plugin subsystem=adaptor event=parse_batch_failed plugin=%q reason=hook_failed body_bytes=%d elapsed_ms=%d", a.plugin.Meta.Key, len(body), time.Since(started).Milliseconds())
+ return nil, err
+ }
+ var parsed []struct {
+ TaskID string `json:"taskId"`
+ Action string `json:"action"`
+ Status string `json:"status"`
+ Progress string `json:"progress"`
+ Reason string `json:"reason"`
+ URL string `json:"url"`
+ SubmitTime int64 `json:"submitTime"`
+ StartTime int64 `json:"startTime"`
+ FinishTime int64 `json:"finishTime"`
+ Data any `json:"data"`
+ }
+ if err = convert(value, &parsed); err != nil {
+ logger.LogDebug(context.Background(), "task_plugin subsystem=adaptor event=parse_batch_failed plugin=%q reason=invalid_result body_bytes=%d elapsed_ms=%d", a.plugin.Meta.Key, len(body), time.Since(started).Milliseconds())
+ return nil, err
+ }
+ results := make(map[string]*service.BatchTaskResult, len(parsed))
+ hasCompletionUsage := a.hasHook(context.Background(), "extractUsageOnComplete")
+ for _, item := range parsed {
+ if strings.TrimSpace(item.TaskID) == "" {
+ continue
+ }
+ info := relaycommon.TaskInfo{TaskID: item.TaskID, Status: item.Status, Progress: item.Progress, Reason: item.Reason, Url: item.URL}
+ if hasCompletionUsage {
+ usageBody := item.Data
+ if usageBody == nil {
+ usageBody = jsonValue(item)
+ }
+ facts, hookErr := a.plugin.Engine.Call(context.Background(), "extractUsageOnComplete", nil, jsonValue(&info), usageBody)
+ if hookErr == nil {
+ a.applyCompletionUsageFacts(&info, facts)
+ }
+ }
+ results[item.TaskID] = &service.BatchTaskResult{TaskInfo: info, Action: item.Action, SubmitTime: item.SubmitTime, StartTime: item.StartTime, FinishTime: item.FinishTime, Data: item.Data}
+ }
+ logger.LogDebug(
+ context.Background(),
+ "task_plugin subsystem=adaptor event=parse_batch_complete plugin=%q body_bytes=%d results=%d completion_usage_hook=%t elapsed_ms=%d",
+ a.plugin.Meta.Key,
+ len(body),
+ len(results),
+ hasCompletionUsage,
+ time.Since(started).Milliseconds(),
+ )
+ return results, nil
+}
+
+func (a *TaskAdaptor) ParseTaskResult(body []byte) (*relaycommon.TaskInfo, error) {
+ started := time.Now()
+ input := any(string(body))
+ var decoded any
+ if common.Unmarshal(body, &decoded) == nil {
+ input = decoded
+ }
+ value, err := a.plugin.Engine.Call(context.Background(), "parseTaskResult", map[string]any{}, input)
+ if err != nil {
+ logger.LogDebug(context.Background(), "task_plugin subsystem=adaptor event=parse_task_failed plugin=%q reason=hook_failed body_bytes=%d elapsed_ms=%d", a.plugin.Meta.Key, len(body), time.Since(started).Milliseconds())
+ return nil, err
+ }
+ var parsed taskResult
+ if err = convert(value, &parsed); err != nil {
+ logger.LogDebug(context.Background(), "task_plugin subsystem=adaptor event=parse_task_failed plugin=%q reason=invalid_result body_bytes=%d elapsed_ms=%d", a.plugin.Meta.Key, len(body), time.Since(started).Milliseconds())
+ return nil, err
+ }
+ result := &relaycommon.TaskInfo{
+ Code: parsed.Code,
+ TaskID: parsed.TaskID,
+ Status: parsed.Status,
+ Progress: parsed.Progress,
+ Reason: parsed.Reason,
+ Url: parsed.URL,
+ RemoteUrl: parsed.RemoteURL,
+ CompletionTokens: positiveInt(parsed.CompletionTokens),
+ TotalTokens: positiveInt(parsed.TotalTokens),
+ }
+ // The raw polling response only exists at this boundary. Capture upstream
+ // units here so the host settlement path can consume them from TaskInfo.
+ if a.hasHook(context.Background(), "extractUsageOnComplete") {
+ facts, hookErr := a.plugin.Engine.Call(context.Background(), "extractUsageOnComplete", nil, jsonValue(result), input)
+ if hookErr == nil {
+ a.applyCompletionUsageFacts(result, facts)
+ }
+ }
+ taskStatus := model.TaskStatus(result.Status)
+ logger.LogDebug(
+ context.Background(),
+ "task_plugin subsystem=adaptor event=parse_task_complete plugin=%q terminal=%t body_bytes=%d elapsed_ms=%d",
+ a.plugin.Meta.Key,
+ taskStatus == model.TaskStatusSuccess || taskStatus == model.TaskStatusFailure,
+ len(body),
+ time.Since(started).Milliseconds(),
+ )
+ return result, nil
+}
+
+func (a *TaskAdaptor) applyCompletionUsageFacts(result *relaycommon.TaskInfo, facts any) {
+ values, err := a.validatedCompletionUsageFacts(facts)
+ if err != nil {
+ a.logRejectedUsage("extractUsageOnComplete", err)
+ return
+ }
+ if len(values) == 0 {
+ return
+ }
+ result.UsageFacts = values
+ if units := positiveInt(values["upstreamUnits"]); units > 0 {
+ result.CompletionTokens = units
+ result.TotalTokens = units
+ return
+ }
+ if completionTokens, exists := values["completionTokens"]; exists {
+ result.CompletionTokens = positiveInt(completionTokens)
+ }
+ if totalTokens, exists := values["totalTokens"]; exists {
+ result.TotalTokens = positiveInt(totalTokens)
+ }
+}
+
+func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) ([]byte, error) {
+ if task == nil {
+ return nil, fmt.Errorf("task is required")
+ }
+ claimed := slices.ContainsFunc(a.plugin.Meta.Protocols, func(claim pluginruntime.ProtocolClaim) bool {
+ return claim.Name == "openai_video"
+ })
+ if !claimed {
+ return nil, fmt.Errorf("plugin does not claim openai_video")
+ }
+ view, err := service.BuildTaskPluginView(task)
+ if err != nil {
+ return nil, err
+ }
+ value, err := a.plugin.Engine.CallPath(context.Background(), "protocols", []string{"openai_video", "render"}, map[string]any{"protocol": "openai_video", "operation": "retrieve"}, jsonValue(view))
+ if err != nil {
+ return nil, err
+ }
+ encoded, err := common.Marshal(value)
+ if err != nil {
+ return nil, err
+ }
+ rendered := kitdto.NewOpenAIVideo()
+ if err = common.Unmarshal(encoded, rendered); err != nil {
+ return nil, fmt.Errorf("plugin returned an invalid OpenAI video object")
+ }
+ host := task.ToOpenAIVideo()
+ rendered.ID = host.ID
+ rendered.Object = host.Object
+ rendered.TaskID = ""
+ rendered.Status = host.Status
+ rendered.Progress = host.Progress
+ rendered.CreatedAt = host.CreatedAt
+ rendered.Model = host.Model
+ rendered.CompletedAt = host.CompletedAt
+ for key := range rendered.Metadata {
+ if strings.EqualFold(key, "url") {
+ delete(rendered.Metadata, key)
+ }
+ }
+ if len(rendered.Metadata) == 0 {
+ rendered.Metadata = nil
+ }
+ return common.Marshal(rendered)
+}
+
+func (a *TaskAdaptor) ListArtifacts(task *model.Task) ([]channel.TaskArtifact, error) {
+ if !a.hasHook(context.Background(), "listArtifacts") {
+ return nil, nil
+ }
+ ctx, err := taskArtifactContext(task)
+ if err != nil {
+ return nil, err
+ }
+ value, err := a.plugin.Engine.Call(context.Background(), "listArtifacts", ctx)
+ if err != nil {
+ return nil, fmt.Errorf("plugin artifact listing failed")
+ }
+ return validateTaskArtifacts(value)
+}
+
+func (a *TaskAdaptor) BuildContentRequest(task *model.Task, artifactKey string, clientRequest channel.TaskArtifactClientRequest) (*channel.TaskContentRequest, error) {
+ if !a.hasHook(context.Background(), "buildContentRequest") {
+ return nil, nil
+ }
+ if a.info == nil {
+ return nil, fmt.Errorf("plugin adaptor is not initialized")
+ }
+ if !taskArtifactKeyPattern.MatchString(artifactKey) {
+ return nil, fmt.Errorf("invalid artifact key")
+ }
+ ctx, err := taskArtifactContext(task)
+ if err != nil {
+ return nil, err
+ }
+ ctx["upstreamTaskId"] = task.GetUpstreamTaskID()
+ ctx["artifactKey"] = artifactKey
+ ctx["baseUrl"] = a.info.ChannelBaseUrl
+ ctx["clientRequest"] = jsonValue(clientRequest)
+ proxy := a.info.ChannelSetting.Proxy
+ auth, err := resolveAuth(a.plugin.Meta.Auth, a.info.ApiKey, proxy)
+ if err != nil {
+ return nil, err
+ }
+ ctx["auth"] = auth
+ ctx["authHeader"] = auth["authHeader"]
+ if a.plugin.Meta.Auth.Type == "" || a.plugin.Meta.Auth.Type == "none" || a.plugin.Meta.Auth.Type == "api_key" {
+ ctx["apiKey"] = a.info.ApiKey
+ }
+ value, err := a.plugin.Engine.Call(context.Background(), "buildContentRequest", ctx)
+ if err != nil {
+ return nil, err
+ }
+ var descriptor requestDescriptor
+ if err = convert(value, &descriptor); err != nil {
+ return nil, err
+ }
+ method := strings.ToUpper(strings.TrimSpace(descriptor.Method))
+ if method == "" {
+ method = strings.ToUpper(strings.TrimSpace(clientRequest.Method))
+ }
+ if method == "" {
+ method = http.MethodGet
+ }
+ if method != http.MethodGet && method != http.MethodHead && method != http.MethodPost {
+ return nil, fmt.Errorf("plugin returned an unsupported artifact request method")
+ }
+ if descriptor.Credentialless {
+ if method != http.MethodGet && method != http.MethodHead {
+ return nil, fmt.Errorf("credentialless artifact requests must use GET or HEAD")
+ }
+ if len(descriptor.Headers) != 0 || descriptor.Body != nil {
+ return nil, fmt.Errorf("credentialless artifact requests cannot contain headers or a body")
+ }
+ parsedURL, parseErr := url.Parse(descriptor.URL)
+ if parseErr != nil || parsedURL.Host == "" || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
+ return nil, fmt.Errorf("credentialless artifact request URL must be absolute HTTP(S)")
+ }
+ } else if err = pluginruntime.ValidateRequestURL(descriptor.URL, a.info.ChannelBaseUrl, a.plugin.Meta.AllowedHosts); err != nil {
+ return nil, err
+ }
+ var body []byte
+ if descriptor.Body != nil {
+ if text, ok := descriptor.Body.(string); ok {
+ body = []byte(text)
+ } else {
+ body, err = common.Marshal(descriptor.Body)
+ if err != nil {
+ return nil, fmt.Errorf("plugin returned an invalid artifact request body")
+ }
+ }
+ }
+ return &channel.TaskContentRequest{
+ URL: descriptor.URL,
+ Method: method,
+ Headers: descriptor.Headers,
+ Body: body,
+ Credentialless: descriptor.Credentialless,
+ }, nil
+}
+
+func taskArtifactContext(task *model.Task) (map[string]any, error) {
+ if task == nil {
+ return nil, fmt.Errorf("task is required")
+ }
+ var data any
+ if len(task.Data) > 0 {
+ if err := common.Unmarshal(task.Data, &data); err != nil {
+ return nil, fmt.Errorf("task data is invalid")
+ }
+ }
+ producerVersion := ""
+ if task.PrivateData.Execution != nil && task.PrivateData.Execution.TaskPlugin != nil {
+ producerVersion = task.PrivateData.Execution.TaskPlugin.Version
+ }
+ return map[string]any{
+ "taskId": task.TaskID,
+ "status": string(task.Status),
+ "action": task.Action,
+ "data": data,
+ "producerVersion": producerVersion,
+ }, nil
+}
+
+func validateTaskArtifacts(value any) ([]channel.TaskArtifact, error) {
+ encoded, err := common.Marshal(value)
+ if err != nil {
+ return nil, fmt.Errorf("plugin returned invalid artifacts")
+ }
+ if common.GetJsonType(encoded) != "array" {
+ return nil, fmt.Errorf("plugin listArtifacts must return an array")
+ }
+ var items []map[string]any
+ if err = common.Unmarshal(encoded, &items); err != nil {
+ return nil, fmt.Errorf("plugin listArtifacts must return an array")
+ }
+ if len(items) > maxTaskArtifacts {
+ return nil, fmt.Errorf("plugin returned too many artifacts")
+ }
+ artifacts := make([]channel.TaskArtifact, 0, len(items))
+ keys := make(map[string]struct{}, len(items))
+ for _, item := range items {
+ for field := range item {
+ if field != "key" && field != "type" && field != "mimeType" {
+ return nil, fmt.Errorf("plugin artifact contains unsupported field %q", field)
+ }
+ }
+ key, keyOK := item["key"].(string)
+ artifactType, typeOK := item["type"].(string)
+ if !keyOK || !taskArtifactKeyPattern.MatchString(key) {
+ return nil, fmt.Errorf("plugin artifact has invalid key")
+ }
+ if _, exists := keys[key]; exists {
+ return nil, fmt.Errorf("plugin artifact keys must be unique")
+ }
+ keys[key] = struct{}{}
+ switch artifactType {
+ case "video", "audio", "image", "file":
+ default:
+ if !typeOK {
+ return nil, fmt.Errorf("plugin artifact has invalid type")
+ }
+ return nil, fmt.Errorf("plugin artifact has unsupported type")
+ }
+ mimeType := ""
+ if rawMimeType, exists := item["mimeType"]; exists {
+ var mimeTypeOK bool
+ mimeType, mimeTypeOK = rawMimeType.(string)
+ if !mimeTypeOK {
+ return nil, fmt.Errorf("plugin artifact has invalid mimeType")
+ }
+ }
+ artifacts = append(artifacts, channel.TaskArtifact{
+ Key: key,
+ Type: artifactType,
+ MimeType: mimeType,
+ })
+ }
+ return artifacts, nil
+}
+
+func (a *TaskAdaptor) buildSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*requestDescriptor, error) {
+ if a.submit != nil {
+ return a.submit, nil
+ }
+ started := time.Now()
+ value, err := a.plugin.Engine.Call(c.Request.Context(), "buildSubmitRequest", a.submitContext(c, info))
+ if err != nil {
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=adaptor event=build_submit_failed plugin=%q stage=build_submit_request reason=hook_failed elapsed_ms=%d",
+ a.plugin.Meta.Key,
+ time.Since(started).Milliseconds(),
+ )
+ return nil, err
+ }
+ var descriptor requestDescriptor
+ if err = convert(value, &descriptor); err != nil {
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=adaptor event=build_submit_failed plugin=%q stage=build_submit_request reason=invalid_descriptor elapsed_ms=%d",
+ a.plugin.Meta.Key,
+ time.Since(started).Milliseconds(),
+ )
+ return nil, err
+ }
+ if strings.TrimSpace(descriptor.URL) == "" {
+ logger.LogDebug(c, "task_plugin subsystem=adaptor event=build_submit_failed plugin=%q stage=validate_url reason=empty_url", a.plugin.Meta.Key)
+ return nil, fmt.Errorf("plugin returned an empty submit URL")
+ }
+ if err = pluginruntime.ValidateRequestURL(descriptor.URL, info.ChannelBaseUrl, a.plugin.Meta.AllowedHosts); err != nil {
+ logger.LogDebug(c, "task_plugin subsystem=adaptor event=build_submit_failed plugin=%q stage=validate_url reason=url_not_allowed", a.plugin.Meta.Key)
+ return nil, err
+ }
+ if descriptor.Action != "" {
+ info.Action = descriptor.Action
+ }
+ if descriptor.Model != "" {
+ if _, pinnedEndpoint := c.Get(pluginruntime.ContextKeyPinnedEndpoint); pinnedEndpoint {
+ resolvedModel := c.GetString("resolved_task_model")
+ if resolvedModel == "" || descriptor.Model != resolvedModel {
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=adaptor event=build_submit_failed plugin=%q stage=model_pin reason=model_mismatch",
+ a.plugin.Meta.Key,
+ )
+ return nil, fmt.Errorf("plugin submit model does not match the pinned endpoint model")
+ }
+ }
+ info.OriginModelName = descriptor.Model
+ }
+ if descriptor.RewriteModel != "" {
+ info.UpstreamModelName = descriptor.RewriteModel
+ }
+ a.submit = &descriptor
+ method := strings.ToUpper(strings.TrimSpace(descriptor.Method))
+ if method == "" {
+ method = http.MethodPost
+ }
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=adaptor event=build_submit_complete plugin=%q method=%q body_type=%q parts=%d model=%q action_present=%t rewrite_model=%t elapsed_ms=%d",
+ a.plugin.Meta.Key,
+ method,
+ descriptor.BodyType,
+ len(descriptor.Parts),
+ info.OriginModelName,
+ info.Action != "",
+ descriptor.RewriteModel != "",
+ time.Since(started).Milliseconds(),
+ )
+ return a.submit, nil
+}
+
+func (a *TaskAdaptor) submitContext(c *gin.Context, info *relaycommon.RelayInfo) map[string]any {
+ routeRequest := pluginruntime.RouteRequestContext{
+ Params: map[string]string{},
+ Query: map[string][]string{},
+ RequestBody: map[string]any{},
+ }
+ requestHeaders := map[string]string{}
+ files := make([]map[string]any, 0)
+ if a.routeRequest != nil {
+ routeRequest = *a.routeRequest
+ requestHeaders = make(map[string]string, len(a.requestHeaders))
+ for name, value := range a.requestHeaders {
+ requestHeaders[name] = value
+ }
+ files = append(files, a.files...)
+ }
+ if c != nil {
+ requestHeaders = map[string]string{}
+ files = make([]map[string]any, 0)
+ if prepared, exists := c.Get(pluginruntime.ContextKeyRouteRequest); exists {
+ if canonical, ok := prepared.(pluginruntime.RouteRequestContext); ok {
+ routeRequest = canonical
+ }
+ }
+ if taskRequest, exists := c.Get("task_request"); exists {
+ routeRequest.RequestBody = jsonValue(taskRequest)
+ }
+ if c.Request != nil {
+ if routeRequest.Path == "" {
+ routeRequest.Path = c.Request.URL.Path
+ }
+ if routeRequest.Method == "" {
+ routeRequest.Method = c.Request.Method
+ }
+ if len(routeRequest.Params) == 0 {
+ routeRequest.Params = make(map[string]string, len(c.Params))
+ for _, param := range c.Params {
+ routeRequest.Params[param.Key] = param.Value
+ }
+ }
+ if len(routeRequest.Query) == 0 {
+ routeRequest.Query = make(map[string][]string, len(c.Request.URL.Query()))
+ for key, values := range c.Request.URL.Query() {
+ routeRequest.Query[key] = append([]string(nil), values...)
+ }
+ }
+ requestHeaders["Content-Type"] = c.GetHeader("Content-Type")
+ requestHeaders["Accept"] = c.GetHeader("Accept")
+ if strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") {
+ if form, err := common.ParseMultipartFormReusable(c); err == nil {
+ defer form.RemoveAll()
+ for field, headers := range form.File {
+ for _, header := range headers {
+ files = append(files, map[string]any{"ref": "request_file:" + field, "field": field, "filename": header.Filename, "mimeType": header.Header.Get("Content-Type"), "size": header.Size})
+ }
+ }
+ }
+ }
+ }
+ snapshot := routeRequest
+ a.routeRequest = &snapshot
+ a.requestHeaders = make(map[string]string, len(requestHeaders))
+ maps.Copy(a.requestHeaders, requestHeaders)
+ a.files = append(a.files[:0], files...)
+ }
+ ctx := routeRequest.JSValue()
+ ctx["requestBody"] = jsonValue(routeRequest.RequestBody)
+ ctx["requestHeaders"] = requestHeaders
+ ctx["files"] = files
+ ctx["action"] = info.Action
+ ctx["originTaskId"] = info.OriginTaskID
+ if info.TaskRelayInfo != nil && len(info.OriginTasks) > 0 {
+ originTasks := make([]map[string]any, 0, len(info.OriginTasks))
+ for _, ref := range info.OriginTasks {
+ var data any
+ if len(ref.Data) > 0 {
+ if err := common.Unmarshal(ref.Data, &data); err != nil {
+ data = nil
+ }
+ }
+ originTasks = append(originTasks, map[string]any{
+ "taskId": ref.TaskID,
+ "upstreamTaskId": ref.UpstreamTaskID,
+ "action": ref.Action,
+ "status": ref.Status,
+ "data": data,
+ })
+ }
+ ctx["originTasks"] = originTasks
+ }
+ ctx["publicTaskId"] = info.PublicTaskID
+ ctx["model"] = info.OriginModelName
+ ctx["upstreamModel"] = info.UpstreamModelName
+ ctx["baseUrl"] = info.ChannelBaseUrl
+ ctx["userSetting"] = info.UserSetting
+ proxy := ""
+ proxy = info.ChannelSetting.Proxy
+ if auth, err := resolveAuth(a.plugin.Meta.Auth, info.ApiKey, proxy); err == nil {
+ ctx["auth"] = auth
+ ctx["authHeader"] = auth["authHeader"]
+ if a.plugin.Meta.Auth.Type == "" || a.plugin.Meta.Auth.Type == "none" || a.plugin.Meta.Auth.Type == "api_key" {
+ ctx["apiKey"] = info.ApiKey
+ }
+ } else {
+ ctx["authError"] = err.Error()
+ }
+ return ctx
+}
+
+func (a *TaskAdaptor) usageRatios(ctx context.Context, hook string, args ...any) (map[string]float64, error) {
+ if !a.hasHook(ctx, hook) {
+ return nil, nil
+ }
+ started := time.Now()
+ value, err := a.plugin.Engine.Call(ctx, hook, args...)
+ if err != nil {
+ logger.LogDebug(ctx, "task_plugin subsystem=adaptor event=usage_hook_failed plugin=%q hook=%q reason=hook_failed elapsed_ms=%d", a.plugin.Meta.Key, hook, time.Since(started).Milliseconds())
+ return nil, fmt.Errorf("plugin usage hook failed")
+ }
+ if value == nil {
+ return nil, nil
+ }
+ facts, ok := value.(map[string]any)
+ if !ok {
+ logger.LogDebug(ctx, "task_plugin subsystem=adaptor event=usage_hook_failed plugin=%q hook=%q reason=result_not_object elapsed_ms=%d", a.plugin.Meta.Key, hook, time.Since(started).Milliseconds())
+ return nil, fmt.Errorf("plugin usage hook must return an object")
+ }
+ ratios, err := a.validatedUsageRatios(facts)
+ if err != nil {
+ logger.LogDebug(ctx, "task_plugin subsystem=adaptor event=usage_hook_failed plugin=%q hook=%q reason=invalid_usage elapsed_ms=%d", a.plugin.Meta.Key, hook, time.Since(started).Milliseconds())
+ return nil, err
+ }
+ logger.LogDebug(
+ ctx,
+ "task_plugin subsystem=adaptor event=usage_hook_complete plugin=%q hook=%q facts=%d positive_ratios=%d elapsed_ms=%d",
+ a.plugin.Meta.Key,
+ hook,
+ len(facts),
+ len(ratios),
+ time.Since(started).Milliseconds(),
+ )
+ return ratios, nil
+}
+
+func (a *TaskAdaptor) validateResolvedUsageRequest(request any) error {
+ return a.validateResolvedUsageValue(jsonValue(request))
+}
+
+func (a *TaskAdaptor) validateResolvedUsageValue(value any) error {
+ switch typed := value.(type) {
+ case map[string]any:
+ for key, item := range typed {
+ if schema, declared := a.plugin.Meta.UsageSchema[key]; declared {
+ if _, err := validateUsageValue(item, schema, true); err != nil {
+ return err
+ }
+ } else if limit, canonical := canonicalUsageLimit(key); canonical {
+ if err := validateUsageLimit(item, limit, true); err != nil {
+ return err
+ }
+ }
+ if err := a.validateResolvedUsageValue(item); err != nil {
+ return err
+ }
+ }
+ case []any:
+ for _, item := range typed {
+ if err := a.validateResolvedUsageValue(item); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func (a *TaskAdaptor) validatedUsageRatios(facts map[string]any) (map[string]float64, error) {
+ ratios := make(map[string]float64)
+ for key, value := range facts {
+ if schema, declared := a.plugin.Meta.UsageSchema[key]; declared {
+ number, err := validateUsageValue(value, schema, false)
+ if err != nil {
+ return nil, err
+ }
+ if schema.Type == "number" {
+ facts[key] = number
+ if number > 0 {
+ ratios[key] = number
+ }
+ }
+ continue
+ }
+ number, numeric := usageNumber(value, false)
+ if !numeric {
+ continue
+ }
+ limit, canonical := canonicalUsageLimit(key)
+ if !canonical {
+ // Undeclared numeric facts remain extensible, but still use the
+ // largest canonical task multiplier ceiling so they cannot be
+ // unbounded before quota calculation.
+ limit = relaycommon.MaxTaskDurationSeconds
+ }
+ if err := validateUsageNumberLimit(number, limit); err != nil {
+ return nil, err
+ }
+ if number > 0 {
+ ratios[key] = number
+ }
+ }
+ return ratios, nil
+}
+
+func (a *TaskAdaptor) validatedCompletionUsageFacts(facts any) (map[string]any, error) {
+ if facts == nil {
+ return nil, nil
+ }
+ values, ok := facts.(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("plugin usage hook must return an object")
+ }
+ validated := make(map[string]any, len(values))
+ for key, value := range values {
+ validated[key] = value
+ if schema, declared := a.plugin.Meta.UsageSchema[key]; declared {
+ number, err := validateUsageValue(value, schema, false)
+ if err != nil {
+ return nil, err
+ }
+ if schema.Type == "number" {
+ validated[key] = number
+ }
+ continue
+ }
+ if limit, canonical := canonicalUsageLimit(key); canonical {
+ number, numeric := usageNumber(value, false)
+ if !numeric {
+ return nil, fmt.Errorf("plugin usage value must be a number")
+ }
+ if err := validateUsageNumberLimit(number, limit); err != nil {
+ return nil, err
+ }
+ validated[key] = number
+ continue
+ }
+ switch key {
+ case "upstreamUnits", "completionTokens", "totalTokens":
+ number, numeric := usageNumber(value, false)
+ if !numeric || math.IsNaN(number) || math.IsInf(number, 0) || number < 0 {
+ return nil, fmt.Errorf("plugin usage value must be a finite non-negative number")
+ }
+ validated[key] = float64(common.QuotaFromFloat(number))
+ }
+ }
+ return validated, nil
+}
+
+func validateUsageValue(value any, schema pluginruntime.UsageFieldSchema, allowNumericString bool) (float64, error) {
+ if len(schema.Enum) > 0 {
+ text, ok := value.(string)
+ if !ok {
+ return 0, fmt.Errorf("plugin usage enum must be a string")
+ }
+ if slices.Contains(schema.Enum, text) {
+ return 0, nil
+ }
+ return 0, fmt.Errorf("plugin usage enum is not an allowed value")
+ }
+ if schema.Type == "boolean" {
+ if _, ok := value.(bool); !ok {
+ return 0, fmt.Errorf("plugin usage value must be a boolean")
+ }
+ return 0, nil
+ }
+ number, ok := usageNumber(value, allowNumericString)
+ if !ok {
+ return 0, fmt.Errorf("plugin usage value must be a number")
+ }
+ if schema.Unit == "token" || schema.Unit == "credit" {
+ if math.IsNaN(number) || math.IsInf(number, 0) || number < 0 {
+ return 0, fmt.Errorf("plugin usage value must be a finite non-negative number")
+ }
+ // Bound-check with QuotaFromFloatChecked (int32 saturation) but keep
+ // the original fractional part so credit facts like 3.5 survive.
+ if quota, clamp := common.QuotaFromFloatChecked(number); clamp != nil {
+ return float64(quota), nil
+ }
+ return number, nil
+ }
+ limit := relaycommon.MaxTaskDurationSeconds
+ if schema.Unit == "count" {
+ limit = kitdto.MaxImageN
+ }
+ if err := validateUsageNumberLimit(number, limit); err != nil {
+ return 0, err
+ }
+ return number, nil
+}
+
+func validateUsageLimit(value any, limit int, allowNumericString bool) error {
+ number, ok := usageNumber(value, allowNumericString)
+ if !ok {
+ return fmt.Errorf("plugin usage value must be a number")
+ }
+ return validateUsageNumberLimit(number, limit)
+}
+
+func validateUsageNumberLimit(number float64, limit int) error {
+ if math.IsNaN(number) || math.IsInf(number, 0) || number < 0 {
+ return fmt.Errorf("plugin usage value must be a finite non-negative number")
+ }
+ if number > float64(limit) {
+ return fmt.Errorf("plugin usage value exceeds the host limit")
+ }
+ return nil
+}
+
+func usageNumber(value any, allowNumericString bool) (float64, bool) {
+ switch number := value.(type) {
+ case float64:
+ return number, true
+ case int64:
+ return float64(number), true
+ case int:
+ return float64(number), true
+ case string:
+ if !allowNumericString {
+ return 0, false
+ }
+ parsed, err := strconv.ParseFloat(strings.TrimSpace(number), 64)
+ return parsed, err == nil
+ default:
+ return 0, false
+ }
+}
+
+func canonicalUsageLimit(key string) (int, bool) {
+ normalized := strings.NewReplacer("_", "", "-", "").Replace(strings.ToLower(key))
+ switch normalized {
+ case "duration", "durationseconds", "second", "seconds":
+ return relaycommon.MaxTaskDurationSeconds, true
+ case "n", "count", "imagecount", "samplecount", "batchcount", "numimages":
+ return kitdto.MaxImageN, true
+ default:
+ return 0, false
+ }
+}
+
+func (a *TaskAdaptor) logRejectedUsage(hook string, _ error) {
+ common.SysError(fmt.Sprintf("task plugin %s rejected invalid %s billing facts", a.plugin.Meta.Key, hook))
+}
+
+func (a *TaskAdaptor) hasHook(ctx context.Context, hook string) bool {
+ has, err := a.plugin.Engine.HasExport(ctx, hook)
+ return err == nil && has
+}
+func convert(value any, target any) error {
+ data, err := common.Marshal(value)
+ if err != nil {
+ return err
+ }
+ return common.Unmarshal(data, target)
+}
+
+func jsonValue(value any) any {
+ data, err := common.Marshal(value)
+ if err != nil {
+ return value
+ }
+ var normalized any
+ if err = common.Unmarshal(data, &normalized); err != nil {
+ return value
+ }
+ return normalized
+}
+func positiveInt(value any) int {
+ switch number := value.(type) {
+ case int64:
+ if number <= 0 {
+ return 0
+ }
+ return common.QuotaFromFloat(float64(number))
+ case float64:
+ if number <= 0 {
+ return 0
+ }
+ return common.QuotaFromFloat(number)
+ default:
+ return 0
+ }
+}
+
+var _ channel.TaskAdaptor = (*TaskAdaptor)(nil)
+var _ channel.OpenAIVideoConverter = (*TaskAdaptor)(nil)
+var _ channel.TaskArtifactProvider = (*TaskAdaptor)(nil)
+var _ channel.TaskContentRequestProvider = (*TaskAdaptor)(nil)
+var _ channel.TaskUsageFactsProvider = (*TaskAdaptor)(nil)
+var _ channel.TaskValidatedBillingProvider = (*TaskAdaptor)(nil)
+var _ channel.TaskValidatedUsageFactsProvider = (*TaskAdaptor)(nil)
diff --git a/relay/channel/task/jsplugin/adaptor_test.go b/relay/channel/task/jsplugin/adaptor_test.go
new file mode 100644
index 000000000000..f7a48df304cc
--- /dev/null
+++ b/relay/channel/task/jsplugin/adaptor_test.go
@@ -0,0 +1,1156 @@
+package jsplugin
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "io"
+ "math"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "net/textproto"
+ "strings"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/model"
+ pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/QuantumNous/new-api/relay/channel"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const mockPlugin = `
+export const meta = {
+ apiVersion: 1, key: "mock-task", name: "Mock Task", version: "1.0.0",
+ author: {name: "Test"},
+ channelTypes: [1001], models: ["mock-v1"], fetchMode: "per_task",
+ protocols: ["openai_video"],
+ usageSchema: {seconds: {type: "number", unit: "second"}, mode: {enum: ["std", "pro"]}},
+};
+export function buildSubmitRequest(ctx) {
+ if (!ctx.requestBody.prompt) throw new Error("prompt required");
+ return { url: ctx.baseUrl + "/submit", method: "POST", headers: {"X-Plugin": "submit"}, body: {prompt: ctx.requestBody.prompt}, action: "text_to_video", model: "mock-v1", rewriteModel: "mock-upstream" };
+}
+
+export function parseSubmitResponse(ctx, resp) {
+ return {
+ taskId: resp.body.id,
+ taskData: {accepted: true, status: resp.statusCode},
+ };
+}
+export function extractUsage(ctx) { return {seconds: 5, mode: "pro"}; }
+export function extractUsageOnSubmit(ctx, data) { return {seconds: data.seconds || 7}; }
+export function extractUsageOnComplete(task, result) { return {upstreamUnits: 23}; }
+export function buildQueryRequest(ctx) { return {url: ctx.baseUrl + "/tasks/" + ctx.taskId, method: "GET", headers: {"X-Plugin": "query"}}; }
+export function parseTaskResult(ctx, body) { return {taskId: body.id, status: "SUCCESS", progress: "100%", url: body.url}; }
+export function listArtifacts() { return []; }
+export function buildContentRequest() { throw new Error("artifact_not_found"); }
+export const protocols = {openai_video: {
+ decodeRequest: function(ctx) { return {kind: "submit", model: ctx.model, requestBody: ctx.body.value}; },
+ render: function(ctx, task) { return {id: task.task_id, status: "completed"}; }
+}};
+`
+
+func TestTaskAdaptorRejectsDeprecatedClientResponse(t *testing.T) {
+ source := strings.Replace(mockPlugin, `taskData: {accepted: true, status: resp.statusCode},`, `taskData: {}, clientResponse: {id: ctx.publicTaskId},`, 1)
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+ info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}, TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_public"}}
+ adaptor.Init(info)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
+ response := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"id":"upstream"}`))}
+
+ parsed, taskErr := adaptor.ParseResponse(c, response, info)
+
+ assert.Nil(t, parsed)
+ require.NotNil(t, taskErr)
+ require.Error(t, taskErr.Error)
+ assert.Contains(t, taskErr.Error.Error(), "must not return clientResponse")
+}
+
+func TestTaskAdaptorBuildsMultipartFromOpaqueFileReference(t *testing.T) {
+ source := `
+export const meta = {apiVersion:1,key:"multipart",name:"Multipart",version:"1.0.0",author:{name:"Test"},models:["m"],fetchMode:"per_task"};
+export function buildSubmitRequest(ctx) { return {url:ctx.baseUrl+"/submit",bodyType:"multipart",parts:[{name:"model",value:"m"},{name:"input_reference",fileRef:ctx.files[0].ref}]}; }
+export function parseSubmitResponse(ctx,r){return {taskId:"1"}} export function buildQueryRequest(){return {url:"https://example.com"}} export function parseTaskResult(){return {status:"SUCCESS"}}
+`
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+ info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example"}, TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
+ adaptor.Init(info)
+ var input bytes.Buffer
+ writer := multipart.NewWriter(&input)
+ file, err := writer.CreateFormFile("input_reference", "ref.png")
+ require.NoError(t, err)
+ _, err = file.Write([]byte("image-bytes"))
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", bytes.NewReader(input.Bytes()))
+ c.Request.Header.Set("Content-Type", writer.FormDataContentType())
+ c.Set("task_request", relaycommon.TaskSubmitReq{Prompt: "p"})
+ body, err := adaptor.BuildRequestBody(c, info)
+ require.NoError(t, err)
+ requestBytes, err := io.ReadAll(body)
+ require.NoError(t, err)
+ reader := multipart.NewReader(bytes.NewReader(requestBytes), strings.TrimPrefix(c.GetHeader("Content-Type"), "multipart/form-data; boundary="))
+ form, err := reader.ReadForm(1024)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"m"}, form.Value["model"])
+ require.Len(t, form.File["input_reference"], 1)
+ opened, err := form.File["input_reference"][0].Open()
+ require.NoError(t, err)
+ content, err := io.ReadAll(opened)
+ require.NoError(t, err)
+ assert.Equal(t, "image-bytes", string(content))
+}
+
+func TestTaskAdaptorInlinesJSONFilePlaceholders(t *testing.T) {
+ const fileBytes = "image-bytes"
+ encoded := base64.StdEncoding.EncodeToString([]byte(fileBytes))
+ source := `
+export const meta = {apiVersion:1,key:"json-inline",name:"JSON Inline",version:"1.0.0",author:{name:"Test"},models:["m"],fetchMode:"per_task"};
+export function buildSubmitRequest(ctx) {
+ return {url:ctx.baseUrl+"/submit",body:{
+ prompt:"p",
+ image:{__fileRef:ctx.files[0].ref,encoding:"base64"},
+ nested:{items:[{__fileRef:ctx.files[0].ref,encoding:"dataUrl",mimeType:"image/png"}]},
+ dataUrl:{__fileRef:ctx.files[0].ref,encoding:"dataUrl"}
+ }};
+}
+export function parseSubmitResponse(){return {taskId:"1"}} export function buildQueryRequest(){return {url:"https://example.com"}} export function parseTaskResult(){return {status:"SUCCESS"}}
+`
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+ info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example"}, TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
+ adaptor.Init(info)
+ c := newMultipartFileContext(t, "input_reference", "ref.png", "image/jpeg", []byte(fileBytes))
+ c.Set("task_request", map[string]any{"prompt": "p"})
+ body, err := adaptor.BuildRequestBody(c, info)
+ require.NoError(t, err)
+ requestBytes, err := io.ReadAll(body)
+ require.NoError(t, err)
+ var decoded map[string]any
+ require.NoError(t, common.Unmarshal(requestBytes, &decoded))
+ assert.Equal(t, "p", decoded["prompt"])
+ assert.Equal(t, encoded, decoded["image"])
+ nested := decoded["nested"].(map[string]any)
+ items := nested["items"].([]any)
+ require.Len(t, items, 1)
+ assert.Equal(t, "data:image/png;base64,"+encoded, items[0])
+ assert.Equal(t, "data:image/jpeg;base64,"+encoded, decoded["dataUrl"])
+}
+
+func TestTaskAdaptorJSONFilePlaceholderErrors(t *testing.T) {
+ tests := []struct {
+ name string
+ part string
+ fileSize int
+ globalMB int
+ wantContain string
+ }{
+ {name: "unknown ref", part: `{__fileRef:"request_file:missing",encoding:"base64"}`, fileSize: 4, wantContain: `unknown file reference "request_file:missing"`},
+ {name: "extra key", part: `{__fileRef:"request_file:input_reference",encoding:"base64",extra:true}`, fileSize: 4, wantContain: "invalid file placeholder"},
+ {name: "missing encoding", part: `{__fileRef:"request_file:input_reference"}`, fileSize: 4, wantContain: "encoding"},
+ {name: "oversize maxBytes", part: `{__fileRef:"request_file:input_reference",encoding:"base64",maxBytes:3}`, fileSize: 4, wantContain: "3 byte limit"},
+ {name: "oversize global", part: `{__fileRef:"request_file:input_reference",encoding:"base64"}`, fileSize: 2 << 20, globalMB: 1, wantContain: "1048576 byte limit"},
+ {name: "multiple references cap", part: `{a:{__fileRef:"request_file:input_reference",encoding:"base64"},b:{__fileRef:"request_file:input_reference",encoding:"base64"}}`, fileSize: 700 << 10, globalMB: 1, wantContain: "1048576 byte limit"},
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ if testCase.globalMB > 0 {
+ previous := constant.MaxFileDownloadMB
+ constant.MaxFileDownloadMB = testCase.globalMB
+ t.Cleanup(func() { constant.MaxFileDownloadMB = previous })
+ }
+ source := strings.Replace(`
+export const meta = {apiVersion:1,key:"json-inline-err",name:"JSON Inline Err",version:"1.0.0",author:{name:"Test"},models:["m"],fetchMode:"per_task"};
+export function buildSubmitRequest() { return {url:"https://provider.example/submit",body:PLACEHOLDER}; }
+export function parseSubmitResponse(){return {taskId:"1"}} export function buildQueryRequest(){return {url:"https://example.com"}} export function parseTaskResult(){return {status:"SUCCESS"}}
+`, "PLACEHOLDER", testCase.part, 1)
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+ info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example"}, TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
+ adaptor.Init(info)
+ c := newMultipartFileContext(t, "input_reference", "ref.bin", "application/octet-stream", bytes.Repeat([]byte("x"), testCase.fileSize))
+ c.Set("task_request", map[string]any{"prompt": "p"})
+ _, err = adaptor.BuildRequestBody(c, info)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), testCase.wantContain)
+ })
+ }
+}
+
+func newMultipartFileContext(t *testing.T, field, filename, contentType string, content []byte) *gin.Context {
+ t.Helper()
+ var input bytes.Buffer
+ writer := multipart.NewWriter(&input)
+ part, err := writer.CreatePart(textproto.MIMEHeader{
+ "Content-Disposition": {`form-data; name="` + field + `"; filename="` + filename + `"`},
+ "Content-Type": {contentType},
+ })
+ require.NoError(t, err)
+ _, err = part.Write(content)
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", bytes.NewReader(input.Bytes()))
+ c.Request.Header.Set("Content-Type", writer.FormDataContentType())
+ return c
+}
+
+func TestTaskAdaptorDoesNotEmitInjectedMultipartDispositionHeaders(t *testing.T) {
+ tests := []struct {
+ name string
+ part string
+ }{
+ {name: "part name", part: `{name:"prompt\r\nX-Injected: yes",value:"hello"}`},
+ {name: "filename", part: `{name:"input_reference",fileRef:"request_file:input_reference",filename:"safe.png\r\nX-Injected: yes"}`},
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ source := strings.Replace(`
+export const meta = {apiVersion:1,key:"multipart-safe",name:"Multipart Safe",version:"1.0.0",author:{name:"Test"},models:["m"],fetchMode:"per_task"};
+export function buildSubmitRequest(ctx) { return {url:ctx.baseUrl+"/submit",bodyType:"multipart",parts:[PART]}; }
+export function parseSubmitResponse(){return {taskId:"1"}} export function buildQueryRequest(){return {}} export function parseTaskResult(){return {status:"SUCCESS"}}
+`, "PART", testCase.part, 1)
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+ info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example"}, TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
+ adaptor.Init(info)
+ var input bytes.Buffer
+ writer := multipart.NewWriter(&input)
+ file, err := writer.CreateFormFile("input_reference", "input.png")
+ require.NoError(t, err)
+ _, err = file.Write([]byte("image"))
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", bytes.NewReader(input.Bytes()))
+ c.Request.Header.Set("Content-Type", writer.FormDataContentType())
+ c.Set("task_request", map[string]any{"model": "m"})
+
+ body, buildErr := adaptor.BuildRequestBody(c, info)
+ if buildErr != nil {
+ assert.Contains(t, buildErr.Error(), "multipart")
+ return
+ }
+ encoded, err := io.ReadAll(body)
+ require.NoError(t, err)
+ assert.NotContains(t, string(encoded), "\r\nX-Injected: yes")
+ })
+ }
+}
+
+func TestTaskAdaptorRejectsPostDistributionEndpointModelDrift(t *testing.T) {
+ source := `
+export const meta = {apiVersion:1,key:"endpoint-drift",name:"Endpoint Drift",version:"1.0.0",author:{name:"Test"},models:["claimed-model"],fetchMode:"per_task"};
+export function buildSubmitRequest(ctx) {
+ return {url:ctx.baseUrl+"/submit",method:"POST",model:"outside-model",rewriteModel:"allowed-upstream-rewrite"};
+}
+
+export function parseSubmitResponse(){return {taskId:"1"}}
+export function buildQueryRequest(){return {url:"https://example.com"}}
+export function parseTaskResult(){return {status:"SUCCESS"}}
+`
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+ info := &relaycommon.RelayInfo{
+ ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example"},
+ TaskRelayInfo: &relaycommon.TaskRelayInfo{},
+ OriginModelName: "claimed-model",
+ }
+ adaptor.Init(info)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
+ c.Set("task_request", map[string]any{"model": "claimed-model"})
+ c.Set("resolved_task_model", "claimed-model")
+ c.Set(pluginruntime.ContextKeyPinnedEndpoint, pluginruntime.PinnedEndpoint{
+ Plugin: plugin,
+ })
+
+ taskErr := adaptor.ValidateRequestAndSetAction(c, info)
+
+ require.NotNil(t, taskErr)
+ assert.Equal(t, "claimed-model", info.OriginModelName)
+ assert.Contains(t, taskErr.Message, "does not match")
+}
+
+func TestTaskAdaptorReDecodesFinalCandidateAndRejectsModelDrift(t *testing.T) {
+ source := `
+export const meta = {apiVersion:1,key:"redecode",name:"Redecode",version:"1.0.0",author:{name:"Test"},models:["claimed-model"],fetchMode:"per_task",protocols:[{name:"openai_responses",supports:["sync","background"]}]};
+let calls = 0;
+export const protocols = {openai_responses:{decodeRequest:function(ctx){calls++;return {kind:"submit",model:calls === 1 ? ctx.model : "drifted-model",requestBody:ctx.body.value};},renderFinal:function(){return {};}}};
+export function buildSubmitRequest(ctx){return {url:ctx.baseUrl+"/submit"}} export function parseSubmitResponse(){return {taskId:"one"}} export function buildQueryRequest(){return {}} export function parseTaskResult(){return {status:"SUCCESS"}}
+`
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+ protocolContext := pluginruntime.ProtocolRequestContext{
+ RouteRequestContext: pluginruntime.RouteRequestContext{Body: map[string]any{"kind": "json", "value": map[string]any{"model": "claimed-model"}}, RequestBody: map[string]any{"model": "claimed-model"}},
+ Protocol: "openai_responses", Model: "claimed-model",
+ }
+ _, err = plugin.Engine.CallPath(context.Background(), "protocols", []string{"openai_responses", "decodeRequest"}, protocolContext.JSValue())
+ require.NoError(t, err)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
+ c.Set(pluginruntime.ContextKeyPinnedEndpoint, pluginruntime.PinnedEndpoint{Plugin: plugin, Protocol: "openai_responses", Model: "claimed-model"})
+ c.Set(pluginruntime.ContextKeyProtocolRequest, protocolContext)
+ info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example"}, TaskRelayInfo: &relaycommon.TaskRelayInfo{}, OriginModelName: "claimed-model"}
+ adaptor := New(plugin)
+ adaptor.Init(info)
+
+ taskErr := adaptor.ValidateRequestAndSetAction(c, info)
+
+ require.NotNil(t, taskErr)
+ assert.Equal(t, http.StatusBadRequest, taskErr.StatusCode)
+ assert.Contains(t, taskErr.Message, "pinned model")
+}
+
+func TestTaskAdaptorRejectsRendererFromFinalProtocolDecoder(t *testing.T) {
+ source := `
+export const meta = {apiVersion:1,key:"renderer-reject",name:"Renderer Reject",version:"1.0.0",author:{name:"Test"},models:["claimed-model"],fetchMode:"per_task",protocols:[{name:"openai_responses",supports:["sync","background"]}]};
+export const protocols = {openai_responses:{decodeRequest:function(ctx){return {kind:"submit",model:ctx.model,requestBody:ctx.body.value,renderer:"legacy"};},renderFinal:function(){return {};}}};
+export function buildSubmitRequest(ctx){return {url:ctx.baseUrl+"/submit"}} export function parseSubmitResponse(){return {taskId:"one"}} export function buildQueryRequest(){return {}} export function parseTaskResult(){return {status:"SUCCESS"}}
+`
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+ protocolContext := pluginruntime.ProtocolRequestContext{
+ RouteRequestContext: pluginruntime.RouteRequestContext{Body: map[string]any{"kind": "json", "value": map[string]any{"model": "claimed-model"}}},
+ Protocol: "openai_responses", Model: "claimed-model",
+ }
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
+ c.Set(pluginruntime.ContextKeyPinnedEndpoint, pluginruntime.PinnedEndpoint{Plugin: plugin, Protocol: "openai_responses", Model: "claimed-model"})
+ c.Set(pluginruntime.ContextKeyProtocolRequest, protocolContext)
+ info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example"}, TaskRelayInfo: &relaycommon.TaskRelayInfo{}, OriginModelName: "claimed-model"}
+ adaptor := New(plugin)
+ adaptor.Init(info)
+
+ taskErr := adaptor.ValidateRequestAndSetAction(c, info)
+
+ require.NotNil(t, taskErr)
+ assert.Equal(t, http.StatusBadRequest, taskErr.StatusCode)
+ assert.Contains(t, taskErr.Message, "must not return renderer")
+}
+
+func TestTaskAdaptorBuildContentRequestHookAndMissingFallback(t *testing.T) {
+ source := strings.Replace(mockPlugin, `export function listArtifacts() { return []; }
+export function buildContentRequest() { throw new Error("artifact_not_found"); }`, `export function listArtifacts(task) { return [{key: "video", type: "video", mimeType: "video/mp4"}]; }
+export function buildContentRequest(ctx) {
+ if (ctx.data.id !== "raw-upstream" || ctx.upstreamTaskId !== "upstream-task" || ctx.producerVersion !== "0.9.0") throw new Error("bad task context");
+ return {url: ctx.baseUrl + "/content/" + ctx.artifactKey, method: ctx.clientRequest.method, headers: {"X-Content": "plugin"}};
+}`, 1)
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+ adaptor.Init(&relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example", ApiKey: "key"}})
+ taskData, err := common.Marshal(map[string]any{"id": "raw-upstream"})
+ require.NoError(t, err)
+ task := &model.Task{
+ TaskID: "task-public", Status: model.TaskStatusSuccess, Data: taskData,
+ PrivateData: model.TaskPrivateData{
+ UpstreamTaskID: "upstream-task",
+ Execution: &model.TaskExecutionSnapshot{TaskPlugin: &model.TaskPluginSnapshot{
+ Version: "0.9.0",
+ }},
+ },
+ }
+ artifacts, err := adaptor.ListArtifacts(task)
+ require.NoError(t, err)
+ require.Equal(t, []channel.TaskArtifact{{Key: "video", Type: "video", MimeType: "video/mp4"}}, artifacts)
+ descriptor, err := adaptor.BuildContentRequest(task, "video", channel.TaskArtifactClientRequest{Method: http.MethodHead})
+ require.NoError(t, err)
+ require.NotNil(t, descriptor)
+ assert.Equal(t, "https://provider.example/content/video", descriptor.URL)
+ assert.Equal(t, http.MethodHead, descriptor.Method)
+ assert.Equal(t, "plugin", descriptor.Headers["X-Content"])
+
+ withoutHook, err := pluginruntime.NewRegistry().Register(`
+export const meta = {apiVersion:1,key:"no-artifacts",name:"No Artifacts",version:"1.0.0",author:{name:"Test"},models:["m"],fetchMode:"per_task"};
+export function buildSubmitRequest(){return {url:"https://provider.example"};}
+export function parseSubmitResponse(){return {taskId:"1"};}
+export function buildQueryRequest(){return {url:"https://provider.example"};}
+export function parseTaskResult(){return {status:"SUCCESS"};}
+`, pluginruntime.Options{})
+ require.NoError(t, err)
+ fallback := New(withoutHook)
+ fallback.Init(&relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example"}})
+ artifacts, err = fallback.ListArtifacts(&model.Task{})
+ require.NoError(t, err)
+ assert.Nil(t, artifacts)
+ descriptor, err = fallback.BuildContentRequest(&model.Task{}, "video", channel.TaskArtifactClientRequest{Method: http.MethodGet})
+ require.NoError(t, err)
+ assert.Nil(t, descriptor)
+}
+
+func TestTaskAdaptorRejectsInvalidArtifactProjection(t *testing.T) {
+ testCases := []struct {
+ name string
+ projection string
+ }{
+ {name: "duplicate key", projection: `[{key:"video",type:"video"},{key:"video",type:"video"}]`},
+ {name: "array index identity", projection: `[{key:"video",type:"video",index:0}]`},
+ {name: "upstream url", projection: `[{key:"video",type:"video",url:"https://cdn.example/video.mp4"}]`},
+ {name: "invalid key", projection: `[{key:"video/0",type:"video"}]`},
+ {name: "unsupported type", projection: `[{key:"video",type:"text"}]`},
+ }
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ source := strings.Replace(mockPlugin, `export function listArtifacts() { return []; }
+export function buildContentRequest() { throw new Error("artifact_not_found"); }`, `export function listArtifacts() { return `+testCase.projection+`; }
+export function buildContentRequest(ctx) { return {url:ctx.baseUrl+"/content",method:"GET"}; }
+`, 1)
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+ _, err = adaptor.ListArtifacts(&model.Task{TaskID: "task", Status: model.TaskStatusSuccess, Data: []byte(`{}`)})
+ require.Error(t, err)
+ })
+ }
+}
+
+func TestTaskAdaptorAllowsExplicitCredentiallessCDNRequest(t *testing.T) {
+ source := strings.Replace(mockPlugin, `export function listArtifacts() { return []; }
+export function buildContentRequest() { throw new Error("artifact_not_found"); }`, `export function listArtifacts() { return [{key:"video",type:"video"}]; }
+export function buildContentRequest(ctx) { return {url:"https://cdn.example/video.mp4",method:ctx.clientRequest.method,credentialless:true}; }
+`, 1)
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+ adaptor.Init(&relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example"}})
+ descriptor, err := adaptor.BuildContentRequest(
+ &model.Task{TaskID: "task", Data: []byte(`{}`)},
+ "video",
+ channel.TaskArtifactClientRequest{Method: http.MethodGet},
+ )
+ require.NoError(t, err)
+ require.NotNil(t, descriptor)
+ assert.True(t, descriptor.Credentialless)
+ assert.Equal(t, "https://cdn.example/video.mp4", descriptor.URL)
+}
+
+func TestTaskAdaptorMapsJSContract(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ service.InitHttpClient()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/submit":
+ assert.Equal(t, "submit", r.Header.Get("X-Plugin"))
+ body, err := io.ReadAll(r.Body)
+ require.NoError(t, err)
+ assert.JSONEq(t, `{"prompt":"hello"}`, string(body))
+ _, _ = w.Write([]byte(`{"id":"upstream-1"}`))
+ case "/tasks/upstream-1":
+ assert.Equal(t, "query", r.Header.Get("X-Plugin"))
+ _, _ = w.Write([]byte(`{"id":"upstream-1","url":"https://cdn.example/video.mp4"}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ registry := pluginruntime.NewRegistry()
+ plugin, err := registry.Register(mockPlugin, pluginruntime.Options{Key: "mock-task", Version: "1.0.0"})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+ info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: server.URL, ApiKey: "secret"}, OriginModelName: "client-model", TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_public"}}
+ adaptor.Init(info)
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
+ c.Set("task_request", relaycommon.TaskSubmitReq{Prompt: "hello"})
+ require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
+ assert.Equal(t, "text_to_video", info.Action)
+ assert.Equal(t, "mock-v1", info.OriginModelName)
+ assert.Equal(t, "mock-upstream", info.UpstreamModelName)
+ assert.Equal(t, []string{"mock-v1"}, adaptor.GetModelList())
+ assert.Equal(t, "Mock Task", adaptor.GetChannelName())
+ assert.Equal(t, map[string]float64{"seconds": 5}, adaptor.EstimateBilling(c, info))
+
+ requestBody, err := adaptor.BuildRequestBody(c, info)
+ require.NoError(t, err)
+ url, err := adaptor.BuildRequestURL(info)
+ require.NoError(t, err)
+ assert.Equal(t, server.URL+"/submit", url)
+ req := httptest.NewRequest(http.MethodPost, url, nil)
+ require.NoError(t, adaptor.BuildRequestHeader(c, req, info))
+ assert.Equal(t, "submit", req.Header.Get("X-Plugin"))
+
+ resp, err := adaptor.DoRequest(c, info, requestBody)
+ require.NoError(t, err)
+ parsed, taskErr := adaptor.ParseResponse(c, resp, info)
+ require.Nil(t, taskErr)
+ require.NotNil(t, parsed)
+ assert.Equal(t, "upstream-1", parsed.UpstreamTaskID)
+ assert.JSONEq(t, `{"accepted":true,"status":200}`, string(parsed.TaskData))
+ assert.Nil(t, parsed.ClientResponse)
+ assert.Empty(t, recorder.Body.String(), "response parsing must not write before the durable task barrier")
+ assert.Equal(t, map[string]float64{"seconds": 7}, adaptor.AdjustBillingOnSubmit(info, []byte(`{"seconds":7}`)))
+
+ queryResp, err := adaptor.FetchTask(server.URL, "secret", map[string]any{"task_id": parsed.UpstreamTaskID, "action": info.Action}, "")
+ require.NoError(t, err)
+ queryBody, err := io.ReadAll(queryResp.Body)
+ require.NoError(t, err)
+ require.NoError(t, queryResp.Body.Close())
+ result, err := adaptor.ParseTaskResult(queryBody)
+ require.NoError(t, err)
+ assert.Equal(t, "SUCCESS", result.Status)
+ assert.Equal(t, "https://cdn.example/video.mp4", result.Url)
+ assert.Zero(t, adaptor.AdjustBillingOnComplete(&model.Task{}, result))
+ assert.Equal(t, 23, result.TotalTokens)
+
+ rendered, err := adaptor.ConvertToOpenAIVideo(&model.Task{TaskID: "task_public", Status: model.TaskStatusSuccess})
+ require.NoError(t, err)
+ assert.JSONEq(t, `{
+ "id":"task_public",
+ "object":"video",
+ "model":"",
+ "status":"completed",
+ "progress":0,
+ "created_at":0
+ }`, string(rendered))
+ _, err = plugin.Engine.Export(context.Background(), "meta")
+ require.NoError(t, err)
+}
+
+func TestTaskAdaptorSanitizesOpenAIVideoRendererOutput(t *testing.T) {
+ source := `
+export const meta = {
+ apiVersion: 1, key: "safe-video", name: "Safe Video", version: "1.0.0",
+ author: {name: "Test"}, models: ["model"], fetchMode: "per_task", protocols: ["openai_video"],
+};
+export function buildSubmitRequest(ctx) { return {url: ctx.baseUrl + "/submit"}; }
+export function parseSubmitResponse() { return {taskId: "upstream"}; }
+export function buildQueryRequest(ctx) { return {url: ctx.baseUrl + "/query"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+export function listArtifacts() { return []; }
+export function buildContentRequest() { throw new Error("artifact_not_found"); }
+export const protocols = {openai_video: {
+decodeRequest: function(ctx) { return {kind:"submit", model:ctx.model, requestBody:ctx.body.value}; },
+render: function() {
+ return {
+ id: "upstream-id",
+ task_id: "upstream-task-id",
+ object: "provider-video",
+ model: "model",
+ status: "completed",
+ progress: 100,
+ created_at: 10,
+ completed_at: 20,
+ metadata: {
+ url: "https://upstream.example/video.mp4",
+ URL: "https://upstream.example/uppercase.mp4",
+ label: "safe",
+ },
+ url: "https://upstream.example/top-level.mp4",
+ upstream_url: "https://upstream.example/unknown.mp4",
+ provider_payload: {task_id: "upstream-task-id"},
+ };
+}}};
+`
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+
+ rendered, err := adaptor.ConvertToOpenAIVideo(&model.Task{
+ TaskID: "task_public",
+ Status: model.TaskStatusInProgress,
+ Properties: model.Properties{OriginModelName: "origin-model"},
+ })
+ require.NoError(t, err)
+
+ var video dto.OpenAIVideo
+ require.NoError(t, common.Unmarshal(rendered, &video))
+ assert.Equal(t, "task_public", video.ID)
+ assert.Equal(t, "video", video.Object)
+ assert.Empty(t, video.TaskID)
+ assert.Equal(t, "origin-model", video.Model)
+ assert.Zero(t, video.CompletedAt)
+ assert.Equal(t, map[string]any{"label": "safe"}, video.Metadata)
+
+ var fields map[string]any
+ require.NoError(t, common.Unmarshal(rendered, &fields))
+ assert.NotContains(t, fields, "url")
+ assert.NotContains(t, fields, "upstream_url")
+ assert.NotContains(t, fields, "provider_payload")
+ assert.NotContains(t, fields, "completed_at")
+ assert.NotContains(t, string(rendered), "upstream.example")
+ assert.NotContains(t, string(rendered), "upstream-task-id")
+}
+
+func TestTaskAdaptorPreservesOpenAIVideoFailureSlotsAndOwnsLifecycle(t *testing.T) {
+ source := strings.Replace(mockPlugin, `render: function(ctx, task) { return {id: task.task_id, status: "completed"}; }`, `render: function() { return {id:"provider", object:"provider", model:"provider-model", status:"completed", progress:100, created_at:99, completed_at:20, error:{code:"provider_error",message:"provider rejected request"}}; }`, 1)
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+ task := &model.Task{
+ TaskID: "task_public",
+ Status: model.TaskStatusFailure,
+ FailReason: "provider secret",
+ CreatedAt: 10,
+ UpdatedAt: 20,
+ Properties: model.Properties{OriginModelName: "origin-model"},
+ }
+
+ rendered, err := adaptor.ConvertToOpenAIVideo(task)
+
+ require.NoError(t, err)
+ assert.JSONEq(t, `{"id":"task_public","object":"video","model":"origin-model","status":"failed","progress":0,"created_at":10,"error":{"message":"provider rejected request","code":"provider_error"}}`, string(rendered))
+}
+
+func TestTaskAdaptorBoundsNativeUsageBeforeQuotaCalculation(t *testing.T) {
+ source := `
+export const meta = {
+ apiVersion: 1, key: "bounded-usage", name: "Bounded Usage", version: "1.0.0",
+ author: {name: "Test"},
+ models: ["model"], fetchMode: "per_task",
+ usageSchema: {
+ duration: {type: "number", unit: "second"},
+ count: {type: "number", unit: "count"},
+ tokens: {type: "number", unit: "token"},
+ mode: {enum: ["std", "pro"]},
+ },
+ usageExamples: [{label: "std · 1s", facts: {duration: 1, count: 1, tokens: 1, mode: "std"}}],
+};
+export function buildSubmitRequest(ctx) {
+ return {url: ctx.baseUrl + "/submit", method: "POST", body: {}};
+}
+export function parseSubmitResponse() { return {taskId: "1"}; }
+export function buildQueryRequest() { return {url: "https://example.com"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+export function extractUsage(ctx) {
+ const entries = (ctx.requestBody || {}).hookUsageEntries || [];
+ const facts = {};
+ entries.forEach(function(entry) { facts[entry.name] = entry.value; });
+ return facts;
+}
+export function extractUsageOnSubmit(ctx, data) { return (data || {}).usage || {}; }
+export function extractUsageOnComplete(task, result, body) { return (body || {}).completionUsage || {}; }
+`
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+
+ newRequest := func(t *testing.T, requestBody map[string]any) (*TaskAdaptor, *gin.Context, *relaycommon.RelayInfo) {
+ t.Helper()
+ adaptor := New(plugin)
+ info := &relaycommon.RelayInfo{
+ ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example"},
+ TaskRelayInfo: &relaycommon.TaskRelayInfo{},
+ }
+ adaptor.Init(info)
+ context, _ := gin.CreateTestContext(httptest.NewRecorder())
+ context.Request = httptest.NewRequest(http.MethodPost, "/native/submit", nil)
+ context.Set("task_request", requestBody)
+ return adaptor, context, info
+ }
+
+ requestTests := []struct {
+ name string
+ body map[string]any
+ }{
+ {
+ name: "duration in resolved metadata",
+ body: map[string]any{"metadata": map[string]any{"duration": relaycommon.MaxTaskDurationSeconds + 1}},
+ },
+ {
+ name: "count in resolved metadata",
+ body: map[string]any{"metadata": map[string]any{"count": dto.MaxImageN + 1}},
+ },
+ {
+ name: "declared enum in resolved metadata",
+ body: map[string]any{"metadata": map[string]any{"mode": "turbo"}},
+ },
+ {
+ name: "implicit duration key without declaration",
+ body: map[string]any{"durationSeconds": relaycommon.MaxTaskDurationSeconds + 1},
+ },
+ {
+ name: "implicit count key without declaration",
+ body: map[string]any{"image_count": dto.MaxImageN + 1},
+ },
+ {
+ name: "negative resolved duration",
+ body: map[string]any{"duration": -1},
+ },
+ {
+ name: "non-finite resolved duration",
+ body: map[string]any{"duration": math.Inf(1)},
+ },
+ {
+ name: "metadata cannot hide behind valid top-level duration",
+ body: map[string]any{
+ "duration": relaycommon.MaxTaskDurationSeconds,
+ "metadata": map[string]any{"duration": relaycommon.MaxTaskDurationSeconds + 1},
+ },
+ },
+ {
+ name: "nested passthrough duration",
+ body: map[string]any{
+ "metadata": map[string]any{
+ "parameters": map[string]any{"duration": relaycommon.MaxTaskDurationSeconds + 1},
+ },
+ },
+ },
+ }
+ for _, testCase := range requestTests {
+ t.Run(testCase.name, func(t *testing.T) {
+ adaptor, context, info := newRequest(t, testCase.body)
+ taskErr := adaptor.ValidateRequestAndSetAction(context, info)
+ require.NotNil(t, taskErr)
+ assert.Equal(t, "plugin_usage_invalid", taskErr.Code)
+ })
+ }
+
+ hookTests := []struct {
+ name string
+ usage map[string]any
+ }{
+ {
+ name: "duration returned only by extractUsage",
+ usage: map[string]any{"duration": float64(relaycommon.MaxTaskDurationSeconds + 1)},
+ },
+ {
+ name: "count returned only by extractUsage",
+ usage: map[string]any{"count": float64(dto.MaxImageN + 1)},
+ },
+ {
+ name: "enum returned only by extractUsage",
+ usage: map[string]any{"mode": "turbo"},
+ },
+ {
+ name: "undeclared numeric ratio uses conservative host ceiling",
+ usage: map[string]any{"custom_ratio": float64(relaycommon.MaxTaskDurationSeconds + 1)},
+ },
+ {
+ name: "negative hook ratio",
+ usage: map[string]any{"custom_ratio": -1.0},
+ },
+ {
+ name: "non-finite hook ratio",
+ usage: map[string]any{"custom_ratio": math.NaN()},
+ },
+ }
+ for _, testCase := range hookTests {
+ t.Run(testCase.name, func(t *testing.T) {
+ entries := make([]any, 0, len(testCase.usage))
+ for key, value := range testCase.usage {
+ entries = append(entries, map[string]any{"name": key, "value": value})
+ }
+ adaptor, context, info := newRequest(t, map[string]any{"hookUsageEntries": entries})
+ require.Nil(t, adaptor.ValidateRequestAndSetAction(context, info))
+ ratios, err := adaptor.EstimateBillingValidated(context, info)
+ require.Error(t, err)
+ assert.Nil(t, ratios)
+ })
+ }
+
+ t.Run("numeric strings remain valid in vendor request fields", func(t *testing.T) {
+ adaptor, context, info := newRequest(t, map[string]any{
+ "metadata": map[string]any{
+ "duration": "5",
+ "count": "2",
+ "mode": "std",
+ },
+ })
+ assert.Nil(t, adaptor.ValidateRequestAndSetAction(context, info))
+ })
+
+ t.Run("numeric strings from usage hooks are rejected", func(t *testing.T) {
+ adaptor, context, info := newRequest(t, map[string]any{
+ "hookUsageEntries": []any{map[string]any{"name": "duration", "value": "5"}},
+ })
+ require.Nil(t, adaptor.ValidateRequestAndSetAction(context, info))
+ ratios, err := adaptor.EstimateBillingValidated(context, info)
+ require.Error(t, err)
+ assert.Nil(t, ratios)
+ })
+
+ t.Run("declared token facts use int32 saturation instead of duration cap", func(t *testing.T) {
+ adaptor, context, info := newRequest(t, map[string]any{
+ "hookUsageEntries": []any{
+ map[string]any{"name": "tokens", "value": float64(500000)},
+ },
+ })
+ require.Nil(t, adaptor.ValidateRequestAndSetAction(context, info))
+ facts, err := adaptor.ExtractUsageFactsValidated(context, info)
+ require.NoError(t, err)
+ assert.EqualValues(t, 500000, facts["tokens"])
+
+ ratios, err := adaptor.EstimateBillingValidated(context, info)
+ require.NoError(t, err)
+ assert.Equal(t, 500000.0, ratios["tokens"])
+ })
+
+ t.Run("declared token facts saturate at the int32 quota bound", func(t *testing.T) {
+ adaptor, context, info := newRequest(t, map[string]any{
+ "hookUsageEntries": []any{
+ map[string]any{"name": "tokens", "value": float64(common.MaxQuota) + 1},
+ },
+ })
+ require.Nil(t, adaptor.ValidateRequestAndSetAction(context, info))
+ facts, err := adaptor.ExtractUsageFactsValidated(context, info)
+ require.NoError(t, err)
+ assert.EqualValues(t, common.MaxQuota, facts["tokens"])
+ })
+
+ t.Run("canonical maxima and enum are accepted", func(t *testing.T) {
+ adaptor, context, info := newRequest(t, map[string]any{
+ "duration": relaycommon.MaxTaskDurationSeconds,
+ "count": dto.MaxImageN,
+ "mode": "std",
+ "hookUsageEntries": []any{
+ map[string]any{"name": "duration", "value": float64(relaycommon.MaxTaskDurationSeconds)},
+ map[string]any{"name": "count", "value": float64(dto.MaxImageN)},
+ map[string]any{"name": "mode", "value": "pro"},
+ },
+ })
+ require.Nil(t, adaptor.ValidateRequestAndSetAction(context, info))
+ ratios, err := adaptor.EstimateBillingValidated(context, info)
+ require.NoError(t, err)
+ assert.Equal(t, map[string]float64{
+ "duration": relaycommon.MaxTaskDurationSeconds,
+ "count": dto.MaxImageN,
+ }, ratios)
+ })
+
+ t.Run("runtime error does not expose plugin-controlled usage key", func(t *testing.T) {
+ adaptor, context, info := newRequest(t, map[string]any{
+ "hookUsageEntries": []any{
+ map[string]any{
+ "name": "https://private.invalid/?token=secret",
+ "value": float64(relaycommon.MaxTaskDurationSeconds + 1),
+ },
+ },
+ })
+ require.Nil(t, adaptor.ValidateRequestAndSetAction(context, info))
+ _, err := adaptor.EstimateBillingValidated(context, info)
+ require.Error(t, err)
+ assert.NotContains(t, err.Error(), "private.invalid")
+ assert.NotContains(t, err.Error(), "secret")
+ })
+
+ for _, testCase := range []struct {
+ name string
+ usage map[string]any
+ }{
+ {
+ name: "oversized completion duration is discarded",
+ usage: map[string]any{"duration": relaycommon.MaxTaskDurationSeconds + 1},
+ },
+ {
+ name: "oversized completion count is discarded",
+ usage: map[string]any{"count": dto.MaxImageN + 1},
+ },
+ {
+ name: "completion hook numeric string is discarded",
+ usage: map[string]any{"duration": "5"},
+ },
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ adaptor, _, _ := newRequest(t, map[string]any{})
+ body, marshalErr := common.Marshal(map[string]any{"completionUsage": testCase.usage})
+ require.NoError(t, marshalErr)
+ result, parseErr := adaptor.ParseTaskResult(body)
+ require.NoError(t, parseErr)
+ assert.Nil(t, result.UsageFacts)
+ assert.Zero(t, result.TotalTokens)
+ })
+ }
+
+ t.Run("declared completion token unit is saturated instead of discarded", func(t *testing.T) {
+ adaptor, _, _ := newRequest(t, map[string]any{})
+ body, err := common.Marshal(map[string]any{"completionUsage": map[string]any{"tokens": 500000}})
+ require.NoError(t, err)
+ result, err := adaptor.ParseTaskResult(body)
+ require.NoError(t, err)
+ assert.EqualValues(t, 500000, result.UsageFacts["tokens"])
+ })
+
+ t.Run("declared credit facts keep sub-integer precision", func(t *testing.T) {
+ source := `
+export const meta = {
+ apiVersion: 1, key: "credit-decimals", name: "Credit Decimals", version: "1.0.0",
+ author: {name: "Test"}, models: ["model"], fetchMode: "per_task",
+ usageSchema: {units: {type: "number", unit: "credit"}},
+ usageExamples: [{label: "3.5 credits", facts: {units: 3.5}}],
+};
+export function buildSubmitRequest(ctx) { return {url: ctx.baseUrl + "/submit"}; }
+export function parseSubmitResponse() { return {taskId: "task"}; }
+export function buildQueryRequest() { return {url: "https://example.com"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+export function extractUsage() { return {units: 3.5}; }
+export function extractUsageOnComplete() { return {units: 3.5}; }
+`
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+ info := &relaycommon.RelayInfo{
+ ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example"},
+ TaskRelayInfo: &relaycommon.TaskRelayInfo{},
+ }
+ adaptor.Init(info)
+ context, _ := gin.CreateTestContext(httptest.NewRecorder())
+ context.Request = httptest.NewRequest(http.MethodPost, "/native/submit", nil)
+ context.Set("task_request", map[string]any{"model": "model"})
+ require.Nil(t, adaptor.ValidateRequestAndSetAction(context, info))
+
+ facts, err := adaptor.ExtractUsageFactsValidated(context, info)
+ require.NoError(t, err)
+ assert.Equal(t, 3.5, facts["units"])
+
+ body, err := common.Marshal(map[string]any{})
+ require.NoError(t, err)
+ result, err := adaptor.ParseTaskResult(body)
+ require.NoError(t, err)
+ assert.Equal(t, 3.5, result.UsageFacts["units"])
+ })
+
+ t.Run("completion token facts are saturated instead of duration-capped", func(t *testing.T) {
+ adaptor, _, _ := newRequest(t, map[string]any{})
+ body, err := common.Marshal(map[string]any{"completionUsage": map[string]any{"upstreamUnits": 5000}})
+ require.NoError(t, err)
+ result, err := adaptor.ParseTaskResult(body)
+ require.NoError(t, err)
+ assert.Equal(t, 5000, result.TotalTokens)
+ assert.EqualValues(t, 5000, result.UsageFacts["upstreamUnits"])
+ })
+
+ t.Run("invalid post-submit adjustment is discarded before recalculation", func(t *testing.T) {
+ adaptor, _, info := newRequest(t, map[string]any{})
+ ratios := adaptor.AdjustBillingOnSubmit(info, []byte(`{"usage":{"duration":1000000000000000}}`))
+ assert.Nil(t, ratios)
+ })
+}
+
+func TestTaskAdaptorSeparatesExpressionFactsFromLegacyBillingRatios(t *testing.T) {
+ source := `
+export const meta = {
+ apiVersion: 1, key: "usage-purpose", name: "Usage Purpose", version: "1.0.0",
+ author: {name: "Test"}, models: ["usage-model"], fetchMode: "per_task",
+ usageSchema: {seconds: {type: "number", unit: "second"}},
+};
+export function buildSubmitRequest(ctx) { return {url: ctx.baseUrl + "/submit"}; }
+export function parseSubmitResponse() { return {taskId: "task"}; }
+export function buildQueryRequest(ctx) { return {url: ctx.baseUrl + "/query"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+export function extractUsage(ctx) {
+ return ctx.usagePurpose === "billing_ratios" ? {legacy_multiplier: 2} : {seconds: 5};
+}
+`
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+ info := &relaycommon.RelayInfo{
+ ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example"},
+ TaskRelayInfo: &relaycommon.TaskRelayInfo{},
+ }
+ adaptor.Init(info)
+ context, _ := gin.CreateTestContext(httptest.NewRecorder())
+ context.Request = httptest.NewRequest(http.MethodPost, "/submit", nil)
+ context.Set("task_request", map[string]any{"model": "usage-model"})
+ require.Nil(t, adaptor.ValidateRequestAndSetAction(context, info))
+
+ facts, err := adaptor.ExtractUsageFactsValidated(context, info)
+ require.NoError(t, err)
+ assert.EqualValues(t, 5, facts["seconds"])
+ assert.Len(t, facts, 1)
+
+ ratios, err := adaptor.EstimateBillingValidated(context, info)
+ require.NoError(t, err)
+ assert.Equal(t, map[string]float64{"legacy_multiplier": 2}, ratios)
+}
+
+func TestTaskAdaptorAcceptsNormalizedLegacyTokenCounters(t *testing.T) {
+ source := `
+export const meta = {apiVersion:1,key:"normalized-tokens",name:"Normalized Tokens",version:"1.0.0",author:{name:"Test"},models:["m"],fetchMode:"per_task"};
+export function buildSubmitRequest(ctx) { return {url: ctx.baseUrl + "/submit"}; }
+export function parseSubmitResponse() { return {taskId: "task"}; }
+export function buildQueryRequest(ctx) { return {url: ctx.baseUrl + "/query"}; }
+export function parseTaskResult(ctx, body) { return {status: "SUCCESS", completionTokens: body.completion, totalTokens: body.total}; }
+`
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+
+ result, err := adaptor.ParseTaskResult([]byte(`{"completion":13,"total":17}`))
+ require.NoError(t, err)
+ assert.Equal(t, 13, result.CompletionTokens)
+ assert.Equal(t, 17, result.TotalTokens)
+ assert.Nil(t, result.UsageFacts)
+}
+
+func TestSubmitContextExposesOriginTasks(t *testing.T) {
+ plugin, err := pluginruntime.NewRegistry().Register(mockPlugin, pluginruntime.Options{})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+ info := &relaycommon.RelayInfo{
+ ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example", ApiKey: "secret"},
+ TaskRelayInfo: &relaycommon.TaskRelayInfo{
+ OriginTasks: []relaycommon.OriginTaskRef{{
+ TaskID: "task_pub_1",
+ UpstreamTaskID: "cgt-upstream-1",
+ Action: "text_to_video",
+ Status: "SUCCESS",
+ Data: []byte(`{"id":"cgt-upstream-1"}`),
+ }},
+ },
+ }
+ adaptor.Init(info)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
+
+ ctx := adaptor.submitContext(c, info)
+
+ originTasks, ok := ctx["originTasks"].([]map[string]any)
+ require.True(t, ok)
+ require.Len(t, originTasks, 1)
+ assert.Equal(t, "task_pub_1", originTasks[0]["taskId"])
+ assert.Equal(t, "cgt-upstream-1", originTasks[0]["upstreamTaskId"])
+ assert.Equal(t, "text_to_video", originTasks[0]["action"])
+ assert.Equal(t, "SUCCESS", originTasks[0]["status"])
+ assert.Equal(t, map[string]any{"id": "cgt-upstream-1"}, originTasks[0]["data"])
+}
+
+func TestSubmitContextOmitsOriginTasksWhenEmpty(t *testing.T) {
+ plugin, err := pluginruntime.NewRegistry().Register(mockPlugin, pluginruntime.Options{})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+ info := &relaycommon.RelayInfo{
+ ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example", ApiKey: "secret"},
+ TaskRelayInfo: &relaycommon.TaskRelayInfo{},
+ }
+ adaptor.Init(info)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
+
+ ctx := adaptor.submitContext(c, info)
+
+ _, ok := ctx["originTasks"]
+ assert.False(t, ok)
+}
+
+func TestSubmitContextOriginTasksNilDataOnInvalidJSON(t *testing.T) {
+ plugin, err := pluginruntime.NewRegistry().Register(mockPlugin, pluginruntime.Options{})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+ info := &relaycommon.RelayInfo{
+ ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example", ApiKey: "secret"},
+ TaskRelayInfo: &relaycommon.TaskRelayInfo{
+ OriginTasks: []relaycommon.OriginTaskRef{{
+ TaskID: "task_pub_1",
+ UpstreamTaskID: "cgt-upstream-1",
+ Action: "text_to_video",
+ Status: "SUCCESS",
+ Data: []byte("not-json"),
+ }},
+ },
+ }
+ adaptor.Init(info)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
+
+ ctx := adaptor.submitContext(c, info)
+
+ originTasks, ok := ctx["originTasks"].([]map[string]any)
+ require.True(t, ok)
+ require.Len(t, originTasks, 1)
+ assert.Nil(t, originTasks[0]["data"])
+}
+
+func TestTaskAdaptorRejectsRequestHostOverride(t *testing.T) {
+ source := strings.Replace(mockPlugin, `ctx.baseUrl + "/submit"`, `"https://attacker.example/steal"`, 1)
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{Key: "mock-task", Version: "1.0.0"})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+ info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example"}, TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
+ c.Set("task_request", relaycommon.TaskSubmitReq{Prompt: "hello"})
+ taskErr := adaptor.ValidateRequestAndSetAction(c, info)
+ require.NotNil(t, taskErr)
+ assert.Contains(t, taskErr.Message, "not allowed")
+}
+
+const batchMockPlugin = `
+export const meta = { apiVersion: 1, key: "mock-batch", name: "Mock Batch", version: "1.0.0", author: {name: "Test"}, channelTypes: [1002], models: ["batch-v1"], fetchMode: "batch" };
+export function buildSubmitRequest(ctx) { return { url: ctx.baseUrl + "/submit", method: "POST", body: {} }; }
+export function parseSubmitResponse(ctx, resp) { return { taskId: resp.body.id }; }
+export function buildQueryRequest(ctx) { return { url: ctx.baseUrl + "/tasks/" + ctx.taskId }; }
+export function parseTaskResult(ctx, body) { return { taskId: body.id, status: "SUCCESS" }; }
+export function buildBatchQueryRequest(ctx, taskIds) { return { url: ctx.baseUrl + "/batch", method: "POST", headers: { "X-Plugin": "batch" }, body: { ids: taskIds } }; }
+export function parseBatchResult(ctx, body) {
+ return body.items.map(function (item) {
+ return { taskId: item.id, action: item.action, status: item.status, progress: item.progress, url: (item.urls || [])[0] || "", finishTime: item.finish || 0, data: item };
+ });
+}
+export function extractUsageOnComplete(task, result, body) { return {upstreamUnits: body.usage || 0}; }
+`
+
+// Covers the bridge half of the batch contract: FetchBatchTasks must build the
+// upstream request from the plugin descriptor, and ParseBatchResult must key
+// results by taskId, preserve the explicit result URL, and skip entries without
+// a task id.
+func TestTaskAdaptorBatchBridge(t *testing.T) {
+ service.InitHttpClient()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/batch", r.URL.Path)
+ require.Equal(t, http.MethodPost, r.Method)
+ assert.Equal(t, "batch", r.Header.Get("X-Plugin"))
+ body, err := io.ReadAll(r.Body)
+ require.NoError(t, err)
+ assert.JSONEq(t, `{"ids":["task-a","task-b"]}`, string(body))
+ _, _ = w.Write([]byte(`{"items":[
+ {"id":"task-a","action":"music","status":"SUCCESS","progress":"100%","urls":["https://cdn.example/a1.mp3","https://cdn.example/a2.mp3"],"finish":1700000000,"usage":23},
+ {"id":"task-b","status":"IN_PROGRESS","progress":"40%"},
+ {"id":"","status":"SUCCESS"}
+ ]}`))
+ }))
+ defer server.Close()
+
+ plugin, err := pluginruntime.NewRegistry().Register(batchMockPlugin, pluginruntime.Options{Key: "mock-batch", Version: "1.0.0"})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+ require.Equal(t, "batch", adaptor.FetchMode())
+
+ resp, err := adaptor.FetchBatchTasks(server.URL, "secret", []string{"task-a", "task-b"}, "")
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ payload, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+
+ results, err := adaptor.ParseBatchResult(payload)
+ require.NoError(t, err)
+ require.Len(t, results, 2, "entry without taskId must be skipped")
+
+ done := results["task-a"]
+ require.NotNil(t, done)
+ assert.Equal(t, "music", done.Action)
+ assert.Equal(t, "SUCCESS", done.TaskInfo.Status)
+ assert.Equal(t, "100%", done.TaskInfo.Progress)
+ assert.Equal(t, "https://cdn.example/a1.mp3", done.TaskInfo.Url)
+ assert.Equal(t, int64(1700000000), done.FinishTime)
+ assert.EqualValues(t, 23, done.TaskInfo.UsageFacts["upstreamUnits"])
+ assert.Equal(t, 23, done.TaskInfo.TotalTokens)
+ require.NotNil(t, done.Data)
+
+ pending := results["task-b"]
+ require.NotNil(t, pending)
+ assert.Equal(t, "IN_PROGRESS", pending.TaskInfo.Status)
+ assert.Equal(t, "40%", pending.TaskInfo.Progress)
+ assert.Empty(t, pending.TaskInfo.Url)
+}
diff --git a/relay/channel/task/jsplugin/auth.go b/relay/channel/task/jsplugin/auth.go
new file mode 100644
index 000000000000..2af7fc11dbb9
--- /dev/null
+++ b/relay/channel/task/jsplugin/auth.go
@@ -0,0 +1,45 @@
+package jsplugin
+
+import (
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
+ vertexcore "github.com/QuantumNous/new-api/relay/channel/vertex"
+)
+
+type cachedAuth struct {
+ header, projectID string
+ expiresAt time.Time
+}
+
+var pluginAuthCache sync.Map
+var acquireAccessToken = vertexcore.AcquireAccessToken
+
+func resolveAuth(meta pluginruntime.AuthMeta, apiKey, proxy string) (map[string]any, error) {
+ typeName := strings.TrimSpace(meta.Type)
+ if typeName == "" || typeName == "none" || typeName == "api_key" {
+ return map[string]any{"authHeader": apiKey}, nil
+ }
+ cacheKey := apiKey + "\x00" + proxy
+ if value, ok := pluginAuthCache.Load(cacheKey); ok {
+ entry := value.(cachedAuth)
+ if time.Now().Before(entry.expiresAt) {
+ return map[string]any{"authHeader": entry.header, "projectId": entry.projectID}, nil
+ }
+ }
+ var credentials vertexcore.Credentials
+ if err := common.Unmarshal([]byte(apiKey), &credentials); err != nil {
+ return nil, fmt.Errorf("decode oauth2_jwt credentials: %w", err)
+ }
+ token, err := acquireAccessToken(credentials, proxy)
+ if err != nil {
+ return nil, err
+ }
+ entry := cachedAuth{header: "Bearer " + token, projectID: credentials.ProjectID, expiresAt: time.Now().Add(25 * time.Minute)}
+ pluginAuthCache.Store(cacheKey, entry)
+ return map[string]any{"authHeader": entry.header, "projectId": entry.projectID}, nil
+}
diff --git a/relay/channel/task/jsplugin/auth_test.go b/relay/channel/task/jsplugin/auth_test.go
new file mode 100644
index 000000000000..18f26d1d8b41
--- /dev/null
+++ b/relay/channel/task/jsplugin/auth_test.go
@@ -0,0 +1,77 @@
+package jsplugin
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
+ vertexcore "github.com/QuantumNous/new-api/relay/channel/vertex"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestOAuth2JWTAuthCachesAndRefreshes(t *testing.T) {
+ pluginAuthCache = sync.Map{}
+ original := acquireAccessToken
+ t.Cleanup(func() { acquireAccessToken = original; pluginAuthCache = sync.Map{} })
+ calls := 0
+ acquireAccessToken = func(_ vertexcore.Credentials, _ string) (string, error) {
+ calls++
+ return fmt.Sprintf("token-%d", calls), nil
+ }
+ credentials, err := common.Marshal(vertexcore.Credentials{ProjectID: "project", ClientEmail: "a@example.com", PrivateKey: "secret"})
+ require.NoError(t, err)
+ meta := pluginruntime.AuthMeta{Type: "oauth2_jwt"}
+ first, err := resolveAuth(meta, string(credentials), "")
+ require.NoError(t, err)
+ second, err := resolveAuth(meta, string(credentials), "")
+ require.NoError(t, err)
+ assert.Equal(t, "Bearer token-1", first["authHeader"])
+ assert.Equal(t, first, second)
+ assert.Equal(t, 1, calls)
+ pluginAuthCache.Store(string(credentials)+"\x00", cachedAuth{expiresAt: time.Now().Add(-time.Second)})
+ refreshed, err := resolveAuth(meta, string(credentials), "")
+ require.NoError(t, err)
+ assert.Equal(t, "Bearer token-2", refreshed["authHeader"])
+ assert.Equal(t, 2, calls)
+}
+
+func TestOAuth2JWTContextDoesNotExposeServiceAccountKey(t *testing.T) {
+ pluginAuthCache = sync.Map{}
+ original := acquireAccessToken
+ t.Cleanup(func() { acquireAccessToken = original; pluginAuthCache = sync.Map{} })
+ acquireAccessToken = func(_ vertexcore.Credentials, _ string) (string, error) {
+ return "access-token", nil
+ }
+ credentials, err := common.Marshal(vertexcore.Credentials{ProjectID: "project", ClientEmail: "a@example.com", PrivateKey: "secret"})
+ require.NoError(t, err)
+ source := `
+export const meta = {apiVersion:1,key:"oauth",name:"OAuth",version:"1.0.0",author:{name:"Test"},models:["m"],fetchMode:"per_task",auth:{type:"oauth2_jwt"}};
+export function buildSubmitRequest(ctx) {
+ if (ctx.apiKey !== undefined) throw new Error("raw key exposed");
+ return {url:ctx.baseUrl+"/submit",headers:{Authorization:ctx.authHeader}};
+}
+export function parseSubmitResponse(){return {taskId:"1"}}
+export function buildQueryRequest(){return {url:"https://example.com"}}
+export function parseTaskResult(){return {status:"SUCCESS"}}
+`
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+ info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example", ApiKey: string(credentials)}, TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
+ adaptor.Init(info)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
+ c.Set("task_request", relaycommon.TaskSubmitReq{Prompt: "p"})
+ require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
+ req := httptest.NewRequest(http.MethodPost, "https://provider.example/submit", nil)
+ require.NoError(t, adaptor.BuildRequestHeader(c, req, info))
+ assert.Equal(t, "Bearer access-token", req.Header.Get("Authorization"))
+}
diff --git a/relay/channel/task/kling/adaptor.go b/relay/channel/task/kling/adaptor.go
deleted file mode 100644
index 200c3c6829ee..000000000000
--- a/relay/channel/task/kling/adaptor.go
+++ /dev/null
@@ -1,418 +0,0 @@
-package kling
-
-import (
- "bytes"
- "fmt"
- "io"
- "math"
- "net/http"
- "strconv"
- "strings"
- "time"
-
- "github.com/QuantumNous/new-api/common"
- "github.com/QuantumNous/new-api/model"
-
- "github.com/samber/lo"
-
- "github.com/gin-gonic/gin"
- "github.com/golang-jwt/jwt/v5"
- "github.com/pkg/errors"
-
- "github.com/QuantumNous/new-api/constant"
- taskdto "github.com/QuantumNous/new-api/dto"
- "github.com/QuantumNous/new-api/relay/channel"
- taskcommon "github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
- relaycommon "github.com/QuantumNous/new-api/relay/common"
- "github.com/QuantumNous/new-api/relaykit/dto"
- "github.com/QuantumNous/new-api/service"
-)
-
-// ============================
-// Request / Response structures
-// ============================
-
-type TrajectoryPoint struct {
- X int `json:"x"`
- Y int `json:"y"`
-}
-
-type DynamicMask struct {
- Mask string `json:"mask,omitempty"`
- Trajectories []TrajectoryPoint `json:"trajectories,omitempty"`
-}
-
-type CameraConfig struct {
- Horizontal float64 `json:"horizontal,omitempty"`
- Vertical float64 `json:"vertical,omitempty"`
- Pan float64 `json:"pan,omitempty"`
- Tilt float64 `json:"tilt,omitempty"`
- Roll float64 `json:"roll,omitempty"`
- Zoom float64 `json:"zoom,omitempty"`
-}
-
-type CameraControl struct {
- Type string `json:"type,omitempty"`
- Config *CameraConfig `json:"config,omitempty"`
-}
-
-type requestPayload struct {
- Prompt string `json:"prompt,omitempty"`
- Image string `json:"image,omitempty"`
- ImageTail string `json:"image_tail,omitempty"`
- NegativePrompt string `json:"negative_prompt,omitempty"`
- Mode string `json:"mode,omitempty"`
- Duration string `json:"duration,omitempty"`
- AspectRatio string `json:"aspect_ratio,omitempty"`
- ModelName string `json:"model_name,omitempty"`
- Model string `json:"model,omitempty"` // Compatible with upstreams that only recognize "model"
- CfgScale float64 `json:"cfg_scale,omitempty"`
- StaticMask string `json:"static_mask,omitempty"`
- DynamicMasks []DynamicMask `json:"dynamic_masks,omitempty"`
- CameraControl *CameraControl `json:"camera_control,omitempty"`
- CallbackUrl string `json:"callback_url,omitempty"`
- ExternalTaskId string `json:"external_task_id,omitempty"`
-}
-
-type responsePayload struct {
- Code int `json:"code"`
- Message string `json:"message"`
- TaskId string `json:"task_id"`
- RequestId string `json:"request_id"`
- Data struct {
- TaskId string `json:"task_id"`
- TaskStatus string `json:"task_status"`
- TaskStatusMsg string `json:"task_status_msg"`
- TaskInfo struct {
- ExternalTaskId string `json:"external_task_id"`
- } `json:"task_info"`
- WatermarkInfo struct {
- Enabled bool `json:"enabled"`
- } `json:"watermark_info"`
- TaskResult struct {
- Videos []struct {
- Id string `json:"id"`
- Url string `json:"url"`
- WatermarkUrl string `json:"watermark_url"`
- Duration string `json:"duration"`
- } `json:"videos"`
- Images []struct {
- Index int `json:"index"`
- Url string `json:"url"`
- WatermarkUrl string `json:"watermark_url"`
- } `json:"images"`
- } `json:"task_result"`
- CreatedAt int64 `json:"created_at"`
- UpdatedAt int64 `json:"updated_at"`
- FinalUnitDeduction string `json:"final_unit_deduction"`
- } `json:"data"`
-}
-
-// ============================
-// Adaptor implementation
-// ============================
-
-type TaskAdaptor struct {
- taskcommon.BaseBilling
- ChannelType int
- apiKey string
- baseURL string
-}
-
-func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
- a.ChannelType = info.ChannelType
- a.baseURL = info.ChannelBaseUrl
- a.apiKey = info.ApiKey
-
- // apiKey format: "access_key|secret_key"
-}
-
-// ValidateRequestAndSetAction parses body, validates fields and sets default action.
-func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *taskdto.TaskError) {
- // Use the standard validation method for TaskSubmitReq
- return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate)
-}
-
-// BuildRequestURL constructs the upstream URL.
-func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
- path := lo.Ternary(info.Action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video")
-
- if isNewAPIRelay(info.ApiKey) {
- return fmt.Sprintf("%s/kling%s", a.baseURL, path), nil
- }
-
- return fmt.Sprintf("%s%s", a.baseURL, path), nil
-}
-
-// BuildRequestHeader sets required headers.
-func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
- token, err := a.createJWTToken()
- if err != nil {
- return fmt.Errorf("failed to create JWT token: %w", err)
- }
-
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Accept", "application/json")
- req.Header.Set("Authorization", "Bearer "+token)
- req.Header.Set("User-Agent", "kling-sdk/1.0")
- return nil
-}
-
-// BuildRequestBody converts request into Kling specific format.
-func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
- v, exists := c.Get("task_request")
- if !exists {
- return nil, fmt.Errorf("request not found in context")
- }
- req := v.(relaycommon.TaskSubmitReq)
-
- body, err := a.convertToRequestPayload(&req, info)
- if err != nil {
- return nil, err
- }
- if body.Image == "" && body.ImageTail == "" {
- c.Set("action", constant.TaskActionTextGenerate)
- }
- data, err := common.Marshal(body)
- if err != nil {
- return nil, err
- }
- return bytes.NewReader(data), nil
-}
-
-// DoRequest delegates to common helper.
-func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
- if action := c.GetString("action"); action != "" {
- info.Action = action
- }
- return channel.DoTaskApiRequest(a, c, info, requestBody)
-}
-
-// DoResponse handles upstream response, returns taskID etc.
-func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *taskdto.TaskError) {
- responseBody, err := io.ReadAll(resp.Body)
- if err != nil {
- taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
- return
- }
-
- var kResp responsePayload
- err = common.Unmarshal(responseBody, &kResp)
- if err != nil {
- taskErr = service.TaskErrorWrapper(err, "unmarshal_response_failed", http.StatusInternalServerError)
- return
- }
- if kResp.Code != 0 {
- taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("%s", kResp.Message), "task_failed", http.StatusBadRequest)
- return
- }
- ov := dto.NewOpenAIVideo()
- ov.ID = info.PublicTaskID
- ov.TaskID = info.PublicTaskID
- ov.CreatedAt = time.Now().Unix()
- ov.Model = info.OriginModelName
- c.JSON(http.StatusOK, ov)
- return kResp.Data.TaskId, responseBody, nil
-}
-
-// FetchTask fetch task status
-func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) {
- taskID, ok := body["task_id"].(string)
- if !ok {
- return nil, fmt.Errorf("invalid task_id")
- }
- action, ok := body["action"].(string)
- if !ok {
- return nil, fmt.Errorf("invalid action")
- }
- path := lo.Ternary(action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video")
- url := fmt.Sprintf("%s%s/%s", baseUrl, path, taskID)
- if isNewAPIRelay(key) {
- url = fmt.Sprintf("%s/kling%s/%s", baseUrl, path, taskID)
- }
-
- req, err := http.NewRequest(http.MethodGet, url, nil)
- if err != nil {
- return nil, err
- }
-
- token, err := a.createJWTTokenWithKey(key)
- if err != nil {
- token = key
- }
-
- req.Header.Set("Accept", "application/json")
- req.Header.Set("Authorization", "Bearer "+token)
- req.Header.Set("User-Agent", "kling-sdk/1.0")
-
- client, err := service.GetHttpClientWithProxy(proxy)
- if err != nil {
- return nil, fmt.Errorf("new proxy http client failed: %w", err)
- }
- return client.Do(req)
-}
-
-func (a *TaskAdaptor) GetModelList() []string {
- return []string{"kling-v1", "kling-v1-6", "kling-v2-master"}
-}
-
-func (a *TaskAdaptor) GetChannelName() string {
- return "kling"
-}
-
-// ============================
-// helpers
-// ============================
-
-func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq, info *relaycommon.RelayInfo) (*requestPayload, error) {
- r := requestPayload{
- Prompt: req.Prompt,
- Image: req.Image,
- Mode: taskcommon.DefaultString(req.Mode, "std"),
- Duration: fmt.Sprintf("%d", taskcommon.DefaultInt(req.Duration, 5)),
- AspectRatio: a.getAspectRatio(req.Size),
- ModelName: info.UpstreamModelName,
- Model: info.UpstreamModelName,
- CfgScale: 0.5,
- StaticMask: "",
- DynamicMasks: []DynamicMask{},
- CameraControl: nil,
- CallbackUrl: "",
- ExternalTaskId: "",
- }
- if r.ModelName == "" {
- r.ModelName = "kling-v1"
- r.Model = "kling-v1"
- }
- if err := taskcommon.UnmarshalMetadata(req.Metadata, &r); err != nil {
- return nil, errors.Wrap(err, "unmarshal metadata failed")
- }
- return &r, nil
-}
-
-func (a *TaskAdaptor) getAspectRatio(size string) string {
- switch size {
- case "1024x1024", "512x512":
- return "1:1"
- case "1280x720", "1920x1080":
- return "16:9"
- case "720x1280", "1080x1920":
- return "9:16"
- default:
- return "1:1"
- }
-}
-
-// ============================
-// JWT helpers
-// ============================
-
-func (a *TaskAdaptor) createJWTToken() (string, error) {
- return a.createJWTTokenWithKey(a.apiKey)
-}
-
-func (a *TaskAdaptor) createJWTTokenWithKey(apiKey string) (string, error) {
- if isNewAPIRelay(apiKey) {
- return apiKey, nil // new api relay
- }
- keyParts := strings.Split(apiKey, "|")
- if len(keyParts) != 2 {
- return "", errors.New("invalid api_key, required format is accessKey|secretKey")
- }
- accessKey := strings.TrimSpace(keyParts[0])
- if len(keyParts) == 1 {
- return accessKey, nil
- }
- secretKey := strings.TrimSpace(keyParts[1])
- now := time.Now().Unix()
- claims := jwt.MapClaims{
- "iss": accessKey,
- "exp": now + 1800, // 30 minutes
- "nbf": now - 5,
- }
- token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
- token.Header["typ"] = "JWT"
- return token.SignedString([]byte(secretKey))
-}
-
-func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
- taskInfo := &relaycommon.TaskInfo{}
- resPayload := responsePayload{}
- err := common.Unmarshal(respBody, &resPayload)
- if err != nil {
- return nil, errors.Wrap(err, "failed to unmarshal response body")
- }
- taskInfo.Code = resPayload.Code
- taskInfo.TaskID = resPayload.Data.TaskId
- taskInfo.Reason = resPayload.Data.TaskStatusMsg
- //任务状态,枚举值:submitted(已提交)、processing(处理中)、succeed(成功)、failed(失败)
- status := resPayload.Data.TaskStatus
- switch status {
- case "submitted":
- taskInfo.Status = model.TaskStatusSubmitted
- case "processing":
- taskInfo.Status = model.TaskStatusInProgress
- case "succeed":
- taskInfo.Status = model.TaskStatusSuccess
- if videos := resPayload.Data.TaskResult.Videos; len(videos) > 0 {
- video := videos[0]
- taskInfo.Url = video.Url
- }
- if tokens, err := strconv.ParseFloat(resPayload.Data.FinalUnitDeduction, 64); err == nil {
- // 上游返回的扣费数值,饱和转换防止超大数值回绕成负数
- rounded := common.QuotaFromFloat(math.Ceil(tokens))
- if rounded > 0 {
- taskInfo.CompletionTokens = rounded
- taskInfo.TotalTokens = rounded
- }
- }
- case "failed":
- taskInfo.Status = model.TaskStatusFailure
- default:
- return nil, fmt.Errorf("unknown task status: %s", status)
- }
- return taskInfo, nil
-}
-
-func isNewAPIRelay(apiKey string) bool {
- return strings.HasPrefix(apiKey, "sk-")
-}
-
-func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) {
- var klingResp responsePayload
- if err := common.Unmarshal(originTask.Data, &klingResp); err != nil {
- return nil, errors.Wrap(err, "unmarshal kling task data failed")
- }
-
- openAIVideo := dto.NewOpenAIVideo()
- openAIVideo.ID = originTask.TaskID
- openAIVideo.Status = originTask.Status.ToVideoStatus()
- openAIVideo.SetProgressStr(originTask.Progress)
- openAIVideo.CreatedAt = klingResp.Data.CreatedAt
- openAIVideo.CompletedAt = klingResp.Data.UpdatedAt
-
- if len(klingResp.Data.TaskResult.Videos) > 0 {
- video := klingResp.Data.TaskResult.Videos[0]
- if video.Url != "" {
- openAIVideo.SetMetadata("url", video.Url)
- }
- if video.Duration != "" {
- openAIVideo.Seconds = video.Duration
- }
- }
-
- if klingResp.Code != 0 && klingResp.Message != "" {
- openAIVideo.Error = &dto.OpenAIVideoError{
- Message: klingResp.Message,
- Code: fmt.Sprintf("%d", klingResp.Code),
- }
- }
-
- // https://app.klingai.com/cn/dev/document-api/apiReference/model/textToVideo
- if data := klingResp.Data; data.TaskStatus == "failed" {
- openAIVideo.Error = &dto.OpenAIVideoError{
- Message: data.TaskStatusMsg,
- }
- }
- return common.Marshal(openAIVideo)
-}
diff --git a/relay/channel/task/sora/adaptor.go b/relay/channel/task/sora/adaptor.go
deleted file mode 100644
index 7f81e5335ebb..000000000000
--- a/relay/channel/task/sora/adaptor.go
+++ /dev/null
@@ -1,331 +0,0 @@
-package sora
-
-import (
- "bytes"
- "fmt"
- "io"
- "mime/multipart"
- "net/http"
- "net/textproto"
- "strconv"
- "strings"
-
- "github.com/QuantumNous/new-api/common"
- "github.com/QuantumNous/new-api/constant"
- "github.com/QuantumNous/new-api/dto"
- "github.com/QuantumNous/new-api/model"
- "github.com/QuantumNous/new-api/relay/channel"
- taskcommon "github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
- relaycommon "github.com/QuantumNous/new-api/relay/common"
- "github.com/QuantumNous/new-api/service"
-
- "github.com/gin-gonic/gin"
- "github.com/pkg/errors"
- "github.com/tidwall/sjson"
-)
-
-// ============================
-// Request / Response structures
-// ============================
-
-type ContentItem struct {
- Type string `json:"type"` // "text" or "image_url"
- Text string `json:"text,omitempty"` // for text type
- ImageURL *ImageURL `json:"image_url,omitempty"` // for image_url type
-}
-
-type ImageURL struct {
- URL string `json:"url"`
-}
-
-type responseTask struct {
- ID string `json:"id"`
- TaskID string `json:"task_id,omitempty"` //兼容旧接口
- Object string `json:"object"`
- Model string `json:"model"`
- Status string `json:"status"`
- Progress int `json:"progress"`
- CreatedAt int64 `json:"created_at"`
- CompletedAt int64 `json:"completed_at,omitempty"`
- ExpiresAt int64 `json:"expires_at,omitempty"`
- Seconds string `json:"seconds,omitempty"`
- Size string `json:"size,omitempty"`
- RemixedFromVideoID string `json:"remixed_from_video_id,omitempty"`
- Error *struct {
- Message string `json:"message"`
- Code string `json:"code"`
- } `json:"error,omitempty"`
-}
-
-// ============================
-// Adaptor implementation
-// ============================
-
-type TaskAdaptor struct {
- taskcommon.BaseBilling
- ChannelType int
- apiKey string
- baseURL string
-}
-
-func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
- a.ChannelType = info.ChannelType
- a.baseURL = info.ChannelBaseUrl
- a.apiKey = info.ApiKey
-}
-
-func validateRemixRequest(c *gin.Context) *dto.TaskError {
- var req relaycommon.TaskSubmitReq
- if err := common.UnmarshalBodyReusable(c, &req); err != nil {
- return service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest)
- }
- if strings.TrimSpace(req.Prompt) == "" {
- return service.TaskErrorWrapperLocal(fmt.Errorf("field prompt is required"), "invalid_request", http.StatusBadRequest)
- }
- // 存储原始请求到 context,与 ValidateMultipartDirect 路径保持一致
- c.Set("task_request", req)
- return nil
-}
-
-func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
- if info.Action == constant.TaskActionRemix {
- return validateRemixRequest(c)
- }
- return relaycommon.ValidateMultipartDirect(c, info)
-}
-
-// EstimateBilling 根据用户请求的 seconds 和 size 计算 OtherRatios。
-func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 {
- // remix 路径的 OtherRatios 已在 ResolveOriginTask 中设置
- if info.Action == constant.TaskActionRemix {
- return nil
- }
-
- req, err := relaycommon.GetTaskRequest(c)
- if err != nil {
- return nil
- }
-
- seconds, _ := strconv.Atoi(req.Seconds)
- if seconds == 0 {
- seconds = req.Duration
- }
- if seconds <= 0 {
- seconds = 4
- }
-
- size := req.Size
- if size == "" {
- size = "720x1280"
- }
-
- ratios := map[string]float64{
- "seconds": float64(seconds),
- "size": 1,
- }
- if size == "1792x1024" || size == "1024x1792" {
- ratios["size"] = 1.666667
- }
- return ratios
-}
-
-func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
- if info.Action == constant.TaskActionRemix {
- return fmt.Sprintf("%s/v1/videos/%s/remix", a.baseURL, info.OriginTaskID), nil
- }
- return fmt.Sprintf("%s/v1/videos", a.baseURL), nil
-}
-
-// BuildRequestHeader sets required headers.
-func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
- req.Header.Set("Authorization", "Bearer "+a.apiKey)
- req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
- return nil
-}
-
-func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
- storage, err := common.GetBodyStorage(c)
- if err != nil {
- return nil, errors.Wrap(err, "get_request_body_failed")
- }
- cachedBody, err := storage.Bytes()
- if err != nil {
- return nil, errors.Wrap(err, "read_body_bytes_failed")
- }
- contentType := c.GetHeader("Content-Type")
-
- if strings.HasPrefix(contentType, "application/json") {
- var bodyMap map[string]interface{}
- if err := common.Unmarshal(cachedBody, &bodyMap); err == nil {
- bodyMap["model"] = info.UpstreamModelName
- if newBody, err := common.Marshal(bodyMap); err == nil {
- return bytes.NewReader(newBody), nil
- }
- }
- return bytes.NewReader(cachedBody), nil
- }
-
- if strings.Contains(contentType, "multipart/form-data") {
- formData, err := common.ParseMultipartFormReusable(c)
- if err != nil {
- return bytes.NewReader(cachedBody), nil
- }
- var buf bytes.Buffer
- writer := multipart.NewWriter(&buf)
- writer.WriteField("model", info.UpstreamModelName)
- for key, values := range formData.Value {
- if key == "model" {
- continue
- }
- for _, v := range values {
- writer.WriteField(key, v)
- }
- }
- for fieldName, fileHeaders := range formData.File {
- for _, fh := range fileHeaders {
- f, err := fh.Open()
- if err != nil {
- continue
- }
- ct := fh.Header.Get("Content-Type")
- if ct == "" || ct == "application/octet-stream" {
- buf512 := make([]byte, 512)
- n, _ := io.ReadFull(f, buf512)
- ct = http.DetectContentType(buf512[:n])
- // Re-open after sniffing so the full content is copied below
- f.Close()
- f, err = fh.Open()
- if err != nil {
- continue
- }
- }
- h := make(textproto.MIMEHeader)
- h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, fh.Filename))
- h.Set("Content-Type", ct)
- part, err := writer.CreatePart(h)
- if err != nil {
- f.Close()
- continue
- }
- io.Copy(part, f)
- f.Close()
- }
- }
- writer.Close()
- c.Request.Header.Set("Content-Type", writer.FormDataContentType())
- return &buf, nil
- }
-
- return common.NewReplayableBodyReader(storage), nil
-}
-
-// DoRequest delegates to common helper.
-func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
- return channel.DoTaskApiRequest(a, c, info, requestBody)
-}
-
-// DoResponse handles upstream response, returns taskID etc.
-func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
- responseBody, err := io.ReadAll(resp.Body)
- if err != nil {
- taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
- return
- }
- _ = resp.Body.Close()
-
- // Parse Sora response
- var dResp responseTask
- if err := common.Unmarshal(responseBody, &dResp); err != nil {
- taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError)
- return
- }
-
- upstreamID := dResp.ID
- if upstreamID == "" {
- upstreamID = dResp.TaskID
- }
- if upstreamID == "" {
- taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError)
- return
- }
-
- // 使用公开 task_xxxx ID 返回给客户端
- dResp.ID = info.PublicTaskID
- dResp.TaskID = info.PublicTaskID
- c.JSON(http.StatusOK, dResp)
- return upstreamID, responseBody, nil
-}
-
-// FetchTask fetch task status
-func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) {
- taskID, ok := body["task_id"].(string)
- if !ok {
- return nil, fmt.Errorf("invalid task_id")
- }
-
- uri := fmt.Sprintf("%s/v1/videos/%s", baseUrl, taskID)
-
- req, err := http.NewRequest(http.MethodGet, uri, nil)
- if err != nil {
- return nil, err
- }
-
- req.Header.Set("Authorization", "Bearer "+key)
-
- client, err := service.GetHttpClientWithProxy(proxy)
- if err != nil {
- return nil, fmt.Errorf("new proxy http client failed: %w", err)
- }
- return client.Do(req)
-}
-
-func (a *TaskAdaptor) GetModelList() []string {
- return ModelList
-}
-
-func (a *TaskAdaptor) GetChannelName() string {
- return ChannelName
-}
-
-func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
- resTask := responseTask{}
- if err := common.Unmarshal(respBody, &resTask); err != nil {
- return nil, errors.Wrap(err, "unmarshal task result failed")
- }
-
- taskResult := relaycommon.TaskInfo{
- Code: 0,
- }
-
- switch resTask.Status {
- case "queued", "pending":
- taskResult.Status = model.TaskStatusQueued
- case "processing", "in_progress":
- taskResult.Status = model.TaskStatusInProgress
- case "completed":
- taskResult.Status = model.TaskStatusSuccess
- // Url intentionally left empty — the caller constructs the proxy URL using the public task ID
- case "failed", "cancelled":
- taskResult.Status = model.TaskStatusFailure
- if resTask.Error != nil {
- taskResult.Reason = resTask.Error.Message
- } else {
- taskResult.Reason = "task failed"
- }
- default:
- }
- if resTask.Progress > 0 && resTask.Progress < 100 {
- taskResult.Progress = fmt.Sprintf("%d%%", resTask.Progress)
- }
-
- return &taskResult, nil
-}
-
-func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) ([]byte, error) {
- data := task.Data
- var err error
- if data, err = sjson.SetBytes(data, "id", task.TaskID); err != nil {
- return nil, errors.Wrap(err, "set id failed")
- }
- return data, nil
-}
diff --git a/relay/channel/task/sora/adaptor_test.go b/relay/channel/task/sora/adaptor_test.go
deleted file mode 100644
index 7021f2a9d6dc..000000000000
--- a/relay/channel/task/sora/adaptor_test.go
+++ /dev/null
@@ -1,41 +0,0 @@
-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 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))
- 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)
- 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), replayable.Size())
-
- replayBody, err := replayable.NewReader()
- 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/channel/task/sora/constants.go b/relay/channel/task/sora/constants.go
deleted file mode 100644
index e2f6536eafcd..000000000000
--- a/relay/channel/task/sora/constants.go
+++ /dev/null
@@ -1,8 +0,0 @@
-package sora
-
-var ModelList = []string{
- "sora-2",
- "sora-2-pro",
-}
-
-var ChannelName = "sora"
diff --git a/relay/channel/task/suno/adaptor.go b/relay/channel/task/suno/adaptor.go
deleted file mode 100644
index 35b5e423b7ff..000000000000
--- a/relay/channel/task/suno/adaptor.go
+++ /dev/null
@@ -1,167 +0,0 @@
-package suno
-
-import (
- "bytes"
- "fmt"
- "io"
- "net/http"
- "strings"
-
- "github.com/QuantumNous/new-api/common"
- "github.com/QuantumNous/new-api/constant"
- "github.com/QuantumNous/new-api/dto"
- "github.com/QuantumNous/new-api/relay/channel"
- taskcommon "github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
- relaycommon "github.com/QuantumNous/new-api/relay/common"
- "github.com/QuantumNous/new-api/service"
-
- "github.com/gin-gonic/gin"
-)
-
-type TaskAdaptor struct {
- taskcommon.BaseBilling
- ChannelType int
-}
-
-// ParseTaskResult is not used for Suno tasks.
-// Suno polling uses a dedicated batch-fetch path (service.UpdateSunoTasks) that
-// receives dto.TaskResponse[[]dto.SunoDataResponse] from the upstream /fetch API.
-// This differs from the per-task polling used by video adaptors.
-func (a *TaskAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) {
- return nil, fmt.Errorf("suno uses batch polling via UpdateSunoTasks, ParseTaskResult is not applicable")
-}
-
-func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
- a.ChannelType = info.ChannelType
-}
-
-func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
- action := strings.ToUpper(c.Param("action"))
-
- var sunoRequest *dto.SunoSubmitReq
- err := common.UnmarshalBodyReusable(c, &sunoRequest)
- if err != nil {
- taskErr = service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest)
- return
- }
- err = actionValidate(c, sunoRequest, action)
- if err != nil {
- taskErr = service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest)
- return
- }
-
- //if sunoRequest.ContinueClipId != "" {
- // if sunoRequest.TaskID == "" {
- // taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("task id is empty"), "invalid_request", http.StatusBadRequest)
- // return
- // }
- // info.OriginTaskID = sunoRequest.TaskID
- //}
-
- info.Action = action
- c.Set("task_request", sunoRequest)
- return nil
-}
-
-func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
- baseURL := info.ChannelBaseUrl
- fullRequestURL := fmt.Sprintf("%s%s", baseURL, "/suno/submit/"+info.Action)
- return fullRequestURL, nil
-}
-
-func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
- req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
- req.Header.Set("Accept", c.Request.Header.Get("Accept"))
- req.Header.Set("Authorization", "Bearer "+info.ApiKey)
- return nil
-}
-
-func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
- sunoRequest, ok := c.Get("task_request")
- if !ok {
- return nil, fmt.Errorf("task_request not found in context")
- }
- data, err := common.Marshal(sunoRequest)
- if err != nil {
- return nil, err
- }
- return bytes.NewReader(data), nil
-}
-
-func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
- return channel.DoTaskApiRequest(a, c, info, requestBody)
-}
-
-func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
- responseBody, err := io.ReadAll(resp.Body)
- if err != nil {
- taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
- return
- }
- var sunoResponse dto.TaskResponse[string]
- err = common.Unmarshal(responseBody, &sunoResponse)
- if err != nil {
- taskErr = service.TaskErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError)
- return
- }
- if !sunoResponse.IsSuccess() {
- taskErr = service.TaskErrorWrapper(fmt.Errorf("%s", sunoResponse.Message), sunoResponse.Code, http.StatusInternalServerError)
- return
- }
-
- // 使用公开 task_xxxx ID 替换上游 ID 返回给客户端
- publicResponse := dto.TaskResponse[string]{
- Code: sunoResponse.Code,
- Message: sunoResponse.Message,
- Data: info.PublicTaskID,
- }
- c.JSON(http.StatusOK, publicResponse)
-
- return sunoResponse.Data, nil, nil
-}
-
-func (a *TaskAdaptor) GetModelList() []string {
- return ModelList
-}
-
-func (a *TaskAdaptor) GetChannelName() string {
- return ChannelName
-}
-
-func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) {
- requestUrl := fmt.Sprintf("%s/suno/fetch", baseUrl)
- byteBody, err := common.Marshal(body)
- if err != nil {
- return nil, err
- }
-
- req, err := http.NewRequest("POST", requestUrl, bytes.NewBuffer(byteBody))
- if err != nil {
- common.SysLog(fmt.Sprintf("Get Task error: %v", err))
- return nil, err
- }
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Authorization", "Bearer "+key)
- client, err := service.GetHttpClientWithProxy(proxy)
- if err != nil {
- return nil, fmt.Errorf("new proxy http client failed: %w", err)
- }
- return client.Do(req)
-}
-
-func actionValidate(c *gin.Context, sunoRequest *dto.SunoSubmitReq, action string) (err error) {
- switch action {
- case constant.SunoActionMusic:
- if sunoRequest.Mv == "" {
- sunoRequest.Mv = "chirp-v3-0"
- }
- case constant.SunoActionLyrics:
- if sunoRequest.Prompt == "" {
- err = fmt.Errorf("prompt_empty")
- return
- }
- default:
- err = fmt.Errorf("invalid_action")
- }
- return
-}
diff --git a/relay/channel/task/suno/models.go b/relay/channel/task/suno/models.go
deleted file mode 100644
index 967cf1b1d7c5..000000000000
--- a/relay/channel/task/suno/models.go
+++ /dev/null
@@ -1,7 +0,0 @@
-package suno
-
-var ModelList = []string{
- "suno_music", "suno_lyrics",
-}
-
-var ChannelName = "suno"
diff --git a/relay/channel/task/vertex/adaptor.go b/relay/channel/task/vertex/adaptor.go
deleted file mode 100644
index d73c151c33c3..000000000000
--- a/relay/channel/task/vertex/adaptor.go
+++ /dev/null
@@ -1,417 +0,0 @@
-package vertex
-
-import (
- "bytes"
- "fmt"
- "io"
- "net/http"
- "regexp"
- "strings"
- "time"
-
- "github.com/QuantumNous/new-api/common"
- "github.com/QuantumNous/new-api/model"
- "github.com/gin-gonic/gin"
-
- "github.com/QuantumNous/new-api/constant"
- taskdto "github.com/QuantumNous/new-api/dto"
- "github.com/QuantumNous/new-api/relay/channel"
- geminitask "github.com/QuantumNous/new-api/relay/channel/task/gemini"
- taskcommon "github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
- vertexcore "github.com/QuantumNous/new-api/relay/channel/vertex"
- relaycommon "github.com/QuantumNous/new-api/relay/common"
- "github.com/QuantumNous/new-api/relaykit/dto"
- "github.com/QuantumNous/new-api/service"
-)
-
-// ============================
-// Request / Response structures
-// ============================
-
-type fetchOperationPayload struct {
- OperationName string `json:"operationName"`
-}
-
-type submitResponse struct {
- Name string `json:"name"`
-}
-
-type operationVideo struct {
- MimeType string `json:"mimeType"`
- BytesBase64Encoded string `json:"bytesBase64Encoded"`
- Encoding string `json:"encoding"`
-}
-
-type operationResponse struct {
- Name string `json:"name"`
- Done bool `json:"done"`
- Response struct {
- Type string `json:"@type"`
- RaiMediaFilteredCount int `json:"raiMediaFilteredCount"`
- Videos []operationVideo `json:"videos"`
- BytesBase64Encoded string `json:"bytesBase64Encoded"`
- Encoding string `json:"encoding"`
- Video string `json:"video"`
- } `json:"response"`
- Error struct {
- Message string `json:"message"`
- } `json:"error"`
-}
-
-// ============================
-// Adaptor implementation
-// ============================
-
-type TaskAdaptor struct {
- taskcommon.BaseBilling
- ChannelType int
- apiKey string
- baseURL string
-}
-
-func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
- a.ChannelType = info.ChannelType
- a.baseURL = info.ChannelBaseUrl
- a.apiKey = info.ApiKey
-}
-
-// ValidateRequestAndSetAction parses body, validates fields and sets default action.
-func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *taskdto.TaskError) {
- // Use the standard validation method for TaskSubmitReq
- return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionTextGenerate)
-}
-
-// BuildRequestURL constructs the upstream URL.
-func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
- adc := &vertexcore.Credentials{}
- if err := common.Unmarshal([]byte(a.apiKey), adc); err != nil {
- return "", fmt.Errorf("failed to decode credentials: %w", err)
- }
- modelName := info.UpstreamModelName
- if modelName == "" {
- modelName = "veo-3.0-generate-001"
- }
-
- region := vertexcore.GetModelRegion(info.ApiVersion, modelName)
- if strings.TrimSpace(region) == "" {
- region = "global"
- }
- return vertexcore.BuildGoogleModelURL(a.baseURL, vertexcore.DefaultAPIVersion, adc.ProjectID, region, modelName, "predictLongRunning"), nil
-}
-
-// BuildRequestHeader sets required headers.
-func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Accept", "application/json")
-
- adc := &vertexcore.Credentials{}
- if err := common.Unmarshal([]byte(a.apiKey), adc); err != nil {
- return fmt.Errorf("failed to decode credentials: %w", err)
- }
-
- proxy := ""
- if info != nil {
- proxy = info.ChannelSetting.Proxy
- }
- token, err := vertexcore.AcquireAccessToken(*adc, proxy)
- if err != nil {
- return fmt.Errorf("failed to acquire access token: %w", err)
- }
- req.Header.Set("Authorization", "Bearer "+token)
- req.Header.Set("x-goog-user-project", adc.ProjectID)
- return nil
-}
-
-// EstimateBilling returns OtherRatios based on durationSeconds and resolution.
-func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 {
- v, ok := c.Get("task_request")
- if !ok {
- return nil
- }
- req := v.(relaycommon.TaskSubmitReq)
-
- seconds := geminitask.ResolveVeoDuration(req.Metadata, req.Duration, req.Seconds)
- resolution := geminitask.ResolveVeoResolution(req.Metadata, req.Size)
- resRatio := geminitask.VeoResolutionRatio(info.UpstreamModelName, resolution)
-
- return map[string]float64{
- "seconds": float64(seconds),
- "resolution": resRatio,
- }
-}
-
-// BuildRequestBody converts request into Vertex specific format.
-func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
- v, ok := c.Get("task_request")
- if !ok {
- return nil, fmt.Errorf("request not found in context")
- }
- req := v.(relaycommon.TaskSubmitReq)
-
- instance := geminitask.VeoInstance{Prompt: req.Prompt}
- if img := geminitask.ExtractMultipartImage(c, info); img != nil {
- instance.Image = img
- } else if len(req.Images) > 0 {
- if parsed := geminitask.ParseImageInput(req.Images[0]); parsed != nil {
- instance.Image = parsed
- info.Action = constant.TaskActionGenerate
- }
- }
-
- params := &geminitask.VeoParameters{}
- if err := taskcommon.UnmarshalMetadata(req.Metadata, params); err != nil {
- return nil, fmt.Errorf("unmarshal metadata failed: %w", err)
- }
- if params.DurationSeconds == 0 && req.Duration > 0 {
- params.DurationSeconds = req.Duration
- }
- if params.Resolution == "" && req.Size != "" {
- params.Resolution = geminitask.SizeToVeoResolution(req.Size)
- }
- if params.AspectRatio == "" && req.Size != "" {
- params.AspectRatio = geminitask.SizeToVeoAspectRatio(req.Size)
- }
- params.Resolution = strings.ToLower(params.Resolution)
- params.SampleCount = 1
-
- body := geminitask.VeoRequestPayload{
- Instances: []geminitask.VeoInstance{instance},
- Parameters: params,
- }
-
- data, err := common.Marshal(body)
- if err != nil {
- return nil, err
- }
- return bytes.NewReader(data), nil
-}
-
-// DoRequest delegates to common helper.
-func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
- return channel.DoTaskApiRequest(a, c, info, requestBody)
-}
-
-// DoResponse handles upstream response, returns taskID etc.
-func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *taskdto.TaskError) {
- responseBody, err := io.ReadAll(resp.Body)
- if err != nil {
- return "", nil, service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
- }
- _ = resp.Body.Close()
-
- var s submitResponse
- if err := common.Unmarshal(responseBody, &s); err != nil {
- return "", nil, service.TaskErrorWrapper(err, "unmarshal_response_failed", http.StatusInternalServerError)
- }
- if strings.TrimSpace(s.Name) == "" {
- return "", nil, service.TaskErrorWrapper(fmt.Errorf("missing operation name"), "invalid_response", http.StatusInternalServerError)
- }
- localID := taskcommon.EncodeLocalTaskID(s.Name)
- ov := dto.NewOpenAIVideo()
- ov.ID = info.PublicTaskID
- ov.TaskID = info.PublicTaskID
- ov.CreatedAt = time.Now().Unix()
- ov.Model = info.OriginModelName
- c.JSON(http.StatusOK, ov)
- return localID, responseBody, nil
-}
-
-func (a *TaskAdaptor) GetModelList() []string {
- return []string{
- "veo-3.0-generate-001",
- "veo-3.0-fast-generate-001",
- "veo-3.1-generate-preview",
- "veo-3.1-fast-generate-preview",
- }
-}
-func (a *TaskAdaptor) GetChannelName() string { return "vertex" }
-
-func buildFetchOperationURL(baseURL, upstreamName string) (string, error) {
- region := extractRegionFromOperationName(upstreamName)
- if region == "" {
- region = "us-central1"
- }
- project := extractProjectFromOperationName(upstreamName)
- modelName := extractModelFromOperationName(upstreamName)
- if strings.TrimSpace(modelName) == "" {
- return "", fmt.Errorf("cannot extract model from operation name")
- }
- if strings.TrimSpace(project) == "" {
- return "", fmt.Errorf("cannot extract project from operation name")
- }
- return vertexcore.BuildGoogleModelURL(baseURL, vertexcore.DefaultAPIVersion, project, region, modelName, "fetchPredictOperation"), nil
-}
-
-// FetchTask fetch task status
-func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) {
- taskID, ok := body["task_id"].(string)
- if !ok {
- return nil, fmt.Errorf("invalid task_id")
- }
- upstreamName, err := taskcommon.DecodeLocalTaskID(taskID)
- if err != nil {
- return nil, fmt.Errorf("decode task_id failed: %w", err)
- }
- url, err := buildFetchOperationURL(baseUrl, upstreamName)
- if err != nil {
- return nil, err
- }
- payload := fetchOperationPayload{OperationName: upstreamName}
- data, err := common.Marshal(payload)
- if err != nil {
- return nil, err
- }
- adc := &vertexcore.Credentials{}
- if err := common.Unmarshal([]byte(key), adc); err != nil {
- return nil, fmt.Errorf("failed to decode credentials: %w", err)
- }
- token, err := vertexcore.AcquireAccessToken(*adc, proxy)
- if err != nil {
- return nil, fmt.Errorf("failed to acquire access token: %w", err)
- }
- req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
- if err != nil {
- return nil, err
- }
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Accept", "application/json")
- req.Header.Set("Authorization", "Bearer "+token)
- req.Header.Set("x-goog-user-project", adc.ProjectID)
- client, err := service.GetHttpClientWithProxy(proxy)
- if err != nil {
- return nil, fmt.Errorf("new proxy http client failed: %w", err)
- }
- return client.Do(req)
-}
-
-func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
- var op operationResponse
- if err := common.Unmarshal(respBody, &op); err != nil {
- return nil, fmt.Errorf("unmarshal operation response failed: %w", err)
- }
- ti := &relaycommon.TaskInfo{}
- if op.Error.Message != "" {
- ti.Status = model.TaskStatusFailure
- ti.Reason = op.Error.Message
- ti.Progress = "100%"
- return ti, nil
- }
- if !op.Done {
- ti.Status = model.TaskStatusInProgress
- ti.Progress = "50%"
- return ti, nil
- }
- ti.Status = model.TaskStatusSuccess
- ti.Progress = "100%"
- if len(op.Response.Videos) > 0 {
- v0 := op.Response.Videos[0]
- if v0.BytesBase64Encoded != "" {
- mime := strings.TrimSpace(v0.MimeType)
- if mime == "" {
- enc := strings.TrimSpace(v0.Encoding)
- if enc == "" {
- enc = "mp4"
- }
- if strings.Contains(enc, "/") {
- mime = enc
- } else {
- mime = "video/" + enc
- }
- }
- ti.Url = "data:" + mime + ";base64," + v0.BytesBase64Encoded
- return ti, nil
- }
- }
- if op.Response.BytesBase64Encoded != "" {
- enc := strings.TrimSpace(op.Response.Encoding)
- if enc == "" {
- enc = "mp4"
- }
- mime := enc
- if !strings.Contains(enc, "/") {
- mime = "video/" + enc
- }
- ti.Url = "data:" + mime + ";base64," + op.Response.BytesBase64Encoded
- return ti, nil
- }
- if op.Response.Video != "" { // some variants use `video` as base64
- enc := strings.TrimSpace(op.Response.Encoding)
- if enc == "" {
- enc = "mp4"
- }
- mime := enc
- if !strings.Contains(enc, "/") {
- mime = "video/" + enc
- }
- ti.Url = "data:" + mime + ";base64," + op.Response.Video
- return ti, nil
- }
- return ti, nil
-}
-
-func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) ([]byte, error) {
- // Use GetUpstreamTaskID() to get the real upstream operation name for model extraction.
- // task.TaskID is now a public task_xxxx ID, no longer a base64-encoded upstream name.
- upstreamTaskID := task.GetUpstreamTaskID()
- upstreamName, err := taskcommon.DecodeLocalTaskID(upstreamTaskID)
- if err != nil {
- upstreamName = ""
- }
- modelName := extractModelFromOperationName(upstreamName)
- if strings.TrimSpace(modelName) == "" {
- modelName = "veo-3.0-generate-001"
- }
- v := dto.NewOpenAIVideo()
- v.ID = task.TaskID
- v.Model = modelName
- v.Status = task.Status.ToVideoStatus()
- v.SetProgressStr(task.Progress)
- v.CreatedAt = task.CreatedAt
- v.CompletedAt = task.UpdatedAt
- if resultURL := task.GetResultURL(); strings.HasPrefix(resultURL, "data:") && len(resultURL) > 0 {
- v.SetMetadata("url", resultURL)
- }
-
- return common.Marshal(v)
-}
-
-// ============================
-// helpers
-// ============================
-
-var regionRe = regexp.MustCompile(`locations/([a-z0-9-]+)/`)
-
-func extractRegionFromOperationName(name string) string {
- m := regionRe.FindStringSubmatch(name)
- if len(m) == 2 {
- return m[1]
- }
- return ""
-}
-
-var modelRe = regexp.MustCompile(`models/([^/]+)/operations/`)
-
-func extractModelFromOperationName(name string) string {
- m := modelRe.FindStringSubmatch(name)
- if len(m) == 2 {
- return m[1]
- }
- idx := strings.Index(name, "models/")
- if idx >= 0 {
- s := name[idx+len("models/"):]
- if p := strings.Index(s, "/operations/"); p > 0 {
- return s[:p]
- }
- }
- return ""
-}
-
-var projectRe = regexp.MustCompile(`projects/([^/]+)/locations/`)
-
-func extractProjectFromOperationName(name string) string {
- m := projectRe.FindStringSubmatch(name)
- if len(m) == 2 {
- return m[1]
- }
- return ""
-}
diff --git a/relay/channel/task/vidu/adaptor.go b/relay/channel/task/vidu/adaptor.go
deleted file mode 100644
index 62e029bbe88e..000000000000
--- a/relay/channel/task/vidu/adaptor.go
+++ /dev/null
@@ -1,301 +0,0 @@
-package vidu
-
-import (
- "bytes"
- "fmt"
- "io"
- "net/http"
- "strings"
- "time"
-
- "github.com/QuantumNous/new-api/common"
- "github.com/gin-gonic/gin"
-
- "github.com/QuantumNous/new-api/constant"
- taskdto "github.com/QuantumNous/new-api/dto"
- "github.com/QuantumNous/new-api/model"
- "github.com/QuantumNous/new-api/relay/channel"
- taskcommon "github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
- relaycommon "github.com/QuantumNous/new-api/relay/common"
- "github.com/QuantumNous/new-api/relaykit/dto"
- "github.com/QuantumNous/new-api/service"
-
- "github.com/pkg/errors"
-)
-
-// ============================
-// Request / Response structures
-// ============================
-
-type requestPayload struct {
- Model string `json:"model"`
- Images []string `json:"images"`
- Prompt string `json:"prompt,omitempty"`
- Duration int `json:"duration,omitempty"`
- Seed int `json:"seed,omitempty"`
- Resolution string `json:"resolution,omitempty"`
- MovementAmplitude string `json:"movement_amplitude,omitempty"`
- Bgm bool `json:"bgm,omitempty"`
- Payload string `json:"payload,omitempty"`
- CallbackUrl string `json:"callback_url,omitempty"`
-}
-
-type responsePayload struct {
- TaskId string `json:"task_id"`
- State string `json:"state"`
- Model string `json:"model"`
- Images []string `json:"images"`
- Prompt string `json:"prompt"`
- Duration int `json:"duration"`
- Seed int `json:"seed"`
- Resolution string `json:"resolution"`
- Bgm bool `json:"bgm"`
- MovementAmplitude string `json:"movement_amplitude"`
- Payload string `json:"payload"`
- CreatedAt string `json:"created_at"`
-}
-
-type taskResultResponse struct {
- State string `json:"state"`
- ErrCode string `json:"err_code"`
- Credits int `json:"credits"`
- Payload string `json:"payload"`
- Creations []creation `json:"creations"`
-}
-
-type creation struct {
- ID string `json:"id"`
- URL string `json:"url"`
- CoverURL string `json:"cover_url"`
-}
-
-// ============================
-// Adaptor implementation
-// ============================
-
-type TaskAdaptor struct {
- taskcommon.BaseBilling
- ChannelType int
- baseURL string
-}
-
-func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
- a.ChannelType = info.ChannelType
- a.baseURL = info.ChannelBaseUrl
-}
-
-func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *taskdto.TaskError {
- if err := relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate); err != nil {
- return err
- }
- req, err := relaycommon.GetTaskRequest(c)
- if err != nil {
- return service.TaskErrorWrapper(err, "get_task_request_failed", http.StatusBadRequest)
- }
- action := constant.TaskActionTextGenerate
- if meatAction, ok := req.Metadata["action"]; ok {
- action, _ = meatAction.(string)
- } else if req.HasImage() {
- action = constant.TaskActionGenerate
- if info.ChannelType == constant.ChannelTypeVidu {
- // vidu 增加 首尾帧生视频和参考图生视频
- if len(req.Images) == 2 {
- action = constant.TaskActionFirstTailGenerate
- } else if len(req.Images) > 2 {
- action = constant.TaskActionReferenceGenerate
- }
- }
- }
- info.Action = action
- return nil
-}
-
-func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
- v, exists := c.Get("task_request")
- if !exists {
- return nil, fmt.Errorf("request not found in context")
- }
- req := v.(relaycommon.TaskSubmitReq)
-
- body, err := a.convertToRequestPayload(&req, info)
- if err != nil {
- return nil, err
- }
-
- if info.Action == constant.TaskActionReferenceGenerate {
- if strings.Contains(body.Model, "viduq2") {
- // 参考图生视频只能用 viduq2 模型, 不能带有pro或turbo后缀 https://platform.vidu.cn/docs/reference-to-video
- body.Model = "viduq2"
- }
- }
-
- data, err := common.Marshal(body)
- if err != nil {
- return nil, err
- }
- return bytes.NewReader(data), nil
-}
-
-func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
- var path string
- switch info.Action {
- case constant.TaskActionGenerate:
- path = "/img2video"
- case constant.TaskActionFirstTailGenerate:
- path = "/start-end2video"
- case constant.TaskActionReferenceGenerate:
- path = "/reference2video"
- default:
- path = "/text2video"
- }
- return fmt.Sprintf("%s/ent/v2%s", a.baseURL, path), nil
-}
-
-func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Accept", "application/json")
- req.Header.Set("Authorization", "Token "+info.ApiKey)
- return nil
-}
-
-func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
- return channel.DoTaskApiRequest(a, c, info, requestBody)
-}
-
-func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *taskdto.TaskError) {
- responseBody, err := io.ReadAll(resp.Body)
- if err != nil {
- taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
- return
- }
-
- var vResp responsePayload
- err = common.Unmarshal(responseBody, &vResp)
- if err != nil {
- taskErr = service.TaskErrorWrapper(errors.Wrap(err, fmt.Sprintf("%s", responseBody)), "unmarshal_response_failed", http.StatusInternalServerError)
- return
- }
-
- if vResp.State == "failed" {
- taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("task failed"), "task_failed", http.StatusBadRequest)
- return
- }
-
- ov := dto.NewOpenAIVideo()
- ov.ID = info.PublicTaskID
- ov.TaskID = info.PublicTaskID
- ov.CreatedAt = time.Now().Unix()
- ov.Model = info.OriginModelName
- c.JSON(http.StatusOK, ov)
- return vResp.TaskId, responseBody, nil
-}
-
-func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) {
- taskID, ok := body["task_id"].(string)
- if !ok {
- return nil, fmt.Errorf("invalid task_id")
- }
-
- url := fmt.Sprintf("%s/ent/v2/tasks/%s/creations", baseUrl, taskID)
-
- req, err := http.NewRequest(http.MethodGet, url, nil)
- if err != nil {
- return nil, err
- }
-
- req.Header.Set("Accept", "application/json")
- req.Header.Set("Authorization", "Token "+key)
-
- client, err := service.GetHttpClientWithProxy(proxy)
- if err != nil {
- return nil, fmt.Errorf("new proxy http client failed: %w", err)
- }
- return client.Do(req)
-}
-
-func (a *TaskAdaptor) GetModelList() []string {
- return []string{"viduq2", "viduq1", "vidu2.0", "vidu1.5"}
-}
-
-func (a *TaskAdaptor) GetChannelName() string {
- return "vidu"
-}
-
-// ============================
-// helpers
-// ============================
-
-func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq, info *relaycommon.RelayInfo) (*requestPayload, error) {
- r := requestPayload{
- Model: taskcommon.DefaultString(info.UpstreamModelName, "viduq1"),
- Images: req.Images,
- Prompt: req.Prompt,
- Duration: taskcommon.DefaultInt(req.Duration, 5),
- Resolution: taskcommon.DefaultString(req.Size, "1080p"),
- MovementAmplitude: "auto",
- Bgm: false,
- }
- if err := taskcommon.UnmarshalMetadata(req.Metadata, &r); err != nil {
- return nil, errors.Wrap(err, "unmarshal metadata failed")
- }
- return &r, nil
-}
-
-func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
- taskInfo := &relaycommon.TaskInfo{}
-
- var taskResp taskResultResponse
- err := common.Unmarshal(respBody, &taskResp)
- if err != nil {
- return nil, errors.Wrap(err, "failed to unmarshal response body")
- }
-
- state := taskResp.State
- switch state {
- case "created", "queueing":
- taskInfo.Status = model.TaskStatusSubmitted
- case "processing":
- taskInfo.Status = model.TaskStatusInProgress
- case "success":
- taskInfo.Status = model.TaskStatusSuccess
- if len(taskResp.Creations) > 0 {
- taskInfo.Url = taskResp.Creations[0].URL
- }
- case "failed":
- taskInfo.Status = model.TaskStatusFailure
- if taskResp.ErrCode != "" {
- taskInfo.Reason = taskResp.ErrCode
- }
- default:
- return nil, fmt.Errorf("unknown task state: %s", state)
- }
-
- return taskInfo, nil
-}
-
-func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) {
- var viduResp taskResultResponse
- if err := common.Unmarshal(originTask.Data, &viduResp); err != nil {
- return nil, errors.Wrap(err, "unmarshal vidu task data failed")
- }
-
- openAIVideo := dto.NewOpenAIVideo()
- openAIVideo.ID = originTask.TaskID
- openAIVideo.Status = originTask.Status.ToVideoStatus()
- openAIVideo.SetProgressStr(originTask.Progress)
- openAIVideo.CreatedAt = originTask.CreatedAt
- openAIVideo.CompletedAt = originTask.UpdatedAt
-
- if len(viduResp.Creations) > 0 && viduResp.Creations[0].URL != "" {
- openAIVideo.SetMetadata("url", viduResp.Creations[0].URL)
- }
-
- if viduResp.State == "failed" && viduResp.ErrCode != "" {
- openAIVideo.Error = &dto.OpenAIVideoError{
- Message: viduResp.ErrCode,
- Code: viduResp.ErrCode,
- }
- }
-
- return common.Marshal(openAIVideo)
-}
diff --git a/relay/channel/volcengine/adaptor.go b/relay/channel/volcengine/adaptor.go
index 598949368e5f..ce8fd5901b78 100644
--- a/relay/channel/volcengine/adaptor.go
+++ b/relay/channel/volcengine/adaptor.go
@@ -239,7 +239,7 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
baseUrl := info.ChannelBaseUrl
if baseUrl == "" {
- baseUrl = channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine]
+ baseUrl = channelconstant.GetChannelBaseURL(channelconstant.ChannelTypeVolcEngine)
}
specialPlan, hasSpecialPlan := channelconstant.ChannelSpecialBases[baseUrl]
@@ -274,7 +274,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
case constant.RelayModeResponses:
return fmt.Sprintf("%s/api/v3/responses", baseUrl), nil
case constant.RelayModeAudioSpeech:
- if baseUrl == channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine] {
+ if baseUrl == channelconstant.GetChannelBaseURL(channelconstant.ChannelTypeVolcEngine) {
return "wss://openspeech.bytedance.com/api/v1/tts/ws_binary", nil
}
return fmt.Sprintf("%s/v1/audio/speech", baseUrl), nil
@@ -333,10 +333,10 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
if info.RelayMode == constant.RelayModeAudioSpeech {
baseUrl := info.ChannelBaseUrl
if baseUrl == "" {
- baseUrl = channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine]
+ baseUrl = channelconstant.GetChannelBaseURL(channelconstant.ChannelTypeVolcEngine)
}
- if baseUrl == channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine] {
+ if baseUrl == channelconstant.GetChannelBaseURL(channelconstant.ChannelTypeVolcEngine) {
if info.IsStream {
return nil, nil
}
diff --git a/relay/channel/zhipu_4v/adaptor.go b/relay/channel/zhipu_4v/adaptor.go
index 3500c5aa7bcf..9153d39f35c6 100644
--- a/relay/channel/zhipu_4v/adaptor.go
+++ b/relay/channel/zhipu_4v/adaptor.go
@@ -46,7 +46,7 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
baseURL := info.ChannelBaseUrl
if baseURL == "" {
- baseURL = channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeZhipu_v4]
+ baseURL = channelconstant.GetChannelBaseURL(channelconstant.ChannelTypeZhipu_v4)
}
specialPlan, hasSpecialPlan := channelconstant.ChannelSpecialBases[baseURL]
diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go
index 56f572343345..6154cfc790f3 100644
--- a/relay/common/relay_info.go
+++ b/relay/common/relay_info.go
@@ -850,6 +850,14 @@ func (info *RelayInfo) HasSendResponse() bool {
return info.FirstResponseTime.After(info.StartTime)
}
+type OriginTaskRef struct {
+ TaskID string
+ UpstreamTaskID string
+ Action string
+ Status string
+ Data []byte
+}
+
type TaskRelayInfo struct {
Action string
OriginTaskID string
@@ -859,6 +867,10 @@ type TaskRelayInfo struct {
ConsumeQuota bool
+ // OriginTasks are plugin-declared public-task dependencies resolved by the
+ // host. Driver hooks receive these as ctx.originTasks; presenters do not.
+ OriginTasks []OriginTaskRef
+
// LockedChannel holds the full channel object when the request is bound to
// a specific channel (e.g., remix on origin task's channel). Stored as any
// to avoid an import cycle with model; callers type-assert to *model.Channel.
@@ -948,15 +960,16 @@ func (t *TaskSubmitReq) UnmarshalMetadata(v any) error {
}
type TaskInfo struct {
- Code int `json:"code"`
- TaskID string `json:"task_id"`
- Status string `json:"status"`
- Reason string `json:"reason,omitempty"`
- Url string `json:"url,omitempty"`
- RemoteUrl string `json:"remote_url,omitempty"`
- Progress string `json:"progress,omitempty"`
- CompletionTokens int `json:"completion_tokens,omitempty"` // 用于按倍率计费
- TotalTokens int `json:"total_tokens,omitempty"` // 用于按倍率计费
+ Code int `json:"code"`
+ TaskID string `json:"task_id"`
+ Status string `json:"status"`
+ Reason string `json:"reason,omitempty"`
+ Url string `json:"url,omitempty"`
+ RemoteUrl string `json:"remote_url,omitempty"`
+ Progress string `json:"progress,omitempty"`
+ CompletionTokens int `json:"completion_tokens,omitempty"` // 用于按倍率计费
+ TotalTokens int `json:"total_tokens,omitempty"` // 用于按倍率计费
+ UsageFacts map[string]any `json:"usage_facts,omitempty"`
}
func FailTaskInfo(reason string) *TaskInfo {
diff --git a/relay/common/relay_utils.go b/relay/common/relay_utils.go
index ab9937595c5a..a0c623ef962d 100644
--- a/relay/common/relay_utils.go
+++ b/relay/common/relay_utils.go
@@ -238,9 +238,9 @@ func ValidateMultipartDirect(c *gin.Context, info *RelayInfo) *dto.TaskError {
return taskErr
}
- action := constant.TaskActionTextGenerate
+ action := constant.TaskActionTextToVideo
if hasInputReference {
- action = constant.TaskActionGenerate
+ action = constant.TaskActionImageToVideo
}
if strings.HasPrefix(model, "sora-2") {
diff --git a/relay/common/relay_utils_test.go b/relay/common/relay_utils_test.go
index 0746d34468a3..47f9566420dd 100644
--- a/relay/common/relay_utils_test.go
+++ b/relay/common/relay_utils_test.go
@@ -74,7 +74,7 @@ func TestValidateMultipartDirectNormalizesImageField(t *testing.T) {
storedReq, err := GetTaskRequest(context)
require.NoError(t, err)
require.Equal(t, []string{"https://example.com/first.png"}, storedReq.Images)
- require.Equal(t, constant.TaskActionGenerate, info.Action)
+ require.Equal(t, constant.TaskActionImageToVideo, info.Action)
}
// TestTaskDurationBounds guards the billing invariant that user-supplied
@@ -130,7 +130,7 @@ func TestTaskDurationBounds(t *testing.T) {
})
t.Run(tt.name+" (basic task request)", func(t *testing.T) {
context, info := newContext(t, tt.body)
- taskErr := ValidateBasicTaskRequest(context, info, constant.TaskActionGenerate)
+ taskErr := ValidateBasicTaskRequest(context, info, constant.TaskActionImageToVideo)
if tt.wantErr {
require.NotNil(t, taskErr)
require.Equal(t, "invalid_seconds", taskErr.Code)
diff --git a/relay/constant/relay_mode.go b/relay/constant/relay_mode.go
index 5f4b3be5b96f..f191c3b66f8d 100644
--- a/relay/constant/relay_mode.go
+++ b/relay/constant/relay_mode.go
@@ -1,7 +1,6 @@
package constant
import (
- "net/http"
"strings"
)
@@ -36,10 +35,6 @@ const (
RelayModeAudioTranscription // whisper
RelayModeAudioTranslation // whisper
- RelayModeSunoFetch
- RelayModeSunoFetchByID
- RelayModeSunoSubmit
-
RelayModeVideoFetchByID
RelayModeVideoSubmit
@@ -140,15 +135,3 @@ func Path2RelayModeMidjourney(path string) int {
}
return relayMode
}
-
-func Path2RelaySuno(method, path string) int {
- relayMode := RelayModeUnknown
- if method == http.MethodPost && strings.HasSuffix(path, "/fetch") {
- relayMode = RelayModeSunoFetch
- } else if method == http.MethodGet && strings.Contains(path, "/fetch/") {
- relayMode = RelayModeSunoFetchByID
- } else if strings.Contains(path, "/submit/") {
- relayMode = RelayModeSunoSubmit
- }
- return relayMode
-}
diff --git a/relay/plugin_protocol.go b/relay/plugin_protocol.go
new file mode 100644
index 000000000000..617ed3688d2f
--- /dev/null
+++ b/relay/plugin_protocol.go
@@ -0,0 +1,1077 @@
+package relay
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/dto"
+)
+
+const (
+ pluginResponseStatusInProgress = "in_progress"
+ pluginResponseStatusCompleted = "completed"
+ pluginResponseStatusFailed = "failed"
+ pluginResponseStatusIncomplete = "incomplete"
+ pluginResponseStatusQueued = "queued"
+)
+
+// PluginProtocolLimits bounds untrusted semantic output before it reaches the
+// host-owned Responses state machine.
+type PluginProtocolLimits struct {
+ MaxEventsPerTick int
+ MaxEventsBytes int
+ MaxEventBytes int
+ MaxEventDepth int
+ MaxStateBytes int
+ MaxStateDepth int
+ MaxOutputs int
+ MaxTotalOutputBytes int
+ MaxMessageBytes int
+ MaxMetadataValueBytes int
+ MaxCodeBytes int
+}
+
+func DefaultPluginProtocolLimits() PluginProtocolLimits {
+ return PluginProtocolLimits{
+ MaxEventsPerTick: 16,
+ MaxEventsBytes: 64 << 10,
+ MaxEventBytes: 32 << 10,
+ MaxEventDepth: 16,
+ MaxStateBytes: 16 << 10,
+ MaxStateDepth: 16,
+ MaxOutputs: 64,
+ MaxTotalOutputBytes: 1 << 20,
+ MaxMessageBytes: 4 << 10,
+ MaxMetadataValueBytes: 512,
+ MaxCodeBytes: 128,
+ }
+}
+
+func (l PluginProtocolLimits) withDefaults() PluginProtocolLimits {
+ defaults := DefaultPluginProtocolLimits()
+ if l.MaxEventsPerTick <= 0 {
+ l.MaxEventsPerTick = defaults.MaxEventsPerTick
+ }
+ if l.MaxEventsBytes <= 0 {
+ l.MaxEventsBytes = defaults.MaxEventsBytes
+ }
+ if l.MaxEventBytes <= 0 {
+ l.MaxEventBytes = defaults.MaxEventBytes
+ }
+ if l.MaxEventDepth <= 0 {
+ l.MaxEventDepth = defaults.MaxEventDepth
+ }
+ if l.MaxStateBytes <= 0 {
+ l.MaxStateBytes = defaults.MaxStateBytes
+ }
+ if l.MaxStateDepth <= 0 {
+ l.MaxStateDepth = defaults.MaxStateDepth
+ }
+ if l.MaxOutputs <= 0 {
+ l.MaxOutputs = defaults.MaxOutputs
+ }
+ if l.MaxTotalOutputBytes <= 0 {
+ l.MaxTotalOutputBytes = defaults.MaxTotalOutputBytes
+ }
+ if l.MaxMessageBytes <= 0 {
+ l.MaxMessageBytes = defaults.MaxMessageBytes
+ }
+ if l.MaxMetadataValueBytes <= 0 {
+ l.MaxMetadataValueBytes = defaults.MaxMetadataValueBytes
+ }
+ if l.MaxCodeBytes <= 0 {
+ l.MaxCodeBytes = defaults.MaxCodeBytes
+ }
+ return l
+}
+
+// ProtocolState distinguishes an omitted state property from an explicit JSON
+// null. Value is always a validated, detached JSON value when Present is true.
+type ProtocolState struct {
+ Present bool
+ Null bool
+ Value json.RawMessage
+}
+
+func (s ProtocolState) PluginValue() (any, error) {
+ if !s.Present || s.Null {
+ return nil, nil
+ }
+ var value any
+ if err := common.Unmarshal(s.Value, &value); err != nil {
+ return nil, err
+ }
+ return value, nil
+}
+
+type ProtocolSemanticEvent struct {
+ Type string
+ Progress *float64
+ Message *string
+ Data json.RawMessage
+ Code *string
+}
+
+type ProtocolEventResult struct {
+ Events []ProtocolSemanticEvent
+ State ProtocolState
+ Done bool
+}
+
+// DecodePluginProtocolEventResult converts an exported JS value into the
+// deliberately small semantic event contract. Unknown fields are rejected so
+// plugins cannot smuggle protocol-owned wire fields into the response.
+func DecodePluginProtocolEventResult(value any, limits PluginProtocolLimits) (ProtocolEventResult, error) {
+ limits = limits.withDefaults()
+ encoded, err := common.Marshal(value)
+ if err != nil {
+ return ProtocolEventResult{}, fmt.Errorf("protocol event result is not JSON-compatible: %w", err)
+ }
+ maxResultBytes := limits.MaxEventsBytes + limits.MaxStateBytes + 4096
+ if len(encoded) > maxResultBytes {
+ return ProtocolEventResult{}, fmt.Errorf("protocol event result exceeds %d bytes", maxResultBytes)
+ }
+
+ var fields map[string]json.RawMessage
+ if err := common.Unmarshal(encoded, &fields); err != nil || fields == nil {
+ return ProtocolEventResult{}, errors.New("protocol event result must be an object")
+ }
+ for name := range fields {
+ switch name {
+ case "events", "state", "done":
+ default:
+ return ProtocolEventResult{}, fmt.Errorf("protocol event result contains unknown field %q", name)
+ }
+ }
+
+ rawEvents, ok := fields["events"]
+ if !ok || isJSONNull(rawEvents) {
+ return ProtocolEventResult{}, errors.New("protocol event result events must be an array")
+ }
+ if len(rawEvents) > limits.MaxEventsBytes {
+ return ProtocolEventResult{}, fmt.Errorf("protocol events exceed %d bytes", limits.MaxEventsBytes)
+ }
+ var encodedEvents []json.RawMessage
+ if err := common.Unmarshal(rawEvents, &encodedEvents); err != nil {
+ return ProtocolEventResult{}, errors.New("protocol event result events must be an array")
+ }
+ if len(encodedEvents) > limits.MaxEventsPerTick {
+ return ProtocolEventResult{}, fmt.Errorf("protocol events exceed limit of %d", limits.MaxEventsPerTick)
+ }
+
+ rawDone, ok := fields["done"]
+ if !ok || isJSONNull(rawDone) {
+ return ProtocolEventResult{}, errors.New("protocol event result done must be a boolean")
+ }
+ var done bool
+ if err := common.Unmarshal(rawDone, &done); err != nil {
+ return ProtocolEventResult{}, errors.New("protocol event result done must be a boolean")
+ }
+
+ result := ProtocolEventResult{
+ Events: make([]ProtocolSemanticEvent, 0, len(encodedEvents)),
+ Done: done,
+ }
+ for _, rawEvent := range encodedEvents {
+ event, err := decodePluginSemanticEvent(rawEvent, limits)
+ if err != nil {
+ return ProtocolEventResult{}, err
+ }
+ result.Events = append(result.Events, event)
+ }
+
+ if rawState, exists := fields["state"]; exists {
+ if len(rawState) > limits.MaxStateBytes {
+ return ProtocolEventResult{}, fmt.Errorf("protocol state exceeds %d bytes", limits.MaxStateBytes)
+ }
+ depth, err := pluginJSONDepth(rawState)
+ if err != nil {
+ return ProtocolEventResult{}, errors.New("protocol state must be JSON-compatible")
+ }
+ if depth > limits.MaxStateDepth {
+ return ProtocolEventResult{}, fmt.Errorf("protocol state exceeds depth limit of %d", limits.MaxStateDepth)
+ }
+ result.State = ProtocolState{
+ Present: true,
+ Null: isJSONNull(rawState),
+ Value: append(json.RawMessage(nil), rawState...),
+ }
+ }
+ return result, nil
+}
+
+func decodePluginSemanticEvent(raw json.RawMessage, limits PluginProtocolLimits) (ProtocolSemanticEvent, error) {
+ if len(raw) > limits.MaxEventBytes {
+ return ProtocolSemanticEvent{}, fmt.Errorf("protocol event exceeds %d bytes", limits.MaxEventBytes)
+ }
+ depth, err := pluginJSONDepth(raw)
+ if err != nil {
+ return ProtocolSemanticEvent{}, errors.New("protocol event must be a JSON object")
+ }
+ if depth > limits.MaxEventDepth {
+ return ProtocolSemanticEvent{}, fmt.Errorf("protocol event exceeds depth limit of %d", limits.MaxEventDepth)
+ }
+
+ var fields map[string]json.RawMessage
+ if err := common.Unmarshal(raw, &fields); err != nil || fields == nil {
+ return ProtocolSemanticEvent{}, errors.New("protocol event must be a JSON object")
+ }
+ rawType, ok := fields["type"]
+ if !ok || isJSONNull(rawType) {
+ return ProtocolSemanticEvent{}, errors.New("protocol event type is required")
+ }
+ var eventType string
+ if err := common.Unmarshal(rawType, &eventType); err != nil {
+ return ProtocolSemanticEvent{}, errors.New("protocol event type must be a string")
+ }
+
+ event := ProtocolSemanticEvent{Type: eventType}
+ switch eventType {
+ case "progress":
+ if err := rejectUnknownProtocolFields(fields, "type", "progress", "message"); err != nil {
+ return ProtocolSemanticEvent{}, err
+ }
+ if rawProgress, exists := fields["progress"]; exists {
+ if isJSONNull(rawProgress) {
+ return ProtocolSemanticEvent{}, errors.New("progress event progress must be a number")
+ }
+ var progress float64
+ if err := common.Unmarshal(rawProgress, &progress); err != nil ||
+ math.IsNaN(progress) || math.IsInf(progress, 0) ||
+ progress < 0 || progress > 100 {
+ return ProtocolSemanticEvent{}, errors.New("progress event progress must be between 0 and 100")
+ }
+ event.Progress = &progress
+ }
+ if rawMessage, exists := fields["message"]; exists {
+ message, err := decodeBoundedProtocolString(rawMessage, "progress event message", limits.MaxMetadataValueBytes, false)
+ if err != nil {
+ return ProtocolSemanticEvent{}, err
+ }
+ event.Message = &message
+ }
+ case "output":
+ if err := rejectUnknownProtocolFields(fields, "type", "data"); err != nil {
+ return ProtocolSemanticEvent{}, err
+ }
+ rawData, exists := fields["data"]
+ if !exists {
+ return ProtocolSemanticEvent{}, errors.New("output event data is required")
+ }
+ if len(rawData) > limits.MaxEventBytes {
+ return ProtocolSemanticEvent{}, fmt.Errorf("output event data exceeds %d bytes", limits.MaxEventBytes)
+ }
+ event.Data = append(json.RawMessage(nil), rawData...)
+ case "error":
+ if err := rejectUnknownProtocolFields(fields, "type", "code", "message"); err != nil {
+ return ProtocolSemanticEvent{}, err
+ }
+ rawMessage, exists := fields["message"]
+ if !exists {
+ return ProtocolSemanticEvent{}, errors.New("error event message is required")
+ }
+ message, err := decodeBoundedProtocolString(rawMessage, "error event message", limits.MaxMessageBytes, true)
+ if err != nil {
+ return ProtocolSemanticEvent{}, err
+ }
+ event.Message = &message
+ if rawCode, exists := fields["code"]; exists {
+ code, err := decodeBoundedProtocolString(rawCode, "error event code", limits.MaxCodeBytes, false)
+ if err != nil {
+ return ProtocolSemanticEvent{}, err
+ }
+ event.Code = &code
+ }
+ default:
+ return ProtocolSemanticEvent{}, fmt.Errorf("unsupported protocol event type %q", eventType)
+ }
+ return event, nil
+}
+
+func rejectUnknownProtocolFields(fields map[string]json.RawMessage, allowed ...string) error {
+ allowedSet := make(map[string]struct{}, len(allowed))
+ for _, name := range allowed {
+ allowedSet[name] = struct{}{}
+ }
+ for name := range fields {
+ if _, ok := allowedSet[name]; !ok {
+ return fmt.Errorf("protocol event contains unknown field %q", name)
+ }
+ }
+ return nil
+}
+
+func decodeBoundedProtocolString(raw json.RawMessage, field string, maxBytes int, required bool) (string, error) {
+ if isJSONNull(raw) {
+ return "", fmt.Errorf("%s must be a string", field)
+ }
+ var value string
+ if err := common.Unmarshal(raw, &value); err != nil {
+ return "", fmt.Errorf("%s must be a string", field)
+ }
+ if required && strings.TrimSpace(value) == "" {
+ return "", fmt.Errorf("%s is required", field)
+ }
+ if len(value) > maxBytes {
+ return "", fmt.Errorf("%s exceeds %d bytes", field, maxBytes)
+ }
+ return value, nil
+}
+
+func pluginJSONDepth(raw json.RawMessage) (int, error) {
+ var value any
+ if err := common.Unmarshal(raw, &value); err != nil {
+ return 0, err
+ }
+ var depth func(any) int
+ depth = func(current any) int {
+ switch typed := current.(type) {
+ case []any:
+ maxChild := 0
+ for _, child := range typed {
+ maxChild = max(maxChild, depth(child))
+ }
+ return 1 + maxChild
+ case map[string]any:
+ maxChild := 0
+ for _, child := range typed {
+ maxChild = max(maxChild, depth(child))
+ }
+ return 1 + maxChild
+ default:
+ return 1
+ }
+ }
+ return depth(value), nil
+}
+
+func isJSONNull(raw json.RawMessage) bool {
+ return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
+}
+
+// PluginResponsesMachine owns the Responses wire state for one durable task.
+// It contains no IO and is intentionally independent of plugin runtimes.
+type PluginResponsesMachine struct {
+ taskID string
+ responseID string
+ model string
+ createdAt int64
+ limits PluginProtocolLimits
+ nextSequence int
+ started bool
+ terminal bool
+ status string
+ metadata map[string]string
+ outputs []dto.PluginResponsesOutput
+ totalOutputBytes int
+ usage *dto.PluginResponsesUsage
+ background bool
+}
+
+func NewPluginResponsesMachine(taskID, model string, createdAt int64, limits PluginProtocolLimits) *PluginResponsesMachine {
+ taskID = strings.TrimSpace(taskID)
+ responseID := "resp_" + strings.TrimPrefix(taskID, "task_")
+ return &PluginResponsesMachine{
+ taskID: taskID,
+ responseID: responseID,
+ model: model,
+ createdAt: createdAt,
+ limits: limits.withDefaults(),
+ status: pluginResponseStatusInProgress,
+ metadata: map[string]string{
+ "task_id": taskID,
+ "task_status": pluginResponseStatusQueued,
+ "retrieval_path": "/v1/responses/" + responseID,
+ },
+ outputs: make([]dto.PluginResponsesOutput, 0),
+ }
+}
+
+func (m *PluginResponsesMachine) SetBackground(background bool) {
+ m.background = background
+}
+
+// PendingResponse is the host-synthesized non-terminal Responses snapshot.
+// Callers must pass a non-terminal task status; completed/failed/incomplete
+// inputs are mapped to in_progress so the wire status stays queued|in_progress.
+func (m *PluginResponsesMachine) PendingResponse(taskStatus string) map[string]any {
+ status := pluginTaskStatus(taskStatus)
+ if status != pluginResponseStatusQueued && status != pluginResponseStatusInProgress {
+ status = pluginResponseStatusInProgress
+ }
+ return map[string]any{
+ "id": m.responseID,
+ "object": "response",
+ "created_at": m.createdAt,
+ "status": status,
+ "background": m.background,
+ "completed_at": nil,
+ "error": nil,
+ "incomplete_details": nil,
+ "model": m.model,
+ "output": []any{},
+ "usage": nil,
+ "metadata": map[string]string{
+ "task_id": m.taskID,
+ "task_status": status,
+ "retrieval_path": "/v1/responses/" + m.responseID,
+ },
+ }
+}
+
+func (m *PluginResponsesMachine) CreatedEvent() (dto.PluginResponsesStreamEvent, error) {
+ if m.started {
+ return dto.PluginResponsesStreamEvent{}, errors.New("response.created was already emitted")
+ }
+ if m.terminal {
+ return dto.PluginResponsesStreamEvent{}, errors.New("response is already terminal")
+ }
+ m.started = true
+ return m.responseEvent("response.created"), nil
+}
+
+// ApplyTick maps bounded plugin semantics onto host-owned Responses events.
+// taskStatus is the current durable DB status (for example, IN_PROGRESS,
+// SUCCESS, or FAILURE).
+func (m *PluginResponsesMachine) ApplyTick(result ProtocolEventResult, taskStatus string) ([]dto.PluginResponsesStreamEvent, error) {
+ if !m.started {
+ return nil, errors.New("response.created must be emitted before applying events")
+ }
+ if m.terminal {
+ return nil, errors.New("response is already terminal")
+ }
+ if strings.EqualFold(strings.TrimSpace(taskStatus), "FAILURE") {
+ m.metadata["task_status"] = pluginResponseStatusFailed
+ return []dto.PluginResponsesStreamEvent{m.fail("server_error", "The task failed.")}, nil
+ }
+
+ outputTexts := make(map[int]string)
+ additionalBytes := 0
+ additionalOutputs := 0
+ for index, event := range result.Events {
+ switch event.Type {
+ case "progress":
+ if event.Progress != nil &&
+ (math.IsNaN(*event.Progress) || math.IsInf(*event.Progress, 0) ||
+ *event.Progress < 0 || *event.Progress > 100) {
+ return nil, errors.New("progress event progress must be between 0 and 100")
+ }
+ if event.Message != nil && len(*event.Message) > m.limits.MaxMetadataValueBytes {
+ return nil, fmt.Errorf("progress event message exceeds %d bytes", m.limits.MaxMetadataValueBytes)
+ }
+ case "output":
+ text, err := pluginOutputText(event.Data)
+ if err != nil {
+ return nil, err
+ }
+ if len(text) > m.limits.MaxEventBytes {
+ return nil, fmt.Errorf("output event data exceeds %d bytes", m.limits.MaxEventBytes)
+ }
+ outputTexts[index] = text
+ additionalBytes += len(text)
+ additionalOutputs++
+ case "error":
+ if event.Message == nil || strings.TrimSpace(*event.Message) == "" {
+ return nil, errors.New("error event message is required")
+ }
+ default:
+ return nil, fmt.Errorf("unsupported protocol event type %q", event.Type)
+ }
+ }
+ if len(m.outputs)+additionalOutputs > m.limits.MaxOutputs {
+ return nil, fmt.Errorf("response outputs exceed limit of %d", m.limits.MaxOutputs)
+ }
+ if m.totalOutputBytes+additionalBytes > m.limits.MaxTotalOutputBytes {
+ return nil, fmt.Errorf("response output exceeds cumulative limit of %d bytes", m.limits.MaxTotalOutputBytes)
+ }
+
+ m.metadata["task_status"] = pluginTaskStatus(taskStatus)
+ events := make([]dto.PluginResponsesStreamEvent, 0, len(result.Events)*2+1)
+ for index, semantic := range result.Events {
+ switch semantic.Type {
+ case "progress":
+ if semantic.Progress != nil {
+ m.metadata["task_progress"] = strconv.FormatFloat(*semantic.Progress, 'f', -1, 64)
+ }
+ if semantic.Message != nil {
+ m.metadata["task_message"] = *semantic.Message
+ }
+ if len(m.outputs) == 0 {
+ events = append(events, m.progressEvent())
+ }
+ case "output":
+ events = append(events, m.appendOutput(outputTexts[index])...)
+ case "error":
+ events = append(events, m.fail(
+ "server_error",
+ "The task failed.",
+ ))
+ return events, nil
+ }
+ }
+
+ switch strings.ToUpper(strings.TrimSpace(taskStatus)) {
+ case "SUCCESS":
+ events = append(events, m.complete())
+ case "FAILURE":
+ events = append(events, m.fail("server_error", "The task failed."))
+ default:
+ if result.Done {
+ events = append(events, m.incomplete())
+ }
+ }
+ return events, nil
+}
+
+// FailureEvent terminates an already-started stream after a host observation,
+// hook, or validation failure. It never accepts a detail string, preventing
+// upstream and plugin internals from reaching clients.
+func (m *PluginResponsesMachine) FailureEvent(taskStatus ...string) (dto.PluginResponsesStreamEvent, error) {
+ if !m.started {
+ return dto.PluginResponsesStreamEvent{}, errors.New("response.created must be emitted before response.failed")
+ }
+ if m.terminal {
+ return dto.PluginResponsesStreamEvent{}, errors.New("response is already terminal")
+ }
+ m.setPersistedTaskStatus(taskStatus)
+ return m.fail("server_error", "The task could not be observed."), nil
+}
+
+func (m *PluginResponsesMachine) TimeoutEvent(taskStatus ...string) (dto.PluginResponsesStreamEvent, error) {
+ if !m.started {
+ return dto.PluginResponsesStreamEvent{}, errors.New("response.created must be emitted before response.incomplete")
+ }
+ if m.terminal {
+ return dto.PluginResponsesStreamEvent{}, errors.New("response is already terminal")
+ }
+ m.setPersistedTaskStatus(taskStatus)
+ return m.incomplete(), nil
+}
+
+// FinalResponse validates a plugin-authored complete Responses object and
+// overwrites every host-owned identity and lifecycle field. Unknown
+// protocol fields are retained so the unreleased v1 Record contract can
+// represent response features beyond output_text.
+func (m *PluginResponsesMachine) FinalResponse(payload any, taskStatus string) (map[string]any, error) {
+ if m.started || m.terminal {
+ return nil, errors.New("response state machine has already started")
+ }
+ switch strings.ToUpper(strings.TrimSpace(taskStatus)) {
+ case "SUCCESS":
+ response, err := m.canonicalFinalResponse(payload)
+ if err != nil {
+ return nil, err
+ }
+ m.status = pluginResponseStatusCompleted
+ m.metadata["task_status"] = pluginResponseStatusCompleted
+ response["id"] = m.responseID
+ response["object"] = "response"
+ response["created_at"] = m.createdAt
+ response["status"] = pluginResponseStatusCompleted
+ response["error"] = nil
+ response["incomplete_details"] = nil
+ response["model"] = m.model
+ response["metadata"] = m.finalMetadata(response["metadata"])
+ response["usage"] = zeroPluginResponsesUsage()
+ delete(response, "sequence_number")
+ m.terminal = true
+ return response, nil
+ case "FAILURE":
+ m.status = pluginResponseStatusFailed
+ m.metadata["task_status"] = pluginResponseStatusFailed
+ default:
+ return nil, errors.New("final response requires a terminal task")
+ }
+ m.terminal = true
+ response, err := pluginResponseMap(m.responseSnapshot(&dto.PluginResponsesError{
+ Code: "server_error",
+ Message: "The task failed.",
+ }))
+ if err != nil {
+ return nil, err
+ }
+ return response, nil
+}
+
+// FinalFromEvents synthesizes the retrieval Response for stream-only plugins
+// from one renderEvents call at terminal task status. Synthesis runs on a
+// scratch machine so a hook failure leaves the receiver untouched and the
+// caller's failure-envelope path (which requires an unstarted machine) stays valid.
+func (m *PluginResponsesMachine) FinalFromEvents(result ProtocolEventResult, taskStatus string) (map[string]any, error) {
+ if m.started || m.terminal {
+ return nil, errors.New("response state machine has already started")
+ }
+ scratch := NewPluginResponsesMachine(m.taskID, m.model, m.createdAt, m.limits)
+ scratch.background = m.background
+ scratch.started = true
+ if _, err := scratch.ApplyTick(result, taskStatus); err != nil {
+ return nil, err
+ }
+ if !scratch.terminal {
+ return nil, errors.New("renderEvents did not terminate at terminal task status")
+ }
+ if scratch.status != pluginResponseStatusCompleted {
+ return nil, errors.New("renderEvents reported failure at terminal task status")
+ }
+ *m = *scratch
+ return pluginResponseMap(m.responseSnapshot(nil))
+}
+
+// FailureResponse returns a sanitized non-stream failure for host-side
+// protocol errors.
+func (m *PluginResponsesMachine) FailureResponse(taskStatus ...string) (*dto.PluginResponsesResponse, error) {
+ if m.started || m.terminal {
+ return nil, errors.New("response state machine has already started")
+ }
+ m.status = pluginResponseStatusFailed
+ m.setPersistedTaskStatus(taskStatus)
+ m.terminal = true
+ return m.responseSnapshot(&dto.PluginResponsesError{
+ Code: "server_error",
+ Message: "The task could not be observed.",
+ }), nil
+}
+
+// TimeoutResponse is the documented non-stream polling timeout shape. Unlike
+// a live stream timeout, its top-level state remains queued so clients know to
+// use retrieval_path rather than treating observation timeout as task failure.
+func (m *PluginResponsesMachine) TimeoutResponse(taskStatus ...string) (*dto.PluginResponsesResponse, error) {
+ if m.started || m.terminal {
+ return nil, errors.New("response state machine has already started")
+ }
+ m.status = pluginResponseStatusQueued
+ lastStatus := ""
+ if len(taskStatus) > 0 {
+ lastStatus = taskStatus[0]
+ }
+ persistedStatus := pluginTaskStatus(lastStatus)
+ if persistedStatus == "" {
+ persistedStatus = pluginResponseStatusQueued
+ }
+ m.metadata["task_status"] = persistedStatus
+ m.terminal = true
+ return m.responseSnapshot(nil), nil
+}
+
+func (m *PluginResponsesMachine) canonicalFinalResponse(payload any) (map[string]any, error) {
+ encoded, err := common.Marshal(payload)
+ if err != nil {
+ return nil, fmt.Errorf("final response is not JSON-compatible: %w", err)
+ }
+ if len(encoded) > m.limits.MaxTotalOutputBytes+(64<<10) {
+ return nil, fmt.Errorf("final response exceeds %d bytes", m.limits.MaxTotalOutputBytes+(64<<10))
+ }
+ depth, err := pluginJSONDepth(encoded)
+ if err != nil || depth > m.limits.MaxEventDepth {
+ return nil, fmt.Errorf("final response exceeds depth limit of %d", m.limits.MaxEventDepth)
+ }
+ var response map[string]any
+ if err = common.Unmarshal(encoded, &response); err != nil || response == nil {
+ return nil, errors.New("final response must be an object")
+ }
+
+ rawOutput, exists := response["output"]
+ output := []any{}
+ if exists {
+ var ok bool
+ output, ok = rawOutput.([]any)
+ if !ok {
+ return nil, errors.New("final response output must be an array")
+ }
+ }
+ if len(output) > m.limits.MaxOutputs {
+ return nil, fmt.Errorf("response outputs exceed limit of %d", m.limits.MaxOutputs)
+ }
+ for outputIndex, rawItem := range output {
+ item, ok := rawItem.(map[string]any)
+ if !ok {
+ return nil, errors.New("final response output items must be objects")
+ }
+ itemType, ok := item["type"].(string)
+ if !ok || itemType != "message" {
+ return nil, errors.New("final response output items must be message objects")
+ }
+ role, ok := item["role"].(string)
+ if !ok || role != "assistant" {
+ return nil, errors.New("final response message role must be assistant")
+ }
+ item["id"] = fmt.Sprintf("item_%s_%d", m.taskID, outputIndex)
+ item["status"] = pluginResponseStatusCompleted
+ rawContent, hasContent := item["content"]
+ if !hasContent {
+ return nil, errors.New("final response message content must be an array")
+ }
+ content, ok := rawContent.([]any)
+ if !ok || len(content) > m.limits.MaxOutputs {
+ return nil, fmt.Errorf("final response output content must contain at most %d objects", m.limits.MaxOutputs)
+ }
+ for contentIndex, rawPart := range content {
+ part, ok := rawPart.(map[string]any)
+ if !ok {
+ return nil, errors.New("final response output content parts must be objects")
+ }
+ partType, ok := part["type"].(string)
+ if !ok {
+ return nil, errors.New("final response content part type is required")
+ }
+ switch partType {
+ case "output_text":
+ if _, ok = part["text"].(string); !ok {
+ return nil, errors.New("final response output_text text must be a string")
+ }
+ if _, ok = part["annotations"].([]any); !ok {
+ return nil, errors.New("final response output_text annotations must be an array")
+ }
+ if _, ok = part["logprobs"].([]any); !ok {
+ return nil, errors.New("final response output_text logprobs must be an array")
+ }
+ case "refusal":
+ if _, ok = part["refusal"].(string); !ok {
+ return nil, errors.New("final response refusal must be a string")
+ }
+ default:
+ return nil, fmt.Errorf("unsupported final response content part type %q", partType)
+ }
+ part["id"] = fmt.Sprintf("content_%s_%d_%d", m.taskID, outputIndex, contentIndex)
+ }
+ }
+ encodedOutput, err := common.Marshal(output)
+ if err != nil || len(encodedOutput) > m.limits.MaxTotalOutputBytes {
+ return nil, fmt.Errorf("final response output exceeds %d bytes", m.limits.MaxTotalOutputBytes)
+ }
+ response["output"] = output
+ if err = normalizeFinalResponseDefaults(response); err != nil {
+ return nil, err
+ }
+ return response, nil
+}
+
+func normalizeFinalResponseDefaults(response map[string]any) error {
+ if instructions, exists := response["instructions"]; exists {
+ switch instructions.(type) {
+ case nil, string, []any:
+ default:
+ return errors.New("final response instructions must be null, a string, or an array")
+ }
+ } else {
+ response["instructions"] = nil
+ }
+ if parallel, exists := response["parallel_tool_calls"]; exists {
+ if _, ok := parallel.(bool); !ok {
+ return errors.New("final response parallel_tool_calls must be a boolean")
+ }
+ } else {
+ response["parallel_tool_calls"] = true
+ }
+ if err := normalizeFinalResponseNumber(response, "temperature", 1, 0, 2); err != nil {
+ return err
+ }
+ if toolChoice, exists := response["tool_choice"]; exists {
+ switch toolChoice.(type) {
+ case string, map[string]any:
+ default:
+ return errors.New("final response tool_choice must be a string or object")
+ }
+ } else {
+ response["tool_choice"] = "auto"
+ }
+ if tools, exists := response["tools"]; exists {
+ if _, ok := tools.([]any); !ok {
+ return errors.New("final response tools must be an array")
+ }
+ } else {
+ response["tools"] = []any{}
+ }
+ return normalizeFinalResponseNumber(response, "top_p", 1, 0, 1)
+}
+
+func normalizeFinalResponseNumber(
+ response map[string]any,
+ field string,
+ defaultValue float64,
+ minimum float64,
+ maximum float64,
+) error {
+ value, exists := response[field]
+ if !exists {
+ response[field] = defaultValue
+ return nil
+ }
+ number, ok := value.(float64)
+ if !ok || math.IsNaN(number) || math.IsInf(number, 0) || number < minimum || number > maximum {
+ return fmt.Errorf("final response %s must be between %g and %g", field, minimum, maximum)
+ }
+ return nil
+}
+
+func (m *PluginResponsesMachine) finalMetadata(pluginValue any) map[string]string {
+ metadata := make(map[string]string)
+ if pluginMetadata, ok := pluginValue.(map[string]any); ok {
+ keys := make([]string, 0, len(pluginMetadata))
+ for key := range pluginMetadata {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ pluginLimit := max(0, 16-len(m.metadata))
+ for _, key := range keys {
+ if len(metadata) >= pluginLimit {
+ break
+ }
+ if _, hostOwned := m.metadata[key]; hostOwned {
+ continue
+ }
+ rawValue := pluginMetadata[key]
+ value, stringOK := rawValue.(string)
+ if !stringOK || len(key) > 64 || len(value) > m.limits.MaxMetadataValueBytes {
+ continue
+ }
+ metadata[key] = value
+ }
+ }
+ for key, value := range m.metadata {
+ metadata[key] = value
+ }
+ return metadata
+}
+
+func pluginResponseMap(response *dto.PluginResponsesResponse) (map[string]any, error) {
+ encoded, err := common.Marshal(response)
+ if err != nil {
+ return nil, err
+ }
+ var value map[string]any
+ if err = common.Unmarshal(encoded, &value); err != nil {
+ return nil, err
+ }
+ return value, nil
+}
+
+func (m *PluginResponsesMachine) appendOutput(text string) []dto.PluginResponsesStreamEvent {
+ outputIndex := len(m.outputs)
+ itemID := fmt.Sprintf("msg_%s_%d", m.taskID, outputIndex)
+ contentID := fmt.Sprintf("content_%s_%d", m.taskID, outputIndex)
+ emptyLogprobs := []any{}
+
+ addedItem := dto.PluginResponsesOutput{
+ ID: itemID,
+ Type: "message",
+ Status: pluginResponseStatusInProgress,
+ Role: "assistant",
+ Content: []dto.PluginResponsesContent{},
+ }
+ partAdded := dto.PluginResponsesContent{
+ ID: contentID,
+ Type: "output_text",
+ Text: "",
+ Annotations: []any{},
+ Logprobs: []any{},
+ }
+ completedItem := m.newCompletedOutputWithIDs(itemID, contentID, text)
+ m.outputs = append(m.outputs, completedItem)
+ m.totalOutputBytes += len(text)
+
+ return []dto.PluginResponsesStreamEvent{
+ m.event(dto.PluginResponsesStreamEvent{
+ Type: "response.output_item.added",
+ OutputIndex: intPointer(outputIndex),
+ Item: &addedItem,
+ }),
+ m.event(dto.PluginResponsesStreamEvent{
+ Type: "response.content_part.added",
+ OutputIndex: intPointer(outputIndex),
+ ContentIndex: intPointer(0),
+ ItemID: itemID,
+ Part: &partAdded,
+ }),
+ m.event(dto.PluginResponsesStreamEvent{
+ Type: "response.output_text.delta",
+ OutputIndex: intPointer(outputIndex),
+ ContentIndex: intPointer(0),
+ ItemID: itemID,
+ Delta: &text,
+ Logprobs: &emptyLogprobs,
+ }),
+ m.event(dto.PluginResponsesStreamEvent{
+ Type: "response.output_text.done",
+ OutputIndex: intPointer(outputIndex),
+ ContentIndex: intPointer(0),
+ ItemID: itemID,
+ Text: &text,
+ Logprobs: &emptyLogprobs,
+ }),
+ m.event(dto.PluginResponsesStreamEvent{
+ Type: "response.content_part.done",
+ OutputIndex: intPointer(outputIndex),
+ ContentIndex: intPointer(0),
+ ItemID: itemID,
+ Part: &completedItem.Content[0],
+ }),
+ m.event(dto.PluginResponsesStreamEvent{
+ Type: "response.output_item.done",
+ OutputIndex: intPointer(outputIndex),
+ Item: &completedItem,
+ }),
+ }
+}
+
+func (m *PluginResponsesMachine) newCompletedOutputWithIDs(itemID, contentID, text string) dto.PluginResponsesOutput {
+ return dto.PluginResponsesOutput{
+ ID: itemID,
+ Type: "message",
+ Status: pluginResponseStatusCompleted,
+ Role: "assistant",
+ Content: []dto.PluginResponsesContent{
+ {
+ ID: contentID,
+ Type: "output_text",
+ Text: text,
+ Annotations: []any{},
+ Logprobs: []any{},
+ },
+ },
+ }
+}
+
+func (m *PluginResponsesMachine) complete() dto.PluginResponsesStreamEvent {
+ m.status = pluginResponseStatusCompleted
+ m.metadata["task_status"] = pluginResponseStatusCompleted
+ m.usage = zeroPluginResponsesUsage()
+ m.terminal = true
+ return m.responseEvent("response.completed")
+}
+
+func (m *PluginResponsesMachine) incomplete() dto.PluginResponsesStreamEvent {
+ m.status = pluginResponseStatusIncomplete
+ m.usage = zeroPluginResponsesUsage()
+ m.terminal = true
+ return m.responseEvent("response.incomplete")
+}
+
+func (m *PluginResponsesMachine) fail(code, message string) dto.PluginResponsesStreamEvent {
+ m.status = pluginResponseStatusFailed
+ m.terminal = true
+ return m.event(dto.PluginResponsesStreamEvent{
+ Type: "response.failed",
+ Response: m.responseSnapshot(&dto.PluginResponsesError{
+ Code: code,
+ Message: message,
+ }),
+ })
+}
+
+func (m *PluginResponsesMachine) responseEvent(eventType string) dto.PluginResponsesStreamEvent {
+ return m.event(dto.PluginResponsesStreamEvent{
+ Type: eventType,
+ Response: m.responseSnapshot(nil),
+ })
+}
+
+func (m *PluginResponsesMachine) progressEvent() dto.PluginResponsesStreamEvent {
+ response := m.responseSnapshot(nil)
+ // Progress events carry task metadata, while output content is already
+ // represented by its own item/content events and the eventual terminal
+ // snapshot. Keeping this list empty prevents repeated 1 MiB snapshots.
+ response.Output = []dto.PluginResponsesOutput{}
+ return m.event(dto.PluginResponsesStreamEvent{
+ Type: "response.in_progress",
+ Response: response,
+ })
+}
+
+func (m *PluginResponsesMachine) setPersistedTaskStatus(taskStatus []string) {
+ if len(taskStatus) == 0 {
+ return
+ }
+ status := pluginTaskStatus(taskStatus[0])
+ if status != "" {
+ m.metadata["task_status"] = status
+ }
+}
+
+func (m *PluginResponsesMachine) event(event dto.PluginResponsesStreamEvent) dto.PluginResponsesStreamEvent {
+ event.SequenceNumber = m.nextSequence
+ m.nextSequence++
+ return event
+}
+
+func (m *PluginResponsesMachine) responseSnapshot(responseError *dto.PluginResponsesError) *dto.PluginResponsesResponse {
+ metadata := make(map[string]string, len(m.metadata))
+ for key, value := range m.metadata {
+ metadata[key] = value
+ }
+ outputs := make([]dto.PluginResponsesOutput, len(m.outputs))
+ for index, output := range m.outputs {
+ outputs[index] = output
+ outputs[index].Content = append([]dto.PluginResponsesContent(nil), output.Content...)
+ }
+ return &dto.PluginResponsesResponse{
+ ID: m.responseID,
+ Object: "response",
+ CreatedAt: m.createdAt,
+ Status: m.status,
+ Error: responseError,
+ IncompleteDetails: nil,
+ Instructions: nil,
+ Model: m.model,
+ Output: outputs,
+ ParallelToolCalls: true,
+ Temperature: 1,
+ ToolChoice: "auto",
+ Tools: []any{},
+ TopP: 1,
+ Metadata: metadata,
+ Usage: m.usage,
+ }
+}
+
+func zeroPluginResponsesUsage() *dto.PluginResponsesUsage {
+ return &dto.PluginResponsesUsage{}
+}
+
+func pluginOutputText(raw json.RawMessage) (string, error) {
+ if len(raw) == 0 {
+ return "", errors.New("output event data is required")
+ }
+ if common.GetJsonType(raw) == "string" {
+ var text string
+ if err := common.Unmarshal(raw, &text); err != nil {
+ return "", errors.New("output event data must be JSON-compatible")
+ }
+ return text, nil
+ }
+ var value any
+ if err := common.Unmarshal(raw, &value); err != nil {
+ return "", errors.New("output event data must be JSON-compatible")
+ }
+ encoded, err := common.Marshal(value)
+ if err != nil {
+ return "", errors.New("output event data must be JSON-compatible")
+ }
+ return string(encoded), nil
+}
+
+func pluginTaskStatus(taskStatus string) string {
+ switch strings.ToUpper(strings.TrimSpace(taskStatus)) {
+ case "SUCCESS":
+ return pluginResponseStatusCompleted
+ case "FAILURE":
+ return pluginResponseStatusFailed
+ case "IN_PROGRESS":
+ return pluginResponseStatusInProgress
+ case "NOT_START", "SUBMITTED", "QUEUED", "UNKNOWN", "":
+ return pluginResponseStatusQueued
+ default:
+ return pluginResponseStatusQueued
+ }
+}
+
+func intPointer(value int) *int {
+ return &value
+}
diff --git a/relay/plugin_protocol_test.go b/relay/plugin_protocol_test.go
new file mode 100644
index 000000000000..d0fc8f9d383c
--- /dev/null
+++ b/relay/plugin_protocol_test.go
@@ -0,0 +1,562 @@
+package relay
+
+import (
+ "encoding/json"
+ "math"
+ "strings"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDecodePluginProtocolEventResultPreservesStatePresence(t *testing.T) {
+ tests := []struct {
+ name string
+ value map[string]any
+ wantPresent bool
+ wantNull bool
+ wantValue string
+ }{
+ {
+ name: "omitted",
+ value: map[string]any{"events": []any{}, "done": false},
+ wantPresent: false,
+ },
+ {
+ name: "explicit null",
+ value: map[string]any{"events": []any{}, "state": nil, "done": false},
+ wantPresent: true,
+ wantNull: true,
+ wantValue: "null",
+ },
+ {
+ name: "json value",
+ value: map[string]any{"events": []any{}, "state": map[string]any{"seen": 1}, "done": false},
+ wantPresent: true,
+ wantValue: `{"seen":1}`,
+ },
+ }
+
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ result, err := DecodePluginProtocolEventResult(testCase.value, PluginProtocolLimits{})
+ require.NoError(t, err)
+ assert.Equal(t, testCase.wantPresent, result.State.Present)
+ assert.Equal(t, testCase.wantNull, result.State.Null)
+ if testCase.wantPresent {
+ assert.JSONEq(t, testCase.wantValue, string(result.State.Value))
+ } else {
+ assert.Empty(t, result.State.Value)
+ }
+
+ if result.State.Present {
+ _, err = result.State.PluginValue()
+ require.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestDecodePluginProtocolEventResultValidatesSemanticContract(t *testing.T) {
+ result, err := DecodePluginProtocolEventResult(map[string]any{
+ "events": []any{
+ map[string]any{"type": "progress", "progress": 42.5, "message": "working"},
+ map[string]any{"type": "output", "data": map[string]any{"url": "https://example.invalid/video.mp4"}},
+ map[string]any{"type": "error", "code": "provider_failed", "message": "not exposed"},
+ },
+ "done": true,
+ }, PluginProtocolLimits{})
+ require.NoError(t, err)
+ require.Len(t, result.Events, 3)
+ assert.Equal(t, "progress", result.Events[0].Type)
+ assert.Equal(t, 42.5, *result.Events[0].Progress)
+ assert.Equal(t, "working", *result.Events[0].Message)
+ assert.JSONEq(t, `{"url":"https://example.invalid/video.mp4"}`, string(result.Events[1].Data))
+ assert.Equal(t, "provider_failed", *result.Events[2].Code)
+ assert.True(t, result.Done)
+
+ invalid := []struct {
+ name string
+ value any
+ }{
+ {
+ name: "unknown top-level field",
+ value: map[string]any{"events": []any{}, "done": false, "sequence_number": 99},
+ },
+ {
+ name: "unknown event field",
+ value: map[string]any{"events": []any{map[string]any{"type": "output", "data": "x", "id": "plugin-id"}}, "done": false},
+ },
+ {
+ name: "progress outside range",
+ value: map[string]any{"events": []any{map[string]any{"type": "progress", "progress": 101}}, "done": false},
+ },
+ {
+ name: "missing output data",
+ value: map[string]any{"events": []any{map[string]any{"type": "output"}}, "done": false},
+ },
+ {
+ name: "empty error message",
+ value: map[string]any{"events": []any{map[string]any{"type": "error", "message": " "}}, "done": false},
+ },
+ {
+ name: "unsupported event",
+ value: map[string]any{"events": []any{map[string]any{"type": "response.completed"}}, "done": false},
+ },
+ {
+ name: "non-json number",
+ value: map[string]any{"events": []any{}, "done": false, "state": math.Inf(1)},
+ },
+ }
+ for _, testCase := range invalid {
+ t.Run(testCase.name, func(t *testing.T) {
+ _, err := DecodePluginProtocolEventResult(testCase.value, PluginProtocolLimits{})
+ require.Error(t, err)
+ })
+ }
+}
+
+func TestDecodePluginProtocolEventResultEnforcesBounds(t *testing.T) {
+ limits := DefaultPluginProtocolLimits()
+ limits.MaxEventsPerTick = 1
+ _, err := DecodePluginProtocolEventResult(map[string]any{
+ "events": []any{
+ map[string]any{"type": "progress"},
+ map[string]any{"type": "progress"},
+ },
+ "done": false,
+ }, limits)
+ require.ErrorContains(t, err, "exceed limit")
+
+ limits = DefaultPluginProtocolLimits()
+ limits.MaxEventBytes = 32
+ _, err = DecodePluginProtocolEventResult(map[string]any{
+ "events": []any{map[string]any{"type": "output", "data": strings.Repeat("x", 64)}},
+ "done": false,
+ }, limits)
+ require.ErrorContains(t, err, "protocol event exceeds")
+
+ limits = DefaultPluginProtocolLimits()
+ limits.MaxStateDepth = 3
+ _, err = DecodePluginProtocolEventResult(map[string]any{
+ "events": []any{},
+ "state": map[string]any{"one": map[string]any{"two": map[string]any{"three": true}}},
+ "done": false,
+ }, limits)
+ require.ErrorContains(t, err, "depth limit")
+
+ limits = DefaultPluginProtocolLimits()
+ _, err = DecodePluginProtocolEventResult(map[string]any{
+ "events": []any{map[string]any{
+ "type": "progress",
+ "message": strings.Repeat("x", limits.MaxMetadataValueBytes+1),
+ }},
+ "done": false,
+ }, limits)
+ require.ErrorContains(t, err, "progress event message exceeds")
+}
+
+func TestPluginResponsesMachineOwnsIDsSequenceAndLifecycle(t *testing.T) {
+ machine := NewPluginResponsesMachine("task_public", "video-model", 1710000000, PluginProtocolLimits{})
+ created, err := machine.CreatedEvent()
+ require.NoError(t, err)
+ assert.Equal(t, "response.created", created.Type)
+ assert.Equal(t, 0, created.SequenceNumber)
+ require.NotNil(t, created.Response)
+ assert.Equal(t, "resp_public", created.Response.ID)
+ assert.Equal(t, "in_progress", created.Response.Status)
+ assert.Equal(t, "task_public", created.Response.Metadata["task_id"])
+ assert.Equal(t, "/v1/responses/resp_public", created.Response.Metadata["retrieval_path"])
+
+ result, err := DecodePluginProtocolEventResult(map[string]any{
+ "events": []any{
+ map[string]any{"type": "progress", "progress": 25, "message": "rendering"},
+ map[string]any{"type": "output", "data": map[string]any{"url": "https://cdn.invalid/v.mp4"}},
+ },
+ "done": false,
+ }, PluginProtocolLimits{})
+ require.NoError(t, err)
+ events, err := machine.ApplyTick(result, "SUCCESS")
+ require.NoError(t, err)
+ require.Len(t, events, 8)
+ for index, event := range events {
+ assert.Equal(t, index+1, event.SequenceNumber)
+ }
+ assert.Equal(t, "response.in_progress", events[0].Type)
+ assert.Equal(t, "25", events[0].Response.Metadata["task_progress"])
+ assert.Equal(t, "rendering", events[0].Response.Metadata["task_message"])
+
+ itemAdded := events[1]
+ assert.Equal(t, "response.output_item.added", itemAdded.Type)
+ assert.Equal(t, 0, *itemAdded.OutputIndex)
+ assert.Equal(t, "msg_task_public_0", itemAdded.Item.ID)
+ assert.Empty(t, itemAdded.Item.Content)
+
+ partAdded := events[2]
+ assert.Equal(t, "response.content_part.added", partAdded.Type)
+ assert.Equal(t, "msg_task_public_0", partAdded.ItemID)
+ assert.Equal(t, "content_task_public_0", partAdded.Part.ID)
+
+ delta := events[3]
+ assert.Equal(t, "response.output_text.delta", delta.Type)
+ assert.Equal(t, `{"url":"https://cdn.invalid/v.mp4"}`, *delta.Delta)
+ require.NotNil(t, delta.Logprobs)
+ assert.Empty(t, *delta.Logprobs)
+
+ assert.Equal(t, "response.output_text.done", events[4].Type)
+ assert.Equal(t, "response.content_part.done", events[5].Type)
+ assert.Equal(t, "response.output_item.done", events[6].Type)
+ completed := events[7]
+ assert.Equal(t, "response.completed", completed.Type)
+ assert.Equal(t, "completed", completed.Response.Status)
+ assert.Nil(t, completed.Response.Error)
+ require.NotNil(t, completed.Response.Usage)
+ assert.Zero(t, completed.Response.Usage.InputTokens)
+ assert.Zero(t, completed.Response.Usage.OutputTokens)
+ assert.Zero(t, completed.Response.Usage.TotalTokens)
+ require.Len(t, completed.Response.Output, 1)
+ assert.Equal(t, "completed", completed.Response.Output[0].Status)
+
+ encoded, err := common.Marshal(completed)
+ require.NoError(t, err)
+ assert.Contains(t, string(encoded), `"usage":{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":0}`)
+
+ encoded, err = common.Marshal(delta)
+ require.NoError(t, err)
+ assert.Contains(t, string(encoded), `"logprobs":[]`)
+ assert.NotContains(t, string(encoded), `"response"`)
+
+ _, err = machine.ApplyTick(ProtocolEventResult{}, "SUCCESS")
+ require.ErrorContains(t, err, "already terminal")
+}
+
+func TestPluginResponsesMachineUsesVerbatimStringOutput(t *testing.T) {
+ machine := NewPluginResponsesMachine("task_text", "model", 1, PluginProtocolLimits{})
+ _, err := machine.CreatedEvent()
+ require.NoError(t, err)
+ result, err := DecodePluginProtocolEventResult(map[string]any{
+ "events": []any{map[string]any{"type": "output", "data": "verbatim\ntext"}},
+ "done": true,
+ }, PluginProtocolLimits{})
+ require.NoError(t, err)
+ events, err := machine.ApplyTick(result, "IN_PROGRESS")
+ require.NoError(t, err)
+ require.Len(t, events, 7)
+ assert.Equal(t, "verbatim\ntext", *events[2].Delta)
+ assert.Equal(t, "response.incomplete", events[6].Type)
+ assert.Nil(t, events[6].Response.IncompleteDetails)
+ require.NotNil(t, events[6].Response.Usage)
+}
+
+func TestPluginResponsesMachineSanitizesFailure(t *testing.T) {
+ t.Run("plugin semantic error", func(t *testing.T) {
+ machine := NewPluginResponsesMachine("task_error", "model", 1, PluginProtocolLimits{})
+ _, err := machine.CreatedEvent()
+ require.NoError(t, err)
+ result, err := DecodePluginProtocolEventResult(map[string]any{
+ "events": []any{map[string]any{
+ "type": "error",
+ "code": "upstream_secret_code",
+ "message": "https://secret.invalid/?credential=hidden",
+ }},
+ "done": true,
+ }, PluginProtocolLimits{})
+ require.NoError(t, err)
+ events, err := machine.ApplyTick(result, "IN_PROGRESS")
+ require.NoError(t, err)
+ require.Len(t, events, 1)
+ require.NotNil(t, events[0].Response.Error)
+ assert.Equal(t, "server_error", events[0].Response.Error.Code)
+ assert.Equal(t, "The task failed.", events[0].Response.Error.Message)
+ encoded, marshalErr := common.Marshal(events[0])
+ require.NoError(t, marshalErr)
+ assert.NotContains(t, string(encoded), "secret")
+ })
+
+ t.Run("database failure ignores plugin output", func(t *testing.T) {
+ machine := NewPluginResponsesMachine("task_db_error", "model", 1, PluginProtocolLimits{})
+ _, err := machine.CreatedEvent()
+ require.NoError(t, err)
+ result := ProtocolEventResult{Events: []ProtocolSemanticEvent{
+ {Type: "output", Data: json.RawMessage(`"must not leak"`)},
+ }}
+ events, err := machine.ApplyTick(result, "FAILURE")
+ require.NoError(t, err)
+ require.Len(t, events, 1)
+ assert.Equal(t, "response.failed", events[0].Type)
+ assert.Empty(t, events[0].Response.Output)
+ encoded, marshalErr := common.Marshal(events[0])
+ require.NoError(t, marshalErr)
+ assert.NotContains(t, string(encoded), "must not leak")
+ })
+
+ t.Run("host observation error", func(t *testing.T) {
+ machine := NewPluginResponsesMachine("task_host_error", "model", 1, PluginProtocolLimits{})
+ _, err := machine.CreatedEvent()
+ require.NoError(t, err)
+ failed, err := machine.FailureEvent("SUCCESS")
+ require.NoError(t, err)
+ assert.Equal(t, 1, failed.SequenceNumber)
+ assert.Equal(t, "server_error", failed.Response.Error.Code)
+ assert.Equal(t, "The task could not be observed.", failed.Response.Error.Message)
+ assert.Equal(t, "failed", failed.Response.Status)
+ assert.Equal(t, "completed", failed.Response.Metadata["task_status"])
+ })
+}
+
+func TestPluginResponsesMachineNonStreamShapes(t *testing.T) {
+ t.Run("completed canonicalizes renderFinal response", func(t *testing.T) {
+ machine := NewPluginResponsesMachine("task_final", "model", 99, PluginProtocolLimits{})
+ response, err := machine.FinalResponse(map[string]any{
+ "id": "plugin-controlled-id",
+ "object": "plugin-controlled-object",
+ "created_at": -1,
+ "status": "plugin-controlled-status",
+ "model": "plugin-controlled-model",
+ "error": map[string]any{"message": "plugin-controlled-error"},
+ "metadata": map[string]any{
+ "plugin_field": "kept",
+ "task_id": "plugin-controlled-task",
+ },
+ "output": []any{
+ map[string]any{
+ "id": "plugin-controlled-item",
+ "type": "message",
+ "status": "plugin-controlled-item-status",
+ "role": "assistant",
+ "content": []any{
+ map[string]any{
+ "id": "plugin-controlled-content",
+ "type": "output_text",
+ "text": "hello",
+ "annotations": []any{},
+ "logprobs": []any{},
+ },
+ },
+ },
+ },
+ "custom_field": map[string]any{"kept": true},
+ }, "SUCCESS")
+ require.NoError(t, err)
+ assert.Equal(t, "resp_final", response["id"])
+ assert.Equal(t, "response", response["object"])
+ assert.Equal(t, int64(99), response["created_at"])
+ assert.Equal(t, "completed", response["status"])
+ assert.Equal(t, "model", response["model"])
+ assert.Nil(t, response["error"])
+ assert.Nil(t, response["incomplete_details"])
+ assert.Nil(t, response["instructions"])
+ assert.Equal(t, true, response["parallel_tool_calls"])
+ assert.Equal(t, float64(1), response["temperature"])
+ assert.Equal(t, "auto", response["tool_choice"])
+ assert.Empty(t, response["tools"])
+ assert.Equal(t, float64(1), response["top_p"])
+ assert.Equal(t, map[string]any{"kept": true}, response["custom_field"])
+
+ metadata, ok := response["metadata"].(map[string]string)
+ require.True(t, ok)
+ assert.Equal(t, "kept", metadata["plugin_field"])
+ assert.Equal(t, "task_final", metadata["task_id"])
+ assert.Equal(t, "completed", metadata["task_status"])
+
+ output, ok := response["output"].([]any)
+ require.True(t, ok)
+ require.Len(t, output, 1)
+ item, ok := output[0].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "item_task_final_0", item["id"])
+ assert.Equal(t, "completed", item["status"])
+ content, ok := item["content"].([]any)
+ require.True(t, ok)
+ require.Len(t, content, 1)
+ part, ok := content[0].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "content_task_final_0_0", part["id"])
+ assert.Equal(t, "hello", part["text"])
+ })
+
+ t.Run("failed is generic", func(t *testing.T) {
+ machine := NewPluginResponsesMachine("task_failed", "model", 99, PluginProtocolLimits{})
+ response, err := machine.FinalResponse(map[string]any{"secret": "ignored"}, "FAILURE")
+ require.NoError(t, err)
+ assert.Equal(t, "failed", response["status"])
+ assert.Empty(t, response["output"])
+ responseError, ok := response["error"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "server_error", responseError["code"])
+ encoded, marshalErr := common.Marshal(response)
+ require.NoError(t, marshalErr)
+ assert.NotContains(t, string(encoded), "secret")
+ })
+
+ t.Run("invalid output union is rejected", func(t *testing.T) {
+ machine := NewPluginResponsesMachine("task_invalid", "model", 99, PluginProtocolLimits{})
+ _, err := machine.FinalResponse(map[string]any{
+ "output": []any{map[string]any{}},
+ }, "SUCCESS")
+ require.ErrorContains(t, err, "message objects")
+ })
+
+ t.Run("poll timeout stays queued with retrieval path", func(t *testing.T) {
+ machine := NewPluginResponsesMachine("task_wait", "model", 99, PluginProtocolLimits{})
+ response, err := machine.TimeoutResponse("QUEUED")
+ require.NoError(t, err)
+ assert.Equal(t, "queued", response.Status)
+ assert.Nil(t, response.Error)
+ assert.Nil(t, response.IncompleteDetails)
+ assert.Equal(t, "queued", response.Metadata["task_status"])
+ assert.Equal(t, "/v1/responses/resp_wait", response.Metadata["retrieval_path"])
+ })
+
+ t.Run("host failure preserves last persisted status", func(t *testing.T) {
+ machine := NewPluginResponsesMachine("task_observe", "model", 99, PluginProtocolLimits{})
+ response, err := machine.FailureResponse("IN_PROGRESS")
+ require.NoError(t, err)
+ assert.Equal(t, "failed", response.Status)
+ assert.Equal(t, "in_progress", response.Metadata["task_status"])
+ })
+
+ t.Run("unknown persisted status stays in documented vocabulary", func(t *testing.T) {
+ machine := NewPluginResponsesMachine("task_unknown", "model", 99, PluginProtocolLimits{})
+ response, err := machine.TimeoutResponse("UNKNOWN_PROVIDER_STATE")
+ require.NoError(t, err)
+ assert.Equal(t, "queued", response.Metadata["task_status"])
+ })
+}
+
+func TestPluginResponsesMachinePendingResponse(t *testing.T) {
+ tests := []struct {
+ name string
+ taskStatus string
+ background bool
+ wantStatus string
+ wantBackground bool
+ }{
+ {name: "queued from submitted", taskStatus: "SUBMITTED", wantStatus: "queued"},
+ {name: "queued from not start", taskStatus: "NOT_START", wantStatus: "queued"},
+ {name: "in progress", taskStatus: "IN_PROGRESS", wantStatus: "in_progress"},
+ {name: "background flag", taskStatus: "QUEUED", background: true, wantStatus: "queued", wantBackground: true},
+ {name: "terminal input stays pending", taskStatus: "SUCCESS", wantStatus: "in_progress"},
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ machine := NewPluginResponsesMachine("task_pending", "video-model", 1_710_000_000, PluginProtocolLimits{})
+ machine.SetBackground(testCase.background)
+ response := machine.PendingResponse(testCase.taskStatus)
+ assert.Equal(t, "resp_pending", response["id"])
+ assert.Equal(t, "response", response["object"])
+ assert.Equal(t, int64(1_710_000_000), response["created_at"])
+ assert.Equal(t, testCase.wantStatus, response["status"])
+ assert.Equal(t, testCase.wantBackground, response["background"])
+ assert.Nil(t, response["completed_at"])
+ assert.Nil(t, response["error"])
+ assert.Nil(t, response["incomplete_details"])
+ assert.Equal(t, "video-model", response["model"])
+ assert.Empty(t, response["output"])
+ assert.Nil(t, response["usage"])
+ metadata, ok := response["metadata"].(map[string]string)
+ require.True(t, ok)
+ assert.Equal(t, map[string]string{
+ "task_id": "task_pending",
+ "task_status": testCase.wantStatus,
+ "retrieval_path": "/v1/responses/resp_pending",
+ }, metadata)
+
+ encoded, err := common.Marshal(response)
+ require.NoError(t, err)
+ assert.Contains(t, string(encoded), `"completed_at":null`)
+ assert.Contains(t, string(encoded), `"output":[]`)
+ assert.Contains(t, string(encoded), `"usage":null`)
+ })
+ }
+}
+
+func TestPluginResponsesMachineProgressDoesNotRepeatAccumulatedOutput(t *testing.T) {
+ machine := NewPluginResponsesMachine("task_progress", "model", 1, PluginProtocolLimits{})
+ _, err := machine.CreatedEvent()
+ require.NoError(t, err)
+ events, err := machine.ApplyTick(ProtocolEventResult{Events: []ProtocolSemanticEvent{
+ {Type: "output", Data: json.RawMessage(`"large-output"`)},
+ }}, "IN_PROGRESS")
+ require.NoError(t, err)
+ require.Len(t, events, 6)
+
+ message := "still working"
+ events, err = machine.ApplyTick(ProtocolEventResult{Events: []ProtocolSemanticEvent{
+ {Type: "progress", Message: &message},
+ }}, "IN_PROGRESS")
+ require.NoError(t, err)
+ assert.Empty(t, events)
+
+ events, err = machine.ApplyTick(ProtocolEventResult{}, "SUCCESS")
+ require.NoError(t, err)
+ require.Len(t, events, 1)
+ require.NotNil(t, events[0].Response)
+ require.Len(t, events[0].Response.Output, 1)
+ assert.Equal(t, "still working", events[0].Response.Metadata["task_message"])
+}
+
+func TestPluginResponsesMachineEnforcesCumulativeOutputLimit(t *testing.T) {
+ limits := DefaultPluginProtocolLimits()
+ limits.MaxOutputs = 1
+ machine := NewPluginResponsesMachine("task_bounded", "model", 1, limits)
+ _, err := machine.CreatedEvent()
+ require.NoError(t, err)
+
+ first := ProtocolEventResult{Events: []ProtocolSemanticEvent{
+ {Type: "output", Data: json.RawMessage(`"first"`)},
+ }}
+ _, err = machine.ApplyTick(first, "IN_PROGRESS")
+ require.NoError(t, err)
+
+ second := ProtocolEventResult{Events: []ProtocolSemanticEvent{
+ {Type: "output", Data: json.RawMessage(`"second"`)},
+ }}
+ _, err = machine.ApplyTick(second, "IN_PROGRESS")
+ require.ErrorContains(t, err, "outputs exceed limit")
+}
+
+func TestPluginResponsesMachineFinalFromEvents(t *testing.T) {
+ t.Run("output event becomes completed response", func(t *testing.T) {
+ machine := NewPluginResponsesMachine("task_from_events", "model", 99, PluginProtocolLimits{})
+ result, err := DecodePluginProtocolEventResult(map[string]any{
+ "events": []any{map[string]any{"type": "output", "data": "synthesized-output"}},
+ "done": true,
+ }, PluginProtocolLimits{})
+ require.NoError(t, err)
+
+ response, err := machine.FinalFromEvents(result, "SUCCESS")
+ require.NoError(t, err)
+ assert.Equal(t, "completed", response["status"])
+ output, ok := response["output"].([]any)
+ require.True(t, ok)
+ require.NotEmpty(t, output)
+ })
+
+ t.Run("error event leaves receiver unstarted", func(t *testing.T) {
+ machine := NewPluginResponsesMachine("task_from_events_fail", "model", 99, PluginProtocolLimits{})
+ result, err := DecodePluginProtocolEventResult(map[string]any{
+ "events": []any{map[string]any{"type": "error", "message": "provider failed"}},
+ "done": true,
+ }, PluginProtocolLimits{})
+ require.NoError(t, err)
+
+ _, err = machine.FinalFromEvents(result, "SUCCESS")
+ require.ErrorContains(t, err, "reported failure")
+
+ response, failErr := machine.FailureResponse("FAILURE")
+ require.NoError(t, failErr)
+ require.NotNil(t, response)
+ assert.Equal(t, "failed", response.Status)
+ })
+
+ t.Run("started machine is rejected", func(t *testing.T) {
+ machine := NewPluginResponsesMachine("task_from_events_started", "model", 99, PluginProtocolLimits{})
+ _, err := machine.CreatedEvent()
+ require.NoError(t, err)
+ _, err = machine.FinalFromEvents(ProtocolEventResult{}, "SUCCESS")
+ require.ErrorContains(t, err, "already started")
+ })
+}
diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go
index e6298dc034f3..68f09087aee1 100644
--- a/relay/relay_adaptor.go
+++ b/relay/relay_adaptor.go
@@ -1,9 +1,12 @@
package relay
import (
+ "fmt"
"strconv"
"github.com/QuantumNous/new-api/constant"
+ pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
+ _ "github.com/QuantumNous/new-api/plugins"
"github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/advancedcustom"
"github.com/QuantumNous/new-api/relay/channel/ali"
@@ -33,16 +36,7 @@ import (
"github.com/QuantumNous/new-api/relay/channel/siliconflow"
"github.com/QuantumNous/new-api/relay/channel/sub2api"
"github.com/QuantumNous/new-api/relay/channel/submodel"
- taskali "github.com/QuantumNous/new-api/relay/channel/task/ali"
- taskdoubao "github.com/QuantumNous/new-api/relay/channel/task/doubao"
- taskGemini "github.com/QuantumNous/new-api/relay/channel/task/gemini"
- "github.com/QuantumNous/new-api/relay/channel/task/hailuo"
- taskjimeng "github.com/QuantumNous/new-api/relay/channel/task/jimeng"
- "github.com/QuantumNous/new-api/relay/channel/task/kling"
- tasksora "github.com/QuantumNous/new-api/relay/channel/task/sora"
- "github.com/QuantumNous/new-api/relay/channel/task/suno"
- taskvertex "github.com/QuantumNous/new-api/relay/channel/task/vertex"
- taskVidu "github.com/QuantumNous/new-api/relay/channel/task/vidu"
+ jspluginadaptor "github.com/QuantumNous/new-api/relay/channel/task/jsplugin"
"github.com/QuantumNous/new-api/relay/channel/tencent"
"github.com/QuantumNous/new-api/relay/channel/vertex"
"github.com/QuantumNous/new-api/relay/channel/volcengine"
@@ -134,6 +128,9 @@ func GetAdaptor(apiType int) channel.Adaptor {
}
func GetTaskPlatform(c *gin.Context) constant.TaskPlatform {
+ if pluginKey := c.GetString("task_plugin_key"); pluginKey != "" {
+ return constant.TaskPlatform(pluginKey)
+ }
channelType := c.GetInt("channel_type")
if channelType > 0 {
return constant.TaskPlatform(strconv.Itoa(channelType))
@@ -141,34 +138,98 @@ func GetTaskPlatform(c *gin.Context) constant.TaskPlatform {
return constant.TaskPlatform(c.GetString("platform"))
}
+var taskPluginKeys = map[constant.TaskPlatform]string{
+ constant.TaskPlatformSuno: "sunoapi",
+ constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeAli)): "alibaba",
+ constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeKling)): "kling",
+ constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeJimeng)): "jimeng",
+ constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeVidu)): "vidu",
+ constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeDoubaoVideo)): "doubao",
+ constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeVolcEngine)): "doubao",
+ constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeGemini)): "google",
+ constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeMiniMax)): "hailuo",
+ constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeSora)): "sora",
+ constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeOpenAI)): "sora",
+ constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeVertexAi)): "vertex-ai",
+}
+
+func ResolveTaskPluginForPlatform(generation *pluginruntime.RoutingGeneration, platform constant.TaskPlatform) (*pluginruntime.LoadedPlugin, bool) {
+ if generation == nil {
+ return nil, false
+ }
+ if key, ok := taskPluginKeys[platform]; ok {
+ if plugin, found := generation.Get(key); found {
+ return plugin, true
+ }
+ }
+ return generation.Get(string(platform))
+}
+
+// TaskPlatformUnavailableError explains why no adaptor serves the platform:
+// the task-plugin system is switched off, the resolved plugin is disabled,
+// or the platform simply names nothing. The distinction is user-actionable,
+// so it must survive into the client-facing message.
+func TaskPlatformUnavailableError(platform constant.TaskPlatform) (string, string) {
+ if !pluginruntime.DefaultRegistry.Enabled() {
+ return "task_plugin_system_disabled", "the task plugin system is disabled on this gateway"
+ }
+ key := string(platform)
+ if mapped, ok := taskPluginKeys[platform]; ok {
+ key = mapped
+ }
+ for _, meta := range pluginruntime.DefaultRegistry.Snapshot().Factory {
+ if meta.Key == key {
+ return "task_plugin_disabled", fmt.Sprintf("task plugin %q is disabled on this gateway", key)
+ }
+ }
+ return "invalid_api_platform", fmt.Sprintf("invalid api platform: %s", platform)
+}
+
func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor {
- switch platform {
- //case constant.APITypeAIProxyLibrary:
- // return &aiproxy.Adaptor{}
- case constant.TaskPlatformSuno:
- return &suno.TaskAdaptor{}
+ plugin, ok := ResolveTaskPluginForPlatform(pluginruntime.DefaultRegistry.Generation(), platform)
+ if !ok {
+ return nil
}
- if channelType, err := strconv.ParseInt(string(platform), 10, 64); err == nil {
- switch channelType {
- case constant.ChannelTypeAli:
- return &taskali.TaskAdaptor{}
- case constant.ChannelTypeKling:
- return &kling.TaskAdaptor{}
- case constant.ChannelTypeJimeng:
- return &taskjimeng.TaskAdaptor{}
- case constant.ChannelTypeVertexAi:
- return &taskvertex.TaskAdaptor{}
- case constant.ChannelTypeVidu:
- return &taskVidu.TaskAdaptor{}
- case constant.ChannelTypeDoubaoVideo, constant.ChannelTypeVolcEngine:
- return &taskdoubao.TaskAdaptor{}
- case constant.ChannelTypeSora, constant.ChannelTypeOpenAI:
- return &tasksora.TaskAdaptor{}
- case constant.ChannelTypeGemini:
- return &taskGemini.TaskAdaptor{}
- case constant.ChannelTypeMiniMax:
- return &hailuo.TaskAdaptor{}
+ return jspluginadaptor.New(plugin)
+}
+
+// getTaskAdaptorForRequest preserves the exact plugin object pinned by the
+// declarative or shared-endpoint router. Legacy task routes are pinned here
+// from one registry generation before the adaptor is returned.
+func getTaskAdaptorForRequest(c *gin.Context, platform constant.TaskPlatform) (constant.TaskPlatform, channel.TaskAdaptor) {
+ if c != nil {
+ if value, exists := c.Get(pluginruntime.ContextKeyPinnedPlugin); exists {
+ if pinned, ok := value.(pluginruntime.PinnedPlugin); ok && pinned.Plugin != nil {
+ platform = constant.TaskPlatform(pinned.Plugin.Meta.Key)
+ return platform, jspluginadaptor.New(pinned.Plugin)
+ }
+ return platform, nil
+ }
+ if value, exists := c.Get(pluginruntime.ContextKeyPinnedEndpoint); exists {
+ if pinned, ok := value.(pluginruntime.PinnedEndpoint); ok && pinned.Plugin != nil {
+ platform = constant.TaskPlatform(pinned.Plugin.Meta.Key)
+ return platform, jspluginadaptor.New(pinned.Plugin)
+ }
+ return platform, nil
+ }
+ if value, exists := c.Get(pluginruntime.ContextKeyPinnedRoute); exists {
+ if pinned, ok := value.(pluginruntime.PinnedRoute); ok && pinned.Plugin != nil {
+ platform = constant.TaskPlatform(pinned.Plugin.Meta.Key)
+ return platform, jspluginadaptor.New(pinned.Plugin)
+ }
+ return platform, nil
}
}
- return nil
+ generation := pluginruntime.DefaultRegistry.Generation()
+ plugin, ok := ResolveTaskPluginForPlatform(generation, platform)
+ if !ok {
+ return platform, nil
+ }
+ if c != nil {
+ c.Set(pluginruntime.ContextKeyPinnedPlugin, pluginruntime.PinnedPlugin{
+ Generation: generation,
+ Plugin: plugin,
+ })
+ }
+ return platform, jspluginadaptor.New(plugin)
}
diff --git a/relay/relay_adaptor_jsplugin_test.go b/relay/relay_adaptor_jsplugin_test.go
new file mode 100644
index 000000000000..c9c1428625d2
--- /dev/null
+++ b/relay/relay_adaptor_jsplugin_test.go
@@ -0,0 +1,98 @@
+package relay
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "testing"
+
+ "github.com/QuantumNous/new-api/constant"
+ pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
+ jspluginadaptor "github.com/QuantumNous/new-api/relay/channel/task/jsplugin"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetTaskAdaptorMapsMigratedPlatformsToFactoryPlugins(t *testing.T) {
+ platforms := []constant.TaskPlatform{
+ constant.TaskPlatformSuno,
+ constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeAli)),
+ constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeDoubaoVideo)),
+ constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeVolcEngine)),
+ constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeGemini)),
+ constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeMiniMax)),
+ constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeJimeng)),
+ constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeKling)),
+ constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeVidu)),
+ constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeSora)),
+ constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeOpenAI)),
+ constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeVertexAi)),
+ }
+ for _, platform := range platforms {
+ _, isJS := GetTaskAdaptor(platform).(*jspluginadaptor.TaskAdaptor)
+ assert.True(t, isJS, "platform %s should use its factory plugin", platform)
+ }
+}
+
+func TestGetTaskAdaptorUsesPlatformAsThirdPartyPluginKey(t *testing.T) {
+ t.Cleanup(func() {
+ pluginruntime.DefaultRegistry.Unregister("registry-fallback")
+ })
+ source := `
+export const meta = {apiVersion: 1, key: "registry-fallback", name: "Registry Fallback", version: "1.0.0", author: {name: "Test"}, channelTypes: [1999], models: ["fallback-v1"], fetchMode: "per_task"};
+export function buildSubmitRequest(ctx) { return {url: ctx.baseUrl + "/submit"}; }
+export function parseSubmitResponse(ctx, resp) { return {taskId: "id", taskData: resp.body}; }
+export function buildQueryRequest(ctx) { return {url: ctx.baseUrl + "/tasks/" + ctx.taskId}; }
+export function parseTaskResult(ctx, body) { return {taskId: body.id, status: "SUCCESS"}; }
+`
+ _, err := pluginruntime.DefaultRegistry.Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+
+ adaptor := GetTaskAdaptor(constant.TaskPlatform("registry-fallback"))
+ require.NotNil(t, adaptor)
+ assert.Equal(t, "Registry Fallback", adaptor.GetChannelName())
+}
+
+func TestGetTaskAdaptorReturnsNilForUnknownPlatform(t *testing.T) {
+ assert.Nil(t, GetTaskAdaptor(constant.TaskPlatform("missing-task-platform")))
+}
+
+func TestGetTaskAdaptorForRequestUsesExactPinnedPlugin(t *testing.T) {
+ source := `
+export const meta = {apiVersion: 1, key: "pinned-request", name: "Pinned Generation", version: "1.0.0", author: {name: "Test"}, models: ["pinned-v1"], fetchMode: "per_task"};
+export function buildSubmitRequest(ctx) { return {url: ctx.baseUrl + "/submit"}; }
+export function parseSubmitResponse(ctx) { return {taskId: "one"}; }
+export function buildQueryRequest(ctx) { return {url: ctx.baseUrl + "/query"}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+`
+ pinned, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/vendor/submit", nil)
+ c.Set(pluginruntime.ContextKeyPinnedPlugin, pluginruntime.PinnedPlugin{Plugin: pinned})
+
+ platform, adaptor := getTaskAdaptorForRequest(c, constant.TaskPlatform("missing-task-platform"))
+ require.NotNil(t, adaptor)
+ assert.Equal(t, constant.TaskPlatform("pinned-request"), platform)
+ assert.Equal(t, "Pinned Generation", adaptor.GetChannelName())
+}
+
+func TestGetTaskAdaptorForRequestPinsLegacyMappedPlugin(t *testing.T) {
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos/video_1/remix", nil)
+ legacyPlatform := constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeSora))
+
+ platform, adaptor := getTaskAdaptorForRequest(c, legacyPlatform)
+
+ require.NotNil(t, adaptor)
+ assert.Equal(t, legacyPlatform, platform)
+ pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedPlugin)
+ require.True(t, exists)
+ pinned, ok := pinnedValue.(pluginruntime.PinnedPlugin)
+ require.True(t, ok)
+ require.NotNil(t, pinned.Generation)
+ require.NotNil(t, pinned.Plugin)
+ assert.Equal(t, "sora", pinned.Plugin.Meta.Key)
+ assert.Same(t, pinned.Generation, pluginruntime.DefaultRegistry.Generation())
+}
diff --git a/relay/relay_task.go b/relay/relay_task.go
index fb384d18937a..63270b5defc4 100644
--- a/relay/relay_task.go
+++ b/relay/relay_task.go
@@ -13,20 +13,25 @@ import (
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/pkg/billingexpr"
"github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/setting/billing_setting"
+ "github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
type TaskSubmitResult struct {
UpstreamTaskID string
TaskData []byte
+ ClientResponse any
Platform constant.TaskPlatform
Quota int
+ Immediate *relaycommon.TaskInfo
//PerCallPrice types.PriceData
}
@@ -137,11 +142,57 @@ func ResolveOriginTask(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskErr
return nil
}
+// ApplyChannelPin copies plugin-declared origin-task facts from the prepare
+// context onto RelayInfo and, when the resolved pin retries on the same
+// channel, writes LockedChannel. ResolveOriginTask is unchanged.
+func ApplyChannelPin(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError {
+ if info == nil {
+ return nil
+ }
+ if info.TaskRelayInfo == nil {
+ info.TaskRelayInfo = &relaycommon.TaskRelayInfo{}
+ }
+ if tasks, ok := common.GetContextKeyType[[]*model.Task](c, constant.ContextKeyOriginTasks); ok {
+ refs := make([]relaycommon.OriginTaskRef, 0, len(tasks))
+ for _, task := range tasks {
+ if task == nil {
+ continue
+ }
+ refs = append(refs, relaycommon.OriginTaskRef{
+ TaskID: task.TaskID,
+ UpstreamTaskID: task.GetUpstreamTaskID(),
+ Action: task.Action,
+ Status: string(task.Status),
+ Data: append([]byte(nil), task.Data...),
+ })
+ }
+ info.OriginTasks = refs
+ }
+ pin, found, _ := service.GetChannelConstraints(c).ResolvedPin()
+ if !found || pin.RetryMode != dto.PinRetrySameChannel {
+ return nil
+ }
+ ch, err := model.CacheGetChannel(pin.ChannelId)
+ if err != nil {
+ return service.TaskErrorWrapperLocal(err, "origin_task_channel_disabled", http.StatusBadRequest)
+ }
+ if ch.Status != common.ChannelStatusEnabled {
+ return service.TaskErrorWrapperLocal(errors.New("the channel of the origin task is disabled"), "origin_task_channel_disabled", http.StatusBadRequest)
+ }
+ info.LockedChannel = ch
+ return nil
+}
+
+// ApplyOriginTaskAffinity is the compatibility name for ApplyChannelPin.
+func ApplyOriginTaskAffinity(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError {
+ return ApplyChannelPin(c, info)
+}
+
// RelayTaskSubmit 完成 task 提交的全部流程(每次尝试调用一次):
// 刷新渠道元数据 → 确定 platform/adaptor → 验证请求 →
// 估算计费(EstimateBilling) → 计算价格 → 预扣费(仅首次)→
// 构建/发送/解析上游请求 → 提交后计费调整(AdjustBillingOnSubmit)。
-// 控制器负责 defer Refund 和成功后 Settle。
+// 共享控制器编排负责未落库退款、最终额度预留、落库和结算。
func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitResult, *dto.TaskError) {
info.InitChannelMeta(c)
@@ -150,9 +201,15 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe
if platform == "" {
platform = GetTaskPlatform(c)
}
- adaptor := GetTaskAdaptor(platform)
+ platform, adaptor := getTaskAdaptorForRequest(c, platform)
if adaptor == nil {
- return nil, service.TaskErrorWrapperLocal(fmt.Errorf("invalid api platform: %s", platform), "invalid_api_platform", http.StatusBadRequest)
+ code, message := TaskPlatformUnavailableError(platform)
+ return nil, service.TaskErrorWrapperLocal(errors.New(message), code, http.StatusBadRequest)
+ }
+ // buildSubmitRequest runs during validation and the unreleased plugin
+ // contract exposes this host-generated id to that hook.
+ if info.PublicTaskID == "" {
+ info.PublicTaskID = model.GenerateTaskID()
}
adaptor.Init(info)
if taskErr := adaptor.ValidateRequestAndSetAction(c, info); taskErr != nil {
@@ -172,30 +229,67 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe
return nil, service.TaskErrorWrapperLocal(err, "model_mapping_failed", http.StatusBadRequest)
}
- // 3. 预生成公开 task ID(仅首次)
- if info.PublicTaskID == "" {
- info.PublicTaskID = model.GenerateTaskID()
- }
-
// 4. 价格计算:基础模型价格
info.OriginModelName = modelName
- priceData, err := helper.ModelPriceHelperPerCall(c, info)
- if err != nil {
- return nil, service.TaskErrorWrapper(err, "model_price_error", http.StatusBadRequest)
+ var priceData types.PriceData
+ var err error
+ if billing_setting.GetBillingMode(modelName) == billing_setting.BillingModeTieredExpr {
+ exprStr, exists := billing_setting.GetBillingExpr(modelName)
+ provider, supported := adaptor.(channel.TaskUsageFactsProvider)
+ if !exists || !supported {
+ return nil, service.TaskErrorWrapper(fmt.Errorf("task model %s has no usage expression or meter", modelName), "model_price_error", http.StatusBadRequest)
+ }
+ var facts map[string]any
+ if validatedProvider, ok := adaptor.(channel.TaskValidatedUsageFactsProvider); ok {
+ facts, err = validatedProvider.ExtractUsageFactsValidated(c, info)
+ if err != nil {
+ return nil, service.TaskErrorWrapperLocal(err, "plugin_usage_invalid", http.StatusBadRequest)
+ }
+ } else {
+ facts = provider.ExtractUsageFacts(c, info)
+ }
+ cost, trace, runErr := billingexpr.RunExprWithRequest(exprStr, billingexpr.TokenParams{}, billingexpr.RequestInput{Usage: facts})
+ if runErr != nil || cost < 0 {
+ if runErr == nil {
+ runErr = fmt.Errorf("negative task expression result")
+ }
+ return nil, service.TaskErrorWrapper(runErr, "model_price_error", http.StatusBadRequest)
+ }
+ groupRatioInfo := helper.HandleGroupRatio(c, info)
+ quota, clamp := common.QuotaRoundChecked(cost * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
+ noteTaskQuotaClamp(info, clamp)
+ priceData = types.PriceData{Quota: quota, QuotaToPreConsume: quota, GroupRatioInfo: groupRatioInfo}
+ info.TieredBillingSnapshot = &billingexpr.BillingSnapshot{BillingMode: billing_setting.BillingModeTieredExpr, ModelName: modelName, ExprString: exprStr, ExprHash: billingexpr.ExprHashString(exprStr), GroupRatio: groupRatioInfo.GroupRatio, EstimatedQuotaBeforeGroup: cost * common.QuotaPerUnit, EstimatedQuotaAfterGroup: quota, EstimatedTier: trace.MatchedTier, QuotaPerUnit: common.QuotaPerUnit, ExprVersion: billingexpr.ExprVersion(exprStr), TaskUsageBilling: true, UsageFacts: facts}
+ } else {
+ priceData, err = helper.ModelPriceHelperPerCall(c, info)
+ if err != nil {
+ return nil, service.TaskErrorWrapper(err, "model_price_error", http.StatusBadRequest)
+ }
}
info.PriceData = priceData
// 5. 计费估算:让适配器根据用户请求提供 OtherRatios(时长、分辨率等)
// 必须在 ModelPriceHelperPerCall 之后调用(它会重建 PriceData)。
// ResolveOriginTask 可能已在 remix 路径中预设了 OtherRatios,此处合并。
- if estimatedRatios := adaptor.EstimateBilling(c, info); len(estimatedRatios) > 0 {
- for k, v := range estimatedRatios {
- info.PriceData.AddOtherRatio(k, v)
+ if info.TieredBillingSnapshot == nil {
+ var estimatedRatios map[string]float64
+ if validatedProvider, ok := adaptor.(channel.TaskValidatedBillingProvider); ok {
+ estimatedRatios, err = validatedProvider.EstimateBillingValidated(c, info)
+ if err != nil {
+ return nil, service.TaskErrorWrapperLocal(err, "plugin_usage_invalid", http.StatusBadRequest)
+ }
+ } else {
+ estimatedRatios = adaptor.EstimateBilling(c, info)
+ }
+ if len(estimatedRatios) > 0 {
+ for k, v := range estimatedRatios {
+ info.PriceData.AddOtherRatio(k, v)
+ }
}
}
// 6. 将 OtherRatios 应用到基础额度(饱和转换,防止溢出成负数)
- if !common.StringsContains(constant.TaskPricePatches, modelName) {
+ if info.TieredBillingSnapshot == nil && !common.StringsContains(constant.TaskPricePatches, modelName) {
quotaWithRatios := info.PriceData.ApplyOtherRatiosToFloat(float64(info.PriceData.Quota))
quota, clamp := common.QuotaFromFloatChecked(quotaWithRatios)
info.PriceData.Quota = quota
@@ -221,41 +315,45 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe
if err != nil {
return nil, service.TaskErrorWrapper(err, "do_request_failed", http.StatusInternalServerError)
}
- if resp != nil && resp.StatusCode != http.StatusOK {
+ if resp == nil {
+ return nil, service.TaskErrorWrapperLocal(errors.New("upstream returned an empty response"), "fail_to_fetch_task", http.StatusBadGateway)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
responseBody, _ := io.ReadAll(resp.Body)
return nil, service.TaskErrorWrapper(fmt.Errorf("%s", string(responseBody)), "fail_to_fetch_task", resp.StatusCode)
}
- // 10. 返回 OtherRatios 给下游(header 必须在 DoResponse 写 body 之前设置)
- otherRatios := info.PriceData.OtherRatios()
- if otherRatios == nil {
- otherRatios = map[string]float64{}
- }
- ratiosJSON, _ := common.Marshal(otherRatios)
- c.Header("X-New-Api-Other-Ratios", string(ratiosJSON))
-
- // 11. 解析响应
- upstreamTaskID, taskData, taskErr := adaptor.DoResponse(c, resp, info)
+ // 10. Parse only. The controller presents the response after the durable
+ // task barrier and billing settlement.
+ parsed, taskErr := adaptor.ParseResponse(c, resp, info)
if taskErr != nil {
return nil, taskErr
}
+ if parsed == nil {
+ return nil, service.TaskErrorWrapperLocal(errors.New("task adaptor returned an empty response"), "plugin_submit_response_invalid", http.StatusBadGateway)
+ }
// 11. 提交后计费调整:让适配器根据上游实际返回调整 OtherRatios
finalQuota := info.PriceData.Quota
- if adjustedRatios := adaptor.AdjustBillingOnSubmit(info, taskData); len(adjustedRatios) > 0 {
- if adjustedQuota, ok := recalcQuotaFromRatios(info, adjustedRatios); ok {
- // 基于调整后的 ratios 重新计算 quota
- finalQuota = adjustedQuota
- info.PriceData.ReplaceOtherRatios(adjustedRatios)
- info.PriceData.Quota = finalQuota
+ if info.TieredBillingSnapshot == nil {
+ if adjustedRatios := adaptor.AdjustBillingOnSubmit(info, parsed.TaskData); len(adjustedRatios) > 0 {
+ if adjustedQuota, ok := recalcQuotaFromRatios(info, adjustedRatios); ok {
+ // 基于调整后的 ratios 重新计算 quota
+ finalQuota = adjustedQuota
+ info.PriceData.ReplaceOtherRatios(adjustedRatios)
+ info.PriceData.Quota = finalQuota
+ }
}
}
return &TaskSubmitResult{
- UpstreamTaskID: upstreamTaskID,
- TaskData: taskData,
+ UpstreamTaskID: parsed.UpstreamTaskID,
+ TaskData: parsed.TaskData,
+ ClientResponse: parsed.ClientResponse,
Platform: platform,
Quota: finalQuota,
+ Immediate: parsed.Immediate,
}, nil
}
@@ -288,8 +386,6 @@ func noteTaskQuotaClamp(info *relaycommon.RelayInfo, clamp *common.QuotaClamp) {
}
var fetchRespBuilders = map[int]func(c *gin.Context) (respBody []byte, taskResp *dto.TaskError){
- relayconstant.RelayModeSunoFetchByID: sunoFetchByIDRespBodyBuilder,
- relayconstant.RelayModeSunoFetch: sunoFetchRespBodyBuilder,
relayconstant.RelayModeVideoFetchByID: videoFetchByIDRespBodyBuilder,
}
@@ -316,58 +412,6 @@ func RelayTaskFetch(c *gin.Context, relayMode int) (taskResp *dto.TaskError) {
return
}
-func sunoFetchRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dto.TaskError) {
- userId := c.GetInt("id")
- var condition = struct {
- IDs []any `json:"ids"`
- Action string `json:"action"`
- }{}
- err := c.BindJSON(&condition)
- if err != nil {
- taskResp = service.TaskErrorWrapper(err, "invalid_request", http.StatusBadRequest)
- return
- }
- var tasks []any
- if len(condition.IDs) > 0 {
- taskModels, err := model.GetByTaskIds(userId, condition.IDs)
- if err != nil {
- taskResp = service.TaskErrorWrapper(err, "get_tasks_failed", http.StatusInternalServerError)
- return
- }
- for _, task := range taskModels {
- tasks = append(tasks, TaskModel2Dto(task))
- }
- } else {
- tasks = make([]any, 0)
- }
- respBody, err = common.Marshal(dto.TaskResponse[[]any]{
- Code: "success",
- Data: tasks,
- })
- return
-}
-
-func sunoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dto.TaskError) {
- taskId := c.Param("id")
- userId := c.GetInt("id")
-
- originTask, exist, err := model.GetByTaskId(userId, taskId)
- if err != nil {
- taskResp = service.TaskErrorWrapper(err, "get_task_failed", http.StatusInternalServerError)
- return
- }
- if !exist {
- taskResp = service.TaskErrorWrapperLocal(errors.New("task_not_exist"), "task_not_exist", http.StatusBadRequest)
- return
- }
-
- respBody, err = common.Marshal(dto.TaskResponse[any]{
- Code: "success",
- Data: TaskModel2Dto(originTask),
- })
- return
-}
-
func videoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dto.TaskError) {
taskId := c.Param("task_id")
if taskId == "" {
@@ -436,7 +480,7 @@ func tryRealtimeFetch(task *model.Task, isOpenAIVideoAPI bool) []byte {
return nil
}
- baseURL := constant.ChannelBaseURLs[channelModel.Type]
+ baseURL := constant.GetChannelBaseURL(channelModel.Type)
if channelModel.GetBaseURL() != "" {
baseURL = channelModel.GetBaseURL()
}
@@ -448,7 +492,7 @@ func tryRealtimeFetch(task *model.Task, isOpenAIVideoAPI bool) []byte {
resp, err := adaptor.FetchTask(baseURL, channelModel.Key, map[string]any{
"task_id": task.GetUpstreamTaskID(),
- "action": task.Action,
+ "action": constant.NormalizeTaskAction(task.Action),
}, proxy)
if err != nil || resp == nil {
return nil
@@ -558,7 +602,7 @@ func TaskModel2Dto(task *model.Task) *dto.TaskDto {
Group: task.Group,
ChannelId: task.ChannelId,
Quota: task.Quota,
- Action: task.Action,
+ Action: constant.NormalizeTaskAction(task.Action),
Status: string(task.Status),
FailReason: task.FailReason,
ResultURL: task.GetResultURL(),
diff --git a/relay/relay_task_test.go b/relay/relay_task_test.go
new file mode 100644
index 000000000000..39e0fd19459e
--- /dev/null
+++ b/relay/relay_task_test.go
@@ -0,0 +1,18 @@
+package relay
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestTaskModel2DtoNormalizesLegacyAction(t *testing.T) {
+ task := &model.Task{Action: "firstTailGenerate"}
+
+ dtoTask := TaskModel2Dto(task)
+
+ assert.Equal(t, constant.TaskActionFirstTailToVideo, dtoTask.Action)
+ assert.Equal(t, "firstTailGenerate", task.Action)
+}
diff --git a/relay/task_platform_error_test.go b/relay/task_platform_error_test.go
new file mode 100644
index 000000000000..71fbf9e52c87
--- /dev/null
+++ b/relay/task_platform_error_test.go
@@ -0,0 +1,36 @@
+package relay
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/constant"
+ pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/stretchr/testify/assert"
+)
+
+// TaskPlatformUnavailableError feeds the client-facing rejection when no
+// adaptor serves a platform. The three shapes are user-actionable and must
+// stay distinguishable: system switched off, plugin disabled, unknown platform.
+func TestTaskPlatformUnavailableError(t *testing.T) {
+ t.Run("master switch off", func(t *testing.T) {
+ pluginruntime.DefaultRegistry.SetEnabled(false)
+ t.Cleanup(func() { pluginruntime.DefaultRegistry.SetEnabled(true) })
+ code, message := TaskPlatformUnavailableError(constant.TaskPlatform("17"))
+ assert.Equal(t, "task_plugin_system_disabled", code)
+ assert.Contains(t, message, "task plugin system is disabled")
+ })
+
+ t.Run("factory plugin disabled resolves legacy channel type to plugin key", func(t *testing.T) {
+ pluginruntime.DefaultRegistry.SetDisabledFactoryKeys([]string{"alibaba"})
+ t.Cleanup(func() { pluginruntime.DefaultRegistry.SetDisabledFactoryKeys(nil) })
+ code, message := TaskPlatformUnavailableError(constant.TaskPlatform("17"))
+ assert.Equal(t, "task_plugin_disabled", code)
+ assert.Contains(t, message, `task plugin "alibaba" is disabled`)
+ })
+
+ t.Run("unknown platform keeps the legacy message", func(t *testing.T) {
+ code, message := TaskPlatformUnavailableError(constant.TaskPlatform("no-such-platform"))
+ assert.Equal(t, "invalid_api_platform", code)
+ assert.Contains(t, message, "invalid api platform: no-such-platform")
+ })
+}
diff --git a/relay/task_platform_test.go b/relay/task_platform_test.go
new file mode 100644
index 000000000000..03a4b626cc43
--- /dev/null
+++ b/relay/task_platform_test.go
@@ -0,0 +1,18 @@
+package relay
+
+import (
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGetTaskPlatformPriority(t *testing.T) {
+ c, _ := gin.CreateTestContext(nil)
+ c.Set("platform", "fallback")
+ assert.Equal(t, "fallback", string(GetTaskPlatform(c)))
+ c.Set("channel_type", 59)
+ assert.Equal(t, "59", string(GetTaskPlatform(c)))
+ c.Set("task_plugin_key", "document-parser")
+ assert.Equal(t, "document-parser", string(GetTaskPlatform(c)))
+}
diff --git a/relaykit/dto/channel_settings.go b/relaykit/dto/channel_settings.go
index 4b4e71911283..51f7062a805b 100644
--- a/relaykit/dto/channel_settings.go
+++ b/relaykit/dto/channel_settings.go
@@ -11,6 +11,7 @@ import (
)
type ChannelSettings struct {
+ TaskPluginKey string `json:"task_plugin_key,omitempty"`
ForceFormat bool `json:"force_format,omitempty"`
ThinkingToContent bool `json:"thinking_to_content,omitempty"`
Proxy string `json:"proxy"`
diff --git a/router/api-router.go b/router/api-router.go
index 31c595e00db2..092600aa06cc 100644
--- a/router/api-router.go
+++ b/router/api-router.go
@@ -3,6 +3,7 @@ package router
import (
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/middleware"
+ "github.com/QuantumNous/new-api/service/authz"
// Import oauth package to register providers via init()
_ "github.com/QuantumNous/new-api/oauth"
@@ -231,6 +232,23 @@ func SetApiRouter(router *gin.Engine) {
ratioSyncRoute.GET("/channels", controller.GetSyncableChannels)
ratioSyncRoute.POST("/fetch", controller.FetchUpstreamRatios)
}
+ taskPluginRoute := apiRouter.Group("/plugin/task")
+ taskPluginRoute.Use(middleware.RootAuth())
+ {
+ taskPluginRoute.GET("", controller.ListTaskPlugins)
+ taskPluginRoute.POST("", controller.UploadTaskPlugin)
+ taskPluginRoute.PUT("", controller.UploadTaskPlugin)
+ taskPluginRoute.GET("/runtime/status", controller.GetTaskPluginRuntime)
+ taskPluginRoute.GET("/marketplace/sources", controller.GetTaskPluginMarketplaceSources)
+ taskPluginRoute.PUT("/marketplace/sources", controller.UpdateTaskPluginMarketplaceSources)
+ taskPluginRoute.GET("/:key", controller.GetTaskPlugin)
+ taskPluginRoute.GET("/:key/versions", controller.GetTaskPluginVersions)
+ taskPluginRoute.POST("/:key/activate", controller.ActivateTaskPlugin)
+ taskPluginRoute.POST("/:key/status", controller.SetTaskPluginStatus)
+ taskPluginRoute.POST("/:key/dryrun", controller.DryRunTaskPlugin)
+ taskPluginRoute.DELETE("/:key/versions/:version", controller.DeleteTaskPluginVersion)
+ }
+ apiRouter.GET("/task_plugin_options", middleware.AdminAuth(), middleware.RequirePermission(authz.TaskPluginBind), controller.GetTaskPluginOptions)
registerChannelRoutes(apiRouter)
registerAuthzRoutes(apiRouter)
tokenRoute := apiRouter.Group("/token")
@@ -327,7 +345,8 @@ func SetApiRouter(router *gin.Engine) {
taskRoute := apiRouter.Group("/task")
{
taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask)
- taskRoute.GET("/", middleware.AdminAuth(), controller.GetAllTask)
+ taskRoute.GET("", middleware.AdminAuth(), controller.GetAllTask)
+ taskRoute.GET("/:task_id/artifacts", middleware.UserAuth(), controller.GetDashboardTaskArtifacts)
}
vendorRoute := apiRouter.Group("/vendors")
diff --git a/router/main.go b/router/main.go
index 6eeeeb88502a..f3e81035ffa3 100644
--- a/router/main.go
+++ b/router/main.go
@@ -16,19 +16,25 @@ func SetRouter(router *gin.Engine, assets WebAssets) {
SetApiRouter(router)
SetDashboardRouter(router)
SetRelayRouter(router)
+ SetTaskPluginProtocolRouter(router)
SetVideoRouter(router)
+ SetTaskRouter(router)
+ pluginDispatcher := SetPluginRouter(router)
frontendBaseUrl := os.Getenv("FRONTEND_BASE_URL")
if common.IsMasterNode && frontendBaseUrl != "" {
frontendBaseUrl = ""
common.SysLog("FRONTEND_BASE_URL is ignored on master node")
}
if frontendBaseUrl == "" {
- SetWebRouter(router, assets)
+ SetWebRouter(router, assets, pluginDispatcher)
} else {
frontendBaseUrl = strings.TrimSuffix(frontendBaseUrl, "/")
- router.NoRoute(func(c *gin.Context) {
- c.Set(middleware.RouteTagKey, "web")
- c.Redirect(http.StatusMovedPermanently, fmt.Sprintf("%s%s", frontendBaseUrl, c.Request.RequestURI))
- })
+ router.NoRoute(
+ pluginDispatcher,
+ middleware.RouteTag("web"),
+ func(c *gin.Context) {
+ c.Redirect(http.StatusMovedPermanently, fmt.Sprintf("%s%s", frontendBaseUrl, c.Request.RequestURI))
+ },
+ )
}
}
diff --git a/router/plugin-router.go b/router/plugin-router.go
new file mode 100644
index 000000000000..f90a43ba4105
--- /dev/null
+++ b/router/plugin-router.go
@@ -0,0 +1,588 @@
+package router
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+ "sort"
+ "strings"
+ "sync"
+ "sync/atomic"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/controller"
+ "github.com/QuantumNous/new-api/logger"
+ "github.com/QuantumNous/new-api/middleware"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/gin-gonic/gin"
+)
+
+type pluginDispatchStateKey struct{}
+
+type pluginDispatchState struct {
+ generation *jsplugin.RoutingGeneration
+ hit atomic.Bool
+ writer *gatedResponseWriter
+ requestID string
+ language string
+}
+
+func (s *pluginDispatchState) markHit() {
+ s.hit.Store(true)
+ s.writer.activate()
+}
+
+type pluginRouteHandlers func(*jsplugin.RoutingGeneration, jsplugin.RouteBinding) []gin.HandlerFunc
+
+type pluginGenerationBuilder struct {
+ staticRoutes []gin.RouteInfo
+ trustedProxies []string
+ routeHandlers pluginRouteHandlers
+ registerRoute func(*gin.Engine, jsplugin.RouteBinding, []gin.HandlerFunc)
+ configure func(*gin.Engine) error
+}
+
+type pluginRouteDispatcher struct {
+ registry *jsplugin.Registry
+}
+
+func SetPluginRouter(outer *gin.Engine) gin.HandlerFunc {
+ trustedProxies, _, err := common.ResolveTrustedProxies(os.Getenv("TRUSTED_PROXIES"))
+ dispatcher := &pluginRouteDispatcher{registry: jsplugin.DefaultRegistry}
+ if err != nil {
+ common.SysError("configure plugin router trusted proxies: " + err.Error())
+ return dispatcher.dispatch
+ }
+ builder := newPluginGenerationBuilder(outer.Routes(), trustedProxies, productionPluginRouteHandlers)
+ if err = jsplugin.DefaultRegistry.SetGenerationPreparer(builder.prepare); err != nil {
+ common.SysError("build initial plugin router: " + err.Error())
+ }
+ return dispatcher.dispatch
+}
+
+func newPluginGenerationBuilder(staticRoutes []gin.RouteInfo, trustedProxies []string, handlers pluginRouteHandlers) *pluginGenerationBuilder {
+ builder := &pluginGenerationBuilder{
+ staticRoutes: append([]gin.RouteInfo(nil), staticRoutes...),
+ trustedProxies: append([]string(nil), trustedProxies...),
+ routeHandlers: handlers,
+ }
+ builder.registerRoute = func(engine *gin.Engine, binding jsplugin.RouteBinding, routeHandlers []gin.HandlerFunc) {
+ engine.Handle(binding.Route.Method, binding.Route.Path, routeHandlers...)
+ }
+ builder.configure = func(engine *gin.Engine) error {
+ return common.ConfigureTrustedProxies(engine, builder.trustedProxies)
+ }
+ return builder
+}
+
+func productionPluginRouteHandlers(generation *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) []gin.HandlerFunc {
+ pinRoute := func(c *gin.Context) {
+ pinnedGeneration := generation
+ if state, _ := c.Request.Context().Value(pluginDispatchStateKey{}).(*pluginDispatchState); state != nil && state.generation != nil {
+ pinnedGeneration = state.generation
+ }
+ c.Set(jsplugin.ContextKeyPinnedPlugin, jsplugin.PinnedPlugin{
+ Generation: pinnedGeneration,
+ Plugin: binding.Plugin,
+ })
+ c.Set(jsplugin.ContextKeyPinnedRoute, jsplugin.PinnedRoute{
+ Generation: pinnedGeneration,
+ Plugin: binding.Plugin,
+ Route: binding.Route,
+ })
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=router event=route_matched generation=%d plugin=%q version=%q method=%q route_type=%q",
+ pinnedGeneration.Number,
+ binding.Plugin.Meta.Key,
+ binding.Plugin.Meta.Version,
+ binding.Route.Method,
+ binding.Route.Type,
+ )
+ c.Next()
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=router event=route_complete generation=%d plugin=%q method=%q status=%d",
+ pinnedGeneration.Number,
+ binding.Plugin.Meta.Key,
+ binding.Route.Method,
+ c.Writer.Status(),
+ )
+ }
+ return []gin.HandlerFunc{
+ pinRoute,
+ middleware.TokenAuth(),
+ middleware.SystemPerformanceCheck(),
+ middleware.ModelRequestRateLimit(),
+ middleware.PrepareTaskPluginRoute(),
+ middleware.Distribute(),
+ controller.RelayTask,
+ }
+}
+
+func (b *pluginGenerationBuilder) prepare(candidate, current *jsplugin.RoutingGeneration) (jsplugin.PreparedRoutingGeneration, error) {
+ blocked := make(map[*jsplugin.LoadedPlugin]string)
+ for {
+ accepted, routingErrors := b.admitPlugins(candidate, current, blocked)
+ plugins := sortedPlugins(accepted)
+ filtered, err := candidate.RebuildWithPlugins(plugins)
+ if err != nil {
+ return jsplugin.PreparedRoutingGeneration{}, err
+ }
+ engine, offender, buildErr := b.buildInnerEngine(filtered)
+ if buildErr == nil {
+ return jsplugin.PreparedRoutingGeneration{
+ Generation: filtered.WithRuntime(engine),
+ Errors: routingErrors,
+ }, nil
+ }
+ if offender == "" {
+ return jsplugin.PreparedRoutingGeneration{}, buildErr
+ }
+
+ failedPlugin := accepted[offender]
+ if failedPlugin == nil {
+ return jsplugin.PreparedRoutingGeneration{}, fmt.Errorf("public route rebuild attributed failure to absent plugin %q: %w", offender, buildErr)
+ }
+ blocked[failedPlugin] = fmt.Sprintf("plugin %s rejected while rebuilding public routes: %v", offender, buildErr)
+ }
+}
+
+func (b *pluginGenerationBuilder) admitPlugins(
+ candidate, current *jsplugin.RoutingGeneration,
+ blocked map[*jsplugin.LoadedPlugin]string,
+) (map[string]*jsplugin.LoadedPlugin, map[string]string) {
+ accepted := make(map[string]*jsplugin.LoadedPlugin)
+ currentByKey := make(map[string]*jsplugin.LoadedPlugin)
+ if current != nil && current.RuntimeHandler() != nil {
+ for _, plugin := range current.Plugins() {
+ currentByKey[plugin.Meta.Key] = plugin
+ }
+ }
+
+ unchangedKeys := make([]string, 0)
+ changedKeys := make([]string, 0)
+ newKeys := make([]string, 0)
+ for _, plugin := range candidate.Plugins() {
+ incumbent, existed := currentByKey[plugin.Meta.Key]
+ switch {
+ case existed && incumbent == plugin:
+ unchangedKeys = append(unchangedKeys, plugin.Meta.Key)
+ case existed:
+ changedKeys = append(changedKeys, plugin.Meta.Key)
+ default:
+ newKeys = append(newKeys, plugin.Meta.Key)
+ }
+ }
+ sort.Strings(unchangedKeys)
+ sort.Strings(changedKeys)
+ sort.Strings(newKeys)
+
+ routingErrors := make(map[string]string)
+ orderedKeys := append(unchangedKeys, changedKeys...)
+ orderedKeys = append(orderedKeys, newKeys...)
+ rejectedKeys := make([]string, 0)
+ for _, key := range orderedKeys {
+ plugin, _ := candidate.Get(key)
+ if blockedError := blocked[plugin]; blockedError != "" {
+ routingErrors[key] = blockedError
+ } else if err := b.validatePlugin(plugin, accepted); err != nil {
+ routingErrors[key] = fmt.Sprintf("plugin %s rejected from public routes: %v", key, err)
+ } else {
+ accepted[key] = plugin
+ continue
+ }
+ rejectedKeys = append(rejectedKeys, key)
+ }
+
+ for _, key := range rejectedKeys {
+ plugin, _ := candidate.Get(key)
+ incumbent := currentByKey[key]
+ if incumbent == nil || incumbent == plugin || !candidate.RetainsIncumbent(key) {
+ continue
+ }
+ if blockedError := blocked[incumbent]; blockedError != "" {
+ routingErrors[key] = blockedError
+ continue
+ }
+ if err := b.validatePlugin(incumbent, accepted); err == nil {
+ accepted[key] = incumbent
+ }
+ }
+ return accepted, routingErrors
+}
+
+func (b *pluginGenerationBuilder) validatePlugin(plugin *jsplugin.LoadedPlugin, accepted map[string]*jsplugin.LoadedPlugin) error {
+ for _, route := range plugin.Meta.Routes {
+ for _, staticRoute := range b.staticRoutes {
+ if routeIntersectsStaticRoute(route.Path, staticRoute.Path) {
+ return fmt.Errorf("route %s %s intersects static route %s %s", route.Method, route.Path, staticRoute.Method, staticRoute.Path)
+ }
+ }
+ }
+ for index, left := range plugin.Meta.Routes {
+ for _, right := range plugin.Meta.Routes[index+1:] {
+ if left.Method != right.Method {
+ continue
+ }
+ if routePatternsIntersect(left.Path, right.Path) {
+ return fmt.Errorf("routes %s %s and %s overlap", left.Method, left.Path, right.Path)
+ }
+ if !routesGinCompatible(left.Path, right.Path) {
+ return fmt.Errorf("routes %s %s and %s use incompatible wildcard names", left.Method, left.Path, right.Path)
+ }
+ }
+ }
+ for _, other := range accepted {
+ if other.Meta.Key == plugin.Meta.Key {
+ continue
+ }
+ for _, route := range plugin.Meta.Routes {
+ for _, otherRoute := range other.Meta.Routes {
+ if routePatternsIntersect(route.Path, otherRoute.Path) {
+ return fmt.Errorf("route %s %s overlaps plugin %s route %s %s", route.Method, route.Path, other.Meta.Key, otherRoute.Method, otherRoute.Path)
+ }
+ if route.Method == otherRoute.Method && !routesGinCompatible(route.Path, otherRoute.Path) {
+ return fmt.Errorf("route %s %s is structurally incompatible with plugin %s route %s", route.Method, route.Path, other.Meta.Key, otherRoute.Path)
+ }
+ }
+ }
+ }
+ return nil
+}
+
+func (b *pluginGenerationBuilder) buildInnerEngine(generation *jsplugin.RoutingGeneration) (engine *gin.Engine, offender string, err error) {
+ currentPlugin := ""
+ defer func() {
+ if recovered := recover(); recovered != nil {
+ offender = currentPlugin
+ err = fmt.Errorf("inner Gin registration panic: %v", recovered)
+ engine = nil
+ }
+ }()
+
+ engine = gin.New()
+ engine.RedirectTrailingSlash = false
+ engine.HandleMethodNotAllowed = true
+ if err = b.configure(engine); err != nil {
+ return nil, "", err
+ }
+ engine.Use(importPluginDispatchState())
+ engine.Use(pluginRouteRecovery())
+ engine.Use(middleware.BodyStorageCleanup())
+ engine.NoMethod(func(c *gin.Context) {
+ markPluginRouteHit(c)
+ generation := uint64(0)
+ if state, _ := c.Request.Context().Value(pluginDispatchStateKey{}).(*pluginDispatchState); state != nil && state.generation != nil {
+ generation = state.generation.Number
+ }
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=router event=method_not_allowed generation=%d request_method=%q status=%d",
+ generation,
+ c.Request.Method,
+ http.StatusMethodNotAllowed,
+ )
+ c.AbortWithStatus(http.StatusMethodNotAllowed)
+ })
+
+ for _, binding := range generation.Routes() {
+ currentPlugin = binding.Plugin.Meta.Key
+ b.registerRoute(engine, binding, b.routeHandlers(generation, binding))
+ }
+ currentPlugin = ""
+ return engine, "", nil
+}
+
+func importPluginDispatchState() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ state, _ := c.Request.Context().Value(pluginDispatchStateKey{}).(*pluginDispatchState)
+ if state != nil {
+ if c.FullPath() != "" {
+ state.markHit()
+ }
+ if state.requestID != "" {
+ c.Set(common.RequestIdKey, state.requestID)
+ }
+ if state.language != "" {
+ c.Set(string(constant.ContextKeyLanguage), state.language)
+ }
+ }
+ c.Set(middleware.RouteTagKey, "relay")
+ c.Next()
+ }
+}
+
+func markPluginRouteHit(c *gin.Context) {
+ state, _ := c.Request.Context().Value(pluginDispatchStateKey{}).(*pluginDispatchState)
+ if state != nil {
+ state.markHit()
+ }
+}
+
+func pluginRouteRecovery() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ defer func() {
+ if recover() == nil {
+ return
+ }
+ common.SysError("panic recovered in plugin route")
+ if pinnedValue, exists := c.Get(jsplugin.ContextKeyPinnedRoute); exists {
+ if pinned, ok := pinnedValue.(jsplugin.PinnedRoute); ok && pinned.Plugin != nil && pinned.Generation != nil {
+ logger.LogDebug(
+ c,
+ "task_plugin subsystem=router event=panic_recovered generation=%d plugin=%q method=%q",
+ pinned.Generation.Number,
+ pinned.Plugin.Meta.Key,
+ pinned.Route.Method,
+ )
+ }
+ }
+ c.Abort()
+ if !c.Writer.Written() {
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": gin.H{
+ "message": "internal plugin route error",
+ "type": "plugin_route_error",
+ },
+ })
+ }
+ }()
+ c.Next()
+ }
+}
+
+func (d *pluginRouteDispatcher) dispatch(c *gin.Context) {
+ generation := d.registry.Generation()
+ if generation == nil || generation.RuntimeHandler() == nil {
+ c.Next()
+ return
+ }
+
+ previousTag, hadPreviousTag := c.Get(middleware.RouteTagKey)
+ c.Set(middleware.RouteTagKey, "relay")
+ originalContext := c.Request.Context()
+ state := &pluginDispatchState{
+ generation: generation,
+ requestID: c.GetString(common.RequestIdKey),
+ language: c.GetString(string(constant.ContextKeyLanguage)),
+ }
+ gatedWriter := newGatedResponseWriter(c.Writer)
+ state.writer = gatedWriter
+ c.Request = c.Request.WithContext(context.WithValue(originalContext, pluginDispatchStateKey{}, state))
+
+ generation.RuntimeHandler().ServeHTTP(gatedWriter, c.Request)
+ if state.hit.Load() {
+ if !c.Writer.Written() {
+ c.Writer.WriteHeaderNow()
+ }
+ c.Abort()
+ return
+ }
+
+ c.Request = c.Request.WithContext(originalContext)
+ if hadPreviousTag {
+ c.Set(middleware.RouteTagKey, previousTag)
+ } else {
+ delete(c.Keys, middleware.RouteTagKey)
+ }
+ c.Next()
+}
+
+type gatedResponseWriter struct {
+ underlying gin.ResponseWriter
+ privateHeader http.Header
+ active atomic.Bool
+ activateOnce sync.Once
+ pendingStatus int
+}
+
+func newGatedResponseWriter(underlying gin.ResponseWriter) *gatedResponseWriter {
+ return &gatedResponseWriter{
+ underlying: underlying,
+ privateHeader: underlying.Header().Clone(),
+ }
+}
+
+func (w *gatedResponseWriter) activate() {
+ w.activateOnce.Do(func() {
+ target := w.underlying.Header()
+ for key := range target {
+ target.Del(key)
+ }
+ for key, values := range w.privateHeader {
+ target[key] = append([]string(nil), values...)
+ }
+ w.active.Store(true)
+ if w.pendingStatus != 0 {
+ w.underlying.WriteHeader(w.pendingStatus)
+ }
+ })
+}
+
+func (w *gatedResponseWriter) Header() http.Header {
+ if w.active.Load() {
+ return w.underlying.Header()
+ }
+ return w.privateHeader
+}
+
+func (w *gatedResponseWriter) WriteHeader(statusCode int) {
+ if w.active.Load() {
+ w.underlying.WriteHeader(statusCode)
+ return
+ }
+ if w.pendingStatus == 0 {
+ w.pendingStatus = statusCode
+ }
+}
+
+func (w *gatedResponseWriter) Write(data []byte) (int, error) {
+ if !w.active.Load() {
+ return len(data), nil
+ }
+ return w.underlying.Write(data)
+}
+
+func (w *gatedResponseWriter) Flush() {
+ if w.active.Load() {
+ w.underlying.Flush()
+ }
+}
+
+func (w *gatedResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
+ if !w.active.Load() {
+ return nil, nil, errors.New("cannot hijack an unmatched plugin route")
+ }
+ return w.underlying.Hijack()
+}
+
+func (w *gatedResponseWriter) CloseNotify() <-chan bool {
+ if w.active.Load() {
+ return w.underlying.CloseNotify()
+ }
+ never := make(chan bool)
+ return never
+}
+
+func (w *gatedResponseWriter) Push(target string, options *http.PushOptions) error {
+ if !w.active.Load() {
+ return http.ErrNotSupported
+ }
+ pusher := w.underlying.Pusher()
+ if pusher == nil {
+ return http.ErrNotSupported
+ }
+ return pusher.Push(target, options)
+}
+
+func sortedPluginKeys(plugins map[string]*jsplugin.LoadedPlugin) []string {
+ keys := make([]string, 0, len(plugins))
+ for key := range plugins {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ return keys
+}
+
+func sortedPlugins(plugins map[string]*jsplugin.LoadedPlugin) []*jsplugin.LoadedPlugin {
+ keys := sortedPluginKeys(plugins)
+ sorted := make([]*jsplugin.LoadedPlugin, 0, len(keys))
+ for _, key := range keys {
+ sorted = append(sorted, plugins[key])
+ }
+ return sorted
+}
+
+type routePatternSegment struct {
+ value string
+ dynamic bool
+ catchAll bool
+}
+
+func parseRoutePattern(routePath string) []routePatternSegment {
+ parts := strings.Split(strings.TrimPrefix(routePath, "/"), "/")
+ segments := make([]routePatternSegment, 0, len(parts))
+ for _, part := range parts {
+ segments = append(segments, routePatternSegment{
+ value: part,
+ dynamic: strings.HasPrefix(part, ":") || strings.HasPrefix(part, "*"),
+ catchAll: strings.HasPrefix(part, "*"),
+ })
+ }
+ return segments
+}
+
+func routePatternsIntersect(leftPath, rightPath string) bool {
+ left := parseRoutePattern(leftPath)
+ right := parseRoutePattern(rightPath)
+ for index := 0; ; index++ {
+ leftDone := index >= len(left)
+ rightDone := index >= len(right)
+ if leftDone || rightDone {
+ return leftDone && rightDone
+ }
+ if left[index].catchAll || right[index].catchAll {
+ return true
+ }
+ if !left[index].dynamic && !right[index].dynamic && left[index].value != right[index].value {
+ return false
+ }
+ if (left[index].dynamic && right[index].value == "") || (right[index].dynamic && left[index].value == "") {
+ return false
+ }
+ }
+}
+
+func routesGinCompatible(leftPath, rightPath string) bool {
+ left := parseRoutePattern(leftPath)
+ right := parseRoutePattern(rightPath)
+ limit := len(left)
+ if len(right) < limit {
+ limit = len(right)
+ }
+ for index := 0; index < limit; index++ {
+ leftSegment := left[index]
+ rightSegment := right[index]
+ if !leftSegment.dynamic && !rightSegment.dynamic {
+ if leftSegment.value != rightSegment.value {
+ return true
+ }
+ continue
+ }
+ if leftSegment.dynamic && rightSegment.dynamic {
+ if leftSegment.value != rightSegment.value {
+ return false
+ }
+ continue
+ }
+ return true
+ }
+ return true
+}
+
+func routeIntersectsStaticRoute(pluginPath, staticPath string) bool {
+ if routePatternsIntersect(pluginPath, staticPath) {
+ return true
+ }
+ if catchAllIndex := strings.LastIndex(staticPath, "/*"); catchAllIndex >= 0 && catchAllIndex+2 < len(staticPath) {
+ prefix := staticPath[:catchAllIndex]
+ if routePatternsIntersect(pluginPath, prefix) || routePatternsIntersect(pluginPath, prefix+"/") {
+ return true
+ }
+ return false
+ }
+ if staticPath == "/" {
+ return false
+ }
+ alternate := strings.TrimSuffix(staticPath, "/")
+ if alternate == staticPath {
+ alternate += "/"
+ }
+ return routePatternsIntersect(pluginPath, alternate)
+}
diff --git a/router/plugin_router_test.go b/router/plugin_router_test.go
new file mode 100644
index 000000000000..5e8618504d07
--- /dev/null
+++ b/router/plugin_router_test.go
@@ -0,0 +1,884 @@
+package router
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/middleware"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/gin-contrib/gzip"
+ "github.com/gin-gonic/gin"
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+func TestPluginDispatcherMissFallsThroughWithoutLeakingInnerResponse(t *testing.T) {
+ outer, registry := newPluginRouterTest(t, nil, nil)
+ dispatcher := (&pluginRouteDispatcher{registry: registry}).dispatch
+ outer.NoRoute(
+ dispatcher,
+ func(c *gin.Context) {
+ assert.Equal(t, "before", c.GetString(middleware.RouteTagKey))
+ c.Header("X-Fallback", "true")
+ c.String(http.StatusOK, "spa")
+ },
+ )
+
+ recorder := performPluginRequest(outer, http.MethodGet, "/not-owned")
+
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ assert.Equal(t, "spa", recorder.Body.String())
+ assert.Equal(t, "true", recorder.Header().Get("X-Fallback"))
+ assert.Empty(t, recorder.Header().Get("Location"))
+}
+
+func TestPluginDebugLogsOnlyOwnedRoutes(t *testing.T) {
+ previousDebug := common.DebugEnabled
+ common.DebugEnabled = true
+ t.Cleanup(func() { common.DebugEnabled = previousDebug })
+
+ var output bytes.Buffer
+ common.LogWriterMu.Lock()
+ previousWriter := gin.DefaultErrorWriter
+ gin.DefaultErrorWriter = &output
+ common.LogWriterMu.Unlock()
+ t.Cleanup(func() {
+ common.LogWriterMu.Lock()
+ gin.DefaultErrorWriter = previousWriter
+ common.LogWriterMu.Unlock()
+ })
+
+ plugin := compileRouterPlugin(t, "debug-owner", "1.0.0", `[
+ {method: "GET", path: "/vendor/debug", type: "dynamic"}
+ ]`)
+ handlers := func(generation *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) []gin.HandlerFunc {
+ production := productionPluginRouteHandlers(generation, binding)
+ return []gin.HandlerFunc{
+ production[0],
+ func(c *gin.Context) { c.Status(http.StatusNoContent) },
+ }
+ }
+ outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, handlers)
+ outer.NoRoute(
+ (&pluginRouteDispatcher{registry: registry}).dispatch,
+ func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
+ )
+ output.Reset()
+
+ owned := performPluginRequest(outer, http.MethodGet, "/vendor/debug")
+ require.Equal(t, http.StatusNoContent, owned.Code)
+ logOutput := output.String()
+ assert.Contains(t, logOutput, "request-phase-two")
+ assert.Contains(t, logOutput, "task_plugin subsystem=router event=route_matched")
+ assert.Contains(t, logOutput, `plugin="debug-owner"`)
+ assert.NotContains(t, logOutput, "/vendor/debug")
+ assert.Contains(t, logOutput, "event=route_complete")
+
+ output.Reset()
+ miss := performPluginRequest(outer, http.MethodGet, "/not-owned")
+ require.Equal(t, http.StatusOK, miss.Code)
+ assert.Equal(t, "fallback", miss.Body.String())
+ assert.NotContains(t, output.String(), "task_plugin")
+}
+
+func TestPluginAuthoredHeaderOnly404PassesThrough(t *testing.T) {
+ plugin := compileRouterPlugin(t, "authored-404", "1.0.0", `[
+ {method: "GET", path: "/vendor/missing", type: "dynamic"}
+ ]`)
+ handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
+ c.Header("X-Plugin", "authored")
+ c.Status(http.StatusNotFound)
+ })
+ outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, handlers)
+ outer.NoRoute(
+ (&pluginRouteDispatcher{registry: registry}).dispatch,
+ func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
+ )
+
+ recorder := performPluginRequest(outer, http.MethodGet, "/vendor/missing")
+
+ assert.Equal(t, http.StatusNotFound, recorder.Code)
+ assert.Empty(t, recorder.Body.String())
+ assert.Equal(t, "authored", recorder.Header().Get("X-Plugin"))
+}
+
+func TestPluginOwnedPathMethodMismatchReturns405(t *testing.T) {
+ plugin := compileRouterPlugin(t, "method-owner", "1.0.0", `[
+ {method: "GET", path: "/vendor/jobs/:task_id", type: "query", render: "native"}
+ ]`)
+ outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, testPluginRouteHandlers(
+ func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
+ c.String(http.StatusOK, "plugin")
+ },
+ ))
+ outer.NoRoute(
+ (&pluginRouteDispatcher{registry: registry}).dispatch,
+ func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
+ )
+
+ recorder := performPluginRequest(outer, http.MethodPost, "/vendor/jobs/task-1")
+
+ assert.Equal(t, http.StatusMethodNotAllowed, recorder.Code)
+ assert.Empty(t, recorder.Body.String())
+}
+
+func TestPluginTrailingSlashMissDoesNotRedirect(t *testing.T) {
+ for _, testCase := range []struct {
+ name string
+ routePath string
+ requestPath string
+ }{
+ {name: "declared without slash", routePath: "/vendor/job", requestPath: "/vendor/job/"},
+ {name: "declared with slash", routePath: "/vendor/job/", requestPath: "/vendor/job"},
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ plugin := compileRouterPlugin(t, "slash-owner", "1.0.0", fmt.Sprintf(`[
+ {method: "GET", path: %q, type: "dynamic"}
+ ]`, testCase.routePath))
+ outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, testPluginRouteHandlers(
+ func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
+ c.String(http.StatusOK, "plugin")
+ },
+ ))
+ outer.NoRoute(
+ (&pluginRouteDispatcher{registry: registry}).dispatch,
+ func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
+ )
+
+ recorder := performPluginRequest(outer, http.MethodGet, testCase.requestPath)
+
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ assert.Equal(t, "fallback", recorder.Body.String())
+ assert.Empty(t, recorder.Header().Get("Location"))
+ })
+ }
+}
+
+func TestPluginSSEFlushesWithoutFallbackBuffering(t *testing.T) {
+ plugin := compileRouterPlugin(t, "stream-owner", "1.0.0", `[
+ {method: "GET", path: "/vendor/stream", type: "dynamic"}
+ ]`)
+ handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
+ c.Header("Content-Type", "text/event-stream")
+ _, _ = c.Writer.WriteString("data: ready\n\n")
+ c.Writer.Flush()
+ })
+ outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, handlers)
+ outer.NoRoute(
+ (&pluginRouteDispatcher{registry: registry}).dispatch,
+ middleware.RouteTag("web"),
+ gzip.Gzip(gzip.DefaultCompression),
+ middleware.Cache(),
+ func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
+ )
+
+ request := httptest.NewRequest(http.MethodGet, "/vendor/stream", nil)
+ request.Header.Set("Accept-Encoding", "gzip")
+ recorder := httptest.NewRecorder()
+ outer.ServeHTTP(recorder, request)
+
+ assert.True(t, recorder.Flushed)
+ assert.Equal(t, "text/event-stream", recorder.Header().Get("Content-Type"))
+ assert.Equal(t, "data: ready\n\n", recorder.Body.String())
+ assert.Empty(t, recorder.Header().Get("Content-Encoding"))
+ assert.Empty(t, recorder.Header().Get("Cache-Control"))
+ assert.Empty(t, recorder.Header().Get("Cache-Version"))
+}
+
+func TestWebCacheHeadersDoNotLeakOntoPluginRoutes(t *testing.T) {
+ plugin := compileRouterPlugin(t, "cache-owner", "1.0.0", `[
+ {method: "GET", path: "/vendor/status/:task_id", type: "query", render: "native"}
+ ]`)
+ outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, testPluginRouteHandlers(
+ func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
+ c.JSON(http.StatusOK, gin.H{"status": "queued"})
+ },
+ ))
+ outer.NoRoute(
+ (&pluginRouteDispatcher{registry: registry}).dispatch,
+ middleware.Cache(),
+ func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
+ )
+
+ pluginResponse := performPluginRequest(outer, http.MethodGet, "/vendor/status/task-1")
+ assert.Empty(t, pluginResponse.Header().Get("Cache-Control"))
+ assert.Empty(t, pluginResponse.Header().Get("Cache-Version"))
+
+ fallbackResponse := performPluginRequest(outer, http.MethodGet, "/unknown")
+ assert.Equal(t, "max-age=604800", fallbackResponse.Header().Get("Cache-Control"))
+ assert.NotEmpty(t, fallbackResponse.Header().Get("Cache-Version"))
+}
+
+func TestPluginInnerContextImportsRequestMetadataAndTrustedProxyConfig(t *testing.T) {
+ plugin := compileRouterPlugin(t, "context-owner", "1.0.0", `[
+ {method: "GET", path: "/vendor/context", type: "dynamic"}
+ ]`)
+ handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
+ c.JSON(http.StatusOK, gin.H{
+ "request_id": c.GetString(common.RequestIdKey),
+ "language": c.GetString(string(constant.ContextKeyLanguage)),
+ "client_ip": c.ClientIP(),
+ "route_tag": c.GetString(middleware.RouteTagKey),
+ })
+ })
+ outer, registry := newPluginRouterTestWithProxies(t, []*jsplugin.LoadedPlugin{plugin}, handlers, []string{"127.0.0.0/8"})
+ outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
+
+ request := httptest.NewRequest(http.MethodGet, "/vendor/context", nil)
+ request.RemoteAddr = "127.0.0.1:1234"
+ request.Header.Set("X-Forwarded-For", "203.0.113.20")
+ recorder := httptest.NewRecorder()
+ outer.ServeHTTP(recorder, request)
+
+ assert.JSONEq(t, `{"request_id":"request-phase-two","language":"zh-CN","client_ip":"203.0.113.20","route_tag":"relay"}`, recorder.Body.String())
+}
+
+func TestPluginRequestPinsGenerationAcrossHotSwap(t *testing.T) {
+ v1 := compileRouterPlugin(t, "hot-swap", "1.0.0", `[
+ {method: "GET", path: "/vendor/version", type: "dynamic"}
+ ]`)
+ v2 := compileRouterPlugin(t, "hot-swap", "2.0.0", `[
+ {method: "GET", path: "/vendor/version", type: "dynamic"}
+ ]`)
+ started := make(chan struct{})
+ release := make(chan struct{})
+ var startOnce sync.Once
+ t.Cleanup(func() {
+ select {
+ case <-release:
+ default:
+ close(release)
+ }
+ })
+ var registry *jsplugin.Registry
+ handlers := testPluginRouteHandlers(func(c *gin.Context, generation *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
+ if binding.Plugin.Meta.Version == "1.0.0" {
+ startOnce.Do(func() { close(started) })
+ <-release
+ }
+ pinnedValue, exists := c.Get(jsplugin.ContextKeyPinnedRoute)
+ pinned, ok := pinnedValue.(jsplugin.PinnedRoute)
+ if !exists || !ok || pinned.Plugin == nil || pinned.Generation == nil {
+ c.AbortWithStatus(http.StatusInternalServerError)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "version": binding.Plugin.Meta.Version,
+ "generation": generation.Number,
+ "pinned_version": pinned.Plugin.Meta.Version,
+ "pinned_generation": pinned.Generation.Number,
+ "current_generation": registry.Generation().Number,
+ })
+ })
+ outer, activeRegistry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{v1}, handlers)
+ registry = activeRegistry
+ outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
+ firstGeneration := registry.Generation().Number
+
+ firstDone := make(chan *httptest.ResponseRecorder, 1)
+ go func() {
+ firstDone <- performPluginRequest(outer, http.MethodGet, "/vendor/version")
+ }()
+ select {
+ case <-started:
+ case <-time.After(2 * time.Second):
+ require.FailNow(t, "first generation request did not start")
+ }
+ require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{v2}))
+ secondGeneration := registry.Generation().Number
+ close(release)
+
+ var first *httptest.ResponseRecorder
+ select {
+ case first = <-firstDone:
+ case <-time.After(2 * time.Second):
+ require.FailNow(t, "pinned first generation request did not finish")
+ }
+ assert.JSONEq(t, fmt.Sprintf(`{
+ "version": "1.0.0",
+ "generation": %d,
+ "pinned_version": "1.0.0",
+ "pinned_generation": %d,
+ "current_generation": %d
+ }`, firstGeneration, firstGeneration, secondGeneration), first.Body.String())
+ second := performPluginRequest(outer, http.MethodGet, "/vendor/version")
+ assert.JSONEq(t, fmt.Sprintf(`{
+ "version": "2.0.0",
+ "generation": %d,
+ "pinned_version": "2.0.0",
+ "pinned_generation": %d,
+ "current_generation": %d
+ }`, secondGeneration, secondGeneration, secondGeneration), second.Body.String())
+}
+
+func TestPluginStaticRouteConflictsAreExcluded(t *testing.T) {
+ tests := []struct {
+ name string
+ staticPath string
+ pluginPath string
+ }{
+ {name: "parameter intersection", staticPath: "/core/:id", pluginPath: "/core/fixed"},
+ {name: "trailing slash redirect shadow", staticPath: "/fixed", pluginPath: "/fixed/"},
+ {name: "catchall redirect shadow", staticPath: "/files/*filepath", pluginPath: "/files"},
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ outer := newOuterPluginTestEngine()
+ outer.GET(testCase.staticPath, func(c *gin.Context) { c.String(http.StatusOK, "static") })
+ registry := jsplugin.NewRegistry()
+ plugin := compileRouterPlugin(t, "static-conflict", "1.0.0", fmt.Sprintf(`[
+ {method: "POST", path: %q, type: "submit"}
+ ]`, testCase.pluginPath))
+ require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{plugin}))
+ builder := newPluginGenerationBuilder(outer.Routes(), nil, testPluginRouteHandlers(
+ func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
+ c.String(http.StatusOK, "plugin")
+ },
+ ))
+ require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
+ outer.NoRoute(
+ (&pluginRouteDispatcher{registry: registry}).dispatch,
+ func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
+ )
+
+ recorder := performPluginRequest(outer, http.MethodPost, testCase.pluginPath)
+ assert.Equal(t, "fallback", recorder.Body.String())
+ assert.Contains(t, registry.RoutingErrors()["static-conflict"], "intersects static route")
+ })
+ }
+}
+
+func TestStaticConflictingUpdateRetainsIncumbentAndPublishesHealthyPeer(t *testing.T) {
+ outer := newOuterPluginTestEngine()
+ outer.GET("/core/:id", func(c *gin.Context) { c.String(http.StatusOK, "static") })
+ registry := jsplugin.NewRegistry()
+ incumbent := compileRouterPlugin(t, "static-update", "1.0.0", `[
+ {method: "GET", path: "/safe/incumbent", type: "dynamic"}
+ ]`)
+ conflictingUpdate := compileRouterPlugin(t, "static-update", "2.0.0", `[
+ {method: "POST", path: "/core/fixed", type: "submit"}
+ ]`)
+ healthyV1 := compileRouterPlugin(t, "healthy-peer", "1.0.0", `[
+ {method: "GET", path: "/safe/healthy", type: "dynamic"}
+ ]`)
+ healthyV2 := compileRouterPlugin(t, "healthy-peer", "2.0.0", `[
+ {method: "GET", path: "/safe/healthy", type: "dynamic"}
+ ]`)
+ require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{incumbent, healthyV1}))
+ handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
+ c.String(http.StatusOK, binding.Plugin.Meta.Version)
+ })
+ builder := newPluginGenerationBuilder(outer.Routes(), nil, handlers)
+ require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
+ outer.NoRoute(
+ (&pluginRouteDispatcher{registry: registry}).dispatch,
+ func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
+ )
+
+ require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{conflictingUpdate, healthyV2}))
+
+ activeIncumbent, ok := registry.Get("static-update")
+ require.True(t, ok)
+ assert.Same(t, incumbent, activeIncumbent)
+ activeHealthy, ok := registry.Get("healthy-peer")
+ require.True(t, ok)
+ assert.Same(t, healthyV2, activeHealthy)
+ assert.Equal(t, "1.0.0", performPluginRequest(outer, http.MethodGet, "/safe/incumbent").Body.String())
+ assert.Equal(t, "2.0.0", performPluginRequest(outer, http.MethodGet, "/safe/healthy").Body.String())
+ assert.Contains(t, registry.RoutingErrors()["static-update"], "intersects static route")
+}
+
+func TestStaticConflictingNewOverrideRetainsFactoryRoute(t *testing.T) {
+ outer := newOuterPluginTestEngine()
+ outer.GET("/core/:id", func(c *gin.Context) { c.String(http.StatusOK, "static") })
+ registry := jsplugin.NewRegistry()
+ factory, err := registry.RegisterFactory(routerPluginSource("factory-route", "1.0.0", `[
+ {method: "GET", path: "/factory-route/safe", type: "dynamic"}
+ ]`), jsplugin.Options{})
+ require.NoError(t, err)
+ handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
+ c.String(http.StatusOK, binding.Plugin.Meta.Version)
+ })
+ builder := newPluginGenerationBuilder(outer.Routes(), nil, handlers)
+ require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
+ outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
+ conflictingOverride := compileRouterPlugin(t, "factory-route", "2.0.0", `[
+ {method: "POST", path: "/core/fixed", type: "submit"}
+ ]`)
+
+ require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{conflictingOverride}))
+
+ active, ok := registry.Get("factory-route")
+ require.True(t, ok)
+ assert.Same(t, factory, active)
+ assert.Equal(t, "1.0.0", performPluginRequest(outer, http.MethodGet, "/factory-route/safe").Body.String())
+ assert.Contains(t, registry.RoutingErrors()["factory-route"], "intersects static route")
+}
+
+func TestPluginRouteOwnershipCanSwapWithinOneGeneration(t *testing.T) {
+ alphaV1 := compileRouterPlugin(t, "route-swap-alpha", "1.0.0", `[
+ {method: "GET", path: "/route-swap/alpha", type: "dynamic"}
+ ]`)
+ betaV1 := compileRouterPlugin(t, "route-swap-beta", "1.0.0", `[
+ {method: "GET", path: "/route-swap/beta", type: "dynamic"}
+ ]`)
+ alphaV2 := compileRouterPlugin(t, "route-swap-alpha", "2.0.0", `[
+ {method: "GET", path: "/route-swap/beta", type: "dynamic"}
+ ]`)
+ betaV2 := compileRouterPlugin(t, "route-swap-beta", "2.0.0", `[
+ {method: "GET", path: "/route-swap/alpha", type: "dynamic"}
+ ]`)
+ handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
+ c.String(http.StatusOK, binding.Plugin.Meta.Key+"@"+binding.Plugin.Meta.Version)
+ })
+ outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{alphaV1, betaV1}, handlers)
+ outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
+
+ require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{alphaV2, betaV2}))
+
+ assert.Equal(t, "route-swap-beta@2.0.0", performPluginRequest(outer, http.MethodGet, "/route-swap/alpha").Body.String())
+ assert.Equal(t, "route-swap-alpha@2.0.0", performPluginRequest(outer, http.MethodGet, "/route-swap/beta").Body.String())
+ assert.Empty(t, registry.RoutingErrors())
+}
+
+func TestRejectedRouteUpdateDoesNotFreezeHealthyPeer(t *testing.T) {
+ alphaV1 := compileRouterPlugin(t, "route-fallback-alpha", "1.0.0", `[
+ {method: "GET", path: "/route-fallback/alpha", type: "dynamic"}
+ ]`)
+ betaV1 := compileRouterPlugin(t, "route-fallback-beta", "1.0.0", `[
+ {method: "GET", path: "/route-fallback/beta", type: "dynamic"}
+ ]`)
+ owner := compileRouterPlugin(t, "route-fallback-owner", "1.0.0", `[
+ {method: "GET", path: "/route-fallback/owner", type: "dynamic"}
+ ]`)
+ alphaV2 := compileRouterPlugin(t, "route-fallback-alpha", "2.0.0", `[
+ {method: "POST", path: "/route-fallback/owner", type: "submit"}
+ ]`)
+ betaV2 := compileRouterPlugin(t, "route-fallback-beta", "2.0.0", `[
+ {method: "GET", path: "/route-fallback/alpha", type: "dynamic"}
+ ]`)
+ handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
+ c.String(http.StatusOK, binding.Plugin.Meta.Key+"@"+binding.Plugin.Meta.Version)
+ })
+ outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{alphaV1, betaV1, owner}, handlers)
+ outer.NoRoute(
+ (&pluginRouteDispatcher{registry: registry}).dispatch,
+ func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
+ )
+
+ require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{alphaV2, betaV2, owner}))
+
+ _, alphaActive := registry.Get("route-fallback-alpha")
+ assert.False(t, alphaActive)
+ activeBeta, ok := registry.Get("route-fallback-beta")
+ require.True(t, ok)
+ assert.Same(t, betaV2, activeBeta)
+ assert.Equal(t, "route-fallback-beta@2.0.0", performPluginRequest(outer, http.MethodGet, "/route-fallback/alpha").Body.String())
+ assert.Contains(t, registry.RoutingErrors()["route-fallback-alpha"], "overlaps plugin route-fallback-owner")
+ assert.NotContains(t, registry.RoutingErrors(), "route-fallback-beta")
+}
+
+func TestPluginPathOwnershipIsExclusiveAcrossMethods(t *testing.T) {
+ alpha := compileRouterPlugin(t, "alpha-owner", "1.0.0", `[
+ {method: "GET", path: "/shared/jobs/:task_id", type: "query", render: "native"}
+ ]`)
+ beta := compileRouterPlugin(t, "beta-owner", "1.0.0", `[
+ {method: "POST", path: "/shared/jobs/:task_id", type: "submit"}
+ ]`)
+ handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
+ c.String(http.StatusOK, binding.Plugin.Meta.Key)
+ })
+ outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{beta, alpha}, handlers)
+ outer.NoRoute(
+ (&pluginRouteDispatcher{registry: registry}).dispatch,
+ func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
+ )
+
+ getResponse := performPluginRequest(outer, http.MethodGet, "/shared/jobs/task-1")
+ assert.Equal(t, "alpha-owner", getResponse.Body.String())
+ postResponse := performPluginRequest(outer, http.MethodPost, "/shared/jobs/task-1")
+ assert.Equal(t, http.StatusMethodNotAllowed, postResponse.Code)
+ assert.Contains(t, registry.RoutingErrors()["beta-owner"], "overlaps plugin alpha-owner")
+}
+
+func TestOnePluginMayOwnMultipleMethodsForSamePath(t *testing.T) {
+ plugin := compileRouterPlugin(t, "multi-method", "1.0.0", `[
+ {method: "GET", path: "/vendor/multi/:task_id", type: "query", render: "native"},
+ {method: "POST", path: "/vendor/multi/:task_id", type: "submit"}
+ ]`)
+ handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
+ c.String(http.StatusOK, c.Request.Method)
+ })
+ outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, handlers)
+ outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
+
+ assert.Equal(t, http.MethodGet, performPluginRequest(outer, http.MethodGet, "/vendor/multi/task-1").Body.String())
+ assert.Equal(t, http.MethodPost, performPluginRequest(outer, http.MethodPost, "/vendor/multi/task-1").Body.String())
+ assert.NotContains(t, registry.RoutingErrors(), "multi-method")
+}
+
+func TestGinWildcardNameConflictRejectsWholePlugin(t *testing.T) {
+ plugin := compileRouterPlugin(t, "wildcard-conflict", "1.0.0", `[
+ {method: "GET", path: "/vendor/:id/first", type: "query", render: "native", taskIdParam: "id"},
+ {method: "GET", path: "/vendor/:name/second", type: "query", render: "native", taskIdParam: "name"}
+ ]`)
+ outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, testPluginRouteHandlers(
+ func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
+ c.String(http.StatusOK, "plugin")
+ },
+ ))
+ outer.NoRoute(
+ (&pluginRouteDispatcher{registry: registry}).dispatch,
+ func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
+ )
+
+ assert.Equal(t, "fallback", performPluginRequest(outer, http.MethodGet, "/vendor/1/first").Body.String())
+ assert.Contains(t, registry.RoutingErrors()["wildcard-conflict"], "incompatible wildcard names")
+}
+
+func TestGinRegistrationPanicRebuildsWithoutOffender(t *testing.T) {
+ alpha := compileRouterPlugin(t, "panic-alpha", "1.0.0", `[
+ {method: "GET", path: "/panic/alpha", type: "dynamic"}
+ ]`)
+ beta := compileRouterPlugin(t, "panic-beta", "1.0.0", `[
+ {method: "GET", path: "/panic/beta", type: "dynamic"}
+ ]`)
+ registry := jsplugin.NewRegistry()
+ require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{alpha, beta}))
+ outer := newOuterPluginTestEngine()
+ builder := newPluginGenerationBuilder(outer.Routes(), nil, testPluginRouteHandlers(
+ func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
+ c.String(http.StatusOK, binding.Plugin.Meta.Key)
+ },
+ ))
+ normalRegister := builder.registerRoute
+ builder.registerRoute = func(engine *gin.Engine, binding jsplugin.RouteBinding, handlers []gin.HandlerFunc) {
+ if binding.Plugin.Meta.Key == "panic-beta" {
+ panic("registration failed")
+ }
+ normalRegister(engine, binding, handlers)
+ }
+ require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
+ outer.NoRoute(
+ (&pluginRouteDispatcher{registry: registry}).dispatch,
+ func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
+ )
+
+ assert.Equal(t, "panic-alpha", performPluginRequest(outer, http.MethodGet, "/panic/alpha").Body.String())
+ assert.Equal(t, "fallback", performPluginRequest(outer, http.MethodGet, "/panic/beta").Body.String())
+ assert.Contains(t, registry.RoutingErrors()["panic-beta"], "registration panic")
+}
+
+func TestGinRegistrationPanicReadmitsPluginBlockedByOffender(t *testing.T) {
+ alpha := compileRouterPlugin(t, "panic-owner-alpha", "1.0.0", `[
+ {method: "GET", path: "/panic/reconsider", type: "dynamic"}
+ ]`)
+ beta := compileRouterPlugin(t, "panic-owner-beta", "1.0.0", `[
+ {method: "POST", path: "/panic/reconsider", type: "submit"}
+ ]`)
+ registry := jsplugin.NewRegistry()
+ require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{alpha, beta}))
+ outer := newOuterPluginTestEngine()
+ builder := newPluginGenerationBuilder(outer.Routes(), nil, testPluginRouteHandlers(
+ func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
+ c.String(http.StatusOK, binding.Plugin.Meta.Key)
+ },
+ ))
+ normalRegister := builder.registerRoute
+ builder.registerRoute = func(engine *gin.Engine, binding jsplugin.RouteBinding, handlers []gin.HandlerFunc) {
+ if binding.Plugin.Meta.Key == "panic-owner-alpha" {
+ panic("registration failed")
+ }
+ normalRegister(engine, binding, handlers)
+ }
+ require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
+ outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
+
+ _, alphaActive := registry.Get("panic-owner-alpha")
+ assert.False(t, alphaActive)
+ activeBeta, ok := registry.Get("panic-owner-beta")
+ require.True(t, ok)
+ assert.Same(t, beta, activeBeta)
+ assert.Equal(t, "panic-owner-beta", performPluginRequest(outer, http.MethodPost, "/panic/reconsider").Body.String())
+ assert.Contains(t, registry.RoutingErrors()["panic-owner-alpha"], "registration panic")
+ assert.NotContains(t, registry.RoutingErrors(), "panic-owner-beta")
+}
+
+func TestUpdatedRegistrationPanicRestoresIncumbent(t *testing.T) {
+ v1 := compileRouterPlugin(t, "panic-update", "1.0.0", `[
+ {method: "GET", path: "/panic/stable", type: "dynamic"}
+ ]`)
+ v2 := compileRouterPlugin(t, "panic-update", "2.0.0", `[
+ {method: "GET", path: "/panic/stable", type: "dynamic"}
+ ]`)
+ registry := jsplugin.NewRegistry()
+ require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{v1}))
+ outer := newOuterPluginTestEngine()
+ handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
+ c.String(http.StatusOK, binding.Plugin.Meta.Version)
+ })
+ builder := newPluginGenerationBuilder(outer.Routes(), nil, handlers)
+ normalRegister := builder.registerRoute
+ builder.registerRoute = func(engine *gin.Engine, binding jsplugin.RouteBinding, routeHandlers []gin.HandlerFunc) {
+ if binding.Plugin.Meta.Version == "2.0.0" {
+ panic("new version registration failed")
+ }
+ normalRegister(engine, binding, routeHandlers)
+ }
+ require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
+ outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
+
+ require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{v2}))
+
+ active, ok := registry.Get("panic-update")
+ require.True(t, ok)
+ assert.Same(t, v1, active)
+ assert.Equal(t, "1.0.0", performPluginRequest(outer, http.MethodGet, "/panic/stable").Body.String())
+ assert.Contains(t, registry.RoutingErrors()["panic-update"], "registration panic")
+}
+
+func TestUnattributableRebuildFailureRetainsOldGeneration(t *testing.T) {
+ v1 := compileRouterPlugin(t, "rebuild-stable", "1.0.0", `[
+ {method: "GET", path: "/rebuild/version", type: "dynamic"}
+ ]`)
+ v2 := compileRouterPlugin(t, "rebuild-stable", "2.0.0", `[
+ {method: "GET", path: "/rebuild/version", type: "dynamic"}
+ ]`)
+ handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
+ c.String(http.StatusOK, binding.Plugin.Meta.Version)
+ })
+ outer := newOuterPluginTestEngine()
+ registry := jsplugin.NewRegistry()
+ require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{v1}))
+ builder := newPluginGenerationBuilder(outer.Routes(), nil, handlers)
+ require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
+ outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
+ before := registry.Generation()
+
+ normalConfigure := builder.configure
+ builder.configure = func(*gin.Engine) error { return errors.New("engine configuration failed") }
+ require.Error(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{v2}))
+ assert.Same(t, before, registry.Generation())
+
+ assert.Equal(t, "1.0.0", performPluginRequest(outer, http.MethodGet, "/rebuild/version").Body.String())
+ assert.Contains(t, registry.LastRebuildError(), "engine configuration failed")
+
+ builder.configure = normalConfigure
+ require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{v2}))
+ assert.Equal(t, "2.0.0", performPluginRequest(outer, http.MethodGet, "/rebuild/version").Body.String())
+}
+
+func TestPluginRouteRecoverySanitizesPanicResponse(t *testing.T) {
+ plugin := compileRouterPlugin(t, "panic-route", "1.0.0", `[
+ {method: "GET", path: "/vendor/panic", type: "dynamic"}
+ ]`)
+ var calls atomic.Int32
+ outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, testPluginRouteHandlers(
+ func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
+ if calls.Add(1) == 1 {
+ panic("https://secret.example/internal?token=credential")
+ }
+ c.String(http.StatusOK, "recovered")
+ },
+ ))
+ outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
+
+ recorder := performPluginRequest(outer, http.MethodGet, "/vendor/panic")
+
+ assert.Equal(t, http.StatusInternalServerError, recorder.Code)
+ assert.Contains(t, recorder.Body.String(), "internal plugin route error")
+ assert.NotContains(t, recorder.Body.String(), "secret.example")
+ assert.NotContains(t, recorder.Body.String(), "credential")
+
+ second := performPluginRequest(outer, http.MethodGet, "/vendor/panic")
+ assert.Equal(t, http.StatusOK, second.Code)
+ assert.Equal(t, "recovered", second.Body.String())
+}
+
+func TestProductionPluginRoutePipelineRequiresTokenAuth(t *testing.T) {
+ plugin := compileRouterPlugin(t, "forced-auth", "1.0.0", `[
+ {method: "GET", path: "/vendor/protected/:task_id", type: "query", render: "native"}
+ ]`)
+ outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, productionPluginRouteHandlers)
+ outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
+
+ recorder := performPluginRequest(outer, http.MethodGet, "/vendor/protected/task-1")
+
+ assert.NotEqual(t, http.StatusNotImplemented, recorder.Code)
+ assert.Contains(t, recorder.Body.String(), "error")
+}
+
+func TestProductionPluginNativeQueryTraversesInnerRouter(t *testing.T) {
+ previousDB := model.DB
+ database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, database.AutoMigrate(&model.Task{}))
+ model.DB = database
+ t.Cleanup(func() { model.DB = previousDB })
+ require.NoError(t, database.Create(&model.Task{
+ TaskID: "task_native_router",
+ Platform: constant.TaskPlatform("kling"),
+ UserId: 91,
+ Status: model.TaskStatusSuccess,
+ Progress: "100%",
+ ChannelId: 17,
+ PrivateData: model.TaskPrivateData{
+ UpstreamTaskID: "private_upstream_id",
+ ResultURL: "https://secret.example/video.mp4",
+ },
+ }).Error)
+
+ kling, found := jsplugin.DefaultRegistry.Get("kling")
+ require.True(t, found)
+ authenticatedProductionHandlers := func(
+ generation *jsplugin.RoutingGeneration,
+ binding jsplugin.RouteBinding,
+ ) []gin.HandlerFunc {
+ production := productionPluginRouteHandlers(generation, binding)
+ return []gin.HandlerFunc{
+ production[0],
+ func(c *gin.Context) {
+ common.SetContextKey(c, constant.ContextKeyUserId, 91)
+ common.SetContextKey(c, constant.ContextKeyUserGroup, "default")
+ common.SetContextKey(c, constant.ContextKeyTokenGroup, "default")
+ c.Next()
+ },
+ production[2],
+ production[3],
+ production[4],
+ production[5],
+ production[6],
+ }
+ }
+ outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{kling}, authenticatedProductionHandlers)
+ outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
+
+ request := httptest.NewRequest(http.MethodGet, "/kling/v1/videos/text2video/task_native_router", nil)
+ recorder := httptest.NewRecorder()
+ outer.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+ assert.Contains(t, recorder.Body.String(), `"task_id":"task_native_router"`)
+ assert.Contains(t, recorder.Body.String(), `"task_status":"succeed"`)
+ assert.NotContains(t, recorder.Body.String(), "private_upstream_id")
+ assert.NotContains(t, recorder.Body.String(), "secret.example")
+}
+
+func newPluginRouterTest(
+ t *testing.T,
+ plugins []*jsplugin.LoadedPlugin,
+ handlers pluginRouteHandlers,
+) (*gin.Engine, *jsplugin.Registry) {
+ t.Helper()
+ return newPluginRouterTestWithProxies(t, plugins, handlers, nil)
+}
+
+func newPluginRouterTestWithProxies(
+ t *testing.T,
+ plugins []*jsplugin.LoadedPlugin,
+ handlers pluginRouteHandlers,
+ trustedProxies []string,
+) (*gin.Engine, *jsplugin.Registry) {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+ outer := newOuterPluginTestEngine()
+ registry := jsplugin.NewRegistry()
+ if plugins != nil {
+ require.NoError(t, registry.ReplaceOverrides(plugins))
+ }
+ if handlers == nil {
+ handlers = testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
+ c.String(http.StatusOK, binding.Plugin.Meta.Key)
+ })
+ }
+ builder := newPluginGenerationBuilder(outer.Routes(), trustedProxies, handlers)
+ require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
+ return outer, registry
+}
+
+func newOuterPluginTestEngine() *gin.Engine {
+ outer := gin.New()
+ outer.Use(func(c *gin.Context) {
+ c.Set(common.RequestIdKey, "request-phase-two")
+ c.Set(string(constant.ContextKeyLanguage), "zh-CN")
+ c.Set(middleware.RouteTagKey, "before")
+ c.Next()
+ })
+ return outer
+}
+
+func testPluginRouteHandlers(
+ handler func(*gin.Context, *jsplugin.RoutingGeneration, jsplugin.RouteBinding),
+) pluginRouteHandlers {
+ return func(generation *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) []gin.HandlerFunc {
+ return []gin.HandlerFunc{func(c *gin.Context) {
+ pinnedGeneration := generation
+ if state, _ := c.Request.Context().Value(pluginDispatchStateKey{}).(*pluginDispatchState); state != nil && state.generation != nil {
+ pinnedGeneration = state.generation
+ }
+ c.Set(jsplugin.ContextKeyPinnedRoute, jsplugin.PinnedRoute{
+ Generation: pinnedGeneration,
+ Plugin: binding.Plugin,
+ Route: binding.Route,
+ })
+ handler(c, pinnedGeneration, binding)
+ }}
+ }
+}
+
+func compileRouterPlugin(t *testing.T, key, version, routes string) *jsplugin.LoadedPlugin {
+ t.Helper()
+ plugin, err := jsplugin.CompilePlugin(routerPluginSource(key, version, routes), jsplugin.Options{Key: key, Version: version})
+ require.NoError(t, err)
+ return plugin
+}
+
+func routerPluginSource(key, version, routes string) string {
+ return fmt.Sprintf(`
+export const meta = {
+ apiVersion: 1,
+ key: %q,
+ name: %q,
+ version: %q,
+ author: {name: "Test"},
+ models: ["model"],
+ fetchMode: "per_task",
+ routes: (%s).map(function(route) {
+ const migrated = Object.assign({}, route);
+ delete migrated.renderer;
+ migrated.render = route.render || route.renderer || "render";
+ if (route.type !== "query") migrated.decode = route.decode || "decode";
+ return migrated;
+ }),
+};
+export const native = {
+ decode: function(ctx) { return {kind: "submit", model: "model", requestBody: ctx.body.value}; },
+ render: function(ctx, task) { return task; },
+ native: function(ctx, task) { return task; },
+};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+`, key, key, version, routes)
+}
+
+func performPluginRequest(handler http.Handler, method, path string) *httptest.ResponseRecorder {
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(method, path, strings.NewReader(""))
+ handler.ServeHTTP(recorder, request)
+ return recorder
+}
diff --git a/router/relay-router.go b/router/relay-router.go
index b230a5a8084c..7cc7f741041e 100644
--- a/router/relay-router.go
+++ b/router/relay-router.go
@@ -98,9 +98,6 @@ func SetRelayRouter(router *gin.Engine) {
})
// response related routes
- httpRouter.POST("/responses", func(c *gin.Context) {
- controller.Relay(c, types.RelayFormatOpenAIResponses)
- })
httpRouter.POST("/responses/compact", func(c *gin.Context) {
controller.Relay(c, types.RelayFormatOpenAIResponsesCompaction)
})
@@ -181,16 +178,6 @@ func SetRelayRouter(router *gin.Engine) {
registerMjRouterGroup(relayMjModeRouter)
//relayMjRouter.Use()
- relaySunoRouter := router.Group("/suno")
- relaySunoRouter.Use(middleware.RouteTag("relay"))
- relaySunoRouter.Use(middleware.SystemPerformanceCheck())
- relaySunoRouter.Use(middleware.TokenAuth(), middleware.Distribute())
- {
- relaySunoRouter.POST("/submit/:action", controller.RelayTask)
- relaySunoRouter.POST("/fetch", controller.RelayTaskFetch)
- relaySunoRouter.GET("/fetch/:id", controller.RelayTaskFetch)
- }
-
relayGeminiRouter := router.Group("/v1beta")
relayGeminiRouter.Use(middleware.RouteTag("relay"))
relayGeminiRouter.Use(middleware.SystemPerformanceCheck())
diff --git a/router/retired_frontend_routes_test.go b/router/retired_frontend_routes_test.go
deleted file mode 100644
index 89514a644a08..000000000000
--- a/router/retired_frontend_routes_test.go
+++ /dev/null
@@ -1,26 +0,0 @@
-package router
-
-import (
- "net/http"
- "testing"
-
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/assert"
-)
-
-func TestRetiredFrontendAPIRoutes(t *testing.T) {
- gin.SetMode(gin.TestMode)
- engine := gin.New()
- SetApiRouter(engine)
-
- routes := make(map[string]struct{}, len(engine.Routes()))
- for _, route := range engine.Routes() {
- routes[route.Method+" "+route.Path] = struct{}{}
- }
- _, hasAsyncCleanup := routes[http.MethodPost+" /api/system-task/log-cleanup"]
- _, hasDirectDelete := routes[http.MethodDelete+" /api/log/"]
- _, hasConsoleMigration := routes[http.MethodPost+" /api/option/migrate_console_setting"]
- assert.True(t, hasAsyncCleanup)
- assert.False(t, hasDirectDelete)
- assert.False(t, hasConsoleMigration)
-}
diff --git a/router/task-plugin-protocol-router.go b/router/task-plugin-protocol-router.go
new file mode 100644
index 000000000000..6b52292ef19c
--- /dev/null
+++ b/router/task-plugin-protocol-router.go
@@ -0,0 +1,52 @@
+package router
+
+import (
+ "fmt"
+
+ "github.com/QuantumNous/new-api/controller"
+ "github.com/QuantumNous/new-api/middleware"
+ pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/QuantumNous/new-api/relaykit/types"
+ "github.com/gin-gonic/gin"
+)
+
+func SetTaskPluginProtocolRouter(router *gin.Engine) {
+ for _, protocol := range pluginruntime.HostProtocols() {
+ for _, operation := range protocol.Operations {
+ for _, method := range operation.Methods {
+ handlers, err := taskPluginProtocolHandlers(protocol.Name, operation.Name)
+ if err != nil {
+ panic(err)
+ }
+ router.Handle(method, operation.Path, handlers...)
+ }
+ }
+ }
+}
+
+func taskPluginProtocolHandlers(protocol, operation string) ([]gin.HandlerFunc, error) {
+ switch protocol + "." + operation {
+ case "openai_responses.create":
+ return []gin.HandlerFunc{
+ middleware.RouteTag("relay"), middleware.SystemPerformanceCheck(), middleware.TokenAuth(),
+ middleware.ModelRequestRateLimit(), middleware.PinTaskPluginEndpoint(), middleware.PrepareTaskPluginEndpoint(), middleware.Distribute(),
+ func(c *gin.Context) {
+ controller.RelayTaskPluginEndpoint(c, func(c *gin.Context) { controller.Relay(c, types.RelayFormatOpenAIResponses) })
+ },
+ }, nil
+ case "openai_video.create":
+ return []gin.HandlerFunc{
+ middleware.RouteTag("relay"), middleware.TokenAuth(), middleware.SystemPerformanceCheck(),
+ middleware.PinTaskPluginEndpoint(), middleware.TaskPluginEndpointOnly(middleware.ModelRequestRateLimit()), middleware.PrepareTaskPluginEndpoint(), middleware.Distribute(),
+ func(c *gin.Context) { controller.RelayTaskPluginEndpoint(c, controller.RelayTask) },
+ }, nil
+ case "openai_responses.retrieve":
+ return []gin.HandlerFunc{middleware.RouteTag("relay"), middleware.TokenAuth(), controller.RetrieveTaskPluginResponse}, nil
+ case "openai_video.retrieve":
+ return []gin.HandlerFunc{middleware.RouteTag("relay"), middleware.TokenAuth(), middleware.Distribute(), controller.RelayTaskFetch}, nil
+ case "openai_video.content":
+ return []gin.HandlerFunc{middleware.RouteTag("relay"), middleware.TokenAuth(), controller.VideoProxy}, nil
+ default:
+ return nil, fmt.Errorf("host protocol registry operation %s.%s has no handler", protocol, operation)
+ }
+}
diff --git a/router/task-router.go b/router/task-router.go
new file mode 100644
index 000000000000..6d6321536497
--- /dev/null
+++ b/router/task-router.go
@@ -0,0 +1,37 @@
+package router
+
+import (
+ "github.com/QuantumNous/new-api/controller"
+ "github.com/QuantumNous/new-api/middleware"
+ "github.com/gin-gonic/gin"
+)
+
+// SetTaskRouter registers the generic task-plugin API surface.
+//
+// Gin requires every route sharing a path position to use the same wildcard
+// name, so the first segment is uniformly ":key"; it carries the plugin key
+// on submit routes and the task id on read routes.
+func SetTaskRouter(router *gin.Engine) {
+ taskSubmitRouter := router.Group("/v1/tasks")
+ taskSubmitRouter.Use(middleware.RouteTag("relay"), middleware.TokenAuth())
+ {
+ taskSubmitRouter.POST("/:key", middleware.PrepareTaskPluginSubmit(), middleware.Distribute(), controller.RelayTask)
+ }
+
+ taskReadRouter := router.Group("/v1/tasks")
+ taskReadRouter.Use(middleware.RouteTag("relay"), middleware.TokenAuth())
+ {
+ taskReadRouter.GET("/:key", controller.GetTask)
+ taskReadRouter.GET("/:key/artifacts", controller.GetTaskArtifacts)
+ }
+
+ taskContentRouter := router.Group("/v1/tasks")
+ taskContentRouter.Use(
+ middleware.RouteTag("relay"),
+ middleware.TokenOrTaskArtifactAccessAuth("key", "artifact_key"),
+ )
+ {
+ taskContentRouter.GET("/:key/artifacts/:artifact_key/content", controller.TaskArtifactContent)
+ taskContentRouter.HEAD("/:key/artifacts/:artifact_key/content", controller.TaskArtifactContent)
+ }
+}
diff --git a/router/task_plugin_options_router_test.go b/router/task_plugin_options_router_test.go
new file mode 100644
index 000000000000..8cfbf0645a47
--- /dev/null
+++ b/router/task_plugin_options_router_test.go
@@ -0,0 +1,55 @@
+package router
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/controller"
+ "github.com/QuantumNous/new-api/middleware"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/service/authz"
+ "github.com/gin-gonic/gin"
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+func TestGetTaskPluginOptionsAdminForbiddenRootAllowed(t *testing.T) {
+ wasMaster := common.IsMasterNode
+ common.IsMasterNode = true
+ db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ require.NoError(t, err)
+ sqlDB, err := db.DB()
+ require.NoError(t, err)
+ sqlDB.SetMaxOpenConns(1)
+ require.NoError(t, db.AutoMigrate(&model.CasbinRule{}, &model.AuthzRole{}))
+ require.NoError(t, authz.Init(db))
+ t.Cleanup(func() { common.IsMasterNode = wasMaster })
+
+ gin.SetMode(gin.TestMode)
+ for _, testCase := range []struct {
+ name string
+ id int
+ role int
+ wantStatus int
+ }{
+ {name: "admin", id: 2, role: common.RoleAdminUser, wantStatus: http.StatusForbidden},
+ {name: "root", id: 1, role: common.RoleRootUser, wantStatus: http.StatusOK},
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Request = httptest.NewRequest(http.MethodGet, "/api/task_plugin_options", nil)
+ context.Set("id", testCase.id)
+ context.Set("role", testCase.role)
+ middleware.RequirePermission(authz.TaskPluginBind)(context)
+ if !context.IsAborted() {
+ controller.GetTaskPluginOptions(context)
+ }
+ assert.Equal(t, testCase.wantStatus, recorder.Code)
+ })
+ }
+}
diff --git a/router/task_plugin_protocol_router_test.go b/router/task_plugin_protocol_router_test.go
new file mode 100644
index 000000000000..811c6415e1de
--- /dev/null
+++ b/router/task_plugin_protocol_router_test.go
@@ -0,0 +1,31 @@
+package router
+
+import (
+ "fmt"
+ "sort"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestHostProtocolRegistryDrivesProtocolRoutesOnce(t *testing.T) {
+ engine := gin.New()
+ SetTaskPluginProtocolRouter(engine)
+
+ expected := []string{
+ "POST /v1/responses",
+ "GET /v1/responses/:response_id",
+ "POST /v1/videos",
+ "GET /v1/videos/:task_id",
+ "GET /v1/videos/:task_id/content",
+ "HEAD /v1/videos/:task_id/content",
+ }
+ actual := make([]string, 0, len(engine.Routes()))
+ for _, route := range engine.Routes() {
+ actual = append(actual, fmt.Sprintf("%s %s", route.Method, route.Path))
+ }
+ sort.Strings(expected)
+ sort.Strings(actual)
+ assert.Equal(t, expected, actual)
+}
diff --git a/router/task_router_test.go b/router/task_router_test.go
new file mode 100644
index 000000000000..986b9b49f3dd
--- /dev/null
+++ b/router/task_router_test.go
@@ -0,0 +1,52 @@
+/*
+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
+*/
+package router
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// Gin panics at registration time when routes sharing a path position use
+// different wildcard names, which unit tests that build their own routers
+// never catch. Registering against a real engine is the only guard.
+func TestSetTaskRouterRegistersWithoutConflict(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ engine := gin.New()
+ require.NotPanics(t, func() { SetTaskRouter(engine) })
+
+ routes := engine.Routes()
+ require.Len(t, routes, 5)
+ actual := make(map[string]struct{}, len(routes))
+ for _, route := range routes {
+ actual[route.Method+" "+route.Path] = struct{}{}
+ }
+ assert.Contains(t, actual, http.MethodPost+" /v1/tasks/:key")
+ assert.Contains(t, actual, http.MethodGet+" /v1/tasks/:key")
+ assert.Contains(t, actual, http.MethodGet+" /v1/tasks/:key/artifacts")
+ assert.Contains(t, actual, http.MethodGet+" /v1/tasks/:key/artifacts/:artifact_key/content")
+ assert.Contains(t, actual, http.MethodHead+" /v1/tasks/:key/artifacts/:artifact_key/content")
+ for route := range actual {
+ assert.NotContains(t, route, "/native/")
+ }
+}
diff --git a/router/video-router.go b/router/video-router.go
index 461451104520..3bd007c0ecd3 100644
--- a/router/video-router.go
+++ b/router/video-router.go
@@ -8,45 +8,26 @@ import (
)
func SetVideoRouter(router *gin.Engine) {
- // Video proxy: accepts either session auth (dashboard) or token auth (API clients)
- videoProxyRouter := router.Group("/v1")
- videoProxyRouter.Use(middleware.RouteTag("relay"))
- videoProxyRouter.Use(middleware.TokenOrUserAuth())
- {
- videoProxyRouter.GET("/videos/:task_id/content", controller.VideoProxy)
- }
+ videoSharedRouter := router.Group("/v1")
+ videoSharedRouter.Use(middleware.RouteTag("relay"))
+ videoSharedRouter.Use(middleware.TokenAuth())
+ videoSharedRouter.Use(middleware.SystemPerformanceCheck())
+ videoSharedRouter.POST(
+ "/video/generations",
+ middleware.PinTaskPluginEndpoint(),
+ middleware.TaskPluginEndpointOnly(middleware.ModelRequestRateLimit()),
+ middleware.PrepareTaskPluginEndpoint(),
+ middleware.Distribute(),
+ func(c *gin.Context) {
+ controller.RelayTaskPluginEndpoint(c, controller.RelayTask)
+ },
+ )
videoV1Router := router.Group("/v1")
videoV1Router.Use(middleware.RouteTag("relay"))
videoV1Router.Use(middleware.TokenAuth(), middleware.Distribute())
{
- videoV1Router.POST("/video/generations", controller.RelayTask)
videoV1Router.GET("/video/generations/:task_id", controller.RelayTaskFetch)
videoV1Router.POST("/videos/:video_id/remix", controller.RelayTask)
}
- // openai compatible API video routes
- // docs: https://platform.openai.com/docs/api-reference/videos/create
- {
- videoV1Router.POST("/videos", controller.RelayTask)
- videoV1Router.GET("/videos/:task_id", controller.RelayTaskFetch)
- }
-
- klingV1Router := router.Group("/kling/v1")
- klingV1Router.Use(middleware.RouteTag("relay"))
- klingV1Router.Use(middleware.KlingRequestConvert(), middleware.TokenAuth(), middleware.Distribute())
- {
- klingV1Router.POST("/videos/text2video", controller.RelayTask)
- klingV1Router.POST("/videos/image2video", controller.RelayTask)
- klingV1Router.GET("/videos/text2video/:task_id", controller.RelayTaskFetch)
- klingV1Router.GET("/videos/image2video/:task_id", controller.RelayTaskFetch)
- }
-
- // Jimeng official API routes - direct mapping to official API format
- jimengOfficialGroup := router.Group("jimeng")
- jimengOfficialGroup.Use(middleware.RouteTag("relay"))
- jimengOfficialGroup.Use(middleware.JimengRequestConvert(), middleware.TokenAuth(), middleware.Distribute())
- {
- // Maps to: /?Action=CVSync2AsyncSubmitTask&Version=2022-08-31 and /?Action=CVSync2AsyncGetResult&Version=2022-08-31
- jimengOfficialGroup.POST("/", controller.RelayTask)
- }
}
diff --git a/router/video_router_test.go b/router/video_router_test.go
new file mode 100644
index 000000000000..4f1ae31e7683
--- /dev/null
+++ b/router/video_router_test.go
@@ -0,0 +1,151 @@
+package router
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetOpenAIVideoRouteRendersJimengTask(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ previousDB := model.DB
+ previousDatabaseType := common.MainDatabaseType()
+ previousLogDatabaseType := common.LogDatabaseType()
+ previousSQLitePath := common.SQLitePath
+ previousMasterNode := common.IsMasterNode
+ previousRedisEnabled := common.RedisEnabled
+ common.SQLitePath = t.TempDir() + "/router-video.db"
+ common.IsMasterNode = false
+ common.RedisEnabled = false
+ t.Setenv("SQL_DSN", "")
+ require.NoError(t, model.InitDB())
+ database := model.DB
+ require.NoError(t, database.AutoMigrate(&model.User{}, &model.Token{}, &model.Channel{}, &model.Task{}))
+ t.Cleanup(func() {
+ sqlDB, closeErr := database.DB()
+ require.NoError(t, closeErr)
+ require.NoError(t, sqlDB.Close())
+ model.DB = previousDB
+ common.SetDatabaseTypes(previousDatabaseType, previousLogDatabaseType)
+ common.SQLitePath = previousSQLitePath
+ common.IsMasterNode = previousMasterNode
+ common.RedisEnabled = previousRedisEnabled
+ })
+
+ require.NoError(t, database.Create(&model.User{
+ Id: 91,
+ Username: "jimeng-fetch-user",
+ Role: common.RoleCommonUser,
+ Status: common.UserStatusEnabled,
+ Quota: 100,
+ Group: "default",
+ AuthVersion: 1,
+ }).Error)
+ require.NoError(t, database.Create(&model.Token{
+ Id: 1,
+ UserId: 91,
+ Key: "jimengfetch",
+ Status: common.TokenStatusEnabled,
+ Name: "jimeng fetch",
+ ExpiredTime: -1,
+ UnlimitedQuota: true,
+ }).Error)
+ require.NoError(t, database.Create(&model.Channel{
+ Id: 17,
+ Type: constant.ChannelTypeJimeng,
+ Key: "unused",
+ Status: common.ChannelStatusEnabled,
+ Name: "jimeng fetch",
+ Models: "jimeng_vgfm_t2v_l20",
+ Group: "default",
+ }).Error)
+
+ task := &model.Task{
+ CreatedAt: 1710000000,
+ UpdatedAt: 1710000060,
+ TaskID: "task_jimeng_public",
+ Platform: constant.TaskPlatform("jimeng"),
+ UserId: 91,
+ Group: "default",
+ ChannelId: 17,
+ Status: model.TaskStatusSuccess,
+ Progress: "100%",
+ PrivateData: model.TaskPrivateData{
+ ResultURL: "data:video/mp4;base64,ZGF0YQ==",
+ },
+ }
+ task.SetData(map[string]any{
+ "code": 10000,
+ "data": map[string]any{
+ "status": "done",
+ "task_id": "jimeng-private-1",
+ "video_url": "https://cdn.example/video.mp4",
+ },
+ "message": "success",
+ })
+ require.NoError(t, database.Create(task).Error)
+
+ engine := gin.New()
+ SetVideoRouter(engine)
+ SetTaskPluginProtocolRouter(engine)
+ request := httptest.NewRequest(http.MethodGet, "/v1/videos/task_jimeng_public", nil)
+ request.Header.Set("Authorization", "Bearer sk-jimengfetch")
+ recorder := httptest.NewRecorder()
+
+ engine.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+ var response struct {
+ ID string `json:"id"`
+ Object string `json:"object"`
+ Status string `json:"status"`
+ Progress int `json:"progress"`
+ CreatedAt int64 `json:"created_at"`
+ CompletedAt int64 `json:"completed_at"`
+ }
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.Equal(t, "task_jimeng_public", response.ID)
+ assert.Equal(t, "video", response.Object)
+ assert.Equal(t, "completed", response.Status)
+ assert.Equal(t, 100, response.Progress)
+ assert.Equal(t, int64(1710000000), response.CreatedAt)
+ assert.Equal(t, int64(1710000060), response.CompletedAt)
+ assert.NotContains(t, recorder.Body.String(), "cdn.example")
+ assert.NotContains(t, recorder.Body.String(), "jimeng-private-1")
+
+ for _, testCase := range []struct {
+ name string
+ authorization string
+ query string
+ wantStatus int
+ }{
+ {name: "missing credential rejected", wantStatus: http.StatusUnauthorized},
+ {name: "access rejected", query: "?access=not-a-video-credential", wantStatus: http.StatusUnauthorized},
+ {name: "bearer accepted", authorization: "Bearer sk-jimengfetch", wantStatus: http.StatusOK},
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ request := httptest.NewRequest(
+ http.MethodGet,
+ "/v1/videos/task_jimeng_public/content"+testCase.query,
+ nil,
+ )
+ if testCase.authorization != "" {
+ request.Header.Set("Authorization", testCase.authorization)
+ }
+ recorder := httptest.NewRecorder()
+ engine.ServeHTTP(recorder, request)
+ assert.Equal(t, testCase.wantStatus, recorder.Code, recorder.Body.String())
+ if testCase.wantStatus == http.StatusOK {
+ assert.Equal(t, "data", recorder.Body.String())
+ }
+ })
+ }
+}
diff --git a/router/web-router.go b/router/web-router.go
index 83c91d8fe155..dbfadf215297 100644
--- a/router/web-router.go
+++ b/router/web-router.go
@@ -19,20 +19,23 @@ type WebAssets struct {
IndexPage []byte
}
-func SetWebRouter(router *gin.Engine, assets WebAssets) {
+func SetWebRouter(router *gin.Engine, assets WebAssets, pluginDispatcher gin.HandlerFunc) {
frontendFS := common.EmbedFolder(assets.BuildFS, "web/dist")
- router.Use(gzip.Gzip(gzip.DefaultCompression))
- router.Use(middleware.GlobalWebRateLimit())
- router.Use(middleware.Cache())
- router.Use(static.Serve("/", frontendFS))
- router.NoRoute(func(c *gin.Context) {
- c.Set(middleware.RouteTagKey, "web")
- if strings.HasPrefix(c.Request.RequestURI, "/v1") || strings.HasPrefix(c.Request.RequestURI, "/api") || strings.HasPrefix(c.Request.RequestURI, "/assets") {
- controller.RelayNotFound(c)
- return
- }
- c.Header("Cache-Control", "no-cache")
- c.Data(http.StatusOK, "text/html; charset=utf-8", assets.IndexPage)
- })
+ router.NoRoute(
+ pluginDispatcher,
+ middleware.RouteTag("web"),
+ gzip.Gzip(gzip.DefaultCompression),
+ middleware.GlobalWebRateLimit(),
+ middleware.Cache(),
+ static.Serve("/", frontendFS),
+ func(c *gin.Context) {
+ if strings.HasPrefix(c.Request.RequestURI, "/v1") || strings.HasPrefix(c.Request.RequestURI, "/api") || strings.HasPrefix(c.Request.RequestURI, "/assets") {
+ controller.RelayNotFound(c)
+ return
+ }
+ c.Header("Cache-Control", "no-cache")
+ c.Data(http.StatusOK, "text/html; charset=utf-8", assets.IndexPage)
+ },
+ )
}
diff --git a/service/authz/authz_test.go b/service/authz/authz_test.go
index eda3f4add2e9..1c4b8cdafa48 100644
--- a/service/authz/authz_test.go
+++ b/service/authz/authz_test.go
@@ -105,6 +105,9 @@ func TestSetUserPermissionsStoresOnlyOverrides(t *testing.T) {
ActionSensitiveWrite: true,
ActionSecretView: false,
},
+ ResourceTaskPlugin: {
+ ActionBind: false,
+ },
}, ExplicitUserPermissions(42))
assert.Equal(t, PermissionsMap{
ResourceChannel: {
@@ -133,6 +136,9 @@ func TestSetUserPermissionsStoresOnlyOverrides(t *testing.T) {
ActionSensitiveWrite: false,
ActionSecretView: false,
},
+ ResourceTaskPlugin: {
+ ActionBind: false,
+ },
}, ExplicitUserPermissions(42))
assert.Empty(t, ExplicitUserOverrides(42))
}
@@ -226,4 +232,40 @@ func TestCapabilitiesUseCatalogShape(t *testing.T) {
assert.True(t, capabilities[ResourceChannel][ActionWrite])
assert.False(t, capabilities[ResourceChannel][ActionSensitiveWrite])
assert.False(t, capabilities[ResourceChannel][ActionSecretView])
+ assert.False(t, capabilities[ResourceTaskPlugin][ActionBind])
+}
+
+func TestTaskPluginBindIsRootOnlyUntilGranted(t *testing.T) {
+ db := newAuthzTestDB(t)
+ require.NoError(t, Init(db))
+
+ var bindAction *ActionDefinition
+ for _, resource := range Catalog() {
+ if resource.Resource != ResourceTaskPlugin {
+ continue
+ }
+ assert.Equal(t, "Task Plugin", resource.LabelKey)
+ for i := range resource.Actions {
+ if resource.Actions[i].Action == ActionBind {
+ bindAction = &resource.Actions[i]
+ }
+ }
+ }
+ require.NotNil(t, bindAction)
+ assert.Equal(t, "Bind task plugins", bindAction.LabelKey)
+ assert.Equal(t, "List registered task plugins and bind them when creating or editing task plugin channels.", bindAction.DescriptionKey)
+ assert.Empty(t, bindAction.DefaultRoles)
+
+ assert.False(t, Can(2, common.RoleAdminUser, TaskPluginBind))
+ assert.True(t, Can(1, common.RoleRootUser, TaskPluginBind))
+
+ enforcer := currentEnforcer()
+ require.NotNil(t, enforcer)
+ _, err := enforcer.AddPolicy(RoleSubject(BuiltInRoleAdmin), ResourceTaskPlugin, ActionBind, EffectAllow)
+ require.NoError(t, err)
+ assert.True(t, Can(2, common.RoleAdminUser, TaskPluginBind))
+
+ _, err = enforcer.RemovePolicy(RoleSubject(BuiltInRoleAdmin), ResourceTaskPlugin, ActionBind, EffectAllow)
+ require.NoError(t, err)
+ assert.False(t, Can(2, common.RoleAdminUser, TaskPluginBind))
}
diff --git a/service/authz/resources_task_plugin.go b/service/authz/resources_task_plugin.go
new file mode 100644
index 000000000000..b5c6890f980e
--- /dev/null
+++ b/service/authz/resources_task_plugin.go
@@ -0,0 +1,23 @@
+package authz
+
+const (
+ ResourceTaskPlugin = "task_plugin"
+
+ ActionBind = "bind"
+)
+
+var TaskPluginBind = Permission{Resource: ResourceTaskPlugin, Action: ActionBind}
+
+func init() {
+ RegisterResource(ResourceDefinition{
+ Resource: ResourceTaskPlugin,
+ LabelKey: "Task Plugin",
+ Actions: []ActionDefinition{
+ {
+ Action: ActionBind,
+ LabelKey: "Bind task plugins",
+ DescriptionKey: "List registered task plugins and bind them when creating or editing task plugin channels.",
+ },
+ },
+ })
+}
diff --git a/service/channel_select.go b/service/channel_select.go
index 0ab88dc84ff2..d37d4f6d31e0 100644
--- a/service/channel_select.go
+++ b/service/channel_select.go
@@ -5,11 +5,36 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/gin-gonic/gin"
)
+func GetChannelConstraints(c *gin.Context) *dto.ChannelConstraints {
+ if c == nil {
+ return &dto.ChannelConstraints{}
+ }
+ if existing, ok := common.GetContextKeyType[*dto.ChannelConstraints](c, constant.ContextKeyChannelConstraints); ok && existing != nil {
+ return existing
+ }
+ constraints := &dto.ChannelConstraints{}
+ common.SetContextKey(c, constant.ContextKeyChannelConstraints, constraints)
+ return constraints
+}
+
+func AppendTaskPluginIdentityFilter(c *gin.Context, pluginKey string) {
+ if c == nil {
+ return
+ }
+ GetChannelConstraints(c).AddFilter(dto.ChannelFilter{
+ Kind: dto.FilterTaskPluginIdentity,
+ TaskPluginKey: pluginKey,
+ TaskPluginChannelTypes: pinnedTaskPluginChannelTypes(c, pluginKey),
+ })
+}
+
type RetryParam struct {
Ctx *gin.Context
TokenGroup string
@@ -85,6 +110,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
var err error
selectGroup := param.TokenGroup
userGroup := common.GetContextKeyString(param.Ctx, constant.ContextKeyUserGroup)
+ filters := GetChannelConstraints(param.Ctx).Filters
if param.TokenGroup == "auto" {
autoGroups := GetRequestAutoGroups(param.Ctx, userGroup)
@@ -115,7 +141,12 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
}
logger.LogDebug(param.Ctx, "Auto selecting group: %s, priorityRetry: %d", autoGroup, priorityRetry)
- channel, _ = model.GetRandomSatisfiedChannel(autoGroup, param.ModelName, priorityRetry, param.RequestPath)
+ channel, _ = model.GetRandomSatisfiedChannel(
+ autoGroup,
+ param.ModelName,
+ priorityRetry,
+ filters,
+ )
if channel == nil {
// Current group has no available channel for this model, try next group
// 当前分组没有该模型的可用渠道,尝试下一个分组
@@ -153,10 +184,68 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
break
}
} else {
- channel, err = model.GetRandomSatisfiedChannel(param.TokenGroup, param.ModelName, param.GetRetry(), param.RequestPath)
+ channel, err = model.GetRandomSatisfiedChannel(
+ param.TokenGroup,
+ param.ModelName,
+ param.GetRetry(),
+ filters,
+ )
if err != nil {
return nil, param.TokenGroup, err
}
}
return channel, selectGroup, nil
}
+
+func pinnedTaskPluginChannelTypes(c *gin.Context, expected string) []int {
+ if c == nil || expected == "" {
+ return nil
+ }
+ if value, exists := c.Get(jsplugin.ContextKeyPinnedEndpoint); exists {
+ pinned, ok := value.(jsplugin.PinnedEndpoint)
+ if ok && pinned.Generation != nil && len(pinned.Candidates) > 1 {
+ expectedFound := false
+ channelTypes := make([]int, 0, len(pinned.Candidates))
+ seen := make(map[int]struct{}, len(pinned.Candidates))
+ for _, candidate := range pinned.Candidates {
+ if candidate.Plugin == nil {
+ continue
+ }
+ if candidate.Plugin.Meta.Key == expected {
+ expectedFound = true
+ }
+ for _, channelType := range candidate.Plugin.Meta.ChannelTypes {
+ if channelType == 0 || channelType == constant.ChannelTypeTaskPlugin {
+ continue
+ }
+ if _, duplicate := seen[channelType]; duplicate {
+ continue
+ }
+ if plugin, indexed := pinned.Generation.GetByChannelType(channelType); indexed && plugin == candidate.Plugin {
+ seen[channelType] = struct{}{}
+ channelTypes = append(channelTypes, channelType)
+ }
+ }
+ }
+ if expectedFound {
+ return channelTypes
+ }
+ }
+ }
+ value, exists := c.Get(jsplugin.ContextKeyPinnedPlugin)
+ pinned, ok := value.(jsplugin.PinnedPlugin)
+ if !exists || !ok || pinned.Generation == nil || pinned.Plugin == nil || pinned.Plugin.Meta.Key != expected {
+ return nil
+ }
+ channelTypes := make([]int, 0, len(pinned.Plugin.Meta.ChannelTypes))
+ for _, channelType := range pinned.Plugin.Meta.ChannelTypes {
+ if channelType == 0 || channelType == constant.ChannelTypeTaskPlugin {
+ continue
+ }
+ channelTypes = append(channelTypes, channelType)
+ }
+ if len(channelTypes) == 0 {
+ return nil
+ }
+ return channelTypes
+}
diff --git a/service/channel_select_test.go b/service/channel_select_test.go
new file mode 100644
index 000000000000..0cbb36f06424
--- /dev/null
+++ b/service/channel_select_test.go
@@ -0,0 +1,152 @@
+package service
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPinnedTaskPluginChannelTypesUsesPinnedGenerationIndex(t *testing.T) {
+ registry := jsplugin.NewRegistry()
+ plugin, err := registry.Register(channelSelectTaskPluginSource("legacy-select", constant.ChannelTypeKling), jsplugin.Options{})
+ require.NoError(t, err)
+
+ c, _ := gin.CreateTestContext(nil)
+ c.Set(jsplugin.ContextKeyPinnedPlugin, jsplugin.PinnedPlugin{
+ Generation: registry.Generation(),
+ Plugin: plugin,
+ })
+
+ assert.Equal(t, []int{constant.ChannelTypeKling}, pinnedTaskPluginChannelTypes(c, "legacy-select"))
+ assert.Empty(t, pinnedTaskPluginChannelTypes(c, "another-plugin"))
+ assert.Empty(t, pinnedTaskPluginChannelTypes(nil, "legacy-select"))
+}
+
+func TestPinnedTaskPluginChannelTypesLeavesGenericChannelsKeyed(t *testing.T) {
+ registry := jsplugin.NewRegistry()
+ plugin, err := registry.Register(channelSelectTaskPluginSource("generic-select", constant.ChannelTypeTaskPlugin), jsplugin.Options{})
+ require.NoError(t, err)
+
+ c, _ := gin.CreateTestContext(nil)
+ c.Set(jsplugin.ContextKeyPinnedPlugin, jsplugin.PinnedPlugin{
+ Generation: registry.Generation(),
+ Plugin: plugin,
+ })
+
+ assert.Empty(t, pinnedTaskPluginChannelTypes(c, "generic-select"))
+}
+
+func TestPinnedTaskPluginChannelTypesIncludesSharedEndpointProviders(t *testing.T) {
+ registry := jsplugin.NewRegistry()
+ _, err := registry.Register(channelSelectEndpointPluginSource("gemini-select", constant.ChannelTypeGemini), jsplugin.Options{})
+ require.NoError(t, err)
+ _, err = registry.Register(channelSelectEndpointPluginSource("vertex-select", constant.ChannelTypeVertexAi), jsplugin.Options{})
+ require.NoError(t, err)
+ candidates := registry.Generation().LookupEndpointCandidates("POST", "/v1/responses", "task-model")
+ require.Len(t, candidates, 2)
+
+ c, _ := gin.CreateTestContext(nil)
+ c.Set(jsplugin.ContextKeyPinnedPlugin, jsplugin.PinnedPlugin{
+ Generation: registry.Generation(),
+ Plugin: candidates[0].Plugin,
+ })
+ c.Set(jsplugin.ContextKeyPinnedEndpoint, jsplugin.PinnedEndpoint{
+ Generation: registry.Generation(),
+ Plugin: candidates[0].Plugin,
+ Protocol: candidates[0].Protocol,
+ Operation: candidates[0].Operation,
+ Model: "task-model",
+ Candidates: candidates,
+ })
+
+ assert.Equal(t, []int{constant.ChannelTypeGemini, constant.ChannelTypeVertexAi}, pinnedTaskPluginChannelTypes(c, candidates[0].Plugin.Meta.Key))
+}
+
+func channelSelectTaskPluginSource(key string, channelType int) string {
+ return fmt.Sprintf(`
+export const meta = {
+ apiVersion: 1,
+ key: %q,
+ name: %q,
+ version: "1.0.0",
+ author: {name: "Test"},
+ %s
+ models: ["task-model"],
+ fetchMode: "per_task",
+};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {taskId: "task"}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+`, key, key, channelSelectChannelTypesField(channelType))
+}
+
+func channelSelectEndpointPluginSource(key string, channelType int) string {
+ return fmt.Sprintf(`
+export const meta = {
+ apiVersion: 1,
+ key: %q,
+ name: %q,
+ version: "1.0.0",
+ author: {name: "Test"},
+ %s
+ models: ["task-model"],
+ fetchMode: "per_task",
+ protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],
+};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {taskId: "task"}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+export const protocols = {openai_responses: {
+ decodeRequest: function(ctx) { return {kind: "submit", model: "task-model", requestBody: ctx.body.value}; },
+ renderEvents: function() { return {events: [], state: null, done: false}; },
+ renderFinal: function() { return {output: []}; },
+}};
+`, key, key, channelSelectChannelTypesField(channelType))
+}
+
+func channelSelectChannelTypesField(channelType int) string {
+ if channelType <= 0 || channelType == constant.ChannelTypeTaskPlugin {
+ return ""
+ }
+ return fmt.Sprintf("channelTypes: [%d],", channelType)
+}
+
+func TestPinnedTaskPluginChannelTypesIncludesCompatibleTypes(t *testing.T) {
+ registry := jsplugin.NewRegistry()
+ plugin, err := registry.Register(channelSelectCompatiblePluginSource("sora-select", constant.ChannelTypeSora, constant.ChannelTypeOpenAI), jsplugin.Options{})
+ require.NoError(t, err)
+
+ c, _ := gin.CreateTestContext(nil)
+ c.Set(jsplugin.ContextKeyPinnedPlugin, jsplugin.PinnedPlugin{
+ Generation: registry.Generation(),
+ Plugin: plugin,
+ })
+
+ assert.Equal(t, []int{constant.ChannelTypeSora, constant.ChannelTypeOpenAI}, pinnedTaskPluginChannelTypes(c, "sora-select"))
+}
+
+func channelSelectCompatiblePluginSource(key string, channelType, compatibleType int) string {
+ return fmt.Sprintf(`
+export const meta = {
+ apiVersion: 1,
+ key: %q,
+ name: %q,
+ version: "1.0.0",
+ author: {name: "Test"},
+ channelTypes: [%d, %d],
+ models: ["task-model"],
+ fetchMode: "per_task",
+};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {taskId: "task"}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {status: "SUCCESS"}; }
+`, key, key, channelType, compatibleType)
+}
diff --git a/service/codex_channel_models.go b/service/codex_channel_models.go
index 99c0645ecfa1..d90c8b16d517 100644
--- a/service/codex_channel_models.go
+++ b/service/codex_channel_models.go
@@ -33,7 +33,7 @@ func FetchCodexChannelModels(channel *model.Channel) ([]string, error) {
baseURL := channel.GetBaseURL()
if baseURL == "" {
- baseURL = constant.ChannelBaseURLs[constant.ChannelTypeCodex]
+ baseURL = constant.GetChannelBaseURL(constant.ChannelTypeCodex)
}
return fetchCodexChannelModels(ctx, channel, baseURL, client, clientVersion)
}
diff --git a/service/task_artifact_access.go b/service/task_artifact_access.go
new file mode 100644
index 000000000000..c5ba1542517a
--- /dev/null
+++ b/service/task_artifact_access.go
@@ -0,0 +1,137 @@
+package service
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "net/url"
+ "strings"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/setting/system_setting"
+)
+
+const (
+ TaskArtifactAccessQueryParameter = "access"
+ taskArtifactAccessVersion = "v1"
+ taskArtifactAccessLength = 43
+ maxTaskArtifactTaskIDLength = 191
+ maxTaskArtifactKeyLength = 128
+)
+
+var ErrTaskArtifactAccessInvalid = errors.New("task artifact access is invalid")
+
+func taskArtifactAccessMessage(taskID, artifactKey string) []byte {
+ return []byte(taskArtifactAccessVersion + "\x00" + taskID + "\x00" + artifactKey)
+}
+
+// IssueTaskArtifactAccess creates a stable capability bound to exactly one
+// public task ID and artifact key. It contains no user or upstream data.
+func IssueTaskArtifactAccess(taskID, artifactKey string) (string, error) {
+ taskID = strings.TrimSpace(taskID)
+ artifactKey = strings.TrimSpace(artifactKey)
+ if taskID == "" || len(taskID) > maxTaskArtifactTaskIDLength ||
+ artifactKey == "" || len(artifactKey) > maxTaskArtifactKeyLength ||
+ common.CryptoSecret == "" {
+ return "", ErrTaskArtifactAccessInvalid
+ }
+
+ mac := hmac.New(sha256.New, []byte(common.CryptoSecret))
+ _, _ = mac.Write(taskArtifactAccessMessage(taskID, artifactKey))
+ return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
+}
+
+// VerifyTaskArtifactAccess verifies the route binding without reading task,
+// user, or token state. Signature comparison is constant-time.
+func VerifyTaskArtifactAccess(access, taskID, artifactKey string) bool {
+ taskID = strings.TrimSpace(taskID)
+ artifactKey = strings.TrimSpace(artifactKey)
+ if len(access) != taskArtifactAccessLength ||
+ taskID == "" || len(taskID) > maxTaskArtifactTaskIDLength ||
+ artifactKey == "" || len(artifactKey) > maxTaskArtifactKeyLength ||
+ common.CryptoSecret == "" {
+ return false
+ }
+
+ actualSignature, err := base64.RawURLEncoding.Strict().DecodeString(access)
+ if err != nil || len(actualSignature) != sha256.Size {
+ return false
+ }
+
+ mac := hmac.New(sha256.New, []byte(common.CryptoSecret))
+ _, _ = mac.Write(taskArtifactAccessMessage(taskID, artifactKey))
+ return hmac.Equal(actualSignature, mac.Sum(nil))
+}
+
+// ValidateTaskArtifactBaseURL validates configuration syntax only. It
+// deliberately performs no DNS lookup or reachability probe.
+func ValidateTaskArtifactBaseURL(raw string) error {
+ trimmed := strings.TrimSpace(raw)
+ if trimmed == "" {
+ return errors.New("task artifact base URL is empty")
+ }
+ if raw != trimmed {
+ return errors.New("task artifact base URL must not contain surrounding whitespace")
+ }
+ raw = trimmed
+ parsed, err := url.Parse(raw)
+ if err != nil || parsed == nil {
+ return errors.New("task artifact base URL is invalid")
+ }
+ if !strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https") {
+ return errors.New("task artifact base URL must use http or https")
+ }
+ if parsed.Host == "" || parsed.User != nil || parsed.Opaque != "" {
+ return errors.New("task artifact base URL must contain a host and no userinfo")
+ }
+ if parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || strings.Contains(raw, "#") {
+ return errors.New("task artifact base URL must not contain a query or fragment")
+ }
+ return nil
+}
+
+// BuildTaskArtifactContentURL returns an absolute, long-lived capability URL.
+// TaskPublicAddress wins when configured; ServerAddress is the only fallback.
+// Request Host headers are intentionally not involved.
+func BuildTaskArtifactContentURL(taskID, artifactKey string) (string, error) {
+ taskID = strings.TrimSpace(taskID)
+ artifactKey = strings.TrimSpace(artifactKey)
+ if taskID == "" || len(taskID) > maxTaskArtifactTaskIDLength ||
+ artifactKey == "" || len(artifactKey) > maxTaskArtifactKeyLength {
+ return "", ErrTaskArtifactAccessInvalid
+ }
+
+ baseAddress := strings.TrimSpace(system_setting.TaskPublicAddress)
+ if baseAddress == "" {
+ baseAddress = strings.TrimSpace(system_setting.ServerAddress)
+ }
+ if err := ValidateTaskArtifactBaseURL(baseAddress); err != nil {
+ return "", err
+ }
+ baseURL, err := url.Parse(baseAddress)
+ if err != nil {
+ return "", err
+ }
+
+ access, err := IssueTaskArtifactAccess(taskID, artifactKey)
+ if err != nil {
+ return "", err
+ }
+
+ basePath := strings.TrimRight(baseURL.Path, "/")
+ escapedBasePath := strings.TrimRight(baseURL.EscapedPath(), "/")
+ suffixPath := fmt.Sprintf("/v1/tasks/%s/artifacts/%s/content", taskID, artifactKey)
+ escapedSuffixPath := fmt.Sprintf(
+ "/v1/tasks/%s/artifacts/%s/content",
+ url.PathEscape(taskID),
+ url.PathEscape(artifactKey),
+ )
+ baseURL.Path = basePath + suffixPath
+ baseURL.RawPath = escapedBasePath + escapedSuffixPath
+ query := baseURL.Query()
+ query.Set(TaskArtifactAccessQueryParameter, access)
+ baseURL.RawQuery = query.Encode()
+ return baseURL.String(), nil
+}
diff --git a/service/task_artifact_access_test.go b/service/task_artifact_access_test.go
new file mode 100644
index 000000000000..cef811cfe665
--- /dev/null
+++ b/service/task_artifact_access_test.go
@@ -0,0 +1,99 @@
+package service
+
+import (
+ "net/url"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/setting/system_setting"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTaskArtifactAccessBindsTaskAndKey(t *testing.T) {
+ previousSecret := common.CryptoSecret
+ common.CryptoSecret = "task-artifact-access-test-secret"
+ t.Cleanup(func() { common.CryptoSecret = previousSecret })
+
+ access, err := IssueTaskArtifactAccess("task-1", "video-main")
+ require.NoError(t, err)
+ assert.Len(t, access, 43)
+ assert.NotContains(t, access, ".")
+ assert.True(t, VerifyTaskArtifactAccess(access, "task-1", "video-main"))
+ assert.False(t, VerifyTaskArtifactAccess(access, "task-2", "video-main"))
+ assert.False(t, VerifyTaskArtifactAccess(access, "task-1", "video-other"))
+ assert.False(t, VerifyTaskArtifactAccess(access+"x", "task-1", "video-main"))
+
+ common.CryptoSecret = "another-node-secret"
+ assert.False(t, VerifyTaskArtifactAccess(access, "task-1", "video-main"))
+}
+
+func TestBuildTaskArtifactContentURLUsesConfiguredAddressAndPreservesPrefix(t *testing.T) {
+ previousSecret := common.CryptoSecret
+ previousPublicAddress := system_setting.TaskPublicAddress
+ previousServerAddress := system_setting.ServerAddress
+ common.CryptoSecret = "task-artifact-url-test-secret"
+ system_setting.TaskPublicAddress = "https://media.example/gateway/prefix/"
+ system_setting.ServerAddress = "https://fallback.invalid"
+ t.Cleanup(func() {
+ common.CryptoSecret = previousSecret
+ system_setting.TaskPublicAddress = previousPublicAddress
+ system_setting.ServerAddress = previousServerAddress
+ })
+
+ contentURL, err := BuildTaskArtifactContentURL("task-public", "video-main")
+ require.NoError(t, err)
+ parsed, err := url.Parse(contentURL)
+ require.NoError(t, err)
+ assert.Equal(t, "media.example", parsed.Host)
+ assert.Equal(t, "/gateway/prefix/v1/tasks/task-public/artifacts/video-main/content", parsed.Path)
+ assert.True(t, VerifyTaskArtifactAccess(
+ parsed.Query().Get(TaskArtifactAccessQueryParameter),
+ "task-public",
+ "video-main",
+ ))
+}
+
+func TestBuildTaskArtifactContentURLFallsBackOnlyToServerAddress(t *testing.T) {
+ previousSecret := common.CryptoSecret
+ previousPublicAddress := system_setting.TaskPublicAddress
+ previousServerAddress := system_setting.ServerAddress
+ common.CryptoSecret = "task-artifact-fallback-test-secret"
+ system_setting.TaskPublicAddress = ""
+ system_setting.ServerAddress = "https://gateway.example/root"
+ t.Cleanup(func() {
+ common.CryptoSecret = previousSecret
+ system_setting.TaskPublicAddress = previousPublicAddress
+ system_setting.ServerAddress = previousServerAddress
+ })
+
+ contentURL, err := BuildTaskArtifactContentURL("task-fallback", "audio")
+ require.NoError(t, err)
+ assert.Contains(t, contentURL, "https://gateway.example/root/v1/tasks/task-fallback/artifacts/audio/content")
+
+ system_setting.TaskPublicAddress = "not-a-url"
+ _, err = BuildTaskArtifactContentURL("task-fallback", "audio")
+ assert.Error(t, err)
+}
+
+func TestValidateTaskArtifactBaseURLOnlyAcceptsSafeAbsoluteHTTPURLs(t *testing.T) {
+ for _, valid := range []string{
+ "http://localhost:3000",
+ "https://gateway.example",
+ "https://gateway.example/prefix/path/",
+ } {
+ assert.NoError(t, ValidateTaskArtifactBaseURL(valid), valid)
+ }
+ for _, invalid := range []string{
+ "",
+ "/relative",
+ "ftp://gateway.example",
+ "https://user:secret@gateway.example",
+ "https://gateway.example/path?tenant=1",
+ "https://gateway.example/path#fragment",
+ " https://gateway.example",
+ "https://gateway.example ",
+ } {
+ assert.Error(t, ValidateTaskArtifactBaseURL(invalid), invalid)
+ }
+}
diff --git a/service/task_artifact_store.go b/service/task_artifact_store.go
new file mode 100644
index 000000000000..fdd2ba56950e
--- /dev/null
+++ b/service/task_artifact_store.go
@@ -0,0 +1,63 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "io"
+
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/setting/system_setting"
+ "github.com/QuantumNous/new-api/types"
+ "github.com/gin-gonic/gin"
+)
+
+// StoredArtifactRef describes a persisted artifact object. No reference is
+// produced until a concrete storage backend is implemented.
+type StoredArtifactRef struct {
+ Backend string
+ Bucket string
+ ObjectKey string
+ MimeType string
+ Size int64
+}
+
+// TaskArtifactStore is the persistence boundary for generated artifact bytes.
+// types.TaskArtifact is re-exported by relay/channel as channel.TaskArtifact.
+type TaskArtifactStore interface {
+ Enabled() bool
+ Resolve(task *model.Task, artifactKey string) (*StoredArtifactRef, error)
+ Persist(ctx context.Context, task *model.Task, artifact types.TaskArtifact, content io.Reader) (*StoredArtifactRef, error)
+ Serve(c *gin.Context, task *model.Task, ref *StoredArtifactRef) error
+}
+
+var ErrTaskArtifactStoreDisabled = errors.New("task artifact store is disabled")
+
+type disabledArtifactStore struct{}
+
+func (disabledArtifactStore) Enabled() bool {
+ return false
+}
+
+func (disabledArtifactStore) Resolve(*model.Task, string) (*StoredArtifactRef, error) {
+ return nil, nil
+}
+
+func (disabledArtifactStore) Persist(context.Context, *model.Task, types.TaskArtifact, io.Reader) (*StoredArtifactRef, error) {
+ return nil, ErrTaskArtifactStoreDisabled
+}
+
+func (disabledArtifactStore) Serve(*gin.Context, *model.Task, *StoredArtifactRef) error {
+ return ErrTaskArtifactStoreDisabled
+}
+
+var taskArtifactStore TaskArtifactStore = &disabledArtifactStore{}
+
+func init() {
+ _ = system_setting.LoadTaskArtifactStoreConfig()
+}
+
+// GetTaskArtifactStore returns the process-wide artifact storage backend. This
+// release always returns the disabled implementation.
+func GetTaskArtifactStore() TaskArtifactStore {
+ return taskArtifactStore
+}
diff --git a/service/task_artifact_store_test.go b/service/task_artifact_store_test.go
new file mode 100644
index 000000000000..b87886d0739e
--- /dev/null
+++ b/service/task_artifact_store_test.go
@@ -0,0 +1,31 @@
+package service
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/types"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDisabledTaskArtifactStoreHasNoStorageBehavior(t *testing.T) {
+ store := GetTaskArtifactStore()
+ require.NotNil(t, store)
+ assert.False(t, store.Enabled())
+
+ task := &model.Task{TaskID: "task-disabled-store"}
+ ref, err := store.Resolve(task, "video")
+ require.NoError(t, err)
+ assert.Nil(t, ref)
+
+ ref, err = store.Persist(t.Context(), task, types.TaskArtifact{Key: "video", Type: "video"}, strings.NewReader("content"))
+ assert.Nil(t, ref)
+ assert.ErrorIs(t, err, ErrTaskArtifactStoreDisabled)
+ assert.ErrorIs(t, store.Serve(&gin.Context{}, task, &StoredArtifactRef{Backend: "s3"}), ErrTaskArtifactStoreDisabled)
+ assert.Same(t, store, GetTaskArtifactStore())
+}
+
+var _ TaskArtifactStore = disabledArtifactStore{}
diff --git a/service/task_billing.go b/service/task_billing.go
index 64dbdcef14a5..78b834c622ff 100644
--- a/service/task_billing.go
+++ b/service/task_billing.go
@@ -2,6 +2,7 @@ package service
import (
"context"
+ "encoding/base64"
"fmt"
"strings"
@@ -17,24 +18,29 @@ import (
// LogTaskConsumption 记录任务消费日志和统计信息(仅记录,不涉及实际扣费)。
// 实际扣费已由 BillingSession(PreConsumeBilling + SettleBilling)完成。
-func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) {
+func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo, task *model.Task) {
tokenName := c.GetString("token_name")
logContent := fmt.Sprintf("操作 %s", info.Action)
// 支持任务仅按次计费
if common.StringsContains(constant.TaskPricePatches, info.OriginModelName) {
logContent = fmt.Sprintf("%s,按次计费", logContent)
} else {
+ var contents []string
if otherRatios := info.PriceData.OtherRatios(); len(otherRatios) > 0 {
- var contents []string
for key, ra := range otherRatios {
if 1.0 != ra {
contents = append(contents, fmt.Sprintf("%s: %.2f", key, ra))
}
}
- if len(contents) > 0 {
- logContent = fmt.Sprintf("%s, 计算参数:%s", logContent, strings.Join(contents, ", "))
+ }
+ if snap := info.TieredBillingSnapshot; snap != nil {
+ for key, value := range snap.UsageFacts {
+ contents = append(contents, fmt.Sprintf("%s: %v", key, value))
}
}
+ if len(contents) > 0 {
+ logContent = fmt.Sprintf("%s, 计算参数:%s", logContent, strings.Join(contents, ", "))
+ }
}
other := make(map[string]interface{})
other["is_task"] = true
@@ -51,6 +57,15 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) {
other["is_model_mapped"] = true
other["upstream_model_name"] = info.UpstreamModelName
}
+ if snap := info.TieredBillingSnapshot; snap != nil {
+ other["billing_mode"] = "tiered_expr"
+ other["expr_b64"] = base64.StdEncoding.EncodeToString([]byte(snap.ExprString))
+ other["matched_tier"] = snap.EstimatedTier
+ if len(snap.UsageFacts) > 0 {
+ other["usage_facts"] = snap.UsageFacts
+ }
+ }
+ appendTaskLogInfo(task, other)
attachQuotaSaturation(c, info, other)
model.RecordConsumeLog(c, info.UserId, model.RecordConsumeLogParams{
ChannelId: info.ChannelId,
@@ -132,15 +147,50 @@ func taskBillingOther(task *model.Task) map[string]interface{} {
other[k] = v
}
}
+ if snap := bc.TieredSnapshot; snap != nil {
+ other["billing_mode"] = "tiered_expr"
+ other["expr_b64"] = base64.StdEncoding.EncodeToString([]byte(snap.ExprString))
+ other["matched_tier"] = snap.EstimatedTier
+ if len(snap.UsageFacts) > 0 {
+ other["usage_facts"] = snap.UsageFacts
+ }
+ }
}
props := task.Properties
if props.UpstreamModelName != "" && props.UpstreamModelName != props.OriginModelName {
other["is_model_mapped"] = true
other["upstream_model_name"] = props.UpstreamModelName
}
+ appendTaskLogInfo(task, other)
return other
}
+func appendTaskLogInfo(task *model.Task, other map[string]interface{}) {
+ if task == nil || other == nil {
+ return
+ }
+ if task.TaskID != "" {
+ other["task_id"] = task.TaskID
+ }
+ if task.PrivateData.Execution != nil {
+ AppendTaskPluginAuditInfo(other, task.PrivateData.Execution.TaskPlugin)
+ }
+ if task.PrivateData.UpstreamTaskID == "" && task.PrivateData.NodeName == "" {
+ return
+ }
+ rootInfo, ok := other["root_info"].(map[string]interface{})
+ if !ok || rootInfo == nil {
+ rootInfo = map[string]interface{}{}
+ other["root_info"] = rootInfo
+ }
+ if task.PrivateData.UpstreamTaskID != "" {
+ rootInfo["upstream_task_id"] = task.PrivateData.UpstreamTaskID
+ }
+ if task.PrivateData.NodeName != "" {
+ rootInfo["node_name"] = task.PrivateData.NodeName
+ }
+}
+
func taskBillingContextPriceData(bc *model.TaskBillingContext) *types.PriceData {
if bc == nil || len(bc.OtherRatios) == 0 {
return nil
@@ -212,7 +262,7 @@ func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) bool
// reason 用于日志记录(例如 "token重算" 或 "adaptor调整")。
// clamps 可选:若计算 actualQuota 时发生额度饱和,将其记入日志 admin_info(仅管理员可见)。
func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int, reason string, clamps ...*common.QuotaClamp) {
- if actualQuota <= 0 {
+ if actualQuota < 0 {
return
}
preConsumedQuota := task.Quota
@@ -283,9 +333,9 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int
// RecalculateTaskQuotaByTokens 根据实际 token 消耗重新计费(异步差额结算)。
// 当任务成功且返回了 totalTokens 时,根据模型倍率和分组倍率重新计算实际扣费额度,
// 与预扣费的差额进行补扣或退还。支持钱包和订阅计费来源。
-func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTokens int) {
+func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTokens int) bool {
if totalTokens <= 0 {
- return
+ return false
}
modelName := taskModelName(task)
@@ -294,7 +344,7 @@ func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTo
modelRatio, hasRatioSetting, _ := ratio_setting.GetModelRatio(modelName)
// 只有配置了倍率(非固定价格)时才按 token 重新计费
if !hasRatioSetting || modelRatio <= 0 {
- return
+ return false
}
// 获取用户和组的倍率信息
@@ -306,7 +356,7 @@ func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTo
}
}
if group == "" {
- return
+ return false
}
groupRatio := ratio_setting.GetGroupRatio(group)
@@ -330,4 +380,5 @@ func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTo
reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f, otherMultiplier=%.4f", totalTokens, modelRatio, finalGroupRatio, otherMultiplier)
RecalculateTaskQuota(ctx, task, actualQuota, reason, clamp)
+ return true
}
diff --git a/service/task_billing_test.go b/service/task_billing_test.go
index 699cc1ed67ae..87cc35778b4d 100644
--- a/service/task_billing_test.go
+++ b/service/task_billing_test.go
@@ -2,17 +2,22 @@ package service
import (
"context"
+ "encoding/base64"
"encoding/json"
"math"
"net/http"
+ "net/http/httptest"
"os"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/types"
+ "github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
@@ -230,6 +235,194 @@ func TestTaskBillingOtherFiltersHistoricalOtherRatios(t *testing.T) {
assert.NotContains(t, other, "negative")
assert.NotContains(t, other, "nan")
assert.NotContains(t, other, "inf")
+ assert.NotContains(t, other, "billing_mode")
+ assert.NotContains(t, other, "expr_b64")
+ assert.NotContains(t, other, "matched_tier")
+ assert.NotContains(t, other, "usage_facts")
+}
+
+func TestTaskBillingOtherIncludesTieredSnapshotAndKeepsUsageFactsNested(t *testing.T) {
+ task := makeTask(1, 1, 100, 0, BillingSourceWallet, 0)
+ expression := `tier("720P", u("seconds") * 5)`
+ task.PrivateData.BillingContext.TieredSnapshot = &billingexpr.BillingSnapshot{
+ ExprString: expression,
+ EstimatedTier: "720P",
+ UsageFacts: map[string]any{
+ "resolution": "720P",
+ "seconds": 5,
+ },
+ }
+
+ other := taskBillingOther(task)
+
+ assert.Equal(t, "tiered_expr", other["billing_mode"])
+ assert.Equal(t, base64.StdEncoding.EncodeToString([]byte(expression)), other["expr_b64"])
+ assert.Equal(t, "720P", other["matched_tier"])
+ facts, ok := other["usage_facts"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, map[string]any{
+ "resolution": "720P",
+ "seconds": 5,
+ }, facts)
+ assert.NotContains(t, other, "resolution")
+ assert.NotContains(t, other, "seconds")
+}
+
+func TestTaskBillingOtherOmitsEmptyUsageFacts(t *testing.T) {
+ task := makeTask(1, 1, 100, 0, BillingSourceWallet, 0)
+ expression := `tier("base", 1)`
+ task.PrivateData.BillingContext.TieredSnapshot = &billingexpr.BillingSnapshot{
+ ExprString: expression,
+ EstimatedTier: "base",
+ UsageFacts: map[string]any{},
+ }
+
+ other := taskBillingOther(task)
+
+ assert.Equal(t, "tiered_expr", other["billing_mode"])
+ assert.Equal(t, base64.StdEncoding.EncodeToString([]byte(expression)), other["expr_b64"])
+ assert.Equal(t, "base", other["matched_tier"])
+ assert.NotContains(t, other, "usage_facts")
+}
+
+func callLogTaskConsumption(t *testing.T, info *relaycommon.RelayInfo, task *model.Task) *model.Log {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+ ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+ ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
+ ctx.Set("token_name", "test_token")
+ LogTaskConsumption(ctx, info, task)
+ log := getLastLog(t)
+ require.NotNil(t, log)
+ return log
+}
+
+func TestLogTaskConsumptionIncludesTieredSnapshotUsageFacts(t *testing.T) {
+ truncate(t)
+ const userID, channelID = 40, 40
+ seedUser(t, userID, 10_000)
+ seedChannel(t, channelID)
+
+ expression := `tier("720P", u("seconds") * 5)`
+ task := makeTask(userID, channelID, 100, 0, BillingSourceWallet, 0)
+ info := &relaycommon.RelayInfo{
+ UserId: userID,
+ TokenId: 0,
+ OriginModelName: "wan2.5-i2v-preview",
+ UsingGroup: "default",
+ ChannelMeta: &relaycommon.ChannelMeta{ChannelId: channelID},
+ TaskRelayInfo: &relaycommon.TaskRelayInfo{Action: "GENERATE"},
+ PriceData: types.PriceData{
+ ModelPrice: 0.02,
+ Quota: 100,
+ GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1},
+ },
+ TieredBillingSnapshot: &billingexpr.BillingSnapshot{
+ ExprString: expression,
+ EstimatedTier: "720P",
+ UsageFacts: map[string]any{
+ "resolution": "720P",
+ "seconds": 5,
+ },
+ },
+ }
+
+ log := callLogTaskConsumption(t, info, task)
+
+ var other map[string]any
+ require.NoError(t, common.UnmarshalJsonStr(log.Other, &other))
+ assert.Equal(t, "tiered_expr", other["billing_mode"])
+ assert.Equal(t, base64.StdEncoding.EncodeToString([]byte(expression)), other["expr_b64"])
+ assert.Equal(t, "720P", other["matched_tier"])
+ facts, ok := other["usage_facts"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "720P", facts["resolution"])
+ assert.Equal(t, float64(5), facts["seconds"])
+ assert.NotContains(t, other, "resolution")
+ assert.NotContains(t, other, "seconds")
+ assert.Contains(t, log.Content, "计算参数:")
+ assert.Contains(t, log.Content, "resolution: 720P")
+ assert.Contains(t, log.Content, "seconds: 5")
+}
+
+func TestLogTaskConsumptionWithoutSnapshotKeepsRatioMode(t *testing.T) {
+ truncate(t)
+ const userID, channelID = 41, 41
+ seedUser(t, userID, 10_000)
+ seedChannel(t, channelID)
+
+ priceData := types.PriceData{
+ ModelPrice: 0.02,
+ Quota: 100,
+ GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1},
+ }
+ priceData.AddOtherRatio("size", 2)
+ task := makeTask(userID, channelID, 100, 0, BillingSourceWallet, 0)
+ info := &relaycommon.RelayInfo{
+ UserId: userID,
+ TokenId: 0,
+ OriginModelName: "test-model",
+ UsingGroup: "default",
+ ChannelMeta: &relaycommon.ChannelMeta{ChannelId: channelID},
+ TaskRelayInfo: &relaycommon.TaskRelayInfo{Action: "GENERATE"},
+ PriceData: priceData,
+ }
+
+ log := callLogTaskConsumption(t, info, task)
+
+ var other map[string]any
+ require.NoError(t, common.UnmarshalJsonStr(log.Other, &other))
+ assert.Equal(t, true, other["is_task"])
+ assert.Equal(t, "/v1/videos", other["request_path"])
+ assert.NotContains(t, other, "billing_mode")
+ assert.NotContains(t, other, "expr_b64")
+ assert.NotContains(t, other, "matched_tier")
+ assert.NotContains(t, other, "usage_facts")
+ assert.Contains(t, log.Content, "计算参数:")
+ assert.Contains(t, log.Content, "size: 2.00")
+}
+
+func TestTaskBillingOtherSeparatesPluginAndRootDiagnostics(t *testing.T) {
+ task := makeTask(1, 1, 100, 0, BillingSourceWallet, 0)
+ task.TaskID = "task_public"
+ task.PrivateData.UpstreamTaskID = "upstream-private"
+ task.PrivateData.NodeName = "node-a"
+ task.PrivateData.Execution = &model.TaskExecutionSnapshot{
+ TaskPlugin: &model.TaskPluginSnapshot{
+ Key: "document-parser",
+ Name: "Document Parser",
+ Version: "1.2.3",
+ Author: &model.TaskPluginAuthorSnapshot{
+ Name: "Community Author",
+ URL: "https://plugins.example/author",
+ },
+ APIVersion: 1,
+ Generation: 42,
+ },
+ }
+
+ other := taskBillingOther(task)
+
+ assert.Equal(t, "task_public", other["task_id"])
+ adminInfo, ok := other["admin_info"].(map[string]interface{})
+ require.True(t, ok)
+ pluginInfo, ok := adminInfo["task_plugin"].(map[string]interface{})
+ require.True(t, ok)
+ assert.Equal(t, "document-parser", pluginInfo["key"])
+ assert.Equal(t, "1.2.3", pluginInfo["version"])
+ assert.Equal(t, map[string]interface{}{
+ "name": "Community Author",
+ "url": "https://plugins.example/author",
+ }, pluginInfo["author"])
+
+ rootInfo, ok := other["root_info"].(map[string]interface{})
+ require.True(t, ok)
+ assert.Equal(t, "upstream-private", rootInfo["upstream_task_id"])
+ assert.Equal(t, "node-a", rootInfo["node_name"])
+ runtimeInfo, ok := rootInfo["task_plugin"].(map[string]interface{})
+ require.True(t, ok)
+ assert.Equal(t, uint64(42), runtimeInfo["generation"])
+ assert.NotContains(t, runtimeInfo, "author")
}
func TestTaskBillingContextPriceDataFiltersMultiplier(t *testing.T) {
@@ -873,17 +1066,37 @@ func TestRecalculate_ActualQuotaZero(t *testing.T) {
truncate(t)
ctx := context.Background()
- const userID = 13
+ const userID, preConsumed = 13, 5000
const initQuota = 10000
seedUser(t, userID, initQuota)
- task := makeTask(userID, 0, 5000, 0, BillingSourceWallet, 0)
+ task := makeTask(userID, 0, preConsumed, 0, BillingSourceWallet, 0)
+ require.NoError(t, model.DB.Create(task).Error)
RecalculateTaskQuota(ctx, task, 0, "zero actual")
- // No change (early return)
+ assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID))
+ assert.Zero(t, task.Quota)
+ log := getLastLog(t)
+ require.NotNil(t, log)
+ assert.Equal(t, model.LogTypeRefund, log.Type)
+ assert.Equal(t, preConsumed, log.Quota)
+}
+
+func TestRecalculate_RejectsNegativeActualQuota(t *testing.T) {
+ truncate(t)
+ ctx := context.Background()
+
+ const userID, preConsumed = 34, 5000
+ const initQuota = 10000
+ seedUser(t, userID, initQuota)
+ task := makeTask(userID, 0, preConsumed, 0, BillingSourceWallet, 0)
+
+ RecalculateTaskQuota(ctx, task, -1, "invalid negative actual")
+
assert.Equal(t, initQuota, getUserQuota(t, userID))
+ assert.Equal(t, preConsumed, task.Quota)
assert.Equal(t, int64(0), countLogs(t))
}
@@ -1160,9 +1373,10 @@ func TestSettle_PerCallBilling_SkipsAdaptorAdjust(t *testing.T) {
adaptor := &mockAdaptor{adjustReturn: 2000}
taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess}
- settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
+ settled := settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
// Per-call: no adjustment despite adaptor returning 2000
+ assert.False(t, settled)
assert.Equal(t, initQuota, getUserQuota(t, userID))
assert.Equal(t, tokenRemain, getTokenRemainQuota(t, tokenID))
assert.Equal(t, preConsumed, task.Quota)
@@ -1187,9 +1401,10 @@ func TestSettle_PerCallBilling_SkipsTotalTokens(t *testing.T) {
adaptor := &mockAdaptor{adjustReturn: 0}
taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess, TotalTokens: 9999}
- settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
+ settled := settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
// Per-call: no recalculation by tokens
+ assert.False(t, settled)
assert.Equal(t, initQuota, getUserQuota(t, userID))
assert.Equal(t, tokenRemain, getTokenRemainQuota(t, tokenID))
assert.Equal(t, preConsumed, task.Quota)
@@ -1215,9 +1430,10 @@ func TestSettle_NonPerCallBilling_AppliesAdaptorAdjustment(t *testing.T) {
adaptor := &mockAdaptor{adjustReturn: adaptorQuota}
taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess}
- settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
+ settled := settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
// Non-per-call: adaptor adjustment applies (refund 2000)
+ assert.True(t, settled)
assert.Equal(t, initQuota+(preConsumed-adaptorQuota), getUserQuota(t, userID))
assert.Equal(t, tokenRemain+(preConsumed-adaptorQuota), getTokenRemainQuota(t, tokenID))
assert.Equal(t, adaptorQuota, task.Quota)
@@ -1226,3 +1442,303 @@ func TestSettle_NonPerCallBilling_AppliesAdaptorAdjustment(t *testing.T) {
require.NotNil(t, log)
assert.Equal(t, model.LogTypeRefund, log.Type)
}
+
+func TestSettle_TieredEvaluationFailureKeepsPreConsumedCharge(t *testing.T) {
+ truncate(t)
+ ctx := context.Background()
+
+ const userID, preConsumed = 33, 5_000
+ const initialQuota = 10_000
+ seedUser(t, userID, initialQuota)
+
+ task := makeTask(userID, 0, preConsumed, 0, BillingSourceWallet, 0)
+ task.PrivateData.BillingContext.TieredSnapshot = &billingexpr.BillingSnapshot{
+ ExprString: `tier("broken",`,
+ ExprHash: billingexpr.ExprHashString(`tier("broken",`),
+ GroupRatio: 1,
+ QuotaPerUnit: 1_000,
+ ExprVersion: 1,
+ TaskUsageBilling: true,
+ }
+
+ settled := settleTaskBillingOnComplete(ctx, &mockAdaptor{}, task, &relaycommon.TaskInfo{Status: model.TaskStatusFailure})
+
+ assert.True(t, settled)
+ assert.Equal(t, preConsumed, task.Quota)
+ assert.Equal(t, initialQuota, getUserQuota(t, userID))
+ assert.Equal(t, int64(0), countLogs(t))
+}
+
+func TestSettle_TieredFailureReturnsFalseForCallerRefund(t *testing.T) {
+ truncate(t)
+ ctx := context.Background()
+
+ const userID = 37
+ const initialQuota, preConsumed = 10_000, 25
+ seedUser(t, userID, initialQuota)
+
+ expression := `tier("base", u("seconds") + u("clips") * 10)`
+ task := makeTask(userID, 0, preConsumed, 0, BillingSourceWallet, 0)
+ task.Status = model.TaskStatusFailure
+ task.PrivateData.BillingContext.TieredSnapshot = &billingexpr.BillingSnapshot{
+ ExprString: expression,
+ ExprHash: billingexpr.ExprHashString(expression),
+ GroupRatio: 1,
+ QuotaPerUnit: 1,
+ ExprVersion: 1,
+ TaskUsageBilling: true,
+ UsageFacts: map[string]any{"seconds": float64(5), "clips": float64(2)},
+ EstimatedTier: "base",
+ }
+
+ settled := settleTaskBillingOnComplete(
+ ctx,
+ &mockAdaptor{adjustReturn: 1},
+ task,
+ &relaycommon.TaskInfo{Status: model.TaskStatusFailure, UsageFacts: map[string]any{"seconds": float64(8)}},
+ )
+
+ assert.False(t, settled)
+ assert.Equal(t, preConsumed, task.Quota)
+ assert.Equal(t, map[string]any{"seconds": float64(5), "clips": float64(2)}, task.PrivateData.BillingContext.TieredSnapshot.UsageFacts)
+ assert.Equal(t, "base", task.PrivateData.BillingContext.TieredSnapshot.EstimatedTier)
+ assert.Equal(t, initialQuota, getUserQuota(t, userID))
+ assert.Equal(t, int64(0), countLogs(t))
+}
+
+func TestSettle_TieredSuccessStillRecomputes(t *testing.T) {
+ truncate(t)
+ ctx := context.Background()
+
+ const userID = 38
+ const initialQuota, preConsumed = 10_000, 50
+ seedUser(t, userID, initialQuota)
+
+ expression := `tier("base", u("seconds") + u("clips") * 10)`
+ task := makeTask(userID, 0, preConsumed, 0, BillingSourceWallet, 0)
+ task.Status = model.TaskStatusSuccess
+ task.PrivateData.BillingContext.TieredSnapshot = &billingexpr.BillingSnapshot{
+ ExprString: expression,
+ ExprHash: billingexpr.ExprHashString(expression),
+ GroupRatio: 1,
+ QuotaPerUnit: 1,
+ ExprVersion: 1,
+ TaskUsageBilling: true,
+ UsageFacts: map[string]any{"seconds": float64(5), "clips": float64(2)},
+ EstimatedTier: "base",
+ }
+
+ settled := settleTaskBillingOnComplete(
+ ctx,
+ &mockAdaptor{adjustReturn: 1},
+ task,
+ &relaycommon.TaskInfo{Status: model.TaskStatusSuccess, UsageFacts: map[string]any{"seconds": float64(8)}},
+ )
+
+ assert.True(t, settled)
+ assert.Equal(t, 28, task.Quota)
+ assert.Equal(t, map[string]any{"seconds": float64(8), "clips": float64(2)}, task.PrivateData.BillingContext.TieredSnapshot.UsageFacts)
+ assert.Equal(t, "base", task.PrivateData.BillingContext.TieredSnapshot.EstimatedTier)
+ assert.Equal(t, initialQuota+(preConsumed-28), getUserQuota(t, userID))
+
+ log := getLastLog(t)
+ require.NotNil(t, log)
+ assert.Equal(t, model.LogTypeRefund, log.Type)
+ var other map[string]any
+ require.NoError(t, common.UnmarshalJsonStr(log.Other, &other))
+ assert.Equal(t, "tiered_expr", other["billing_mode"])
+ assert.Equal(t, "base", other["matched_tier"])
+ facts, ok := other["usage_facts"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, map[string]any{"seconds": float64(8), "clips": float64(2)}, facts)
+}
+
+func TestSettle_TieredUsageFactsMergeCompletionOverSubmission(t *testing.T) {
+ tests := []struct {
+ name string
+ completionFacts map[string]any
+ expectedQuota int
+ expectedFacts map[string]any
+ }{
+ {
+ name: "submission facts survive missing completion facts",
+ expectedQuota: 25,
+ expectedFacts: map[string]any{"seconds": float64(5), "clips": float64(2)},
+ },
+ {
+ name: "completion facts partially override submission facts",
+ completionFacts: map[string]any{"seconds": float64(8)},
+ expectedQuota: 28,
+ expectedFacts: map[string]any{"seconds": float64(8), "clips": float64(2)},
+ },
+ {
+ name: "completion facts fully override submission facts",
+ completionFacts: map[string]any{"seconds": float64(8), "clips": float64(3)},
+ expectedQuota: 38,
+ expectedFacts: map[string]any{"seconds": float64(8), "clips": float64(3)},
+ },
+ }
+
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ truncate(t)
+ const userID = 34
+ const initialQuota = 10_000
+ const preConsumed = 50
+ seedUser(t, userID, initialQuota)
+
+ expression := `tier("base", u("seconds") + u("clips") * 10)`
+ submissionFacts := map[string]any{"seconds": float64(5), "clips": float64(2)}
+ task := makeTask(userID, 0, preConsumed, 0, BillingSourceWallet, 0)
+ task.PrivateData.BillingContext.TieredSnapshot = &billingexpr.BillingSnapshot{
+ ExprString: expression,
+ ExprHash: billingexpr.ExprHashString(expression),
+ GroupRatio: 1,
+ QuotaPerUnit: 1,
+ ExprVersion: 1,
+ TaskUsageBilling: true,
+ UsageFacts: submissionFacts,
+ EstimatedTier: "base",
+ }
+
+ settled := settleTaskBillingOnComplete(
+ context.Background(),
+ &mockAdaptor{},
+ task,
+ &relaycommon.TaskInfo{Status: model.TaskStatusSuccess, UsageFacts: testCase.completionFacts},
+ )
+
+ assert.True(t, settled)
+ assert.Equal(t, testCase.expectedQuota, task.Quota)
+ assert.Equal(t, map[string]any{"seconds": float64(5), "clips": float64(2)}, submissionFacts)
+ require.NotNil(t, task.PrivateData.BillingContext.TieredSnapshot)
+ assert.Equal(t, testCase.expectedFacts, task.PrivateData.BillingContext.TieredSnapshot.UsageFacts)
+ assert.Equal(t, "base", task.PrivateData.BillingContext.TieredSnapshot.EstimatedTier)
+
+ log := getLastLog(t)
+ require.NotNil(t, log)
+ var other map[string]any
+ require.NoError(t, common.UnmarshalJsonStr(log.Other, &other))
+ assert.Equal(t, "tiered_expr", other["billing_mode"])
+ assert.Equal(t, "base", other["matched_tier"])
+ facts, ok := other["usage_facts"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, testCase.expectedFacts, facts)
+ assert.NotContains(t, other, "seconds")
+ assert.NotContains(t, other, "clips")
+ })
+ }
+}
+
+func TestSettle_TieredSnapshotWriteBackUsesSettledFactsAndMatchedTier(t *testing.T) {
+ truncate(t)
+ const userID = 36
+ const initialQuota = 10_000
+ const preConsumed = 25
+ seedUser(t, userID, initialQuota)
+
+ expression := `u("resolution") == "1080P" ? tier("1080P", u("seconds") * 10) : tier("720P", u("seconds") * 5)`
+ task := makeTask(userID, 0, preConsumed, 0, BillingSourceWallet, 0)
+ task.PrivateData.BillingContext.TieredSnapshot = &billingexpr.BillingSnapshot{
+ ExprString: expression,
+ ExprHash: billingexpr.ExprHashString(expression),
+ GroupRatio: 1,
+ QuotaPerUnit: 1,
+ ExprVersion: 1,
+ TaskUsageBilling: true,
+ UsageFacts: map[string]any{"resolution": "720P", "seconds": float64(5)},
+ EstimatedTier: "720P",
+ }
+
+ settled := settleTaskBillingOnComplete(
+ context.Background(),
+ &mockAdaptor{},
+ task,
+ &relaycommon.TaskInfo{
+ Status: model.TaskStatusSuccess,
+ UsageFacts: map[string]any{"resolution": "1080P"},
+ },
+ )
+
+ require.True(t, settled)
+ snap := task.PrivateData.BillingContext.TieredSnapshot
+ require.NotNil(t, snap)
+ assert.Equal(t, map[string]any{"resolution": "1080P", "seconds": float64(5)}, snap.UsageFacts)
+ assert.Equal(t, "1080P", snap.EstimatedTier)
+ assert.Equal(t, 50, task.Quota)
+
+ log := getLastLog(t)
+ require.NotNil(t, log)
+ var other map[string]any
+ require.NoError(t, common.UnmarshalJsonStr(log.Other, &other))
+ assert.Equal(t, "tiered_expr", other["billing_mode"])
+ assert.Equal(t, "1080P", other["matched_tier"])
+ facts, ok := other["usage_facts"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "1080P", facts["resolution"])
+ assert.Equal(t, float64(5), facts["seconds"])
+ assert.NotContains(t, other, "resolution")
+ assert.NotContains(t, other, "seconds")
+}
+
+func TestSettle_TokenRecalcFallsBackToCompletionTokens(t *testing.T) {
+ previousRatios := ratio_setting.ModelRatio2JSONString()
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"test-model":1}`))
+ t.Cleanup(func() {
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(previousRatios))
+ })
+
+ tests := []struct {
+ name string
+ totalTokens int
+ completionTokens int
+ wantSettled bool
+ wantQuota int
+ }{
+ {
+ name: "total tokens still win when both are present",
+ totalTokens: 80,
+ completionTokens: 20,
+ wantSettled: true,
+ wantQuota: 80,
+ },
+ {
+ name: "completion tokens trigger recalc when total is zero",
+ totalTokens: 0,
+ completionTokens: 80,
+ wantSettled: true,
+ wantQuota: 80,
+ },
+ {
+ name: "neither token count skips recalc",
+ wantSettled: false,
+ wantQuota: 50,
+ },
+ }
+
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ truncate(t)
+ const userID, tokenID, channelID = 35, 35, 35
+ const initialQuota, preConsumed, tokenRemain = 10_000, 50, 8_000
+ seedUser(t, userID, initialQuota)
+ seedToken(t, tokenID, userID, "sk-completion-fallback", tokenRemain)
+ seedChannel(t, channelID)
+
+ task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
+ settled := settleTaskBillingOnComplete(
+ context.Background(),
+ &mockAdaptor{},
+ task,
+ &relaycommon.TaskInfo{
+ Status: model.TaskStatusSuccess,
+ TotalTokens: testCase.totalTokens,
+ CompletionTokens: testCase.completionTokens,
+ },
+ )
+
+ assert.Equal(t, testCase.wantSettled, settled)
+ assert.Equal(t, testCase.wantQuota, task.Quota)
+ })
+ }
+}
diff --git a/service/task_plugin_audit.go b/service/task_plugin_audit.go
new file mode 100644
index 000000000000..618e2433a713
--- /dev/null
+++ b/service/task_plugin_audit.go
@@ -0,0 +1,98 @@
+package service
+
+import (
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
+ pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/gin-gonic/gin"
+)
+
+// TaskExecutionSnapshotFromContext captures immutable request and plugin
+// provenance at submission time. It never copies plugin source or payloads.
+func TaskExecutionSnapshotFromContext(ctx *gin.Context) *model.TaskExecutionSnapshot {
+ if ctx == nil {
+ return nil
+ }
+ snapshot := &model.TaskExecutionSnapshot{
+ RequestID: ctx.GetString(common.RequestIdKey),
+ }
+ if ctx.Request != nil && ctx.Request.URL != nil {
+ snapshot.RequestPath = ctx.Request.URL.Path
+ }
+
+ pinnedValue, exists := ctx.Get(pluginruntime.ContextKeyPinnedPlugin)
+ if exists {
+ pinned, ok := pinnedValue.(pluginruntime.PinnedPlugin)
+ if ok && pinned.Plugin != nil {
+ generation := uint64(0)
+ if pinned.Generation != nil {
+ generation = pinned.Generation.Number
+ }
+ meta := pinned.Plugin.Meta
+ snapshot.TaskPlugin = &model.TaskPluginSnapshot{
+ Key: meta.Key,
+ Name: meta.Name,
+ Version: meta.Version,
+ Author: &model.TaskPluginAuthorSnapshot{
+ Name: meta.Author.Name,
+ URL: meta.Author.URL,
+ },
+ APIVersion: meta.APIVersion,
+ Generation: generation,
+ }
+ }
+ }
+
+ if snapshot.RequestID == "" && snapshot.RequestPath == "" && snapshot.TaskPlugin == nil {
+ return nil
+ }
+ return snapshot
+}
+
+// AppendTaskPluginAuditInfo writes role-separated, credential-free plugin
+// provenance into a usage log.
+func AppendTaskPluginAuditInfo(other map[string]interface{}, snapshot *model.TaskPluginSnapshot) {
+ if other == nil || snapshot == nil || snapshot.Key == "" {
+ return
+ }
+ adminInfo, ok := other["admin_info"].(map[string]interface{})
+ if !ok || adminInfo == nil {
+ adminInfo = map[string]interface{}{}
+ other["admin_info"] = adminInfo
+ }
+ taskPlugin := map[string]interface{}{
+ "key": snapshot.Key,
+ "name": snapshot.Name,
+ "version": snapshot.Version,
+ }
+ if snapshot.Author != nil && snapshot.Author.Name != "" {
+ author := map[string]interface{}{"name": snapshot.Author.Name}
+ if snapshot.Author.URL != "" {
+ author["url"] = snapshot.Author.URL
+ }
+ taskPlugin["author"] = author
+ }
+ adminInfo["task_plugin"] = taskPlugin
+
+ rootInfo, ok := other["root_info"].(map[string]interface{})
+ if !ok || rootInfo == nil {
+ rootInfo = map[string]interface{}{}
+ other["root_info"] = rootInfo
+ }
+ rootInfo["task_plugin"] = map[string]interface{}{
+ "key": snapshot.Key,
+ "version": snapshot.Version,
+ "api_version": snapshot.APIVersion,
+ "generation": snapshot.Generation,
+ }
+}
+
+// AppendTaskPluginContextAuditInfo is used before a task row exists, such as
+// an upstream submission error log.
+func AppendTaskPluginContextAuditInfo(ctx *gin.Context, other map[string]interface{}) {
+ execution := TaskExecutionSnapshotFromContext(ctx)
+ if execution == nil {
+ return
+ }
+ AppendTaskPluginAuditInfo(other, execution.TaskPlugin)
+}
diff --git a/service/task_plugin_view.go b/service/task_plugin_view.go
new file mode 100644
index 000000000000..ad9547765b7e
--- /dev/null
+++ b/service/task_plugin_view.go
@@ -0,0 +1,61 @@
+package service
+
+import (
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/dto"
+ "github.com/QuantumNous/new-api/model"
+)
+
+// BuildTaskPluginView converts a persisted task into the deliberately narrow
+// public shape permitted at JavaScript plugin boundaries.
+func BuildTaskPluginView(task *model.Task) (dto.TaskView, error) {
+ createdAt := task.CreatedAt
+ if createdAt == 0 {
+ createdAt = task.SubmitTime
+ }
+ view := dto.TaskView{
+ TaskID: task.TaskID,
+ Platform: string(task.Platform),
+ Status: string(task.Status),
+ Progress: task.Progress,
+ FailReason: task.FailReason,
+ CreatedAt: createdAt,
+ UpdatedAt: task.UpdatedAt,
+ FinishedAt: task.FinishTime,
+ }
+ if len(task.Data) > 0 {
+ if err := common.Unmarshal(task.Data, &view.Data); err != nil {
+ return dto.TaskView{}, err
+ }
+ view.Data = replacePrivateTaskID(view.Data, task.PrivateData.UpstreamTaskID, task.TaskID)
+ }
+ return view, nil
+}
+
+// replacePrivateTaskID rewrites exact private IDs only in known task-ID fields.
+// Map keys and opaque strings, including URLs containing the ID, are preserved.
+func replacePrivateTaskID(value any, privateTaskID, publicTaskID string) any {
+ if privateTaskID == "" || privateTaskID == publicTaskID {
+ return value
+ }
+ switch typed := value.(type) {
+ case []any:
+ replaced := make([]any, len(typed))
+ for index, item := range typed {
+ replaced[index] = replacePrivateTaskID(item, privateTaskID, publicTaskID)
+ }
+ return replaced
+ case map[string]any:
+ replaced := make(map[string]any, len(typed))
+ for key, item := range typed {
+ if (key == "id" || key == "task_id" || key == "taskId") && item == privateTaskID {
+ replaced[key] = publicTaskID
+ continue
+ }
+ replaced[key] = replacePrivateTaskID(item, privateTaskID, publicTaskID)
+ }
+ return replaced
+ default:
+ return value
+ }
+}
diff --git a/service/task_plugin_view_test.go b/service/task_plugin_view_test.go
new file mode 100644
index 000000000000..228f474fb82d
--- /dev/null
+++ b/service/task_plugin_view_test.go
@@ -0,0 +1,64 @@
+package service
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
+)
+
+func TestBuildTaskPluginViewRewritesOnlyStructuredTaskIDFields(t *testing.T) {
+ const (
+ privateTaskID = "upstream-task-123"
+ publicTaskID = "task_public_123"
+ resultURL = "https://cdn.example.com/results/upstream-task-123/video.mp4"
+ )
+
+ taskData, err := common.Marshal(map[string]any{
+ "task_id": privateTaskID,
+ "id": privateTaskID,
+ "taskId": privateTaskID,
+ "url": resultURL,
+ "message": "completed upstream-task-123",
+ "nested": []any{
+ map[string]any{
+ "task_id": privateTaskID,
+ "url": resultURL,
+ },
+ privateTaskID,
+ },
+ privateTaskID: "opaque map key",
+ })
+ require.NoError(t, err)
+ task := &model.Task{
+ TaskID: publicTaskID,
+ PrivateData: model.TaskPrivateData{
+ UpstreamTaskID: privateTaskID,
+ },
+ Data: taskData,
+ }
+
+ view, err := BuildTaskPluginView(task)
+ require.NoError(t, err)
+
+ data, ok := view.Data.(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, publicTaskID, data["task_id"])
+ assert.Equal(t, publicTaskID, data["id"])
+ assert.Equal(t, publicTaskID, data["taskId"])
+ assert.Equal(t, resultURL, data["url"])
+ assert.Equal(t, "completed upstream-task-123", data["message"])
+ assert.Equal(t, "opaque map key", data[privateTaskID])
+
+ nested, ok := data["nested"].([]any)
+ require.True(t, ok)
+ nestedData, ok := nested[0].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, publicTaskID, nestedData["task_id"])
+ assert.Equal(t, resultURL, nestedData["url"])
+ assert.Equal(t, privateTaskID, nested[1])
+
+}
diff --git a/service/task_polling.go b/service/task_polling.go
index 250201ae0525..59375844933c 100644
--- a/service/task_polling.go
+++ b/service/task_polling.go
@@ -2,7 +2,6 @@ package service
import (
"context"
- "errors"
"fmt"
"io"
"net/http"
@@ -16,6 +15,7 @@ import (
taskdto "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/pkg/billingexpr"
"github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
@@ -34,6 +34,22 @@ type TaskPollingAdaptor interface {
AdjustBillingOnComplete(task *model.Task, taskResult *relaycommon.TaskInfo) int
}
+type BatchTaskPollingAdaptor interface {
+ TaskPollingAdaptor
+ FetchMode() string
+ FetchBatchTasks(baseURL, key string, taskIDs []string, proxy string) (*http.Response, error)
+ ParseBatchResult(body []byte) (map[string]*BatchTaskResult, error)
+}
+
+type BatchTaskResult struct {
+ TaskInfo relaycommon.TaskInfo
+ Action string
+ SubmitTime int64
+ StartTime int64
+ FinishTime int64
+ Data any
+}
+
// GetTaskAdaptorFunc 由 main 包注入,用于获取指定平台的任务适配器。
// 打破 service -> relay -> relay/channel -> service 的循环依赖。
var GetTaskAdaptorFunc func(platform constant.TaskPlatform) TaskPollingAdaptor
@@ -180,25 +196,28 @@ func DispatchPlatformUpdate(ctx context.Context, platform constant.TaskPlatform,
if ctx == nil {
ctx = context.Background()
}
- switch platform {
- case constant.TaskPlatformMidjourney:
+ if platform == constant.TaskPlatformMidjourney {
// MJ 轮询由其自身处理,这里预留入口
- case constant.TaskPlatformSuno:
- _ = UpdateSunoTasks(ctx, taskChannelM, taskM)
- default:
- if err := UpdateVideoTasks(ctx, platform, taskChannelM, taskM); err != nil {
- common.SysLog(fmt.Sprintf("UpdateVideoTasks fail: %s", err))
+ return
+ }
+ adaptor := GetTaskAdaptorFunc(platform)
+ if batchAdaptor, ok := adaptor.(BatchTaskPollingAdaptor); ok && batchAdaptor.FetchMode() == "batch" {
+ if err := UpdateBatchTasks(ctx, batchAdaptor, taskChannelM, taskM); err != nil {
+ common.SysLog(fmt.Sprintf("UpdateBatchTasks fail: %s", err))
}
+ return
+ }
+ if err := UpdateVideoTasks(ctx, platform, taskChannelM, taskM); err != nil {
+ common.SysLog(fmt.Sprintf("UpdateVideoTasks fail: %s", err))
}
}
-// UpdateSunoTasks 按渠道更新所有 Suno 任务
-func UpdateSunoTasks(ctx context.Context, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
+func UpdateBatchTasks(ctx context.Context, adaptor BatchTaskPollingAdaptor, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
for channelId, taskIds := range taskChannelM {
if ctx.Err() != nil {
return ctx.Err()
}
- err := updateSunoTasks(ctx, channelId, taskIds, taskM)
+ err := updateBatchTasks(ctx, adaptor, channelId, taskIds, taskM)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("渠道 #%d 更新异步任务失败: %s", channelId, err.Error()))
}
@@ -206,7 +225,7 @@ func UpdateSunoTasks(ctx context.Context, taskChannelM map[int][]string, taskM m
return nil
}
-func updateSunoTasks(ctx context.Context, channelId int, taskIds []string, taskM map[string]*model.Task) error {
+func updateBatchTasks(ctx context.Context, adaptor BatchTaskPollingAdaptor, channelId int, taskIds []string, taskM map[string]*model.Task) error {
logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
if ctx.Err() != nil {
return ctx.Err()
@@ -234,14 +253,12 @@ func updateSunoTasks(ctx context.Context, channelId int, taskIds []string, taskM
}
return err
}
- adaptor := GetTaskAdaptorFunc(constant.TaskPlatformSuno)
- if adaptor == nil {
- return errors.New("adaptor not found")
- }
proxy := ch.GetSetting().Proxy
- resp, err := adaptor.FetchTask(*ch.BaseURL, ch.Key, map[string]any{
- "ids": taskIds,
- }, proxy)
+ baseURL := ch.GetBaseURL()
+ if baseURL == "" {
+ baseURL = constant.GetChannelBaseURL(ch.Type)
+ }
+ resp, err := adaptor.FetchBatchTasks(baseURL, ch.Key, taskIds, proxy)
if err != nil {
common.SysLog(fmt.Sprintf("Get Task Do req error: %v", err))
return err
@@ -256,98 +273,69 @@ func updateSunoTasks(ctx context.Context, channelId int, taskIds []string, taskM
common.SysLog(fmt.Sprintf("Get Suno Task parse body error: %v", err))
return err
}
- var responseItems taskdto.TaskResponse[[]taskdto.SunoDataResponse]
- err = common.Unmarshal(responseBody, &responseItems)
+ responseItems, err := adaptor.ParseBatchResult(responseBody)
if err != nil {
- logger.LogError(ctx, fmt.Sprintf("Get Suno Task parse body error2: %v, body: %s", err, string(responseBody)))
- return err
+ return fmt.Errorf("parse batch result: %w", err)
}
- if !responseItems.IsSuccess() {
- common.SysLog(fmt.Sprintf("渠道 #%d 未完成的任务有: %d, 成功获取到任务数: %s", channelId, len(taskIds), string(responseBody)))
- return err
- }
-
- for _, responseItem := range responseItems.Data {
+ for upstreamID, responseItem := range responseItems {
if ctx.Err() != nil {
return ctx.Err()
}
- task := taskM[responseItem.TaskID]
+ task := taskM[upstreamID]
if task == nil {
- logger.LogWarn(ctx, fmt.Sprintf("Suno task response ignored: unknown task_id=%s", responseItem.TaskID))
+ logger.LogWarn(ctx, fmt.Sprintf("Batch task response ignored: unknown task_id=%s", upstreamID))
continue
}
- if !taskNeedsUpdate(task, responseItem) {
- continue
- }
-
- prevStatus := task.Status
- task.Status = lo.If(model.TaskStatus(responseItem.Status) != "", model.TaskStatus(responseItem.Status)).Else(task.Status)
- task.FailReason = lo.If(responseItem.FailReason != "", responseItem.FailReason).Else(task.FailReason)
+ snap := task.Snapshot()
+ task.Status = lo.If(model.TaskStatus(responseItem.TaskInfo.Status) != "", model.TaskStatus(responseItem.TaskInfo.Status)).Else(task.Status)
+ task.FailReason = lo.If(responseItem.TaskInfo.Reason != "", responseItem.TaskInfo.Reason).Else(task.FailReason)
task.SubmitTime = lo.If(responseItem.SubmitTime != 0, responseItem.SubmitTime).Else(task.SubmitTime)
task.StartTime = lo.If(responseItem.StartTime != 0, responseItem.StartTime).Else(task.StartTime)
task.FinishTime = lo.If(responseItem.FinishTime != 0, responseItem.FinishTime).Else(task.FinishTime)
- isFailure := responseItem.FailReason != "" || task.Status == model.TaskStatusFailure
- if isFailure {
+ if responseItem.TaskInfo.Progress != "" {
+ task.Progress = responseItem.TaskInfo.Progress
+ }
+ if responseItem.TaskInfo.Reason != "" || task.Status == model.TaskStatusFailure {
logger.LogInfo(ctx, task.TaskID+" 构建失败,"+task.FailReason)
task.Status = model.TaskStatusFailure
task.Progress = "100%"
}
- if responseItem.Status == model.TaskStatusSuccess {
+ if responseItem.TaskInfo.Status == model.TaskStatusSuccess {
task.Progress = "100%"
}
- task.Data = responseItem.Data
+ if responseItem.Data != nil {
+ task.SetData(responseItem.Data)
+ } else if task.Status == model.TaskStatusSuccess || task.Status == model.TaskStatusFailure {
+ logger.LogWarn(ctx, fmt.Sprintf(
+ "Batch task %s reached terminal status without data; preserving existing task data",
+ task.TaskID,
+ ))
+ }
+ if responseItem.TaskInfo.Url != "" {
+ task.PrivateData.ResultURL = responseItem.TaskInfo.Url
+ }
- // 持久化走 CAS,防止重叠轮询/sweep/多实例/持久化失败重试导致重复退款或覆盖终态。
- won, err := task.UpdateWithStatus(prevStatus)
- if err != nil {
- logger.LogError(ctx, fmt.Sprintf("UpdateSunoTask task %s error: %v", task.TaskID, err))
- } else if !won {
- logger.LogWarn(ctx, fmt.Sprintf("Task %s CAS lost or no-op update, skip billing", task.TaskID))
- } else if isFailure && prevStatus != model.TaskStatusFailure && task.Quota != 0 {
- RefundTaskQuota(ctx, task, task.FailReason)
+ isDone := task.Status == model.TaskStatusSuccess || task.Status == model.TaskStatusFailure
+ terminalTransition := isDone && snap.Status != task.Status
+ won, updateErr := task.UpdateWithStatus(snap.Status)
+ if updateErr != nil {
+ common.SysLog("UpdateSunoTask task error: " + updateErr.Error())
+ continue
+ }
+ if !won {
+ logger.LogWarn(ctx, fmt.Sprintf("Batch task %s already transitioned by another process, skip billing", task.TaskID))
+ continue
+ }
+ if terminalTransition {
+ billingSettled := settleTaskBillingOnComplete(ctx, adaptor, task, &responseItem.TaskInfo)
+ if task.Status == model.TaskStatusFailure && !billingSettled && task.Quota != 0 {
+ RefundTaskQuota(ctx, task, task.FailReason)
+ }
}
}
return nil
}
-// taskNeedsUpdate 检查 Suno 任务是否需要更新
-func taskNeedsUpdate(oldTask *model.Task, newTask taskdto.SunoDataResponse) bool {
- if oldTask.SubmitTime != newTask.SubmitTime {
- return true
- }
- if oldTask.StartTime != newTask.StartTime {
- return true
- }
- if oldTask.FinishTime != newTask.FinishTime {
- return true
- }
- if string(oldTask.Status) != newTask.Status {
- return true
- }
- if oldTask.FailReason != newTask.FailReason {
- return true
- }
-
- if (oldTask.Status == model.TaskStatusFailure || oldTask.Status == model.TaskStatusSuccess) && oldTask.Progress != "100%" {
- return true
- }
-
- oldData, _ := common.Marshal(oldTask.Data)
- newData, _ := common.Marshal(newTask.Data)
-
- sort.Slice(oldData, func(i, j int) bool {
- return oldData[i] < oldData[j]
- })
- sort.Slice(newData, func(i, j int) bool {
- return newData[i] < newData[j]
- })
-
- if string(oldData) != string(newData) {
- return true
- }
- return false
-}
-
// UpdateVideoTasks 按渠道更新所有视频任务
func UpdateVideoTasks(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
channelIDs := make([]int, 0, len(taskChannelM))
@@ -442,7 +430,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
if ctx.Err() != nil {
return ctx.Err()
}
- baseURL := constant.ChannelBaseURLs[ch.Type]
+ baseURL := constant.GetChannelBaseURL(ch.Type)
if ch.GetBaseURL() != "" {
baseURL = ch.GetBaseURL()
}
@@ -461,7 +449,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
}
resp, err := adaptor.FetchTask(baseURL, key, map[string]any{
"task_id": task.GetUpstreamTaskID(),
- "action": task.Action,
+ "action": constant.NormalizeTaskAction(task.Action),
}, proxy)
if err != nil {
return fmt.Errorf("fetchTask failed for task %s: %w", taskId, err)
@@ -519,9 +507,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
}
}
- shouldRefund := false
- shouldSettle := false
- quota := task.Quota
+ shouldFinalizeBilling := false
task.Status = model.TaskStatus(taskResult.Status)
switch taskResult.Status {
@@ -549,7 +535,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
// No URL from adaptor — construct proxy URL using public task ID
task.PrivateData.ResultURL = taskcommon.BuildProxyURL(task.TaskID)
}
- shouldSettle = true
+ shouldFinalizeBilling = true
case model.TaskStatusFailure:
logger.LogJson(ctx, fmt.Sprintf("Task %s failed", taskId), task)
task.Status = model.TaskStatusFailure
@@ -560,9 +546,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
task.FailReason = taskResult.Reason
logger.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason))
taskResult.Progress = taskcommon.ProgressComplete
- if quota != 0 {
- shouldRefund = true
- }
+ shouldFinalizeBilling = true
default:
return fmt.Errorf("unknown task status %s for task %s", taskResult.Status, task.TaskID)
}
@@ -575,12 +559,10 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
won, err := task.UpdateWithStatus(snap.Status)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("UpdateWithStatus failed for task %s: %s", task.TaskID, err.Error()))
- shouldRefund = false
- shouldSettle = false
+ shouldFinalizeBilling = false
} else if !won {
logger.LogWarn(ctx, fmt.Sprintf("Task %s CAS lost or no-op update, skip billing", task.TaskID))
- shouldRefund = false
- shouldSettle = false
+ shouldFinalizeBilling = false
}
} else if !snap.Equal(task.Snapshot()) {
if _, err := task.UpdateWithStatus(snap.Status); err != nil {
@@ -591,11 +573,11 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
logger.LogDebug(ctx, "No update needed for task %s", task.TaskID)
}
- if shouldSettle {
- settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
- }
- if shouldRefund {
- RefundTaskQuota(ctx, task, task.FailReason)
+ if shouldFinalizeBilling {
+ billingSettled := settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
+ if task.Status == model.TaskStatusFailure && !billingSettled && task.Quota != 0 {
+ RefundTaskQuota(ctx, task, task.FailReason)
+ }
}
return nil
@@ -636,25 +618,53 @@ func truncateBase64(s string) string {
}
// settleTaskBillingOnComplete 任务完成时的统一计费调整。
-// 优先级:1. adaptor.AdjustBillingOnComplete 返回正数 → 使用 adaptor 计算的额度
+// 返回 true 表示用量结算路径已接管最终计费;失败任务仅在返回 false 时补做全额退款。
+// 优先级:1. tiered snapshot → 2. adaptor 调整 → 3. token 重算。
//
-// 2. taskResult.TotalTokens > 0 → 按 token 重算
-// 3. 都不满足 → 保持预扣额度不变
-func settleTaskBillingOnComplete(ctx context.Context, adaptor TaskPollingAdaptor, task *model.Task, taskResult *relaycommon.TaskInfo) {
- // 0. 按次计费的任务不做差额结算
+// 表达式求值失败会保留预扣额度,因此也视为已接管,避免错误全退。
+func settleTaskBillingOnComplete(ctx context.Context, adaptor TaskPollingAdaptor, task *model.Task, taskResult *relaycommon.TaskInfo) bool {
+ if bc := task.PrivateData.BillingContext; bc != nil && bc.TieredSnapshot != nil {
+ // 用量表达式结算只适用于成功任务;失败任务由调用方全额退款。
+ if task.Status == model.TaskStatusFailure {
+ return false
+ }
+ usageFacts := make(map[string]any, len(bc.TieredSnapshot.UsageFacts)+len(taskResult.UsageFacts))
+ for key, value := range bc.TieredSnapshot.UsageFacts {
+ usageFacts[key] = value
+ }
+ for key, value := range taskResult.UsageFacts {
+ usageFacts[key] = value
+ }
+ result, err := billingexpr.ComputeTieredQuotaWithRequest(bc.TieredSnapshot, billingexpr.TokenParams{}, billingexpr.RequestInput{Usage: usageFacts})
+ if err != nil {
+ logger.LogWarn(ctx, fmt.Sprintf("任务 %s 表达式结算失败,保留预扣额度: %v", task.TaskID, err))
+ return true
+ }
+ if result.Clamp != nil {
+ logger.LogWarn(ctx, fmt.Sprintf("任务 %s 表达式结算额度发生饱和: %+v", task.TaskID, result.Clamp))
+ }
+ bc.TieredSnapshot.UsageFacts = usageFacts
+ bc.TieredSnapshot.EstimatedTier = result.MatchedTier
+ RecalculateTaskQuota(ctx, task, result.ActualQuotaAfterGroup, "任务用量表达式结算", result.Clamp)
+ return true
+ }
+ // 按次计费的成功任务保持预扣;失败任务由调用方全额退款。
if bc := task.PrivateData.BillingContext; bc != nil && bc.PerCallBilling {
logger.LogInfo(ctx, fmt.Sprintf("任务 %s 按次计费,跳过差额结算", task.TaskID))
- return
+ return false
}
- // 1. 优先让 adaptor 决定最终额度
+ // 优先让 adaptor 决定最终额度。
if actualQuota := adaptor.AdjustBillingOnComplete(task, taskResult); actualQuota > 0 {
RecalculateTaskQuota(ctx, task, actualQuota, "adaptor计费调整")
- return
+ return true
}
- // 2. 回退到 token 重算
- if taskResult.TotalTokens > 0 {
- RecalculateTaskQuotaByTokens(ctx, task, taskResult.TotalTokens)
- return
+ // 回退到 token 重算。
+ tokens := taskResult.TotalTokens
+ if tokens == 0 && taskResult.CompletionTokens > 0 {
+ tokens = taskResult.CompletionTokens
}
- // 3. 无调整,保持预扣额度
+ if tokens > 0 {
+ return RecalculateTaskQuotaByTokens(ctx, task, tokens)
+ }
+ return false
}
diff --git a/service/task_polling_test.go b/service/task_polling_test.go
index 57b382fd6af5..105ba8af8406 100644
--- a/service/task_polling_test.go
+++ b/service/task_polling_test.go
@@ -5,6 +5,7 @@ import (
"context"
"io"
"net/http"
+ "strings"
"sync"
"testing"
"time"
@@ -13,6 +14,7 @@ import (
"github.com/QuantumNous/new-api/constant"
taskdto "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/bytedance/gopkg/util/gopool"
@@ -30,43 +32,28 @@ type taskPollingFetchAdaptor struct {
blockOnce sync.Once
}
-type sunoFailurePollingAdaptor struct {
- failReason string
+type batchPollingAdaptor struct {
+ taskPollingFetchAdaptor
+ batchCalls int
+ batchIDs []string
+ results map[string]*BatchTaskResult
}
-func (a *sunoFailurePollingAdaptor) Init(_ *relaycommon.RelayInfo) {}
-
-func (a *sunoFailurePollingAdaptor) FetchTask(_ string, _ string, body map[string]any, _ string) (*http.Response, error) {
- taskIDs, _ := body["ids"].([]string)
- items := make([]taskdto.SunoDataResponse, 0, len(taskIDs))
- for _, taskID := range taskIDs {
- items = append(items, taskdto.SunoDataResponse{
- TaskID: taskID,
- Status: string(model.TaskStatusFailure),
- FailReason: a.failReason,
- FinishTime: time.Now().Unix(),
- })
+func (a *batchPollingAdaptor) FetchMode() string { return "batch" }
+func (a *batchPollingAdaptor) FetchBatchTasks(_ string, _ string, taskIDs []string, _ string) (*http.Response, error) {
+ a.batchCalls++
+ a.batchIDs = append([]string(nil), taskIDs...)
+ return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte(`{}`)))}, nil
+}
+func (a *batchPollingAdaptor) ParseBatchResult([]byte) (map[string]*BatchTaskResult, error) {
+ if a.results != nil {
+ return a.results, nil
}
-
- responseBody, err := common.Marshal(taskdto.TaskResponse[[]taskdto.SunoDataResponse]{
- Code: taskdto.TaskSuccessCode,
- Data: items,
- })
- if err != nil {
- return nil, err
+ results := make(map[string]*BatchTaskResult, len(a.batchIDs))
+ for _, taskID := range a.batchIDs {
+ results[taskID] = &BatchTaskResult{TaskInfo: relaycommon.TaskInfo{TaskID: taskID, Status: model.TaskStatusInProgress, Url: "https://example.com/result"}}
}
- return &http.Response{
- StatusCode: http.StatusOK,
- Body: io.NopCloser(bytes.NewReader(responseBody)),
- }, nil
-}
-
-func (a *sunoFailurePollingAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) {
- return nil, nil
-}
-
-func (a *sunoFailurePollingAdaptor) AdjustBillingOnComplete(_ *model.Task, _ *relaycommon.TaskInfo) int {
- return 0
+ return results, nil
}
func (a *taskPollingFetchAdaptor) Init(_ *relaycommon.RelayInfo) {}
@@ -130,6 +117,43 @@ func (a *taskPollingFetchAdaptor) fetchedTaskIDs() []string {
return append([]string(nil), a.taskIDs...)
}
+func TestRedactVideoResponseBodyPreservesPollingPayloadShape(t *testing.T) {
+ rawVideo := strings.Repeat("a", 300)
+ body, err := common.Marshal(map[string]any{
+ "done": true,
+ "name": "operations/provider-task",
+ "response": map[string]any{
+ "bytesBase64Encoded": "secret-bytes",
+ "video": rawVideo,
+ "videos": []any{
+ map[string]any{
+ "bytesBase64Encoded": "other-secret-bytes",
+ "mimeType": "video/mp4",
+ "uri": "https://media.example/video.mp4",
+ },
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ var stored map[string]any
+ require.NoError(t, common.Unmarshal(redactVideoResponseBody(body), &stored))
+ assert.Equal(t, true, stored["done"])
+ assert.Equal(t, "operations/provider-task", stored["name"])
+ response, ok := stored["response"].(map[string]any)
+ require.True(t, ok)
+ assert.NotContains(t, response, "bytesBase64Encoded")
+ assert.Equal(t, strings.Repeat("a", 256)+"...", response["video"])
+ videos, ok := response["videos"].([]any)
+ require.True(t, ok)
+ require.Len(t, videos, 1)
+ video, ok := videos[0].(map[string]any)
+ require.True(t, ok)
+ assert.NotContains(t, video, "bytesBase64Encoded")
+ assert.Equal(t, "video/mp4", video["mimeType"])
+ assert.Equal(t, "https://media.example/video.mp4", video["uri"])
+}
+
func seedTaskPollingChannel(t *testing.T, id int, disableSleep bool) {
t.Helper()
ch := &model.Channel{
@@ -152,7 +176,7 @@ func seedPollingTask(t *testing.T, channelID int, publicID string, upstreamID st
Platform: constant.TaskPlatform("kling"),
UserId: 1,
ChannelId: channelID,
- Action: constant.TaskActionGenerate,
+ Action: constant.TaskActionImageToVideo,
Status: model.TaskStatusInProgress,
Progress: "30%",
CreatedAt: time.Now().Unix(),
@@ -195,6 +219,201 @@ func TestUpdateVideoTasksDefaultSleepWaitsBetweenTasks(t *testing.T) {
assert.Equal(t, 1, adaptor.fetchCount())
}
+func TestDispatchPlatformUpdateUsesFetchMode(t *testing.T) {
+ truncate(t)
+ const channelID = 109
+ seedTaskPollingChannel(t, channelID, true)
+ task := seedPollingTask(t, channelID, "task_batch", "upstream_batch")
+ taskChannels := map[int][]string{channelID: {task.GetUpstreamTaskID()}}
+ tasks := map[string]*model.Task{task.GetUpstreamTaskID(): task}
+
+ batch := &batchPollingAdaptor{}
+ previousFactory := GetTaskAdaptorFunc
+ GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor { return batch }
+ DispatchPlatformUpdate(context.Background(), "batch-plugin", taskChannels, tasks)
+ assert.Equal(t, 1, batch.batchCalls)
+ assert.Equal(t, 0, batch.fetchCount())
+ var persisted model.Task
+ require.NoError(t, model.DB.First(&persisted, task.ID).Error)
+ assert.Equal(t, "https://example.com/result", persisted.GetResultURL())
+
+ perTask := &taskPollingFetchAdaptor{}
+ GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor { return perTask }
+ DispatchPlatformUpdate(context.Background(), "per-task-plugin", taskChannels, tasks)
+ assert.Equal(t, 1, perTask.fetchCount())
+
+ GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor { return nil }
+ assert.NotPanics(t, func() { DispatchPlatformUpdate(context.Background(), "missing-plugin", taskChannels, tasks) })
+ GetTaskAdaptorFunc = previousFactory
+}
+
+func TestUpdateBatchTasksSettlesTieredUsageForTerminalStates(t *testing.T) {
+ testCases := []struct {
+ name string
+ status model.TaskStatus
+ units float64
+ actualQuota int
+ }{
+ {name: "success with usage", status: model.TaskStatusSuccess, units: 3, actualQuota: 3_000},
+ {name: "failure with usage", status: model.TaskStatusFailure, units: 3, actualQuota: 0},
+ {name: "success with zero usage", status: model.TaskStatusSuccess, units: 0, actualQuota: 0},
+ {name: "failure with zero usage", status: model.TaskStatusFailure, units: 0, actualQuota: 0},
+ }
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ truncate(t)
+
+ const userID, tokenID, channelID = 41, 41, 141
+ const initialQuota, preConsumedQuota = 10_000, 5_000
+ const tokenRemain = 8_000
+ seedUser(t, userID, initialQuota)
+ seedToken(t, tokenID, userID, "sk-batch-tiered", tokenRemain)
+ seedTaskPollingChannel(t, channelID, true)
+
+ expression := `tier("actual", u("units"))`
+ task := makeTask(userID, channelID, preConsumedQuota, tokenID, BillingSourceWallet, 0)
+ task.TaskID = "task_batch_tiered_" + string(testCase.status)
+ task.Platform = "batch-plugin"
+ task.PrivateData.UpstreamTaskID = "upstream_batch_tiered_" + string(testCase.status)
+ task.SetData(map[string]any{"provider_payload": "must-be-preserved"})
+ task.PrivateData.BillingContext.TieredSnapshot = &billingexpr.BillingSnapshot{
+ ExprString: expression,
+ ExprHash: billingexpr.ExprHashString(expression),
+ GroupRatio: 1,
+ QuotaPerUnit: 1_000,
+ ExprVersion: 1,
+ TaskUsageBilling: true,
+ }
+ require.NoError(t, model.DB.Create(task).Error)
+
+ upstreamID := task.GetUpstreamTaskID()
+ reason := ""
+ if testCase.status == model.TaskStatusFailure {
+ reason = "upstream failed"
+ }
+ result := &BatchTaskResult{TaskInfo: relaycommon.TaskInfo{
+ TaskID: upstreamID,
+ Status: string(testCase.status),
+ Reason: reason,
+ UsageFacts: map[string]any{"units": testCase.units},
+ }}
+ adaptor := &batchPollingAdaptor{results: map[string]*BatchTaskResult{upstreamID: result}}
+ taskIDs := []string{upstreamID}
+ taskMap := map[string]*model.Task{upstreamID: task}
+
+ require.NoError(t, UpdateBatchTasks(context.Background(), adaptor, map[int][]string{channelID: taskIDs}, taskMap))
+
+ var persisted model.Task
+ require.NoError(t, model.DB.First(&persisted, task.ID).Error)
+ assert.Equal(t, testCase.status, persisted.Status)
+ assert.Equal(t, testCase.actualQuota, persisted.Quota)
+ var persistedData map[string]any
+ require.NoError(t, common.Unmarshal(persisted.Data, &persistedData))
+ assert.Equal(t, "must-be-preserved", persistedData["provider_payload"])
+ assert.Equal(t, initialQuota+(preConsumedQuota-testCase.actualQuota), getUserQuota(t, userID))
+ assert.Equal(t, tokenRemain+(preConsumedQuota-testCase.actualQuota), getTokenRemainQuota(t, tokenID))
+ assert.Equal(t, int64(1), countLogs(t))
+ if testCase.status == model.TaskStatusFailure {
+ log := getLastLog(t)
+ require.NotNil(t, log)
+ assert.Equal(t, model.LogTypeRefund, log.Type)
+ }
+
+ // A duplicate terminal response must not settle the same task twice.
+ require.NoError(t, UpdateBatchTasks(context.Background(), adaptor, map[int][]string{channelID: taskIDs}, taskMap))
+ assert.Equal(t, initialQuota+(preConsumedQuota-testCase.actualQuota), getUserQuota(t, userID))
+ assert.Equal(t, int64(1), countLogs(t))
+ })
+ }
+}
+
+func TestUpdateBatchTasksRefundsFailedTieredTask(t *testing.T) {
+ truncate(t)
+
+ const userID, tokenID, channelID = 43, 43, 143
+ const initialQuota, preConsumedQuota, tokenRemain = 10_000, 5_000, 8_000
+ seedUser(t, userID, initialQuota)
+ seedToken(t, tokenID, userID, "sk-batch-tiered-refund", tokenRemain)
+ seedTaskPollingChannel(t, channelID, true)
+
+ expression := `tier("actual", u("units"))`
+ task := makeTask(userID, channelID, preConsumedQuota, tokenID, BillingSourceWallet, 0)
+ task.TaskID = "task_batch_tiered_refund"
+ task.Platform = "batch-plugin"
+ task.PrivateData.UpstreamTaskID = "upstream_batch_tiered_refund"
+ task.PrivateData.BillingContext.TieredSnapshot = &billingexpr.BillingSnapshot{
+ ExprString: expression,
+ ExprHash: billingexpr.ExprHashString(expression),
+ GroupRatio: 1,
+ QuotaPerUnit: 1_000,
+ ExprVersion: 1,
+ TaskUsageBilling: true,
+ UsageFacts: map[string]any{"units": float64(5)},
+ EstimatedTier: "actual",
+ }
+ require.NoError(t, model.DB.Create(task).Error)
+
+ upstreamID := task.GetUpstreamTaskID()
+ adaptor := &batchPollingAdaptor{results: map[string]*BatchTaskResult{
+ upstreamID: {TaskInfo: relaycommon.TaskInfo{
+ TaskID: upstreamID,
+ Status: model.TaskStatusFailure,
+ Reason: "upstream failed",
+ UsageFacts: map[string]any{"units": float64(5)},
+ }},
+ }}
+ require.NoError(t, UpdateBatchTasks(context.Background(), adaptor, map[int][]string{channelID: {upstreamID}}, map[string]*model.Task{upstreamID: task}))
+
+ var persisted model.Task
+ require.NoError(t, model.DB.First(&persisted, task.ID).Error)
+ assert.EqualValues(t, model.TaskStatusFailure, persisted.Status)
+ assert.Zero(t, persisted.Quota)
+ assert.Equal(t, initialQuota+preConsumedQuota, getUserQuota(t, userID))
+ assert.Equal(t, tokenRemain+preConsumedQuota, getTokenRemainQuota(t, tokenID))
+
+ log := getLastLog(t)
+ require.NotNil(t, log)
+ assert.Equal(t, model.LogTypeRefund, log.Type)
+ assert.Equal(t, preConsumedQuota, log.Quota)
+ var other map[string]any
+ require.NoError(t, common.UnmarshalJsonStr(log.Other, &other))
+ assert.Equal(t, "tiered_expr", other["billing_mode"])
+ assert.Equal(t, "actual", other["matched_tier"])
+ facts, ok := other["usage_facts"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, map[string]any{"units": float64(5)}, facts)
+}
+
+func TestUpdateBatchTasksRefundsFailedTaskWithoutUsageSettlement(t *testing.T) {
+ truncate(t)
+
+ const userID, tokenID, channelID = 42, 42, 142
+ const initialQuota, preConsumedQuota, tokenRemain = 10_000, 4_000, 7_000
+ seedUser(t, userID, initialQuota)
+ seedToken(t, tokenID, userID, "sk-batch-refund", tokenRemain)
+ seedTaskPollingChannel(t, channelID, true)
+
+ task := makeTask(userID, channelID, preConsumedQuota, tokenID, BillingSourceWallet, 0)
+ task.TaskID = "task_batch_refund"
+ task.Platform = "batch-plugin"
+ task.Properties.OriginModelName = "missing-batch-token-price"
+ task.PrivateData.UpstreamTaskID = "upstream_batch_refund"
+ task.PrivateData.BillingContext.OriginModelName = "missing-batch-token-price"
+ require.NoError(t, model.DB.Create(task).Error)
+
+ upstreamID := task.GetUpstreamTaskID()
+ adaptor := &batchPollingAdaptor{results: map[string]*BatchTaskResult{
+ upstreamID: {TaskInfo: relaycommon.TaskInfo{TaskID: upstreamID, Status: model.TaskStatusFailure, Reason: "upstream failed", TotalTokens: 123}},
+ }}
+ require.NoError(t, UpdateBatchTasks(context.Background(), adaptor, map[int][]string{channelID: {upstreamID}}, map[string]*model.Task{upstreamID: task}))
+
+ assert.Equal(t, initialQuota+preConsumedQuota, getUserQuota(t, userID))
+ assert.Equal(t, tokenRemain+preConsumedQuota, getTokenRemainQuota(t, tokenID))
+ log := getLastLog(t)
+ require.NotNil(t, log)
+ assert.Equal(t, model.LogTypeRefund, log.Type)
+}
+
func TestUpdateVideoTasksCanSkipPollingSleepPerChannel(t *testing.T) {
truncate(t)
@@ -405,15 +624,21 @@ func TestUpdateSunoTasksStalePollsRefundExactlyOnce(t *testing.T) {
require.NoError(t, model.DB.First(&firstPollTask, task.ID).Error)
require.NoError(t, model.DB.First(&staleSecondPollTask, task.ID).Error)
- adaptor := &sunoFailurePollingAdaptor{failReason: "upstream failed"}
+ adaptor := &batchPollingAdaptor{results: map[string]*BatchTaskResult{
+ upstreamTaskID: {TaskInfo: relaycommon.TaskInfo{
+ TaskID: upstreamTaskID,
+ Status: model.TaskStatusFailure,
+ Reason: "upstream failed",
+ }},
+ }}
previousFactory := GetTaskAdaptorFunc
GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor { return adaptor }
t.Cleanup(func() { GetTaskAdaptorFunc = previousFactory })
- require.NoError(t, updateSunoTasks(context.Background(), channelID, []string{upstreamTaskID}, map[string]*model.Task{
+ require.NoError(t, updateBatchTasks(context.Background(), adaptor, channelID, []string{upstreamTaskID}, map[string]*model.Task{
upstreamTaskID: &firstPollTask,
}))
- require.NoError(t, updateSunoTasks(context.Background(), channelID, []string{upstreamTaskID}, map[string]*model.Task{
+ require.NoError(t, updateBatchTasks(context.Background(), adaptor, channelID, []string{upstreamTaskID}, map[string]*model.Task{
upstreamTaskID: &staleSecondPollTask,
}))
diff --git a/setting/billing_setting/tiered_billing.go b/setting/billing_setting/tiered_billing.go
index 46dc70de257f..a7e32b29aad6 100644
--- a/setting/billing_setting/tiered_billing.go
+++ b/setting/billing_setting/tiered_billing.go
@@ -2,8 +2,14 @@ package billing_setting
import (
"fmt"
+ "math"
+ "sort"
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/pkg/billingexpr"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/setting/config"
"github.com/samber/lo"
)
@@ -13,6 +19,7 @@ const (
BillingModeTieredExpr = "tiered_expr"
BillingModeField = "billing_mode"
BillingExprField = "billing_expr"
+ maxTaskExprSmokeTests = 64
)
// BillingSetting is managed by config.GlobalConfig.Register.
@@ -75,32 +82,173 @@ func SmokeTestExpr(exprStr string) error {
}
func smokeTestExpr(exprStr string) error {
+ if _, err := billingexpr.CompileFromCache(exprStr); err != nil {
+ return err
+ }
+ usageKeys := billingexpr.UsedUsageKeys(exprStr)
+ if len(usageKeys) > 0 {
+ sortedKeys := make([]string, 0, len(usageKeys))
+ for key := range usageKeys {
+ sortedKeys = append(sortedKeys, key)
+ }
+ sort.Strings(sortedKeys)
+ return fmt.Errorf("expression references usage keys %v but the model has no task plugin usage schema", sortedKeys)
+ }
+
vectors := []billingexpr.TokenParams{
{P: 0, C: 0, Len: 0},
{P: 1000, C: 1000, Len: 1000},
{P: 100000, C: 100000, Len: 100000},
{P: 1000000, C: 1000000, Len: 1000000},
}
- requests := []billingexpr.RequestInput{
- {},
- {
- Headers: map[string]string{
- "anthropic-beta": "fast-mode-2026-02-01",
- },
- Body: []byte(`{"service_tier":"fast","stream_options":{"include_usage":true},"messages":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21]}`),
- },
- }
for _, v := range vectors {
- for _, request := range requests {
+ for _, request := range billingExprSmokeRequests() {
result, _, err := billingexpr.RunExprWithRequest(exprStr, v, request)
if err != nil {
return fmt.Errorf("vector {p=%g, c=%g}: run failed: %w", v.P, v.C, err)
}
- if result < 0 {
- return fmt.Errorf("vector {p=%g, c=%g}: result %f < 0", v.P, v.C, result)
+ if math.IsNaN(result) || math.IsInf(result, 0) || result < 0 {
+ return fmt.Errorf("vector {p=%g, c=%g}: result must be finite and non-negative, got %f", v.P, v.C, result)
+ }
+ }
+ }
+ return nil
+}
+
+// SmokeTestTaskExpr validates a task usage expression against the usage facts
+// declared by its plugin. Literal u() keys must be declared; dynamic calls are
+// still exercised by the generated runtime vectors when possible.
+func SmokeTestTaskExpr(exprStr string, schema map[string]jsplugin.UsageFieldSchema) error {
+ if _, err := billingexpr.CompileFromCache(exprStr); err != nil {
+ return err
+ }
+ for key := range billingexpr.UsedUsageKeys(exprStr) {
+ if _, declared := schema[key]; !declared {
+ return fmt.Errorf("usage key %q is not declared by the task plugin", key)
+ }
+ }
+
+ for _, usage := range taskUsageSmokeVectors(schema) {
+ for _, request := range billingExprSmokeRequests() {
+ request.Usage = usage
+ result, _, err := billingexpr.RunExprWithRequest(exprStr, billingexpr.TokenParams{}, request)
+ if err != nil {
+ return fmt.Errorf("usage vector %v: run failed: %w", usage, err)
+ }
+ if math.IsNaN(result) || math.IsInf(result, 0) || result < 0 {
+ return fmt.Errorf("usage vector %v: result must be finite and non-negative, got %f", usage, result)
}
}
}
return nil
}
+
+type usageSmokeDimension struct {
+ name string
+ values []any
+}
+
+func taskUsageSmokeVectors(schema map[string]jsplugin.UsageFieldSchema) []map[string]any {
+ names := make([]string, 0, len(schema))
+ for name := range schema {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+
+ dimensions := make([]usageSmokeDimension, 0, len(names))
+ for _, name := range names {
+ field := schema[name]
+ if len(field.Enum) > 0 {
+ values := make([]any, len(field.Enum))
+ for index, value := range field.Enum {
+ values[index] = value
+ }
+ dimensions = append(dimensions, usageSmokeDimension{name: name, values: values})
+ continue
+ }
+ if field.Type == "boolean" {
+ dimensions = append(dimensions, usageSmokeDimension{name: name, values: []any{false, true}})
+ continue
+ }
+ limit := relaycommon.MaxTaskDurationSeconds
+ if field.Unit == "count" {
+ limit = dto.MaxImageN
+ }
+ if field.Unit == "token" || field.Unit == "credit" {
+ limit = common.MaxQuota
+ }
+ dimensions = append(dimensions, usageSmokeDimension{
+ name: name,
+ values: []any{float64(0), float64(1), float64(limit)},
+ })
+ }
+
+ if usageSmokeCombinationCount(dimensions, maxTaskExprSmokeTests) > maxTaskExprSmokeTests {
+ for index := range dimensions {
+ field := schema[dimensions[index].name]
+ if len(field.Enum) <= 2 {
+ continue
+ }
+ dimensions[index].values = []any{field.Enum[0], field.Enum[len(field.Enum)-1]}
+ }
+ }
+
+ vectors := make([]map[string]any, 0, maxTaskExprSmokeTests)
+ var appendVectors func(int, map[string]any)
+ appendVectors = func(index int, current map[string]any) {
+ if len(vectors) >= maxTaskExprSmokeTests {
+ return
+ }
+ if index == len(dimensions) {
+ vector := make(map[string]any, len(current))
+ for key, value := range current {
+ vector[key] = value
+ }
+ vectors = append(vectors, vector)
+ return
+ }
+ for _, value := range dimensions[index].values {
+ current[dimensions[index].name] = value
+ appendVectors(index+1, current)
+ }
+ delete(current, dimensions[index].name)
+ }
+ appendVectors(0, make(map[string]any, len(dimensions)))
+
+ combinationCount := usageSmokeCombinationCount(dimensions, maxTaskExprSmokeTests)
+ if combinationCount > maxTaskExprSmokeTests && len(vectors) > 0 {
+ last := make(map[string]any, len(dimensions))
+ for _, dimension := range dimensions {
+ last[dimension.name] = dimension.values[len(dimension.values)-1]
+ }
+ vectors[len(vectors)-1] = last
+ }
+ return vectors
+}
+
+func usageSmokeCombinationCount(dimensions []usageSmokeDimension, stopAfter int) int {
+ count := 1
+ for _, dimension := range dimensions {
+ if len(dimension.values) == 0 {
+ return 0
+ }
+ if count > stopAfter/len(dimension.values) {
+ return stopAfter + 1
+ }
+ count *= len(dimension.values)
+ }
+ return count
+}
+
+func billingExprSmokeRequests() []billingexpr.RequestInput {
+ return []billingexpr.RequestInput{
+ {},
+ {
+ Headers: map[string]string{
+ "anthropic-beta": "fast-mode-2026-02-01",
+ },
+ Body: []byte(`{"service_tier":"fast","stream_options":{"include_usage":true},"messages":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21]}`),
+ },
+ }
+}
diff --git a/setting/billing_setting/tiered_billing_test.go b/setting/billing_setting/tiered_billing_test.go
new file mode 100644
index 000000000000..1ce748a63ea1
--- /dev/null
+++ b/setting/billing_setting/tiered_billing_test.go
@@ -0,0 +1,105 @@
+package billing_setting
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSmokeTestTaskExprValidatesDeclaredUsageVectors(t *testing.T) {
+ videoSchema := map[string]jsplugin.UsageFieldSchema{
+ "seconds": {Type: "number", Unit: "second"},
+ "mode": {Enum: []string{"std", "pro"}},
+ "quality": {Enum: []string{"sd", "hd"}},
+ }
+
+ tests := []struct {
+ name string
+ schema map[string]jsplugin.UsageFieldSchema
+ expression string
+ expectedError string
+ }{
+ {
+ name: "declared numeric and enum facts",
+ schema: videoSchema,
+ expression: `u("mode") == "pro" ? tier("pro", u("seconds") * 0.8) : tier("std", u("seconds") * 0.4)`,
+ },
+ {
+ name: "undeclared literal key",
+ schema: videoSchema,
+ expression: `tier("base", u("clips") * 0.1)`,
+ expectedError: `usage key "clips" is not declared`,
+ },
+ {
+ name: "negative duration boundary",
+ schema: videoSchema,
+ expression: fmt.Sprintf(`u("seconds") == %d ? -1 : 0`, relaycommon.MaxTaskDurationSeconds),
+ expectedError: "result must be finite and non-negative",
+ },
+ {
+ name: "negative count boundary",
+ schema: map[string]jsplugin.UsageFieldSchema{"clips": {Type: "number", Unit: "count"}},
+ expression: fmt.Sprintf(`u("clips") == %d ? -1 : 0`, dto.MaxImageN),
+ expectedError: "result must be finite and non-negative",
+ },
+ {
+ name: "negative token boundary",
+ schema: map[string]jsplugin.UsageFieldSchema{"tokens": {Type: "number", Unit: "token"}},
+ expression: fmt.Sprintf(`u("tokens") == %d ? -1 : 0`, common.MaxQuota),
+ expectedError: "result must be finite and non-negative",
+ },
+ {
+ name: "negative credit boundary",
+ schema: map[string]jsplugin.UsageFieldSchema{"units": {Type: "number", Unit: "credit"}},
+ expression: fmt.Sprintf(`u("units") == %d ? -1 : 0`, common.MaxQuota),
+ expectedError: "result must be finite and non-negative",
+ },
+ {
+ name: "negative enum combination",
+ schema: videoSchema,
+ expression: `u("mode") == "pro" && u("quality") == "hd" ? -1 : 0`,
+ expectedError: "result must be finite and non-negative",
+ },
+ }
+
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ err := SmokeTestTaskExpr(testCase.expression, testCase.schema)
+ if testCase.expectedError == "" {
+ require.NoError(t, err)
+ return
+ }
+ require.ErrorContains(t, err, testCase.expectedError)
+ })
+ }
+}
+
+func TestSmokeTestTaskExprCapsOversizedEnumProductsAtLastCombination(t *testing.T) {
+ schema := make(map[string]jsplugin.UsageFieldSchema, 7)
+ condition := ""
+ for index := 0; index < 7; index++ {
+ schema[fmt.Sprintf("enum_%d", index)] = jsplugin.UsageFieldSchema{Enum: []string{"first", "middle", "last"}}
+ if condition != "" {
+ condition += " && "
+ }
+ condition += fmt.Sprintf(`u("enum_%d") == "last"`, index)
+ }
+
+ err := SmokeTestTaskExpr(condition+" ? -1 : 0", schema)
+ require.ErrorContains(t, err, "result must be finite and non-negative")
+}
+
+func TestSmokeTestExprRejectsTaskUsageWithoutSchema(t *testing.T) {
+ err := SmokeTestExpr(`u("mode") == "std" ? 1 : 2`)
+ require.Error(t, err)
+ assert.ErrorContains(t, err, "mode")
+ assert.ErrorContains(t, err, "no task plugin usage schema")
+
+ require.NoError(t, SmokeTestExpr(`tier("base", p * 2 + c * 8)`))
+}
diff --git a/setting/system_setting/system_setting_old.go b/setting/system_setting/system_setting_old.go
index 4e0f1a502087..737bd3820019 100644
--- a/setting/system_setting/system_setting_old.go
+++ b/setting/system_setting/system_setting_old.go
@@ -1,6 +1,7 @@
package system_setting
var ServerAddress = "http://localhost:3000"
+var TaskPublicAddress = ""
var WorkerUrl = ""
var WorkerValidKey = ""
var WorkerAllowHttpImageRequestEnabled = false
diff --git a/setting/system_setting/task_artifact.go b/setting/system_setting/task_artifact.go
new file mode 100644
index 000000000000..54b0bf9785a7
--- /dev/null
+++ b/setting/system_setting/task_artifact.go
@@ -0,0 +1,56 @@
+package system_setting
+
+import "github.com/QuantumNous/new-api/common"
+
+const (
+ DefaultTaskArtifactInvalidRateLimitPerMinute = 60
+ DefaultTaskArtifactGlobalConcurrency = 128
+ DefaultTaskArtifactIPConcurrency = 64
+ DefaultTaskArtifactObjectConcurrency = 16
+)
+
+const (
+ TaskArtifactInvalidRateLimitEnv = "TASK_ARTIFACT_INVALID_RATE_LIMIT_PER_MINUTE"
+ TaskArtifactGlobalLimitEnv = "TASK_ARTIFACT_GLOBAL_CONCURRENCY"
+ TaskArtifactIPLimitEnv = "TASK_ARTIFACT_IP_CONCURRENCY"
+ TaskArtifactObjectLimitEnv = "TASK_ARTIFACT_OBJECT_CONCURRENCY"
+)
+
+type TaskArtifactAccessLimits struct {
+ InvalidRatePerMinute int
+ GlobalConcurrency int
+ IPConcurrency int
+ ObjectConcurrency int
+}
+
+func positiveTaskArtifactLimit(env string, defaultValue int) int {
+ value := common.GetEnvOrDefault(env, defaultValue)
+ if value <= 0 {
+ common.SysError(env + " must be positive; using default")
+ return defaultValue
+ }
+ return value
+}
+
+// LoadTaskArtifactAccessLimits reads startup configuration. Invalid and
+// non-positive values safely fall back to the documented defaults.
+func LoadTaskArtifactAccessLimits() TaskArtifactAccessLimits {
+ return TaskArtifactAccessLimits{
+ InvalidRatePerMinute: positiveTaskArtifactLimit(
+ TaskArtifactInvalidRateLimitEnv,
+ DefaultTaskArtifactInvalidRateLimitPerMinute,
+ ),
+ GlobalConcurrency: positiveTaskArtifactLimit(
+ TaskArtifactGlobalLimitEnv,
+ DefaultTaskArtifactGlobalConcurrency,
+ ),
+ IPConcurrency: positiveTaskArtifactLimit(
+ TaskArtifactIPLimitEnv,
+ DefaultTaskArtifactIPConcurrency,
+ ),
+ ObjectConcurrency: positiveTaskArtifactLimit(
+ TaskArtifactObjectLimitEnv,
+ DefaultTaskArtifactObjectConcurrency,
+ ),
+ }
+}
diff --git a/setting/system_setting/task_artifact_store.go b/setting/system_setting/task_artifact_store.go
new file mode 100644
index 000000000000..8bdfe393b4ca
--- /dev/null
+++ b/setting/system_setting/task_artifact_store.go
@@ -0,0 +1,165 @@
+package system_setting
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "net/url"
+ "regexp"
+ "strings"
+ "unicode"
+
+ "github.com/QuantumNous/new-api/common"
+)
+
+const (
+ TaskArtifactStoreModeUpstream = "upstream"
+ TaskArtifactStoreModeS3 = "s3"
+
+ DefaultTaskArtifactStorePresignTTLSeconds = 900
+ MaxTaskArtifactStorePresignTTLSeconds = 7 * 24 * 60 * 60
+)
+
+const (
+ TaskArtifactStoreModeEnv = "TASK_ARTIFACT_STORE_MODE"
+ TaskArtifactStoreS3EndpointEnv = "TASK_ARTIFACT_STORE_S3_ENDPOINT"
+ TaskArtifactStoreS3BucketEnv = "TASK_ARTIFACT_STORE_S3_BUCKET"
+ TaskArtifactStoreS3RegionEnv = "TASK_ARTIFACT_STORE_S3_REGION"
+ TaskArtifactStoreS3AccessKeyEnv = "TASK_ARTIFACT_STORE_S3_ACCESS_KEY"
+ TaskArtifactStoreS3SecretKeyEnv = "TASK_ARTIFACT_STORE_S3_SECRET_KEY"
+ TaskArtifactStoreS3PrefixEnv = "TASK_ARTIFACT_STORE_S3_PREFIX"
+ TaskArtifactStoreS3PresignTTLEnv = "TASK_ARTIFACT_STORE_S3_PRESIGN_TTL"
+)
+
+var (
+ taskArtifactStoreBucketPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$`)
+ taskArtifactStoreRegionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
+)
+
+// TaskArtifactStoreConfig reserves the configuration contract for a future S3
+// implementation. The current release always falls back to upstream proxying.
+type TaskArtifactStoreConfig struct {
+ Mode string
+ S3Endpoint string
+ S3Bucket string
+ S3Region string
+ S3AccessKey string
+ S3SecretKey string
+ S3Prefix string
+ S3PresignTTLSeconds int
+}
+
+// LoadTaskArtifactStoreConfig reads and validates startup-only configuration.
+// S3 mode is deliberately disabled until a storage implementation is shipped.
+func LoadTaskArtifactStoreConfig() TaskArtifactStoreConfig {
+ config := TaskArtifactStoreConfig{
+ Mode: common.GetEnvOrDefaultString(TaskArtifactStoreModeEnv, TaskArtifactStoreModeUpstream),
+ S3Endpoint: common.GetEnvOrDefaultString(TaskArtifactStoreS3EndpointEnv, ""),
+ S3Bucket: common.GetEnvOrDefaultString(TaskArtifactStoreS3BucketEnv, ""),
+ S3Region: common.GetEnvOrDefaultString(TaskArtifactStoreS3RegionEnv, ""),
+ S3AccessKey: common.GetEnvOrDefaultString(TaskArtifactStoreS3AccessKeyEnv, ""),
+ S3SecretKey: common.GetEnvOrDefaultString(TaskArtifactStoreS3SecretKeyEnv, ""),
+ S3Prefix: common.GetEnvOrDefaultString(TaskArtifactStoreS3PrefixEnv, ""),
+ S3PresignTTLSeconds: common.GetEnvOrDefault(TaskArtifactStoreS3PresignTTLEnv, DefaultTaskArtifactStorePresignTTLSeconds),
+ }
+ if err := ValidateTaskArtifactStoreConfig(config); err != nil {
+ common.SysError("invalid task artifact store configuration: " + err.Error() + "; using upstream mode")
+ config.Mode = TaskArtifactStoreModeUpstream
+ return config
+ }
+ if config.Mode == TaskArtifactStoreModeS3 {
+ common.SysError("task artifact S3 storage is not implemented; using upstream mode")
+ config.Mode = TaskArtifactStoreModeUpstream
+ }
+ return config
+}
+
+// ValidateTaskArtifactStoreConfig performs syntax checks only. It never
+// resolves hosts, contacts an endpoint, or verifies credentials.
+func ValidateTaskArtifactStoreConfig(config TaskArtifactStoreConfig) error {
+ if config.Mode != TaskArtifactStoreModeUpstream && config.Mode != TaskArtifactStoreModeS3 {
+ return fmt.Errorf("unsupported mode %q", config.Mode)
+ }
+ if config.S3PresignTTLSeconds <= 0 || config.S3PresignTTLSeconds > MaxTaskArtifactStorePresignTTLSeconds {
+ return fmt.Errorf("S3 presign TTL must be between 1 and %d seconds", MaxTaskArtifactStorePresignTTLSeconds)
+ }
+
+ requireS3Fields := config.Mode == TaskArtifactStoreModeS3
+ if requireS3Fields && config.S3Endpoint == "" {
+ return errors.New("S3 endpoint is required")
+ }
+ if config.S3Endpoint != "" {
+ if config.S3Endpoint != strings.TrimSpace(config.S3Endpoint) {
+ return errors.New("S3 endpoint must not contain surrounding whitespace")
+ }
+ endpoint, err := url.Parse(config.S3Endpoint)
+ if err != nil || endpoint == nil || endpoint.Host == "" || endpoint.User != nil || endpoint.Opaque != "" {
+ return errors.New("S3 endpoint must be an absolute URL without userinfo")
+ }
+ if endpoint.Scheme != "http" && endpoint.Scheme != "https" {
+ return errors.New("S3 endpoint must use http or https")
+ }
+ if endpoint.RawQuery != "" || endpoint.ForceQuery || endpoint.Fragment != "" {
+ return errors.New("S3 endpoint must not contain a query or fragment")
+ }
+ }
+
+ if requireS3Fields && config.S3Bucket == "" {
+ return errors.New("S3 bucket is required")
+ }
+ if config.S3Bucket != "" {
+ if !taskArtifactStoreBucketPattern.MatchString(config.S3Bucket) ||
+ strings.Contains(config.S3Bucket, "..") || net.ParseIP(config.S3Bucket) != nil {
+ return errors.New("S3 bucket syntax is invalid")
+ }
+ }
+
+ if requireS3Fields && config.S3Region == "" {
+ return errors.New("S3 region is required")
+ }
+ if config.S3Region != "" && !taskArtifactStoreRegionPattern.MatchString(config.S3Region) {
+ return errors.New("S3 region syntax is invalid")
+ }
+ if err := validateTaskArtifactStoreCredential("access key", config.S3AccessKey, 256, requireS3Fields); err != nil {
+ return err
+ }
+ if err := validateTaskArtifactStoreCredential("secret key", config.S3SecretKey, 1024, requireS3Fields); err != nil {
+ return err
+ }
+
+ if config.S3Prefix != "" {
+ if config.S3Prefix != strings.TrimSpace(config.S3Prefix) || len(config.S3Prefix) > 512 ||
+ strings.HasPrefix(config.S3Prefix, "/") || strings.Contains(config.S3Prefix, "\\") {
+ return errors.New("S3 prefix syntax is invalid")
+ }
+ for _, part := range strings.Split(config.S3Prefix, "/") {
+ if part == "." || part == ".." {
+ return errors.New("S3 prefix must not contain dot segments")
+ }
+ }
+ for _, character := range config.S3Prefix {
+ if unicode.IsControl(character) {
+ return errors.New("S3 prefix must not contain control characters")
+ }
+ }
+ }
+ return nil
+}
+
+func validateTaskArtifactStoreCredential(name, value string, maxLength int, required bool) error {
+ if required && value == "" {
+ return fmt.Errorf("S3 %s is required", name)
+ }
+ if value == "" {
+ return nil
+ }
+ if value != strings.TrimSpace(value) || len(value) > maxLength {
+ return fmt.Errorf("S3 %s syntax is invalid", name)
+ }
+ for _, character := range value {
+ if unicode.IsControl(character) {
+ return fmt.Errorf("S3 %s syntax is invalid", name)
+ }
+ }
+ return nil
+}
diff --git a/setting/system_setting/task_artifact_store_test.go b/setting/system_setting/task_artifact_store_test.go
new file mode 100644
index 000000000000..e2505000abb1
--- /dev/null
+++ b/setting/system_setting/task_artifact_store_test.go
@@ -0,0 +1,76 @@
+package system_setting
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestValidateTaskArtifactStoreConfig(t *testing.T) {
+ valid := TaskArtifactStoreConfig{
+ Mode: TaskArtifactStoreModeS3,
+ S3Endpoint: "https://objects.example.com/storage",
+ S3Bucket: "task-artifacts",
+ S3Region: "us-east-1",
+ S3AccessKey: "access-key",
+ S3SecretKey: "secret-key",
+ S3Prefix: "tasks/v1/",
+ S3PresignTTLSeconds: 900,
+ }
+ require.NoError(t, ValidateTaskArtifactStoreConfig(valid))
+ require.NoError(t, ValidateTaskArtifactStoreConfig(TaskArtifactStoreConfig{
+ Mode: TaskArtifactStoreModeUpstream,
+ S3PresignTTLSeconds: DefaultTaskArtifactStorePresignTTLSeconds,
+ }))
+
+ tests := []struct {
+ name string
+ mutate func(*TaskArtifactStoreConfig)
+ match string
+ }{
+ {name: "mode", mutate: func(config *TaskArtifactStoreConfig) { config.Mode = "filesystem" }, match: "unsupported mode"},
+ {name: "endpoint scheme", mutate: func(config *TaskArtifactStoreConfig) { config.S3Endpoint = "ftp://objects.example.com" }, match: "http or https"},
+ {name: "endpoint credentials", mutate: func(config *TaskArtifactStoreConfig) { config.S3Endpoint = "https://user:pass@objects.example.com" }, match: "without userinfo"},
+ {name: "endpoint query", mutate: func(config *TaskArtifactStoreConfig) { config.S3Endpoint = "https://objects.example.com?token=secret" }, match: "query or fragment"},
+ {name: "bucket", mutate: func(config *TaskArtifactStoreConfig) { config.S3Bucket = "Invalid_Bucket" }, match: "bucket syntax"},
+ {name: "IP bucket", mutate: func(config *TaskArtifactStoreConfig) { config.S3Bucket = "192.168.1.1" }, match: "bucket syntax"},
+ {name: "region", mutate: func(config *TaskArtifactStoreConfig) { config.S3Region = "bad region" }, match: "region syntax"},
+ {name: "access key", mutate: func(config *TaskArtifactStoreConfig) { config.S3AccessKey = " access-key" }, match: "access key syntax"},
+ {name: "secret key", mutate: func(config *TaskArtifactStoreConfig) { config.S3SecretKey = "secret\nkey" }, match: "secret key syntax"},
+ {name: "prefix root", mutate: func(config *TaskArtifactStoreConfig) { config.S3Prefix = "/tasks" }, match: "prefix syntax"},
+ {name: "prefix traversal", mutate: func(config *TaskArtifactStoreConfig) { config.S3Prefix = "tasks/../private" }, match: "dot segments"},
+ {name: "TTL zero", mutate: func(config *TaskArtifactStoreConfig) { config.S3PresignTTLSeconds = 0 }, match: "presign TTL"},
+ {name: "TTL too long", mutate: func(config *TaskArtifactStoreConfig) {
+ config.S3PresignTTLSeconds = MaxTaskArtifactStorePresignTTLSeconds + 1
+ }, match: "presign TTL"},
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ config := valid
+ testCase.mutate(&config)
+ assert.ErrorContains(t, ValidateTaskArtifactStoreConfig(config), testCase.match)
+ })
+ }
+}
+
+func TestLoadTaskArtifactStoreConfigFallsBackToUpstream(t *testing.T) {
+ t.Setenv(TaskArtifactStoreModeEnv, "filesystem")
+ t.Setenv(TaskArtifactStoreS3PresignTTLEnv, "900")
+ config := LoadTaskArtifactStoreConfig()
+ assert.Equal(t, TaskArtifactStoreModeUpstream, config.Mode)
+
+ t.Setenv(TaskArtifactStoreModeEnv, TaskArtifactStoreModeS3)
+ t.Setenv(TaskArtifactStoreS3EndpointEnv, "https://objects.example.com")
+ t.Setenv(TaskArtifactStoreS3BucketEnv, "task-artifacts")
+ t.Setenv(TaskArtifactStoreS3RegionEnv, "us-east-1")
+ t.Setenv(TaskArtifactStoreS3AccessKeyEnv, "access-key")
+ t.Setenv(TaskArtifactStoreS3SecretKeyEnv, "secret-key")
+ t.Setenv(TaskArtifactStoreS3PrefixEnv, "tasks/v1")
+ t.Setenv(TaskArtifactStoreS3PresignTTLEnv, "600")
+ config = LoadTaskArtifactStoreConfig()
+
+ assert.Equal(t, TaskArtifactStoreModeUpstream, config.Mode)
+ assert.Equal(t, "https://objects.example.com", config.S3Endpoint)
+ assert.Equal(t, 600, config.S3PresignTTLSeconds)
+}
diff --git a/setting/system_setting/task_artifact_test.go b/setting/system_setting/task_artifact_test.go
new file mode 100644
index 000000000000..be31922377a0
--- /dev/null
+++ b/setting/system_setting/task_artifact_test.go
@@ -0,0 +1,33 @@
+package system_setting
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestLoadTaskArtifactAccessLimitsUsesPositiveEnvironmentValues(t *testing.T) {
+ t.Setenv(TaskArtifactInvalidRateLimitEnv, "17")
+ t.Setenv(TaskArtifactGlobalLimitEnv, "23")
+ t.Setenv(TaskArtifactIPLimitEnv, "11")
+ t.Setenv(TaskArtifactObjectLimitEnv, "7")
+
+ limits := LoadTaskArtifactAccessLimits()
+ assert.Equal(t, 17, limits.InvalidRatePerMinute)
+ assert.Equal(t, 23, limits.GlobalConcurrency)
+ assert.Equal(t, 11, limits.IPConcurrency)
+ assert.Equal(t, 7, limits.ObjectConcurrency)
+}
+
+func TestLoadTaskArtifactAccessLimitsFallsBackForInvalidValues(t *testing.T) {
+ t.Setenv(TaskArtifactInvalidRateLimitEnv, "0")
+ t.Setenv(TaskArtifactGlobalLimitEnv, "-1")
+ t.Setenv(TaskArtifactIPLimitEnv, "invalid")
+ t.Setenv(TaskArtifactObjectLimitEnv, "")
+
+ limits := LoadTaskArtifactAccessLimits()
+ assert.Equal(t, DefaultTaskArtifactInvalidRateLimitPerMinute, limits.InvalidRatePerMinute)
+ assert.Equal(t, DefaultTaskArtifactGlobalConcurrency, limits.GlobalConcurrency)
+ assert.Equal(t, DefaultTaskArtifactIPConcurrency, limits.IPConcurrency)
+ assert.Equal(t, DefaultTaskArtifactObjectConcurrency, limits.ObjectConcurrency)
+}
diff --git a/setting/task_plugin.go b/setting/task_plugin.go
new file mode 100644
index 000000000000..04731017f90b
--- /dev/null
+++ b/setting/task_plugin.go
@@ -0,0 +1,120 @@
+package setting
+
+import (
+ "sort"
+ "strings"
+
+ "github.com/QuantumNous/new-api/common"
+)
+
+const (
+ TaskPluginMarketplaceSourcesKey = "TaskPluginMarketplaceSources"
+ TaskPluginDisabledFactoryKeysKey = "TaskPluginDisabledFactoryKeys"
+
+ officialTaskPluginMarketplaceIndexURL = "https://www.newapi.ai/api/v1/plugins/index.json"
+ githubTaskPluginMarketplaceIndexURL = "https://raw.githubusercontent.com/QuantumNous/new-api-plugins/main/index.json"
+)
+
+type TaskPluginMarketplaceSource struct {
+ Name string `json:"name"`
+ IndexURL string `json:"index_url"`
+}
+
+func defaultTaskPluginMarketplaceSources() []TaskPluginMarketplaceSource {
+ return []TaskPluginMarketplaceSource{
+ {Name: "Official", IndexURL: officialTaskPluginMarketplaceIndexURL},
+ {Name: "GitHub", IndexURL: githubTaskPluginMarketplaceIndexURL},
+ }
+}
+
+func GetTaskPluginMarketplaceSources() []TaskPluginMarketplaceSource {
+ common.OptionMapRWMutex.RLock()
+ raw := ""
+ if common.OptionMap != nil {
+ raw = common.OptionMap[TaskPluginMarketplaceSourcesKey]
+ }
+ common.OptionMapRWMutex.RUnlock()
+
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return defaultTaskPluginMarketplaceSources()
+ }
+ var sources []TaskPluginMarketplaceSource
+ if err := common.UnmarshalJsonStr(raw, &sources); err != nil {
+ return defaultTaskPluginMarketplaceSources()
+ }
+ if sources == nil {
+ return []TaskPluginMarketplaceSource{}
+ }
+ return sources
+}
+
+func TaskPluginMarketplaceSources2JsonString() string {
+ encoded, err := common.Marshal(defaultTaskPluginMarketplaceSources())
+ if err != nil {
+ return "[]"
+ }
+ return string(encoded)
+}
+
+func ParseTaskPluginDisabledFactoryKeys(raw string) []string {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return []string{}
+ }
+ var keys []string
+ if err := common.Unmarshal([]byte(raw), &keys); err != nil {
+ return []string{}
+ }
+ if keys == nil {
+ return []string{}
+ }
+ return keys
+}
+
+func GetTaskPluginDisabledFactoryKeys() []string {
+ common.OptionMapRWMutex.RLock()
+ raw := ""
+ if common.OptionMap != nil {
+ raw = common.OptionMap[TaskPluginDisabledFactoryKeysKey]
+ }
+ common.OptionMapRWMutex.RUnlock()
+ return ParseTaskPluginDisabledFactoryKeys(raw)
+}
+
+func SetTaskPluginDisabledFactoryKeysOption(keys []string) error {
+ normalized := make([]string, 0, len(keys))
+ seen := make(map[string]struct{}, len(keys))
+ for _, key := range keys {
+ key = strings.TrimSpace(key)
+ if key == "" {
+ continue
+ }
+ if _, exists := seen[key]; exists {
+ continue
+ }
+ seen[key] = struct{}{}
+ normalized = append(normalized, key)
+ }
+ sort.Strings(normalized)
+ encoded, err := common.Marshal(normalized)
+ if err != nil {
+ return err
+ }
+ common.OptionMapRWMutex.Lock()
+ if common.OptionMap == nil {
+ common.OptionMap = make(map[string]string)
+ }
+ common.OptionMap[TaskPluginDisabledFactoryKeysKey] = string(encoded)
+ common.OptionMapRWMutex.Unlock()
+ return nil
+}
+
+func IsTaskPluginFactoryDisabled(key string) bool {
+ for _, item := range GetTaskPluginDisabledFactoryKeys() {
+ if item == key {
+ return true
+ }
+ }
+ return false
+}
diff --git a/setting/task_plugin_test.go b/setting/task_plugin_test.go
new file mode 100644
index 000000000000..8f5cbf8ad18c
--- /dev/null
+++ b/setting/task_plugin_test.go
@@ -0,0 +1,65 @@
+package setting
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func setupTaskPluginDisabledFactoryKeysTest(t *testing.T) {
+ t.Helper()
+ originalMap := common.OptionMap
+ common.OptionMapRWMutex.Lock()
+ common.OptionMap = map[string]string{}
+ common.OptionMapRWMutex.Unlock()
+ t.Cleanup(func() {
+ common.OptionMapRWMutex.Lock()
+ common.OptionMap = originalMap
+ common.OptionMapRWMutex.Unlock()
+ })
+}
+
+func TestTaskPluginDisabledFactoryKeysRoundTripAndDedupe(t *testing.T) {
+ setupTaskPluginDisabledFactoryKeysTest(t)
+
+ assert.Empty(t, GetTaskPluginDisabledFactoryKeys())
+ assert.False(t, IsTaskPluginFactoryDisabled("kling"))
+
+ require.NoError(t, SetTaskPluginDisabledFactoryKeysOption([]string{"kling", "sora", "kling", " hailuo "}))
+ assert.Equal(t, []string{"hailuo", "kling", "sora"}, GetTaskPluginDisabledFactoryKeys())
+ assert.Equal(t, `["hailuo","kling","sora"]`, common.OptionMap[TaskPluginDisabledFactoryKeysKey])
+ assert.True(t, IsTaskPluginFactoryDisabled("kling"))
+ assert.True(t, IsTaskPluginFactoryDisabled("hailuo"))
+ assert.False(t, IsTaskPluginFactoryDisabled("google"))
+}
+
+func TestTaskPluginDisabledFactoryKeysBadJSONReturnsEmpty(t *testing.T) {
+ setupTaskPluginDisabledFactoryKeysTest(t)
+
+ for _, testCase := range []struct {
+ name string
+ raw string
+ }{
+ {name: "absent", raw: ""},
+ {name: "null", raw: "null"},
+ {name: "object", raw: "{}"},
+ {name: "number", raw: "1"},
+ {name: "truncated", raw: `["kling"`},
+ {name: "not json", raw: "kling"},
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ common.OptionMapRWMutex.Lock()
+ if testCase.raw == "" {
+ delete(common.OptionMap, TaskPluginDisabledFactoryKeysKey)
+ } else {
+ common.OptionMap[TaskPluginDisabledFactoryKeysKey] = testCase.raw
+ }
+ common.OptionMapRWMutex.Unlock()
+
+ assert.Empty(t, GetTaskPluginDisabledFactoryKeys())
+ assert.False(t, IsTaskPluginFactoryDisabled("kling"))
+ })
+ }
+}
diff --git a/setting/task_pricing_setting/config.go b/setting/task_pricing_setting/config.go
new file mode 100644
index 000000000000..c4c439bfa00f
--- /dev/null
+++ b/setting/task_pricing_setting/config.go
@@ -0,0 +1,61 @@
+package task_pricing_setting
+
+import (
+ "strings"
+
+ "github.com/QuantumNous/new-api/setting/config"
+ "github.com/samber/lo"
+)
+
+type TaskPricingSetting struct {
+ SoraSizeRatio map[string]float64 `json:"sora_size_ratio"`
+ VertexResolution4K map[string]float64 `json:"vertex_resolution_4k_ratio"`
+}
+
+var taskPricingSetting = TaskPricingSetting{
+ SoraSizeRatio: map[string]float64{
+ "1792x1024": 1.666667,
+ "1024x1792": 1.666667,
+ },
+ VertexResolution4K: map[string]float64{
+ "veo-3.1-fast-generate": 2.333333,
+ "veo-3.1-generate": 1.5,
+ "veo-3.1": 1.5,
+ },
+}
+
+func init() {
+ config.GlobalConfig.Register("task_pricing_setting", &taskPricingSetting)
+}
+
+func SoraSizeRatio(size string) float64 {
+ if ratio, ok := taskPricingSetting.SoraSizeRatio[size]; ok && ratio > 0 {
+ return ratio
+ }
+ return 1
+}
+
+func VertexResolutionRatio(model, resolution string) float64 {
+ if !strings.EqualFold(resolution, "4k") {
+ return 1
+ }
+ matchedPattern := ""
+ matchedRatio := 1.0
+ for pattern, ratio := range taskPricingSetting.VertexResolution4K {
+ if !strings.Contains(model, pattern) || ratio <= 0 {
+ continue
+ }
+ if len(pattern) > len(matchedPattern) || (len(pattern) == len(matchedPattern) && pattern < matchedPattern) {
+ matchedPattern = pattern
+ matchedRatio = ratio
+ }
+ }
+ return matchedRatio
+}
+
+func GetCopy() TaskPricingSetting {
+ return TaskPricingSetting{
+ SoraSizeRatio: lo.Assign(taskPricingSetting.SoraSizeRatio),
+ VertexResolution4K: lo.Assign(taskPricingSetting.VertexResolution4K),
+ }
+}
diff --git a/setting/task_pricing_setting/config_test.go b/setting/task_pricing_setting/config_test.go
new file mode 100644
index 000000000000..4cb52ed847ad
--- /dev/null
+++ b/setting/task_pricing_setting/config_test.go
@@ -0,0 +1,33 @@
+package task_pricing_setting
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/setting/config"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTaskPricingDefaultsAndOptionUpdate(t *testing.T) {
+ original := GetCopy()
+ t.Cleanup(func() { taskPricingSetting = original })
+ assert.InDelta(t, 1.666667, SoraSizeRatio("1792x1024"), 0.000001)
+ assert.InDelta(t, 2.333333, VertexResolutionRatio("veo-3.1-fast-generate-preview", "4K"), 0.000001)
+ require.NoError(t, config.UpdateConfigFromMap(&taskPricingSetting, map[string]string{"sora_size_ratio": `{"1792x1024":2}`}))
+ assert.Equal(t, 2.0, SoraSizeRatio("1792x1024"))
+ assert.Equal(t, 1.0, SoraSizeRatio("720x1280"))
+}
+
+func TestVertexResolutionRatioPrefersMostSpecificModelPattern(t *testing.T) {
+ original := GetCopy()
+ t.Cleanup(func() { taskPricingSetting = original })
+ taskPricingSetting.VertexResolution4K = map[string]float64{
+ "veo-3.1": 1.5,
+ "veo-3.1-fast-generate": 2.333333,
+ "veo-3.1-fast-generate-preview": 3,
+ }
+
+ assert.Equal(t, 3.0, VertexResolutionRatio("veo-3.1-fast-generate-preview", "4K"))
+ assert.Equal(t, 2.333333, VertexResolutionRatio("veo-3.1-fast-generate", "4k"))
+ assert.Equal(t, 1.5, VertexResolutionRatio("veo-3.1-generate", "4K"))
+}
diff --git a/types/task_artifact.go b/types/task_artifact.go
new file mode 100644
index 000000000000..5fd03c35cbec
--- /dev/null
+++ b/types/task_artifact.go
@@ -0,0 +1,9 @@
+package types
+
+// TaskArtifact is the transport-neutral identity of one generated task output.
+// relay/channel re-exports this type for adaptor compatibility.
+type TaskArtifact struct {
+ Key string `json:"key"`
+ Type string `json:"type"`
+ MimeType string `json:"mimeType,omitempty"`
+}
diff --git a/web/.gitignore b/web/.gitignore
index a613398c2408..da89be04c33b 100644
--- a/web/.gitignore
+++ b/web/.gitignore
@@ -24,4 +24,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
-.tanstack/*
\ No newline at end of file
+.tanstack/*
+# i18n sync tool output (regenerated by `bun run i18n:sync`)
+src/i18n/locales/_reports/
diff --git a/web/bun.lock b/web/bun.lock
index 8e3d098022a3..763317b26dda 100644
--- a/web/bun.lock
+++ b/web/bun.lock
@@ -6,6 +6,7 @@
"name": "newapi-web",
"dependencies": {
"@base-ui/react": "^1.6.0",
+ "@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.0",
diff --git a/web/package.json b/web/package.json
index 2893f36b0d0f..d2d858c51dac 100644
--- a/web/package.json
+++ b/web/package.json
@@ -10,6 +10,9 @@
"typecheck": "tsgo -b",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint -c .oxlintrc.json . --fix",
+ "lint:plugins": "cd ../plugins && oxlint -c .oxlintrc.json .",
+ "format:plugins": "cd ../plugins && oxfmt -c .oxfmtrc.json --write .",
+ "format:plugins:check": "cd ../plugins && oxfmt -c .oxfmtrc.json --check .",
"preview": "rsbuild preview",
"test": "vitest run",
"test:watch": "vitest",
@@ -22,6 +25,7 @@
},
"dependencies": {
"@base-ui/react": "^1.6.0",
+ "@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.0",
diff --git a/web/rsbuild.config.ts b/web/rsbuild.config.ts
index 3b9d11de6a88..e0d9c25c46ca 100644
--- a/web/rsbuild.config.ts
+++ b/web/rsbuild.config.ts
@@ -17,7 +17,7 @@ export default defineConfig(({ envMode }) => {
const isProd = envMode === 'production'
const devProxy = Object.fromEntries(
- (['/api', '/mj', '/pg'] as const).map((key) => [
+ (['/api', '/v1', '/mj', '/pg'] as const).map((key) => [
key,
{ target: serverUrl, changeOrigin: true },
])
diff --git a/web/scripts/sync-i18n.mjs b/web/scripts/sync-i18n.mjs
index 51dd38974dae..ae99aafeae81 100644
--- a/web/scripts/sync-i18n.mjs
+++ b/web/scripts/sync-i18n.mjs
@@ -30,6 +30,7 @@ const OBFUSCATED_KEYS = [
]
const BRAND_AND_LITERAL_KEYS = new Set([
+ '1M token',
'AI Proxy',
'AIGC2D',
'Alipay',
@@ -47,6 +48,7 @@ const BRAND_AND_LITERAL_KEYS = new Set([
'Client Secret',
'Cloudflare',
'Cohere',
+ 'credit',
'DeepSeek',
'Discord',
'DoubaoVideo',
diff --git a/web/src/components/ai-elements/code-block.tsx b/web/src/components/ai-elements/code-block.tsx
index 77a73409bb3e..4abf55757b27 100644
--- a/web/src/components/ai-elements/code-block.tsx
+++ b/web/src/components/ai-elements/code-block.tsx
@@ -19,10 +19,15 @@ For commercial licensing, please contact support@quantumnous.com
/* eslint-disable react-refresh/only-export-components */
'use client'
+import { javascript } from '@codemirror/lang-javascript'
import { markdown } from '@codemirror/lang-markdown'
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { EditorState, type Extension } from '@codemirror/state'
-import { EditorView, lineNumbers } from '@codemirror/view'
+import {
+ EditorView,
+ lineNumbers,
+ placeholder as placeholderExtension,
+} from '@codemirror/view'
import { tags as highlightTags } from '@lezer/highlight'
import {
CheckIcon,
@@ -75,9 +80,11 @@ type CodeBlockEditorProps = Omit<
> & {
actions?: ReactNode
ariaLabel: string
+ autoFocus?: boolean
language: BundledLanguage | string
onChange: (value: string) => void
onKeyDown?: (event: globalThis.KeyboardEvent) => void
+ placeholder?: string
rows?: number
title?: ReactNode
value: string
@@ -89,6 +96,7 @@ type CodeMirrorCodeViewProps = {
language: BundledLanguage | string
onChange?: (value: string) => void
onKeyDown?: (event: globalThis.KeyboardEvent) => void
+ placeholder?: string
readOnly?: boolean
rows?: number
showLineNumbers?: boolean
@@ -223,6 +231,14 @@ function getCodeMirrorLanguageExtension(language: BundledLanguage | string) {
return markdown()
}
+ if (requestedLanguage === 'javascript' || requestedLanguage === 'jsx') {
+ return javascript({ jsx: requestedLanguage === 'jsx' })
+ }
+
+ if (requestedLanguage === 'typescript' || requestedLanguage === 'tsx') {
+ return javascript({ jsx: requestedLanguage === 'tsx', typescript: true })
+ }
+
return []
}
@@ -266,6 +282,7 @@ function getCodeBlockMaxHeight(
function getCodeMirrorExtensions(options: {
language: BundledLanguage | string
onKeyDown: (event: globalThis.KeyboardEvent) => void
+ placeholder?: string
readOnly: boolean
showLineNumbers: boolean
}): Extension[] {
@@ -284,6 +301,10 @@ function getCodeMirrorExtensions(options: {
}),
]
+ if (options.placeholder) {
+ extensions.push(placeholderExtension(options.placeholder))
+ }
+
if (options.showLineNumbers) {
extensions.unshift(lineNumbers())
}
@@ -297,6 +318,7 @@ function CodeMirrorCodeView({
language,
onChange,
onKeyDown,
+ placeholder,
readOnly = false,
rows = 8,
showLineNumbers = true,
@@ -317,10 +339,11 @@ function CodeMirrorCodeView({
getCodeMirrorExtensions({
language,
onKeyDown: (event) => onKeyDownRef.current?.(event),
+ placeholder,
readOnly,
showLineNumbers,
}),
- [language, readOnly, showLineNumbers]
+ [language, placeholder, readOnly, showLineNumbers]
)
useEffect(() => {
@@ -575,10 +598,12 @@ export const CodeBlock = ({
export const CodeBlockEditor = ({
actions,
ariaLabel,
+ autoFocus = true,
className,
language,
onChange,
onKeyDown,
+ placeholder,
rows = 8,
title,
value,
@@ -595,10 +620,11 @@ export const CodeBlockEditor = ({
>
{
+ const response = await api.get<{
+ success: boolean
+ data: TaskPluginOption[]
+ }>('/api/task_plugin_options')
+ return response.data.data
+}
+
export type CodexUsageResponse = {
success: boolean
message?: string
diff --git a/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx
index 56a4e950d6f7..93f6a0a1763e 100644
--- a/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx
+++ b/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx
@@ -134,6 +134,7 @@ import {
getChannelKey,
getGroups,
getPrefillGroups,
+ getTaskPluginOptions,
refreshCodexCredential,
} from '../../api'
import {
@@ -141,6 +142,8 @@ import {
CLAUDE_FIELD_PASSTHROUGH_TYPES,
CHANNEL_STATUS_LABELS,
CHANNEL_TYPE_OPTIONS,
+ CHANNEL_TYPE_TASK_PLUGIN,
+ channelTypeOptionsForTaskPluginBind,
CHANNEL_TYPE_WARNINGS,
ERROR_MESSAGES,
FIELD_PASSTHROUGH_TYPES,
@@ -620,6 +623,11 @@ export function ChannelMutateDrawer({
ADMIN_PERMISSION_RESOURCES.CHANNEL,
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
)
+ const canBindTaskPlugin = hasPermission(
+ currentUser,
+ ADMIN_PERMISSION_RESOURCES.TASK_PLUGIN,
+ ADMIN_PERMISSION_ACTIONS.BIND
+ )
const canRevealChannelKey = currentUser?.role === ROLE.SUPER_ADMIN
const [fetchModelsDialogOpen, setFetchModelsDialogOpen] = useState(false)
const [channelKey, setChannelKey] = useState(null)
@@ -929,13 +937,20 @@ export function ChannelMutateDrawer({
?.label || `#${currentType}`,
[currentType]
)
+ const taskPluginOptionsQuery = useQuery({
+ queryKey: ['task-plugin-options'],
+ queryFn: getTaskPluginOptions,
+ enabled: currentType === CHANNEL_TYPE_TASK_PLUGIN && canBindTaskPlugin,
+ })
const channelTypeOptions = useMemo(() => {
- const options = CHANNEL_TYPE_OPTIONS.map((option) => ({
- value: String(option.value),
- label: t(option.label),
- icon: ,
- }))
+ const options = channelTypeOptionsForTaskPluginBind(canBindTaskPlugin).map(
+ (option) => ({
+ value: String(option.value),
+ label: t(option.label),
+ icon: ,
+ })
+ )
if (!options.some((option) => Number(option.value) === currentType)) {
options.push({
value: String(currentType),
@@ -944,7 +959,7 @@ export function ChannelMutateDrawer({
})
}
return options
- }, [currentType, t])
+ }, [canBindTaskPlugin, currentType, t])
const formErrors = form.formState.errors
const identityHasErrors = Boolean(
@@ -2029,6 +2044,81 @@ export function ChannelMutateDrawer({
/>
+ {currentType === CHANNEL_TYPE_TASK_PLUGIN && (
+ (
+
+ {t('Task plugin *')}
+ {canBindTaskPlugin ? (
+ {
+ field.onChange(value)
+ const plugin =
+ taskPluginOptionsQuery.data?.find(
+ (item) => item.key === value
+ )
+ if (plugin?.models?.length) {
+ form.setValue(
+ 'models',
+ formatModelsArray(plugin.models),
+ {
+ shouldDirty: true,
+ }
+ )
+ }
+ }}
+ items={(
+ taskPluginOptionsQuery.data ?? []
+ ).map((plugin) => ({
+ value: plugin.key,
+ label: `${plugin.name} (${plugin.key})`,
+ }))}
+ >
+
+
+
+
+
+
+ {(
+ taskPluginOptionsQuery.data ?? []
+ ).map((plugin) => (
+
+ {plugin.name} ({plugin.key})
+
+ ))}
+
+
+ ) : (
+
+
+
+ )}
+
+ {t(
+ 'Selecting a plugin fills its declared models.'
+ )}
+
+
+
+ )}
+ />
+ )}
+
(
- {t('Base URL')}
+
+ {currentType === CHANNEL_TYPE_TASK_PLUGIN
+ ? t('Base URL *')
+ : t('Base URL')}
+
{
@@ -108,6 +111,17 @@ export const CHANNEL_TYPE_OPTIONS: { value: number; label: string }[] = (() => {
return ordered
})()
+export function channelTypeOptionsForTaskPluginBind(
+ canBindTaskPlugin: boolean
+): { value: number; label: string }[] {
+ if (canBindTaskPlugin) {
+ return CHANNEL_TYPE_OPTIONS
+ }
+ return CHANNEL_TYPE_OPTIONS.filter(
+ (option) => option.value !== CHANNEL_TYPE_TASK_PLUGIN
+ )
+}
+
// ============================================================================
// Channel Status (label values are i18n keys; use t(config.label) in components)
// ============================================================================
diff --git a/web/src/features/channels/lib/__tests__/channel-type-options.test.ts b/web/src/features/channels/lib/__tests__/channel-type-options.test.ts
new file mode 100644
index 000000000000..4ec346fda284
--- /dev/null
+++ b/web/src/features/channels/lib/__tests__/channel-type-options.test.ts
@@ -0,0 +1,44 @@
+/*
+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 { describe, expect, test } from 'vitest'
+
+import {
+ CHANNEL_TYPE_OPTIONS,
+ CHANNEL_TYPE_TASK_PLUGIN,
+ channelTypeOptionsForTaskPluginBind,
+} from '../../constants'
+
+describe('channel type options for task plugin bind', () => {
+ test('hides the task plugin type when the caller cannot bind', () => {
+ const options = channelTypeOptionsForTaskPluginBind(false)
+
+ expect(
+ options.some((option) => option.value === CHANNEL_TYPE_TASK_PLUGIN)
+ ).toBe(false)
+ })
+
+ test('shows the task plugin type when the caller can bind', () => {
+ const options = channelTypeOptionsForTaskPluginBind(true)
+
+ expect(options).toEqual(CHANNEL_TYPE_OPTIONS)
+ expect(
+ options.some((option) => option.value === CHANNEL_TYPE_TASK_PLUGIN)
+ ).toBe(true)
+ })
+})
diff --git a/web/src/features/channels/lib/channel-form.ts b/web/src/features/channels/lib/channel-form.ts
index 244014cb334c..f31a5c424ee0 100644
--- a/web/src/features/channels/lib/channel-form.ts
+++ b/web/src/features/channels/lib/channel-form.ts
@@ -21,6 +21,7 @@ import { z } from 'zod'
import {
CLAUDE_FIELD_PASSTHROUGH_TYPES,
CHANNEL_TYPE_NEW_API,
+ CHANNEL_TYPE_TASK_PLUGIN,
CHANNEL_STATUS,
ERROR_MESSAGES,
FIELD_PASSTHROUGH_TYPES,
@@ -201,6 +202,7 @@ export const channelFormSchema = z
name: z.string().min(1, ERROR_MESSAGES.REQUIRED_NAME),
type: z.number().min(0, ERROR_MESSAGES.REQUIRED_TYPE),
base_url: z.string().optional(),
+ task_plugin_key: z.string().optional(),
key: z.string(),
openai_organization: z.string().optional(),
models: z.string().min(1, ERROR_MESSAGES.REQUIRED_MODELS),
@@ -285,7 +287,9 @@ export const channelFormSchema = z
})
.superRefine((data, ctx) => {
if (
- [3, 8, 36, 45, CHANNEL_TYPE_NEW_API].includes(data.type) &&
+ [3, 8, 36, 45, CHANNEL_TYPE_NEW_API, CHANNEL_TYPE_TASK_PLUGIN].includes(
+ data.type
+ ) &&
!data.base_url?.trim()
) {
addRequiredIssue(
@@ -294,6 +298,12 @@ export const channelFormSchema = z
'Base URL is required for this channel type'
)
}
+ if (
+ data.type === CHANNEL_TYPE_TASK_PLUGIN &&
+ !data.task_plugin_key?.trim()
+ ) {
+ addRequiredIssue(ctx, 'task_plugin_key', 'Task plugin is required')
+ }
if (data.type === CHANNEL_TYPE_ADVANCED_CUSTOM) {
const advancedCustomConfig = parseAdvancedCustomConfig(
@@ -405,6 +415,7 @@ export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = {
name: '',
type: 1,
base_url: '',
+ task_plugin_key: '',
key: '',
openai_organization: '',
models: '',
@@ -468,6 +479,7 @@ export function transformChannelToFormDefaults(
): ChannelFormValues {
// Parse channel extra settings from setting field
let extraSettings = {
+ task_plugin_key: '',
force_format: false,
thinking_to_content: false,
proxy: '',
@@ -486,6 +498,7 @@ export function transformChannelToFormDefaults(
parsed.http2_connection_shards
)
extraSettings = {
+ task_plugin_key: parsed.task_plugin_key || '',
force_format: parsed.force_format || false,
thinking_to_content: parsed.thinking_to_content || false,
proxy: parsed.proxy || '',
@@ -605,6 +618,10 @@ export function transformChannelToFormDefaults(
*/
export function buildSettingJSON(formData: ChannelFormValues): string {
const settingObj: Record = {
+ task_plugin_key:
+ formData.type === CHANNEL_TYPE_TASK_PLUGIN
+ ? formData.task_plugin_key?.trim() || ''
+ : undefined,
force_format: formData.force_format || false,
thinking_to_content: formData.thinking_to_content || false,
proxy: formData.proxy?.trim() || '',
diff --git a/web/src/features/pricing/__tests__/breakdown-tier-match.test.ts b/web/src/features/pricing/__tests__/breakdown-tier-match.test.ts
new file mode 100644
index 000000000000..2c2dd56aa864
--- /dev/null
+++ b/web/src/features/pricing/__tests__/breakdown-tier-match.test.ts
@@ -0,0 +1,102 @@
+/*
+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 { describe, test } from 'vitest'
+
+import type { ParsedTaskTier } from '../lib/billing-expr'
+import { isBreakdownTierMatched } from '../lib/breakdown-tier-match'
+import { getTaskMatrixDisplayTiers } from '../lib/task-matrix-display'
+import type { BillingUsageSchema } from '../types'
+
+const seedanceSchema: BillingUsageSchema = {
+ tokens: { type: 'number', unit: 'token' },
+ resolution: { enum: ['480p', '720p', '1080p', '4k'] },
+ video_input: { enum: ['none', 'video'] },
+}
+
+const uniformSeedanceExpr = 'tier("base", u("tokens") * 10 / 1000000)'
+
+function seedanceDisplayTiers(): ParsedTaskTier[] {
+ const tiers = getTaskMatrixDisplayTiers(uniformSeedanceExpr, seedanceSchema)
+ assert.ok(tiers)
+ assert.equal(tiers.length, 8)
+ return tiers
+}
+
+function matchedLabels(
+ tiers: ParsedTaskTier[],
+ matchedTierLabel?: string | null,
+ usageFacts?: Record
+): string[] {
+ return tiers
+ .filter((tier) =>
+ isBreakdownTierMatched(tier, tiers, matchedTierLabel, usageFacts)
+ )
+ .map((tier) => tier.label)
+}
+
+describe('breakdown tier matched-row highlight', () => {
+ test('highlights only the 720p·video row when a uniform matrix log matches base with those usage facts', () => {
+ const tiers = seedanceDisplayTiers()
+
+ assert.deepEqual(
+ matchedLabels(tiers, 'base', {
+ resolution: '720p',
+ video_input: 'video',
+ }),
+ ['720p·video']
+ )
+ })
+
+ test('does not highlight any row when a uniform matrix log matches base without usage facts', () => {
+ const tiers = seedanceDisplayTiers()
+
+ assert.deepEqual(matchedLabels(tiers, 'base'), [])
+ })
+
+ test('does not highlight any row when usage facts omit a condition field', () => {
+ const tiers = seedanceDisplayTiers()
+
+ assert.deepEqual(
+ matchedLabels(tiers, 'base', {
+ resolution: '720p',
+ }),
+ []
+ )
+ })
+
+ test('highlights the labeled non-matrix row and does not facts-match another row', () => {
+ const tiers: ParsedTaskTier[] = [
+ {
+ label: 'pro',
+ conditions: [{ field: 'mode', value: 'pro' }],
+ constant: 0,
+ unitPrices: { seconds: 0.8 },
+ },
+ {
+ label: 'std',
+ conditions: [{ field: 'mode', value: 'std' }],
+ constant: 0,
+ unitPrices: { seconds: 0.4 },
+ },
+ ]
+
+ assert.deepEqual(matchedLabels(tiers, 'pro', { mode: 'std' }), ['pro'])
+ })
+})
diff --git a/web/src/features/pricing/__tests__/dynamic-price.test.ts b/web/src/features/pricing/__tests__/dynamic-price.test.ts
new file mode 100644
index 000000000000..6319548c50ac
--- /dev/null
+++ b/web/src/features/pricing/__tests__/dynamic-price.test.ts
@@ -0,0 +1,409 @@
+/*
+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 { describe, test } from 'vitest'
+
+import { getBillingModeLabelKey } from '../lib/billing-mode'
+import {
+ getCardExamplePrice,
+ getDynamicPriceUnitLabelKey,
+ getDynamicPricingSummary,
+ getTaskUsagePriceUnitLabelKey,
+ hasTaskUsageSchema,
+ isUnconfiguredTaskUsageModel,
+} from '../lib/dynamic-price'
+import { isTokenBasedModel } from '../lib/model-helpers'
+import type { PricingModel } from '../types'
+
+function pricingModel(overrides: Partial): PricingModel {
+ return {
+ id: 1,
+ model_name: 'test-model',
+ quota_type: 0,
+ model_ratio: 1,
+ completion_ratio: 1,
+ enable_groups: ['default'],
+ ...overrides,
+ }
+}
+
+const summaryOptions = {
+ tokenUnit: 'K' as const,
+ showRechargePrice: true,
+ priceRate: 3,
+ usdExchangeRate: 6,
+ groupRatioMultiplier: 2,
+}
+
+describe('task dynamic pricing', () => {
+ test('treats task coefficients as dollars per unit without a token divisor', () => {
+ const model = pricingModel({
+ billing_mode: 'tiered_expr',
+ billing_expr:
+ 'u("mode") == "pro" ? tier("pro", u("seconds") * 0.8) : tier("std", u("seconds") * 0.4)',
+ billing_usage_schema: {
+ seconds: { type: 'number', unit: 'second' },
+ mode: { enum: ['std', 'pro'] },
+ },
+ })
+
+ const summary = getDynamicPricingSummary(model, summaryOptions)
+
+ assert.ok(summary)
+ assert.equal(summary.isTaskUsage, true)
+ assert.equal(summary.isSpecialExpression, false)
+ assert.equal(summary.tier?.label, 'std')
+ assert.equal(summary.primaryEntries[0]?.value, 0.4)
+ assert.equal(summary.primaryEntries[0]?.unit, 'second')
+ assert.match(summary.primaryEntries[0]?.formatted ?? '', /0[.,]4/)
+ })
+
+ test('falls back for a non-canonical task expression', () => {
+ const model = pricingModel({
+ billing_mode: 'tiered_expr',
+ billing_expr:
+ 'u("seconds") > 30 ? tier("long", u("seconds") * 0.3) : tier("short", u("seconds") * 0.4)',
+ billing_usage_schema: {
+ seconds: { type: 'number', unit: 'second' },
+ },
+ })
+
+ const summary = getDynamicPricingSummary(model, summaryOptions)
+
+ assert.ok(summary)
+ assert.equal(summary.isSpecialExpression, true)
+ assert.equal(summary.tiers.length, 0)
+ })
+
+ test('summarizes different task tier prices as a range while preserving the fallback price', () => {
+ const model = pricingModel({
+ billing_mode: 'tiered_expr',
+ billing_expr:
+ 'u("mode") == "pro" ? tier("pro", u("seconds") * 0.8) : tier("std", u("seconds") * 0.4)',
+ billing_usage_schema: {
+ seconds: { type: 'number', unit: 'second' },
+ mode: { enum: ['std', 'pro'] },
+ },
+ })
+
+ const summary = getDynamicPricingSummary(model, summaryOptions)
+
+ assert.ok(summary)
+ assert.match(summary.primaryEntries[0]?.formattedRange ?? '', /0[.,]4/)
+ assert.match(summary.primaryEntries[0]?.formattedRange ?? '', /0[.,]8/)
+ assert.match(summary.primaryEntries[0]?.formattedRange ?? '', /–/)
+ assert.match(summary.primaryEntries[0]?.formatted ?? '', /0[.,]4/)
+ })
+
+ test('omits a task price range when every tier has the same unit price', () => {
+ const model = pricingModel({
+ billing_mode: 'tiered_expr',
+ billing_expr: 'tier("base", u("seconds") * 0.4)',
+ billing_usage_schema: {
+ seconds: { type: 'number', unit: 'second' },
+ },
+ })
+
+ const summary = getDynamicPricingSummary(model, summaryOptions)
+
+ assert.ok(summary)
+ assert.equal(summary.primaryEntries[0]?.formattedRange, undefined)
+ })
+
+ test('identifies unconfigured task usage models without inventing token pricing', () => {
+ const secondsModel = pricingModel({
+ billing_usage_schema: {
+ seconds: { type: 'number', unit: 'second' },
+ },
+ })
+ const countModel = pricingModel({
+ billing_usage_schema: {
+ clips: { type: 'number', unit: 'count' },
+ },
+ })
+
+ assert.equal(hasTaskUsageSchema(secondsModel), true)
+ assert.equal(isUnconfiguredTaskUsageModel(secondsModel), true)
+ assert.equal(getDynamicPricingSummary(secondsModel, summaryOptions), null)
+ assert.equal(getBillingModeLabelKey(secondsModel), 'Task billing')
+ assert.equal(getBillingModeLabelKey(countModel), 'Task billing')
+ })
+
+ test('does not mark configured task usage pricing as unconfigured', () => {
+ const model = pricingModel({
+ billing_mode: 'tiered_expr',
+ billing_expr: 'tier("base", u("seconds") * 0.4)',
+ billing_usage_schema: {
+ seconds: { type: 'number', unit: 'second' },
+ },
+ })
+
+ assert.equal(isUnconfiguredTaskUsageModel(model), false)
+ assert.ok(getDynamicPricingSummary(model, summaryOptions))
+ })
+
+ test('leaves fixed per-request pricing configured when a usage schema is present', () => {
+ const model = pricingModel({
+ quota_type: 1,
+ model_price: 0.5,
+ billing_usage_schema: {
+ seconds: { type: 'number', unit: 'second' },
+ },
+ })
+
+ assert.equal(isUnconfiguredTaskUsageModel(model), false)
+ assert.equal(getDynamicPricingSummary(model, summaryOptions), null)
+ assert.equal(isTokenBasedModel(model), false)
+ })
+
+ test('labels task token usage prices without changing chat token units', () => {
+ const model = pricingModel({
+ billing_mode: 'tiered_expr',
+ billing_expr: 'tier("base", u("tokens") * 9.8 / 1000000)',
+ billing_usage_schema: {
+ tokens: { type: 'number', unit: 'token' },
+ },
+ })
+
+ const summary = getDynamicPricingSummary(model, summaryOptions)
+
+ assert.ok(summary)
+ const tokenEntry = summary.primaryEntries[0]
+ assert.ok(tokenEntry)
+ assert.equal(tokenEntry.unit, 'token')
+ assert.equal(tokenEntry.value, 9.8)
+ assert.equal(getDynamicPriceUnitLabelKey(tokenEntry), '1M token')
+ assert.equal(getTaskUsagePriceUnitLabelKey('token'), '1M token')
+ assert.equal(
+ getDynamicPriceUnitLabelKey({
+ key: 'p',
+ field: 'inputPrice',
+ label: 'Input',
+ shortLabel: 'Input',
+ labelKind: 'i18n',
+ value: 2,
+ formatted: '$2',
+ unit: 'token',
+ variable: {
+ key: 'p',
+ field: 'inputPrice',
+ tierField: 'input_unit_cost',
+ label: 'Input price',
+ shortLabel: 'Input',
+ side: 'input',
+ },
+ }),
+ null
+ )
+ })
+
+ test('labels task credit usage prices as a direct per-credit rate', () => {
+ const model = pricingModel({
+ billing_mode: 'tiered_expr',
+ billing_expr: 'tier("base", u("units") * 0.14)',
+ billing_usage_schema: {
+ units: { type: 'number', unit: 'credit' },
+ },
+ })
+
+ const summary = getDynamicPricingSummary(model, summaryOptions)
+
+ assert.ok(summary)
+ const creditEntry = summary.primaryEntries[0]
+ assert.ok(creditEntry)
+ assert.equal(creditEntry.unit, 'credit')
+ assert.equal(creditEntry.value, 0.14)
+ assert.equal(getDynamicPriceUnitLabelKey(creditEntry), 'credit')
+ assert.equal(getTaskUsagePriceUnitLabelKey('credit'), 'credit')
+ })
+
+ test('leaves token models without a usage schema unchanged', () => {
+ const model = pricingModel({})
+
+ assert.equal(hasTaskUsageSchema(model), false)
+ assert.equal(isUnconfiguredTaskUsageModel(model), false)
+ assert.equal(getBillingModeLabelKey(model), 'Token-based')
+ })
+
+ test('preserves all billing-mode badge states', () => {
+ assert.equal(
+ getBillingModeLabelKey(
+ pricingModel({
+ billing_mode: 'tiered_expr',
+ billing_expr: 'tier("base", u("seconds") * 0.4)',
+ billing_usage_schema: {
+ seconds: { type: 'number', unit: 'second' },
+ },
+ })
+ ),
+ 'Task billing'
+ )
+ assert.equal(
+ getBillingModeLabelKey(
+ pricingModel({
+ billing_mode: 'tiered_expr',
+ billing_expr: 'tier("base", u("clips") * 0.05)',
+ billing_usage_schema: {
+ clips: { type: 'number', unit: 'count' },
+ },
+ })
+ ),
+ 'Task billing'
+ )
+ assert.equal(
+ getBillingModeLabelKey(
+ pricingModel({
+ billing_mode: 'tiered_expr',
+ billing_expr: 'tier("base", u("tokens") * 9.8 / 1000000)',
+ billing_usage_schema: {
+ tokens: { type: 'number', unit: 'token' },
+ },
+ })
+ ),
+ 'Task billing'
+ )
+ assert.equal(
+ getBillingModeLabelKey(
+ pricingModel({
+ billing_mode: 'tiered_expr',
+ billing_expr: 'tier("base", u("units") * 0.14)',
+ billing_usage_schema: {
+ units: { type: 'number', unit: 'credit' },
+ },
+ })
+ ),
+ 'Task billing'
+ )
+ assert.equal(
+ getBillingModeLabelKey(
+ pricingModel({
+ billing_mode: 'tiered_expr',
+ billing_expr: 'tier("base", p * 2 + c * 8)',
+ })
+ ),
+ 'Dynamic Pricing'
+ )
+ assert.equal(getBillingModeLabelKey(pricingModel({})), 'Token-based')
+ assert.equal(
+ getBillingModeLabelKey(pricingModel({ quota_type: 1 })),
+ 'Per Request'
+ )
+ })
+
+ test('marks task usage field labels as schema-owned so they are not translated', () => {
+ const tokenModel = pricingModel({
+ billing_mode: 'tiered_expr',
+ billing_expr: 'tier("base", 0.1 + u("tokens") * 9.8 / 1000000)',
+ billing_usage_schema: {
+ tokens: { type: 'number', unit: 'token' },
+ },
+ })
+ const tokenSummary = getDynamicPricingSummary(tokenModel, summaryOptions)
+ assert.ok(tokenSummary)
+ assert.equal(tokenSummary.primaryEntries[0]?.shortLabel, 'tokens')
+ assert.equal(tokenSummary.primaryEntries[0]?.labelKind, 'schema')
+ assert.equal(tokenSummary.secondaryEntries[0]?.shortLabel, 'Base')
+ assert.equal(tokenSummary.secondaryEntries[0]?.labelKind, 'i18n')
+
+ const multiFieldModel = pricingModel({
+ billing_mode: 'tiered_expr',
+ billing_expr:
+ 'tier("base", u("seconds") * 0.4 + u("tokens") * 9.8 / 1000000)',
+ billing_usage_schema: {
+ seconds: { type: 'number', unit: 'second' },
+ tokens: { type: 'number', unit: 'token' },
+ },
+ })
+ const multiSummary = getDynamicPricingSummary(
+ multiFieldModel,
+ summaryOptions
+ )
+ assert.ok(multiSummary)
+ assert.equal(multiSummary.primaryEntries.length, 2)
+ assert.ok(
+ multiSummary.primaryEntries.every((entry) => entry.labelKind === 'schema')
+ )
+
+ const chatSummary = getDynamicPricingSummary(
+ pricingModel({
+ billing_mode: 'tiered_expr',
+ billing_expr: 'tier("base", p * 2 + c * 8)',
+ }),
+ summaryOptions
+ )
+ assert.ok(chatSummary)
+ assert.ok(
+ chatSummary.primaryEntries.every((entry) => entry.labelKind === 'i18n')
+ )
+ })
+
+ test('returns the first evaluated usage example for a canonical task expression', () => {
+ const model = pricingModel({
+ billing_mode: 'tiered_expr',
+ billing_expr: 'tier("base", u("tokens") * 9.8 / 1000000)',
+ billing_usage_schema: {
+ tokens: { type: 'number', unit: 'token' },
+ },
+ billing_usage_examples: [
+ { label: '720p · 5s', facts: { tokens: 108000 } },
+ { label: '1080p · 5s', facts: { tokens: 243000 } },
+ ],
+ })
+
+ const example = getCardExamplePrice(model, summaryOptions)
+
+ assert.ok(example)
+ assert.equal(example.label, '720p · 5s')
+ assert.match(example.formatted, /1[.,]0584/)
+ })
+
+ test('returns null when the expression is not canonical or examples are missing', () => {
+ const schema = {
+ tokens: { type: 'number' as const, unit: 'token' as const },
+ }
+ const examples = [{ label: '720p · 5s', facts: { tokens: 108000 } }]
+
+ assert.equal(
+ getCardExamplePrice(
+ pricingModel({
+ billing_mode: 'tiered_expr',
+ billing_expr:
+ 'u("tokens") * 0.00007 * (u("tokens") > 100000 ? 0.8 : 1)',
+ billing_usage_schema: schema,
+ billing_usage_examples: examples,
+ }),
+ summaryOptions
+ ),
+ null
+ )
+ assert.equal(
+ getCardExamplePrice(
+ pricingModel({
+ billing_mode: 'tiered_expr',
+ billing_expr: 'tier("base", u("tokens") * 9.8 / 1000000)',
+ billing_usage_schema: schema,
+ }),
+ summaryOptions
+ ),
+ null
+ )
+ assert.equal(getCardExamplePrice(pricingModel({}), summaryOptions), null)
+ })
+})
diff --git a/web/src/features/pricing/__tests__/task-expr.test.ts b/web/src/features/pricing/__tests__/task-expr.test.ts
new file mode 100644
index 000000000000..fad6e5f17142
--- /dev/null
+++ b/web/src/features/pricing/__tests__/task-expr.test.ts
@@ -0,0 +1,381 @@
+/*
+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 { describe, test } from 'vitest'
+
+import {
+ combineBillingExpr,
+ parseTaskTiersFromExpr,
+ splitBillingExprAndRequestRules,
+} from '../lib/billing-expr'
+import {
+ evaluateTaskUsageExamples,
+ evaluateTaskVisualConfig,
+ generateTaskExprFromConfig,
+ tryParseTaskVisualConfig,
+ type TaskVisualConfig,
+} from '../lib/task-expr'
+import type { BillingUsageSchema } from '../types'
+
+const schema: BillingUsageSchema = {
+ seconds: { type: 'number', unit: 'second' },
+ clips: { type: 'number', unit: 'count' },
+ mode: { enum: ['std', 'pro'] },
+}
+
+function assertConfigRoundTrip(config: TaskVisualConfig) {
+ const expression = generateTaskExprFromConfig(config, schema)
+ const parsed = tryParseTaskVisualConfig(expression, schema)
+ assert.ok(parsed)
+ assert.equal(generateTaskExprFromConfig(parsed, schema), expression)
+}
+
+describe('task billing expressions', () => {
+ test('round-trips flat, enum-tiered, and additive canonical shapes', () => {
+ assertConfigRoundTrip({
+ tiers: [
+ {
+ label: 'base',
+ conditions: [],
+ constant: 0,
+ unitPrices: { seconds: 0.4, clips: 0 },
+ },
+ ],
+ })
+ assertConfigRoundTrip({
+ tiers: [
+ {
+ label: 'pro',
+ conditions: [{ field: 'mode', value: 'pro' }],
+ constant: 0,
+ unitPrices: { seconds: 0.8, clips: 0 },
+ },
+ {
+ label: 'std',
+ conditions: [],
+ constant: 0,
+ unitPrices: { seconds: 0.4, clips: 0 },
+ },
+ ],
+ })
+ assertConfigRoundTrip({
+ tiers: [
+ {
+ label: 'base',
+ conditions: [],
+ constant: 0.1,
+ unitPrices: { seconds: 0.4, clips: 0.05 },
+ },
+ ],
+ })
+ })
+
+ test('preserves request-rule factors around a canonical task expression', () => {
+ const baseExpression = generateTaskExprFromConfig(
+ {
+ tiers: [
+ {
+ label: 'base',
+ conditions: [],
+ constant: 0,
+ unitPrices: { seconds: 0.4, clips: 0 },
+ },
+ ],
+ },
+ schema
+ )
+ const requestRules = '(header("x-priority") == "high" ? 2 : 1)'
+ const combined = combineBillingExpr(baseExpression, requestRules)
+ const split = splitBillingExprAndRequestRules(combined)
+
+ assert.equal(split.requestRuleExpr, requestRules)
+ const parsed = tryParseTaskVisualConfig(split.billingExpr, schema)
+ assert.ok(parsed)
+ assert.equal(
+ combineBillingExpr(
+ generateTaskExprFromConfig(parsed, schema),
+ requestRules
+ ),
+ combined
+ )
+ })
+
+ test('rejects expressions outside the frozen task shapes', () => {
+ assert.equal(tryParseTaskVisualConfig('u("seconds") * 0.4', schema), null)
+ assert.equal(
+ tryParseTaskVisualConfig(
+ 'u("seconds") > 30 ? tier("long", u("seconds") * 0.3) : tier("short", u("seconds") * 0.4)',
+ schema
+ ),
+ null
+ )
+ assert.equal(
+ tryParseTaskVisualConfig('tier("base", u("unknown") * 0.4)', schema),
+ null
+ )
+ })
+})
+
+describe('task visual pricing preview', () => {
+ test('totals a base charge and usage price for a single tier', () => {
+ const tier = {
+ label: 'base',
+ conditions: [],
+ constant: 0.02,
+ unitPrices: { seconds: 0.1 },
+ }
+
+ const result = evaluateTaskVisualConfig({ tiers: [tier] }, { seconds: 5 })
+
+ assert.ok(result)
+ assert.equal(result.tier, tier)
+ assert.equal(result.total, 0.52)
+ assert.deepEqual(result.parts, [
+ { kind: 'constant', amount: 0.02 },
+ {
+ kind: 'usage',
+ field: 'seconds',
+ amount: 0.5,
+ quantity: 5,
+ unitPrice: 0.1,
+ },
+ ])
+ })
+
+ test('matches enum tiers in order and otherwise uses the fallback', () => {
+ const config: TaskVisualConfig = {
+ tiers: [
+ {
+ label: 'pro',
+ conditions: [{ field: 'mode', value: 'pro' }],
+ constant: 0,
+ unitPrices: { seconds: 0.8 },
+ },
+ {
+ label: 'std',
+ conditions: [],
+ constant: 0,
+ unitPrices: { seconds: 0.4 },
+ },
+ ],
+ }
+
+ assert.equal(
+ evaluateTaskVisualConfig(config, { mode: 'pro', seconds: 1 })?.tier.label,
+ 'pro'
+ )
+ assert.equal(
+ evaluateTaskVisualConfig(config, { mode: 'std', seconds: 1 })?.tier.label,
+ 'std'
+ )
+ assert.equal(
+ evaluateTaskVisualConfig(config, { mode: 'unknown', seconds: 1 })?.tier
+ .label,
+ 'std'
+ )
+ })
+
+ test('requires every enum condition on a multi-condition tier', () => {
+ const config: TaskVisualConfig = {
+ tiers: [
+ {
+ label: 'extend-two',
+ conditions: [
+ { field: 'action', value: 'extend' },
+ { field: 'quality', value: 'high' },
+ ],
+ constant: 0,
+ unitPrices: { clips: 0.2 },
+ },
+ {
+ label: 'base',
+ conditions: [],
+ constant: 0,
+ unitPrices: { clips: 0.1 },
+ },
+ ],
+ }
+
+ assert.equal(
+ evaluateTaskVisualConfig(config, {
+ action: 'extend',
+ quality: 'high',
+ clips: 2,
+ })?.tier.label,
+ 'extend-two'
+ )
+ assert.equal(
+ evaluateTaskVisualConfig(config, {
+ action: 'extend',
+ quality: 'standard',
+ clips: 2,
+ })?.tier.label,
+ 'base'
+ )
+ })
+
+ test('selects the same tier after round-tripping through the expression grammar', () => {
+ const config: TaskVisualConfig = {
+ tiers: [
+ {
+ label: 'pro',
+ conditions: [{ field: 'mode', value: 'pro' }],
+ constant: 0.05,
+ unitPrices: { seconds: 0.8, clips: 0.1 },
+ },
+ {
+ label: 'std',
+ conditions: [],
+ constant: 0.02,
+ unitPrices: { seconds: 0.4, clips: 0.05 },
+ },
+ ],
+ }
+ const sample = { mode: 'pro', seconds: 5, clips: 2 }
+ const expression = generateTaskExprFromConfig(config, schema)
+ const parsedTiers = parseTaskTiersFromExpr(expression, schema)
+ assert.ok(parsedTiers.length > 0)
+ const grammarTier =
+ parsedTiers
+ .slice(0, -1)
+ .find((tier) =>
+ tier.conditions.every(
+ (condition) =>
+ sample[condition.field as keyof typeof sample] === condition.value
+ )
+ ) ?? parsedTiers.at(-1)
+ assert.ok(grammarTier)
+
+ const result = evaluateTaskVisualConfig(config, sample)
+
+ assert.ok(result)
+ assert.equal(result.tier.label, grammarTier.label)
+ })
+
+ test('round-trips a token field at the $/1M editor scale', () => {
+ const tokenSchema: BillingUsageSchema = {
+ tokens: { type: 'number', unit: 'token' },
+ }
+ const config: TaskVisualConfig = {
+ tiers: [
+ {
+ label: 'base',
+ conditions: [],
+ constant: 0,
+ unitPrices: { tokens: 9.8 },
+ },
+ ],
+ }
+
+ const expression = generateTaskExprFromConfig(config, tokenSchema)
+ assert.match(expression, /\/ 1000000/)
+ assert.equal(expression, 'tier("base", u("tokens") * 9.8 / 1000000)')
+
+ const parsed = tryParseTaskVisualConfig(expression, tokenSchema)
+ assert.ok(parsed)
+ assert.equal(parsed.tiers[0].unitPrices.tokens, 9.8)
+ assert.equal(generateTaskExprFromConfig(parsed, tokenSchema), expression)
+ })
+
+ test('treats a bare token term as unparseable so old $/token expressions stay raw', () => {
+ const tokenSchema: BillingUsageSchema = {
+ tokens: { type: 'number', unit: 'token' },
+ }
+ assert.equal(
+ tryParseTaskVisualConfig(
+ 'tier("base", u("tokens") * 0.0000098)',
+ tokenSchema
+ ),
+ null
+ )
+ assert.deepEqual(
+ parseTaskTiersFromExpr('tier("base", u("tokens") * 0.0000098)', tokenSchema),
+ []
+ )
+ })
+
+ test('round-trips a credit field without a /1M division', () => {
+ const creditSchema: BillingUsageSchema = {
+ units: { type: 'number', unit: 'credit' },
+ }
+ const config: TaskVisualConfig = {
+ tiers: [
+ {
+ label: 'base',
+ conditions: [],
+ constant: 0,
+ unitPrices: { units: 0.14 },
+ },
+ ],
+ }
+
+ const expression = generateTaskExprFromConfig(config, creditSchema)
+ assert.equal(expression, 'tier("base", u("units") * 0.14)')
+ assert.doesNotMatch(expression, /\/ 1000000/)
+
+ const parsed = tryParseTaskVisualConfig(expression, creditSchema)
+ assert.ok(parsed)
+ assert.equal(parsed.tiers[0].unitPrices.units, 0.14)
+ })
+
+ test('maps declared usage example labels to evaluated prices', () => {
+ const tokenSchema: BillingUsageSchema = {
+ tokens: { type: 'number', unit: 'token' },
+ }
+ const config = tryParseTaskVisualConfig(
+ 'tier("base", u("tokens") * 9.8 / 1000000)',
+ tokenSchema
+ )
+ assert.ok(config)
+
+ const result = evaluateTaskVisualConfig(
+ config,
+ { tokens: 108000 },
+ tokenSchema
+ )
+ assert.ok(result)
+ assert.equal(result.total, (108000 * 9.8) / 1_000_000)
+
+ assert.deepEqual(
+ evaluateTaskUsageExamples(
+ 'tier("base", u("tokens") * 9.8 / 1000000)',
+ tokenSchema,
+ [
+ { label: '720p · 5s', facts: { tokens: 108000 } },
+ { label: '1080p · 5s', facts: { tokens: 243000 } },
+ ]
+ ),
+ [
+ { label: '720p · 5s', total: (108000 * 9.8) / 1_000_000 },
+ { label: '1080p · 5s', total: (243000 * 9.8) / 1_000_000 },
+ ]
+ )
+ })
+
+ test('returns no usage example prices for a raw unparseable expression', () => {
+ assert.deepEqual(
+ evaluateTaskUsageExamples(
+ 'u("tokens") * 0.00007 * (u("tokens") > 100000 ? 0.8 : 1)',
+ { tokens: { type: 'number', unit: 'token' } },
+ [{ label: '720p · 5s', facts: { tokens: 108000 } }]
+ ),
+ []
+ )
+ })
+})
diff --git a/web/src/features/pricing/__tests__/task-matrix-display.test.ts b/web/src/features/pricing/__tests__/task-matrix-display.test.ts
new file mode 100644
index 000000000000..ec0fa972142b
--- /dev/null
+++ b/web/src/features/pricing/__tests__/task-matrix-display.test.ts
@@ -0,0 +1,169 @@
+/*
+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 { describe, test } from 'vitest'
+
+import { getDynamicPriceEntries } from '../lib/dynamic-price'
+import { getTaskMatrixDisplayTiers } from '../lib/task-matrix-display'
+import type { BillingUsageSchema } from '../types'
+
+const resolutionSchema: BillingUsageSchema = {
+ seconds: { type: 'number', unit: 'second' },
+ resolution: { enum: ['480P', '720P', '1080P'] },
+}
+
+const doubleEnumSchema: BillingUsageSchema = {
+ quality: { enum: ['high', 'low'] },
+ seconds: { type: 'number', unit: 'second' },
+ mode: { enum: ['std', 'pro'] },
+}
+
+const numberOnlySchema: BillingUsageSchema = {
+ seconds: { type: 'number', unit: 'second' },
+}
+
+describe('task matrix marketplace display rows', () => {
+ test('expands a uniform flat expression into every enum combination', () => {
+ const rows = getTaskMatrixDisplayTiers(
+ 'tier("base", u("seconds") * 0.4)',
+ resolutionSchema
+ )
+
+ assert.deepEqual(rows, [
+ {
+ label: '480P',
+ conditions: [{ field: 'resolution', value: '480P' }],
+ constant: 0,
+ unitPrices: { seconds: 0.4 },
+ },
+ {
+ label: '720P',
+ conditions: [{ field: 'resolution', value: '720P' }],
+ constant: 0,
+ unitPrices: { seconds: 0.4 },
+ },
+ {
+ label: '1080P',
+ conditions: [{ field: 'resolution', value: '1080P' }],
+ constant: 0,
+ unitPrices: { seconds: 0.4 },
+ },
+ ])
+ })
+
+ test('expands a full non-uniform partition in canonical order with combination labels', () => {
+ const expression =
+ 'u("mode") == "std" && u("quality") == "high" ? tier("std·high", 0.1 + u("seconds") * 0.2) : u("mode") == "std" && u("quality") == "low" ? tier("std·low", 0.2 + u("seconds") * 0.3) : u("mode") == "pro" && u("quality") == "high" ? tier("pro·high", 0.3 + u("seconds") * 0.4) : tier("pro·low", 0.4 + u("seconds") * 0.5)'
+
+ assert.deepEqual(getTaskMatrixDisplayTiers(expression, doubleEnumSchema), [
+ {
+ label: 'std·high',
+ conditions: [
+ { field: 'mode', value: 'std' },
+ { field: 'quality', value: 'high' },
+ ],
+ constant: 0.1,
+ unitPrices: { seconds: 0.2 },
+ },
+ {
+ label: 'std·low',
+ conditions: [
+ { field: 'mode', value: 'std' },
+ { field: 'quality', value: 'low' },
+ ],
+ constant: 0.2,
+ unitPrices: { seconds: 0.3 },
+ },
+ {
+ label: 'pro·high',
+ conditions: [
+ { field: 'mode', value: 'pro' },
+ { field: 'quality', value: 'high' },
+ ],
+ constant: 0.3,
+ unitPrices: { seconds: 0.4 },
+ },
+ {
+ label: 'pro·low',
+ conditions: [
+ { field: 'mode', value: 'pro' },
+ { field: 'quality', value: 'low' },
+ ],
+ constant: 0.4,
+ unitPrices: { seconds: 0.5 },
+ },
+ ])
+ })
+
+ test('returns null for a number-only schema so the single-row display stays', () => {
+ assert.equal(
+ getTaskMatrixDisplayTiers(
+ 'tier("base", u("seconds") * 0.4)',
+ numberOnlySchema
+ ),
+ null
+ )
+ })
+
+ test('returns null for an unrecognizable sparse expression', () => {
+ assert.equal(
+ getTaskMatrixDisplayTiers(
+ 'u("seconds") > 30 ? tier("long", u("seconds") * 0.3) : tier("short", u("seconds") * 0.4)',
+ resolutionSchema
+ ),
+ null
+ )
+ })
+
+ test('returns null when there is no usage schema', () => {
+ assert.equal(
+ getTaskMatrixDisplayTiers('tier("base", p * 2 + c * 8)', undefined),
+ null
+ )
+ })
+
+ test('keeps group-ratio multiplication on expanded display-row unit prices', () => {
+ const rows = getTaskMatrixDisplayTiers(
+ 'tier("base", 0.1 + u("seconds") * 0.4)',
+ resolutionSchema
+ )
+ assert.ok(rows)
+ assert.equal(rows.length, 3)
+
+ const baseEntries = getDynamicPriceEntries(rows[0], {
+ tokenUnit: 'K',
+ showRechargePrice: false,
+ usageSchema: resolutionSchema,
+ groupRatioMultiplier: 1,
+ })
+ const doubledEntries = getDynamicPriceEntries(rows[0], {
+ tokenUnit: 'K',
+ showRechargePrice: false,
+ usageSchema: resolutionSchema,
+ groupRatioMultiplier: 2,
+ })
+
+ assert.equal(baseEntries[0]?.value, 0.4)
+ assert.equal(doubledEntries[0]?.value, 0.4)
+ assert.match(baseEntries[0]?.formatted ?? '', /0[.,]4/)
+ assert.match(doubledEntries[0]?.formatted ?? '', /0[.,]8/)
+ assert.equal(baseEntries.at(-1)?.value, 0.1)
+ assert.match(doubledEntries.at(-1)?.formatted ?? '', /0[.,]2/)
+ })
+})
diff --git a/web/src/features/pricing/__tests__/task-matrix.test.ts b/web/src/features/pricing/__tests__/task-matrix.test.ts
new file mode 100644
index 000000000000..7fc28ccfd792
--- /dev/null
+++ b/web/src/features/pricing/__tests__/task-matrix.test.ts
@@ -0,0 +1,465 @@
+/*
+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 { describe, test } from 'vitest'
+
+import { parseTaskTiersFromExpr } from '../lib/billing-expr'
+import {
+ createDefaultTaskMatrixConfig,
+ createDefaultTaskVisualConfig,
+ evaluateTaskVisualConfig,
+ generateTaskExprFromConfig,
+ getTaskEnumCombinations,
+ taskMatrixRowLabel,
+ taskMatrixToTiers,
+ tryParseTaskMatrixConfig,
+ type TaskMatrixConfig,
+} from '../lib/task-expr'
+import type { BillingUsageSchema } from '../types'
+
+const singleEnumSchema: BillingUsageSchema = {
+ seconds: { type: 'number', unit: 'second' },
+ mode: { enum: ['std', 'pro'] },
+}
+
+const doubleEnumSchema: BillingUsageSchema = {
+ quality: { enum: ['high', 'low'] },
+ seconds: { type: 'number', unit: 'second' },
+ mode: { enum: ['std', 'pro'] },
+}
+
+const numberOnlySchema: BillingUsageSchema = {
+ seconds: { type: 'number', unit: 'second' },
+}
+
+function createNonUniformMatrix(): TaskMatrixConfig {
+ return {
+ rows: [
+ {
+ combination: { mode: 'std', quality: 'high' },
+ constant: 0.1,
+ unitPrices: { seconds: 0.2 },
+ },
+ {
+ combination: { mode: 'std', quality: 'low' },
+ constant: 0.2,
+ unitPrices: { seconds: 0.3 },
+ },
+ {
+ combination: { mode: 'pro', quality: 'high' },
+ constant: 0.3,
+ unitPrices: { seconds: 0.4 },
+ },
+ {
+ combination: { mode: 'pro', quality: 'low' },
+ constant: 0.4,
+ unitPrices: { seconds: 0.5 },
+ },
+ ],
+ }
+}
+
+describe('task matrix enum combinations', () => {
+ test('enumerates fields lexicographically and values in declaration order with the first field slowest', () => {
+ assert.deepEqual(getTaskEnumCombinations(doubleEnumSchema), [
+ { mode: 'std', quality: 'high' },
+ { mode: 'std', quality: 'low' },
+ { mode: 'pro', quality: 'high' },
+ { mode: 'pro', quality: 'low' },
+ ])
+ })
+
+ test('returns one empty combination for a number-only schema', () => {
+ assert.deepEqual(getTaskEnumCombinations(numberOnlySchema), [{}])
+ })
+})
+
+describe('uniform task matrix conversion', () => {
+ test('collapses rows with identical prices into one unconditioned base tier', () => {
+ const config: TaskMatrixConfig = {
+ rows: getTaskEnumCombinations(doubleEnumSchema).map((combination) => ({
+ combination,
+ constant: 0.1,
+ unitPrices: { seconds: 0.4 },
+ })),
+ }
+
+ assert.deepEqual(taskMatrixToTiers(config, doubleEnumSchema), [
+ {
+ label: 'base',
+ conditions: [],
+ constant: 0.1,
+ unitPrices: { seconds: 0.4 },
+ },
+ ])
+ })
+
+ test('generates the same default expression as the existing visual config', () => {
+ const matrixExpression = generateTaskExprFromConfig(
+ {
+ tiers: taskMatrixToTiers(
+ createDefaultTaskMatrixConfig(singleEnumSchema),
+ singleEnumSchema
+ ),
+ },
+ singleEnumSchema
+ )
+ const visualExpression = generateTaskExprFromConfig(
+ createDefaultTaskVisualConfig(singleEnumSchema),
+ singleEnumSchema
+ )
+
+ assert.equal(matrixExpression, visualExpression)
+ })
+})
+
+describe('non-uniform task matrix conversion', () => {
+ test('expands canonical rows with full ordered conditions and the final row as else', () => {
+ assert.deepEqual(
+ taskMatrixToTiers(createNonUniformMatrix(), doubleEnumSchema),
+ [
+ {
+ label: 'std·high',
+ conditions: [
+ { field: 'mode', value: 'std' },
+ { field: 'quality', value: 'high' },
+ ],
+ constant: 0.1,
+ unitPrices: { seconds: 0.2 },
+ },
+ {
+ label: 'std·low',
+ conditions: [
+ { field: 'mode', value: 'std' },
+ { field: 'quality', value: 'low' },
+ ],
+ constant: 0.2,
+ unitPrices: { seconds: 0.3 },
+ },
+ {
+ label: 'pro·high',
+ conditions: [
+ { field: 'mode', value: 'pro' },
+ { field: 'quality', value: 'high' },
+ ],
+ constant: 0.3,
+ unitPrices: { seconds: 0.4 },
+ },
+ {
+ label: 'pro·low',
+ conditions: [],
+ constant: 0.4,
+ unitPrices: { seconds: 0.5 },
+ },
+ ]
+ )
+ assert.equal(taskMatrixRowLabel({ quality: 'low', mode: 'pro' }), 'pro·low')
+ assert.equal(taskMatrixRowLabel({}), 'base')
+ })
+})
+
+describe('task matrix round trips', () => {
+ test('preserves a uniform matrix through expression generation and recognition', () => {
+ const config: TaskMatrixConfig = {
+ rows: getTaskEnumCombinations(doubleEnumSchema).map((combination) => ({
+ combination,
+ constant: 0.1,
+ unitPrices: { seconds: 0.4 },
+ })),
+ }
+ const expression = generateTaskExprFromConfig(
+ { tiers: taskMatrixToTiers(config, doubleEnumSchema) },
+ doubleEnumSchema
+ )
+
+ assert.deepEqual(
+ tryParseTaskMatrixConfig(expression, doubleEnumSchema),
+ config
+ )
+ })
+
+ test('preserves a non-uniform matrix through expression generation and recognition', () => {
+ const config = createNonUniformMatrix()
+ const expression = generateTaskExprFromConfig(
+ { tiers: taskMatrixToTiers(config, doubleEnumSchema) },
+ doubleEnumSchema
+ )
+
+ assert.deepEqual(
+ tryParseTaskMatrixConfig(expression, doubleEnumSchema),
+ config
+ )
+ })
+})
+
+describe('permuted complete task partitions', () => {
+ test('recognizes legacy tier order, assigns prices canonically, and ignores labels', () => {
+ const expression =
+ 'u("mode") == "pro" ? tier("legacy-pro", u("seconds") * 0.8) : tier("legacy-std", u("seconds") * 0.4)'
+ const matrix = tryParseTaskMatrixConfig(expression, singleEnumSchema)
+
+ assert.deepEqual(matrix, {
+ rows: [
+ {
+ combination: { mode: 'std' },
+ constant: 0,
+ unitPrices: { seconds: 0.4 },
+ },
+ {
+ combination: { mode: 'pro' },
+ constant: 0,
+ unitPrices: { seconds: 0.8 },
+ },
+ ],
+ })
+ assert.ok(matrix)
+ const normalizedExpression = generateTaskExprFromConfig(
+ { tiers: taskMatrixToTiers(matrix, singleEnumSchema) },
+ singleEnumSchema
+ )
+ assert.deepEqual(
+ tryParseTaskMatrixConfig(normalizedExpression, singleEnumSchema),
+ matrix
+ )
+ })
+})
+
+describe('flat task expression recognition', () => {
+ test('expands one flat tier across every enum combination', () => {
+ assert.deepEqual(
+ tryParseTaskMatrixConfig(
+ 'tier("base", u("seconds") * 0.4)',
+ singleEnumSchema
+ ),
+ {
+ rows: [
+ {
+ combination: { mode: 'std' },
+ constant: 0,
+ unitPrices: { seconds: 0.4 },
+ },
+ {
+ combination: { mode: 'pro' },
+ constant: 0,
+ unitPrices: { seconds: 0.4 },
+ },
+ ],
+ }
+ )
+ })
+
+ test('recognizes one flat tier as the number-only matrix row', () => {
+ assert.deepEqual(
+ tryParseTaskMatrixConfig(
+ 'tier("base", u("seconds") * 0.4)',
+ numberOnlySchema
+ ),
+ {
+ rows: [
+ {
+ combination: {},
+ constant: 0,
+ unitPrices: { seconds: 0.4 },
+ },
+ ],
+ }
+ )
+ })
+})
+
+describe('task matrix recognition rejection matrix', () => {
+ test('rejects expressions outside the task tier grammar', () => {
+ assert.equal(
+ tryParseTaskMatrixConfig('u("seconds") * 0.4', singleEnumSchema),
+ null
+ )
+ assert.equal(
+ tryParseTaskMatrixConfig(
+ 'u("seconds") > 30 ? tier("long", u("seconds") * 0.3) : tier("short", u("seconds") * 0.4)',
+ singleEnumSchema
+ ),
+ null
+ )
+ assert.equal(
+ tryParseTaskMatrixConfig(
+ 'price("base", u("seconds") * 0.4)',
+ singleEnumSchema
+ ),
+ null
+ )
+ })
+
+ test('rejects undeclared usage fields and enum values', () => {
+ assert.equal(
+ tryParseTaskMatrixConfig(
+ 'tier("base", u("unknown") * 0.4)',
+ singleEnumSchema
+ ),
+ null
+ )
+ assert.equal(
+ tryParseTaskMatrixConfig(
+ 'u("mode") == "ultra" ? tier("ultra", u("seconds") * 0.8) : tier("base", u("seconds") * 0.4)',
+ singleEnumSchema
+ ),
+ null
+ )
+ })
+
+ test('rejects a tier condition that omits an enum field', () => {
+ const schema: BillingUsageSchema = {
+ seconds: { type: 'number', unit: 'second' },
+ mode: { enum: ['std', 'pro'] },
+ quality: { enum: ['high'] },
+ }
+ const expression =
+ 'u("mode") == "std" ? tier("std", u("seconds") * 0.4) : tier("pro", u("seconds") * 0.8)'
+
+ assert.equal(tryParseTaskMatrixConfig(expression, schema), null)
+ })
+
+ test('rejects duplicate conditions for one enum field in a tier', () => {
+ const schema: BillingUsageSchema = {
+ seconds: { type: 'number', unit: 'second' },
+ mode: { enum: ['std', 'pro'] },
+ quality: { enum: ['high'] },
+ }
+ const expression =
+ 'u("mode") == "std" && u("mode") == "pro" ? tier("std", u("seconds") * 0.4) : tier("pro", u("seconds") * 0.8)'
+
+ assert.equal(tryParseTaskMatrixConfig(expression, schema), null)
+ })
+
+ test('rejects duplicate combinations across tiers', () => {
+ const schema: BillingUsageSchema = {
+ seconds: { type: 'number', unit: 'second' },
+ mode: { enum: ['std', 'pro', 'ultra'] },
+ }
+ const expression =
+ 'u("mode") == "std" ? tier("one", u("seconds") * 0.4) : u("mode") == "std" ? tier("two", u("seconds") * 0.6) : tier("base", u("seconds") * 0.8)'
+
+ assert.equal(tryParseTaskMatrixConfig(expression, schema), null)
+ })
+
+ test('rejects tier counts below or above the combination count', () => {
+ const threeValueSchema: BillingUsageSchema = {
+ seconds: { type: 'number', unit: 'second' },
+ mode: { enum: ['std', 'pro', 'ultra'] },
+ }
+ const partialExpression =
+ 'u("mode") == "std" ? tier("std", u("seconds") * 0.4) : tier("base", u("seconds") * 0.8)'
+ const excessExpression =
+ 'u("mode") == "std" ? tier("std", u("seconds") * 0.4) : u("mode") == "pro" ? tier("pro", u("seconds") * 0.6) : tier("extra", u("seconds") * 0.8)'
+
+ assert.equal(
+ tryParseTaskMatrixConfig(partialExpression, threeValueSchema),
+ null
+ )
+ assert.equal(
+ tryParseTaskMatrixConfig(excessExpression, singleEnumSchema),
+ null
+ )
+ })
+
+ test('rejects a conditional expression without an unconditioned final tier', () => {
+ const expression = 'u("mode") == "std" ? tier("std", u("seconds") * 0.4)'
+
+ assert.equal(tryParseTaskMatrixConfig(expression, singleEnumSchema), null)
+ })
+
+ test('rejects multiple tiers when the schema has no enum fields', () => {
+ const expression =
+ 'u("mode") == "std" ? tier("std", u("seconds") * 0.4) : tier("base", u("seconds") * 0.8)'
+
+ assert.equal(tryParseTaskMatrixConfig(expression, numberOnlySchema), null)
+ })
+})
+
+describe('task matrix grammar cross-check', () => {
+ test('bills the same total and tier label as the highlighted matrix row', () => {
+ const matrix = createNonUniformMatrix()
+ const tiers = taskMatrixToTiers(matrix, doubleEnumSchema)
+ const expression = generateTaskExprFromConfig({ tiers }, doubleEnumSchema)
+ const grammarTiers = parseTaskTiersFromExpr(expression, doubleEnumSchema)
+ const combinations = getTaskEnumCombinations(doubleEnumSchema)
+
+ for (const [matchedRowIndex, combination] of combinations.entries()) {
+ const sample: Record = {
+ ...combination,
+ seconds: 3,
+ }
+ const result = evaluateTaskVisualConfig({ tiers }, sample)
+ const grammarTier =
+ grammarTiers
+ .slice(0, -1)
+ .find((tier) =>
+ tier.conditions.every(
+ (condition) => sample[condition.field] === condition.value
+ )
+ ) ?? grammarTiers.at(-1)
+ assert.ok(result)
+ assert.ok(grammarTier)
+ const grammarTotal =
+ grammarTier.constant +
+ Object.entries(grammarTier.unitPrices).reduce(
+ (total, [field, unitPrice]) =>
+ total + Number(sample[field]) * unitPrice,
+ 0
+ )
+
+ assert.equal(result.total, grammarTotal)
+ assert.equal(result.tier.label, grammarTier.label)
+ assert.equal(
+ grammarTier.label,
+ taskMatrixRowLabel(combinations[matchedRowIndex])
+ )
+ }
+ })
+})
+
+describe('uniform task matrix preview highlighting', () => {
+ test('keeps the sampled combination row identifiable after tiers collapse', () => {
+ const matrix: TaskMatrixConfig = {
+ rows: getTaskEnumCombinations(doubleEnumSchema).map((combination) => ({
+ combination,
+ constant: 0.1,
+ unitPrices: { seconds: 0.4 },
+ })),
+ }
+ const tiers = taskMatrixToTiers(matrix, doubleEnumSchema)
+ const sample: Record = {
+ mode: 'pro',
+ quality: 'low',
+ seconds: 3,
+ }
+ const combinations = getTaskEnumCombinations(doubleEnumSchema)
+ const matchedRowIndex = combinations.findIndex((combination) =>
+ Object.entries(combination).every(
+ ([field, value]) => sample[field] === value
+ )
+ )
+ const result = evaluateTaskVisualConfig({ tiers }, sample)
+
+ assert.ok(result)
+ assert.equal(result.total, 0.1 + 3 * 0.4)
+ assert.equal(result.tier.label, 'base')
+ assert.equal(matchedRowIndex, 3)
+ assert.equal(taskMatrixRowLabel(combinations[matchedRowIndex]), 'pro·low')
+ })
+})
diff --git a/web/src/features/pricing/components/dynamic-pricing-breakdown.tsx b/web/src/features/pricing/components/dynamic-pricing-breakdown.tsx
index 848454cc3ec6..1f69a9a2bb37 100644
--- a/web/src/features/pricing/components/dynamic-pricing-breakdown.tsx
+++ b/web/src/features/pricing/components/dynamic-pricing-breakdown.tsx
@@ -17,7 +17,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
import { Tag as TagIcon } from 'lucide-react'
-import { useMemo } from 'react'
+import { useMemo, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { StaticDataTable } from '@/components/data-table'
@@ -34,17 +34,22 @@ import {
MATCH_LT,
MATCH_RANGE,
SOURCE_TIME,
- normalizeTierLabel,
+ parseTaskTiersFromExpr,
parseTiersFromExpr,
requestRuleGroupsFromTrace,
splitBillingExprAndRequestRules,
tryParseRequestRuleExpr,
+ type ParsedTaskTier,
type ParsedTier,
type RequestCondition,
type RequestRuleGroup,
type RequestRuleTrace,
type TierCondition,
} from '../lib/billing-expr'
+import { isBreakdownTierMatched } from '../lib/breakdown-tier-match'
+import type { DynamicPriceLabelKind } from '../lib/dynamic-price'
+import { getTaskMatrixDisplayTiers } from '../lib/task-matrix-display'
+import type { BillingUsageSchema, BillingUsageUnit } from '../types'
type DynamicPricingBreakdownProps = {
billingExpr: string | null | undefined
@@ -68,6 +73,33 @@ type DynamicPricingBreakdownProps = {
* icon header and uses the dialog's small text sizes. Defaults to false.
*/
compact?: boolean
+ usageSchema?: BillingUsageSchema
+ /**
+ * Settlement usage facts from the consume log. Used to highlight the
+ * expanded matrix display row when the engine label no longer matches
+ * any synthesized combination label.
+ */
+ usageFacts?: Record
+}
+
+type BreakdownTier = ParsedTier | ParsedTaskTier
+
+type BreakdownPriceField = {
+ id: string
+ label: string
+ labelKind: DynamicPriceLabelKind
+ unit: BillingUsageUnit | 'request' | 'token'
+ value: (tier: BreakdownTier) => number
+}
+
+function breakdownPriceFieldLabel(
+ field: BreakdownPriceField,
+ t: (key: string) => string
+): ReactNode {
+ if (field.labelKind === 'schema') {
+ return {field.label}
+ }
+ return t(field.label)
}
const VAR_LABELS: Record = {
@@ -115,6 +147,43 @@ function formatConditionSummary(
.join(' && ')
}
+function isTaskBreakdownTier(tier: BreakdownTier): tier is ParsedTaskTier {
+ return 'unitPrices' in tier
+}
+
+function formatBreakdownConditionSummary(
+ tier: BreakdownTier,
+ t: (key: string) => string
+): string {
+ if (!isTaskBreakdownTier(tier)) {
+ return formatConditionSummary(tier.conditions, t)
+ }
+ return tier.conditions
+ .map((condition) => `${condition.field} = ${condition.value}`)
+ .join(' && ')
+}
+
+function formatBreakdownPrice(
+ value: number,
+ field: BreakdownPriceField,
+ symbol: string,
+ rate: number,
+ t: (key: string) => string
+): string {
+ const amount = `${symbol}${(value * rate).toFixed(4)}`
+ if (field.unit === 'second') return `${amount}/${t('s')}`
+ if (field.unit === 'count') return `${amount}/${t('unit')}`
+ if (field.unit === 'credit') return `${amount}/${t('credit')}`
+ if (
+ field.unit === 'token' &&
+ !BILLING_PRICING_VARS.some((variable) => variable.field === field.id)
+ ) {
+ return `${amount}/${t('1M token')}`
+ }
+ if (field.unit === 'request') return `${amount}/${t('request')}`
+ return amount
+}
+
function describeCondition(
cond: RequestCondition,
t: (key: string) => string
@@ -173,6 +242,8 @@ export function DynamicPricingBreakdown({
requestRules,
hideCacheColumns = false,
compact = false,
+ usageSchema,
+ usageFacts,
}: DynamicPricingBreakdownProps) {
const { t } = useTranslation()
const expr = billingExpr || ''
@@ -193,7 +264,18 @@ export function DynamicPricingBreakdown({
const { tiers, ruleGroups } = useMemo(() => {
const split = splitBillingExprAndRequestRules(expr)
- const parsedTiers = parseTiersFromExpr(split.billingExpr)
+ const matrixTiers = getTaskMatrixDisplayTiers(
+ split.billingExpr,
+ usageSchema
+ )
+ let parsedTiers
+ if (matrixTiers) {
+ parsedTiers = matrixTiers
+ } else if (usageSchema) {
+ parsedTiers = parseTaskTiersFromExpr(split.billingExpr, usageSchema)
+ } else {
+ parsedTiers = parseTiersFromExpr(split.billingExpr)
+ }
const parsedRules =
requestRules != null
? requestRuleGroupsFromTrace(requestRules)
@@ -202,13 +284,10 @@ export function DynamicPricingBreakdown({
tiers: parsedTiers,
ruleGroups: parsedRules || [],
}
- }, [expr, requestRules])
+ }, [expr, usageSchema, requestRules])
const hasTiers = tiers.length > 0
const hasRules = ruleGroups.length > 0
- const normalizedMatchedTierLabel = normalizeTierLabel(
- matchedTierLabel ?? undefined
- )
if (!expr) return null
@@ -240,13 +319,61 @@ export function DynamicPricingBreakdown({
)
}
- const visiblePriceFields = BILLING_PRICING_VARS.filter((v) => {
- if (!hasTiers) return false
- if (hideCacheColumns && v.group === 'cache') return false
- return tiers.some(
- (tier) => Number(tier[v.field as string as keyof ParsedTier] || 0) > 0
- )
- })
+ const visiblePriceFields: BreakdownPriceField[] = (() => {
+ if (!hasTiers) return []
+ if (usageSchema) {
+ const fields: BreakdownPriceField[] = Object.entries(usageSchema)
+ .filter(
+ ([field, definition]) =>
+ definition.type === 'number' &&
+ Boolean(definition.unit) &&
+ tiers.some(
+ (tier) =>
+ isTaskBreakdownTier(tier) &&
+ Number(tier.unitPrices[field] || 0) > 0
+ )
+ )
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([field, definition]) => ({
+ id: field,
+ label: field,
+ labelKind: 'schema' as const,
+ unit: definition.unit as BillingUsageUnit,
+ value: (tier: BreakdownTier) =>
+ isTaskBreakdownTier(tier) ? Number(tier.unitPrices[field] || 0) : 0,
+ }))
+ if (
+ tiers.some((tier) => isTaskBreakdownTier(tier) && tier.constant > 0)
+ ) {
+ fields.push({
+ id: 'constant',
+ label: 'Base charge',
+ labelKind: 'i18n',
+ unit: 'request',
+ value: (tier: BreakdownTier) =>
+ isTaskBreakdownTier(tier) ? tier.constant : 0,
+ })
+ }
+ return fields
+ }
+ return BILLING_PRICING_VARS.filter((variable) => {
+ if (hideCacheColumns && variable.group === 'cache') return false
+ return tiers.some(
+ (tier) =>
+ !isTaskBreakdownTier(tier) &&
+ Number(tier[variable.field as string as keyof ParsedTier] || 0) > 0
+ )
+ }).map((variable, index) => ({
+ id: variable.field ?? `price-${index}`,
+ label: variable.shortLabel,
+ labelKind: 'i18n' as const,
+ unit: 'token',
+ value: (tier: BreakdownTier) =>
+ isTaskBreakdownTier(tier)
+ ? 0
+ : Number(tier[variable.field as string as keyof ParsedTier] || 0),
+ }))
+ })()
const mobileTierKeyOccurrences = new Map()
const requestRuleKeyOccurrences = new Map()
@@ -281,11 +408,13 @@ export function DynamicPricingBreakdown({
{tiers.map((tier) => {
- const condSummary = formatConditionSummary(tier.conditions, t)
- const isMatched =
- matchedTierLabel != null &&
- matchedTierLabel !== '' &&
- tier.label === matchedTierLabel
+ const condSummary = formatBreakdownConditionSummary(tier, t)
+ const isMatched = isBreakdownTierMatched(
+ tier,
+ tiers,
+ matchedTierLabel,
+ usageFacts
+ )
const rowKey = nextOccurrenceKey(
JSON.stringify(tier),
mobileTierKeyOccurrences
@@ -320,14 +449,12 @@ export function DynamicPricingBreakdown({
)}
- {visiblePriceFields.map((v) => {
- const value = Number(
- tier[v.field as string as keyof ParsedTier] || 0
- )
+ {visiblePriceFields.map((field) => {
+ const value = field.value(tier)
return (
-
+
- {t(v.shortLabel)}
+ {breakdownPriceFieldLabel(field, t)}
{value > 0
- ? `${symbol}${(value * rate).toFixed(4)}`
+ ? formatBreakdownPrice(
+ value,
+ field,
+ symbol,
+ rate,
+ t
+ )
: '-'}
@@ -358,9 +491,12 @@ export function DynamicPricingBreakdown({
data={tiers}
getRowKey={(_tier, index) => `tier-${index}`}
getRowClassName={(tier) => {
- const isMatched =
- normalizedMatchedTierLabel !== '' &&
- normalizeTierLabel(tier.label) === normalizedMatchedTierLabel
+ const isMatched = isBreakdownTierMatched(
+ tier,
+ tiers,
+ matchedTierLabel,
+ usageFacts
+ )
return cn(
isMatched &&
'bg-emerald-50/70 hover:bg-emerald-50/70 dark:bg-emerald-500/10 dark:hover:bg-emerald-500/10'
@@ -376,11 +512,13 @@ export function DynamicPricingBreakdown({
),
cellClassName: cn('align-top', compact ? 'py-2' : 'py-2.5'),
cell: (tier) => {
- const condSummary = formatConditionSummary(tier.conditions, t)
- const isMatched =
- normalizedMatchedTierLabel !== '' &&
- normalizeTierLabel(tier.label) ===
- normalizedMatchedTierLabel
+ const condSummary = formatBreakdownConditionSummary(tier, t)
+ const isMatched = isBreakdownTierMatched(
+ tier,
+ tiers,
+ matchedTierLabel,
+ usageFacts
+ )
return (
<>
@@ -408,9 +546,9 @@ export function DynamicPricingBreakdown({
)
},
},
- ...visiblePriceFields.map((v, index) => ({
- id: v.field ?? `price-${index}`,
- header: t(v.shortLabel),
+ ...visiblePriceFields.map((field) => ({
+ id: field.id,
+ header: breakdownPriceFieldLabel(field, t),
className: cn(
'text-muted-foreground py-2 text-right font-medium',
compact && 'h-8'
@@ -419,13 +557,11 @@ export function DynamicPricingBreakdown({
'text-right align-top font-mono',
compact ? 'py-2' : 'py-2.5'
),
- cell: (tier: ParsedTier) => {
- const value = Number(
- tier[v.field as string as keyof ParsedTier] || 0
- )
+ cell: (tier: BreakdownTier) => {
+ const value = field.value(tier)
return value > 0 ? (
- {`${symbol}${(value * rate).toFixed(4)}`}
+ {formatBreakdownPrice(value, field, symbol, rate, t)}
) : (
'-'
diff --git a/web/src/features/pricing/components/model-billing-mode-badge.tsx b/web/src/features/pricing/components/model-billing-mode-badge.tsx
index 86f6972999e0..b806c79cf702 100644
--- a/web/src/features/pricing/components/model-billing-mode-badge.tsx
+++ b/web/src/features/pricing/components/model-billing-mode-badge.tsx
@@ -20,8 +20,8 @@ import { useTranslation } from 'react-i18next'
import { StatusBadge, type StatusVariant } from '@/components/status-badge'
+import { getBillingModeLabelKey } from '../lib/billing-mode'
import { isDynamicPricingModel } from '../lib/dynamic-price'
-import { isTokenBasedModel } from '../lib/model-helpers'
import type { PricingModel } from '../types'
interface ModelBillingModeBadgeProps {
@@ -31,14 +31,13 @@ interface ModelBillingModeBadgeProps {
export function ModelBillingModeBadge(props: ModelBillingModeBadgeProps) {
const { t } = useTranslation()
- let label = t('Per Request')
+ const labelKey = getBillingModeLabelKey(props.model)
+ const label = t(labelKey)
let variant: StatusVariant = 'purple'
if (isDynamicPricingModel(props.model)) {
- label = t('Dynamic Pricing')
variant = 'warning'
- } else if (isTokenBasedModel(props.model)) {
- label = t('Token-based')
+ } else if (labelKey === 'Token-based') {
variant = 'info'
}
diff --git a/web/src/features/pricing/components/model-card.tsx b/web/src/features/pricing/components/model-card.tsx
index 3e17ad44e7cc..223bf761dca6 100644
--- a/web/src/features/pricing/components/model-card.tsx
+++ b/web/src/features/pricing/components/model-card.tsx
@@ -26,9 +26,13 @@ import { cn } from '@/lib/utils'
import { DEFAULT_TOKEN_UNIT } from '../constants'
import {
+ getCardExamplePrice,
getDynamicDisplayGroupRatio,
+ getDynamicPriceUnitLabelKey,
getDynamicPricingSummary,
+ isUnconfiguredTaskUsageModel,
} from '../lib/dynamic-price'
+import { getTaskNumberFields } from '../lib/task-expr'
import { parseTags } from '../lib/filters'
import { isTokenBasedModel } from '../lib/model-helpers'
import { formatPrice, formatRequestPrice } from '../lib/price'
@@ -65,19 +69,27 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
const isDynamicPricing =
props.model.billing_mode === 'tiered_expr' &&
Boolean(props.model.billing_expr)
+ const isUnconfiguredTaskUsage = isUnconfiguredTaskUsageModel(props.model)
const hasCachedPrice = isTokenBased && props.model.cache_ratio != null
+ const dynamicPriceOptions = {
+ tokenUnit,
+ showRechargePrice,
+ priceRate,
+ usdExchangeRate,
+ groupRatioMultiplier: getDynamicDisplayGroupRatio(
+ props.model,
+ props.selectedGroup
+ ),
+ }
const dynamicSummary = isDynamicPricing
- ? getDynamicPricingSummary(props.model, {
- tokenUnit,
- showRechargePrice,
- priceRate,
- usdExchangeRate,
- groupRatioMultiplier: getDynamicDisplayGroupRatio(
- props.model,
- props.selectedGroup
- ),
- })
+ ? getDynamicPricingSummary(props.model, dynamicPriceOptions)
: null
+ const cardExamplePrice = getCardExamplePrice(
+ props.model,
+ dynamicPriceOptions
+ )
+ const showTaskFieldLabels =
+ getTaskNumberFields(props.model.billing_usage_schema).length > 1
const primaryGroup = groups[0]
const bottomTags = [...endpoints.slice(0, 2), ...tags.slice(0, 2)]
@@ -107,17 +119,47 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
} else if (dynamicSummary.primaryEntries.length > 0) {
priceSummary = (
<>
- {dynamicSummary.primaryEntries.map((entry) => (
-
- {t(entry.shortLabel)}{' '}
-
- {entry.formatted}
+ {dynamicSummary.primaryEntries.map((entry) => {
+ const unitLabelKey = getDynamicPriceUnitLabelKey(entry)
+ let fieldPrefix: ReactNode = null
+ if (entry.labelKind !== 'schema') {
+ fieldPrefix = <>{t(entry.shortLabel)} >
+ } else if (showTaskFieldLabels) {
+ fieldPrefix = (
+ <>
+
+ {entry.shortLabel}
+ {' '}
+ >
+ )
+ }
+ return (
+
+ {fieldPrefix}
+
+ {entry.formattedRange ?? entry.formatted}
+ {unitLabelKey && <>/{t(unitLabelKey)}>}
+
+ )
+ })}
+ {cardExamplePrice && (
+
+ {cardExamplePrice.label} ≈ {cardExamplePrice.formatted}
- ))}
+ )}
+ {dynamicSummary.isTaskUsage &&
+ dynamicSummary.tier?.label &&
+ !dynamicSummary.primaryEntries.some(
+ (entry) => entry.formattedRange
+ ) && (
+
+ ({dynamicSummary.tier.label})
+
+ )}
>
)
} else {
@@ -127,6 +169,12 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
)
}
+ } else if (isUnconfiguredTaskUsage) {
+ priceSummary = (
+
+ {t('Usage-based billing · price not configured')}
+
+ )
} else if (isTokenBased) {
priceSummary = (
<>
@@ -263,9 +311,11 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
{item}
))}
-
- {tokenUnitLabel}
-
+ {!dynamicSummary?.isTaskUsage && !isUnconfiguredTaskUsage && (
+
+ {tokenUnitLabel}
+
+ )}
{hiddenCount > 0 && (
+{hiddenCount}
diff --git a/web/src/features/pricing/components/model-details.tsx b/web/src/features/pricing/components/model-details.tsx
index dbe105200866..750e68c6d323 100644
--- a/web/src/features/pricing/components/model-details.tsx
+++ b/web/src/features/pricing/components/model-details.tsx
@@ -61,14 +61,25 @@ import { cn } from '@/lib/utils'
import { DEFAULT_TOKEN_UNIT } from '../constants'
import { usePricingData } from '../hooks/use-pricing-data'
import {
+ formatTaskUsageUnitPrice,
getDynamicPriceEntries,
+ getDynamicPriceUnitLabelKey,
getDynamicPricingSummary,
getDynamicPricingTiers,
+ getTaskUsageQuantityUnitLabelKey,
isDynamicPricingModel,
+ isUnconfiguredTaskUsageModel,
+ type DynamicPriceEntry,
} from '../lib/dynamic-price'
import { parseTags } from '../lib/filters'
import { getAvailableGroups, isTokenBasedModel } from '../lib/model-helpers'
import { formatFixedPrice, formatGroupPrice } from '../lib/price'
+import {
+ evaluateTaskUsageExamples,
+ getTaskEnumFields,
+ getTaskNumberFields,
+} from '../lib/task-expr'
+import { getTaskMatrixDisplayTiers } from '../lib/task-matrix-display'
import type {
ModelCapability,
PriceType,
@@ -92,6 +103,60 @@ function SectionTitle(props: { children: React.ReactNode }) {
)
}
+function DynamicPriceEntryLabel(props: { entry: DynamicPriceEntry }) {
+ const { t } = useTranslation()
+ if (props.entry.labelKind === 'schema') {
+ return {props.entry.shortLabel}
+ }
+ return t(props.entry.shortLabel)
+}
+
+function UnconfiguredTaskPricingNotice(props: { model: PricingModel }) {
+ const { t } = useTranslation()
+ const numberFields = getTaskNumberFields(props.model.billing_usage_schema)
+ const enumFields = getTaskEnumFields(props.model.billing_usage_schema)
+
+ return (
+
+
+ {t(
+ 'This model is billed by usage, but the administrator has not configured its pricing yet.'
+ )}
+
+ {numberFields.length + enumFields.length > 0 ? (
+
+ {numberFields.map(([field, definition]) => (
+
+
+ {field}
+
+
+ {t(getTaskUsageQuantityUnitLabelKey(definition.unit))}
+
+
+ ))}
+ {enumFields.map(([field, definition]) => (
+
+
+ {field}
+
+
+ {(definition.enum ?? []).join(', ')}
+
+
+ ))}
+
+ ) : null}
+
+ )
+}
+
const CAPABILITY_LABEL_KEYS: Record = {
function_calling: 'Function calling',
streaming: 'Streaming',
@@ -654,22 +719,25 @@ function PriceSection(props: {
{t('Base Price')}
{dynamicSummary.primaryEntries.length > 0 ? (
- {dynamicSummary.primaryEntries.map((entry) => (
-
-
- {t(entry.shortLabel)}
-
-
- {entry.formatted}
-
- / {tokenUnitLabel}
-
+ {dynamicSummary.primaryEntries.map((entry) => {
+ const unitLabelKey = getDynamicPriceUnitLabelKey(entry)
+ return (
+
+
+
+
+
+ {entry.formatted}
+
+ / {unitLabelKey ? t(unitLabelKey) : tokenUnitLabel}
+
+
-
- ))}
+ )
+ })}
) : (
@@ -679,22 +747,25 @@ function PriceSection(props: {
{dynamicSummary.secondaryEntries.length > 0 && (
- {dynamicSummary.secondaryEntries.map((entry) => (
-
-
- {t(entry.shortLabel)}
-
-
- {entry.formatted}
-
- / {tokenUnitLabel}
+ {dynamicSummary.secondaryEntries.map((entry) => {
+ const unitLabelKey = getDynamicPriceUnitLabelKey(entry)
+ return (
+
+
+
-
-
- ))}
+
+ {entry.formatted}
+
+ / {unitLabelKey ? t(unitLabelKey) : tokenUnitLabel}
+
+
+
+ )
+ })}
)}
@@ -702,6 +773,15 @@ function PriceSection(props: {
)
}
+ if (isUnconfiguredTaskUsageModel(props.model)) {
+ return (
+
+ )
+ }
+
if (!isTokenBased) {
return (
@@ -911,7 +991,11 @@ function GroupPricingSection(props: {
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
if (isDynamicPricingModel(props.model)) {
- const dynamicTiers = getDynamicPricingTiers(props.model)
+ const dynamicTiers =
+ getTaskMatrixDisplayTiers(
+ props.model.billing_expr,
+ props.model.billing_usage_schema
+ ) ?? getDynamicPricingTiers(props.model)
if (dynamicTiers.length === 0) {
return (
@@ -940,12 +1024,18 @@ function GroupPricingSection(props: {
)
}
+ const usageExampleRows = evaluateTaskUsageExamples(
+ props.model.billing_expr,
+ props.model.billing_usage_schema,
+ props.model.billing_usage_examples
+ )
const priceFields = getDynamicPriceFields(dynamicTiers, {
tokenUnit: props.tokenUnit,
showRechargePrice,
priceRate: props.priceRate,
usdExchangeRate: props.usdExchangeRate,
groupRatioMultiplier: 1,
+ usageSchema: props.model.billing_usage_schema,
})
const formattedPricesByGroup = new Map(
availableGroups.map((group) => {
@@ -958,6 +1048,7 @@ function GroupPricingSection(props: {
priceRate: props.priceRate,
usdExchangeRate: props.usdExchangeRate,
groupRatioMultiplier: ratio,
+ usageSchema: props.model.billing_usage_schema,
}),
] as const
})
@@ -998,29 +1089,100 @@ function GroupPricingSection(props: {
cellClassName: 'text-muted-foreground py-2.5',
cell: (tier) => tier.label || t('Default'),
},
- ...priceFields.map((fieldEntry) => ({
- id: fieldEntry.field,
- header: t(fieldEntry.shortLabel),
- className: `${thClass} text-right`,
- cellClassName: 'py-2.5 text-right font-mono',
- cell: (tier: (typeof dynamicTiers)[number]) =>
- formattedPricesByTier
- .get(tier)
- ?.get(fieldEntry.field) ?? '-',
- })),
+ ...priceFields.map((fieldEntry) => {
+ const unitLabelKey =
+ getDynamicPriceUnitLabelKey(fieldEntry)
+ const fieldLabel =
+ fieldEntry.labelKind === 'schema' ? (
+
+ {fieldEntry.shortLabel}
+
+ ) : (
+ t(fieldEntry.shortLabel)
+ )
+ return {
+ id: fieldEntry.field,
+ header: unitLabelKey ? (
+ <>
+ {fieldLabel}
+ {` / ${t(unitLabelKey)}`}
+ >
+ ) : (
+ fieldLabel
+ ),
+ className: `${thClass} text-right`,
+ cellClassName: 'py-2.5 text-right font-mono',
+ cell: (tier: (typeof dynamicTiers)[number]) =>
+ formattedPricesByTier
+ .get(tier)
+ ?.get(fieldEntry.field) ?? '-',
+ }
+ }),
]}
/>
+ {usageExampleRows.length > 0 ? (
+
+
+ {t('Price examples')}
+
+
`${group}-${row.label}`}
+ columns={[
+ {
+ id: 'spec',
+ header: t('Spec'),
+ className: thClass,
+ cellClassName: 'text-muted-foreground py-2.5',
+ cell: (row) => row.label,
+ },
+ {
+ id: 'price',
+ header: t('Example price'),
+ className: `${thClass} text-right`,
+ cellClassName: 'py-2.5 text-right font-mono',
+ cell: (row) =>
+ `≈ ${formatTaskUsageUnitPrice(row.total, {
+ tokenUnit: props.tokenUnit,
+ showRechargePrice,
+ priceRate: props.priceRate,
+ usdExchangeRate: props.usdExchangeRate,
+ groupRatioMultiplier: ratio,
+ })}`,
+ },
+ ]}
+ />
+
+ {t('Approximate prices for common specs.')}
+
+
+ ) : null}
)
})}
- {t('Prices shown per')} {tokenUnitLabel} tokens
+ {dynamicTiers.some((tier) => 'unitPrices' in tier)
+ ? t('Prices shown per usage unit')
+ : `${t('Prices shown per')} ${tokenUnitLabel} tokens`}
)
}
+ if (isUnconfiguredTaskUsageModel(props.model)) {
+ return (
+
+ {t('Pricing by Group')}
+
+
+
+ )
+ }
+
const renderGroupPrice = (group: string, type: PriceType) =>
formatGroupPrice(
props.model,
@@ -1179,7 +1341,10 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
showRechargePrice={showRechargePrice}
/>
{isDynamic && (
-
+
)}
- {primaryEntries.map((entry, index) => (
-
- {index > 0 && (
- /
- )}
- {stripTrailingZeros(entry.formatted)}
-
- ))}
+ {primaryEntries.map((entry, index) => {
+ const unitLabelKey = getDynamicPriceUnitLabelKey(entry)
+ return (
+
+ {index > 0 && (
+ /
+ )}
+ {stripTrailingZeros(
+ entry.formattedRange ?? entry.formatted
+ )}
+ {unitLabelKey && <>/{t(unitLabelKey)}>}
+
+ )
+ })}
- / {tokenUnitLabel} tokens
+ {!dynamicSummary.isTaskUsage && `/ ${tokenUnitLabel} tokens`}
+ {dynamicSummary.isTaskUsage && dynamicSummary.tier?.label}
{dynamicSummary.tierCount > 1 &&
` · ${t('{{count}} tiers', {
count: dynamicSummary.tierCount,
@@ -174,6 +183,17 @@ export function usePricingColumns(
)
}
+ if (isUnconfiguredTaskUsageModel(model)) {
+ return (
+
+
{t('Not configured')}
+
+ {t('Usage-based billing')}
+
+
+ )
+ }
+
const isTokenBased = isTokenBasedModel(model)
if (isTokenBased) {
@@ -282,6 +302,10 @@ export function usePricingColumns(
)
}
+ if (isUnconfiguredTaskUsageModel(model)) {
+ return
—
+ }
+
const isTokenBased = isTokenBasedModel(model)
if (!isTokenBased || model.cache_ratio == null) {
diff --git a/web/src/features/pricing/components/pricing-sidebar.tsx b/web/src/features/pricing/components/pricing-sidebar.tsx
index e07d095b2cf2..038d31bce583 100644
--- a/web/src/features/pricing/components/pricing-sidebar.tsx
+++ b/web/src/features/pricing/components/pricing-sidebar.tsx
@@ -37,6 +37,7 @@ import {
getEndpointTypeLabels,
getQuotaTypeLabels,
} from '../constants'
+import { hasTaskUsageSchema } from '../lib/dynamic-price'
import { parseTags } from '../lib/filters'
import type { PricingModel, PricingVendor } from '../types'
@@ -201,12 +202,23 @@ export function PricingSidebar(props: PricingSidebarProps) {
{
value: QUOTA_TYPES.TOKEN,
label: quotaTypeLabels[QUOTA_TYPES.TOKEN],
- count: countBy(props.models, (model) => model.quota_type === 0),
+ count: countBy(
+ props.models,
+ (model) => model.quota_type === 0 && !hasTaskUsageSchema(model)
+ ),
},
{
value: QUOTA_TYPES.REQUEST,
label: quotaTypeLabels[QUOTA_TYPES.REQUEST],
- count: countBy(props.models, (model) => model.quota_type === 1),
+ count: countBy(
+ props.models,
+ (model) => model.quota_type === 1 && !hasTaskUsageSchema(model)
+ ),
+ },
+ {
+ value: QUOTA_TYPES.TASK,
+ label: quotaTypeLabels[QUOTA_TYPES.TASK],
+ count: countBy(props.models, (model) => hasTaskUsageSchema(model)),
},
]
diff --git a/web/src/features/pricing/constants.ts b/web/src/features/pricing/constants.ts
index 24225e552b30..ca64779d6748 100644
--- a/web/src/features/pricing/constants.ts
+++ b/web/src/features/pricing/constants.ts
@@ -49,6 +49,7 @@ export const QUOTA_TYPES = {
ALL: 'all',
TOKEN: 'token',
REQUEST: 'request',
+ TASK: 'task',
} as const
export type QuotaTypeOption = (typeof QUOTA_TYPES)[keyof typeof QUOTA_TYPES]
@@ -61,6 +62,7 @@ export function getQuotaTypeLabels(
[QUOTA_TYPES.ALL]: t('All Models'),
[QUOTA_TYPES.TOKEN]: t('Token-based'),
[QUOTA_TYPES.REQUEST]: t('Per Request'),
+ [QUOTA_TYPES.TASK]: t('Task billing'),
}
}
diff --git a/web/src/features/pricing/hooks/use-pricing-data.ts b/web/src/features/pricing/hooks/use-pricing-data.ts
index cca13adf7d7a..bfdb7197004c 100644
--- a/web/src/features/pricing/hooks/use-pricing-data.ts
+++ b/web/src/features/pricing/hooks/use-pricing-data.ts
@@ -23,13 +23,14 @@ import { useStatus } from '@/hooks/use-status'
import { getPricing } from '../api'
-export function usePricingData() {
+export function usePricingData(enabled = true) {
const { status } = useStatus()
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['pricing'],
queryFn: getPricing,
staleTime: 5 * 60 * 1000,
+ enabled,
})
// Ensure rates never reach zero to prevent division errors
diff --git a/web/src/features/pricing/lib/billing-expr.ts b/web/src/features/pricing/lib/billing-expr.ts
index 359e93f6f3c4..1b8579a0260f 100644
--- a/web/src/features/pricing/lib/billing-expr.ts
+++ b/web/src/features/pricing/lib/billing-expr.ts
@@ -28,6 +28,8 @@ For commercial licensing, please contact support@quantumnous.com
* expression syntax.
*/
+import type { BillingUsageSchema } from '../types'
+
// ---------------------------------------------------------------------------
// Variable registry
// ---------------------------------------------------------------------------
@@ -248,6 +250,18 @@ export type ParsedTier = {
[field: string]: unknown
}
+export type TaskTierCondition = {
+ field: string
+ value: string
+}
+
+export type ParsedTaskTier = {
+ label: string
+ conditions: TaskTierCondition[]
+ constant: number
+ unitPrices: Record
+}
+
// ---------------------------------------------------------------------------
// Tier parser
// ---------------------------------------------------------------------------
@@ -312,6 +326,274 @@ export function parseTiersFromExpr(exprStr: string): ParsedTier[] {
}
}
+function findTaskTopLevelCharacter(
+ expression: string,
+ target: string,
+ start = 0
+): number {
+ let depth = 0
+ let quoted = false
+ let escaped = false
+ for (let index = start; index < expression.length; index += 1) {
+ const character = expression[index]
+ if (quoted) {
+ if (escaped) {
+ escaped = false
+ } else if (character === '\\') {
+ escaped = true
+ } else if (character === '"') {
+ quoted = false
+ }
+ continue
+ }
+ if (character === '"') {
+ quoted = true
+ continue
+ }
+ if (character === '(') {
+ depth += 1
+ continue
+ }
+ if (character === ')') {
+ depth -= 1
+ if (depth < 0) return -1
+ continue
+ }
+ if (depth === 0 && character === target) return index
+ }
+ return -1
+}
+
+function findTaskTernaryColon(
+ expression: string,
+ questionIndex: number
+): number {
+ let depth = 0
+ let ternaryDepth = 0
+ let quoted = false
+ let escaped = false
+ for (let index = questionIndex + 1; index < expression.length; index += 1) {
+ const character = expression[index]
+ if (quoted) {
+ if (escaped) {
+ escaped = false
+ } else if (character === '\\') {
+ escaped = true
+ } else if (character === '"') {
+ quoted = false
+ }
+ continue
+ }
+ if (character === '"') {
+ quoted = true
+ continue
+ }
+ if (character === '(') {
+ depth += 1
+ continue
+ }
+ if (character === ')') {
+ depth -= 1
+ if (depth < 0) return -1
+ continue
+ }
+ if (depth !== 0) continue
+ if (character === '?') {
+ ternaryDepth += 1
+ continue
+ }
+ if (character !== ':') continue
+ if (ternaryDepth === 0) return index
+ ternaryDepth -= 1
+ }
+ return -1
+}
+
+function splitTaskTopLevel(expression: string, operator: '&&' | '+'): string[] {
+ const parts: string[] = []
+ let start = 0
+ let depth = 0
+ let quoted = false
+ let escaped = false
+ for (let index = 0; index < expression.length; index += 1) {
+ const character = expression[index]
+ if (quoted) {
+ if (escaped) {
+ escaped = false
+ } else if (character === '\\') {
+ escaped = true
+ } else if (character === '"') {
+ quoted = false
+ }
+ continue
+ }
+ if (character === '"') {
+ quoted = true
+ continue
+ }
+ if (character === '(') {
+ depth += 1
+ continue
+ }
+ if (character === ')') {
+ depth -= 1
+ continue
+ }
+ if (depth !== 0) continue
+ if (operator === '&&' && expression.slice(index, index + 2) === '&&') {
+ parts.push(expression.slice(start, index).trim())
+ start = index + 2
+ index += 1
+ continue
+ }
+ if (
+ operator === '+' &&
+ character === '+' &&
+ expression[index - 1] !== 'e' &&
+ expression[index - 1] !== 'E'
+ ) {
+ parts.push(expression.slice(start, index).trim())
+ start = index + 1
+ }
+ }
+ parts.push(expression.slice(start).trim())
+ return parts.filter(Boolean)
+}
+
+function parseTaskConditions(
+ expression: string,
+ schema: BillingUsageSchema
+): TaskTierCondition[] | null {
+ const conditions: TaskTierCondition[] = []
+ for (const part of splitTaskTopLevel(expression, '&&')) {
+ const match = part.match(
+ /^u\(\s*("(?:[^"\\]|\\.)*")\s*\)\s*==\s*("(?:[^"\\]|\\.)*")$/
+ )
+ if (!match) return null
+ let field: string
+ let value: string
+ try {
+ field = JSON.parse(match[1]) as string
+ value = JSON.parse(match[2]) as string
+ } catch {
+ return null
+ }
+ const declaredValues = schema[field]?.enum
+ if (!declaredValues?.includes(value)) return null
+ conditions.push({ field, value })
+ }
+ return conditions.length > 0 ? conditions : null
+}
+
+function parseTaskTierCall(
+ expression: string,
+ conditions: TaskTierCondition[],
+ schema: BillingUsageSchema
+): ParsedTaskTier | null {
+ const trimmed = expression.trim()
+ if (!trimmed.startsWith('tier(') || !trimmed.endsWith(')')) return null
+ const inner = trimmed.slice(5, -1)
+ const commaIndex = findTaskTopLevelCharacter(inner, ',')
+ if (commaIndex < 0) return null
+
+ let label: string
+ try {
+ label = JSON.parse(inner.slice(0, commaIndex).trim()) as string
+ } catch {
+ return null
+ }
+ if (typeof label !== 'string') return null
+
+ const terms = splitTaskTopLevel(inner.slice(commaIndex + 1), '+')
+ const unitPrices: Record = {}
+ let constant = 0
+ let hasConstant = false
+ for (const term of terms) {
+ if (NUMERIC_LITERAL_REGEX.test(term)) {
+ const value = Number(term)
+ if (hasConstant || !Number.isFinite(value) || value < 0) return null
+ constant = value
+ hasConstant = true
+ continue
+ }
+ const scaledMatch = term.match(
+ /^u\(\s*("(?:[^"\\]|\\.)*")\s*\)\s*\*\s*(-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*\/\s*1000000$/
+ )
+ const bareMatch = term.match(
+ /^u\(\s*("(?:[^"\\]|\\.)*")\s*\)\s*\*\s*(-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)$/
+ )
+ const match = scaledMatch ?? bareMatch
+ if (!match) return null
+ let field: string
+ try {
+ field = JSON.parse(match[1]) as string
+ } catch {
+ return null
+ }
+ const fieldSchema = schema[field]
+ const value = Number(match[2])
+ if (
+ fieldSchema?.type !== 'number' ||
+ !fieldSchema.unit ||
+ field in unitPrices ||
+ !Number.isFinite(value) ||
+ value < 0
+ ) {
+ return null
+ }
+ if (fieldSchema.unit === 'token') {
+ if (!scaledMatch) return null
+ } else if (scaledMatch) {
+ return null
+ }
+ unitPrices[field] = value
+ }
+ if (Object.keys(unitPrices).length === 0) return null
+ return { label, conditions, constant, unitPrices }
+}
+
+export function parseTaskTiersFromExpr(
+ exprStr: string,
+ schema: BillingUsageSchema | null | undefined
+): ParsedTaskTier[] {
+ if (!exprStr || !schema || Object.keys(schema).length === 0) return []
+ try {
+ const split = splitBillingExprAndRequestRules(exprStr)
+ const versioned = stripExprVersion(split.billingExpr).body.trim()
+ if (!versioned) return []
+
+ const tiers: ParsedTaskTier[] = []
+ let remaining = versioned
+ while (remaining) {
+ const questionIndex = findTaskTopLevelCharacter(remaining, '?')
+ if (questionIndex < 0) {
+ const tier = parseTaskTierCall(remaining, [], schema)
+ if (!tier) return []
+ tiers.push(tier)
+ break
+ }
+ const colonIndex = findTaskTernaryColon(remaining, questionIndex)
+ if (colonIndex < 0) return []
+ const conditions = parseTaskConditions(
+ remaining.slice(0, questionIndex).trim(),
+ schema
+ )
+ if (!conditions) return []
+ const tier = parseTaskTierCall(
+ remaining.slice(questionIndex + 1, colonIndex).trim(),
+ conditions,
+ schema
+ )
+ if (!tier) return []
+ tiers.push(tier)
+ remaining = remaining.slice(colonIndex + 1).trim()
+ }
+ return tiers
+ } catch {
+ return []
+ }
+}
+
export function normalizeTierLabel(label: string | undefined): string {
if (!label) return ''
return label
diff --git a/web/src/features/pricing/lib/billing-mode.ts b/web/src/features/pricing/lib/billing-mode.ts
new file mode 100644
index 000000000000..fcd4d78e9885
--- /dev/null
+++ b/web/src/features/pricing/lib/billing-mode.ts
@@ -0,0 +1,38 @@
+/*
+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 type { PricingModel } from '../types'
+import { hasTaskUsageSchema, isDynamicPricingModel } from './dynamic-price'
+import { isTokenBasedModel } from './model-helpers'
+
+export type BillingModeLabelKey =
+ | 'Per Request'
+ | 'Dynamic Pricing'
+ | 'Token-based'
+ | 'Task billing'
+
+export function getBillingModeLabelKey(
+ model: PricingModel
+): BillingModeLabelKey {
+ // Task-usage models badge as one business category; the metering unit
+ // ($/1M token, $/credit, $/second) is already carried by the price line.
+ if (hasTaskUsageSchema(model)) return 'Task billing'
+ if (isDynamicPricingModel(model)) return 'Dynamic Pricing'
+ if (isTokenBasedModel(model)) return 'Token-based'
+ return 'Per Request'
+}
diff --git a/web/src/features/pricing/lib/breakdown-tier-match.ts b/web/src/features/pricing/lib/breakdown-tier-match.ts
new file mode 100644
index 000000000000..06628add9dbe
--- /dev/null
+++ b/web/src/features/pricing/lib/breakdown-tier-match.ts
@@ -0,0 +1,74 @@
+/*
+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 {
+ normalizeTierLabel,
+ type ParsedTaskTier,
+ type ParsedTier,
+} from './billing-expr'
+
+type BreakdownMatchTier = ParsedTier | ParsedTaskTier
+
+function tierMatchesNormalizedLabel(
+ tier: BreakdownMatchTier,
+ normalizedMatchedTierLabel: string
+): boolean {
+ return (
+ normalizedMatchedTierLabel !== '' &&
+ normalizeTierLabel(tier.label) === normalizedMatchedTierLabel
+ )
+}
+
+/**
+ * Decide whether a price-table row is the settlement hit.
+ * Label equality (after normalizeTierLabel) wins; usage-facts matching is
+ * only a fallback when no display row still carries the engine label.
+ */
+export function isBreakdownTierMatched(
+ tier: BreakdownMatchTier,
+ tiers: readonly BreakdownMatchTier[],
+ matchedTierLabel?: string | null,
+ usageFacts?: Record
+): boolean {
+ const normalizedMatchedTierLabel = normalizeTierLabel(
+ matchedTierLabel ?? undefined
+ )
+ if (tierMatchesNormalizedLabel(tier, normalizedMatchedTierLabel)) {
+ return true
+ }
+ if (
+ tiers.some((candidate) =>
+ tierMatchesNormalizedLabel(candidate, normalizedMatchedTierLabel)
+ )
+ ) {
+ return false
+ }
+ if (!usageFacts || tier.conditions.length === 0) {
+ return false
+ }
+ return tier.conditions.every((condition) => {
+ if (!('field' in condition)) {
+ return false
+ }
+ const fact = usageFacts[condition.field]
+ if (fact === undefined) {
+ return false
+ }
+ return String(fact) === condition.value
+ })
+}
diff --git a/web/src/features/pricing/lib/dynamic-price.ts b/web/src/features/pricing/lib/dynamic-price.ts
index 68160822ad14..470a3b63f16b 100644
--- a/web/src/features/pricing/lib/dynamic-price.ts
+++ b/web/src/features/pricing/lib/dynamic-price.ts
@@ -19,16 +19,28 @@ For commercial licensing, please contact support@quantumnous.com
import { formatBillingCurrencyFromUSD } from '@/lib/currency'
import { TOKEN_UNIT_DIVISORS } from '../constants'
-import type { PricingModel, TokenUnit } from '../types'
+import type {
+ BillingUsageSchema,
+ BillingUsageUnit,
+ PricingModel,
+ TokenUnit,
+} from '../types'
import {
BILLING_PRICING_VARS,
+ parseTaskTiersFromExpr,
parseTiersFromExpr,
splitBillingExprAndRequestRules,
tryParseRequestRuleExpr,
type BillingVar,
+ type ParsedTaskTier,
type ParsedTier,
} from './billing-expr'
import { getDisplayGroupRatio } from './model-helpers'
+import {
+ evaluateTaskVisualConfig,
+ getTaskNumberFields,
+ tryParseTaskVisualConfig,
+} from './task-expr'
type DynamicPriceOptions = {
tokenUnit: TokenUnit
@@ -36,21 +48,36 @@ type DynamicPriceOptions = {
priceRate?: number
usdExchangeRate?: number
groupRatioMultiplier?: number
+ usageSchema?: BillingUsageSchema
}
+export type DynamicPriceLabelKind = 'i18n' | 'schema'
+
export type DynamicPriceEntry = {
key: string
field: string
label: string
shortLabel: string
+ /** `schema` labels are raw usage-field names and must not go through `t()`. */
+ labelKind: DynamicPriceLabelKind
value: number
formatted: string
- variable: BillingVar
+ formattedRange?: string
+ unit: 'token' | BillingUsageUnit | 'request'
+ variable?: BillingVar
+ description?: string | Record
+}
+
+export type CardExamplePrice = {
+ label: string
+ formatted: string
}
+export type DynamicPricingTier = ParsedTier | ParsedTaskTier
+
export type DynamicPricingSummary = {
- tiers: ParsedTier[]
- tier: ParsedTier | null
+ tiers: DynamicPricingTier[]
+ tier: DynamicPricingTier | null
tierCount: number
hasRequestRules: boolean
isSpecialExpression: boolean
@@ -58,14 +85,75 @@ export type DynamicPricingSummary = {
entries: DynamicPriceEntry[]
primaryEntries: DynamicPriceEntry[]
secondaryEntries: DynamicPriceEntry[]
+ isTaskUsage: boolean
+}
+
+export function getTaskUsageQuantityUnitLabelKey(
+ unit: BillingUsageUnit | undefined
+): string {
+ if (unit === 'second') return 's'
+ if (unit === 'token') return 'token (unit)'
+ if (unit === 'credit') return 'credit'
+ return 'unit'
+}
+
+export function getTaskUsagePriceUnitLabelKey(
+ unit: BillingUsageUnit | undefined
+): string {
+ if (unit === 'second') return 'second'
+ if (unit === 'token') return '1M token'
+ if (unit === 'credit') return 'credit'
+ return 'unit'
+}
+
+export function getDynamicPriceUnitLabelKey(
+ entry: DynamicPriceEntry
+): string | null {
+ if (entry.unit === 'second') return 's'
+ if (entry.unit === 'count') return 'unit'
+ if (entry.unit === 'credit') return 'credit'
+ // Chat token entries also use unit 'token' but keep the 1M-token label.
+ if (entry.unit === 'token' && !entry.variable) return '1M token'
+ if (entry.unit === 'request') return 'request'
+ return null
}
const PRIMARY_DYNAMIC_FIELDS = new Set(['inputPrice', 'outputPrice'])
+function isTaskPricingTier(tier: DynamicPricingTier): tier is ParsedTaskTier {
+ return (
+ Object.hasOwn(tier, 'unitPrices') &&
+ typeof (tier as ParsedTaskTier).unitPrices === 'object'
+ )
+}
+
export function isDynamicPricingModel(model: PricingModel): boolean {
return model.billing_mode === 'tiered_expr' && Boolean(model.billing_expr)
}
+export function hasTaskUsageSchema(model: PricingModel): boolean {
+ return Object.keys(model.billing_usage_schema ?? {}).length > 0
+}
+
+export function isTaskUsagePricingModel(model: PricingModel): boolean {
+ return model.billing_mode === 'tiered_expr' && hasTaskUsageSchema(model)
+}
+
+export function isUnconfiguredTaskUsageModel(model: PricingModel): boolean {
+ return (
+ model.quota_type !== 1 &&
+ hasTaskUsageSchema(model) &&
+ !isDynamicPricingModel(model)
+ )
+}
+
+export function getTaskPricingUnit(
+ model: PricingModel
+): BillingUsageUnit | null {
+ const primaryField = getTaskNumberFields(model.billing_usage_schema)[0]
+ return primaryField?.[1].unit ?? null
+}
+
export function getDynamicDisplayGroupRatio(
model: PricingModel,
selectedGroup?: string
@@ -107,11 +195,38 @@ export function formatDynamicUnitPrice(
})
}
-export function getDynamicPricingTiers(model: PricingModel): ParsedTier[] {
+export function formatTaskUsageUnitPrice(
+ valuePerUnit: number,
+ options: DynamicPriceOptions
+): string {
+ const groupRatio = options.groupRatioMultiplier ?? 1
+ const priceRate = options.priceRate ?? 1
+ const usdExchangeRate = options.usdExchangeRate ?? 1
+ const priceUSD = valuePerUnit * groupRatio
+ const displayPrice = applyRechargeRate(
+ priceUSD,
+ options.showRechargePrice ?? false,
+ priceRate,
+ usdExchangeRate
+ )
+
+ return formatBillingCurrencyFromUSD(displayPrice, {
+ digitsLarge: 4,
+ digitsSmall: 6,
+ abbreviate: false,
+ })
+}
+
+export function getDynamicPricingTiers(
+ model: PricingModel
+): DynamicPricingTier[] {
if (!isDynamicPricingModel(model)) return []
const { billingExpr } = splitBillingExprAndRequestRules(
model.billing_expr || ''
)
+ if (isTaskUsagePricingModel(model)) {
+ return parseTaskTiersFromExpr(billingExpr, model.billing_usage_schema)
+ }
return parseTiersFromExpr(billingExpr)
}
@@ -124,14 +239,49 @@ export function hasDynamicRequestRules(model: PricingModel): boolean {
}
export function getDynamicPriceEntries(
- tier: ParsedTier | null,
+ tier: DynamicPricingTier | null,
options: DynamicPriceOptions
): DynamicPriceEntry[] {
if (!tier) return []
+ if (isTaskPricingTier(tier) && options.usageSchema) {
+ const usageEntries: DynamicPriceEntry[] = getTaskNumberFields(
+ options.usageSchema
+ ).flatMap(([field, definition]) => {
+ const value = Number(tier.unitPrices[field])
+ if (!Number.isFinite(value) || value <= 0 || !definition.unit) return []
+ return [
+ {
+ key: field,
+ field,
+ label: field,
+ shortLabel: field,
+ labelKind: 'schema',
+ value,
+ formatted: formatTaskUsageUnitPrice(value, options),
+ unit: definition.unit,
+ description: definition.description,
+ } satisfies DynamicPriceEntry,
+ ]
+ })
+ if (tier.constant > 0) {
+ usageEntries.push({
+ key: 'constant',
+ field: 'constant',
+ label: 'Base charge',
+ shortLabel: 'Base',
+ labelKind: 'i18n',
+ value: tier.constant,
+ formatted: formatTaskUsageUnitPrice(tier.constant, options),
+ unit: 'request',
+ })
+ }
+ return usageEntries
+ }
+
return BILLING_PRICING_VARS.flatMap((variable) => {
if (!variable.field) return []
- const value = Number(tier[variable.field])
+ const value = Number((tier as ParsedTier)[variable.field])
if (!Number.isFinite(value) || value <= 0) return []
return [
@@ -140,8 +290,10 @@ export function getDynamicPriceEntries(
field: variable.field,
label: variable.label,
shortLabel: variable.shortLabel,
+ labelKind: 'i18n' as const,
value,
formatted: formatDynamicUnitPrice(value, options),
+ unit: 'token' as const,
variable,
},
]
@@ -160,8 +312,37 @@ export function getDynamicPricingSummary(
if (!isDynamicPricingModel(model)) return null
const tiers = getDynamicPricingTiers(model)
- const tier = tiers[0] || null
- const entries = getDynamicPriceEntries(tier, options)
+ const isTaskUsage = isTaskUsagePricingModel(model)
+ const tier = isTaskUsage ? (tiers.at(-1) ?? null) : (tiers[0] ?? null)
+ let entries = getDynamicPriceEntries(tier, {
+ ...options,
+ usageSchema: model.billing_usage_schema,
+ })
+ if (isTaskUsage) {
+ const priceRanges = new Map()
+ for (const [field] of getTaskNumberFields(model.billing_usage_schema)) {
+ let min = Number.POSITIVE_INFINITY
+ let max = Number.NEGATIVE_INFINITY
+ for (const taskTier of tiers) {
+ if (!isTaskPricingTier(taskTier)) continue
+ const value = Number(taskTier.unitPrices[field])
+ if (!Number.isFinite(value) || value <= 0) continue
+ min = Math.min(min, value)
+ max = Math.max(max, value)
+ }
+ if (Number.isFinite(min) && Number.isFinite(max)) {
+ priceRanges.set(field, { min, max })
+ }
+ }
+ entries = entries.map((entry) => {
+ const range = priceRanges.get(entry.field)
+ if (!range || range.min === range.max) return entry
+ return {
+ ...entry,
+ formattedRange: `${formatTaskUsageUnitPrice(range.min, options)}–${formatTaskUsageUnitPrice(range.max, options)}`,
+ }
+ })
+ }
const rawExpression = model.billing_expr || ''
return {
@@ -172,11 +353,36 @@ export function getDynamicPricingSummary(
isSpecialExpression: rawExpression.trim().length > 0 && tiers.length === 0,
rawExpression,
entries,
- primaryEntries: entries.filter((entry) =>
- PRIMARY_DYNAMIC_FIELDS.has(entry.field)
- ),
- secondaryEntries: entries.filter(
- (entry) => !PRIMARY_DYNAMIC_FIELDS.has(entry.field)
- ),
+ primaryEntries: isTaskUsage
+ ? entries.filter((entry) => entry.unit !== 'request')
+ : entries.filter((entry) => PRIMARY_DYNAMIC_FIELDS.has(entry.field)),
+ secondaryEntries: isTaskUsage
+ ? entries.filter((entry) => entry.unit === 'request')
+ : entries.filter((entry) => !PRIMARY_DYNAMIC_FIELDS.has(entry.field)),
+ isTaskUsage,
+ }
+}
+
+export function getCardExamplePrice(
+ model: PricingModel,
+ options: DynamicPriceOptions
+): CardExamplePrice | null {
+ if (!isTaskUsagePricingModel(model)) return null
+ const schema = model.billing_usage_schema
+ const firstExample = model.billing_usage_examples?.[0]
+ if (!schema || !firstExample) return null
+
+ const { billingExpr } = splitBillingExprAndRequestRules(
+ model.billing_expr || ''
+ )
+ const config = tryParseTaskVisualConfig(billingExpr, schema)
+ if (!config) return null
+
+ const result = evaluateTaskVisualConfig(config, firstExample.facts, schema)
+ if (!result) return null
+
+ return {
+ label: firstExample.label,
+ formatted: formatTaskUsageUnitPrice(result.total, options),
}
}
diff --git a/web/src/features/pricing/lib/filters.ts b/web/src/features/pricing/lib/filters.ts
index 83788dd700f6..22e8da936b0c 100644
--- a/web/src/features/pricing/lib/filters.ts
+++ b/web/src/features/pricing/lib/filters.ts
@@ -24,6 +24,7 @@ import {
ENDPOINT_TYPES,
} from '../constants'
import type { PricingModel } from '../types'
+import { hasTaskUsageSchema } from './dynamic-price'
// ----------------------------------------------------------------------------
// Filter Utilities
@@ -78,11 +79,17 @@ export function filterByQuotaType(
quotaType: string
): PricingModel[] {
if (quotaType === QUOTA_TYPES.ALL) return models
+ // Task-usage models form their own bucket, disjoint from token/request.
+ if (quotaType === QUOTA_TYPES.TASK) {
+ return models.filter((m) => hasTaskUsageSchema(m))
+ }
const targetType =
quotaType === QUOTA_TYPES.TOKEN
? QUOTA_TYPE_VALUES.TOKEN
: QUOTA_TYPE_VALUES.REQUEST
- return models.filter((m) => m.quota_type === targetType)
+ return models.filter(
+ (m) => m.quota_type === targetType && !hasTaskUsageSchema(m)
+ )
}
/**
diff --git a/web/src/features/pricing/lib/task-expr.ts b/web/src/features/pricing/lib/task-expr.ts
new file mode 100644
index 000000000000..1a11d376c50c
--- /dev/null
+++ b/web/src/features/pricing/lib/task-expr.ts
@@ -0,0 +1,462 @@
+/*
+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 type {
+ BillingUsageExample,
+ BillingUsageFieldSchema,
+ BillingUsageSchema,
+} from '../types'
+
+export const TASK_TOKEN_PRICE_SCALE = 1_000_000
+import {
+ parseTaskTiersFromExpr,
+ splitBillingExprAndRequestRules,
+} from './billing-expr'
+
+export type TaskVisualCondition = {
+ field: string
+ value: string
+}
+
+export type TaskVisualTier = {
+ label: string
+ conditions: TaskVisualCondition[]
+ constant: number
+ unitPrices: Record
+}
+
+export type TaskVisualConfig = {
+ tiers: TaskVisualTier[]
+}
+
+export type TaskMatrixRow = {
+ combination: Record
+ constant: number
+ unitPrices: Record
+}
+
+export type TaskMatrixConfig = {
+ rows: TaskMatrixRow[]
+}
+
+export type TaskPreviewResult = {
+ tier: TaskVisualTier
+ total: number
+ parts: {
+ kind: 'constant' | 'usage'
+ field?: string
+ amount: number
+ quantity?: number
+ unitPrice?: number
+ }[]
+}
+
+export function getTaskNumberFields(
+ schema: BillingUsageSchema | null | undefined
+): [string, BillingUsageFieldSchema][] {
+ if (!schema) return []
+ return Object.entries(schema)
+ .filter((entry) => entry[1].type === 'number' && Boolean(entry[1].unit))
+ .sort(([left], [right]) => left.localeCompare(right))
+}
+
+export function getTaskEnumFields(
+ schema: BillingUsageSchema | null | undefined
+): [string, BillingUsageFieldSchema][] {
+ if (!schema) return []
+ return Object.entries(schema)
+ .filter((entry) => Boolean(entry[1].enum?.length))
+ .sort(([left], [right]) => left.localeCompare(right))
+}
+
+export function getTaskEnumCombinations(
+ schema: BillingUsageSchema | null | undefined
+): Record[] {
+ let combinations: Record[] = [{}]
+ for (const [field, definition] of getTaskEnumFields(schema)) {
+ const nextCombinations: Record[] = []
+ for (const combination of combinations) {
+ for (const value of definition.enum ?? []) {
+ nextCombinations.push({ ...combination, [field]: value })
+ }
+ }
+ combinations = nextCombinations
+ }
+ return combinations
+}
+
+export function createDefaultTaskVisualConfig(
+ schema: BillingUsageSchema
+): TaskVisualConfig {
+ return {
+ tiers: [
+ {
+ label: 'base',
+ conditions: [],
+ constant: 0,
+ unitPrices: Object.fromEntries(
+ getTaskNumberFields(schema).map(([field]) => [field, 0])
+ ),
+ },
+ ],
+ }
+}
+
+export function createDefaultTaskMatrixConfig(
+ schema: BillingUsageSchema
+): TaskMatrixConfig {
+ const unitPrices = Object.fromEntries(
+ getTaskNumberFields(schema).map(([field]) => [field, 0])
+ )
+ return {
+ rows: getTaskEnumCombinations(schema).map((combination) => ({
+ combination,
+ constant: 0,
+ unitPrices: { ...unitPrices },
+ })),
+ }
+}
+
+export function taskMatrixRowLabel(
+ combination: Record
+): string {
+ const values = Object.entries(combination)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([, value]) => value)
+ return values.length > 0 ? values.join('·') : 'base'
+}
+
+function taskMatrixCombinationKey(
+ combination: Record,
+ enumFields: [string, BillingUsageFieldSchema][]
+): string {
+ return JSON.stringify(enumFields.map(([field]) => combination[field]))
+}
+
+export function taskMatrixToTiers(
+ config: TaskMatrixConfig,
+ schema: BillingUsageSchema
+): TaskVisualTier[] {
+ const numberFields = getTaskNumberFields(schema)
+ const firstRow = config.rows[0]
+ if (numberFields.length === 0 || !firstRow) return []
+
+ const isUniform = config.rows.every(
+ (row) =>
+ row.constant === firstRow.constant &&
+ numberFields.every(
+ ([field]) => row.unitPrices[field] === firstRow.unitPrices[field]
+ )
+ )
+ if (isUniform) {
+ return [
+ {
+ label: 'base',
+ conditions: [],
+ constant: firstRow.constant,
+ unitPrices: Object.fromEntries(
+ numberFields.map(([field]) => [
+ field,
+ firstRow.unitPrices[field] ?? 0,
+ ])
+ ),
+ },
+ ]
+ }
+
+ return config.rows.map((row, index) => ({
+ label: taskMatrixRowLabel(row.combination),
+ conditions:
+ index === config.rows.length - 1
+ ? []
+ : Object.entries(row.combination)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([field, value]) => ({ field, value })),
+ constant: row.constant,
+ unitPrices: Object.fromEntries(
+ numberFields.map(([field]) => [field, row.unitPrices[field] ?? 0])
+ ),
+ }))
+}
+
+export function tryParseTaskMatrixConfig(
+ expression: string | null | undefined,
+ schema: BillingUsageSchema
+): TaskMatrixConfig | null {
+ if (!expression) return null
+ const tiers = parseTaskTiersFromExpr(expression, schema)
+ if (tiers.length === 0) return null
+
+ const enumFields = getTaskEnumFields(schema)
+ const numberFields = getTaskNumberFields(schema)
+ const combinations = getTaskEnumCombinations(schema)
+
+ if (tiers.length === 1 && tiers[0].conditions.length === 0) {
+ return {
+ rows: combinations.map((combination) => ({
+ combination,
+ constant: tiers[0].constant,
+ unitPrices: Object.fromEntries(
+ numberFields.map(([field]) => [
+ field,
+ tiers[0].unitPrices[field] ?? 0,
+ ])
+ ),
+ })),
+ }
+ }
+
+ if (tiers.length !== combinations.length) return null
+ const fallbackTier = tiers.at(-1)
+ if (!fallbackTier || fallbackTier.conditions.length !== 0) return null
+
+ const tiersByCombination = new Map()
+ for (const tier of tiers.slice(0, -1)) {
+ if (tier.conditions.length !== enumFields.length) return null
+
+ const valuesByField = new Map()
+ for (const condition of tier.conditions) {
+ const definition = schema[condition.field]
+ if (
+ valuesByField.has(condition.field) ||
+ !definition?.enum?.includes(condition.value)
+ ) {
+ return null
+ }
+ valuesByField.set(condition.field, condition.value)
+ }
+ if (valuesByField.size !== enumFields.length) return null
+
+ const combination = Object.fromEntries(
+ enumFields.map(([field]) => [field, valuesByField.get(field) ?? ''])
+ )
+ const key = taskMatrixCombinationKey(combination, enumFields)
+ if (tiersByCombination.has(key)) return null
+ tiersByCombination.set(key, tier)
+ }
+
+ const missingCombinations = combinations.filter(
+ (combination) =>
+ !tiersByCombination.has(taskMatrixCombinationKey(combination, enumFields))
+ )
+ if (missingCombinations.length !== 1) return null
+ tiersByCombination.set(
+ taskMatrixCombinationKey(missingCombinations[0], enumFields),
+ fallbackTier
+ )
+
+ const rows: TaskMatrixRow[] = []
+ for (const combination of combinations) {
+ const tier = tiersByCombination.get(
+ taskMatrixCombinationKey(combination, enumFields)
+ )
+ if (!tier) return null
+ rows.push({
+ combination,
+ constant: tier.constant,
+ unitPrices: Object.fromEntries(
+ numberFields.map(([field]) => [field, tier.unitPrices[field] ?? 0])
+ ),
+ })
+ }
+ return { rows }
+}
+
+export function evaluateTaskVisualConfig(
+ config: TaskVisualConfig,
+ sample: Record,
+ schema?: BillingUsageSchema
+): TaskPreviewResult | null {
+ const fallback = config.tiers.at(-1)
+ if (!fallback) return null
+
+ let matchedTier = fallback
+ for (const tier of config.tiers.slice(0, -1)) {
+ const matches = tier.conditions.every(
+ (condition) => sample[condition.field] === condition.value
+ )
+ if (matches) {
+ matchedTier = tier
+ break
+ }
+ }
+
+ const constant = Number(matchedTier.constant)
+ if (!Number.isFinite(constant) || constant < 0) return null
+
+ const parts: TaskPreviewResult['parts'] = []
+ let total = 0
+ if (constant > 0) {
+ parts.push({ kind: 'constant', amount: constant })
+ total += constant
+ }
+
+ for (const [field, rawUnitPrice] of Object.entries(matchedTier.unitPrices)) {
+ const unitPrice = Number(rawUnitPrice)
+ if (!Number.isFinite(unitPrice) || unitPrice < 0) return null
+ if (unitPrice === 0) continue
+
+ const quantity = Number(sample[field])
+ if (!Number.isFinite(quantity) || quantity < 0) return null
+ const amount =
+ schema?.[field]?.unit === 'token'
+ ? (quantity * unitPrice) / TASK_TOKEN_PRICE_SCALE
+ : quantity * unitPrice
+ if (!Number.isFinite(amount)) return null
+ parts.push({ kind: 'usage', field, amount, quantity, unitPrice })
+ total += amount
+ }
+
+ if (!Number.isFinite(total)) return null
+ return { tier: matchedTier, total, parts }
+}
+
+export function evaluateTaskUsageExamples(
+ expression: string | null | undefined,
+ schema: BillingUsageSchema | null | undefined,
+ examples: BillingUsageExample[] | null | undefined
+): { label: string; total: number }[] {
+ if (!expression || !schema || !examples?.length) return []
+ const { billingExpr } = splitBillingExprAndRequestRules(expression)
+ const config = tryParseTaskVisualConfig(billingExpr, schema)
+ if (!config) return []
+ const rows: { label: string; total: number }[] = []
+ for (const example of examples) {
+ const result = evaluateTaskVisualConfig(config, example.facts, schema)
+ if (!result) continue
+ rows.push({ label: example.label, total: result.total })
+ }
+ return rows
+}
+
+export function normalizeTaskVisualConfig(
+ config: TaskVisualConfig | null | undefined,
+ schema: BillingUsageSchema
+): TaskVisualConfig {
+ if (!config?.tiers?.length) return createDefaultTaskVisualConfig(schema)
+ const numberFields = new Set(
+ getTaskNumberFields(schema).map(([field]) => field)
+ )
+ const enumFields = new Map(
+ getTaskEnumFields(schema).map(([field, definition]) => [
+ field,
+ definition.enum ?? [],
+ ])
+ )
+
+ return {
+ tiers: config.tiers.map((tier, index) => {
+ const unitPrices = Object.fromEntries(
+ [...numberFields].map((field) => {
+ const value = Number(tier.unitPrices?.[field])
+ return [field, Number.isFinite(value) && value >= 0 ? value : 0]
+ })
+ )
+ const constant = Number(tier.constant)
+ return {
+ label: tier.label || (index === 0 ? 'base' : `tier_${index + 1}`),
+ conditions: (tier.conditions ?? []).filter((condition) =>
+ enumFields.get(condition.field)?.includes(condition.value)
+ ),
+ constant: Number.isFinite(constant) && constant >= 0 ? constant : 0,
+ unitPrices,
+ }
+ }),
+ }
+}
+
+function generateTaskTierBody(
+ tier: TaskVisualTier,
+ numberFields: [string, BillingUsageFieldSchema][]
+): string {
+ const parts: string[] = []
+ if (tier.constant > 0) parts.push(String(tier.constant))
+ for (const [field, definition] of numberFields) {
+ const price = tier.unitPrices[field] ?? 0
+ if (definition.unit === 'token') {
+ parts.push(
+ `u(${JSON.stringify(field)}) * ${price} / ${TASK_TOKEN_PRICE_SCALE}`
+ )
+ continue
+ }
+ parts.push(`u(${JSON.stringify(field)}) * ${price}`)
+ }
+ return parts.join(' + ')
+}
+
+function generateTaskTierCall(
+ tier: TaskVisualTier,
+ numberFields: [string, BillingUsageFieldSchema][]
+): string {
+ return `tier(${JSON.stringify(tier.label)}, ${generateTaskTierBody(tier, numberFields)})`
+}
+
+function generateTaskCondition(conditions: TaskVisualCondition[]): string {
+ return conditions
+ .map(
+ (condition) =>
+ `u(${JSON.stringify(condition.field)}) == ${JSON.stringify(condition.value)}`
+ )
+ .join(' && ')
+}
+
+export function generateTaskExprFromConfig(
+ config: TaskVisualConfig | null | undefined,
+ schema: BillingUsageSchema
+): string {
+ const numberFields = getTaskNumberFields(schema)
+ if (numberFields.length === 0) return ''
+ const normalized = normalizeTaskVisualConfig(config, schema)
+ if (normalized.tiers.length === 1) {
+ return generateTaskTierCall(normalized.tiers[0], numberFields)
+ }
+
+ const parts: string[] = []
+ for (let index = 0; index < normalized.tiers.length; index += 1) {
+ const tier = normalized.tiers[index]
+ const call = generateTaskTierCall(tier, numberFields)
+ if (index === normalized.tiers.length - 1) {
+ parts.push(call)
+ continue
+ }
+ const condition = generateTaskCondition(tier.conditions)
+ if (!condition) return ''
+ parts.push(`${condition} ? ${call}`)
+ }
+ return parts.join(' : ')
+}
+
+export function tryParseTaskVisualConfig(
+ expression: string | null | undefined,
+ schema: BillingUsageSchema
+): TaskVisualConfig | null {
+ if (!expression) return null
+ const tiers = parseTaskTiersFromExpr(expression, schema)
+ if (tiers.length === 0) return null
+ return normalizeTaskVisualConfig(
+ {
+ tiers: tiers.map((tier) => ({
+ label: tier.label,
+ conditions: tier.conditions,
+ constant: tier.constant,
+ unitPrices: tier.unitPrices,
+ })),
+ },
+ schema
+ )
+}
diff --git a/web/src/features/pricing/lib/task-matrix-display.ts b/web/src/features/pricing/lib/task-matrix-display.ts
new file mode 100644
index 000000000000..a68c51c34c6d
--- /dev/null
+++ b/web/src/features/pricing/lib/task-matrix-display.ts
@@ -0,0 +1,51 @@
+/*
+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 type { BillingUsageSchema } from '../types'
+import type { ParsedTaskTier } from './billing-expr'
+import {
+ getTaskEnumFields,
+ taskMatrixRowLabel,
+ tryParseTaskMatrixConfig,
+} from './task-expr'
+
+/**
+ * Marketplace display helper: expand a recognized task matrix (flat/uniform
+ * or a full enum partition) into one row per combination. Returns null when
+ * the schema has no enum fields or the expression is not a recognized matrix,
+ * so callers keep the raw parsed-tier display.
+ */
+export function getTaskMatrixDisplayTiers(
+ expression: string | null | undefined,
+ schema: BillingUsageSchema | null | undefined
+): ParsedTaskTier[] | null {
+ if (!schema) return null
+ if (getTaskEnumFields(schema).length === 0) return null
+
+ const matrix = tryParseTaskMatrixConfig(expression, schema)
+ if (!matrix) return null
+
+ return matrix.rows.map((row) => ({
+ label: taskMatrixRowLabel(row.combination),
+ conditions: Object.entries(row.combination)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([field, value]) => ({ field, value })),
+ constant: row.constant,
+ unitPrices: { ...row.unitPrices },
+ }))
+}
diff --git a/web/src/features/pricing/types.ts b/web/src/features/pricing/types.ts
index 8a0e244d5d09..f8ed61255a31 100644
--- a/web/src/features/pricing/types.ts
+++ b/web/src/features/pricing/types.ts
@@ -27,6 +27,22 @@ export type PricingVendor = {
description?: string
}
+export type BillingUsageUnit = 'second' | 'count' | 'token' | 'credit'
+
+export type BillingUsageFieldSchema = {
+ type?: 'number' | 'boolean'
+ unit?: BillingUsageUnit
+ enum?: string[]
+ description?: string | Record
+}
+
+export type BillingUsageSchema = Record
+
+export type BillingUsageExample = {
+ label: string
+ facts: Record
+}
+
export type PricingModel = {
id: number
model_name: string
@@ -54,6 +70,10 @@ export type PricingModel = {
billing_mode?: string
/** Raw expression describing dynamic / tiered billing */
billing_expr?: string
+ /** Task-plugin usage facts and their billing units. */
+ billing_usage_schema?: BillingUsageSchema
+ /** Display-only labeled usage vectors for pricing examples. */
+ billing_usage_examples?: BillingUsageExample[]
/** Pricing version returned by backend, useful for cache busting */
pricing_version?: string
/**
diff --git a/web/src/features/system-settings/__tests__/task-public-address.test.ts b/web/src/features/system-settings/__tests__/task-public-address.test.ts
new file mode 100644
index 000000000000..00ecc6d325e3
--- /dev/null
+++ b/web/src/features/system-settings/__tests__/task-public-address.test.ts
@@ -0,0 +1,54 @@
+/*
+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 { describe, test } from 'vitest'
+
+import { isValidTaskPublicAddress } from '../general/task-public-address'
+
+describe('async task public address', () => {
+ test('allows an empty fallback or an absolute HTTP(S) media base URL', () => {
+ for (const value of [
+ '',
+ 'https://media.example.com',
+ 'https://media.example.com/task-content',
+ 'http://127.0.0.1:8080/nginx/tasks',
+ 'http://localhost:3000/media',
+ ]) {
+ assert.equal(isValidTaskPublicAddress(value), true, value)
+ }
+ })
+
+ test('rejects credentials, query parameters, fragments, and non-HTTP URLs', () => {
+ for (const value of [
+ 'media.example.com/tasks',
+ '/media/tasks',
+ 'ftp://media.example.com/tasks',
+ 'https://user:secret@media.example.com/tasks',
+ 'https://@media.example.com/tasks',
+ 'https://media.example.com/tasks?token=secret',
+ 'https://media.example.com/tasks#preview',
+ ' https://media.example.com/tasks',
+ 'https://media.example.com/tasks\n',
+ 'https:\\\\media.example.com\\tasks',
+ 'https://',
+ ]) {
+ assert.equal(isValidTaskPublicAddress(value), false, value)
+ }
+ })
+})
diff --git a/web/src/features/system-settings/general/system-info-section.tsx b/web/src/features/system-settings/general/system-info-section.tsx
index ad54e92a08e5..3758b369f558 100644
--- a/web/src/features/system-settings/general/system-info-section.tsx
+++ b/web/src/features/system-settings/general/system-info-section.tsx
@@ -44,10 +44,12 @@ import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useSettingsForm } from '../hooks/use-settings-form'
import { useUpdateOption } from '../hooks/use-update-option'
+import { isValidTaskPublicAddress } from './task-public-address'
const _systemInfoSchema = z.object({
SystemName: z.string().min(1),
ServerAddress: z.string().optional(),
+ TaskPublicAddress: z.string().refine(isValidTaskPublicAddress),
Logo: z.string().url().optional().or(z.literal('')),
Footer: z.string().optional(),
About: z.string().optional(),
@@ -76,6 +78,7 @@ export function SystemInfoSection({ defaultValues }: SystemInfoSectionProps) {
const normalizedDefaults: SystemInfoFormValues = {
SystemName: normalizeValue(defaultValues.SystemName),
ServerAddress: normalizeValue(defaultValues.ServerAddress),
+ TaskPublicAddress: normalizeValue(defaultValues.TaskPublicAddress),
Logo: normalizeValue(defaultValues.Logo),
Footer: normalizeValue(defaultValues.Footer),
About: normalizeValue(defaultValues.About),
@@ -91,6 +94,12 @@ export function SystemInfoSection({ defaultValues }: SystemInfoSectionProps) {
error: () => t('System name is required'),
}),
ServerAddress: z.string().optional(),
+ TaskPublicAddress: z.string().refine(isValidTaskPublicAddress, {
+ error: () =>
+ t(
+ 'Enter an absolute HTTP(S) URL without credentials, query parameters, or fragments'
+ ),
+ }),
Logo: z.string().url().optional().or(z.literal('')),
Footer: z.string().optional(),
About: z.string().optional(),
@@ -112,7 +121,7 @@ export function SystemInfoSection({ defaultValues }: SystemInfoSectionProps) {
onSubmit: async (_data, changedFields) => {
for (const [key, value] of Object.entries(changedFields)) {
let v = normalizeValue(value)
- if (key === 'ServerAddress') {
+ if (key === 'ServerAddress' || key === 'TaskPublicAddress') {
v = v.replace(/\/+$/, '')
}
await updateOption.mutateAsync({
@@ -174,6 +183,28 @@ export function SystemInfoSection({ defaultValues }: SystemInfoSectionProps) {
)}
/>
+ (
+
+ {t('Async Task Public Address')}
+
+
+
+
+ {t(
+ 'Public base URL for async task media. Supports a dedicated media domain, port, or Nginx path prefix; falls back to Server Address when empty.'
+ )}
+
+
+
+ )}
+ />
+
.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+export function isValidTaskPublicAddress(value: string): boolean {
+ if (value === '') return true
+ if (
+ value !== value.trim() ||
+ !/^https?:\/\//i.test(value) ||
+ value.includes('?') ||
+ value.includes('#')
+ ) {
+ return false
+ }
+ for (const character of value) {
+ const codePoint = character.codePointAt(0) ?? 0
+ if (codePoint <= 0x1f || codePoint === 0x7f || character === '\\') {
+ return false
+ }
+ }
+
+ try {
+ const url = new URL(value)
+ const authorityStart = value.indexOf('//') + 2
+ const pathStart = value.indexOf('/', authorityStart)
+ const authority = value.slice(
+ authorityStart,
+ pathStart === -1 ? value.length : pathStart
+ )
+ return (
+ (url.protocol === 'https:' || url.protocol === 'http:') &&
+ url.hostname.length > 0 &&
+ !authority.includes('@') &&
+ url.username === '' &&
+ url.password === '' &&
+ url.search === '' &&
+ url.hash === ''
+ )
+ } catch {
+ return false
+ }
+}
diff --git a/web/src/features/system-settings/models/model-pricing-sheet.tsx b/web/src/features/system-settings/models/model-pricing-sheet.tsx
index 4c0178f9a452..18e83f5be003 100644
--- a/web/src/features/system-settings/models/model-pricing-sheet.tsx
+++ b/web/src/features/system-settings/models/model-pricing-sheet.tsx
@@ -24,6 +24,7 @@ import {
useEffect,
useImperativeHandle,
useMemo,
+ useRef,
useState,
} from 'react'
import { useForm } from 'react-hook-form'
@@ -61,6 +62,11 @@ import {
SheetTitle,
} from '@/components/ui/sheet'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { usePricingData } from '@/features/pricing/hooks/use-pricing-data'
+import {
+ createDefaultTaskVisualConfig,
+ generateTaskExprFromConfig,
+} from '@/features/pricing/lib/task-expr'
import { cn } from '@/lib/utils'
import {
@@ -81,6 +87,7 @@ import {
} from './model-pricing-core'
import { PriceInput, PriceLane } from './model-pricing-inputs'
import { formatPricingNumber } from './pricing-format'
+import { TaskUsagePricingEditor } from './task-usage-pricing-editor'
import { TieredPricingEditor } from './tiered-pricing-editor'
export type { ModelRatioData } from './model-pricing-core'
@@ -104,6 +111,8 @@ export type ModelPricingEditorPanelHandle = {
commitDraft: () => Promise
}
+const DEFAULT_TOKEN_BILLING_EXPR = 'tier("base", p * 0 + c * 0)'
+
export const ModelPricingSheet = forwardRef<
ModelPricingEditorPanelHandle,
ModelPricingSheetProps
@@ -156,7 +165,9 @@ export const ModelPricingEditorPanel = forwardRef<
const [billingExpr, setBillingExpr] = useState('')
const [requestRuleExpr, setRequestRuleExpr] = useState('')
const [editorReloadToken, setEditorReloadToken] = useState(0)
+ const autoSwitchedForRef = useRef(null)
const isEditMode = !!editData
+ const { models: pricingModels } = usePricingData()
const form = useForm({
resolver: zodResolver(createModelPricingSchema(t)),
@@ -172,6 +183,44 @@ export const ModelPricingEditorPanel = forwardRef<
audioCompletionRatio: '',
},
})
+ const watchedValues = form.watch()
+ const usageSchemaByModel = useMemo(
+ () =>
+ new Map(
+ pricingModels.map((model) => [
+ model.model_name,
+ model.billing_usage_schema,
+ ])
+ ),
+ [pricingModels]
+ )
+ const usageExamplesByModel = useMemo(
+ () =>
+ new Map(
+ pricingModels.map((model) => [
+ model.model_name,
+ model.billing_usage_examples,
+ ])
+ ),
+ [pricingModels]
+ )
+ const taskUsageSchema = usageSchemaByModel.get(watchedValues.name.trim())
+ const taskUsageExamples = usageExamplesByModel.get(watchedValues.name.trim())
+ const defaultTaskBillingExpr = useMemo(
+ () =>
+ taskUsageSchema
+ ? generateTaskExprFromConfig(
+ createDefaultTaskVisualConfig(taskUsageSchema),
+ taskUsageSchema
+ )
+ : '',
+ [taskUsageSchema]
+ )
+ const resolvedBillingExpr =
+ taskUsageSchema &&
+ (!billingExpr || billingExpr === DEFAULT_TOKEN_BILLING_EXPR)
+ ? defaultTaskBillingExpr
+ : billingExpr
useEffect(() => {
const nextLaneState = createInitialLaneState(editData)
@@ -188,13 +237,13 @@ export const ModelPricingEditorPanel = forwardRef<
audioRatio: editData.audioRatio || '',
audioCompletionRatio: editData.audioCompletionRatio || '',
})
- setPricingMode(
- editData.billingMode === 'tiered_expr'
- ? 'tiered_expr'
- : editData.price
- ? 'per-request'
- : 'per-token'
- )
+ let nextPricingMode: PricingMode = 'per-token'
+ if (editData.billingMode === 'tiered_expr') {
+ nextPricingMode = 'tiered_expr'
+ } else if (editData.price) {
+ nextPricingMode = 'per-request'
+ }
+ setPricingMode(nextPricingMode)
setBillingExpr(editData.billingExpr || '')
setRequestRuleExpr(editData.requestRuleExpr || '')
} else {
@@ -218,8 +267,22 @@ export const ModelPricingEditorPanel = forwardRef<
setLanePrices(nextLaneState.prices)
setLaneEnabled(nextLaneState.enabled)
setEditorReloadToken((token) => token + 1)
+ autoSwitchedForRef.current = null
}, [editData, form])
+ useEffect(() => {
+ if (!editData) return
+ if (editData.billingMode === 'tiered_expr') return
+ if (editData.price || editData.ratio) return
+
+ const usageSchema = usageSchemaByModel.get(editData.name)
+ if (!usageSchema || Object.keys(usageSchema).length === 0) return
+ if (autoSwitchedForRef.current === editData.name) return
+
+ setPricingMode('tiered_expr')
+ autoSwitchedForRef.current = editData.name
+ }, [editData, usageSchemaByModel])
+
const setFormValue = (field: keyof ModelPricingFormValues, value: string) => {
form.setValue(field, value, {
shouldDirty: true,
@@ -336,17 +399,16 @@ export const ModelPricingEditorPanel = forwardRef<
const nextMode = value as PricingMode
setPricingMode(nextMode)
if (nextMode === 'tiered_expr' && !billingExpr) {
- setBillingExpr('tier("base", p * 0 + c * 0)')
+ setBillingExpr(defaultTaskBillingExpr || DEFAULT_TOKEN_BILLING_EXPR)
}
}
- const watchedValues = form.watch()
const previewRows = useMemo(
() =>
buildPreviewRows(
watchedValues,
pricingMode,
- billingExpr,
+ resolvedBillingExpr,
requestRuleExpr,
promptPrice,
lanePrices,
@@ -354,7 +416,7 @@ export const ModelPricingEditorPanel = forwardRef<
t
),
[
- billingExpr,
+ resolvedBillingExpr,
laneEnabled,
lanePrices,
pricingMode,
@@ -454,13 +516,13 @@ export const ModelPricingEditorPanel = forwardRef<
}
if (pricingMode === 'tiered_expr') {
- data.billingExpr = billingExpr
+ data.billingExpr = resolvedBillingExpr
data.requestRuleExpr = requestRuleExpr
}
return data
},
- [billingExpr, pricingMode, requestRuleExpr]
+ [pricingMode, requestRuleExpr, resolvedBillingExpr]
)
useImperativeHandle(
@@ -557,6 +619,32 @@ export const ModelPricingEditorPanel = forwardRef<
+ {taskUsageSchema &&
+ Object.keys(taskUsageSchema).length > 0 && (
+
+
+
+ {t(
+ 'This is a task model billed by usage (e.g. seconds, resolution). Prices entered here act as a per-call base rate, not per-token prices.'
+ )}
+
+
+ {t(
+ 'Tip: after configuring one model, select others in the table and use bulk copy.'
+ )}
+
+ handleModeChange('tiered_expr')}
+ >
+ {t('Configure task pricing')}
+
+
+
+ )}
{t('Input price')}
@@ -641,14 +729,26 @@ export const ModelPricingEditorPanel = forwardRef<
-
+ {taskUsageSchema ? (
+
+ ) : (
+
+ )}
diff --git a/web/src/features/system-settings/models/model-ratio-table-columns.tsx b/web/src/features/system-settings/models/model-ratio-table-columns.tsx
index 5a47b855cdf8..da200d9bcb71 100644
--- a/web/src/features/system-settings/models/model-ratio-table-columns.tsx
+++ b/web/src/features/system-settings/models/model-ratio-table-columns.tsx
@@ -31,6 +31,8 @@ import {
type ModelRow,
} from './model-pricing-snapshots'
+export const TASK_PRICING_MODE_FILTER = 'tiered_expr_task'
+
const filterBySelectedValues = (
rowValue: unknown,
filterValue: unknown
@@ -43,6 +45,7 @@ type BuildModelRatioColumnsOptions = {
onDelete: (name: string) => void
onEdit: (model: ModelRow) => void
deleteDisabled?: boolean
+ taskModelNames?: Set
t: (key: string) => string
}
@@ -50,6 +53,7 @@ export function buildModelRatioColumns({
onDelete,
onEdit,
deleteDisabled,
+ taskModelNames,
t,
}: BuildModelRatioColumnsOptions): ColumnDef[] {
return [
@@ -81,27 +85,54 @@ export function buildModelRatioColumns({
header: ({ column }) => (
),
- cell: ({ row }) => (
-
- {row.getValue('name')}
- {row.original.billingMode === 'tiered_expr' && (
-
- )}
- {row.original.hasConflict && (
-
- )}
-
- ),
+ cell: ({ row }) => {
+ const isTaskModel = Boolean(taskModelNames?.has(row.original.name))
+ const hasConfiguredTaskPricing =
+ row.original.billingMode === 'tiered_expr' &&
+ Boolean(row.original.billingExpr)
+ const showTaskPricingBadge = isTaskModel && hasConfiguredTaskPricing
+ const showTieredBadge =
+ row.original.billingMode === 'tiered_expr' && !isTaskModel
+ const showUnconfiguredTaskBadge = isTaskModel && !hasConfiguredTaskPricing
+
+ return (
+
+ {row.getValue('name')}
+ {showTieredBadge ? (
+
+ ) : null}
+ {showTaskPricingBadge ? (
+
+ ) : null}
+ {row.original.hasConflict && (
+
+ )}
+ {showUnconfiguredTaskBadge ? (
+
+ ) : null}
+
+ )
+ },
enableHiding: false,
},
{
@@ -118,8 +149,17 @@ export function buildModelRatioColumns({
className='-ml-1.5 px-0'
/>
),
- filterFn: (row, id, value) =>
- filterBySelectedValues(row.getValue(id), value),
+ filterFn: (row, id, value) => {
+ if (filterBySelectedValues(row.getValue(id), value)) return true
+ if (!Array.isArray(value) || !value.includes(TASK_PRICING_MODE_FILTER)) {
+ return false
+ }
+ return (
+ Boolean(taskModelNames?.has(row.original.name)) &&
+ row.original.billingMode === 'tiered_expr' &&
+ Boolean(row.original.billingExpr)
+ )
+ },
meta: { label: t('Mode') },
},
{
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 5013455c9bba..d77457cdfa2d 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
@@ -47,6 +47,7 @@ import {
useDataTable,
} from '@/components/data-table'
import { Button } from '@/components/ui/button'
+import { usePricingData } from '@/features/pricing/hooks/use-pricing-data'
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
import { useMediaQuery } from '@/hooks'
@@ -64,7 +65,10 @@ import {
isBasePricingUnset,
type ModelRow,
} from './model-pricing-snapshots'
-import { buildModelRatioColumns } from './model-ratio-table-columns'
+import {
+ buildModelRatioColumns,
+ TASK_PRICING_MODE_FILTER,
+} from './model-ratio-table-columns'
type ModelRatioVisualEditorProps = {
savedModelPrice: string
@@ -136,6 +140,7 @@ const ModelRatioVisualEditorComponent = forwardRef<
ref
) {
const { t } = useTranslation()
+ const { models: pricingModels } = usePricingData()
const isMobile = useMediaQuery('(max-width: 767px)')
const [sheetOpen, setSheetOpen] = useState(false)
const [editorOpen, setEditorOpen] = useState(false)
@@ -188,6 +193,20 @@ const ModelRatioVisualEditorComponent = forwardRef<
localStorage.setItem(STORAGE_KEY, JSON.stringify(columnVisibility))
}, [columnVisibility])
+ const taskModelNames = useMemo(
+ () =>
+ new Set(
+ pricingModels
+ .filter(
+ (model) =>
+ model.billing_usage_schema &&
+ Object.keys(model.billing_usage_schema).length > 0
+ )
+ .map((model) => model.model_name)
+ ),
+ [pricingModels]
+ )
+
const models = useMemo(() => {
const savedRows = buildModelSnapshots({
modelPrice: savedModelPrice,
@@ -267,26 +286,30 @@ const ModelRatioVisualEditorComponent = forwardRef<
billingExpr,
])
- const modeCounts = useMemo(
- () =>
- models.reduce(
- (acc, model) => {
- const mode =
- model.billingMode === 'per-request' ||
- model.billingMode === 'tiered_expr'
- ? model.billingMode
- : 'per-token'
- acc[mode] += 1
- return acc
- },
- {
- 'per-token': 0,
- 'per-request': 0,
- tiered_expr: 0,
- } as Record<'per-token' | 'per-request' | 'tiered_expr', number>
- ),
- [models]
- )
+ const modeCounts = useMemo(() => {
+ const counts = {
+ 'per-token': 0,
+ 'per-request': 0,
+ tiered_expr: 0,
+ [TASK_PRICING_MODE_FILTER]: 0,
+ }
+ for (const model of models) {
+ const mode =
+ model.billingMode === 'per-request' ||
+ model.billingMode === 'tiered_expr'
+ ? model.billingMode
+ : 'per-token'
+ counts[mode] += 1
+ if (
+ taskModelNames.has(model.name) &&
+ model.billingMode === 'tiered_expr' &&
+ Boolean(model.billingExpr)
+ ) {
+ counts[TASK_PRICING_MODE_FILTER] += 1
+ }
+ }
+ return counts
+ }, [models, taskModelNames])
const handleEdit = useCallback(
(model: ModelRow) => {
@@ -440,9 +463,10 @@ const ModelRatioVisualEditorComponent = forwardRef<
onDelete: handleDelete,
onEdit: handleEdit,
deleteDisabled: filterMode === 'unset',
+ taskModelNames,
t,
}),
- [handleEdit, handleDelete, filterMode, t]
+ [handleEdit, handleDelete, filterMode, t, taskModelNames]
)
const ensurePageInRange = useCallback((pageCount: number) => {
@@ -703,6 +727,11 @@ const ModelRatioVisualEditorComponent = forwardRef<
value: 'tiered_expr',
count: modeCounts.tiered_expr,
},
+ {
+ label: 'Expression - Task pricing',
+ value: TASK_PRICING_MODE_FILTER,
+ count: modeCounts[TASK_PRICING_MODE_FILTER],
+ },
],
},
]}
diff --git a/web/src/features/system-settings/models/task-pricing-matrix.tsx b/web/src/features/system-settings/models/task-pricing-matrix.tsx
new file mode 100644
index 000000000000..60510667fe66
--- /dev/null
+++ b/web/src/features/system-settings/models/task-pricing-matrix.tsx
@@ -0,0 +1,511 @@
+/*
+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 { AlertTriangle, ChevronDown, PaintBucket } from 'lucide-react'
+import { useRef, useState, type KeyboardEvent } from 'react'
+import { useTranslation } from 'react-i18next'
+
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Button } from '@/components/ui/button'
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from '@/components/ui/collapsible'
+import { Field, FieldLabel } from '@/components/ui/field'
+import { Input } from '@/components/ui/input'
+import {
+ Popover,
+ PopoverContent,
+ PopoverHeader,
+ PopoverTitle,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@/components/ui/tooltip'
+import { getTaskUsagePriceUnitLabelKey } from '@/features/pricing/lib/dynamic-price'
+import {
+ getTaskEnumFields,
+ getTaskNumberFields,
+ taskMatrixRowLabel,
+ type TaskMatrixRow,
+} from '@/features/pricing/lib/task-expr'
+import type {
+ BillingUsageFieldSchema,
+ BillingUsageSchema,
+} from '@/features/pricing/types'
+import { cn } from '@/lib/utils'
+
+const TASK_MATRIX_GROUP_THRESHOLD = 24
+
+type TaskPricingMatrixProps = {
+ rows: TaskMatrixRow[]
+ usageSchema: BillingUsageSchema
+ matchedRowIndex: number | null
+ onRowChange: (index: number, next: TaskMatrixRow) => void
+ onFillColumn: (priceKey: string, value: number) => void
+}
+
+type IndexedTaskMatrixRow = {
+ index: number
+ row: TaskMatrixRow
+}
+
+type FillColumnPopoverProps = {
+ priceKey: string
+ initialValue: number
+ onFillColumn: (priceKey: string, value: number) => void
+}
+
+function FillColumnPopover(props: FillColumnPopoverProps) {
+ const { t } = useTranslation()
+ const [open, setOpen] = useState(false)
+ const [value, setValue] = useState(props.initialValue)
+
+ const handleOpenChange = (nextOpen: boolean) => {
+ if (nextOpen) setValue(props.initialValue)
+ setOpen(nextOpen)
+ }
+
+ const handleSubmit = () => {
+ const nextValue = Number(value)
+ props.onFillColumn(
+ props.priceKey,
+ Number.isFinite(nextValue) && nextValue >= 0 ? nextValue : 0
+ )
+ setOpen(false)
+ }
+
+ return (
+
+
+ }
+ >
+
+
+
+
+ {t('Fill entire column')}
+
+
+ {t('Fill entire column')}
+ {
+ if (Number(event.currentTarget.value) === 0) {
+ event.currentTarget.select()
+ }
+ }}
+ onChange={(event) => setValue(Number(event.target.value))}
+ onKeyDown={(event) => {
+ if (event.key !== 'Enter') return
+ event.preventDefault()
+ handleSubmit()
+ }}
+ className='font-mono'
+ />
+
+ {t('Apply to all rows')}
+
+
+
+
+ )
+}
+
+type TaskMatrixTableProps = {
+ entries: IndexedTaskMatrixRow[]
+ enumFields: [string, BillingUsageFieldSchema][]
+ numberFields: [string, BillingUsageFieldSchema][]
+ hiddenEnumField?: string
+ firstRow: TaskMatrixRow
+ allRowsFree: boolean
+ matchedRowIndex: number | null
+ onRowChange: (index: number, next: TaskMatrixRow) => void
+ onFillColumn: (priceKey: string, value: number) => void
+ onPriceKeyDown: (
+ event: KeyboardEvent,
+ rowIndex: number,
+ priceKey: string
+ ) => void
+}
+
+function TaskMatrixTable(props: TaskMatrixTableProps) {
+ const { t } = useTranslation()
+ const visibleEnumFields = props.enumFields.filter(
+ ([field]) => field !== props.hiddenEnumField
+ )
+
+ return (
+
+
+
+ {visibleEnumFields.map(([field]) => (
+
+ {field}
+
+ ))}
+ {props.numberFields.map(([field, definition]) => (
+
+
+
+ {field}
+
+ $/{t(getTaskUsagePriceUnitLabelKey(definition.unit))}
+
+
+
+
+
+ ))}
+
+
+
+ {t('Base charge')}
+
+ $/{t('request')}
+
+
+
+
+
+
+ {t('Status')}
+
+
+
+
+ {props.entries.map((entry) => {
+ const isFree =
+ entry.row.constant === 0 &&
+ props.numberFields.every(
+ ([field]) => !(entry.row.unitPrices[field] > 0)
+ )
+ const rowLabel = taskMatrixRowLabel(entry.row.combination)
+ return (
+
+ {visibleEnumFields.map(([field]) => (
+
+ {entry.row.combination[field]}
+
+ ))}
+ {props.numberFields.map(([field]) => (
+
+ {
+ if (Number(event.currentTarget.value) === 0) {
+ event.currentTarget.select()
+ }
+ }}
+ onChange={(event) => {
+ const value = Number(event.target.value)
+ props.onRowChange(entry.index, {
+ ...entry.row,
+ unitPrices: {
+ ...entry.row.unitPrices,
+ [field]:
+ Number.isFinite(value) && value >= 0 ? value : 0,
+ },
+ })
+ }}
+ onKeyDown={(event) =>
+ props.onPriceKeyDown(event, entry.index, field)
+ }
+ className='min-w-28 font-mono'
+ />
+
+ ))}
+
+ {
+ if (Number(event.currentTarget.value) === 0) {
+ event.currentTarget.select()
+ }
+ }}
+ onChange={(event) => {
+ const value = Number(event.target.value)
+ props.onRowChange(entry.index, {
+ ...entry.row,
+ constant:
+ Number.isFinite(value) && value >= 0 ? value : 0,
+ })
+ }}
+ onKeyDown={(event) =>
+ props.onPriceKeyDown(event, entry.index, 'constant')
+ }
+ className='min-w-28 font-mono'
+ />
+
+
+ {!props.allRowsFree && isFree ? (
+
+
+ }
+ >
+
+
+ {t('This combination will be billed as free.')}
+
+
+
+ {t('This combination will be billed as free.')}
+
+
+ ) : null}
+
+
+ )
+ })}
+
+
+ )
+}
+
+type TaskMatrixGroupProps = Omit & {
+ groupField: string
+ groupValue: string
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+function TaskMatrixGroup(props: TaskMatrixGroupProps) {
+ const { t } = useTranslation()
+ const freeCount = props.entries.filter(
+ (entry) =>
+ entry.row.constant === 0 &&
+ props.numberFields.every(([field]) => !(entry.row.unitPrices[field] > 0))
+ ).length
+ const containsMatchedRow = props.entries.some(
+ (entry) => entry.index === props.matchedRowIndex
+ )
+
+ return (
+
+
+ }
+ >
+
+ {props.groupValue}
+
+ {t('{{count}} combinations', { count: props.entries.length })}
+
+ {!props.allRowsFree && freeCount > 0 ? (
+
+
+ {freeCount}
+
+ {t('This combination will be billed as free.')}
+
+
+ ) : null}
+ {!props.open && containsMatchedRow ? (
+
+ {t('Hit tier')}
+
+ ) : null}
+
+
+
+
+
+
+
+ )
+}
+
+export function TaskPricingMatrix(props: TaskPricingMatrixProps) {
+ const { t } = useTranslation()
+ const containerRef = useRef(null)
+ const enumFields = getTaskEnumFields(props.usageSchema)
+ const numberFields = getTaskNumberFields(props.usageSchema)
+ const entries = props.rows.map((row, index) => ({ row, index }))
+ const firstRow = props.rows[0]
+ const allRowsFree = props.rows.every(
+ (row) =>
+ row.constant === 0 &&
+ numberFields.every(([field]) => !(row.unitPrices[field] > 0))
+ )
+ const firstEnumField = enumFields[0]
+ const shouldGroup =
+ props.rows.length > TASK_MATRIX_GROUP_THRESHOLD && Boolean(firstEnumField)
+ const [openGroups, setOpenGroups] = useState(() => {
+ const firstGroupValue = firstEnumField?.[1].enum?.[0]
+ return firstGroupValue ? [firstGroupValue] : []
+ })
+
+ const handlePriceKeyDown = (
+ event: KeyboardEvent,
+ rowIndex: number,
+ priceKey: string
+ ) => {
+ if (event.key !== 'Enter' || rowIndex >= props.rows.length - 1) return
+ event.preventDefault()
+ const selector = `input[data-matrix-col="${CSS.escape(priceKey)}"][data-matrix-row="${rowIndex + 1}"]`
+ const nextInput =
+ containerRef.current?.querySelector(selector)
+ if (nextInput) {
+ nextInput.focus()
+ return
+ }
+
+ const nextGroupValue = firstEnumField
+ ? props.rows[rowIndex + 1]?.combination[firstEnumField[0]]
+ : undefined
+ if (!shouldGroup || !nextGroupValue) return
+ setOpenGroups((current) =>
+ current.includes(nextGroupValue) ? current : [...current, nextGroupValue]
+ )
+ window.requestAnimationFrame(() => {
+ containerRef.current?.querySelector(selector)?.focus()
+ })
+ }
+
+ if (!firstRow) return null
+
+ return (
+
+
+ {allRowsFree ? (
+
+
+
+ {t(
+ 'All combinations are priced at zero. Matching requests will be billed as free.'
+ )}
+
+
+ ) : null}
+ {shouldGroup && firstEnumField ? (
+
+ {(firstEnumField[1].enum ?? []).map((groupValue) => (
+
+ entry.row.combination[firstEnumField[0]] === groupValue
+ )}
+ enumFields={enumFields}
+ numberFields={numberFields}
+ groupField={firstEnumField[0]}
+ groupValue={groupValue}
+ open={openGroups.includes(groupValue)}
+ onOpenChange={(nextOpen) =>
+ setOpenGroups((current) => {
+ if (nextOpen) {
+ return current.includes(groupValue)
+ ? current
+ : [...current, groupValue]
+ }
+ return current.filter((value) => value !== groupValue)
+ })
+ }
+ firstRow={firstRow}
+ allRowsFree={allRowsFree}
+ matchedRowIndex={props.matchedRowIndex}
+ onRowChange={props.onRowChange}
+ onFillColumn={props.onFillColumn}
+ onPriceKeyDown={handlePriceKeyDown}
+ />
+ ))}
+
+ ) : (
+
+ )}
+
+
+ )
+}
diff --git a/web/src/features/system-settings/models/task-usage-pricing-editor.tsx b/web/src/features/system-settings/models/task-usage-pricing-editor.tsx
new file mode 100644
index 000000000000..0aeb074c1853
--- /dev/null
+++ b/web/src/features/system-settings/models/task-usage-pricing-editor.tsx
@@ -0,0 +1,631 @@
+/*
+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 { AlertTriangle } from 'lucide-react'
+import { memo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Badge } from '@/components/ui/badge'
+import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Textarea } from '@/components/ui/textarea'
+import {
+ combineBillingExpr,
+ splitBillingExprAndRequestRules,
+} from '@/features/pricing/lib/billing-expr'
+import {
+ getTaskUsagePriceUnitLabelKey,
+ getTaskUsageQuantityUnitLabelKey,
+} from '@/features/pricing/lib/dynamic-price'
+import {
+ createDefaultTaskMatrixConfig,
+ evaluateTaskVisualConfig,
+ generateTaskExprFromConfig,
+ getTaskEnumCombinations,
+ getTaskEnumFields,
+ getTaskNumberFields,
+ taskMatrixRowLabel,
+ taskMatrixToTiers,
+ tryParseTaskMatrixConfig,
+ tryParseTaskVisualConfig,
+ type TaskMatrixRow,
+ type TaskVisualConfig,
+} from '@/features/pricing/lib/task-expr'
+import type {
+ BillingUsageExample,
+ BillingUsageSchema,
+} from '@/features/pricing/types'
+import { resolveLocalizedText } from '@/lib/localized-text'
+
+import { formatPricingNumber } from './pricing-format'
+import { TaskPricingMatrix } from './task-pricing-matrix'
+
+type TaskUsagePricingEditorProps = {
+ billingExpr: string
+ requestRuleExpr: string
+ usageSchema: BillingUsageSchema
+ usageExamples?: BillingUsageExample[]
+ onBillingExprChange: (next: string) => void
+ onRequestRuleExprChange: (next: string) => void
+}
+
+type EditorMode = 'visual' | 'raw'
+
+type TaskBillingPreviewProps = {
+ config: TaskVisualConfig | null
+ matchedRowLabel: string | null
+ requestRuleExpr: string
+ sample: Record
+ usageSchema: BillingUsageSchema
+ usageExamples?: BillingUsageExample[]
+ onSampleChange: (field: string, value: number | string) => void
+ onSampleReplace: (sample: Record) => void
+}
+
+function TaskBillingPreview(props: TaskBillingPreviewProps) {
+ const { t } = useTranslation()
+ const enumFields = getTaskEnumFields(props.usageSchema)
+ const numberFields = getTaskNumberFields(props.usageSchema)
+ const result = props.config
+ ? evaluateTaskVisualConfig(
+ props.config,
+ props.sample,
+ props.usageSchema
+ )
+ : null
+
+ if (!result) {
+ return (
+
+
+ {t('Preview is unavailable for custom expressions.')}
+
+
+ )
+ }
+
+ const formulaParts = result.parts.map((part) => {
+ if (part.kind === 'constant') {
+ return `$${formatPricingNumber(part.amount)}`
+ }
+
+ const definition = props.usageSchema[part.field ?? '']
+ const quantityUnitKey = getTaskUsageQuantityUnitLabelKey(definition?.unit)
+ const priceUnitKey = getTaskUsagePriceUnitLabelKey(definition?.unit)
+ const quantityUnitLabel = t(quantityUnitKey)
+ const quantityLabel =
+ definition?.unit === 'second'
+ ? `${formatPricingNumber(part.quantity)}${quantityUnitLabel}`
+ : `${formatPricingNumber(part.quantity)} ${quantityUnitLabel}`
+ return `${quantityLabel} × $${formatPricingNumber(part.unitPrice)}/${t(priceUnitKey)}`
+ })
+ const formulaLeft = formulaParts.length > 0 ? formulaParts.join(' + ') : '$0'
+ const formula = `${formulaLeft} = $${formatPricingNumber(result.total)}`
+
+ return (
+
+
+
{t('Preview')}
+
+ {t('Preview excludes group ratios and request rule multipliers.')}
+ {props.requestRuleExpr ? (
+ <> {t('Request rules apply on top of this amount.')}>
+ ) : null}
+
+
+ {props.usageExamples && props.usageExamples.length > 0 ? (
+
+ {t('Example spec')}
+ ({
+ value: example.label,
+ label: example.label,
+ }))}
+ value={
+ props.usageExamples.find((example) =>
+ Object.entries(example.facts).every(
+ ([field, value]) => props.sample[field] === value
+ )
+ )?.label ?? null
+ }
+ onValueChange={(label) => {
+ const example = props.usageExamples?.find(
+ (item) => item.label === label
+ )
+ if (example) props.onSampleReplace({ ...example.facts })
+ }}
+ >
+
+
+
+
+
+ {props.usageExamples.map((example) => (
+
+ {example.label}
+
+ ))}
+
+
+
+
+ ) : null}
+ {enumFields.length + numberFields.length > 0 ? (
+
+ {enumFields.map(([field, definition]) => {
+ const items = (definition.enum ?? []).map((value) => ({
+ value,
+ label: value,
+ }))
+ return (
+
+
+ {field}
+
+
+ value !== null && props.onSampleChange(field, value)
+ }
+ >
+
+
+
+
+
+ {items.map((item) => (
+
+ {item.label}
+
+ ))}
+
+
+
+
+ )
+ })}
+ {numberFields.map(([field, definition]) => (
+
+
+ {field}
+
+
+ {
+ const value = Number(event.target.value)
+ props.onSampleChange(
+ field,
+ Number.isFinite(value) && value >= 0 ? value : 0
+ )
+ }}
+ className='font-mono'
+ />
+
+ {t(getTaskUsageQuantityUnitLabelKey(definition.unit))}
+
+
+
+ ))}
+
+ ) : null}
+
+
+ {t('Hit tier')}: {props.matchedRowLabel ?? result.tier.label}
+
+ {formula}
+
+
+ )
+}
+
+export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
+ props: TaskUsagePricingEditorProps
+) {
+ const { t, i18n } = useTranslation()
+ const [editorMode, setEditorMode] = useState(() =>
+ props.billingExpr &&
+ !tryParseTaskMatrixConfig(props.billingExpr, props.usageSchema)
+ ? 'raw'
+ : 'visual'
+ )
+ const [matrixRows, setMatrixRows] = useState(() => {
+ const parsed = tryParseTaskMatrixConfig(
+ props.billingExpr,
+ props.usageSchema
+ )
+ return (parsed ?? createDefaultTaskMatrixConfig(props.usageSchema)).rows
+ })
+ const [rawExpr, setRawExpr] = useState(() =>
+ combineBillingExpr(props.billingExpr, props.requestRuleExpr)
+ )
+ const [previewSample, setPreviewSample] = useState<
+ Record
+ >(() => {
+ if (props.usageExamples?.[0]) {
+ return { ...props.usageExamples[0].facts }
+ }
+ const sample: Record = {}
+ for (const [field, definition] of getTaskEnumFields(props.usageSchema)) {
+ sample[field] = definition.enum?.[0] ?? ''
+ }
+ for (const [field, definition] of getTaskNumberFields(props.usageSchema)) {
+ sample[field] = definition.unit === 'second' ? 5 : 1
+ }
+ return sample
+ })
+ const enumFields = getTaskEnumFields(props.usageSchema)
+ const numberFields = getTaskNumberFields(props.usageSchema)
+ const combinations = getTaskEnumCombinations(props.usageSchema)
+ const visualTiers = taskMatrixToTiers({ rows: matrixRows }, props.usageSchema)
+
+ let previewConfig: TaskVisualConfig | null = null
+ let previewRequestRuleExpr = props.requestRuleExpr
+ let matchedRowIndex: number | null = null
+ let matchedRowLabel: string | null = null
+ if (editorMode === 'visual') {
+ const generatedExpression = generateTaskExprFromConfig(
+ { tiers: visualTiers },
+ props.usageSchema
+ )
+ if (generatedExpression) previewConfig = { tiers: visualTiers }
+ const nextMatchedRowIndex = combinations.findIndex((combination) =>
+ Object.entries(combination).every(
+ ([field, value]) => previewSample[field] === value
+ )
+ )
+ if (nextMatchedRowIndex >= 0) {
+ matchedRowIndex = nextMatchedRowIndex
+ matchedRowLabel = taskMatrixRowLabel(combinations[nextMatchedRowIndex])
+ }
+ } else {
+ const split = splitBillingExprAndRequestRules(rawExpr)
+ previewConfig = tryParseTaskVisualConfig(
+ split.billingExpr,
+ props.usageSchema
+ )
+ previewRequestRuleExpr = split.requestRuleExpr
+ }
+
+ const publishRows = (nextRows: TaskMatrixRow[]) => {
+ setMatrixRows(nextRows)
+ props.onBillingExprChange(
+ generateTaskExprFromConfig(
+ {
+ tiers: taskMatrixToTiers({ rows: nextRows }, props.usageSchema),
+ },
+ props.usageSchema
+ )
+ )
+ }
+
+ const handleRowChange = (index: number, next: TaskMatrixRow) => {
+ const nextRows = [...matrixRows]
+ nextRows[index] = next
+ publishRows(nextRows)
+ }
+
+ const handleFillColumn = (priceKey: string, value: number) => {
+ const nextRows = matrixRows.map((row) => {
+ if (priceKey === 'constant') return { ...row, constant: value }
+ return {
+ ...row,
+ unitPrices: { ...row.unitPrices, [priceKey]: value },
+ }
+ })
+ publishRows(nextRows)
+ }
+
+ const handleRawChange = (value: string) => {
+ setRawExpr(value)
+ const split = splitBillingExprAndRequestRules(value)
+ props.onBillingExprChange(split.billingExpr)
+ props.onRequestRuleExprChange(split.requestRuleExpr)
+ }
+
+ const handleModeChange = (nextMode: EditorMode) => {
+ if (nextMode === 'visual') {
+ const split = splitBillingExprAndRequestRules(rawExpr)
+ const parsed = tryParseTaskMatrixConfig(
+ split.billingExpr,
+ props.usageSchema
+ )
+ const nextRows = (
+ parsed ?? createDefaultTaskMatrixConfig(props.usageSchema)
+ ).rows
+ setMatrixRows(nextRows)
+ props.onBillingExprChange(
+ generateTaskExprFromConfig(
+ {
+ tiers: taskMatrixToTiers({ rows: nextRows }, props.usageSchema),
+ },
+ props.usageSchema
+ )
+ )
+ props.onRequestRuleExprChange(split.requestRuleExpr)
+ } else {
+ const expression = generateTaskExprFromConfig(
+ { tiers: visualTiers },
+ props.usageSchema
+ )
+ setRawExpr(combineBillingExpr(expression, props.requestRuleExpr))
+ }
+ setEditorMode(nextMode)
+ }
+
+ const handlePreviewSampleChange = (field: string, value: number | string) => {
+ setPreviewSample((current) => ({ ...current, [field]: value }))
+ }
+
+ const allRowsFree = matrixRows.every(
+ (row) =>
+ row.constant === 0 &&
+ numberFields.every(([field]) => !(row.unitPrices[field] > 0))
+ )
+ const showRawMatrixHint = Boolean(
+ props.billingExpr &&
+ enumFields.length > 0 &&
+ !tryParseTaskMatrixConfig(props.billingExpr, props.usageSchema)
+ )
+
+ return (
+
+
+
+ {t('Editor mode')}
+
+ value !== null && handleModeChange(value as EditorMode)
+ }
+ >
+
+
+
+
+
+ {t('Visual editor')}
+ {t('Expression editor')}
+
+
+
+
+
+
+
+
+ {t(
+ 'Task usage prices are USD per declared unit. Token fields use dollars per 1M tokens; the editor writes / 1000000 into the expression. Other units are not divided by one million.'
+ )}
+
+
+
+
+ {editorMode === 'visual' ? (
+ <>
+ {enumFields.length > 0 ? (
+
+
+ {t('Each row prices one combination of {{fields}}.', {
+ fields: enumFields.map(([field]) => field).join(', '),
+ })}
+
+
+
+ ) : (
+ <>
+ {allRowsFree ? (
+
+
+
+ {t(
+ 'All combinations are priced at zero. Matching requests will be billed as free.'
+ )}
+
+
+ ) : null}
+
+
+
+ {t('Usage prices')}
+
+
+ {numberFields.map(([field, definition]) => {
+ const description = resolveLocalizedText(
+ definition.description,
+ i18n.language
+ )
+ return (
+
+
+ {field}
+
+
+ {
+ if (Number(event.currentTarget.value) === 0) {
+ event.currentTarget.select()
+ }
+ }}
+ onChange={(event) => {
+ const value = Number(event.target.value)
+ handleRowChange(0, {
+ ...matrixRows[0],
+ unitPrices: {
+ ...matrixRows[0].unitPrices,
+ [field]:
+ Number.isFinite(value) && value >= 0
+ ? value
+ : 0,
+ },
+ })
+ }}
+ className='font-mono'
+ />
+
+ $/{t(getTaskUsagePriceUnitLabelKey(definition.unit))}
+
+
+ {description ? (
+ {description}
+ ) : null}
+
+ )
+ })}
+
+ {t('Base charge')}
+
+ {
+ if (Number(event.currentTarget.value) === 0) {
+ event.currentTarget.select()
+ }
+ }}
+ onChange={(event) => {
+ const value = Number(event.target.value)
+ handleRowChange(0, {
+ ...matrixRows[0],
+ constant:
+ Number.isFinite(value) && value >= 0
+ ? value
+ : 0,
+ })
+ }}
+ className='font-mono'
+ />
+
+ $/{t('request')}
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+ {t('Request rule pricing')}
+
+ >
+ ) : (
+
+
+
+
+ {t('Usage parameters')}:{' '}
+ {Object.keys(props.usageSchema)
+ .sort((left, right) => left.localeCompare(right))
+ .map((field) => `u(${JSON.stringify(field)})`)
+ .join(', ')}
+
+
+ {t('Functions')}: tier(name, value),{' '}
+ header(name), param(path)
+
+ {showRawMatrixHint ? (
+
+ {t(
+ 'This expression does not price each combination exactly once, so it opens as a raw expression. Sparse or custom pricing stays in this editor.'
+ )}
+
+ ) : null}
+
+
+
+ )}
+
+
+ )
+})
diff --git a/web/src/features/system-settings/site/index.tsx b/web/src/features/system-settings/site/index.tsx
index 8e6f29ceb73f..dffda5593c1a 100644
--- a/web/src/features/system-settings/site/index.tsx
+++ b/web/src/features/system-settings/site/index.tsx
@@ -32,6 +32,7 @@ const defaultSiteSettings: SiteSettings = {
About: '',
HomePageContent: '',
ServerAddress: '',
+ TaskPublicAddress: '',
'legal.user_agreement': '',
'legal.privacy_policy': '',
HeaderNavModules: '',
diff --git a/web/src/features/system-settings/site/section-registry.tsx b/web/src/features/system-settings/site/section-registry.tsx
index 02300e724b27..bf6ca4734cf5 100644
--- a/web/src/features/system-settings/site/section-registry.tsx
+++ b/web/src/features/system-settings/site/section-registry.tsx
@@ -42,6 +42,7 @@ const SITE_SECTIONS = [
About: settings.About,
HomePageContent: settings.HomePageContent,
ServerAddress: settings.ServerAddress,
+ TaskPublicAddress: settings.TaskPublicAddress,
legal: {
user_agreement: settings['legal.user_agreement'],
privacy_policy: settings['legal.privacy_policy'],
diff --git a/web/src/features/system-settings/types.ts b/web/src/features/system-settings/types.ts
index 063694b21cca..437b1dc63f28 100644
--- a/web/src/features/system-settings/types.ts
+++ b/web/src/features/system-settings/types.ts
@@ -114,6 +114,7 @@ export type SiteSettings = {
About: string
HomePageContent: string
ServerAddress: string
+ TaskPublicAddress: string
'legal.user_agreement': string
'legal.privacy_policy': string
HeaderNavModules: string
diff --git a/web/src/features/task-plugins/__tests__/enabled-option.test.ts b/web/src/features/task-plugins/__tests__/enabled-option.test.ts
new file mode 100644
index 000000000000..2e49e19eb09b
--- /dev/null
+++ b/web/src/features/task-plugins/__tests__/enabled-option.test.ts
@@ -0,0 +1,66 @@
+/*
+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 { beforeEach, describe, expect, test, vi } from 'vitest'
+
+import {
+ getTaskPluginEnabledOption,
+ setTaskPluginEnabledOption,
+} from '../api'
+
+const { get, put } = vi.hoisted(() => ({
+ get: vi.fn(),
+ put: vi.fn(),
+}))
+
+vi.mock('@/lib/api', () => ({
+ api: {
+ get,
+ put,
+ },
+}))
+
+describe('task plugin master switch option', () => {
+ beforeEach(() => {
+ get.mockReset()
+ put.mockReset()
+ })
+
+ test('reads TaskPluginEnabled from /api/option/', async () => {
+ get.mockResolvedValue({
+ data: {
+ success: true,
+ data: [{ key: 'TaskPluginEnabled', value: 'true' }],
+ },
+ })
+
+ await expect(getTaskPluginEnabledOption()).resolves.toBe(true)
+ expect(get).toHaveBeenCalledWith('/api/option/')
+ })
+
+ test('writes TaskPluginEnabled to /api/option/', async () => {
+ put.mockResolvedValue({ data: { success: true, data: null } })
+
+ await setTaskPluginEnabledOption(false)
+ expect(put).toHaveBeenCalledWith(
+ '/api/option/',
+ { key: 'TaskPluginEnabled', value: 'false' },
+ expect.anything()
+ )
+ })
+})
diff --git a/web/src/features/task-plugins/__tests__/marketplace-panel.test.tsx b/web/src/features/task-plugins/__tests__/marketplace-panel.test.tsx
new file mode 100644
index 000000000000..1615ee0e5c4a
--- /dev/null
+++ b/web/src/features/task-plugins/__tests__/marketplace-panel.test.tsx
@@ -0,0 +1,164 @@
+/*
+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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { afterEach, describe, expect, test, vi } from 'vitest'
+
+import { MarketplacePanel } from '../components/marketplace-panel'
+import {
+ DEFAULT_MARKETPLACE_INDEX_URL,
+ GITHUB_MARKETPLACE_INDEX_URL,
+} from '../lib/marketplace'
+import type { MarketplaceIndex, MarketplaceSource } from '../types'
+
+vi.mock('../components/marketplace-install-dialog', () => ({
+ MarketplaceInstallDialog: () => null,
+}))
+vi.mock('../components/marketplace-plugin-card', () => ({
+ MarketplacePluginCard: () => null,
+}))
+vi.mock('../components/marketplace-sources-dialog', () => ({
+ MarketplaceSourcesDialog: () => null,
+}))
+
+const officialSource: MarketplaceSource = {
+ name: 'Official',
+ index_url: DEFAULT_MARKETPLACE_INDEX_URL,
+}
+const githubSource: MarketplaceSource = {
+ name: 'GitHub',
+ index_url: GITHUB_MARKETPLACE_INDEX_URL,
+}
+
+const officialIndex: MarketplaceIndex = {
+ indexVersion: 1,
+ name: 'Official catalog',
+ plugins: [],
+}
+const githubIndex: MarketplaceIndex = {
+ indexVersion: 1,
+ name: 'GitHub catalog',
+ plugins: [],
+}
+
+const queryClients: QueryClient[] = []
+
+function renderPanel(): QueryClient {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, staleTime: Number.POSITIVE_INFINITY },
+ },
+ })
+ queryClient.setQueryData(
+ ['task-plugin-marketplace-sources'],
+ [officialSource, githubSource]
+ )
+ queryClient.setQueryData(['task-plugins'], [])
+ queryClients.push(queryClient)
+
+ render(
+
+
+
+ )
+ return queryClient
+}
+
+function installIndexFetchMock() {
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input)
+ const index =
+ url === GITHUB_MARKETPLACE_INDEX_URL ? githubIndex : officialIndex
+ return new Response(JSON.stringify(index), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ })
+ vi.stubGlobal('fetch', fetchMock)
+ return fetchMock
+}
+
+afterEach(() => {
+ for (const queryClient of queryClients) queryClient.clear()
+ queryClients.length = 0
+ vi.unstubAllGlobals()
+})
+
+describe('MarketplacePanel source switch', () => {
+ test('selects only the official source by default and hides both URLs', async () => {
+ const fetchMock = installIndexFetchMock()
+ renderPanel()
+
+ expect(
+ await screen.findByRole('heading', {
+ name: officialIndex.name,
+ })
+ ).toBeInTheDocument()
+ expect(screen.queryByText(DEFAULT_MARKETPLACE_INDEX_URL)).toBeNull()
+ expect(screen.queryByText(GITHUB_MARKETPLACE_INDEX_URL)).toBeNull()
+ expect(screen.getByRole('button', { name: 'Official' })).toHaveAttribute(
+ 'aria-pressed',
+ 'true'
+ )
+ expect(fetchMock).toHaveBeenCalledWith(DEFAULT_MARKETPLACE_INDEX_URL)
+ expect(fetchMock).not.toHaveBeenCalledWith(GITHUB_MARKETPLACE_INDEX_URL)
+ })
+
+ test('loads GitHub only after the administrator switches to it', async () => {
+ const fetchMock = installIndexFetchMock()
+ const user = userEvent.setup()
+ renderPanel()
+ await screen.findByRole('heading', { name: officialIndex.name })
+
+ expect(fetchMock).not.toHaveBeenCalledWith(GITHUB_MARKETPLACE_INDEX_URL)
+
+ await user.click(screen.getByRole('button', { name: 'GitHub' }))
+
+ expect(
+ await screen.findByRole('heading', { name: githubIndex.name })
+ ).toBeInTheDocument()
+ expect(
+ screen.queryByRole('heading', { name: officialIndex.name })
+ ).toBeNull()
+ expect(screen.getByRole('button', { name: 'GitHub' })).toHaveAttribute(
+ 'aria-pressed',
+ 'true'
+ )
+ expect(fetchMock).toHaveBeenCalledWith(GITHUB_MARKETPLACE_INDEX_URL)
+ })
+
+ test('does not request GitHub automatically when the official source fails', async () => {
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input)
+ if (url === DEFAULT_MARKETPLACE_INDEX_URL) {
+ return new Response('', { status: 503 })
+ }
+ return new Response(JSON.stringify(githubIndex), { status: 200 })
+ })
+ vi.stubGlobal('fetch', fetchMock)
+ renderPanel()
+
+ expect(
+ await screen.findByText('Could not load this source')
+ ).toBeInTheDocument()
+ expect(fetchMock).toHaveBeenCalledWith(DEFAULT_MARKETPLACE_INDEX_URL)
+ expect(fetchMock).not.toHaveBeenCalledWith(GITHUB_MARKETPLACE_INDEX_URL)
+ })
+})
diff --git a/web/src/features/task-plugins/__tests__/marketplace.test.ts b/web/src/features/task-plugins/__tests__/marketplace.test.ts
new file mode 100644
index 000000000000..852a228dfa05
--- /dev/null
+++ b/web/src/features/task-plugins/__tests__/marketplace.test.ts
@@ -0,0 +1,471 @@
+/*
+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 { describe, test } from 'vitest'
+
+import {
+ deriveInstallState,
+ findMarketplaceVersion,
+ GITHUB_MARKETPLACE_INDEX_URL,
+ indexHasIntegrityHashes,
+ isDefaultMarketplaceSource,
+ parseMarketplaceIndex,
+ resolvePluginSourceUrl,
+} from '../lib/marketplace'
+import type {
+ MarketplaceIndex,
+ MarketplacePlugin,
+ TaskPluginListItem,
+} from '../types'
+
+const OFFICIAL_INDEX_URL = 'https://www.newapi.ai/api/v1/plugins/index.json'
+
+function marketplacePlugin(
+ overrides: Partial = {}
+): MarketplacePlugin {
+ return {
+ key: 'doubao',
+ name: 'doubao-video',
+ latest: '1.2.0',
+ versions: [
+ { version: '1.2.0', path: 'plugins/tasks/doubao/1.2.0/plugin.js' },
+ { version: '1.0.0', path: 'plugins/tasks/doubao/1.0.0/plugin.js' },
+ ],
+ ...overrides,
+ }
+}
+
+function installedPlugin(key: string, version: string): TaskPluginListItem {
+ return {
+ meta: {
+ apiVersion: 1,
+ key,
+ name: key,
+ version,
+ author: { name: 'test' },
+ models: null,
+ fetchMode: 'poll',
+ },
+ source: 'override',
+ enabled: true,
+ active: true,
+ source_hash: 'hash',
+ remark: '',
+ runtime_status: 'registered',
+ channel_count: 0,
+ in_flight_count: 0,
+ }
+}
+
+describe('marketplace source path resolution', () => {
+ test('resolves a relative path against the directory holding the index', () => {
+ assert.equal(
+ resolvePluginSourceUrl(
+ 'https://host.example/x/index.json',
+ 'plugins/tasks/doubao/1.0.0/plugin.js'
+ ),
+ 'https://host.example/x/plugins/tasks/doubao/1.0.0/plugin.js'
+ )
+ })
+
+ test('resolves against a root index without dropping the path', () => {
+ assert.equal(
+ resolvePluginSourceUrl(OFFICIAL_INDEX_URL, 'x/1.0.0/plugin.js'),
+ 'https://www.newapi.ai/api/v1/plugins/x/1.0.0/plugin.js'
+ )
+ })
+
+ test('resolves a root-relative path against the index origin', () => {
+ assert.equal(
+ resolvePluginSourceUrl(
+ 'https://host.example/x/index.json',
+ '/other/plugin.js'
+ ),
+ 'https://host.example/other/plugin.js'
+ )
+ })
+
+ test('rejects a path that resolves to a different origin', () => {
+ assert.equal(
+ resolvePluginSourceUrl(
+ 'https://host.example/x/index.json',
+ 'https://evil.example/plugin.js'
+ ),
+ null
+ )
+ })
+
+ test('rejects an empty path', () => {
+ assert.equal(resolvePluginSourceUrl(OFFICIAL_INDEX_URL, ' '), null)
+ })
+
+ test('rejects an index URL that is not a valid absolute URL', () => {
+ assert.equal(resolvePluginSourceUrl('not-a-url', 'plugin.js'), null)
+ })
+})
+
+describe('marketplace index parsing', () => {
+ test('keeps plugins whose kind is absent or task', () => {
+ const index = parseMarketplaceIndex({
+ indexVersion: 1,
+ name: 'Official',
+ plugins: [
+ {
+ key: 'no-kind',
+ latest: '1.0.0',
+ versions: [{ version: '1.0.0', path: 'a/plugin.js' }],
+ },
+ {
+ key: 'task-kind',
+ latest: '1.0.0',
+ versions: [{ version: '1.0.0', path: 'b/plugin.js', kind: 'task' }],
+ },
+ ],
+ })
+ assert.deepEqual(
+ index.plugins.map((plugin) => plugin.key),
+ ['no-kind', 'task-kind']
+ )
+ })
+
+ test('drops a plugin whose only version declares an unsupported kind', () => {
+ const index = parseMarketplaceIndex({
+ indexVersion: 1,
+ plugins: [
+ {
+ key: 'relay-only',
+ latest: '1.0.0',
+ versions: [{ version: '1.0.0', path: 'a/plugin.js', kind: 'relay' }],
+ },
+ ],
+ })
+ assert.deepEqual(index.plugins, [])
+ })
+
+ test('carries the optional allowedHosts and auth declarations through', () => {
+ const index = parseMarketplaceIndex({
+ indexVersion: 1,
+ plugins: [
+ {
+ key: 'doubao',
+ latest: '1.0.0',
+ versions: [
+ {
+ version: '1.0.0',
+ path: 'a/plugin.js',
+ sha256: 'abc',
+ allowedHosts: ['ark.cn-beijing.volces.com'],
+ auth: 'api_key',
+ },
+ ],
+ },
+ ],
+ })
+ assert.deepEqual(index.plugins[0].versions[0].allowedHosts, [
+ 'ark.cn-beijing.volces.com',
+ ])
+ assert.equal(index.plugins[0].versions[0].auth, 'api_key')
+ })
+
+ test('falls back to the first listed version when latest names an absent version', () => {
+ const index = parseMarketplaceIndex({
+ indexVersion: 1,
+ plugins: [
+ {
+ key: 'doubao',
+ latest: '9.9.9',
+ versions: [{ version: '1.0.0', path: 'a/plugin.js' }],
+ },
+ ],
+ })
+ assert.equal(index.plugins[0].latest, '1.0.0')
+ })
+
+ test('skips malformed plugin entries instead of failing the whole source', () => {
+ const index = parseMarketplaceIndex({
+ indexVersion: 1,
+ plugins: [
+ null,
+ { name: 'no key' },
+ { key: 'no-versions', versions: [] },
+ {
+ key: 'ok',
+ latest: '1.0.0',
+ versions: [{ version: '1.0.0', path: 'a.js' }],
+ },
+ ],
+ })
+ assert.deepEqual(
+ index.plugins.map((plugin) => plugin.key),
+ ['ok']
+ )
+ })
+
+ test('rejects an index version newer than this gateway understands', () => {
+ assert.throws(
+ () => parseMarketplaceIndex({ indexVersion: 2, plugins: [] }),
+ /unsupported indexVersion 2/
+ )
+ })
+
+ test('rejects a payload with no indexVersion', () => {
+ assert.throws(
+ () => parseMarketplaceIndex({ plugins: [] }),
+ /missing indexVersion/
+ )
+ })
+
+ test('rejects a non-object payload', () => {
+ assert.throws(() => parseMarketplaceIndex(''), /not an object/)
+ })
+
+ test('keeps a present icon string after trim', () => {
+ const index = parseMarketplaceIndex({
+ indexVersion: 1,
+ plugins: [
+ {
+ key: 'sora',
+ latest: '1.0.0',
+ icon: ' Sora.Color ',
+ versions: [{ version: '1.0.0', path: 'a/plugin.js' }],
+ },
+ ],
+ })
+ assert.equal(index.plugins[0].icon, 'Sora.Color')
+ })
+
+ test('omits icon when the field is absent', () => {
+ const index = parseMarketplaceIndex({
+ indexVersion: 1,
+ plugins: [
+ {
+ key: 'sora',
+ latest: '1.0.0',
+ versions: [{ version: '1.0.0', path: 'a/plugin.js' }],
+ },
+ ],
+ })
+ assert.equal(index.plugins[0].icon, undefined)
+ })
+
+ test('drops a non-string icon', () => {
+ const index = parseMarketplaceIndex({
+ indexVersion: 1,
+ plugins: [
+ {
+ key: 'sora',
+ latest: '1.0.0',
+ icon: 12,
+ versions: [{ version: '1.0.0', path: 'a/plugin.js' }],
+ },
+ ],
+ })
+ assert.equal(index.plugins[0].icon, undefined)
+ })
+
+ test('keeps a bare string description from a legacy index', () => {
+ const index = parseMarketplaceIndex({
+ indexVersion: 1,
+ plugins: [
+ {
+ key: 'kling',
+ latest: '1.0.0',
+ description: 'Video generation via Kling API',
+ versions: [{ version: '1.0.0', path: 'a/plugin.js' }],
+ },
+ ],
+ })
+ assert.equal(index.plugins[0].description, 'Video generation via Kling API')
+ })
+
+ test('keeps a LocalizedText object description from a current index', () => {
+ const index = parseMarketplaceIndex({
+ indexVersion: 1,
+ plugins: [
+ {
+ key: 'kling',
+ latest: '1.0.0',
+ description: {
+ en: 'Video generation via Kling API',
+ zh: '可灵视频生成',
+ },
+ versions: [{ version: '1.0.0', path: 'a/plugin.js' }],
+ },
+ ],
+ })
+ assert.deepEqual(index.plugins[0].description, {
+ en: 'Video generation via Kling API',
+ zh: '可灵视频生成',
+ })
+ })
+
+ test('omits a non-string, non-object description', () => {
+ const index = parseMarketplaceIndex({
+ indexVersion: 1,
+ plugins: [
+ {
+ key: 'kling',
+ latest: '1.0.0',
+ description: 12,
+ versions: [{ version: '1.0.0', path: 'a/plugin.js' }],
+ },
+ ],
+ })
+ assert.equal(index.plugins[0].description, undefined)
+ })
+
+ test('drops an icon longer than 128 characters', () => {
+ const index = parseMarketplaceIndex({
+ indexVersion: 1,
+ plugins: [
+ {
+ key: 'sora',
+ latest: '1.0.0',
+ icon: 'A'.repeat(129),
+ versions: [{ version: '1.0.0', path: 'a/plugin.js' }],
+ },
+ ],
+ })
+ assert.equal(index.plugins[0].icon, undefined)
+ })
+})
+
+describe('install state derivation', () => {
+ test('reports not installed when no local plugin shares the key', () => {
+ assert.deepEqual(deriveInstallState(marketplacePlugin(), []), {
+ status: 'not_installed',
+ })
+ })
+
+ test('reports up to date when the installed version equals latest', () => {
+ assert.deepEqual(
+ deriveInstallState(marketplacePlugin(), [
+ installedPlugin('doubao', '1.2.0'),
+ ]),
+ { status: 'up_to_date', installedVersion: '1.2.0' }
+ )
+ })
+
+ test('reports upgradable when an older listed version is installed', () => {
+ assert.deepEqual(
+ deriveInstallState(marketplacePlugin(), [
+ installedPlugin('doubao', '1.0.0'),
+ ]),
+ {
+ status: 'upgradable',
+ installedVersion: '1.0.0',
+ latestVersion: '1.2.0',
+ }
+ )
+ })
+
+ test('reports diverged when the installed version is absent from the index', () => {
+ assert.deepEqual(
+ deriveInstallState(marketplacePlugin(), [
+ installedPlugin('doubao', '3.0.0-local'),
+ ]),
+ {
+ status: 'diverged',
+ installedVersion: '3.0.0-local',
+ latestVersion: '1.2.0',
+ }
+ )
+ })
+
+ test('ignores installed plugins with a different key', () => {
+ assert.deepEqual(
+ deriveInstallState(marketplacePlugin(), [
+ installedPlugin('kling', '1.2.0'),
+ ]),
+ { status: 'not_installed' }
+ )
+ })
+})
+
+describe('marketplace version lookup', () => {
+ test('finds the entry matching a version', () => {
+ assert.equal(
+ findMarketplaceVersion(marketplacePlugin(), '1.0.0')?.path,
+ 'plugins/tasks/doubao/1.0.0/plugin.js'
+ )
+ })
+
+ test('returns undefined for an unknown version', () => {
+ assert.equal(
+ findMarketplaceVersion(marketplacePlugin(), '9.9.9'),
+ undefined
+ )
+ })
+})
+
+describe('source integrity and trust labels', () => {
+ function index(plugins: MarketplacePlugin[]): MarketplaceIndex {
+ return { indexVersion: 1, name: 'test', plugins }
+ }
+
+ test('treats a source as verified only when every version carries a hash', () => {
+ assert.equal(
+ indexHasIntegrityHashes(
+ index([
+ marketplacePlugin({
+ versions: [
+ { version: '1.2.0', path: 'a.js', sha256: 'aa' },
+ { version: '1.0.0', path: 'b.js', sha256: 'bb' },
+ ],
+ }),
+ ])
+ ),
+ true
+ )
+ })
+
+ test('treats a partially hashed source as unverified', () => {
+ assert.equal(
+ indexHasIntegrityHashes(
+ index([
+ marketplacePlugin({
+ versions: [
+ { version: '1.2.0', path: 'a.js', sha256: 'aa' },
+ { version: '1.0.0', path: 'b.js' },
+ ],
+ }),
+ ])
+ ),
+ false
+ )
+ })
+
+ test('treats an empty index as unverified rather than trivially verified', () => {
+ assert.equal(indexHasIntegrityHashes(index([])), false)
+ })
+
+ test('labels both built-in index URLs as official sources', () => {
+ assert.equal(isDefaultMarketplaceSource(OFFICIAL_INDEX_URL), true)
+ assert.equal(isDefaultMarketplaceSource(` ${OFFICIAL_INDEX_URL} `), true)
+ assert.equal(isDefaultMarketplaceSource(GITHUB_MARKETPLACE_INDEX_URL), true)
+ })
+
+ test('labels any other index URL as third-party', () => {
+ assert.equal(
+ isDefaultMarketplaceSource('https://mirror.example/index.json'),
+ false
+ )
+ })
+})
diff --git a/web/src/features/task-plugins/__tests__/plugin-card.test.tsx b/web/src/features/task-plugins/__tests__/plugin-card.test.tsx
new file mode 100644
index 000000000000..93111a92aa75
--- /dev/null
+++ b/web/src/features/task-plugins/__tests__/plugin-card.test.tsx
@@ -0,0 +1,162 @@
+/*
+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 {
+ getCoreRowModel,
+ useReactTable,
+ type ColumnDef,
+} from '@tanstack/react-table'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, test, vi } from 'vitest'
+
+import { PluginCard } from '../components/plugin-card'
+import type { TaskPluginListItem } from '../types'
+
+// @lobehub/icons transitively imports @emoji-mart JSON assets that vitest's
+// externalized ESM loader rejects. Icon rendering is irrelevant to the card
+// layout contracts under test, so the icon loader boundary is stubbed.
+vi.mock('@/lib/lobe-icon', () => ({
+ getLobeIcon: () => null,
+}))
+
+function makeItem(overrides?: Partial): TaskPluginListItem {
+ return {
+ meta: {
+ apiVersion: 1,
+ key: 'kling',
+ name: 'Kling',
+ version: '1.2.3',
+ author: { name: 'acme' },
+ models: ['kling-v1', 'kling-v2'],
+ fetchMode: 'proxy',
+ description: { en: 'Generate videos with Kling.' },
+ usageSchema: {
+ duration: { type: 'number', unit: 'second' },
+ },
+ },
+ source: 'factory',
+ enabled: true,
+ active: true,
+ source_hash: '',
+ remark: '',
+ runtime_status: 'registered',
+ channel_count: 0,
+ in_flight_count: 0,
+ ...overrides,
+ }
+}
+
+const stubColumns: ColumnDef[] = [
+ { id: 'source', cell: () => 'source-badge' },
+ { id: 'runtime', cell: () => 'runtime-badge' },
+ { id: 'enabled', cell: () => 'enabled-switch' },
+ { id: 'actions', cell: () => 'actions-menu' },
+]
+
+function PluginCardHarness({ item }: { item: TaskPluginListItem }) {
+ const table = useReactTable({
+ data: [item],
+ columns: stubColumns,
+ getCoreRowModel: getCoreRowModel(),
+ })
+ return
+}
+
+describe('PluginCard layout', () => {
+ test('given a plugin, the versions read as pills beside the source and runtime badges', () => {
+ render( )
+
+ const version = screen.getByText('v1.2.3')
+ const apiVersion = screen.getByText('API v1')
+ const badgeRow = version.parentElement
+ expect(badgeRow).toBe(apiVersion.parentElement)
+ expect(badgeRow).toHaveClass('flex-wrap')
+ expect(badgeRow?.textContent).toContain('source-badge')
+ expect(badgeRow?.textContent).toContain('runtime-badge')
+ })
+
+ test('given a plugin, the version pills carry labelled accessible names', () => {
+ render( )
+
+ expect(screen.getByLabelText('Active version 1.2.3')).toBeInTheDocument()
+ expect(screen.getByLabelText('API version v1')).toBeInTheDocument()
+ })
+
+ test('given a description, it is clamped to two lines', () => {
+ render( )
+
+ expect(screen.getByText('Generate videos with Kling.')).toHaveClass(
+ 'line-clamp-2'
+ )
+ })
+
+ test('given a plugin, the bound models render as named chips rather than a count', () => {
+ render( )
+
+ expect(screen.getByText('Models')).toBeInTheDocument()
+ expect(screen.getByText('kling-v1')).toBeInTheDocument()
+ expect(screen.getByText('kling-v2')).toBeInTheDocument()
+ })
+
+ test('given more than four models, only four chips render plus an overflow count', () => {
+ render(
+
+ )
+
+ for (const model of ['a', 'b', 'c', 'd']) {
+ expect(screen.getByText(model)).toBeInTheDocument()
+ }
+ expect(screen.queryByText('e')).toBeNull()
+ expect(screen.queryByText('f')).toBeNull()
+ expect(screen.getByText('+2')).toHaveAttribute('title', 'e, f')
+ })
+
+ test('given no models, the models section is omitted', () => {
+ render(
+
+ )
+
+ expect(screen.queryByText('Models')).toBeNull()
+ })
+
+ test('given a usage schema, billing parameters stay out of the card', () => {
+ const { container } = render( )
+
+ expect(screen.queryByText('Billing parameters')).toBeNull()
+ expect(screen.queryByText('duration')).toBeNull()
+ expect(container.querySelector('table')).toBeNull()
+ })
+
+ test('given a plugin, the footer pins the enabled toggle at the card bottom', () => {
+ render( )
+
+ const label = screen.getByText('Enabled')
+ const footer = label.parentElement
+ expect(footer).toHaveClass('mt-auto')
+ expect(screen.getByText('enabled-switch')).toBeInTheDocument()
+ })
+})
diff --git a/web/src/features/task-plugins/__tests__/plugin-detail-sheet.test.tsx b/web/src/features/task-plugins/__tests__/plugin-detail-sheet.test.tsx
new file mode 100644
index 000000000000..aa56209f5bf1
--- /dev/null
+++ b/web/src/features/task-plugins/__tests__/plugin-detail-sheet.test.tsx
@@ -0,0 +1,239 @@
+/*
+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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, test } from 'vitest'
+
+import { PluginDetailSheet } from '../components/plugin-detail-sheet'
+import type {
+ TaskPluginDetail,
+ TaskPluginListItem,
+ TaskPluginMeta,
+} from '../types'
+
+const queryClients: QueryClient[] = []
+
+function makeItem(): TaskPluginListItem {
+ return {
+ meta: {
+ apiVersion: 1,
+ key: 'kling',
+ name: 'Kling',
+ version: '1.2.3',
+ author: { name: 'acme' },
+ models: ['kling-v1'],
+ fetchMode: 'per_task',
+ },
+ source: 'factory',
+ enabled: true,
+ active: true,
+ source_hash: '',
+ remark: '',
+ runtime_status: 'registered',
+ channel_count: 0,
+ in_flight_count: 0,
+ }
+}
+
+function renderSheet(metaOverrides: Partial) {
+ const item = makeItem()
+ const detail: TaskPluginDetail = {
+ meta: { ...item.meta, ...metaOverrides },
+ source: '',
+ layer: 'factory',
+ }
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, staleTime: Number.POSITIVE_INFINITY },
+ },
+ })
+ queryClient.setQueryData(['task-plugin', item.meta.key], detail)
+ queryClient.setQueryData(['task-plugin-versions', item.meta.key], [])
+ queryClients.push(queryClient)
+ render(
+
+ undefined} />
+
+ )
+}
+
+/**
+ * The endpoint row that renders `path`, i.e. the list item holding both the
+ * method badge and the path. Annotations such as the supported request forms
+ * belong to a specific endpoint, so ownership is asserted through this row
+ * rather than through document-wide presence.
+ */
+function endpointRow(path: string): HTMLElement {
+ const row = screen.getByText(path).closest('li')
+ if (!row) throw new Error(`no endpoint row for ${path}`)
+ return row
+}
+
+afterEach(() => {
+ for (const queryClient of queryClients) queryClient.clear()
+ queryClients.length = 0
+})
+
+describe('PluginDetailSheet metadata fields', () => {
+ test('given a plugin whose manifest cannot declare actions, no actions row is rendered in the metadata card', async () => {
+ renderSheet({ protocols: ['openai_video'] })
+
+ // 'Actions' remains a column header in the version-history table, so the
+ // removed metadata row is asserted through the metadata card's own list.
+ const models = await screen.findByText('Models')
+ const metadataList = models.closest('dl')
+ expect(metadataList).not.toBeNull()
+ expect(metadataList?.textContent).not.toContain('Actions')
+ })
+
+ test('given a plugin with several models, the models value renders as one wrapping list', async () => {
+ renderSheet({ models: ['kling-v1', 'kling-v1-6', 'kling-v2-master'] })
+
+ expect(
+ await screen.findByText('kling-v1, kling-v1-6, kling-v2-master')
+ ).toBeInTheDocument()
+ })
+})
+
+describe('PluginDetailSheet host protocol endpoints', () => {
+ test('given an openai_responses claim, both the create and the retrieve endpoint are listed', async () => {
+ renderSheet({
+ protocols: [{ name: 'openai_responses', supports: ['stream'] }],
+ })
+
+ expect(await screen.findByText('/v1/responses')).toBeInTheDocument()
+ expect(screen.getByText('/v1/responses/{response_id}')).toBeInTheDocument()
+ expect(endpointRow('/v1/responses').textContent).toContain('POST')
+ expect(endpointRow('/v1/responses/{response_id}').textContent).toContain(
+ 'GET'
+ )
+ })
+
+ test('given an object claim with all supports, the three mode chips sit on the create row', async () => {
+ renderSheet({
+ protocols: [
+ {
+ name: 'openai_responses',
+ supports: ['stream', 'sync', 'background'],
+ },
+ ],
+ })
+
+ await screen.findByText('/v1/responses')
+
+ const createRow = endpointRow('/v1/responses')
+ expect(createRow).toContainElement(screen.getByText('stream'))
+ expect(createRow).toContainElement(screen.getByText('sync'))
+ expect(createRow).toContainElement(screen.getByText('background'))
+ })
+
+ test('given an object claim with all supports, the retrieve row carries no mode chips', async () => {
+ renderSheet({
+ protocols: [
+ {
+ name: 'openai_responses',
+ supports: ['stream', 'sync', 'background'],
+ },
+ ],
+ })
+
+ await screen.findByText('/v1/responses/{response_id}')
+
+ const retrieveRow = endpointRow('/v1/responses/{response_id}')
+ expect(retrieveRow.textContent).not.toContain('stream')
+ expect(retrieveRow.textContent).not.toContain('sync')
+ expect(retrieveRow.textContent).not.toContain('background')
+ })
+
+ test('given an object claim supporting only stream, the other mode chips are absent', async () => {
+ renderSheet({
+ protocols: [{ name: 'openai_responses', supports: ['stream'] }],
+ })
+
+ expect(await screen.findByText('stream')).toBeInTheDocument()
+ expect(screen.queryByText('sync')).toBeNull()
+ expect(screen.queryByText('background')).toBeNull()
+ })
+
+ test('given a string claim, its three endpoints render without any mode chip', async () => {
+ renderSheet({ protocols: ['openai_video'] })
+
+ expect(await screen.findByText('/v1/videos')).toBeInTheDocument()
+ expect(screen.getByText('/v1/videos/{task_id}')).toBeInTheDocument()
+ expect(screen.getByText('/v1/videos/{task_id}/content')).toBeInTheDocument()
+ expect(screen.queryByText('stream')).toBeNull()
+ expect(screen.queryByText('sync')).toBeNull()
+ expect(screen.queryByText('background')).toBeNull()
+ })
+
+ test('given a claim narrowing the protocol to a model subset, the subset is marked without printing the model list', async () => {
+ renderSheet({
+ models: ['kling-v1', 'kling-v2-master'],
+ protocols: [{ name: 'openai_video', models: ['kling-v1'] }],
+ })
+
+ const hint = await screen.findByText('Model scope')
+ expect(hint).toHaveAttribute('title', 'kling-v1')
+ })
+
+ test('given a claim binding every model, no model scope marker is rendered', async () => {
+ renderSheet({ protocols: ['openai_video'] })
+
+ await screen.findByText('/v1/videos')
+ expect(screen.queryByText('Model scope')).toBeNull()
+ })
+})
+
+describe('PluginDetailSheet native routes', () => {
+ test('given declared native routes, each renders its method, path and type', async () => {
+ renderSheet({
+ routes: [
+ {
+ method: 'POST',
+ path: '/kling/v1/videos/text2video',
+ type: 'submit',
+ },
+ {
+ method: 'GET',
+ path: '/kling/v1/videos/text2video/:task_id',
+ type: 'query',
+ },
+ ],
+ })
+
+ expect(await screen.findByText('Native routes')).toBeInTheDocument()
+ const submitRow = endpointRow('/kling/v1/videos/text2video')
+ expect(submitRow.textContent).toContain('POST')
+ expect(submitRow.textContent).toContain('submit')
+ const queryRow = endpointRow('/kling/v1/videos/text2video/:task_id')
+ expect(queryRow.textContent).toContain('GET')
+ expect(queryRow.textContent).toContain('query')
+ })
+
+ test('given no protocols and no routes, the endpoint section falls back to a single placeholder', async () => {
+ renderSheet({ protocols: [], routes: [] })
+
+ expect(await screen.findByText('Endpoints')).toBeInTheDocument()
+ expect(screen.queryByText('Native routes')).toBeNull()
+ const placeholders = screen
+ .getAllByText('—')
+ .filter((node) => node.tagName === 'P')
+ expect(placeholders).toHaveLength(1)
+ })
+})
diff --git a/web/src/features/task-plugins/__tests__/plugin-icon.test.ts b/web/src/features/task-plugins/__tests__/plugin-icon.test.ts
new file mode 100644
index 000000000000..f25524dab6ef
--- /dev/null
+++ b/web/src/features/task-plugins/__tests__/plugin-icon.test.ts
@@ -0,0 +1,106 @@
+/*
+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 { describe, test } from 'vitest'
+
+import {
+ resolvePluginIcon,
+ TEXT_AVATAR_PALETTE,
+ textAvatarClass,
+} from '../lib/plugin-icon'
+
+describe('resolvePluginIcon', () => {
+ test('uses icon when present even if channelTypes exist', () => {
+ assert.deepEqual(
+ resolvePluginIcon({
+ icon: 'Sora.Color',
+ channelTypes: [1],
+ key: 'sora',
+ }),
+ { kind: 'lobe', name: 'Sora.Color' }
+ )
+ })
+
+ test('uses the first channel type when icon is absent', () => {
+ assert.deepEqual(
+ resolvePluginIcon({
+ channelTypes: [55, 1],
+ key: 'sora',
+ }),
+ { kind: 'lobe', name: 'OpenAI.Color' }
+ )
+ })
+
+ test('renders a text avatar when neither icon nor channelTypes are present', () => {
+ assert.deepEqual(resolvePluginIcon({ key: 'third-party-plugin' }), {
+ kind: 'text',
+ label: 'TH',
+ colorSeed: 'third-party-plugin',
+ })
+ })
+
+ test('derives the text label from name over key when both exist', () => {
+ assert.deepEqual(
+ resolvePluginIcon({ key: 'vendor-x', name: 'My Plugin' }),
+ { kind: 'text', label: 'MY', colorSeed: 'vendor-x' }
+ )
+ })
+
+ test('icon "text" wins over channelTypes so branded borrow is suppressed', () => {
+ assert.deepEqual(
+ resolvePluginIcon({
+ icon: 'text',
+ channelTypes: [36],
+ key: 'sunoapi',
+ name: 'SunoAPI',
+ }),
+ { kind: 'text', label: 'SU', colorSeed: 'sunoapi' }
+ )
+ })
+
+ test('icon "text:" uses the explicit label capped at 4 characters', () => {
+ assert.deepEqual(
+ resolvePluginIcon({ icon: 'text:Suno API', key: 'sunoapi' }),
+ { kind: 'text', label: 'Suno', colorSeed: 'sunoapi' }
+ )
+ })
+
+ test('icon "text:" with empty label falls back to the derived label', () => {
+ assert.deepEqual(
+ resolvePluginIcon({ icon: 'text:', key: 'sunoapi', name: 'SunoAPI' }),
+ { kind: 'text', label: 'SU', colorSeed: 'sunoapi' }
+ )
+ })
+})
+
+describe('textAvatarClass', () => {
+ test('is deterministic for the same seed', () => {
+ assert.equal(textAvatarClass('sunoapi'), textAvatarClass('sunoapi'))
+ })
+
+ test('always picks from the palette', () => {
+ for (const seed of ['a', 'sunoapi', 'third-party-plugin', '插件', '']) {
+ assert.ok(
+ (TEXT_AVATAR_PALETTE as readonly string[]).includes(
+ textAvatarClass(seed)
+ )
+ )
+ }
+ })
+})
diff --git a/web/src/features/task-plugins/__tests__/plugin-url.test.ts b/web/src/features/task-plugins/__tests__/plugin-url.test.ts
new file mode 100644
index 000000000000..efa517ffdad3
--- /dev/null
+++ b/web/src/features/task-plugins/__tests__/plugin-url.test.ts
@@ -0,0 +1,223 @@
+/*
+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 { describe, test } from 'vitest'
+
+import {
+ fetchPluginSourceText,
+ MAX_PLUGIN_SOURCE_BYTES,
+ normalizePluginSourceUrl,
+ PluginSourceFetchError,
+ pluginSourceByteLength,
+} from '../lib/plugin-url'
+
+describe('plugin source URL normalization', () => {
+ test('rewrites a GitHub blob URL to its raw host', () => {
+ assert.equal(
+ normalizePluginSourceUrl(
+ 'https://github.com/QuantumNous/new-api-plugins/blob/main/plugins/tasks/doubao/1.0.0/plugin.js'
+ ),
+ 'https://raw.githubusercontent.com/QuantumNous/new-api-plugins/main/plugins/tasks/doubao/1.0.0/plugin.js'
+ )
+ })
+
+ test('rewrites a GitHub blob URL whose ref contains a slash', () => {
+ assert.equal(
+ normalizePluginSourceUrl(
+ 'https://github.com/owner/repo/blob/feat/plugin-task/plugin.js'
+ ),
+ 'https://raw.githubusercontent.com/owner/repo/feat/plugin-task/plugin.js'
+ )
+ })
+
+ test('strips a line anchor that would break the raw request', () => {
+ assert.equal(
+ normalizePluginSourceUrl(
+ 'https://github.com/owner/repo/blob/main/plugin.js#L12-L20'
+ ),
+ 'https://raw.githubusercontent.com/owner/repo/main/plugin.js'
+ )
+ })
+
+ test('rewrites a gist page URL to its raw URL', () => {
+ assert.equal(
+ normalizePluginSourceUrl('https://gist.github.com/someone/abc123'),
+ 'https://gist.githubusercontent.com/someone/abc123/raw'
+ )
+ })
+
+ test('keeps a gist URL that already targets raw content', () => {
+ assert.equal(
+ normalizePluginSourceUrl('https://gist.github.com/someone/abc123/raw'),
+ 'https://gist.githubusercontent.com/someone/abc123/raw'
+ )
+ })
+
+ test('passes a raw URL through unchanged', () => {
+ const raw =
+ 'https://raw.githubusercontent.com/owner/repo/main/plugins/tasks/x/1.0.0/plugin.js'
+ assert.equal(normalizePluginSourceUrl(raw), raw)
+ })
+
+ test('passes an arbitrary https URL through unchanged', () => {
+ assert.equal(
+ normalizePluginSourceUrl(
+ 'https://cdn.jsdelivr.net/gh/owner/repo/plugin.js'
+ ),
+ 'https://cdn.jsdelivr.net/gh/owner/repo/plugin.js'
+ )
+ })
+
+ test('leaves a GitHub repository URL alone when it names no file', () => {
+ assert.equal(
+ normalizePluginSourceUrl('https://github.com/owner/repo'),
+ 'https://github.com/owner/repo'
+ )
+ })
+
+ test('rejects a relative path with no origin to resolve against', () => {
+ assert.equal(normalizePluginSourceUrl('plugins/tasks/x/plugin.js'), null)
+ })
+
+ test('rejects a non-http scheme', () => {
+ assert.equal(normalizePluginSourceUrl('file:///etc/passwd'), null)
+ assert.equal(normalizePluginSourceUrl('javascript:alert(1)'), null)
+ })
+
+ test('rejects empty input', () => {
+ assert.equal(normalizePluginSourceUrl(' '), null)
+ })
+})
+
+describe('plugin source byte length', () => {
+ test('counts UTF-8 bytes rather than UTF-16 code units', () => {
+ assert.equal(pluginSourceByteLength('abc'), 3)
+ assert.equal(pluginSourceByteLength('中文'), 6)
+ })
+})
+
+function stubResponse(options: {
+ ok: boolean
+ status?: number
+ body?: string
+ contentLength?: string
+}): Response {
+ const headers = new Headers()
+ if (options.contentLength) {
+ headers.set('content-length', options.contentLength)
+ }
+ return {
+ ok: options.ok,
+ status: options.status ?? (options.ok ? 200 : 404),
+ headers,
+ text: async () => options.body ?? '',
+ } as unknown as Response
+}
+
+describe('browser plugin source fetch', () => {
+ test('returns the response body for a successful fetch', async () => {
+ const source = 'export function manifest() {}'
+ const text = await fetchPluginSourceText(
+ 'https://example.com/plugin.js',
+ async () => stubResponse({ ok: true, body: source })
+ )
+ assert.equal(text, source)
+ })
+
+ test('reports the status when the host answers with an error', async () => {
+ await assert.rejects(
+ fetchPluginSourceText('https://example.com/missing.js', async () =>
+ stubResponse({ ok: false, status: 404 })
+ ),
+ (error: unknown) => {
+ assert.ok(error instanceof PluginSourceFetchError)
+ assert.equal(error.reason, 'not_found')
+ assert.equal(error.status, 404)
+ return true
+ }
+ )
+ })
+
+ test('reports unreachable when the request itself throws', async () => {
+ await assert.rejects(
+ fetchPluginSourceText(
+ 'https://blocked.example.com/plugin.js',
+ async () => {
+ throw new TypeError('Failed to fetch')
+ }
+ ),
+ (error: unknown) => {
+ assert.ok(error instanceof PluginSourceFetchError)
+ assert.equal(error.reason, 'unreachable')
+ return true
+ }
+ )
+ })
+
+ test('rejects a declared content-length above the 1 MiB backend limit before reading the body', async () => {
+ let bodyRead = false
+ await assert.rejects(
+ fetchPluginSourceText('https://example.com/huge.js', async () => {
+ const response = stubResponse({
+ ok: true,
+ contentLength: String(MAX_PLUGIN_SOURCE_BYTES + 1),
+ })
+ return {
+ ...response,
+ headers: response.headers,
+ text: async () => {
+ bodyRead = true
+ return ''
+ },
+ } as unknown as Response
+ }),
+ (error: unknown) => {
+ assert.ok(error instanceof PluginSourceFetchError)
+ assert.equal(error.reason, 'too_large')
+ return true
+ }
+ )
+ assert.equal(bodyRead, false)
+ })
+
+ test('rejects an oversized body when the host declares no content-length', async () => {
+ await assert.rejects(
+ fetchPluginSourceText('https://example.com/huge.js', async () =>
+ stubResponse({
+ ok: true,
+ body: 'x'.repeat(MAX_PLUGIN_SOURCE_BYTES + 1),
+ })
+ ),
+ (error: unknown) => {
+ assert.ok(error instanceof PluginSourceFetchError)
+ assert.equal(error.reason, 'too_large')
+ return true
+ }
+ )
+ })
+
+ test('accepts a body exactly at the limit', async () => {
+ const text = await fetchPluginSourceText(
+ 'https://example.com/limit.js',
+ async () =>
+ stubResponse({ ok: true, body: 'x'.repeat(MAX_PLUGIN_SOURCE_BYTES) })
+ )
+ assert.equal(pluginSourceByteLength(text), MAX_PLUGIN_SOURCE_BYTES)
+ })
+})
diff --git a/web/src/features/task-plugins/__tests__/upload-dialog.test.tsx b/web/src/features/task-plugins/__tests__/upload-dialog.test.tsx
new file mode 100644
index 000000000000..41a209296678
--- /dev/null
+++ b/web/src/features/task-plugins/__tests__/upload-dialog.test.tsx
@@ -0,0 +1,263 @@
+/*
+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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { afterEach, describe, expect, test, vi } from 'vitest'
+
+import { UploadDialog } from '../components/upload-dialog'
+import { MAX_PLUGIN_SOURCE_BYTES } from '../lib/plugin-url'
+
+const uploadTaskPlugin = vi.hoisted(() => vi.fn())
+
+vi.mock('../api', () => ({ uploadTaskPlugin }))
+
+const queryClients: QueryClient[] = []
+
+function renderDialog(open = true) {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, staleTime: Number.POSITIVE_INFINITY },
+ mutations: { retry: false },
+ },
+ })
+ queryClients.push(queryClient)
+ const onOpenChange = vi.fn()
+ const view = render(
+
+
+
+ )
+ return { onOpenChange, queryClient, view }
+}
+
+function sourceEditor() {
+ return screen.getByRole('textbox', { name: 'Plugin source' })
+}
+
+function fileInput() {
+ return screen.getByLabelText('JavaScript file') as HTMLInputElement
+}
+
+/** The dialog chrome also renders an sr-only "Close", so scope to the footer. */
+function footerButton(name: string | RegExp) {
+ const footer = document.querySelector('[data-slot=dialog-footer]')
+ return within(footer as HTMLElement).getByRole('button', { name })
+}
+
+afterEach(() => {
+ for (const queryClient of queryClients) queryClient.clear()
+ queryClients.length = 0
+ vi.unstubAllGlobals()
+})
+
+describe('UploadDialog layout', () => {
+ test('renders the source editor and hides the native file input from view', () => {
+ renderDialog()
+
+ expect(sourceEditor()).toBeInTheDocument()
+ // The unstyled native picker is the element that made this dialog look
+ // foreign; it must stay in the DOM (labelled) but never be the visible
+ // control.
+ expect(fileInput()).toHaveClass('sr-only')
+ expect(
+ screen.getByRole('button', { name: 'Choose file' })
+ ).toBeInTheDocument()
+ expect(screen.getByText('0 bytes')).toBeInTheDocument()
+ })
+
+ test('scrolls its body instead of growing past the viewport', () => {
+ renderDialog()
+
+ const content = document.querySelector('[data-slot=dialog-content]')
+ const body = content?.querySelector(':scope > div:nth-child(2)')
+ expect(content).toHaveClass('max-h-[calc(100vh-2rem)]')
+ expect(body).toHaveClass('overflow-y-auto')
+ })
+
+ test('does not steal focus into the mid-dialog source editor on open', () => {
+ renderDialog()
+
+ expect(document.querySelector('.cm-content')).not.toHaveFocus()
+ })
+})
+
+describe('UploadDialog file selection', () => {
+ test('fills the source editor and names the chosen file', async () => {
+ const user = userEvent.setup()
+ renderDialog()
+
+ await user.upload(
+ fileInput(),
+ new File(['export const meta = {}'], 'plugin.js', {
+ type: 'text/javascript',
+ })
+ )
+
+ expect(await screen.findByText('plugin.js')).toBeInTheDocument()
+ await waitFor(() =>
+ expect(document.querySelector('.cm-content')?.textContent).toContain(
+ 'export const meta = {}'
+ )
+ )
+ expect(
+ screen.getByRole('button', { name: 'Choose another file' })
+ ).toBeInTheDocument()
+ })
+
+ test('rejects a file over the 1 MiB limit without touching the source', async () => {
+ const user = userEvent.setup()
+ renderDialog()
+
+ const oversized = new File(['x'], 'huge.js', { type: 'text/javascript' })
+ Object.defineProperty(oversized, 'size', {
+ value: MAX_PLUGIN_SOURCE_BYTES + 1,
+ })
+ await user.upload(fileInput(), oversized)
+
+ expect(
+ await screen.findByText('Plugin source exceeds the 1 MiB limit.')
+ ).toBeInTheDocument()
+ expect(screen.queryByText('huge.js')).toBeNull()
+ expect(footerButton('Upload')).toBeDisabled()
+ })
+})
+
+describe('UploadDialog URL import', () => {
+ test('keeps Fetch disabled until a URL is typed, then fills the source', async () => {
+ const user = userEvent.setup()
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response('const fetched = 1', { status: 200 }))
+ )
+ renderDialog()
+
+ expect(screen.getByRole('button', { name: 'Fetch' })).toBeDisabled()
+
+ await user.type(
+ screen.getByLabelText('Import from URL'),
+ 'https://example.com/plugin.js'
+ )
+ expect(screen.getByRole('button', { name: 'Fetch' })).toBeEnabled()
+ await user.click(screen.getByRole('button', { name: 'Fetch' }))
+
+ await waitFor(() =>
+ expect(document.querySelector('.cm-content')?.textContent).toContain(
+ 'const fetched = 1'
+ )
+ )
+ // Fetching must never upload on its own.
+ expect(uploadTaskPlugin).not.toHaveBeenCalled()
+ })
+
+ test('fetches on Enter and marks the field invalid when the URL is not absolute', async () => {
+ const user = userEvent.setup()
+ renderDialog()
+
+ const urlField = screen.getByLabelText('Import from URL')
+ await user.type(urlField, 'plugin.js{Enter}')
+
+ expect(
+ await screen.findByText('Enter an absolute http(s) URL.')
+ ).toBeInTheDocument()
+ expect(urlField).toHaveAttribute('aria-invalid', 'true')
+ expect(document.querySelector('[data-slot=field-error]')).toHaveAttribute(
+ 'role',
+ 'alert'
+ )
+ })
+})
+
+describe('UploadDialog upload lifecycle', () => {
+ test('disables Upload while the source is empty and shows a pending label', async () => {
+ const user = userEvent.setup()
+ let resolveUpload: (value: unknown) => void = () => undefined
+ uploadTaskPlugin.mockImplementation(
+ () => new Promise((resolve) => (resolveUpload = resolve))
+ )
+ renderDialog()
+
+ expect(footerButton('Upload')).toBeDisabled()
+
+ await user.upload(
+ fileInput(),
+ new File(['const a = 1'], 'plugin.js', { type: 'text/javascript' })
+ )
+ const uploadButton = await waitFor(() => {
+ const button = footerButton('Upload')
+ expect(button).toBeEnabled()
+ return button
+ })
+
+ await user.click(uploadButton)
+ await waitFor(() => expect(footerButton(/Uploading/)).toBeDisabled())
+
+ resolveUpload({
+ source: 'const a = 1',
+ meta: { key: 'demo', name: 'Demo', version: '1.0.0', apiVersion: 1 },
+ })
+ expect(
+ await screen.findByText('Parsed plugin metadata')
+ ).toBeInTheDocument()
+ })
+
+ test('surfaces an upload rejection verbatim', async () => {
+ const user = userEvent.setup()
+ uploadTaskPlugin.mockRejectedValue(new Error('key conflicts with `demo`'))
+ renderDialog()
+
+ await user.upload(
+ fileInput(),
+ new File(['const a = 1'], 'plugin.js', { type: 'text/javascript' })
+ )
+ await waitFor(() => expect(footerButton('Upload')).toBeEnabled())
+ await user.click(footerButton('Upload'))
+
+ expect(
+ await screen.findByText('key conflicts with `demo`')
+ ).toBeInTheDocument()
+ })
+
+ test('clears every field when the dialog is closed', async () => {
+ const user = userEvent.setup()
+ const { onOpenChange, queryClient, view } = renderDialog()
+
+ await user.upload(
+ fileInput(),
+ new File(['const a = 1'], 'plugin.js', { type: 'text/javascript' })
+ )
+ await user.type(screen.getByLabelText('Import from URL'), 'plugin.js')
+ await screen.findByText('plugin.js')
+
+ await user.click(footerButton('Close'))
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+
+ view.rerender(
+
+
+
+ )
+
+ expect(screen.getByLabelText('Import from URL')).toHaveValue('')
+ expect(screen.getByText('0 bytes')).toBeInTheDocument()
+ expect(
+ screen.getByRole('button', { name: 'Choose file' })
+ ).toBeInTheDocument()
+ })
+})
diff --git a/web/src/features/task-plugins/__tests__/usage-schema-table.test.tsx b/web/src/features/task-plugins/__tests__/usage-schema-table.test.tsx
new file mode 100644
index 000000000000..8cda3397a48f
--- /dev/null
+++ b/web/src/features/task-plugins/__tests__/usage-schema-table.test.tsx
@@ -0,0 +1,82 @@
+/*
+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 { render, screen } from '@testing-library/react'
+import { describe, expect, test } from 'vitest'
+
+import type { BillingUsageSchema } from '@/features/pricing/types'
+
+import { UsageSchemaTable } from '../components/usage-schema-table'
+
+const schema: BillingUsageSchema = {
+ duration: {
+ type: 'number',
+ unit: 'second',
+ description: { en: 'Video duration in seconds.', zh: '视频时长(秒)。' },
+ },
+ resolution: {
+ enum: ['480p', '720p', '1080p', '4k'],
+ description: { en: 'Output resolution.' },
+ },
+}
+
+describe('UsageSchemaTable layout', () => {
+ test('given a usage schema, every declaration renders as a five-column table row', () => {
+ render( )
+
+ for (const header of [
+ 'Name',
+ 'Type',
+ 'Unit',
+ 'Enum values',
+ 'Description',
+ ]) {
+ expect(
+ screen.getByRole('columnheader', { name: header })
+ ).toBeInTheDocument()
+ }
+ expect(
+ screen.getByRole('cell', { name: 'Video duration in seconds.' })
+ ).toBeInTheDocument()
+ expect(
+ screen.getByRole('cell', { name: '480p, 720p, 1080p, 4k' })
+ ).toBeInTheDocument()
+ })
+
+ test('given a description without an English entry, the available locale text still renders', () => {
+ render(
+
+ )
+
+ expect(
+ screen.getByRole('cell', { name: '仅中文说明。' })
+ ).toBeInTheDocument()
+ })
+
+ test('given a field without a unit, the unit cell falls back to an em dash', () => {
+ render( )
+
+ const resolutionRow = screen.getByRole('cell', {
+ name: 'resolution',
+ }).parentElement
+ expect(resolutionRow).not.toBeNull()
+ expect(resolutionRow?.textContent).toContain('—')
+ })
+})
diff --git a/web/src/features/task-plugins/api.ts b/web/src/features/task-plugins/api.ts
new file mode 100644
index 000000000000..e9ff7646bb08
--- /dev/null
+++ b/web/src/features/task-plugins/api.ts
@@ -0,0 +1,191 @@
+/*
+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 { api, type ApiRequestConfig } from '@/lib/api'
+
+import type {
+ ApiResponse,
+ MarketplaceSource,
+ TaskPluginDetail,
+ TaskPluginListItem,
+ TaskPluginRecord,
+ TaskPluginUsage,
+ TaskPluginDryRunRequest,
+} from './types'
+
+const mutationConfig: ApiRequestConfig = {
+ skipBusinessError: true,
+ skipErrorHandler: true,
+}
+
+export async function dryRunTaskPlugin(
+ key: string,
+ request: TaskPluginDryRunRequest
+) {
+ const response = await api.post>(
+ `/api/plugin/task/${encodeURIComponent(key)}/dryrun`,
+ request,
+ mutationConfig
+ )
+ return requireSuccess(response.data)
+}
+
+export class TaskPluginUsageError extends Error {
+ constructor(
+ message: string,
+ public usage: TaskPluginUsage
+ ) {
+ super(message)
+ }
+}
+
+function requireSuccess(response: ApiResponse): T {
+ if (!response.success) {
+ const data = response.data as TaskPluginUsage | undefined
+ if (data?.channels && typeof data.in_flight_count === 'number') {
+ throw new TaskPluginUsageError(response.message, data)
+ }
+ throw new Error(response.message)
+ }
+ return response.data
+}
+
+export async function listTaskPlugins() {
+ const response =
+ await api.get>('/api/plugin/task')
+ return requireSuccess(response.data)
+}
+
+export async function getTaskPlugin(key: string, version?: string) {
+ const response = await api.get>(
+ `/api/plugin/task/${encodeURIComponent(key)}`,
+ { params: version ? { version } : undefined }
+ )
+ return requireSuccess(response.data)
+}
+
+export async function getTaskPluginVersions(key: string) {
+ const response = await api.get>(
+ `/api/plugin/task/${encodeURIComponent(key)}/versions`
+ )
+ return requireSuccess(response.data)
+}
+
+export async function uploadTaskPlugin(source: string, remark: string) {
+ const response = await api.post>(
+ '/api/plugin/task',
+ { source, remark },
+ mutationConfig
+ )
+ return requireSuccess(response.data)
+}
+
+/**
+ * Installs a plugin fetched from a marketplace source. `sourceSha256` lets the
+ * server re-verify the bytes it received against the index hash, and `force` is
+ * deliberately never sent: a routing conflict must reject so the administrator
+ * resolves it on the task plugins page instead of being silently overridden.
+ */
+export async function installMarketplacePlugin(request: {
+ source: string
+ sourceSha256?: string
+ remark: string
+}) {
+ const response = await api.post>(
+ '/api/plugin/task',
+ {
+ source: request.source,
+ sourceSha256: request.sourceSha256,
+ enabled: true,
+ remark: request.remark,
+ },
+ mutationConfig
+ )
+ return requireSuccess(response.data)
+}
+
+export async function listMarketplaceSources() {
+ const response = await api.get>(
+ '/api/plugin/task/marketplace/sources'
+ )
+ return requireSuccess(response.data) ?? []
+}
+
+export async function updateMarketplaceSources(sources: MarketplaceSource[]) {
+ const response = await api.put>(
+ '/api/plugin/task/marketplace/sources',
+ sources,
+ mutationConfig
+ )
+ return requireSuccess(response.data) ?? []
+}
+
+export async function activateTaskPlugin(key: string, version: string) {
+ const response = await api.post>(
+ `/api/plugin/task/${encodeURIComponent(key)}/activate`,
+ { version },
+ mutationConfig
+ )
+ requireSuccess(response.data)
+}
+
+export async function setTaskPluginStatus(
+ key: string,
+ enabled: boolean,
+ options?: { cascade?: boolean; force?: boolean }
+) {
+ const response = await api.post>(
+ `/api/plugin/task/${encodeURIComponent(key)}/status`,
+ { enabled },
+ { ...mutationConfig, params: options }
+ )
+ requireSuccess(response.data)
+}
+
+export async function deleteTaskPluginVersion(
+ key: string,
+ version: string,
+ force = false
+) {
+ const response = await api.delete>(
+ `/api/plugin/task/${encodeURIComponent(key)}/versions/${encodeURIComponent(version)}`,
+ { ...mutationConfig, params: force ? { force: true } : undefined }
+ )
+ requireSuccess(response.data)
+}
+
+export async function getTaskPluginEnabledOption() {
+ const response =
+ await api.get>>(
+ '/api/option/'
+ )
+ const options = requireSuccess(response.data)
+ return (
+ options.find((option) => option.key === 'TaskPluginEnabled')
+ ?.value === 'true'
+ )
+}
+
+export async function setTaskPluginEnabledOption(enabled: boolean) {
+ const response = await api.put>(
+ '/api/option/',
+ { key: 'TaskPluginEnabled', value: String(enabled) },
+ mutationConfig
+ )
+ requireSuccess(response.data)
+}
diff --git a/web/src/features/task-plugins/components/javascript-viewer.tsx b/web/src/features/task-plugins/components/javascript-viewer.tsx
new file mode 100644
index 000000000000..080b3b75baba
--- /dev/null
+++ b/web/src/features/task-plugins/components/javascript-viewer.tsx
@@ -0,0 +1,60 @@
+/*
+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 { javascript } from '@codemirror/lang-javascript'
+import { EditorState } from '@codemirror/state'
+import { EditorView, lineNumbers } from '@codemirror/view'
+import { useEffect, useRef } from 'react'
+
+type JavaScriptViewerProps = {
+ value: string
+ className?: string
+}
+
+export function JavaScriptViewer(props: JavaScriptViewerProps) {
+ const containerRef = useRef(null)
+ const viewRef = useRef(null)
+
+ useEffect(() => {
+ if (!containerRef.current) return
+ const view = new EditorView({
+ parent: containerRef.current,
+ state: EditorState.create({
+ doc: props.value,
+ extensions: [
+ lineNumbers(),
+ javascript(),
+ EditorState.readOnly.of(true),
+ EditorView.editable.of(false),
+ EditorView.lineWrapping,
+ EditorView.theme({
+ '&': { height: '100%', backgroundColor: 'transparent' },
+ '.cm-scroller': { overflow: 'auto', fontFamily: 'monospace' },
+ }),
+ ],
+ }),
+ })
+ viewRef.current = view
+ return () => {
+ view.destroy()
+ viewRef.current = null
+ }
+ }, [props.value])
+
+ return
+}
diff --git a/web/src/features/task-plugins/components/marketplace-capabilities.tsx b/web/src/features/task-plugins/components/marketplace-capabilities.tsx
new file mode 100644
index 000000000000..a3c5c79235c4
--- /dev/null
+++ b/web/src/features/task-plugins/components/marketplace-capabilities.tsx
@@ -0,0 +1,90 @@
+/*
+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 { useTranslation } from 'react-i18next'
+
+import { getChannelTypeLabel } from '@/features/channels/lib'
+
+import type { MarketplaceIndexVersion, MarketplacePlugin } from '../types'
+
+type MarketplaceCapabilitiesProps = {
+ plugin: MarketplacePlugin
+ version?: MarketplaceIndexVersion
+}
+
+/**
+ * The sensitive declarations an administrator needs before installing: where the
+ * plugin may send requests, which channel types it can bind, how it
+ * authenticates, and whether the source pins an integrity hash. Reading these is
+ * far more effective than expecting a full source review, so they are surfaced
+ * above the source viewer rather than buried in it.
+ */
+export function MarketplaceCapabilities(props: MarketplaceCapabilitiesProps) {
+ const { t } = useTranslation()
+ const allowedHosts = props.version?.allowedHosts
+ const channelTypes = props.plugin.channelTypes
+
+ return (
+
+
{t('Declared capabilities')}
+
+
+
+ {t('Allowed hosts')}
+
+
+ {allowedHosts?.length ? allowedHosts.join(', ') : t('Not declared')}
+
+
+
+
+ {t('Channel types')}
+
+
+ {channelTypes?.length
+ ? channelTypes
+ .map((type) => `${getChannelTypeLabel(type)} (#${type})`)
+ .join(', ')
+ : t('Not declared')}
+
+
+
+
+ {t('Authentication')}
+
+
+ {props.version?.auth || t('Not declared')}
+
+
+
+
+ {t('Integrity hash')}
+
+
+ {props.version?.sha256 ?? t('Not provided by this source')}
+
+
+
+
+ {t(
+ 'These values come from the source index and are shown for review only. The gateway admits the plugin based on the metadata compiled from its source.'
+ )}
+
+
+ )
+}
diff --git a/web/src/features/task-plugins/components/marketplace-install-dialog.tsx b/web/src/features/task-plugins/components/marketplace-install-dialog.tsx
new file mode 100644
index 000000000000..f952f9733bbf
--- /dev/null
+++ b/web/src/features/task-plugins/components/marketplace-install-dialog.tsx
@@ -0,0 +1,325 @@
+/*
+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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { AlertTriangle, Download } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Spinner } from '@/components/ui/spinner'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+
+import { getTaskPlugin, installMarketplacePlugin } from '../api'
+import {
+ findMarketplaceVersion,
+ resolvePluginSourceUrl,
+ type InstallState,
+} from '../lib/marketplace'
+import {
+ computeSourceSha256,
+ fetchPluginSourceText,
+ PluginSourceFetchError,
+} from '../lib/plugin-url'
+import type { MarketplacePlugin, MarketplaceSource } from '../types'
+import { JavaScriptViewer } from './javascript-viewer'
+import { MarketplaceCapabilities } from './marketplace-capabilities'
+import { SourceDiff } from './source-diff'
+
+export type MarketplaceInstallTarget = {
+ source: MarketplaceSource
+ plugin: MarketplacePlugin
+ version: string
+ installState: InstallState
+}
+
+type MarketplaceInstallDialogProps = {
+ target: MarketplaceInstallTarget | null
+ onOpenChange: (open: boolean) => void
+}
+
+export function MarketplaceInstallDialog(props: MarketplaceInstallDialogProps) {
+ const { t } = useTranslation()
+ const queryClient = useQueryClient()
+ const target = props.target
+ const pluginKey = target?.plugin.key ?? ''
+ const entry = target
+ ? findMarketplaceVersion(target.plugin, target.version)
+ : undefined
+ const isUpgrade = target
+ ? target.installState.status !== 'not_installed'
+ : false
+
+ const sourceQuery = useQuery({
+ queryKey: [
+ 'task-plugin-marketplace-source',
+ target?.source.index_url,
+ pluginKey,
+ target?.version,
+ ],
+ enabled: Boolean(target && entry),
+ retry: false,
+ queryFn: async () => {
+ if (!target || !entry) throw new Error('missing marketplace entry')
+ const url = resolvePluginSourceUrl(target.source.index_url, entry.path)
+ if (!url) {
+ throw new Error(
+ t('This plugin path does not resolve within the source repository.')
+ )
+ }
+ const text = await fetchPluginSourceText(url)
+ // Computed for display only. The upload request carries the index hash and
+ // the server re-hashes what it received, so a tampered browser cannot pass
+ // a mismatched source off as verified.
+ const digest = await computeSourceSha256(text)
+ return { url, text, digest }
+ },
+ })
+
+ // The installed source is the diff baseline for an upgrade.
+ const installedQuery = useQuery({
+ queryKey: ['task-plugin', pluginKey],
+ queryFn: () => getTaskPlugin(pluginKey),
+ enabled: Boolean(target) && isUpgrade,
+ })
+
+ const installMutation = useMutation({
+ mutationFn: () => {
+ if (!target || !sourceQuery.data) throw new Error('source not fetched')
+ return installMarketplacePlugin({
+ source: sourceQuery.data.text,
+ sourceSha256: entry?.sha256,
+ remark: `${target.source.name} v${target.version}`,
+ })
+ },
+ onSuccess: (detail) => {
+ queryClient.invalidateQueries({ queryKey: ['task-plugins'] })
+ queryClient.invalidateQueries({ queryKey: ['task-plugin', pluginKey] })
+ queryClient.invalidateQueries({
+ queryKey: ['task-plugin-versions', pluginKey],
+ })
+ toast.success(
+ t('Installed {{name}} v{{version}}', {
+ name: detail.meta.name,
+ version: detail.meta.version,
+ })
+ )
+ props.onOpenChange(false)
+ },
+ })
+
+ const fetchError = sourceQuery.error
+ let fetchErrorMessage = ''
+ if (fetchError instanceof PluginSourceFetchError) {
+ fetchErrorMessage =
+ fetchError.reason === 'too_large'
+ ? t('Plugin source exceeds the 1 MiB limit.')
+ : t(
+ 'Could not fetch the plugin source from this browser. The host may block cross-origin requests or be unreachable.'
+ )
+ } else if (fetchError) {
+ fetchErrorMessage = fetchError.message
+ }
+
+ const digestMismatch = Boolean(
+ entry?.sha256 &&
+ sourceQuery.data?.digest &&
+ sourceQuery.data.digest.toLowerCase() !== entry.sha256.toLowerCase()
+ )
+
+ let confirmLabel = t('Install and enable')
+ if (installMutation.isPending) confirmLabel = t('Installing...')
+ else if (isUpgrade) confirmLabel = t('Upgrade and enable')
+
+ // Rendering the body needs a target; the dialog itself is driven by its
+ // presence, so an absent target simply means the dialog is closed.
+ if (!target) return null
+
+ return (
+
+
+
+
+ {isUpgrade
+ ? t('Upgrade {{name}}', { name: target.plugin.name })
+ : t('Install {{name}}', { name: target.plugin.name })}
+
+
+ {t('{{key}} · version {{version}} · from {{source}}', {
+ key: pluginKey,
+ version: target.version,
+ source: target.source.name,
+ })}
+
+
+
+
+
+ {t('Third-party plugin risk')}
+
+ {t(
+ 'Uploading a plugin is an administrator-level trust decision. A plugin can access channel credentials and shape upstream requests. Review its source and diff before activation.'
+ )}
+
+
+
+
+
+ {!entry?.sha256 && (
+
+ {t('No integrity hash')}
+
+ {t(
+ 'This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.'
+ )}
+
+
+ )}
+
+ {digestMismatch && (
+
+
+ {t('Integrity check failed')}
+
+ {t(
+ 'The downloaded source does not match the sha256 declared in the index. Do not install it.'
+ )}
+
+
+ )}
+
+ {sourceQuery.isLoading && (
+
+
+ {t('Fetching plugin source...')}
+
+ )}
+
+ {fetchErrorMessage && (
+
+ {fetchErrorMessage}
+
+ )}
+
+ {sourceQuery.data && (
+
+
+ {t('Source')}
+ {isUpgrade && (
+ {t('Source diff')}
+ )}
+
+
+
+
+ {isUpgrade && (
+
+ {installedQuery.isLoading && (
+
+
+ {t('Loading installed source...')}
+
+ )}
+ {installedQuery.data && (
+ <>
+
+ {t('Installed v{{from}} → marketplace v{{to}}', {
+ from: installedQuery.data.meta.version,
+ to: target.version,
+ })}
+
+
+ >
+ )}
+ {installedQuery.error && (
+
+ {installedQuery.error.message}
+
+ )}
+
+ )}
+
+ )}
+
+ {target.installState.status === 'diverged' && (
+
+
+ {t('Installed version is not in this index')}
+
+
+ {t(
+ 'v{{installed}} is installed but this source does not list it. Installing replaces it with v{{target}}.',
+ {
+ installed: target.installState.installedVersion,
+ target: target.version,
+ }
+ )}
+
+
+ )}
+
+ {installMutation.error && (
+
+
+ {t('The gateway rejected this plugin')}
+
+ {/* Verbatim: preflight rejections name the conflicting plugin. */}
+
+ {installMutation.error.message}
+
+
+ {t(
+ 'Marketplace installs never force past a conflict. Resolve it on the task plugins page, then install again.'
+ )}
+
+
+ )}
+
+
+ props.onOpenChange(false)}>
+ {t('Cancel')}
+
+ installMutation.mutate()}
+ >
+
+ {confirmLabel}
+
+
+
+
+ )
+}
diff --git a/web/src/features/task-plugins/components/marketplace-panel.tsx b/web/src/features/task-plugins/components/marketplace-panel.tsx
new file mode 100644
index 000000000000..1e4ea20151be
--- /dev/null
+++ b/web/src/features/task-plugins/components/marketplace-panel.tsx
@@ -0,0 +1,281 @@
+/*
+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 { useQuery } from '@tanstack/react-query'
+import { RefreshCw, Settings2, TriangleAlert } from 'lucide-react'
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ Empty,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyTitle,
+} from '@/components/ui/empty'
+import { Skeleton } from '@/components/ui/skeleton'
+import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
+
+import { listMarketplaceSources, listTaskPlugins } from '../api'
+import {
+ deriveInstallState,
+ indexHasIntegrityHashes,
+ isDefaultMarketplaceSource,
+ parseMarketplaceIndex,
+} from '../lib/marketplace'
+import type { MarketplaceIndex, MarketplaceSource } from '../types'
+import {
+ MarketplaceInstallDialog,
+ type MarketplaceInstallTarget,
+} from './marketplace-install-dialog'
+import { MarketplacePluginCard } from './marketplace-plugin-card'
+import { MarketplaceSourcesDialog } from './marketplace-sources-dialog'
+
+export function MarketplacePanel() {
+ const { t } = useTranslation()
+ const [sourcesOpen, setSourcesOpen] = useState(false)
+ const [selectedSourceUrl, setSelectedSourceUrl] = useState('')
+ const [installTarget, setInstallTarget] =
+ useState(null)
+
+ const sourcesQuery = useQuery({
+ queryKey: ['task-plugin-marketplace-sources'],
+ queryFn: listMarketplaceSources,
+ })
+ const installedQuery = useQuery({
+ queryKey: ['task-plugins'],
+ queryFn: listTaskPlugins,
+ })
+ const sources = sourcesQuery.data ?? []
+ const selectedSource =
+ sources.find((source) => source.index_url === selectedSourceUrl) ??
+ sources[0]
+
+ // Only the selected source is requested. Alternate sources stay idle until
+ // the administrator explicitly switches to them.
+ const indexQuery = useQuery({
+ queryKey: ['task-plugin-marketplace', selectedSource?.index_url],
+ enabled: Boolean(selectedSource),
+ retry: false,
+ queryFn: async (): Promise => {
+ if (!selectedSource) throw new Error('marketplace source is not selected')
+ const response = await fetch(selectedSource.index_url)
+ if (!response.ok) {
+ throw new Error(
+ t('Index request failed with HTTP {{status}}', {
+ status: response.status,
+ })
+ )
+ }
+ return parseMarketplaceIndex(await response.json())
+ },
+ })
+
+ return (
+ <>
+
+
+
+ {t(
+ 'Plugin indexes are fetched by your browser. Installing runs the same review and admission pipeline as a manual upload.'
+ )}
+
+
+ void indexQuery.refetch()}
+ >
+
+ {t('Refresh')}
+
+ setSourcesOpen(true)}
+ >
+
+ {t('Manage sources')}
+
+
+
+
+ {sourcesQuery.isLoading &&
}
+
+ {!sourcesQuery.isLoading && sources.length === 0 && (
+
+
+ {t('No marketplace sources configured.')}
+
+ {t('Add an index URL to browse installable plugins.')}
+
+
+ setSourcesOpen(true)}>
+ {t('Manage sources')}
+
+
+ )}
+
+ {sources.length > 1 && selectedSource && (
+
{
+ const nextSourceUrl = value.find(
+ (item) => item !== selectedSource.index_url
+ )
+ if (nextSourceUrl) setSelectedSourceUrl(nextSourceUrl)
+ }}
+ aria-label={t('Marketplace sources')}
+ variant='outline'
+ size='sm'
+ className='max-w-full overflow-x-auto'
+ >
+ {sources.map((source) => (
+
+ {t(source.name)}
+
+ ))}
+
+ )}
+
+ {selectedSource && (
+
+ )}
+
+
+
+ {
+ if (!open) setInstallTarget(null)
+ }}
+ />
+ >
+ )
+}
+
+type MarketplaceSourceSectionProps = {
+ source: MarketplaceSource
+ index?: MarketplaceIndex
+ isLoading: boolean
+ error: Error | null
+ installed: Awaited>
+ onInstall: (target: MarketplaceInstallTarget) => void
+}
+
+function MarketplaceSourceSection(props: MarketplaceSourceSectionProps) {
+ const { t } = useTranslation()
+ const isOfficial = isDefaultMarketplaceSource(props.source.index_url)
+ const missingHashes = props.index
+ ? !indexHasIntegrityHashes(props.index)
+ : false
+
+ return (
+
+
+
+ {props.index?.name || props.source.name}
+
+ {isOfficial ? (
+ {t('Official')}
+ ) : (
+
+ {t('Third-party — use at your own risk')}
+
+ )}
+ {missingHashes && (
+
+
+ {t('No integrity verification')}
+
+ )}
+
+
+ {props.isLoading && (
+
+
+
+
+
+ )}
+
+ {props.error && (
+
+
+ {t('Could not load this source')}
+
+ {t(
+ 'The index could not be fetched or parsed: {{message}}. The host may block cross-origin requests.',
+ { message: props.error.message }
+ )}
+
+
+ )}
+
+ {props.index && props.index.plugins.length === 0 && (
+
+ {t('This source lists no installable task plugins.')}
+
+ )}
+
+ {props.index && props.index.plugins.length > 0 && (
+
+ {props.index.plugins.map((plugin) => {
+ const installState = deriveInstallState(plugin, props.installed)
+ return (
+ item.meta.key === plugin.key
+ )}
+ onInstall={() =>
+ props.onInstall({
+ source: props.source,
+ plugin,
+ version: plugin.latest,
+ installState,
+ })
+ }
+ />
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/web/src/features/task-plugins/components/marketplace-plugin-card.tsx b/web/src/features/task-plugins/components/marketplace-plugin-card.tsx
new file mode 100644
index 000000000000..8361417ddf6b
--- /dev/null
+++ b/web/src/features/task-plugins/components/marketplace-plugin-card.tsx
@@ -0,0 +1,173 @@
+/*
+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 {
+ ArrowUpCircle,
+ CheckCircle2,
+ Download,
+ TriangleAlert,
+} from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { getChannelTypeLabel } from '@/features/channels/lib'
+import { resolveLocalizedText } from '@/lib/localized-text'
+
+import { findMarketplaceVersion, type InstallState } from '../lib/marketplace'
+import type { MarketplacePlugin, TaskPluginListItem } from '../types'
+import { PluginIcon } from './plugin-icon'
+
+type MarketplacePluginCardProps = {
+ plugin: MarketplacePlugin
+ installState: InstallState
+ installed?: TaskPluginListItem
+ onInstall: () => void
+}
+
+export function MarketplacePluginCard(props: MarketplacePluginCardProps) {
+ const { t, i18n } = useTranslation()
+ const plugin = props.plugin
+ const description = resolveLocalizedText(plugin.description, i18n.language)
+ const channelTypes = plugin.channelTypes ?? []
+ const latestEntry = findMarketplaceVersion(plugin, plugin.latest)
+ const labelClass = 'text-muted-foreground text-[11px] font-medium select-none'
+
+ return (
+
+
+
+
+
+
+
+
{plugin.name}
+
+ {plugin.key}
+
+
+
+
+
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+
+
+
{t('Latest version')}
+
{plugin.latest}
+
+
+
{t('Channel type')}
+
+ {channelTypes.length > 0
+ ? getChannelTypeLabel(channelTypes[0])
+ : '—'}
+
+
+
+
{t('Models')}
+
{plugin.models?.length ?? 0}
+
+
+
+ {props.installed?.factory_meta && (
+
+ {t('Versions')} {' '}
+
+ {t('Built-in v{{factory}} / marketplace v{{market}}', {
+ factory: props.installed.factory_meta.version,
+ market: plugin.latest,
+ })}
+
+
+ )}
+
+ {!latestEntry?.sha256 && (
+
+
+ {t('No integrity hash')}
+
+ )}
+
+
+
+
+ {getActionLabel(props.installState, t)}
+
+
+
+ )
+}
+
+function InstallStateBadge({ state }: { state: InstallState }) {
+ const { t } = useTranslation()
+ if (state.status === 'not_installed') {
+ return {t('Not installed')}
+ }
+ if (state.status === 'up_to_date') {
+ return (
+
+
+ {t('Up to date')}
+
+ )
+ }
+ if (state.status === 'upgradable') {
+ return (
+
+
+
+ v{state.installedVersion} → v{state.latestVersion}
+
+
+ )
+ }
+ return (
+
+ {t('Installed v{{installed}} not listed', {
+ installed: state.installedVersion,
+ })}
+
+ )
+}
+
+function getActionLabel(
+ state: InstallState,
+ t: (key: string, options?: Record) => string
+): string {
+ if (state.status === 'not_installed') return t('Install')
+ if (state.status === 'up_to_date') return t('Reinstall latest')
+ return t('Review and upgrade')
+}
diff --git a/web/src/features/task-plugins/components/marketplace-sources-dialog.tsx b/web/src/features/task-plugins/components/marketplace-sources-dialog.tsx
new file mode 100644
index 000000000000..7578ce145432
--- /dev/null
+++ b/web/src/features/task-plugins/components/marketplace-sources-dialog.tsx
@@ -0,0 +1,214 @@
+/*
+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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { Plus, Trash2 } from 'lucide-react'
+import { useEffect, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+
+import { listMarketplaceSources, updateMarketplaceSources } from '../api'
+import { isDefaultMarketplaceSource } from '../lib/marketplace'
+import type { MarketplaceSource } from '../types'
+
+type MarketplaceSourcesDialogProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+/**
+ * Rows carry a client-only identity so React keys survive edits and removals.
+ * Keying by position would hand a deleted row's input state to its successor,
+ * and an index URL is not unique until the administrator finishes typing it.
+ */
+type DraftRow = MarketplaceSource & { rowId: string }
+
+export function MarketplaceSourcesDialog(props: MarketplaceSourcesDialogProps) {
+ const { t } = useTranslation()
+ const queryClient = useQueryClient()
+ const [draft, setDraft] = useState([])
+ const nextRowId = useRef(0)
+ const makeRowId = () => {
+ nextRowId.current += 1
+ return `row-${nextRowId.current}`
+ }
+ const sourcesQuery = useQuery({
+ queryKey: ['task-plugin-marketplace-sources'],
+ queryFn: listMarketplaceSources,
+ enabled: props.open,
+ })
+
+ // The dialog edits a local copy so a half-typed row is never pushed to the
+ // server; it is re-seeded whenever the dialog opens with fresh server data.
+ useEffect(() => {
+ if (props.open && sourcesQuery.data) {
+ setDraft(
+ sourcesQuery.data.map((source) => ({ ...source, rowId: makeRowId() }))
+ )
+ }
+ }, [props.open, sourcesQuery.data])
+
+ const saveMutation = useMutation({
+ mutationFn: updateMarketplaceSources,
+ onSuccess: (saved) => {
+ queryClient.setQueryData(['task-plugin-marketplace-sources'], saved)
+ queryClient.invalidateQueries({ queryKey: ['task-plugin-marketplace'] })
+ toast.success(t('Marketplace sources updated'))
+ props.onOpenChange(false)
+ },
+ onError: (error) => toast.error(error.message),
+ })
+
+ const updateRow = (index: number, patch: Partial) => {
+ setDraft((rows) =>
+ rows.map((row, position) =>
+ position === index ? { ...row, ...patch } : row
+ )
+ )
+ }
+
+ const invalidRow = draft.some(
+ (row) => !row.name.trim() || !row.index_url.trim()
+ )
+
+ return (
+
+
+
+ {t('Marketplace sources')}
+
+ {t(
+ 'Each source serves an index.json listing installable plugins. Indexes are fetched by your browser; the gateway makes no outbound requests.'
+ )}
+
+
+
+ {draft.length === 0 && (
+
+ {t('No marketplace sources configured.')}
+
+ )}
+ {draft.map((row, index) => (
+
+
+
+ {t('Source name')}
+
+
+ {isDefaultMarketplaceSource(row.index_url) ? (
+ {t('Official')}
+ ) : (
+
+ {t('Third-party — use at your own risk')}
+
+ )}
+
+ setDraft((rows) =>
+ rows.filter((_, position) => position !== index)
+ )
+ }
+ >
+
+
+
+
+
+ updateRow(index, { name: event.target.value })
+ }
+ />
+
+ {t('Index URL')}
+
+
+ updateRow(index, { index_url: event.target.value })
+ }
+ />
+
+ ))}
+
+ setDraft((rows) => [
+ ...rows,
+ { name: '', index_url: '', rowId: makeRowId() },
+ ])
+ }
+ >
+
+ {t('Add source')}
+
+
+ {t('Third-party source risk')}
+
+ {t(
+ 'Anyone can publish an index. A plugin installed from a third-party source has the same access as one you upload by hand: review its source before installing.'
+ )}
+
+
+
+
+ props.onOpenChange(false)}>
+ {t('Cancel')}
+
+
+ saveMutation.mutate(
+ draft.map((row) => ({
+ name: row.name.trim(),
+ index_url: row.index_url.trim(),
+ }))
+ )
+ }
+ >
+ {saveMutation.isPending ? t('Saving...') : t('Save')}
+
+
+
+
+ )
+}
diff --git a/web/src/features/task-plugins/components/plugin-card.tsx b/web/src/features/task-plugins/components/plugin-card.tsx
new file mode 100644
index 000000000000..4805a1396bb8
--- /dev/null
+++ b/web/src/features/task-plugins/components/plugin-card.tsx
@@ -0,0 +1,159 @@
+/*
+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 { flexRender, type Row } from '@tanstack/react-table'
+import { memo } from 'react'
+import { useTranslation } from 'react-i18next'
+
+import { Badge } from '@/components/ui/badge'
+import { resolveLocalizedText } from '@/lib/localized-text'
+
+import type { TaskPluginListItem } from '../types'
+import { PluginIcon } from './plugin-icon'
+
+/**
+ * A card is one grid cell, so the model list has to stay a fixed number of
+ * lines regardless of how many models a plugin binds. The overflow count keeps
+ * the remaining names reachable through its tooltip.
+ */
+const MAX_VISIBLE_MODELS = 4
+
+/**
+ * Bespoke task-plugin card for the card view. Reuses the column cell renderers
+ * via `flexRender` so the table and card views share one implementation of the
+ * source/runtime badges, the enable switch (with its usage-guard mutation),
+ * and the actions menu.
+ *
+ * The card answers "which plugin is this and is it live" — identity, source,
+ * runtime state, the versions, the models it binds, and the enable toggle.
+ * Manifest detail (billing parameters, endpoints, source) belongs to the detail
+ * sheet: rendering it here made every card a different height and buried the
+ * plugin's own description under its parameter descriptions.
+ */
+function PluginCardComponent({ row }: { row: Row }) {
+ const { t, i18n } = useTranslation()
+ const cells = row.getAllCells()
+ const description = resolveLocalizedText(
+ row.original.meta.description,
+ i18n.language
+ )
+ const models = row.original.meta.models ?? []
+ const hiddenModels = models.slice(MAX_VISIBLE_MODELS)
+
+ const renderCell = (id: string) => {
+ const cell = cells.find((c) => c.column.id === id)
+ if (!cell || !cell.column.columnDef.cell) {
+ return null
+ }
+ return flexRender(cell.column.columnDef.cell, cell.getContext())
+ }
+
+ const labelClass = 'text-muted-foreground text-[11px] font-medium select-none'
+
+ return (
+
+ {/* Row 1: type icon + name/key, with runtime status + actions menu */}
+
+
+
+
+
+
+
+ {row.original.meta.name}
+
+
+ {row.original.meta.key}
+
+
+
+
+ {renderCell('actions')}
+
+
+
+ {/* Row 2: source + runtime badges next to the version pills, all wrapping
+ freely. The versions read as pills rather than labelled stats because
+ `v1.2.3` and `API v1` already name themselves. */}
+
+ {renderCell('source')}
+ {renderCell('runtime')}
+
+ {row.original.meta.version ? `v${row.original.meta.version}` : '—'}
+
+
+ API v{row.original.meta.apiVersion}
+
+
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+ {/* Row 3: the bound models, named rather than counted */}
+ {models.length > 0 ? (
+
+
{t('Models')}
+
+ {models.slice(0, MAX_VISIBLE_MODELS).map((model) => (
+
+ {model}
+
+ ))}
+ {hiddenModels.length > 0 ? (
+
+ +{hiddenModels.length}
+
+ ) : null}
+
+
+ ) : null}
+
+ {/* Footer: enabled toggle pinned to the card bottom */}
+
+ {t('Enabled')}
+ {renderCell('enabled')}
+
+
+ )
+}
+
+/**
+ * Memoized so each card only re-renders when its own react-table row reference
+ * changes rather than on every parent table state update.
+ */
+export const PluginCard = memo(PluginCardComponent)
diff --git a/web/src/features/task-plugins/components/plugin-detail-sheet.tsx b/web/src/features/task-plugins/components/plugin-detail-sheet.tsx
new file mode 100644
index 000000000000..d9301d49acbe
--- /dev/null
+++ b/web/src/features/task-plugins/components/plugin-detail-sheet.tsx
@@ -0,0 +1,230 @@
+/*
+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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { RotateCcw } from 'lucide-react'
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from '@/components/ui/sheet'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { resolveLocalizedText } from '@/lib/localized-text'
+
+import {
+ activateTaskPlugin,
+ getTaskPlugin,
+ getTaskPluginVersions,
+} from '../api'
+import type { TaskPluginListItem } from '../types'
+import { JavaScriptViewer } from './javascript-viewer'
+import { PluginMetadataCard } from './plugin-metadata-card'
+import { PluginSandbox } from './plugin-sandbox'
+import { SourceDiff } from './source-diff'
+import { UsageSchemaTable } from './usage-schema-table'
+
+type PluginDetailSheetProps = {
+ plugin: TaskPluginListItem | null
+ onOpenChange: (open: boolean) => void
+}
+
+export function PluginDetailSheet(props: PluginDetailSheetProps) {
+ const { t, i18n } = useTranslation()
+ const queryClient = useQueryClient()
+ const key = props.plugin?.meta.key ?? ''
+ const [compareVersion, setCompareVersion] = useState('')
+ const detailQuery = useQuery({
+ queryKey: ['task-plugin', key],
+ queryFn: () => getTaskPlugin(key),
+ enabled: Boolean(key),
+ })
+ const versionsQuery = useQuery({
+ queryKey: ['task-plugin-versions', key],
+ queryFn: () => getTaskPluginVersions(key),
+ enabled: Boolean(key),
+ })
+ const compareQuery = useQuery({
+ queryKey: ['task-plugin', key, compareVersion],
+ queryFn: () => getTaskPlugin(key, compareVersion),
+ enabled: Boolean(key && compareVersion),
+ })
+ const activateMutation = useMutation({
+ mutationFn: (version: string) => activateTaskPlugin(key, version),
+ onSuccess: () => {
+ toast.success(t('Plugin version activated'))
+ queryClient.invalidateQueries({ queryKey: ['task-plugins'] })
+ queryClient.invalidateQueries({ queryKey: ['task-plugin', key] })
+ queryClient.invalidateQueries({ queryKey: ['task-plugin-versions', key] })
+ },
+ onError: (error) => toast.error(error.message),
+ })
+ const detail = detailQuery.data
+ const versions = versionsQuery.data ?? []
+ const description = resolveLocalizedText(
+ detail?.meta.description ?? props.plugin?.meta.description,
+ i18n.language
+ )
+ return (
+
+
+
+
+ {detail?.meta.name ?? props.plugin?.meta.name}
+
+
+ {key}
+ {description ? (
+ {description}
+ ) : null}
+
+
+
+ {detail && (
+ <>
+
+
+
+ {t('Billing parameters')}
+
+
+ {detail.meta.usageSchema &&
+ Object.keys(detail.meta.usageSchema).length > 0 ? (
+
+ ) : (
+
+ {t('No billing parameters declared')}
+
+ )}
+
+
+ >
+ )}
+
+
+ {t('Source')}
+ {t('Version history')}
+ {t('Source diff')}
+ {t('Sandbox')}
+
+
+
+
+
+
+
+
+ {t('Version')}
+ {t('Remark')}
+ {t('Status')}
+ {t('Actions')}
+
+
+
+ {versions.map((version) => (
+
+ {version.version}
+ {version.remark || '—'}
+
+ {version.active ? {t('Active')} : '—'}
+
+
+
+ activateMutation.mutate(version.version)
+ }
+ >
+
+ {t('Activate / Roll back')}
+
+
+
+ ))}
+
+
+
+
+ setCompareVersion(value ?? '')}
+ >
+
+
+
+
+
+ {versions
+ .filter(
+ (version) => version.version !== detail?.meta.version
+ )
+ .map((version) => (
+
+ {version.version}
+
+ ))}
+
+
+
+ {compareQuery.data && detail && (
+
+ )}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/src/features/task-plugins/components/plugin-icon.tsx b/web/src/features/task-plugins/components/plugin-icon.tsx
new file mode 100644
index 000000000000..1e8072feb092
--- /dev/null
+++ b/web/src/features/task-plugins/components/plugin-icon.tsx
@@ -0,0 +1,55 @@
+/*
+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 { getLobeIcon } from '@/lib/lobe-icon'
+import { cn } from '@/lib/utils'
+
+import {
+ resolvePluginIcon,
+ textAvatarClass,
+ type PluginIconInput,
+} from '../lib/plugin-icon'
+
+type PluginIconProps = {
+ plugin: PluginIconInput
+ size?: number
+}
+
+export function PluginIcon(props: PluginIconProps) {
+ const size = props.size ?? 20
+ const descriptor = resolvePluginIcon(props.plugin)
+ if (descriptor.kind === 'lobe') {
+ return <>{getLobeIcon(descriptor.name, size)}>
+ }
+ return (
+
+ {descriptor.label}
+
+ )
+}
diff --git a/web/src/features/task-plugins/components/plugin-metadata-card.tsx b/web/src/features/task-plugins/components/plugin-metadata-card.tsx
new file mode 100644
index 000000000000..f45b0d02a525
--- /dev/null
+++ b/web/src/features/task-plugins/components/plugin-metadata-card.tsx
@@ -0,0 +1,214 @@
+/*
+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 type { ReactNode } from 'react'
+import { useTranslation } from 'react-i18next'
+
+import { Badge } from '@/components/ui/badge'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+
+import { HOST_PROTOCOL_ENDPOINTS } from '../lib/host-protocols'
+import type { TaskPluginMeta, TaskPluginRoute } from '../types'
+
+type PluginMetadataCardProps = {
+ meta: TaskPluginMeta
+}
+
+/**
+ * Marks a protocol claim or native route that binds only a subset of
+ * `meta.models`. The subset can be long enough to dominate the section, so the
+ * members stay in the title tooltip and only the marker is laid out.
+ */
+function ModelScopeHint(props: { models: string[] }) {
+ const { t } = useTranslation()
+ return (
+
+ {t('Model scope')}
+
+ )
+}
+
+/**
+ * One HTTP endpoint the gateway serves for this plugin. Methods and paths are
+ * wire vocabulary and stay raw; `children` carries the trailing annotations
+ * (supported request forms, native route type) that belong to this endpoint.
+ */
+function EndpointRow(props: {
+ method: string
+ path: string
+ children?: ReactNode
+}) {
+ return (
+
+
+ {props.method}
+
+
+ {props.path}
+
+ {props.children}
+
+ )
+}
+
+/**
+ * The endpoints a plugin exposes: first the host protocol endpoints derived
+ * from each `meta.protocols` claim, then the native routes it declares itself.
+ *
+ * The supported request forms of a mode-bearing protocol are rendered on the
+ * create endpoint rather than next to the protocol name, because `supports`
+ * gates exactly that call — retrieval of a created resource is always
+ * available. Mode names are wire vocabulary and are never translated.
+ */
+function PluginEndpoints(props: {
+ protocols?: TaskPluginMeta['protocols']
+ routes?: TaskPluginRoute[]
+}) {
+ const { t } = useTranslation()
+ const claims = props.protocols ?? []
+ const routes = props.routes ?? []
+
+ return (
+
+
+ {t('Endpoints')}
+
+ {claims.length === 0 && routes.length === 0 ? (
+
—
+ ) : null}
+ {claims.map((claim) => {
+ const name = typeof claim === 'string' ? claim : claim.name
+ const supports = typeof claim === 'string' ? undefined : claim.supports
+ const models = typeof claim === 'string' ? undefined : claim.models
+ const endpoints = HOST_PROTOCOL_ENDPOINTS[name] ?? []
+ const chips = supports?.map((mode) => (
+
+ {mode}
+
+ ))
+ // Chips belong on the create row, but a claim naming a protocol absent
+ // from the frozen table has no rows at all; keep the declared forms
+ // visible on the group header rather than dropping them silently.
+ const hasCreateRow = endpoints.some((endpoint) => endpoint.modeBearing)
+ return (
+
+
+
+ {name}
+
+ {models?.length ? : null}
+ {hasCreateRow ? null : chips}
+
+
+ {endpoints.map((endpoint) => (
+
+ {endpoint.modeBearing ? chips : null}
+
+ ))}
+
+
+ )
+ })}
+ {routes.length > 0 ? (
+
+
{t('Native routes')}
+
+ {routes.map((route) => (
+
+
+ {route.type}
+
+ {route.models?.length ? (
+
+ ) : null}
+
+ ))}
+
+
+ ) : null}
+
+ )
+}
+
+/**
+ * The manifest facts an administrator checks before binding a channel: the
+ * scalar declarations, then every endpoint the gateway serves for this plugin.
+ * Labels are translated; manifest values stay raw and monospaced so they can be
+ * compared against the plugin source verbatim.
+ */
+export function PluginMetadataCard(props: PluginMetadataCardProps) {
+ const { t } = useTranslation()
+ const fields: { label: string; value: string; wide?: boolean }[] = [
+ { label: t('Version'), value: props.meta.version },
+ { label: t('API version'), value: String(props.meta.apiVersion) },
+ {
+ label: t('Channel types'),
+ value: props.meta.channelTypes?.join(', ') ?? '',
+ },
+ { label: t('Fetch mode'), value: props.meta.fetchMode },
+ {
+ label: t('Models'),
+ value: props.meta.models?.join(', ') ?? '',
+ wide: true,
+ },
+ ]
+
+ return (
+
+
+ {t('Plugin metadata')}
+
+
+
+ {fields.map((field) => (
+
+
{field.label}
+
+ {field.value || '—'}
+
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/web/src/features/task-plugins/components/plugin-sandbox.tsx b/web/src/features/task-plugins/components/plugin-sandbox.tsx
new file mode 100644
index 000000000000..aba00e8ee145
--- /dev/null
+++ b/web/src/features/task-plugins/components/plugin-sandbox.tsx
@@ -0,0 +1,96 @@
+/*
+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.
+*/
+import { useMutation } from '@tanstack/react-query'
+import { Play } from 'lucide-react'
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+
+import {
+ CodeBlock,
+ CodeBlockEditor,
+} from '@/components/ai-elements/code-block'
+import { Button } from '@/components/ui/button'
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+
+import { dryRunTaskPlugin } from '../api'
+
+const hooks = [
+ 'resolveRequest',
+ 'buildSubmitRequest',
+ 'parseSubmitResponse',
+ 'extractUsage',
+ 'extractUsageOnSubmit',
+ 'extractUsageOnComplete',
+ 'buildQueryRequest',
+ 'parseTaskResult',
+ 'buildBatchQueryRequest',
+ 'parseBatchResult',
+ 'buildContentRequest',
+ 'renderers.openai_video',
+]
+
+export function PluginSandbox(props: { pluginKey: string }) {
+ const { t } = useTranslation()
+ const [hook, setHook] = useState('buildSubmitRequest')
+ const [args, setArgs] = useState('[{}]')
+ const [output, setOutput] = useState('')
+ const mutation = useMutation({
+ mutationFn: async () => {
+ const parsed = JSON.parse(args) as unknown
+ if (!Array.isArray(parsed)) throw new Error(t('Arguments must be a JSON array'))
+ const memberSeparator = hook.indexOf('.')
+ return dryRunTaskPlugin(props.pluginKey, {
+ hook: memberSeparator < 0 ? hook : hook.slice(0, memberSeparator),
+ member: memberSeparator < 0 ? undefined : hook.slice(memberSeparator + 1),
+ args: parsed,
+ })
+ },
+ onSuccess: (value) => setOutput(JSON.stringify(value, null, 2)),
+ onError: (error) => setOutput(JSON.stringify({ error: error.message }, null, 2)),
+ })
+
+ return (
+
+
setHook(value ?? '')}>
+
+
+
+
+
+ {hooks.map((item) => (
+
+ {item}
+
+ ))}
+
+
+
+
+
mutation.mutate()}>
+
+ {mutation.isPending ? t('Running dry run') : t('Run dry run')}
+
+ {output &&
}
+
+ )
+}
diff --git a/web/src/features/task-plugins/components/plugin-source-picker.tsx b/web/src/features/task-plugins/components/plugin-source-picker.tsx
new file mode 100644
index 000000000000..062f3e574fbc
--- /dev/null
+++ b/web/src/features/task-plugins/components/plugin-source-picker.tsx
@@ -0,0 +1,112 @@
+/*
+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 { FileCode2, FolderOpen, Upload } from 'lucide-react'
+import { useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+
+import { Button } from '@/components/ui/button'
+import { cn } from '@/lib/utils'
+
+type PluginSourcePickerProps = {
+ /** Name of the file whose text currently fills the source field, if any. */
+ fileName: string
+ onSelect: (file: File) => void
+}
+
+/**
+ * Drop zone for the plugin's `.js` file. The native file input is kept in the
+ * DOM (labelled, visually hidden) rather than rendered directly: the unstyled
+ * "Choose file / no file selected" control is the single most off-brand element
+ * in this dialog, and hiding it behind a real button matches how every other
+ * upload in the app looks.
+ */
+export function PluginSourcePicker(props: PluginSourcePickerProps) {
+ const { t } = useTranslation()
+ const inputRef = useRef(null)
+ const [isDragActive, setIsDragActive] = useState(false)
+
+ const selectFile = (file?: File) => {
+ if (!file) return
+ props.onSelect(file)
+ }
+
+ return (
+ {
+ event.preventDefault()
+ setIsDragActive(true)
+ }}
+ onDragLeave={() => setIsDragActive(false)}
+ onDrop={(event) => {
+ event.preventDefault()
+ setIsDragActive(false)
+ selectFile(event.dataTransfer.files[0])
+ }}
+ >
+ {props.fileName ? (
+
+ ) : (
+
+ )}
+
+
+ {props.fileName || t('Drop a JavaScript plugin file here')}
+
+
+ {t(
+ 'Single .js file, up to 1 MiB. Its source is shown below before upload.'
+ )}
+
+
+
inputRef.current?.click()}
+ >
+
+ {props.fileName ? t('Choose another file') : t('Choose file')}
+
+ {/* sr-only rather than hidden so the label stays reachable to a
+ screen reader and to userEvent.upload in tests. */}
+
+ {t('JavaScript file')}
+
+
{
+ selectFile(event.target.files?.[0])
+ // Allow re-picking the same file after an error.
+ event.target.value = ''
+ }}
+ />
+
+ )
+}
diff --git a/web/src/features/task-plugins/components/plugin-url-import-field.tsx b/web/src/features/task-plugins/components/plugin-url-import-field.tsx
new file mode 100644
index 000000000000..af431a4910a6
--- /dev/null
+++ b/web/src/features/task-plugins/components/plugin-url-import-field.tsx
@@ -0,0 +1,140 @@
+/*
+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 { useMutation } from '@tanstack/react-query'
+import { Download } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+
+import {
+ Field,
+ FieldDescription,
+ FieldError,
+ FieldLabel,
+} from '@/components/ui/field'
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupInput,
+} from '@/components/ui/input-group'
+import { Spinner } from '@/components/ui/spinner'
+
+import {
+ fetchPluginSourceText,
+ normalizePluginSourceUrl,
+ PluginSourceFetchError,
+} from '../lib/plugin-url'
+
+type PluginUrlImportFieldProps = {
+ /** URL text, owned by the dialog so closing it clears this field too. */
+ value: string
+ onChange: (value: string) => void
+ error: string
+ onError: (message: string) => void
+ onFetched: (source: string) => void
+}
+
+export function PluginUrlImportField(props: PluginUrlImportFieldProps) {
+ const { t } = useTranslation()
+ const importMutation = useMutation({
+ mutationFn: async () => {
+ const normalized = normalizePluginSourceUrl(props.value)
+ if (!normalized) {
+ throw new Error(t('Enter an absolute http(s) URL.'))
+ }
+ return fetchPluginSourceText(normalized)
+ },
+ // The fetched text only fills the source field. Uploading stays an explicit
+ // administrator action so the source and the risk warning are reviewed
+ // exactly as they are for a manual paste.
+ onSuccess: (text) => {
+ props.onError('')
+ props.onFetched(text)
+ },
+ onError: (error) => {
+ if (!(error instanceof PluginSourceFetchError)) {
+ props.onError(error.message)
+ return
+ }
+ if (error.reason === 'too_large') {
+ props.onError(t('Plugin source exceeds the 1 MiB limit.'))
+ return
+ }
+ if (error.reason === 'not_found') {
+ props.onError(
+ t(
+ 'The URL returned HTTP {{status}}. Check the address, or download the file and paste its source below.',
+ { status: error.status ?? 0 }
+ )
+ )
+ return
+ }
+ props.onError(
+ t(
+ 'Could not fetch this URL from the browser. The host may block cross-origin requests or be unreachable. Download the file and paste its source below.'
+ )
+ )
+ },
+ })
+
+ return (
+
+ {t('Import from URL')}
+
+ {
+ props.onChange(event.target.value)
+ props.onError('')
+ }}
+ onKeyDown={(event) => {
+ if (event.key !== 'Enter') return
+ event.preventDefault()
+ if (props.value.trim()) importMutation.mutate()
+ }}
+ />
+
+ importMutation.mutate()}
+ >
+ {importMutation.isPending ? (
+
+ ) : (
+
+ )}
+ {importMutation.isPending ? t('Fetching...') : t('Fetch')}
+
+
+
+
+ {t(
+ 'Fetched in your browser and placed in the source field below for review. GitHub and gist page URLs are rewritten to their raw URL automatically.'
+ )}
+
+ {props.error ? {props.error} : null}
+
+ )
+}
diff --git a/web/src/features/task-plugins/components/plugins-table.tsx b/web/src/features/task-plugins/components/plugins-table.tsx
new file mode 100644
index 000000000000..5edfa5481c37
--- /dev/null
+++ b/web/src/features/task-plugins/components/plugins-table.tsx
@@ -0,0 +1,435 @@
+/*
+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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import type { ColumnDef, ColumnFiltersState } from '@tanstack/react-table'
+import { Eye, MoreHorizontal, Trash2, Upload } from 'lucide-react'
+import { useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+
+import { ConfirmDialog } from '@/components/confirm-dialog'
+import { DataTablePage, useDataTable } from '@/components/data-table'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { Switch } from '@/components/ui/switch'
+import { getChannelTypeLabel } from '@/features/channels/lib'
+import { resolveLocalizedText } from '@/lib/localized-text'
+
+import {
+ deleteTaskPluginVersion,
+ listTaskPlugins,
+ setTaskPluginStatus,
+ TaskPluginUsageError,
+} from '../api'
+import type { TaskPluginListItem, TaskPluginUsage } from '../types'
+import { PluginCard } from './plugin-card'
+import { PluginIcon } from './plugin-icon'
+
+const VIEW_MODE_STORAGE_KEY = 'task-plugins-view-mode'
+
+type PluginsTableProps = {
+ onDetails: (plugin: TaskPluginListItem) => void
+ onUpload: (key: string) => void
+}
+
+export function PluginsTable(props: PluginsTableProps) {
+ const { t, i18n } = useTranslation()
+ const queryClient = useQueryClient()
+ const [deleteTarget, setDeleteTarget] = useState(
+ null
+ )
+ const [blockedUsage, setBlockedUsage] = useState(null)
+ const [blockedAction, setBlockedAction] = useState<
+ 'delete' | 'disable' | null
+ >(null)
+ const [statusTarget, setStatusTarget] = useState(
+ null
+ )
+ const pluginsQuery = useQuery({
+ queryKey: ['task-plugins'],
+ queryFn: listTaskPlugins,
+ })
+ const statusMutation = useMutation({
+ mutationFn: ({
+ key,
+ enabled,
+ options,
+ }: {
+ key: string
+ enabled: boolean
+ options?: { cascade?: boolean; force?: boolean }
+ }) => setTaskPluginStatus(key, enabled, options),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['task-plugins'] })
+ setBlockedAction(null)
+ setBlockedUsage(null)
+ },
+ onError: (error) => {
+ if (error instanceof TaskPluginUsageError) {
+ setBlockedUsage(error.usage)
+ setBlockedAction('disable')
+ return
+ }
+ toast.error(error.message)
+ },
+ })
+ const deleteMutation = useMutation({
+ mutationFn: (plugin: TaskPluginListItem) =>
+ deleteTaskPluginVersion(plugin.meta.key, plugin.meta.version),
+ onSuccess: () => {
+ setDeleteTarget(null)
+ toast.success(t('Plugin version deleted'))
+ queryClient.invalidateQueries({ queryKey: ['task-plugins'] })
+ },
+ onError: (error) => {
+ if (error instanceof TaskPluginUsageError) {
+ setBlockedUsage(error.usage)
+ setBlockedAction('delete')
+ return
+ }
+ toast.error(error.message)
+ },
+ })
+ const columns = useMemo[]>(
+ () => [
+ {
+ id: 'plugin',
+ accessorFn: (row) => `${row.meta.name} ${row.meta.key}`,
+ header: t('Plugin'),
+ cell: ({ row }) => {
+ const description = resolveLocalizedText(
+ row.original.meta.description,
+ i18n.language
+ )
+ return (
+
+
+
+
+
+
+ {row.original.meta.name}
+
+
+ {row.original.meta.key}
+
+
+
+ )
+ },
+ },
+ {
+ accessorKey: 'meta.version',
+ header: t('Active version'),
+ cell: ({ row }) => (
+ {row.original.meta.version}
+ ),
+ },
+ {
+ id: 'source',
+ header: t('Source'),
+ cell: ({ row }) => {
+ if (row.original.source === 'factory') {
+ return {t('Factory')}
+ }
+ if (row.original.source === 'override_over_factory') {
+ return (
+
+ {t('Custom (overrides factory {{version}})', {
+ version: row.original.factory_meta?.version,
+ })}
+
+ )
+ }
+ return {t('Third-party')}
+ },
+ },
+ {
+ id: 'channelType',
+ header: t('Channel type'),
+ cell: ({ row }) => {
+ const channelTypes = row.original.meta.channelTypes ?? []
+ if (channelTypes.length === 0) {
+ return —
+ }
+ return (
+
+ {getChannelTypeLabel(channelTypes[0])}
+
+ {channelTypes.map((type) => `#${type}`).join(' ')}
+
+
+ )
+ },
+ },
+ {
+ accessorKey: 'meta.apiVersion',
+ header: t('API version'),
+ cell: ({ row }) => (
+
+ v{row.original.meta.apiVersion}
+
+ ),
+ },
+ {
+ id: 'models',
+ header: t('Models'),
+ cell: ({ row }) => row.original.meta.models?.length ?? 0,
+ },
+ {
+ id: 'enabled',
+ header: t('Enabled'),
+ cell: ({ row }) => (
+ {
+ setStatusTarget(row.original)
+ statusMutation.mutate({
+ key: row.original.meta.key,
+ enabled: checked,
+ })
+ }}
+ />
+ ),
+ },
+ {
+ id: 'runtime',
+ header: t('Runtime status'),
+ cell: ({ row }) => {
+ const status = row.original.runtime_status
+ if (status === 'registered') {
+ return {t('Registered')}
+ }
+ if (status === 'compile_failed') {
+ return (
+
+ {t('Compilation failed')}
+
+ )
+ }
+ if (status === 'disabled') {
+ return {t('Disabled')}
+ }
+ if (status === 'disabled_fallback') {
+ return (
+
+ {row.original.factory_meta
+ ? t('Disabled; fell back to factory')
+ : t('Disabled; platform unavailable')}
+
+ )
+ }
+ return {t('Not registered')}
+ },
+ },
+ {
+ id: 'actions',
+ cell: ({ row }) => (
+
+
+ }
+ >
+
+
+
+ props.onDetails(row.original)}>
+
+ {t('Details')}
+
+ props.onUpload(row.original.meta.key)}
+ >
+
+ {t('Upload new version')}
+
+ setDeleteTarget(row.original)}
+ >
+
+ {t('Delete active custom version')}
+
+
+
+ ),
+ },
+ ],
+ [i18n.language, props, statusMutation, t]
+ )
+ const [columnFilters, setColumnFilters] = useState([])
+ const [globalFilter, setGlobalFilter] = useState('')
+ const { table } = useDataTable({
+ data: pluginsQuery.data ?? [],
+ columns,
+ totalCount: pluginsQuery.data?.length ?? 0,
+ columnFilters,
+ onColumnFiltersChange: setColumnFilters,
+ globalFilter,
+ onGlobalFilterChange: setGlobalFilter,
+ withFilteredRowModel: true,
+ withPaginationRowModel: true,
+ withSortedRowModel: true,
+ })
+ const hasFactoryFallback = Boolean(deleteTarget?.factory_meta)
+ const usageDescription = blockedUsage ? (
+
+
+ {t(
+ '{{count}} enabled channels and {{tasks}} in-flight tasks still use this plugin.',
+ {
+ count: blockedUsage.channels.length,
+ tasks: blockedUsage.in_flight_count,
+ }
+ )}
+
+ {blockedUsage.channels.length > 0 && (
+
+ {blockedUsage.channels.map((channel) => (
+
+ #{channel.id} {channel.name}
+
+ ))}
+
+ )}
+
+ ) : (
+ ''
+ )
+ return (
+ <>
+ }
+ cardGridClassName='grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 xl:grid-cols-3'
+ toolbarProps={{ searchPlaceholder: t('Filter plugins...') }}
+ />
+ {
+ if (!open) setDeleteTarget(null)
+ }}
+ title={t('Delete plugin version?')}
+ destructive
+ isLoading={deleteMutation.isPending}
+ confirmText={t('Delete')}
+ handleConfirm={() => {
+ if (deleteTarget) deleteMutation.mutate(deleteTarget)
+ }}
+ desc={
+ hasFactoryFallback
+ ? t(
+ 'Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.'
+ )
+ : t(
+ 'This plugin has no factory fallback. Deleting or disabling it makes this platform unavailable.'
+ )
+ }
+ />
+ {
+ if (!open) {
+ setBlockedAction(null)
+ setBlockedUsage(null)
+ }
+ }}
+ title={t('Plugin is still in use')}
+ desc={usageDescription}
+ handleConfirm={() => setBlockedAction(null)}
+ confirmText={t('Cancel')}
+ >
+
+ {blockedAction === 'disable' && blockedUsage?.channels.length ? (
+
+ statusTarget &&
+ statusMutation.mutate({
+ key: statusTarget.meta.key,
+ enabled: false,
+ options: { cascade: true },
+ })
+ }
+ >
+ {t('Cascade disable channels')}
+
+ ) : null}
+ {
+ if (blockedAction === 'delete' && deleteTarget) {
+ deleteTaskPluginVersion(
+ deleteTarget.meta.key,
+ deleteTarget.meta.version,
+ true
+ )
+ .then(() => {
+ setBlockedAction(null)
+ setDeleteTarget(null)
+ queryClient.invalidateQueries({
+ queryKey: ['task-plugins'],
+ })
+ })
+ .catch((error: Error) => toast.error(error.message))
+ }
+ if (blockedAction === 'disable' && statusTarget) {
+ statusMutation.mutate({
+ key: statusTarget.meta.key,
+ enabled: false,
+ options: { cascade: true, force: true },
+ })
+ }
+ }}
+ >
+ {t('Force operation')}
+
+
+
+ >
+ )
+}
diff --git a/web/src/features/task-plugins/components/source-diff.tsx b/web/src/features/task-plugins/components/source-diff.tsx
new file mode 100644
index 000000000000..aea7aba3dcdf
--- /dev/null
+++ b/web/src/features/task-plugins/components/source-diff.tsx
@@ -0,0 +1,87 @@
+/*
+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 { useTranslation } from 'react-i18next'
+
+type SourceDiffProps = { before: string; after: string }
+
+type DiffLine = { id: string; kind: 'same' | 'added' | 'removed'; text: string }
+
+function diffLines(before: string, after: string): DiffLine[] {
+ const left = before.split('\n')
+ const right = after.split('\n')
+ const lengths = Array.from({ length: left.length + 1 }, () =>
+ Array(right.length + 1).fill(0)
+ )
+ for (let i = left.length - 1; i >= 0; i -= 1) {
+ for (let j = right.length - 1; j >= 0; j -= 1) {
+ lengths[i][j] =
+ left[i] === right[j]
+ ? lengths[i + 1][j + 1] + 1
+ : Math.max(lengths[i + 1][j], lengths[i][j + 1])
+ }
+ }
+ const result: DiffLine[] = []
+ let i = 0
+ let j = 0
+ while (i < left.length || j < right.length) {
+ if (i < left.length && j < right.length && left[i] === right[j]) {
+ result.push({ id: `same-${i}-${j}`, kind: 'same', text: left[i] })
+ i += 1
+ j += 1
+ } else if (
+ j < right.length &&
+ (i === left.length || lengths[i][j + 1] >= lengths[i + 1][j])
+ ) {
+ result.push({ id: `added-${i}-${j}`, kind: 'added', text: right[j] })
+ j += 1
+ } else {
+ result.push({ id: `removed-${i}-${j}`, kind: 'removed', text: left[i] })
+ i += 1
+ }
+ }
+ return result
+}
+
+export function SourceDiff(props: SourceDiffProps) {
+ const { t } = useTranslation()
+ const lines = diffLines(props.before, props.after)
+ return (
+
+ {lines.map((line) => {
+ let prefix = ' '
+ let color = ''
+ if (line.kind === 'added') {
+ prefix = '+'
+ color = 'bg-green-500/10 text-green-700 dark:text-green-300'
+ } else if (line.kind === 'removed') {
+ prefix = '-'
+ color = 'bg-red-500/10 text-red-700 dark:text-red-300'
+ }
+ return (
+
+ {prefix} {line.text || ' '}
+
+ )
+ })}
+
+ )
+}
diff --git a/web/src/features/task-plugins/components/upload-dialog.tsx b/web/src/features/task-plugins/components/upload-dialog.tsx
new file mode 100644
index 000000000000..85e35eefbe49
--- /dev/null
+++ b/web/src/features/task-plugins/components/upload-dialog.tsx
@@ -0,0 +1,214 @@
+/*
+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 { useMutation, useQueryClient } from '@tanstack/react-query'
+import { AlertTriangle, CircleCheck, Upload } from 'lucide-react'
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+
+import { CodeBlockEditor } from '@/components/ai-elements/code-block'
+import { Dialog } from '@/components/dialog'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { Button } from '@/components/ui/button'
+import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
+import { Input } from '@/components/ui/input'
+import { Spinner } from '@/components/ui/spinner'
+
+import { uploadTaskPlugin } from '../api'
+import {
+ MAX_PLUGIN_SOURCE_BYTES,
+ pluginSourceByteLength,
+} from '../lib/plugin-url'
+import type { TaskPluginDetail } from '../types'
+import { PluginSourcePicker } from './plugin-source-picker'
+import { PluginUrlImportField } from './plugin-url-import-field'
+
+type UploadDialogProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ initialKey?: string
+}
+
+export function UploadDialog(props: UploadDialogProps) {
+ const { t } = useTranslation()
+ const queryClient = useQueryClient()
+ const [source, setSource] = useState('')
+ const [fileName, setFileName] = useState('')
+ const [remark, setRemark] = useState('')
+ const [result, setResult] = useState(null)
+ const [importUrl, setImportUrl] = useState('')
+ const [importError, setImportError] = useState('')
+ const mutation = useMutation({
+ mutationFn: () => uploadTaskPlugin(source, remark),
+ onSuccess: (data) => {
+ setResult(data)
+ queryClient.invalidateQueries({ queryKey: ['task-plugins'] })
+ if (props.initialKey) {
+ queryClient.invalidateQueries({
+ queryKey: ['task-plugin', props.initialKey],
+ })
+ queryClient.invalidateQueries({
+ queryKey: ['task-plugin-versions', props.initialKey],
+ })
+ }
+ toast.success(t('Plugin uploaded successfully'))
+ },
+ })
+
+ const handleFile = async (file: File) => {
+ if (file.size > MAX_PLUGIN_SOURCE_BYTES) {
+ setImportError(t('Plugin source exceeds the 1 MiB limit.'))
+ return
+ }
+ setImportError('')
+ setFileName(file.name)
+ setSource(await file.text())
+ setResult(null)
+ }
+
+ const close = (open: boolean) => {
+ props.onOpenChange(open)
+ if (!open) {
+ setSource('')
+ setFileName('')
+ setRemark('')
+ setResult(null)
+ setImportUrl('')
+ setImportError('')
+ mutation.reset()
+ }
+ }
+
+ return (
+
+ close(false)}>
+ {t('Close')}
+
+ mutation.mutate()}
+ >
+ {mutation.isPending ? (
+
+ ) : (
+
+ )}
+ {mutation.isPending ? t('Uploading...') : t('Upload')}
+
+ >
+ }
+ >
+
+
+ {t('Third-party plugin risk')}
+
+ {t(
+ 'Uploading a plugin is an administrator-level trust decision. A plugin can access channel credentials and shape upstream requests. Review its source and diff before activation.'
+ )}
+
+
+
+
+
+
+ {
+ setFileName('')
+ setSource(text)
+ setResult(null)
+ }}
+ />
+
+
+ {t('{{bytes}} bytes', { bytes: pluginSourceByteLength(source) })}
+
+ }
+ ariaLabel={t('Plugin source')}
+ // The editor is mid-dialog; focusing it on open would scroll the
+ // risk warning and the file picker out of view.
+ autoFocus={false}
+ className='my-0'
+ language='javascript'
+ onChange={(value) => {
+ setSource(value)
+ setResult(null)
+ }}
+ placeholder={t('Paste JavaScript source here...')}
+ rows={14}
+ title={t('Plugin source')}
+ value={source}
+ />
+
+
+ {t('Remark')}
+ setRemark(event.target.value)}
+ />
+
+
+
+ {mutation.error ? (
+
+
+ {t('The gateway rejected this plugin')}
+ {/* Verbatim: preflight rejections name the conflicting plugin. */}
+
+ {mutation.error.message}
+
+
+ ) : null}
+
+ {result ? (
+
+
+ {t('Parsed plugin metadata')}
+
+ {result.meta.key} · {result.meta.name} · v{result.meta.version} ·
+ API v{result.meta.apiVersion}
+
+
+ ) : null}
+
+ )
+}
diff --git a/web/src/features/task-plugins/components/usage-schema-table.tsx b/web/src/features/task-plugins/components/usage-schema-table.tsx
new file mode 100644
index 000000000000..0ac3a1f00fd3
--- /dev/null
+++ b/web/src/features/task-plugins/components/usage-schema-table.tsx
@@ -0,0 +1,92 @@
+/*
+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 { useTranslation } from 'react-i18next'
+
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import type { BillingUsageSchema } from '@/features/pricing/types'
+import { resolveLocalizedText } from '@/lib/localized-text'
+
+type UsageSchemaTableProps = {
+ schema: BillingUsageSchema
+}
+
+function getUsageTypeLabelKey(
+ type: BillingUsageSchema[string]['type']
+): string {
+ if (type === 'number') return 'Number'
+ if (type === 'boolean') return 'Boolean'
+ return 'Enum'
+}
+
+function formatUsageUnit(
+ unit: BillingUsageSchema[string]['unit'],
+ t: (key: string) => string
+): string {
+ if (unit === 'second') return t('Second')
+ if (unit === 'count') return t('Count')
+ if (unit === 'token') return t('token (unit)')
+ if (unit === 'credit') return t('credit')
+ return '—'
+}
+
+export function UsageSchemaTable(props: UsageSchemaTableProps) {
+ const { t, i18n } = useTranslation()
+ const entries = Object.entries(props.schema).sort(([left], [right]) =>
+ left.localeCompare(right)
+ )
+
+ return (
+
+
+
+
+ {t('Name')}
+ {t('Type')}
+ {t('Unit')}
+ {t('Enum values')}
+ {t('Description')}
+
+
+
+ {entries.map(([name, definition]) => (
+
+ {name}
+ {t(getUsageTypeLabelKey(definition.type))}
+ {formatUsageUnit(definition.unit, t)}
+
+ {definition.enum?.join(', ') || '—'}
+
+
+ {resolveLocalizedText(definition.description, i18n.language) ||
+ '—'}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/web/src/features/task-plugins/index.tsx b/web/src/features/task-plugins/index.tsx
new file mode 100644
index 000000000000..14c622610b40
--- /dev/null
+++ b/web/src/features/task-plugins/index.tsx
@@ -0,0 +1,208 @@
+/*
+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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { CircleHelp, Upload } from 'lucide-react'
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+
+import { ConfirmDialog } from '@/components/confirm-dialog'
+import { SectionPageLayout } from '@/components/layout'
+import { Button } from '@/components/ui/button'
+import { Label } from '@/components/ui/label'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+import { Switch } from '@/components/ui/switch'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+
+import {
+ getTaskPluginEnabledOption,
+ listTaskPlugins,
+ setTaskPluginEnabledOption,
+} from './api'
+import { MarketplacePanel } from './components/marketplace-panel'
+import { PluginDetailSheet } from './components/plugin-detail-sheet'
+import { PluginsTable } from './components/plugins-table'
+import { UploadDialog } from './components/upload-dialog'
+import type { TaskPluginListItem } from './types'
+
+export function TaskPlugins() {
+ const { t } = useTranslation()
+ const queryClient = useQueryClient()
+ const [detail, setDetail] = useState(null)
+ const [tab, setTab] = useState('installed')
+ const [uploadKey, setUploadKey] = useState(null)
+ const [uploadOpen, setUploadOpen] = useState(false)
+ const [confirmDisable, setConfirmDisable] = useState(false)
+ const enabledQuery = useQuery({
+ queryKey: ['task-plugin-enabled'],
+ queryFn: getTaskPluginEnabledOption,
+ })
+ const pluginsQuery = useQuery({
+ queryKey: ['task-plugins'],
+ queryFn: listTaskPlugins,
+ })
+ const enabledMutation = useMutation({
+ mutationFn: setTaskPluginEnabledOption,
+ onSuccess: (_, enabled) => {
+ queryClient.setQueryData(['task-plugin-enabled'], enabled)
+ toast.success(t('Task plugin setting updated'))
+ },
+ onError: (error) => toast.error(error.message),
+ })
+ const openUpload = (key?: string) => {
+ setUploadKey(key ?? null)
+ setUploadOpen(true)
+ }
+ return (
+ <>
+
+ {t('Task Plugins')}
+
+
+
{
+ if (checked) enabledMutation.mutate(true)
+ else setConfirmDisable(true)
+ }}
+ />
+
+ {t('Enable task plugins')}
+
+
+
+ }
+ >
+
+
+
+
+
+ {t('Enable task plugins')}
+
+
+ {t(
+ 'When disabled, the entire task plugin system stops serving, including factory and custom plugins.'
+ )}
+
+
+ {t('Factory and custom plugin behavior')}
+
+
+ {t(
+ 'Factory plugins cannot be deleted or disabled individually. A custom version can override them; deleting or disabling that version restores the factory plugin. Third-party-only platforms become unavailable when their plugin is deleted or disabled.'
+ )}
+
+
+
+
+
+ {tab === 'installed' && (
+ openUpload()}>
+
+ {t('Upload plugin')}
+
+ )}
+
+
+
+
+ {t('Installed')}
+ {t('Marketplace')}
+
+
+ openUpload(key)}
+ />
+
+
+
+
+
+
+
+ {
+ if (!open) setDetail(null)
+ }}
+ />
+
+
+
+ {t(
+ 'Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.'
+ )}
+
+
+ {(pluginsQuery.data ?? [])
+ .filter((plugin) => plugin.source === 'override')
+ .map((plugin) => (
+
+ {plugin.meta.name} ({plugin.meta.key}):{' '}
+ {t('{{channels}} channels, {{tasks}} in-flight tasks', {
+ channels: plugin.channel_count,
+ tasks: plugin.in_flight_count,
+ })}
+
+ ))}
+
+
+ }
+ destructive
+ handleConfirm={() =>
+ enabledMutation.mutate(false, {
+ onSuccess: () => setConfirmDisable(false),
+ })
+ }
+ confirmText={t('Disable')}
+ />
+ >
+ )
+}
diff --git a/web/src/features/task-plugins/lib/host-protocols.ts b/web/src/features/task-plugins/lib/host-protocols.ts
new file mode 100644
index 000000000000..4c898e978124
--- /dev/null
+++ b/web/src/features/task-plugins/lib/host-protocols.ts
@@ -0,0 +1,49 @@
+/*
+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
+*/
+
+/** One host-served endpoint a protocol claim binds. */
+export type HostProtocolEndpoint = {
+ method: string
+ path: string
+ /**
+ * Whether this endpoint is the mode-bearing create call. `supports` gates the
+ * accepted request forms of the create endpoint only; retrieval of an already
+ * created resource is always available and never declared.
+ */
+ modeBearing?: boolean
+}
+
+/**
+ * The endpoints each host protocol serves, mirroring `hostProtocols` in
+ * `pkg/jsplugin/routing.go`. The table is frozen under `apiVersion: 1`, so it is
+ * mapped client-side rather than fetched: a plugin's `meta.protocols` claim
+ * carries only the protocol name, and the gateway derives these paths from it.
+ * Colon path params are written in `{brace}` form to match the public docs.
+ */
+export const HOST_PROTOCOL_ENDPOINTS: Record = {
+ openai_responses: [
+ { method: 'POST', path: '/v1/responses', modeBearing: true },
+ { method: 'GET', path: '/v1/responses/{response_id}' },
+ ],
+ openai_video: [
+ { method: 'POST', path: '/v1/videos', modeBearing: true },
+ { method: 'GET', path: '/v1/videos/{task_id}' },
+ { method: 'GET', path: '/v1/videos/{task_id}/content' },
+ ],
+}
diff --git a/web/src/features/task-plugins/lib/marketplace.ts b/web/src/features/task-plugins/lib/marketplace.ts
new file mode 100644
index 000000000000..8777a86c28cf
--- /dev/null
+++ b/web/src/features/task-plugins/lib/marketplace.ts
@@ -0,0 +1,266 @@
+/*
+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 type {
+ MarketplaceIndex,
+ MarketplaceIndexVersion,
+ MarketplacePlugin,
+ TaskPluginListItem,
+} from '../types'
+
+export const SUPPORTED_INDEX_VERSION = 1
+
+/** The gateway only runs task plugins today; other kinds are filtered out. */
+export const SUPPORTED_PLUGIN_KIND = 'task'
+
+/**
+ * Resolves a version's `path` against the index URL it was declared in, so the
+ * same index works behind any raw prefix (GitHub raw, jsDelivr, a mirror).
+ * Returns `null` when the path escapes to another origin or cannot be resolved.
+ */
+export function resolvePluginSourceUrl(
+ indexUrl: string,
+ path: string
+): string | null {
+ const trimmed = path.trim()
+ if (!trimmed) return null
+ let base: URL
+ try {
+ base = new URL(indexUrl)
+ } catch {
+ return null
+ }
+ let resolved: URL
+ try {
+ resolved = new URL(trimmed, base)
+ } catch {
+ return null
+ }
+ if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:') {
+ return null
+ }
+ // A relative path in an index must stay on the host that served the index;
+ // an index that redirects source downloads elsewhere is not a source we can
+ // reason about for integrity.
+ if (resolved.origin !== base.origin) return null
+ return resolved.toString()
+}
+
+/**
+ * Validates an untrusted index payload into the display shape. Unknown fields
+ * are dropped and malformed plugin entries are skipped rather than failing the
+ * whole source, because the index is only a display cache — admission still
+ * happens server-side on the compiled source.
+ */
+export function parseMarketplaceIndex(payload: unknown): MarketplaceIndex {
+ if (!payload || typeof payload !== 'object') {
+ throw new Error('index is not an object')
+ }
+ const raw = payload as Record
+ const indexVersion = Number(raw.indexVersion)
+ if (!Number.isFinite(indexVersion)) {
+ throw new Error('index is missing indexVersion')
+ }
+ if (indexVersion > SUPPORTED_INDEX_VERSION) {
+ throw new Error(`unsupported indexVersion ${indexVersion}`)
+ }
+ const plugins: MarketplacePlugin[] = []
+ if (Array.isArray(raw.plugins)) {
+ for (const entry of raw.plugins) {
+ const plugin = parseMarketplacePlugin(entry)
+ if (plugin) plugins.push(plugin)
+ }
+ }
+ return {
+ indexVersion,
+ name: typeof raw.name === 'string' ? raw.name : '',
+ plugins,
+ }
+}
+
+function parseMarketplacePlugin(entry: unknown): MarketplacePlugin | null {
+ if (!entry || typeof entry !== 'object') return null
+ const raw = entry as Record
+ const key = typeof raw.key === 'string' ? raw.key.trim() : ''
+ if (!key) return null
+
+ const versions: MarketplaceIndexVersion[] = []
+ if (Array.isArray(raw.versions)) {
+ for (const candidate of raw.versions) {
+ if (!candidate || typeof candidate !== 'object') continue
+ const rawVersion = candidate as Record
+ const version =
+ typeof rawVersion.version === 'string' ? rawVersion.version.trim() : ''
+ const path =
+ typeof rawVersion.path === 'string' ? rawVersion.path.trim() : ''
+ if (!version || !path) continue
+ const kind =
+ typeof rawVersion.kind === 'string' ? rawVersion.kind.trim() : ''
+ if (kind && kind !== SUPPORTED_PLUGIN_KIND) continue
+ versions.push({
+ version,
+ path,
+ sha256:
+ typeof rawVersion.sha256 === 'string'
+ ? rawVersion.sha256.trim()
+ : undefined,
+ minApiVersion: Number.isFinite(Number(rawVersion.minApiVersion))
+ ? Number(rawVersion.minApiVersion)
+ : undefined,
+ kind: kind || undefined,
+ allowedHosts: stringArray(rawVersion.allowedHosts),
+ auth: typeof rawVersion.auth === 'string' ? rawVersion.auth : undefined,
+ })
+ }
+ }
+ if (versions.length === 0) return null
+
+ const declaredLatest = typeof raw.latest === 'string' ? raw.latest.trim() : ''
+ const latest = versions.some((entry) => entry.version === declaredLatest)
+ ? declaredLatest
+ : versions[0].version
+
+ let icon: string | undefined
+ if (typeof raw.icon === 'string') {
+ const trimmed = raw.icon.trim()
+ if (trimmed && trimmed.length <= 128) {
+ icon = trimmed
+ }
+ }
+
+ return {
+ key,
+ name: typeof raw.name === 'string' && raw.name ? raw.name : key,
+ icon,
+ description: parseMarketplaceDescription(raw.description),
+ channelTypes: numberArray(raw.channelTypes),
+ models: stringArray(raw.models),
+ latest,
+ versions,
+ }
+}
+
+function parseMarketplaceDescription(
+ value: unknown
+): string | Record | undefined {
+ if (typeof value === 'string') return value
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return undefined
+ }
+ const mapped: Record = {}
+ for (const [locale, text] of Object.entries(
+ value as Record
+ )) {
+ if (typeof text === 'string') mapped[locale] = text
+ }
+ return Object.keys(mapped).length > 0 ? mapped : undefined
+}
+
+function stringArray(value: unknown): string[] | undefined {
+ if (!Array.isArray(value)) return undefined
+ const items = value.filter((item): item is string => typeof item === 'string')
+ return items.length > 0 ? items : undefined
+}
+
+function numberArray(value: unknown): number[] | undefined {
+ if (!Array.isArray(value)) return undefined
+ const items = value.filter(
+ (item): item is number => typeof item === 'number' && Number.isFinite(item)
+ )
+ return items.length > 0 ? items : undefined
+}
+
+export function findMarketplaceVersion(
+ plugin: MarketplacePlugin,
+ version: string
+): MarketplaceIndexVersion | undefined {
+ return plugin.versions.find((entry) => entry.version === version)
+}
+
+export type InstallState =
+ | { status: 'not_installed' }
+ | { status: 'up_to_date'; installedVersion: string }
+ | { status: 'upgradable'; installedVersion: string; latestVersion: string }
+ | { status: 'diverged'; installedVersion: string; latestVersion: string }
+
+/**
+ * Compares a marketplace entry against the gateway's installed plugins.
+ *
+ * `diverged` covers the case where a plugin is installed at a version the index
+ * does not list (locally uploaded, or the source rolled a version back): the UI
+ * must not call that an upgrade, because installing would move the gateway to a
+ * version it may already have moved away from deliberately.
+ */
+export function deriveInstallState(
+ plugin: MarketplacePlugin,
+ installed: TaskPluginListItem[]
+): InstallState {
+ const match = installed.find((item) => item.meta.key === plugin.key)
+ if (!match) return { status: 'not_installed' }
+
+ const installedVersion = match.meta.version
+ if (installedVersion === plugin.latest) {
+ return { status: 'up_to_date', installedVersion }
+ }
+ const known = plugin.versions.some(
+ (entry) => entry.version === installedVersion
+ )
+ if (!known) {
+ return {
+ status: 'diverged',
+ installedVersion,
+ latestVersion: plugin.latest,
+ }
+ }
+ return {
+ status: 'upgradable',
+ installedVersion,
+ latestVersion: plugin.latest,
+ }
+}
+
+/**
+ * A source is only integrity-checked when every listed version carries a
+ * sha256. Anything less and installs from it cannot be pinned, so the UI warns.
+ */
+export function indexHasIntegrityHashes(index: MarketplaceIndex): boolean {
+ return (
+ index.plugins.length > 0 &&
+ index.plugins.every((plugin) =>
+ plugin.versions.every((version) => Boolean(version.sha256))
+ )
+ )
+}
+
+export const DEFAULT_MARKETPLACE_INDEX_URL =
+ 'https://www.newapi.ai/api/v1/plugins/index.json'
+
+export const GITHUB_MARKETPLACE_INDEX_URL =
+ 'https://raw.githubusercontent.com/QuantumNous/new-api-plugins/main/index.json'
+
+/**
+ * Both built-in indexes are maintained by the project. Other configured
+ * sources get an explicit at-your-own-risk label.
+ */
+export function isDefaultMarketplaceSource(indexUrl: string): boolean {
+ const normalized = indexUrl.trim()
+ return (
+ normalized === DEFAULT_MARKETPLACE_INDEX_URL ||
+ normalized === GITHUB_MARKETPLACE_INDEX_URL
+ )
+}
diff --git a/web/src/features/task-plugins/lib/plugin-icon.ts b/web/src/features/task-plugins/lib/plugin-icon.ts
new file mode 100644
index 000000000000..c36f0059dbce
--- /dev/null
+++ b/web/src/features/task-plugins/lib/plugin-icon.ts
@@ -0,0 +1,87 @@
+/*
+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 { getChannelTypeIcon } from '@/features/channels/lib/channel-utils'
+
+export type PluginIconDescriptor =
+ | { kind: 'lobe'; name: string }
+ | { kind: 'text'; label: string; colorSeed: string }
+
+export type PluginIconInput = {
+ icon?: string
+ channelTypes?: number[] | null
+ key: string
+ name?: string
+}
+
+/**
+ * Resolves how a plugin logo should render.
+ *
+ * Priority: explicit `icon` (a LobeHub icon name, or the `text` /
+ * `text:` scheme for a generated text avatar), then the first declared
+ * channel type's icon, then a text avatar derived from the plugin name — so a
+ * plugin without any logo still gets a stable, branded-looking mark instead of
+ * a generic placeholder.
+ */
+export function resolvePluginIcon(input: PluginIconInput): PluginIconDescriptor {
+ const icon = input.icon?.trim()
+ if (icon) {
+ if (icon === 'text' || icon.startsWith('text:')) {
+ const explicit = icon.startsWith('text:') ? icon.slice(5).trim() : ''
+ return {
+ kind: 'text',
+ label: explicit ? explicit.slice(0, 4) : deriveTextLabel(input),
+ colorSeed: input.key,
+ }
+ }
+ return { kind: 'lobe', name: icon }
+ }
+ const channelTypes = input.channelTypes
+ if (channelTypes != null && channelTypes.length > 0) {
+ return { kind: 'lobe', name: `${getChannelTypeIcon(channelTypes[0])}.Color` }
+ }
+ return { kind: 'text', label: deriveTextLabel(input), colorSeed: input.key }
+}
+
+function deriveTextLabel(input: PluginIconInput): string {
+ const source = input.name?.trim() || input.key.trim()
+ return [...source].slice(0, 2).join('').toUpperCase()
+}
+
+/**
+ * Deterministic palette pick: the same plugin key always renders the same
+ * color, and every pair meets WCAG AA contrast in both themes.
+ */
+export const TEXT_AVATAR_PALETTE = [
+ 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100',
+ 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-100',
+ 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100',
+ 'bg-violet-100 text-violet-800 dark:bg-violet-900 dark:text-violet-100',
+ 'bg-rose-100 text-rose-800 dark:bg-rose-900 dark:text-rose-100',
+ 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-100',
+ 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-100',
+ 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-100',
+] as const
+
+export function textAvatarClass(colorSeed: string): string {
+ let hash = 0
+ for (let i = 0; i < colorSeed.length; i++) {
+ hash = (hash * 31 + colorSeed.charCodeAt(i)) | 0
+ }
+ return TEXT_AVATAR_PALETTE[Math.abs(hash) % TEXT_AVATAR_PALETTE.length]
+}
diff --git a/web/src/features/task-plugins/lib/plugin-url.ts b/web/src/features/task-plugins/lib/plugin-url.ts
new file mode 100644
index 000000000000..9d932aff1e0b
--- /dev/null
+++ b/web/src/features/task-plugins/lib/plugin-url.ts
@@ -0,0 +1,139 @@
+/*
+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
+*/
+/**
+ * Backend limit for a single plugin source (`maxTaskPluginSourceBytes` in
+ * controller/task_plugin.go). Enforced client-side too so an oversized fetch
+ * fails with a readable message instead of a server rejection.
+ */
+export const MAX_PLUGIN_SOURCE_BYTES = 1024 * 1024
+
+export function pluginSourceByteLength(source: string): number {
+ return new TextEncoder().encode(source).length
+}
+
+/**
+ * Rewrites human-facing code-hosting URLs to their raw-content equivalents, so
+ * pasting a GitHub or gist page URL fetches plugin source instead of HTML.
+ * Returns `null` when the input is not an absolute http(s) URL; every other URL
+ * is passed through unchanged and attempted as-is.
+ */
+export function normalizePluginSourceUrl(input: string): string | null {
+ const trimmed = input.trim()
+ if (!trimmed) return null
+
+ let parsed: URL
+ try {
+ parsed = new URL(trimmed)
+ } catch {
+ return null
+ }
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
+
+ // Line anchors (#L12-L20) are page-only and break raw requests.
+ parsed.hash = ''
+ const host = parsed.hostname.toLowerCase()
+ const segments = parsed.pathname.split('/').filter(Boolean)
+
+ if (host === 'github.com' || host === 'www.github.com') {
+ // github.com///{blob,raw}/[/]. Both forms need the
+ // raw host: github.com sends no CORS headers, and its /raw redirect is
+ // blocked before the redirect is ever followed. `[/]` is kept
+ // verbatim because raw.githubusercontent.com expects exactly that shape,
+ // slashes in branch names included. The query string is dropped: blob-only
+ // parameters such as ?plain=1 are meaningless on the raw host.
+ const isSourceView = segments[2] === 'blob' || segments[2] === 'raw'
+ if (isSourceView && segments.length > 4) {
+ const rest = segments.slice(3).join('/')
+ return `https://raw.githubusercontent.com/${segments[0]}/${segments[1]}/${rest}`
+ }
+ return parsed.toString()
+ }
+
+ if (host === 'gist.github.com' && segments.length > 0) {
+ // gist.github.com// renders HTML; the same path under
+ // gist.githubusercontent.com with a /raw suffix serves the file bytes.
+ const path = segments.join('/')
+ const suffix = segments.includes('raw') ? path : `${path}/raw`
+ return `https://gist.githubusercontent.com/${suffix}${parsed.search}`
+ }
+
+ return parsed.toString()
+}
+
+export type PluginSourceFetchFailure = 'unreachable' | 'not_found' | 'too_large'
+
+export class PluginSourceFetchError extends Error {
+ constructor(
+ public reason: PluginSourceFetchFailure,
+ public status?: number
+ ) {
+ super(reason)
+ }
+}
+
+/**
+ * Fetches plugin source in the browser. Every marketplace and URL-import fetch
+ * goes through here: the gateway never makes the outbound request, so there is
+ * no server-side SSRF surface.
+ */
+export async function fetchPluginSourceText(
+ url: string,
+ fetchImpl: typeof fetch = globalThis.fetch
+): Promise {
+ let response: Response
+ try {
+ response = await fetchImpl(url)
+ } catch {
+ throw new PluginSourceFetchError('unreachable')
+ }
+ if (!response.ok) {
+ throw new PluginSourceFetchError('not_found', response.status)
+ }
+ const declaredLength = Number(response.headers.get('content-length'))
+ if (
+ Number.isFinite(declaredLength) &&
+ declaredLength > MAX_PLUGIN_SOURCE_BYTES
+ ) {
+ throw new PluginSourceFetchError('too_large')
+ }
+ const text = await response.text()
+ if (pluginSourceByteLength(text) > MAX_PLUGIN_SOURCE_BYTES) {
+ throw new PluginSourceFetchError('too_large')
+ }
+ return text
+}
+
+/**
+ * SHA-256 of the source bytes, hex encoded. Returns `null` when WebCrypto is
+ * unavailable (an insecure-context deployment): the hash is only an early
+ * client-side check, the authoritative comparison happens on upload where the
+ * server re-hashes the bytes it received.
+ */
+export async function computeSourceSha256(
+ source: string
+): Promise {
+ if (!globalThis.crypto?.subtle) return null
+ const digest = await globalThis.crypto.subtle.digest(
+ 'SHA-256',
+ new TextEncoder().encode(source)
+ )
+ return [...new Uint8Array(digest)]
+ .map((byte) => byte.toString(16).padStart(2, '0'))
+ .join('')
+}
diff --git a/web/src/features/task-plugins/types.ts b/web/src/features/task-plugins/types.ts
new file mode 100644
index 000000000000..6099159c0d01
--- /dev/null
+++ b/web/src/features/task-plugins/types.ts
@@ -0,0 +1,151 @@
+/*
+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 type { BillingUsageSchema } from '@/features/pricing/types'
+
+export type TaskPluginProtocolClaim =
+ | string
+ | {
+ name: string
+ models?: string[]
+ supports?: ('stream' | 'sync' | 'background')[]
+ }
+
+/**
+ * One native route declared by `meta.routes` (backend `jsplugin.Route`). Only
+ * the fields the admin UI renders are typed; hook bindings such as `decode`,
+ * `render` and `action` are implementation detail of the plugin.
+ */
+export type TaskPluginRoute = {
+ method: string
+ path: string
+ type: 'submit' | 'query' | 'dynamic'
+ models?: string[]
+}
+
+export type TaskPluginMeta = {
+ apiVersion: number
+ key: string
+ name: string
+ icon?: string
+ description?: Record
+ version: string
+ author: {
+ name: string
+ url?: string
+ }
+ channelTypes?: number[] | null
+ models: string[] | null
+ fetchMode: string
+ routes?: TaskPluginRoute[]
+ protocols?: TaskPluginProtocolClaim[]
+ usageSchema?: BillingUsageSchema
+}
+
+export type TaskPluginRecord = {
+ id: number
+ key: string
+ api_version: number
+ version: string
+ source: string
+ source_hash: string
+ enabled: boolean
+ active: boolean
+ created_at: number
+ remark: string
+}
+
+export type TaskPluginListItem = {
+ meta: TaskPluginMeta
+ source: 'factory' | 'override' | 'override_over_factory'
+ enabled: boolean
+ active: boolean
+ source_hash: string
+ remark: string
+ runtime_status:
+ | 'registered'
+ | 'compile_failed'
+ | 'disabled'
+ | 'disabled_fallback'
+ | 'not_registered'
+ runtime_error?: string
+ factory_meta?: TaskPluginMeta
+ channel_count: number
+ in_flight_count: number
+}
+
+export type TaskPluginUsage = {
+ channels: Array<{ id: number; name: string }>
+ in_flight_count: number
+}
+
+export type TaskPluginDetail = {
+ plugin?: TaskPluginRecord
+ meta: TaskPluginMeta
+ source: string
+ layer: 'factory' | 'override'
+}
+
+export type ApiResponse = {
+ success: boolean
+ message: string
+ data: T
+}
+
+export type TaskPluginDryRunRequest = {
+ hook: string
+ member?: string
+ args: unknown[]
+}
+
+export type MarketplaceSource = {
+ name: string
+ index_url: string
+}
+
+/**
+ * A single installable version from a marketplace index. `allowedHosts`, `auth`
+ * and `sha256` are optional: older or hand-rolled indexes may omit them, and
+ * the confirmation dialog degrades to a warning rather than refusing to render.
+ */
+export type MarketplaceIndexVersion = {
+ version: string
+ path: string
+ sha256?: string
+ minApiVersion?: number
+ kind?: string
+ allowedHosts?: string[]
+ auth?: string
+}
+
+export type MarketplacePlugin = {
+ key: string
+ name: string
+ icon?: string
+ description?: string | Record
+ channelTypes?: number[]
+ models?: string[]
+ latest: string
+ versions: MarketplaceIndexVersion[]
+}
+
+export type MarketplaceIndex = {
+ indexVersion: number
+ name: string
+ plugins: MarketplacePlugin[]
+}
diff --git a/web/src/features/usage-logs/__tests__/access.test.ts b/web/src/features/usage-logs/__tests__/access.test.ts
new file mode 100644
index 000000000000..a5c4d699dce2
--- /dev/null
+++ b/web/src/features/usage-logs/__tests__/access.test.ts
@@ -0,0 +1,37 @@
+/*
+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 { describe, test } from 'vitest'
+
+import { ROLE } from '@/lib/roles'
+
+import { resolveLogsViewAccess } from '../components/usage-logs-provider'
+
+describe('usage log access tier', () => {
+ test('keeps users and elevated self views on the self tier', () => {
+ assert.equal(resolveLogsViewAccess(ROLE.USER, 'all'), 'self')
+ assert.equal(resolveLogsViewAccess(ROLE.ADMIN, 'self'), 'self')
+ assert.equal(resolveLogsViewAccess(ROLE.SUPER_ADMIN, 'self'), 'self')
+ })
+
+ test('distinguishes admin and root while viewing all logs', () => {
+ assert.equal(resolveLogsViewAccess(ROLE.ADMIN, 'all'), 'admin')
+ assert.equal(resolveLogsViewAccess(ROLE.SUPER_ADMIN, 'all'), 'root')
+ })
+})
diff --git a/web/src/features/usage-logs/__tests__/artifacts.test.ts b/web/src/features/usage-logs/__tests__/artifacts.test.ts
new file mode 100644
index 000000000000..52eb8cd3a93a
--- /dev/null
+++ b/web/src/features/usage-logs/__tests__/artifacts.test.ts
@@ -0,0 +1,357 @@
+/*
+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 { describe, test } from 'vitest'
+
+import {
+ getSafePluginAuthorUrl,
+ parseTaskArtifactsResponse,
+ resolveTaskPreviewMode,
+ shouldLoadTaskArtifacts,
+ TaskArtifactApiError,
+} from '../lib/task-artifacts'
+import type { TaskLog } from '../types'
+
+const artifactAccessToken = `${'A'.repeat(41)}-_`
+
+function artifactContentUrl(
+ artifactKey: string,
+ baseUrl = 'https://media.example.com/media-prefix'
+): string {
+ return `${baseUrl}/v1/tasks/task-public/artifacts/${artifactKey}/content?access=${artifactAccessToken}`
+}
+
+function taskFixture(overrides: Partial = {}): TaskLog {
+ return {
+ id: 1,
+ user_id: 7,
+ platform: 'openrouter',
+ task_id: 'task-public',
+ action: 'generate',
+ channel_id: 3,
+ group: 'default',
+ quota: 100,
+ submit_time: 1,
+ status: 'SUCCESS',
+ admin_info: {
+ task_plugin: {
+ key: 'openrouter-video',
+ name: 'OpenRouter Video',
+ version: '1.0.0',
+ },
+ },
+ ...overrides,
+ }
+}
+
+describe('task artifact projection', () => {
+ test('enables projection only after a successful artifact viewer opens', () => {
+ const successfulPluginTask = taskFixture()
+
+ assert.equal(shouldLoadTaskArtifacts(successfulPluginTask, false), false)
+ assert.equal(shouldLoadTaskArtifacts(successfulPluginTask, true), true)
+ assert.equal(
+ shouldLoadTaskArtifacts(taskFixture({ status: 'IN_PROGRESS' }), true),
+ false
+ )
+ assert.equal(
+ shouldLoadTaskArtifacts(taskFixture({ admin_info: undefined }), true),
+ true
+ )
+ })
+
+ test('accepts an empty artifact result without inventing a preview', () => {
+ assert.deepEqual(
+ parseTaskArtifactsResponse({
+ success: true,
+ data: { artifacts: [] },
+ }),
+ { artifacts: [] }
+ )
+ })
+
+ test('keeps stable absolute cross-origin content URLs', () => {
+ assert.deepEqual(
+ parseTaskArtifactsResponse({
+ success: true,
+ data: {
+ artifacts: [
+ {
+ key: 'video-main',
+ type: 'video',
+ mime_type: 'video/mp4',
+ content_url: artifactContentUrl('video-main'),
+ },
+ {
+ key: 'poster~main',
+ type: 'image',
+ mime_type: 'image/webp',
+ content_url: artifactContentUrl(
+ 'poster~main',
+ 'http://127.0.0.1:3001/nginx/tasks'
+ ),
+ },
+ {
+ key: 'result-file',
+ type: 'file',
+ content_url: artifactContentUrl(
+ 'result-file',
+ 'https://files.example.net'
+ ),
+ },
+ ],
+ legacy_content_url: artifactContentUrl(
+ 'video',
+ 'https://legacy-media.example.com/public'
+ ),
+ },
+ }),
+ {
+ artifacts: [
+ {
+ key: 'video-main',
+ type: 'video',
+ mime_type: 'video/mp4',
+ content_url: artifactContentUrl('video-main'),
+ },
+ {
+ key: 'poster~main',
+ type: 'image',
+ mime_type: 'image/webp',
+ content_url: artifactContentUrl(
+ 'poster~main',
+ 'http://127.0.0.1:3001/nginx/tasks'
+ ),
+ },
+ {
+ key: 'result-file',
+ type: 'file',
+ content_url: artifactContentUrl(
+ 'result-file',
+ 'https://files.example.net'
+ ),
+ },
+ ],
+ legacyContentUrl: artifactContentUrl(
+ 'video',
+ 'https://legacy-media.example.com/public'
+ ),
+ }
+ )
+ })
+
+ test('rejects failed, malformed, or duplicate artifact results', () => {
+ assert.throws(
+ () =>
+ parseTaskArtifactsResponse({
+ success: false,
+ message: 'plugin unavailable',
+ }),
+ TaskArtifactApiError
+ )
+ assert.throws(
+ () =>
+ parseTaskArtifactsResponse({
+ success: true,
+ data: {
+ artifacts: [
+ {
+ key: 'video-main',
+ type: 'video',
+ content_url: artifactContentUrl('video-main'),
+ },
+ {
+ key: 'video-main',
+ type: 'image',
+ content_url: artifactContentUrl('poster-main'),
+ },
+ ],
+ },
+ }),
+ TaskArtifactApiError
+ )
+ assert.throws(
+ () =>
+ parseTaskArtifactsResponse({
+ success: true,
+ data: {
+ artifacts: [
+ {
+ key: 'video:0',
+ type: 'video',
+ content_url: artifactContentUrl('video-main'),
+ },
+ ],
+ },
+ }),
+ TaskArtifactApiError
+ )
+ assert.throws(
+ () =>
+ parseTaskArtifactsResponse({
+ success: true,
+ data: {
+ artifacts: [
+ {
+ key: ' video-main',
+ type: 'video',
+ content_url: artifactContentUrl('video-main'),
+ },
+ ],
+ },
+ }),
+ TaskArtifactApiError
+ )
+ })
+
+ test('rejects unsafe or missing content URLs', () => {
+ const validContentUrl = artifactContentUrl('video-main')
+ const unsafeUrls: unknown[] = [
+ undefined,
+ 'javascript:alert(1)',
+ 'data:text/plain,artifact',
+ 'https:media.example.com/task',
+ '//media.example.com/task',
+ `/v1/tasks/task-public/artifacts/video-main/content?access=${artifactAccessToken}`,
+ '/\\media.example.com/task',
+ validContentUrl.replace('https://', 'https://user:secret@'),
+ validContentUrl.replace('https://', 'https://@'),
+ `${validContentUrl}#fragment`,
+ `${validContentUrl}#`,
+ ` ${validContentUrl}`,
+ `${validContentUrl}\n`,
+ 'https://media.example.com/video.mp4',
+ `https://media.example.com/v1/videos/task-public/content?access=${artifactAccessToken}`,
+ `https://media.example.com/v1/tasks/task-public/artifacts/video-main/content?token=${artifactAccessToken}`,
+ `https://media.example.com/v1/tasks/task-public/artifacts/video-main/content?access=${'A'.repeat(42)}`,
+ `${validContentUrl}&access=${artifactAccessToken}`,
+ `${validContentUrl}&download=1`,
+ ]
+
+ for (const contentUrl of unsafeUrls) {
+ assert.throws(
+ () =>
+ parseTaskArtifactsResponse({
+ success: true,
+ data: {
+ artifacts: [
+ {
+ key: 'video-main',
+ type: 'video',
+ content_url: contentUrl,
+ },
+ ],
+ },
+ }),
+ TaskArtifactApiError
+ )
+ }
+
+ assert.throws(
+ () =>
+ parseTaskArtifactsResponse({
+ success: true,
+ data: {
+ artifacts: [],
+ legacy_content_url: `https://media.example.com/v1/videos/task-public/content?access=${artifactAccessToken}`,
+ },
+ }),
+ TaskArtifactApiError
+ )
+ })
+})
+
+describe('legacy task preview compatibility', () => {
+ test('preserves old Suno and video previews without duplicating plugin previews', () => {
+ assert.equal(
+ resolveTaskPreviewMode(
+ taskFixture({
+ platform: 'suno',
+ admin_info: undefined,
+ data: [{ audio_url: 'https://media.example/audio.mp3' }],
+ })
+ ),
+ 'legacy-suno'
+ )
+ assert.equal(
+ resolveTaskPreviewMode(
+ taskFixture({
+ admin_info: undefined,
+ legacy_video_available: true,
+ })
+ ),
+ 'legacy-video'
+ )
+ assert.equal(
+ resolveTaskPreviewMode(
+ taskFixture({
+ admin_info: undefined,
+ legacy_video_available: true,
+ }),
+ true
+ ),
+ 'plugin'
+ )
+ assert.equal(
+ resolveTaskPreviewMode(
+ taskFixture({
+ legacy_video_available: true,
+ })
+ ),
+ 'plugin'
+ )
+ assert.equal(
+ resolveTaskPreviewMode(
+ taskFixture({
+ status: 'FAILURE',
+ legacy_video_available: true,
+ })
+ ),
+ 'none'
+ )
+ assert.equal(
+ resolveTaskPreviewMode(
+ taskFixture({
+ admin_info: undefined,
+ legacy_video_available: false,
+ })
+ ),
+ 'plugin'
+ )
+ })
+})
+
+describe('plugin author links', () => {
+ test('allows HTTP authors and rejects executable URL schemes', () => {
+ assert.equal(
+ getSafePluginAuthorUrl({
+ name: 'Community Maintainer',
+ url: 'https://plugins.example.com/maintainer',
+ }),
+ 'https://plugins.example.com/maintainer'
+ )
+ assert.equal(
+ getSafePluginAuthorUrl({
+ name: 'Unsafe Maintainer',
+ url: 'javascript:alert(1)',
+ }),
+ undefined
+ )
+ })
+})
diff --git a/web/src/features/usage-logs/__tests__/mobile-layout.test.ts b/web/src/features/usage-logs/__tests__/mobile-layout.test.ts
new file mode 100644
index 000000000000..cf681fae455f
--- /dev/null
+++ b/web/src/features/usage-logs/__tests__/mobile-layout.test.ts
@@ -0,0 +1,39 @@
+/*
+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 { describe, test } from 'vitest'
+
+import { TASK_MOBILE_SUMMARY_FIELDS } from '../lib/task-mobile-layout'
+
+describe('task log mobile layout', () => {
+ test('keeps plugin, channel, duration, progress, and artifacts visible in the summary', () => {
+ assert.deepEqual(
+ TASK_MOBILE_SUMMARY_FIELDS.map((field) => field.id),
+ [
+ 'submit_time',
+ 'user',
+ 'plugin',
+ 'channel_id',
+ 'duration',
+ 'progress',
+ 'artifacts',
+ ]
+ )
+ })
+})
diff --git a/web/src/features/usage-logs/__tests__/task-details.test.ts b/web/src/features/usage-logs/__tests__/task-details.test.ts
new file mode 100644
index 000000000000..afecb9557dab
--- /dev/null
+++ b/web/src/features/usage-logs/__tests__/task-details.test.ts
@@ -0,0 +1,78 @@
+/*
+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 { describe, test } from 'vitest'
+
+import { resolveTaskDetailAccess } from '../lib/task-details'
+import type { TaskLog } from '../types'
+
+const task: TaskLog = {
+ id: 1,
+ user_id: 7,
+ platform: 'document-parser',
+ task_id: 'task_public',
+ action: 'GENERATE',
+ channel_id: 3,
+ group: 'default',
+ quota: 100,
+ submit_time: 1,
+ status: 'SUCCESS',
+ admin_info: {
+ task_plugin: {
+ key: 'document-parser',
+ name: 'Document Parser',
+ version: '1.2.3',
+ author: {
+ name: 'Community Maintainer',
+ url: 'https://plugins.example.com/maintainers/community',
+ },
+ },
+ },
+ root_info: {
+ task_plugin: {
+ key: 'document-parser',
+ version: '1.2.3',
+ api_version: 1,
+ generation: 42,
+ },
+ upstream_task_id: 'upstream-private',
+ node_name: 'node-a',
+ },
+}
+
+describe('task detail access', () => {
+ test('does not expose elevated fields in a self view', () => {
+ assert.deepEqual(resolveTaskDetailAccess(task, false, false), {})
+ })
+
+ test('gives admins plugin identity without root diagnostics', () => {
+ assert.deepEqual(resolveTaskDetailAccess(task, true, false), {
+ plugin: task.admin_info?.task_plugin,
+ })
+ })
+
+ test('adds runtime and upstream diagnostics for root', () => {
+ assert.deepEqual(resolveTaskDetailAccess(task, true, true), {
+ plugin: task.admin_info?.task_plugin,
+ runtime: task.root_info?.task_plugin,
+ upstreamTaskId: 'upstream-private',
+ nodeName: 'node-a',
+ })
+ })
+})
diff --git a/web/src/features/usage-logs/api.ts b/web/src/features/usage-logs/api.ts
index 79b59c0b6b78..793010be4a7e 100644
--- a/web/src/features/usage-logs/api.ts
+++ b/web/src/features/usage-logs/api.ts
@@ -16,9 +16,10 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import { api } from '@/lib/api'
+import { api, type ApiRequestConfig } from '@/lib/api'
-import { buildQueryParams } from './lib/utils'
+import { buildQueryParams } from './lib/query-params'
+import { parseTaskArtifactsResponse } from './lib/task-artifacts'
import type {
GetLogsParams,
GetLogsResponse,
@@ -26,6 +27,7 @@ import type {
GetLogStatsResponse,
GetMidjourneyLogsParams,
GetTaskLogsParams,
+ TaskArtifactsResponse,
UserInfo,
} from './types'
@@ -110,3 +112,16 @@ export const getAllTaskLogs = (params: GetTaskLogsParams) =>
export const getUserTaskLogs = (params: GetTaskLogsParams) =>
fetchLogs('/api/task', params, false)
+
+const taskArtifactRequestConfig = {
+ skipBusinessError: true,
+ skipErrorHandler: true,
+} satisfies ApiRequestConfig
+
+export async function getTaskArtifacts(taskId: string) {
+ const response = await api.get(
+ `/api/task/${encodeURIComponent(taskId)}/artifacts`,
+ taskArtifactRequestConfig
+ )
+ return parseTaskArtifactsResponse(response.data)
+}
diff --git a/web/src/features/usage-logs/components/__tests__/usage-facts.test.tsx b/web/src/features/usage-logs/components/__tests__/usage-facts.test.tsx
new file mode 100644
index 000000000000..bffc5e4c5074
--- /dev/null
+++ b/web/src/features/usage-logs/components/__tests__/usage-facts.test.tsx
@@ -0,0 +1,166 @@
+/*
+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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { render, screen } from '@testing-library/react'
+import i18next from 'i18next'
+import { afterEach, beforeAll, describe, expect, test } from 'vitest'
+
+import type { UsageLog } from '../../data/schema'
+import type { LogOtherData } from '../../types'
+import { DetailsDialog } from '../dialogs/details-dialog'
+
+const i18nKeys = {
+ 'Log Details': 'Log Details',
+ Consume: 'Consume',
+ 'Billing Details': 'Billing Details',
+ 'Billing Mode': 'Billing Mode',
+ 'Per-token': 'Per-token',
+ 'Dynamic Pricing': 'Dynamic Pricing',
+ 'Matched Tier': 'Matched Tier',
+ 'Group Ratio': 'Group Ratio',
+ 'Total Cost': 'Total Cost',
+ 'Usage parameters': 'Usage parameters',
+}
+
+function makeLog(other: LogOtherData): UsageLog {
+ return {
+ id: 1,
+ user_id: 1,
+ created_at: 1,
+ type: 2,
+ content: '',
+ username: 'user',
+ token_name: 'token',
+ model_name: 'wan2.5-i2v-preview',
+ quota: 5000,
+ prompt_tokens: 0,
+ completion_tokens: 0,
+ use_time: 0,
+ is_stream: false,
+ channel: 1,
+ channel_name: '',
+ token_id: 1,
+ group: 'default',
+ ip: '',
+ other: JSON.stringify(other),
+ request_id: 'req-1',
+ upstream_request_id: '',
+ }
+}
+
+function renderDetails(other: LogOtherData): QueryClient {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ })
+ const freshAt = Date.now() + 60_000
+ queryClient.setQueryData(['status'], {}, { updatedAt: freshAt })
+ queryClient.setQueryData(
+ ['pricing'],
+ { data: [], vendors: [] },
+ { updatedAt: freshAt }
+ )
+
+ render(
+
+ undefined}
+ />
+
+ )
+ return queryClient
+}
+
+function rowValue(label: string): string | null {
+ return screen.getByText(label).nextElementSibling?.textContent ?? null
+}
+
+describe('usage facts billing details', () => {
+ const queryClients: QueryClient[] = []
+
+ beforeAll(() => {
+ i18next.addResourceBundle('en', 'translation', i18nKeys)
+ })
+
+ afterEach(() => {
+ for (const queryClient of queryClients) {
+ queryClient.clear()
+ }
+ queryClients.length = 0
+ })
+
+ test('renders one raw-key row per usage fact before total cost', () => {
+ const expression = 'tier("720P", u("seconds") * 5)'
+ queryClients.push(
+ renderDetails({
+ group_ratio: 1,
+ billing_mode: 'tiered_expr',
+ expr_b64: Buffer.from(expression, 'utf8').toString('base64'),
+ matched_tier: '720P',
+ usage_facts: {
+ resolution: '720P',
+ seconds: 5,
+ },
+ })
+ )
+
+ expect(screen.getByText('Usage parameters')).toBeInTheDocument()
+ expect(rowValue('resolution')).toBe('720P')
+ expect(rowValue('seconds')).toBe('5')
+ expect(rowValue('Billing Mode')).toBe('Dynamic Pricing')
+ expect(rowValue('Matched Tier')).toBe('720P')
+
+ const usageHeader = screen.getByText('Usage parameters')
+ const totalCost = screen.getByText('Total Cost')
+ expect(
+ usageHeader.compareDocumentPosition(totalCost) &
+ Node.DOCUMENT_POSITION_FOLLOWING
+ ).toBeTruthy()
+ })
+
+ test('does not render usage parameter rows when usage_facts is absent', () => {
+ queryClients.push(
+ renderDetails({
+ group_ratio: 1,
+ })
+ )
+
+ expect(screen.queryByText('Usage parameters')).toBeNull()
+ expect(screen.queryByText('resolution')).toBeNull()
+ expect(screen.queryByText('seconds')).toBeNull()
+ expect(screen.getByText('Total Cost')).toBeInTheDocument()
+ })
+
+ test('does not render usage parameter rows when usage_facts is empty', () => {
+ queryClients.push(
+ renderDetails({
+ group_ratio: 1,
+ usage_facts: {},
+ })
+ )
+
+ expect(screen.queryByText('Usage parameters')).toBeNull()
+ expect(screen.queryByText('resolution')).toBeNull()
+ expect(screen.queryByText('seconds')).toBeNull()
+ expect(screen.getByText('Total Cost')).toBeInTheDocument()
+ })
+})
diff --git a/web/src/features/usage-logs/components/columns/common-logs-columns.tsx b/web/src/features/usage-logs/components/columns/common-logs-columns.tsx
index e7b29441e2f3..f5c1f2daa5be 100644
--- a/web/src/features/usage-logs/components/columns/common-logs-columns.tsx
+++ b/web/src/features/usage-logs/components/columns/common-logs-columns.tsx
@@ -101,14 +101,22 @@ function buildDetailSegments(
isAdmin: boolean
): DetailSegment[] {
const segments = buildTypeDetailSegments(log, other, t)
+ const adminSegments: DetailSegment[] = []
// Quota saturation is a rare, admin-only anomaly marker; surface it first
// and in danger styling so it stands out on the related billing log. The
// backend already strips admin_info for non-admins; gate on isAdmin too as
// defense in depth so the marker never leaks if that changes.
if (isAdmin && other?.admin_info?.quota_saturation) {
- return [{ text: t('Quota clamped'), danger: true }, ...segments]
+ adminSegments.push({ text: t('Quota clamped'), danger: true })
}
- return segments
+ const plugin = isAdmin ? other?.admin_info?.task_plugin : undefined
+ if (plugin) {
+ const version = plugin.version ? ` @ ${plugin.version}` : ''
+ adminSegments.push({
+ text: `${t('Plugin')}: ${plugin.name || plugin.key}${version}`,
+ })
+ }
+ return [...adminSegments, ...segments]
}
function buildTypeDetailSegments(
@@ -283,7 +291,10 @@ function buildTypeDetailSegments(
return segments
}
-export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] {
+export function useCommonLogsColumns(
+ isAdmin: boolean,
+ isRoot: boolean
+): ColumnDef[] {
const { t } = useTranslation()
const columns: ColumnDef[] = [
{
@@ -635,6 +646,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] {
return (
@@ -778,6 +790,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] {
diff --git a/web/src/features/usage-logs/components/columns/task-logs-columns.tsx b/web/src/features/usage-logs/components/columns/task-logs-columns.tsx
index 24836e627846..440b1b41da39 100644
--- a/web/src/features/usage-logs/components/columns/task-logs-columns.tsx
+++ b/web/src/features/usage-logs/components/columns/task-logs-columns.tsx
@@ -16,10 +16,11 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
+import { ViewIcon } from '@hugeicons/core-free-icons'
+import { HugeiconsIcon } from '@hugeicons/react'
import type { ColumnDef } from '@tanstack/react-table'
-import { Music } from 'lucide-react'
/* eslint-disable react-refresh/only-export-components */
-import { useState, useMemo } from 'react'
+import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { StatusBadge } from '@/components/status-badge'
@@ -28,14 +29,11 @@ import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
import { formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
-import { TASK_ACTIONS, TASK_STATUS } from '../../constants'
import { taskActionMapper, taskStatusMapper } from '../../lib/mappers'
import type { TaskLog } from '../../types'
-import {
- AudioPreviewDialog,
- type AudioClip,
-} from '../dialogs/audio-preview-dialog'
-import { FailReasonDialog } from '../dialogs/fail-reason-dialog'
+import { TaskDetailsDialog } from '../dialogs/task-details-dialog'
+import { PluginAuthorLink } from '../plugin-author-link'
+import { TaskArtifactsCell } from '../task-artifacts'
import { useUsageLogsContext } from '../usage-logs-provider'
import {
createDurationColumn,
@@ -43,54 +41,51 @@ import {
createProgressColumn,
} from './column-helpers'
-function parseTaskData(data: unknown): unknown[] {
- if (Array.isArray(data)) return data
- if (typeof data === 'string') {
- try {
- const parsed = JSON.parse(data)
- return Array.isArray(parsed) ? parsed : []
- } catch {
- return []
- }
- }
- return []
-}
-
-function AudioPreviewCell({ log }: { log: TaskLog }) {
+function TaskDetailsCell(props: {
+ log: TaskLog
+ isAdmin: boolean
+ isRoot: boolean
+}) {
const { t } = useTranslation()
- const [open, setOpen] = useState(false)
- const clips = useMemo(() => {
- const data = parseTaskData(log.data)
- return data.filter(
- (c) =>
- c && typeof c === 'object' && (c as Record).audio_url
- )
- }, [log.data])
-
- if (clips.length === 0) return null
+ const [dialogOpen, setDialogOpen] = useState(false)
return (
<>
- setOpen(true)}
- >
-
-
- {t('Click to preview audio')}
-
-
-
+ setDialogOpen(true)}
+ >
+
+ {t('View details')}
+
+ {props.log.fail_reason ? (
+
+ {props.log.fail_reason}
+
+ ) : null}
+
+
>
)
}
-export function useTaskLogsColumns(isAdmin: boolean): ColumnDef
[] {
+export function useTaskLogsColumns(
+ isAdmin: boolean,
+ isRoot: boolean
+): ColumnDef[] {
const { t } = useTranslation()
const columns: ColumnDef[] = [
{
@@ -120,46 +115,80 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef[] {
]
if (isAdmin) {
- columns.push(createChannelColumn({ headerLabel: t('Channel') }), {
- id: 'user',
- header: t('User'),
- accessorFn: (row) => row.username || row.user_id,
- cell: function UserCell({ row }) {
- const { sensitiveVisible, setSelectedUserId, setUserInfoDialogOpen } =
- useUsageLogsContext()
- const log = row.original
- const displayName = log.username || String(log.user_id || '?')
+ columns.push(
+ createChannelColumn({ headerLabel: t('Channel') }),
+ {
+ id: 'user',
+ header: t('User'),
+ accessorFn: (row) => row.username || row.user_id,
+ cell: function UserCell({ row }) {
+ const { sensitiveVisible, setSelectedUserId, setUserInfoDialogOpen } =
+ useUsageLogsContext()
+ const log = row.original
+ const displayName = log.username || String(log.user_id || '?')
- return (
- {
- e.stopPropagation()
- setSelectedUserId(log.user_id)
- setUserInfoDialogOpen(true)
- }}
- >
-
-
- {sensitiveVisible ? getUserAvatarFallback(displayName) : '•'}
-
-
-
- {sensitiveVisible ? displayName : '••••'}
-
-
- )
+ return (
+ {
+ e.stopPropagation()
+ setSelectedUserId(log.user_id)
+ setUserInfoDialogOpen(true)
+ }}
+ >
+
+
+ {sensitiveVisible ? getUserAvatarFallback(displayName) : '•'}
+
+
+
+ {sensitiveVisible ? displayName : '••••'}
+
+
+ )
+ },
},
- })
+ {
+ id: 'plugin',
+ header: t('Plugin'),
+ accessorFn: (row) => row.admin_info?.task_plugin?.key ?? '',
+ cell: ({ row }) => {
+ const plugin = row.original.admin_info?.task_plugin
+ if (!plugin) {
+ return -
+ }
+ return (
+
+
+ {plugin.name || plugin.key}
+
+
+ {plugin.key}
+ {plugin.version ? ` @ ${plugin.version}` : ''}
+
+ {plugin.author ? (
+
+ ) : null}
+
+ )
+ },
+ }
+ )
}
columns.push(
@@ -213,80 +242,28 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef[] {
},
},
createProgressColumn({ headerLabel: t('Progress') }),
+ {
+ id: 'artifacts',
+ header: t('Artifacts'),
+ cell: ({ row }) => (
+
+ ),
+ size: 120,
+ maxSize: 140,
+ },
{
accessorKey: 'fail_reason',
header: t('Details'),
- cell: function DetailsCell({ row }) {
- const log = row.original
- const failReason = row.getValue('fail_reason') as string
- const status = log.status
- const [dialogOpen, setDialogOpen] = useState(false)
-
- const isSunoSuccess =
- log.platform === 'suno' && status === TASK_STATUS.SUCCESS
- if (isSunoSuccess) {
- const data = parseTaskData(log.data)
- if (
- data.some(
- (c) =>
- c &&
- typeof c === 'object' &&
- (c as Record).audio_url
- )
- ) {
- return
- }
- }
-
- const isVideoTask =
- log.action === TASK_ACTIONS.GENERATE ||
- log.action === TASK_ACTIONS.TEXT_GENERATE ||
- log.action === TASK_ACTIONS.FIRST_TAIL_GENERATE ||
- log.action === TASK_ACTIONS.REFERENCE_GENERATE ||
- log.action === TASK_ACTIONS.REMIX_GENERATE
- const isSuccess = status === TASK_STATUS.SUCCESS
- const isUrl = failReason?.startsWith('http')
-
- if (isSuccess && isVideoTask && isUrl) {
- const videoUrl = `/v1/videos/${log.task_id}/content`
- return (
-
- {t('Click to preview video')}
-
- )
- }
-
- if (!failReason) {
- return -
- }
-
- return (
- <>
- setDialogOpen(true)}
- title={t('Click to view full error message')}
- >
-
- {failReason}
-
-
-
- >
- )
- },
- size: 200,
- maxSize: 220,
+ cell: ({ row }) => (
+
+ ),
+ size: 220,
+ maxSize: 240,
}
)
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 f30e35a86555..2f0442c6c3ef 100644
--- a/web/src/features/usage-logs/components/dialogs/details-dialog.tsx
+++ b/web/src/features/usage-logs/components/dialogs/details-dialog.tsx
@@ -58,6 +58,7 @@ import { Button } from '@/components/ui/button'
import { IconBadge, type IconBadgeTone } from '@/components/ui/icon-badge'
import { Label } from '@/components/ui/label'
import { DynamicPricingBreakdown } from '@/features/pricing/components/dynamic-pricing-breakdown'
+import { usePricingData } from '@/features/pricing/hooks/use-pricing-data'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { formatBillingCurrencyFromUSD } from '@/lib/currency'
import { formatLogQuota, formatTokens, formatUseTime } from '@/lib/format'
@@ -83,6 +84,7 @@ import {
isTimingLogType,
} from '../../lib/utils'
import { USAGE_BILLING_PATH, type LogOtherData } from '../../types'
+import { PluginAuthorLink } from '../plugin-author-link'
// Maps a channel-update changed-field token (as recorded by the backend audit)
// to its i18n label key for display in the audit details.
@@ -388,18 +390,38 @@ function BillingBreakdown(props: {
})
}
- rows.push({
- label: t('Total Cost'),
- value: formatLogQuota(log.quota),
- })
-
- if (rows.length === 0) return null
+ const usageFacts =
+ other.usage_facts != null &&
+ typeof other.usage_facts === 'object' &&
+ !Array.isArray(other.usage_facts)
+ ? Object.entries(other.usage_facts)
+ : []
return (
{rows.map((row) => (
))}
+ {usageFacts.length > 0 && (
+ <>
+
+ {t('Usage parameters')}
+
+ {usageFacts.map(([key, value]) => (
+
+ ))}
+ >
+ )}
+
)
}
@@ -473,6 +495,7 @@ function TokenBreakdown(props: { log: UsageLog; other: LogOtherData }) {
interface DetailsDialogProps {
log: UsageLog
isAdmin: boolean
+ isRoot: boolean
open: boolean
onOpenChange: (open: boolean) => void
}
@@ -495,6 +518,10 @@ export function DetailsDialog(props: DetailsDialogProps) {
!isViolation &&
other?.billing_mode === 'tiered_expr' &&
!!other?.expr_b64
+ const pricingData = usePricingData(props.open && isTieredBilling)
+ const billingUsageSchema = pricingData.models.find(
+ (model) => model.model_name === props.log.model_name
+ )?.billing_usage_schema
const hasAudioTokens = other?.ws || other?.audio
const showTiming = isTimingLogType(props.log.type)
const showAdminIp =
@@ -864,6 +891,68 @@ export function DetailsDialog(props: DetailsDialogProps) {
)}
+ {props.isAdmin && adminInfo?.task_plugin ? (
+
+
+
+ {adminInfo.task_plugin.version ? (
+
+ ) : null}
+ {adminInfo.task_plugin.author ? (
+
+ }
+ />
+ ) : null}
+
+ ) : null}
+
+ {props.isRoot && other?.root_info ? (
+
+ {other.root_info.task_plugin ? (
+ <>
+
+
+ >
+ ) : null}
+ {other.root_info.upstream_task_id ? (
+
+ ) : null}
+ {other.root_info.node_name ? (
+
+ ) : null}
+
+ ) : null}
+
{/* Top-up audit info (type=1, admin only) */}
{showTopupAuditSection && (
)}
diff --git a/web/src/features/usage-logs/components/dialogs/task-details-dialog.tsx b/web/src/features/usage-logs/components/dialogs/task-details-dialog.tsx
new file mode 100644
index 000000000000..601c4bbff089
--- /dev/null
+++ b/web/src/features/usage-logs/components/dialogs/task-details-dialog.tsx
@@ -0,0 +1,266 @@
+/*
+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 { Shield01Icon, Wrench01Icon } from '@hugeicons/core-free-icons'
+import { HugeiconsIcon } from '@hugeicons/react'
+import { useTranslation } from 'react-i18next'
+
+import { Dialog } from '@/components/dialog'
+import { StatusBadge } from '@/components/status-badge'
+import { Label } from '@/components/ui/label'
+import { formatLogQuota, formatTimestampToDate } from '@/lib/format'
+import { cn } from '@/lib/utils'
+
+import { taskActionMapper, taskStatusMapper } from '../../lib/mappers'
+import { resolveTaskDetailAccess } from '../../lib/task-details'
+import type { TaskLog } from '../../types'
+import { PluginAuthorLink } from '../plugin-author-link'
+
+function DetailRow(props: {
+ label: React.ReactNode
+ value: React.ReactNode
+ mono?: boolean
+}) {
+ return (
+
+ {props.label}
+
+ {props.value}
+
+
+ )
+}
+
+function DetailSection(props: {
+ label: string
+ icon?: React.ReactNode
+ children: React.ReactNode
+}) {
+ return (
+
+
+ {props.icon}
+ {props.label}
+
+
+ {props.children}
+
+
+ )
+}
+
+function formatTaskTimestamp(value?: number): string {
+ return value ? formatTimestampToDate(value, 'seconds') : '-'
+}
+
+interface TaskDetailsDialogProps {
+ log: TaskLog
+ isAdmin: boolean
+ isRoot: boolean
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function TaskDetailsDialog(props: TaskDetailsDialogProps) {
+ const { t } = useTranslation()
+ const access = resolveTaskDetailAccess(props.log, props.isAdmin, props.isRoot)
+ const plugin = access.plugin
+ const runtime = access.runtime
+ const properties = props.log.properties
+
+ return (
+
+ {t('Task Details')}
+
+
+ }
+ description={t('View the complete details for this task')}
+ contentClassName='min-w-0 overflow-hidden sm:max-w-2xl'
+ contentHeight='min(72dvh, 720px)'
+ bodyClassName='pr-2 sm:pr-4'
+ >
+
+
+
+
+
+
+
+
+
+ {properties?.origin_model_name ? (
+
+ ) : null}
+ {properties?.upstream_model_name ? (
+
+ ) : null}
+ {props.log.fail_reason ? (
+
+ ) : null}
+
+
+ {props.isAdmin ? (
+
+ }
+ >
+
+
+
+
+ {props.log.admin_info?.request_id ? (
+
+ ) : null}
+ {props.log.admin_info?.request_path ? (
+
+ ) : null}
+ {plugin ? (
+ <>
+
+
+
+ {plugin.author ? (
+ }
+ />
+ ) : null}
+ >
+ ) : null}
+
+ ) : null}
+
+ {props.isRoot && props.log.root_info ? (
+
+ }
+ >
+ {runtime ? (
+ <>
+
+
+ >
+ ) : null}
+ {access.upstreamTaskId ? (
+
+ ) : null}
+ {access.nodeName ? (
+
+ ) : null}
+
+ ) : null}
+
+
+ )
+}
diff --git a/web/src/features/usage-logs/components/plugin-author-link.tsx b/web/src/features/usage-logs/components/plugin-author-link.tsx
new file mode 100644
index 000000000000..f32feb43ec00
--- /dev/null
+++ b/web/src/features/usage-logs/components/plugin-author-link.tsx
@@ -0,0 +1,65 @@
+/*
+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 { LinkSquare01Icon } from '@hugeicons/core-free-icons'
+import { HugeiconsIcon } from '@hugeicons/react'
+
+import { cn } from '@/lib/utils'
+
+import { getSafePluginAuthorUrl } from '../lib/task-artifacts'
+import type { TaskPluginAuthor } from '../types'
+
+interface PluginAuthorLinkProps {
+ author: TaskPluginAuthor
+ showUrl?: boolean
+ className?: string
+}
+
+export function PluginAuthorLink(props: PluginAuthorLinkProps) {
+ const authorUrl = getSafePluginAuthorUrl(props.author)
+ const authorName = props.author.name.trim()
+ if (!authorName) return null
+
+ if (!authorUrl) {
+ return {authorName}
+ }
+
+ return (
+
+
+ {authorName}
+
+
+ {props.showUrl ? (
+
+ {authorUrl}
+
+ ) : null}
+
+ )
+}
diff --git a/web/src/features/usage-logs/components/task-artifacts.tsx b/web/src/features/usage-logs/components/task-artifacts.tsx
new file mode 100644
index 000000000000..33155c14e1aa
--- /dev/null
+++ b/web/src/features/usage-logs/components/task-artifacts.tsx
@@ -0,0 +1,502 @@
+/*
+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 {
+ Alert02Icon,
+ Download01Icon,
+ File01Icon,
+ Image01Icon,
+ MusicNote01Icon,
+ RefreshIcon,
+ Video01Icon,
+} from '@hugeicons/core-free-icons'
+import { HugeiconsIcon } from '@hugeicons/react'
+import { useQuery } from '@tanstack/react-query'
+import { useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+
+import { Dialog } from '@/components/dialog'
+import {
+ Alert,
+ AlertAction,
+ AlertDescription,
+ AlertTitle,
+} from '@/components/ui/alert'
+import { Button } from '@/components/ui/button'
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card'
+import {
+ Empty,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+} from '@/components/ui/empty'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Spinner } from '@/components/ui/spinner'
+import { cn } from '@/lib/utils'
+
+import { getTaskArtifacts } from '../api'
+import {
+ resolveTaskPreviewMode,
+ shouldLoadTaskArtifacts,
+} from '../lib/task-artifacts'
+import type { TaskArtifact, TaskArtifactType, TaskLog } from '../types'
+import {
+ AudioPreviewDialog,
+ type AudioClip,
+} from './dialogs/audio-preview-dialog'
+
+function artifactIcon(type: TaskArtifactType) {
+ switch (type) {
+ case 'image':
+ return Image01Icon
+ case 'video':
+ return Video01Icon
+ case 'audio':
+ return MusicNote01Icon
+ case 'file':
+ return File01Icon
+ }
+}
+
+function artifactTypeLabel(type: TaskArtifactType): string {
+ switch (type) {
+ case 'image':
+ return 'Image'
+ case 'video':
+ return 'Video'
+ case 'audio':
+ return 'Audio'
+ case 'file':
+ return 'File'
+ }
+}
+
+function parseLegacyAudioClips(data: unknown): AudioClip[] {
+ let values: unknown[] = []
+ if (Array.isArray(data)) {
+ values = data
+ } else if (typeof data === 'string') {
+ try {
+ const parsed = JSON.parse(data)
+ values = Array.isArray(parsed) ? parsed : []
+ } catch {
+ return []
+ }
+ }
+
+ return values.filter(
+ (value): value is AudioClip =>
+ value != null &&
+ typeof value === 'object' &&
+ typeof (value as Record).audio_url === 'string'
+ )
+}
+
+function LegacyAudioPreview(props: { data: unknown }) {
+ const { t } = useTranslation()
+ const [open, setOpen] = useState(false)
+ const clips = useMemo(() => parseLegacyAudioClips(props.data), [props.data])
+
+ if (clips.length === 0) return null
+
+ return (
+ <>
+ setOpen(true)}
+ >
+
+
+ {t('Click to preview audio')}
+
+
+
+ >
+ )
+}
+
+function ArtifactMedia(props: {
+ artifact: TaskArtifact
+ mediaUrl: string
+ onError: () => void
+}) {
+ if (props.artifact.type === 'image') {
+ return (
+
+ )
+ }
+ if (props.artifact.type === 'video') {
+ return (
+
+ )
+ }
+ if (props.artifact.type === 'audio') {
+ return (
+
+ )
+ }
+ return null
+}
+
+function MediaFailure(props: { onRetry: () => void }) {
+ const { t } = useTranslation()
+ return (
+
+
+ {t('Media preview failed. Please try again.')}
+ {t('Preview unavailable')}
+
+
+
+ {t('Retry')}
+
+
+
+ )
+}
+
+function TaskArtifactCard(props: { artifact: TaskArtifact }) {
+ const { t } = useTranslation()
+ const [mediaFailed, setMediaFailed] = useState(false)
+ const [mediaRevision, setMediaRevision] = useState(0)
+ const icon = artifactIcon(props.artifact.type)
+ const isVisualArtifact =
+ props.artifact.type === 'image' || props.artifact.type === 'video'
+
+ let cardContent = (
+
+
+
+ )
+ if (mediaFailed) {
+ cardContent = (
+ {
+ setMediaFailed(false)
+ setMediaRevision((revision) => revision + 1)
+ }}
+ />
+ )
+ } else if (props.artifact.type !== 'file') {
+ cardContent = (
+ setMediaFailed(true)}
+ />
+ )
+ }
+
+ return (
+
+
+
+
+
+ {t(artifactTypeLabel(props.artifact.type))}
+
+
+
+
+ {props.artifact.key}
+
+ {props.artifact.mime_type ? (
+
+ {props.artifact.mime_type}
+
+ ) : null}
+
+
+ {cardContent}
+
+
+ }
+ >
+
+ {t('Download')}
+
+
+
+ )
+}
+
+interface TaskArtifactsProps {
+ taskId: string
+ enabled: boolean
+ emptyContent?: (legacyContentUrl?: string) => React.ReactNode
+}
+
+function TaskArtifacts(props: TaskArtifactsProps) {
+ const { t } = useTranslation()
+ const artifactsQuery = useQuery({
+ queryKey: ['usage-logs', 'task-artifacts', props.taskId],
+ queryFn: () => getTaskArtifacts(props.taskId),
+ enabled: props.enabled,
+ retry: false,
+ staleTime: 30_000,
+ })
+
+ if (!props.enabled) return null
+
+ if (artifactsQuery.isPending) {
+ return (
+
+
+
+ )
+ }
+
+ if (artifactsQuery.isError) {
+ return (
+
+
+ {t('Failed to load artifacts')}
+ {t('Preview unavailable')}
+
+ void artifactsQuery.refetch()}
+ >
+ {artifactsQuery.isFetching ? (
+
+ ) : (
+
+ )}
+ {t('Retry')}
+
+
+
+ )
+ }
+
+ if (artifactsQuery.data.artifacts.length === 0) {
+ return (
+ props.emptyContent?.(artifactsQuery.data.legacyContentUrl) ?? (
+
+ )
+ )
+ }
+
+ return (
+ 1 && 'lg:grid-cols-2'
+ )}
+ >
+ {artifactsQuery.data.artifacts.map((artifact) => (
+
+ ))}
+
+ )
+}
+
+function EmptyTaskArtifacts() {
+ const { t } = useTranslation()
+ return (
+
+
+
+
+
+ {t('Artifacts')}
+ {t('None')}
+
+
+ )
+}
+
+function LegacyTaskArtifacts(props: { legacyContentUrl?: string }) {
+ if (props.legacyContentUrl) {
+ return
+ }
+ return
+}
+
+export function TaskArtifactsCell(props: { log: TaskLog }) {
+ const { t } = useTranslation()
+ const [open, setOpen] = useState(false)
+ const previewMode = resolveTaskPreviewMode(props.log)
+
+ if (!shouldLoadTaskArtifacts(props.log, true)) {
+ return -
+ }
+ if (previewMode === 'legacy-suno') {
+ return
+ }
+
+ return (
+ <>
+ {previewMode === 'legacy-video' ? (
+ setOpen(true)}
+ >
+ {t('Click to preview video')}
+
+ ) : (
+ setOpen(true)}
+ >
+
+ {t('Artifacts')}
+
+ )}
+
+
+ {t('Artifacts')}
+
+ )
+ }
+ contentClassName={
+ previewMode === 'legacy-video' ? 'sm:max-w-xl' : 'sm:max-w-4xl'
+ }
+ contentHeight='auto'
+ bodyClassName='pr-2 sm:pr-4'
+ >
+ (
+
+ )}
+ />
+
+ >
+ )
+}
+
+interface LegacyVideoMediaProps {
+ contentUrl: string
+}
+
+function LegacyVideoMedia(props: LegacyVideoMediaProps) {
+ const [mediaFailed, setMediaFailed] = useState(false)
+ const [mediaRevision, setMediaRevision] = useState(0)
+
+ return mediaFailed ? (
+ {
+ setMediaFailed(false)
+ setMediaRevision((revision) => revision + 1)
+ }}
+ />
+ ) : (
+ setMediaFailed(true)}
+ />
+ )
+}
diff --git a/web/src/features/usage-logs/components/timing-metrics-cell.tsx b/web/src/features/usage-logs/components/timing-metrics-cell.tsx
index 74a17c137963..d2ed62ab644e 100644
--- a/web/src/features/usage-logs/components/timing-metrics-cell.tsx
+++ b/web/src/features/usage-logs/components/timing-metrics-cell.tsx
@@ -151,6 +151,8 @@ export function TimingMetricsCell(props: TimingMetricsCellProps) {
interface StreamTpsCellProps {
isStream: boolean
+ /** Task logs are asynchronous jobs; stream vs non-stream does not apply. */
+ isTask?: boolean
tokensPerSecond?: number | null
streamStatus?: LogOtherData['stream_status']
className?: string
@@ -164,7 +166,10 @@ export function StreamTpsCell(props: StreamTpsCellProps) {
props.tokensPerSecond != null
? `${Math.round(props.tokensPerSecond)} t/s`
: '—'
- const streamLabel = props.isStream ? t('Stream') : t('Non-stream')
+ let streamLabel = props.isStream ? t('Stream') : t('Non-stream')
+ if (props.isTask) {
+ streamLabel = t('Async')
+ }
return (
({
const taskIdCell = cells.get('task_id')
const statusCell = cells.get('status')
- const submitTimeCell = cells.get('submit_time')
return (
@@ -388,10 +389,16 @@ function TaskLogsCard({
-
-
+ {TASK_MOBILE_SUMMARY_FIELDS.map((field) => (
+
+ ))}
diff --git a/web/src/features/usage-logs/components/usage-logs-provider.tsx b/web/src/features/usage-logs/components/usage-logs-provider.tsx
index 50fa0ed30f85..930634c4efea 100644
--- a/web/src/features/usage-logs/components/usage-logs-provider.tsx
+++ b/web/src/features/usage-logs/components/usage-logs-provider.tsx
@@ -19,11 +19,21 @@ For commercial licensing, please contact support@quantumnous.com
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useState, type ReactNode } from 'react'
-import { useIsAdmin } from '@/hooks/use-admin'
+import { ROLE } from '@/lib/roles'
+import { useAuthStore } from '@/stores/auth-store'
import type { ChannelAffinityInfo } from '../types'
export type LogsViewScope = 'all' | 'self'
+export type LogsViewAccess = 'self' | 'admin' | 'root'
+
+export function resolveLogsViewAccess(
+ role: number,
+ viewScope: LogsViewScope
+): LogsViewAccess {
+ if (viewScope !== 'all' || role < ROLE.ADMIN) return 'self'
+ return role === ROLE.SUPER_ADMIN ? 'root' : 'admin'
+}
interface UsageLogsContextValue {
selectedUserId: number | null
@@ -92,13 +102,19 @@ export function useUsageLogsContext() {
* mine" is treated exactly like a regular user for that view.
*/
export function useLogsViewScope() {
- const canManageScope = useIsAdmin()
+ const role = useAuthStore((state) => state.auth.user?.role ?? ROLE.GUEST)
const { viewScope, setViewScope } = useUsageLogsContext()
+ const canManageScope = role >= ROLE.ADMIN
+ const viewAccess = resolveLogsViewAccess(role, viewScope)
+ const isAdminView = viewAccess !== 'self'
+ const isRootView = viewAccess === 'root'
return {
canManageScope,
viewScope,
setViewScope,
- isAdminView: canManageScope && viewScope === 'all',
+ isAdminView,
+ isRootView,
+ viewAccess,
}
}
diff --git a/web/src/features/usage-logs/components/usage-logs-table.tsx b/web/src/features/usage-logs/components/usage-logs-table.tsx
index bb961239a946..8d57cf3a7836 100644
--- a/web/src/features/usage-logs/components/usage-logs-table.tsx
+++ b/web/src/features/usage-logs/components/usage-logs-table.tsx
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
-import { type ColumnDef } from '@tanstack/react-table'
+import type { ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -43,7 +43,7 @@ import type { LogCategory } from '../types'
import { CommonLogsFilterBar } from './common-logs-filter-bar'
import { TaskLogsFilterBar } from './task-logs-filter-bar'
import { UsageLogsMobileList } from './usage-logs-mobile-card'
-import { useLogsViewScope } from './usage-logs-provider'
+import { useLogsViewScope, type LogsViewAccess } from './usage-logs-provider'
const route = getRouteApi('/_authenticated/usage-logs/$section')
@@ -58,13 +58,18 @@ const quotaSaturationRowTint = 'bg-amber-50/60 dark:bg-amber-950/25'
function getColumnVisibilityStorageKey(
logCategory: LogCategory,
- isAdmin: boolean
+ viewAccess: LogsViewAccess
): string {
- return `usage-logs:${logCategory}:${isAdmin ? 'admin' : 'user'}:column-visibility`
+ return `usage-logs:${logCategory}:${viewAccess}:column-visibility`
}
function deserializeLogTypeFilter(value: unknown): unknown[] {
- const values = Array.isArray(value) ? value : value ? [value] : []
+ let values: unknown[] = []
+ if (Array.isArray(value)) {
+ values = value
+ } else if (value) {
+ values = [value]
+ }
return values.filter((item) => String(item) !== LOG_TYPE_ALL_VALUE)
}
@@ -74,7 +79,11 @@ interface UsageLogsTableProps {
export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
const { t } = useTranslation()
- const { isAdminView: isAdmin } = useLogsViewScope()
+ const {
+ isAdminView: isAdmin,
+ isRootView: isRoot,
+ viewAccess,
+ } = useLogsViewScope()
const isMobile = useMediaQuery('(max-width: 640px)')
const searchParams = route.useSearch()
@@ -120,7 +129,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
queryKey: [
'logs',
logCategory,
- isAdmin,
+ viewAccess,
pagination.pageIndex + 1,
pagination.pageSize,
columnFilters,
@@ -145,7 +154,10 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
return result.data || DEFAULT_LOGS_DATA
},
placeholderData: (previousData, previousQuery) => {
- if (previousQuery?.queryKey[1] === logCategory) {
+ if (
+ previousQuery?.queryKey[1] === logCategory &&
+ previousQuery.queryKey[2] === viewAccess
+ ) {
return previousData
}
return undefined
@@ -153,7 +165,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
})
const logs = data?.items || []
- const columns = useColumnsByCategory(logCategory, isAdmin)
+ const columns = useColumnsByCategory(logCategory, isAdmin, isRoot)
const isLoadingData = isLoading || (isFetching && !data)
const { table } = useDataTable({
@@ -162,7 +174,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
columnFilters,
columnVisibilityStorageKey: getColumnVisibilityStorageKey(
logCategory,
- isAdmin
+ viewAccess
),
pagination,
enableRowSelection: false,
diff --git a/web/src/features/usage-logs/constants.ts b/web/src/features/usage-logs/constants.ts
index a2dff48b3c1f..7bc23448fa19 100644
--- a/web/src/features/usage-logs/constants.ts
+++ b/web/src/features/usage-logs/constants.ts
@@ -205,6 +205,7 @@ export const TASK_STATUS = {
*/
export const TASK_PLATFORMS = {
SUNO: 'suno',
+ SUNOAPI: 'sunoapi',
KLING: 'kling',
RUNWAY: 'runway',
LUMA: 'luma',
@@ -320,6 +321,7 @@ export const TASK_STATUS_MAPPINGS: Record
= {
*/
export const TASK_PLATFORM_MAPPINGS: Record = {
[TASK_PLATFORMS.SUNO]: { label: 'suno', variant: 'green' },
+ [TASK_PLATFORMS.SUNOAPI]: { label: 'sunoapi', variant: 'green' },
[TASK_PLATFORMS.KLING]: { label: 'kling', variant: 'blue' },
[TASK_PLATFORMS.RUNWAY]: { label: 'runway', variant: 'violet' },
[TASK_PLATFORMS.LUMA]: { label: 'luma', variant: 'orange' },
diff --git a/web/src/features/usage-logs/lib/columns.ts b/web/src/features/usage-logs/lib/columns.ts
index ed2300469422..43817fd34cbc 100644
--- a/web/src/features/usage-logs/lib/columns.ts
+++ b/web/src/features/usage-logs/lib/columns.ts
@@ -32,12 +32,13 @@ import type { LogCategory } from '../types'
*/
export function useColumnsByCategory(
logCategory: LogCategory,
- isAdmin: boolean
+ isAdmin: boolean,
+ isRoot: boolean
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): ColumnDef[] {
- const commonColumns = useCommonLogsColumns(isAdmin)
+ const commonColumns = useCommonLogsColumns(isAdmin, isRoot)
const drawingColumns = useDrawingLogsColumns(isAdmin)
- const taskColumns = useTaskLogsColumns(isAdmin)
+ const taskColumns = useTaskLogsColumns(isAdmin, isRoot)
switch (logCategory) {
case 'common':
diff --git a/web/src/features/usage-logs/lib/query-params.ts b/web/src/features/usage-logs/lib/query-params.ts
new file mode 100644
index 000000000000..d8f9471a6ecf
--- /dev/null
+++ b/web/src/features/usage-logs/lib/query-params.ts
@@ -0,0 +1,31 @@
+/*
+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
+*/
+export function buildQueryParams(
+ params: Record
+): URLSearchParams {
+ const queryParams = new URLSearchParams()
+
+ Object.entries(params).forEach(([key, value]) => {
+ if (value !== undefined && value !== null && value !== '') {
+ queryParams.append(key, String(value))
+ }
+ })
+
+ return queryParams
+}
diff --git a/web/src/features/usage-logs/lib/task-artifacts.ts b/web/src/features/usage-logs/lib/task-artifacts.ts
new file mode 100644
index 000000000000..abf325d6870a
--- /dev/null
+++ b/web/src/features/usage-logs/lib/task-artifacts.ts
@@ -0,0 +1,201 @@
+/*
+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 { TASK_STATUS } from '../constants'
+import type {
+ TaskArtifact,
+ TaskArtifactProjection,
+ TaskArtifactsResponse,
+ TaskLog,
+ TaskPluginAuthor,
+} from '../types'
+
+const taskArtifactTypes = new Set(['image', 'video', 'audio', 'file'])
+const safeArtifactKeyPattern = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/
+const taskArtifactContentPathPattern =
+ /\/v1\/tasks\/[^/]+\/artifacts\/[^/]+\/content$/
+const taskArtifactAccessTokenPattern = /^[A-Za-z0-9_-]{43}$/
+const maxTaskArtifacts = 64
+export type TaskPreviewMode = 'plugin' | 'legacy-suno' | 'legacy-video' | 'none'
+
+export class TaskArtifactApiError extends Error {
+ constructor(
+ message: string,
+ public code?: string
+ ) {
+ super(message)
+ this.name = 'TaskArtifactApiError'
+ }
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null
+}
+
+function parseContentUrl(value: unknown): string {
+ if (typeof value !== 'string') {
+ throw new TaskArtifactApiError('invalid_content_url')
+ }
+
+ const contentUrl = value.trim()
+ if (
+ contentUrl.length === 0 ||
+ contentUrl !== value ||
+ contentUrl.includes('#') ||
+ !/^https?:\/\//i.test(contentUrl)
+ ) {
+ throw new TaskArtifactApiError('invalid_content_url')
+ }
+ for (const character of contentUrl) {
+ const codePoint = character.codePointAt(0) ?? 0
+ if (codePoint <= 0x1f || codePoint === 0x7f || character === '\\') {
+ throw new TaskArtifactApiError('invalid_content_url')
+ }
+ }
+
+ try {
+ const url = new URL(contentUrl)
+ const authorityStart = contentUrl.indexOf('//') + 2
+ const pathStart = contentUrl.indexOf('/', authorityStart)
+ const authority = contentUrl.slice(
+ authorityStart,
+ pathStart === -1 ? contentUrl.length : pathStart
+ )
+ if (
+ (url.protocol !== 'https:' && url.protocol !== 'http:') ||
+ url.hostname.length === 0 ||
+ authority.includes('@') ||
+ url.username ||
+ url.password ||
+ url.hash ||
+ !taskArtifactContentPathPattern.test(url.pathname)
+ ) {
+ throw new TaskArtifactApiError('invalid_content_url')
+ }
+ const accessToken = url.searchParams.get('access')
+ if (
+ accessToken == null ||
+ !taskArtifactAccessTokenPattern.test(accessToken) ||
+ url.search !== `?access=${accessToken}`
+ ) {
+ throw new TaskArtifactApiError('invalid_content_url')
+ }
+ return contentUrl
+ } catch (error) {
+ if (error instanceof TaskArtifactApiError) throw error
+ throw new TaskArtifactApiError('invalid_content_url')
+ }
+}
+
+function parseTaskArtifact(value: unknown): TaskArtifact {
+ if (!isRecord(value)) {
+ throw new TaskArtifactApiError('invalid_artifact')
+ }
+
+ const key = typeof value.key === 'string' ? value.key : ''
+ const type = typeof value.type === 'string' ? value.type : ''
+ if (
+ key !== key.trim() ||
+ !safeArtifactKeyPattern.test(key) ||
+ !taskArtifactTypes.has(type)
+ ) {
+ throw new TaskArtifactApiError('invalid_artifact')
+ }
+
+ const artifact: TaskArtifact = {
+ key,
+ type: type as TaskArtifact['type'],
+ content_url: parseContentUrl(value.content_url),
+ }
+ if (typeof value.mime_type === 'string' && value.mime_type.trim()) {
+ const mimeType = value.mime_type.trim()
+ if (mimeType.length > 255 || /[\r\n]/.test(mimeType)) {
+ throw new TaskArtifactApiError('invalid_artifact')
+ }
+ artifact.mime_type = mimeType
+ }
+ return artifact
+}
+
+export function parseTaskArtifactsResponse(
+ response: TaskArtifactsResponse
+): TaskArtifactProjection {
+ if (!response.success) {
+ throw new TaskArtifactApiError(
+ response.message || 'artifact_projection_failed',
+ response.code
+ )
+ }
+
+ const rawArtifacts = response.data?.artifacts
+ if (rawArtifacts != null && !Array.isArray(rawArtifacts)) {
+ throw new TaskArtifactApiError('invalid_artifact_response')
+ }
+ const artifactValues = rawArtifacts ?? []
+ if (artifactValues.length > maxTaskArtifacts) {
+ throw new TaskArtifactApiError('invalid_artifact_response')
+ }
+
+ const artifacts = artifactValues.map(parseTaskArtifact)
+ const keys = new Set()
+ for (const artifact of artifacts) {
+ if (keys.has(artifact.key)) {
+ throw new TaskArtifactApiError('duplicate_artifact_key')
+ }
+ keys.add(artifact.key)
+ }
+ const projection: TaskArtifactProjection = { artifacts }
+ if (response.data?.legacy_content_url != null) {
+ projection.legacyContentUrl = parseContentUrl(
+ response.data.legacy_content_url
+ )
+ }
+ return projection
+}
+
+export function shouldLoadTaskArtifacts(
+ log: TaskLog,
+ dialogOpen: boolean
+): boolean {
+ return dialogOpen && log.status === TASK_STATUS.SUCCESS
+}
+
+export function resolveTaskPreviewMode(
+ log: TaskLog,
+ hasProjectedArtifacts = false
+): TaskPreviewMode {
+ if (log.status !== TASK_STATUS.SUCCESS) return 'none'
+ if (hasProjectedArtifacts) return 'plugin'
+ if (log.admin_info?.task_plugin) return 'plugin'
+ if (log.platform === 'suno') return 'legacy-suno'
+ if (log.legacy_video_available) return 'legacy-video'
+ return 'plugin'
+}
+
+export function getSafePluginAuthorUrl(
+ author?: TaskPluginAuthor
+): string | undefined {
+ if (!author?.url) return undefined
+ try {
+ const url = new URL(author.url)
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined
+ return url.toString()
+ } catch {
+ return undefined
+ }
+}
diff --git a/web/src/features/usage-logs/lib/task-details.ts b/web/src/features/usage-logs/lib/task-details.ts
new file mode 100644
index 000000000000..f4a3b508681e
--- /dev/null
+++ b/web/src/features/usage-logs/lib/task-details.ts
@@ -0,0 +1,44 @@
+/*
+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 type { TaskLog, TaskPluginInfo, TaskPluginRuntimeInfo } from '../types'
+
+export interface TaskDetailAccess {
+ plugin?: TaskPluginInfo
+ runtime?: TaskPluginRuntimeInfo
+ upstreamTaskId?: string
+ nodeName?: string
+}
+
+export function resolveTaskDetailAccess(
+ log: TaskLog,
+ isAdmin: boolean,
+ isRoot: boolean
+): TaskDetailAccess {
+ if (!isAdmin) return {}
+
+ const access: TaskDetailAccess = {
+ plugin: log.admin_info?.task_plugin,
+ }
+ if (!isRoot) return access
+
+ access.runtime = log.root_info?.task_plugin
+ access.upstreamTaskId = log.root_info?.upstream_task_id
+ access.nodeName = log.root_info?.node_name
+ return access
+}
diff --git a/web/src/features/usage-logs/lib/task-mobile-layout.ts b/web/src/features/usage-logs/lib/task-mobile-layout.ts
new file mode 100644
index 000000000000..6ee5500c71e8
--- /dev/null
+++ b/web/src/features/usage-logs/lib/task-mobile-layout.ts
@@ -0,0 +1,33 @@
+/*
+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
+*/
+interface TaskMobileSummaryField {
+ id: string
+ label: string
+ primaryOnly?: boolean
+}
+
+export const TASK_MOBILE_SUMMARY_FIELDS: readonly TaskMobileSummaryField[] = [
+ { id: 'submit_time', label: 'Submit Time' },
+ { id: 'user', label: 'User', primaryOnly: true },
+ { id: 'plugin', label: 'Plugin' },
+ { id: 'channel_id', label: 'Channel', primaryOnly: true },
+ { id: 'duration', label: 'Duration', primaryOnly: true },
+ { id: 'progress', label: 'Progress' },
+ { id: 'artifacts', label: 'Artifacts' },
+]
diff --git a/web/src/features/usage-logs/lib/utils.ts b/web/src/features/usage-logs/lib/utils.ts
index 22a648f87f1b..53c316447d20 100644
--- a/web/src/features/usage-logs/lib/utils.ts
+++ b/web/src/features/usage-logs/lib/utils.ts
@@ -40,6 +40,8 @@ import type {
GetTaskLogsParams,
} from '../types'
+export { buildQueryParams } from './query-params'
+
// ============================================================================
// Type Checkers & Utilities
// ============================================================================
@@ -91,24 +93,6 @@ function timestampToSeconds(ms: number): number {
return Math.floor(ms / 1000)
}
-/**
- * Build query parameters from filters
- */
-export function buildQueryParams(
- params: Record
-): URLSearchParams {
- const queryParams = new URLSearchParams()
-
- Object.entries(params).forEach(([key, value]) => {
- // Keep 0 as a valid value, only filter out undefined, null, and empty string
- if (value !== undefined && value !== null && value !== '') {
- queryParams.append(key, String(value))
- }
- })
-
- return queryParams
-}
-
/**
* Build time range parameters with default values
* Shared logic for all log types
diff --git a/web/src/features/usage-logs/types.ts b/web/src/features/usage-logs/types.ts
index f2af3155fa87..6d51e396b8e7 100644
--- a/web/src/features/usage-logs/types.ts
+++ b/web/src/features/usage-logs/types.ts
@@ -142,6 +142,12 @@ export interface LogOtherData {
original: number
clamped: number
}
+ task_plugin?: TaskPluginInfo
+ }
+ root_info?: {
+ task_plugin?: TaskPluginRuntimeInfo
+ upstream_task_id?: string
+ node_name?: string
}
// Language-independent operation descriptor (audit/login logs).
// Frontend renders localized content from action + params via i18n templates.
@@ -196,6 +202,7 @@ export interface LogOtherData {
expr_b64?: string
matched_tier?: string
request_rules?: RequestRuleTrace[]
+ usage_facts?: Record
reasoning_effort?: string
image?: boolean
image_ratio?: number
@@ -295,18 +302,79 @@ export interface TaskLog {
task_id: string
action: string // MUSIC, LYRICS, GENERATE, TEXT_GENERATE, etc.
channel_id: number
+ group: string
+ quota: number
submit_time: number // seconds
+ start_time?: number // seconds
finish_time?: number // seconds
progress?: string
progress_message_en?: string
- data?: string // JSON string
+ data?: unknown
+ properties?: {
+ input?: string
+ upstream_model_name?: string
+ origin_model_name?: string
+ }
+ legacy_video_available?: boolean
fail_reason?: string
status: string // NOT_START, SUBMITTED, IN_PROGRESS, SUCCESS, FAILURE, QUEUED, UNKNOWN
- other?: string
+ admin_info?: {
+ request_id?: string
+ request_path?: string
+ task_plugin?: TaskPluginInfo
+ }
+ root_info?: {
+ task_plugin?: TaskPluginRuntimeInfo
+ upstream_task_id?: string
+ node_name?: string
+ }
created_at?: number
updated_at?: number
}
+export interface TaskPluginInfo {
+ key: string
+ name: string
+ version?: string
+ author?: TaskPluginAuthor
+}
+
+export interface TaskPluginAuthor {
+ name: string
+ url?: string
+}
+
+export interface TaskPluginRuntimeInfo {
+ key: string
+ version: string
+ api_version: number
+ generation: number
+}
+
+export type TaskArtifactType = 'image' | 'video' | 'audio' | 'file'
+
+export interface TaskArtifact {
+ key: string
+ type: TaskArtifactType
+ mime_type?: string
+ content_url: string
+}
+
+export interface TaskArtifactProjection {
+ artifacts: TaskArtifact[]
+ legacyContentUrl?: string
+}
+
+export interface TaskArtifactsResponse {
+ success: boolean
+ message?: string
+ code?: string
+ data?: {
+ artifacts?: unknown
+ legacy_content_url?: unknown
+ }
+}
+
// ============================================================================
// Common Log Types
// ============================================================================
diff --git a/web/src/hooks/use-sidebar-data.ts b/web/src/hooks/use-sidebar-data.ts
index 40a0615aa347..57c194630b4f 100644
--- a/web/src/hooks/use-sidebar-data.ts
+++ b/web/src/hooks/use-sidebar-data.ts
@@ -26,6 +26,7 @@ import {
LayoutDashboard,
ListTodo,
MessageSquare,
+ PlugZap,
Radio,
ServerCog,
Settings,
@@ -150,6 +151,12 @@ export function useSidebarData(): SidebarData {
icon: ServerCog,
requiredRole: ROLE.SUPER_ADMIN,
},
+ {
+ title: t('Task Plugins'),
+ url: '/task-plugins',
+ icon: PlugZap,
+ requiredRole: ROLE.SUPER_ADMIN,
+ },
{
title: t('System Settings'),
url: '/system-settings/site',
diff --git a/web/src/i18n/locales/_reports/_sync-report.json b/web/src/i18n/locales/_reports/_sync-report.json
deleted file mode 100644
index ba41ffbe288d..000000000000
--- a/web/src/i18n/locales/_reports/_sync-report.json
+++ /dev/null
@@ -1,47 +0,0 @@
-{
- "base": "en.json",
- "locales": {
- "en": {
- "file": "en.json",
- "missingCount": 0,
- "extrasCount": 0,
- "untranslatedCount": 0
- },
- "fr": {
- "file": "fr.json",
- "missingCount": 0,
- "extrasCount": 0,
- "untranslatedCount": 0
- },
- "ja": {
- "file": "ja.json",
- "missingCount": 0,
- "extrasCount": 0,
- "untranslatedCount": 0
- },
- "ru": {
- "file": "ru.json",
- "missingCount": 0,
- "extrasCount": 0,
- "untranslatedCount": 0
- },
- "vi": {
- "file": "vi.json",
- "missingCount": 0,
- "extrasCount": 0,
- "untranslatedCount": 0
- },
- "zh-TW": {
- "file": "zh-TW.json",
- "missingCount": 0,
- "extrasCount": 0,
- "untranslatedCount": 0
- },
- "zh": {
- "file": "zh.json",
- "missingCount": 0,
- "extrasCount": 0,
- "untranslatedCount": 0
- }
- }
-}
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index ed2b9246bdb4..a65405c6be6e 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -29,7 +29,9 @@
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]": "[{\"name\":\"Alipay\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]",
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]": "[{\"name\":\"Alipay\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]",
"{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
+ "{{bytes}} bytes": "{{bytes}} bytes",
"{{category}} Models": "{{category}} Models",
+ "{{channels}} channels, {{tasks}} in-flight tasks": "{{channels}} channels, {{tasks}} in-flight tasks",
"{{completed}}/{{total}} completed": "{{completed}}/{{total}} completed",
"{{count}} / {{max}} groups selected": "{{count}} / {{max}} groups selected",
"{{count}} announcements will be removed from the list.": "{{count}} announcements will be removed from the list.",
@@ -39,9 +41,11 @@
"{{count}} channel(s) enabled": "{{count}} channel(s) enabled",
"{{count}} channel(s) failed to disable": "{{count}} channel(s) failed to disable",
"{{count}} channel(s) failed to enable": "{{count}} channel(s) failed to enable",
+ "{{count}} combinations": "{{count}} combinations",
"{{count}} days ago": "{{count}} days ago",
"{{count}} days remaining": "{{count}} days remaining",
"{{count}} disabled channel(s) deleted": "{{count}} disabled channel(s) deleted",
+ "{{count}} enabled channels and {{tasks}} in-flight tasks still use this plugin.": "{{count}} enabled channels and {{tasks}} in-flight tasks still use this plugin.",
"{{count}} FAQ entries will be removed from the list.": "{{count}} FAQ entries will be removed from the list.",
"{{count}} hours ago": "{{count}} hours ago",
"{{count}} incidents": "{{count}} incidents",
@@ -60,6 +64,7 @@
"{{count}} weeks ago": "{{count}} weeks ago",
"{{field}} updated to {{value}}": "{{field}} updated to {{value}}",
"{{field}} updated to {{value}} for tag: {{tag}}": "{{field}} updated to {{value}} for tag: {{tag}}",
+ "{{key}} · version {{version}} · from {{source}}": "{{key}} · version {{version}} · from {{source}}",
"{{method}} {{route}}": "{{method}} {{route}}",
"{{modality}} not supported": "{{modality}} not supported",
"{{modality}} supported": "{{modality}} supported",
@@ -104,6 +109,7 @@
"14 Days": "14 Days",
"192.168.1.1
10.0.0.0/8": "192.168.1.1
10.0.0.0/8",
"1M": "1M",
+ "1M token": "1M token",
"1W": "1W",
"2. Copy the application token": "2. Copy the application token",
"20 / page": "20 / page",
@@ -147,6 +153,7 @@
"Action": "Action",
"Action confirmation": "Action Confirmation",
"Actions": "Actions",
+ "Activate / Roll back": "Activate / Roll back",
"active": "active",
"Active": "Active",
"Active apps": "Active apps",
@@ -155,6 +162,7 @@
"Active models": "Active models",
"Active Tasks": "Active Tasks",
"active users": "active users",
+ "Active version": "Active version",
"Actively check all channels": "Actively check all channels",
"Actively check auto-disable-enabled channels": "Actively check auto-disable-enabled channels",
"Actual Amount": "Actual Amount",
@@ -171,6 +179,7 @@
"Add a new user by providing necessary info.": "Add a new user by providing necessary info.",
"Add a new vendor to the system": "Add a new vendor to the system",
"Add an extra layer of security to your account": "Add an extra layer of security to your account",
+ "Add an index URL to browse installable plugins.": "Add an index URL to browse installable plugins.",
"Add and submit": "Add and submit",
"Add Announcement": "Add Announcement",
"Add API": "Add API",
@@ -217,6 +226,7 @@
"Add rule group": "Add rule group",
"Add rules for a user group": "Add rules for a user group",
"Add selectable group": "Add selectable group",
+ "Add source": "Add source",
"Add split": "Add split",
"Add subscription": "Add subscription",
"Add tags...": "Add tags...",
@@ -292,6 +302,7 @@
"All": "All",
"All API tokens": "All API tokens",
"All categories": "All categories",
+ "All combinations are priced at zero. Matching requests will be billed as free.": "All combinations are priced at zero. Matching requests will be billed as free.",
"All conditions must match before this tier is used.": "All conditions must match before this tier is used.",
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "All edits are overwrite operations. Leave fields empty to keep current values unchanged.",
"All files exceed the maximum size.": "All files exceed the maximum size.",
@@ -348,6 +359,7 @@
"Allow using models without price configuration": "Allow using models without price configuration",
"Allow wallet balance after quota used up": "Allow wallet balance after quota used up",
"Allowed": "Allowed",
+ "Allowed hosts": "Allowed hosts",
"Allowed Origins": "Allowed Origins",
"Allowed Ports": "Allowed Ports",
"Already have an account?": "Already have an account?",
@@ -380,6 +392,7 @@
"Anthropic": "Anthropic",
"Anthropic Messages to OpenAI Chat": "Anthropic Messages to OpenAI Chat",
"Any Match (OR)": "Any Match (OR)",
+ "Anyone can publish an index. A plugin installed from a third-party source has the same access as one you upload by hand: review its source before installing.": "Anyone can publish an index. A plugin installed from a third-party source has the same access as one you upload by hand: review its source before installing.",
"API": "API",
"API Access": "API Access",
"API Addresses": "API Addresses",
@@ -414,6 +427,8 @@
"API token management": "API token management",
"API URL": "API URL",
"API usage records": "API usage records",
+ "API version": "API version",
+ "API Version": "API Version",
"API2GPT": "API2GPT",
"App": "App",
"App rankings shown here are simulated for preview purposes and will be replaced with live usage data once the backend integration is complete.": "App rankings shown here are simulated for preview purposes and will be replaced with live usage data once the backend integration is complete.",
@@ -437,8 +452,10 @@
"Apply plan": "Apply plan",
"Apply reset": "Apply reset",
"Apply Sync": "Apply Sync",
+ "Apply to all rows": "Apply to all rows",
"Applying...": "Applying...",
"Approx.": "Approx.",
+ "Approximate prices for common specs.": "Approximate prices for common specs.",
"apps": "apps",
"Apps": "Apps",
"apps tracked": "apps tracked",
@@ -461,12 +478,17 @@
"Are you sure?": "Are you sure?",
"Area Chart": "Area Chart",
"Args (space separated)": "Args (space separated)",
+ "Arguments JSON": "Arguments JSON",
+ "Arguments must be a JSON array": "Arguments must be a JSON array",
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.",
+ "Artifacts": "Artifacts",
"Asc": "Asc",
"Ask anything": "Ask anything",
"Assigned by administrator only": "Assigned by administrator only",
"Assigned by administrators and used to represent a user level, such as default or vip.": "Assigned by administrators and used to represent a user level, such as default or vip.",
+ "Async": "Async",
"Async task polling": "Async task polling",
+ "Async Task Public Address": "Async Task Public Address",
"Async task refund": "Async task refund",
"At least one model regex pattern is required": "At least one model regex pattern is required",
"At least one valid key source is required": "At least one valid key source is required",
@@ -585,8 +607,10 @@
"Balance updated: {{balance}}": "Balance updated: {{balance}}",
"Bar Chart": "Bar Chart",
"Bark Push URL": "Bark Push URL",
+ "Base": "Base",
"Base address provided by your Epay service": "Base address provided by your Epay service",
"Base amount. Actual deduction = base amount × system group rate.": "Base amount. Actual deduction = base amount × system group rate.",
+ "Base charge": "Base charge",
"Base input and output token prices for this tier.": "Base input and output token prices for this tier.",
"Base input price only": "Base input price only",
"Base Limits": "Base Limits",
@@ -594,6 +618,7 @@
"Base Price": "Base Price",
"Base rate limit windows for this account.": "Base rate limit windows for this account.",
"Base URL": "Base URL",
+ "Base URL *": "Base URL *",
"Base URL is required for this channel type": "Base URL is required for this channel type",
"Base URL is required when an advanced route uses an upstream path": "Base URL is required when an advanced route uses an upstream path",
"Base URL of your Uptime Kuma instance": "Base URL of your Uptime Kuma instance",
@@ -638,6 +663,7 @@
"Billing group = vip (the token has no group, so use the user group)": "Billing group = vip (the token has no group, so use the user group)",
"Billing History": "Billing History",
"Billing Mode": "Billing Mode",
+ "Billing parameters": "Billing parameters",
"Billing Path": "Billing Path",
"Billing Process": "Billing Process",
"Billing rule: each call is billed as the token group (falling back to the user group when the token has none). The base ratio always comes from that billing group, not from the user group. To give a user group a special price on another billing group, add an entry in the override matrix.": "Billing rule: each call is billed as the token group (falling back to the user group when the token has none). The base ratio always comes from that billing group, not from the user group. To give a user group a special price on another billing group, add an entry in the override matrix.",
@@ -648,6 +674,7 @@
"Bind Email": "Bind Email",
"Bind Telegram Account": "Bind Telegram Account",
"Bind WeChat Account": "Bind WeChat Account",
+ "Bind task plugins": "Bind task plugins",
"Binding Information": "Binding Information",
"Binding successful!": "Binding successful!",
"Binding your {{provider}} account": "Binding your {{provider}} account",
@@ -686,6 +713,7 @@
"Built for developers,": "Built for developers,",
"Built-in": "Built-in",
"Built-in Device": "Built-in Device",
+ "Built-in v{{factory}} / marketplace v{{market}}": "Built-in v{{factory}} / marketplace v{{market}}",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key",
"by": "by",
"By category": "By category",
@@ -738,6 +766,7 @@
"Caps the response length": "Caps the response length",
"Capture a reusable bundle of models, tags, or endpoints.": "Capture a reusable bundle of models, tags, or endpoints.",
"Card view": "Card view",
+ "Cascade disable channels": "Cascade disable channels",
"Catch-all route must be last for the same incoming path": "Catch-all route must be last for the same incoming path",
"Category": "Category",
"Category Name": "Category Name",
@@ -779,7 +808,9 @@
"Channel test concurrency": "Channel test concurrency",
"Channel test concurrency must be between 1 and 32": "Channel test concurrency must be between 1 and 32",
"Channel test mode": "Channel test mode",
+ "Channel type": "Channel type",
"Channel type is required": "Channel type is required",
+ "Channel types": "Channel types",
"Channel updated successfully": "Channel updated successfully",
"Channel-specific settings (JSON format)": "Channel-specific settings (JSON format)",
"Channel:": "Channel:",
@@ -950,6 +981,7 @@
"Compare the most popular models on the platform": "Compare the most popular models on the platform",
"compatible API routes": "compatible API routes",
"Compatible API routes for common AI application workflows": "Compatible API routes for common AI application workflows",
+ "Compilation failed": "Compilation failed",
"Complete API documentation with multi-language SDK support": "Complete API documentation with multi-language SDK support",
"Complete Order": "Complete Order",
"Complete these steps to finish the initial installation.": "Complete these steps to finish the initial installation.",
@@ -1000,6 +1032,7 @@
"Configure pricing ratios for a specific model.": "Configure pricing ratios for a specific model.",
"Configure rate limiting rules for a specific user group.": "Configure rate limiting rules for a specific user group.",
"Configure routes": "Configure routes",
+ "Configure task pricing": "Configure task pricing",
"Configure the ratio for this group.": "Configure the ratio for this group.",
"Configure upstream providers and routing.": "Configure upstream providers and routing.",
"Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups",
@@ -1143,6 +1176,10 @@
"Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.",
"Cost in USD per request, regardless of tokens used.": "Cost in USD per request, regardless of tokens used.",
"Cost Tracking": "Cost Tracking",
+ "Could not fetch the plugin source from this browser. The host may block cross-origin requests or be unreachable.": "Could not fetch the plugin source from this browser. The host may block cross-origin requests or be unreachable.",
+ "Could not fetch this URL from the browser. The host may block cross-origin requests or be unreachable. Download the file and paste its source below.": "Could not fetch this URL from the browser. The host may block cross-origin requests or be unreachable. Download the file and paste its source below.",
+ "Could not load this source": "Could not load this source",
+ "Count": "Count",
"Count must be between {{min}} and {{max}}": "Count must be between {{min}} and {{max}}",
"Coze": "Coze",
"CPU": "CPU",
@@ -1199,6 +1236,7 @@
"Credentials": "Credentials",
"Credentials verification failed": "Credentials verification failed",
"Credentials verification failed — double-check Merchant ID and API private key.": "Credentials verification failed — double-check Merchant ID and API private key.",
+ "credit": "credit",
"Credit remaining": "Credit remaining",
"Creem API key (leave blank unless updating)": "Creem API key (leave blank unless updating)",
"Creem Gateway": "Creem Gateway",
@@ -1226,6 +1264,7 @@
"Current version": "Current version",
"Current:": "Current:",
"Custom": "Custom",
+ "Custom (overrides factory {{version}})": "Custom (overrides factory {{version}})",
"Custom (seconds)": "Custom (seconds)",
"Custom Amount": "Custom Amount",
"Custom API base URL. For official channels, New API has built-in addresses. Only fill this for third-party proxy sites or special endpoints. Do not add /v1 or trailing slash.": "Custom API base URL. For official channels, New API has built-in addresses. Only fill this for third-party proxy sites or special endpoints. Do not add /v1 or trailing slash.",
@@ -1245,6 +1284,7 @@
"Custom OAuth Providers": "Custom OAuth Providers",
"Custom Seconds": "Custom Seconds",
"Custom sidebar section": "Custom sidebar section",
+ "Custom task plugin setting updated": "Custom task plugin setting updated",
"Custom Time Range": "Custom Time Range",
"Custom Zoom": "Custom Zoom",
"Customize sidebar display content": "Customize sidebar display content",
@@ -1274,6 +1314,7 @@
"Days to Retain": "Days to Retain",
"decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.": "decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.",
"decides which channels are used and which base ratio applies.": "decides which channels are used and which base ratio applies.",
+ "Declared capabilities": "Declared capabilities",
"Decreased user quota by {{quota}}": "Decreased user quota by {{quota}}",
"Deducted by subscription": "Deducted by subscription",
"DeepSeek": "DeepSeek",
@@ -1306,6 +1347,7 @@
"Delete {{count}} stale instance records? Online instances will not be deleted.": "Delete {{count}} stale instance records? Online instances will not be deleted.",
"Delete a runtime request header": "Delete a runtime request header",
"Delete Account": "Delete Account",
+ "Delete active custom version": "Delete active custom version",
"Delete All Disabled": "Delete All Disabled",
"Delete All Disabled Channels?": "Delete All Disabled Channels?",
"Delete all stale": "Delete all stale",
@@ -1329,6 +1371,7 @@
"Delete mapping": "Delete mapping",
"Delete Model": "Delete Model",
"Delete Models?": "Delete Models?",
+ "Delete plugin version?": "Delete plugin version?",
"Delete Provider": "Delete Provider",
"Delete Request Header": "Delete Request Header",
"Delete selected API keys": "Delete selected API keys",
@@ -1355,6 +1398,7 @@
"Deleted stale instance": "Deleted stale instance",
"Deleted successfully": "Deleted successfully",
"Deleted user {{username}} (ID: {{id}})": "Deleted user {{username}} (ID: {{id}})",
+ "Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.": "Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.",
"Deleting will permanently remove this subscription record (including benefit details). Continue?": "Deleting will permanently remove this subscription record (including benefit details). Continue?",
"Deleting...": "Deleting...",
"Demo site": "Demo site",
@@ -1401,6 +1445,8 @@
"Disable": "Disable",
"Disable 2FA": "Disable 2FA",
"Disable All": "Disable All",
+ "Disable custom task plugins?": "Disable custom task plugins?",
+ "Disable task plugins?": "Disable task plugins?",
"Disable on failure": "Disable on failure",
"Disable selected channels": "Disable selected channels",
"Disable selected models": "Disable selected models",
@@ -1415,6 +1461,8 @@
"Disabled lanes are omitted on save.": "Disabled lanes are omitted on save.",
"Disabled Reason": "Disabled Reason",
"Disabled Time": "Disabled Time",
+ "Disabled; fell back to factory": "Disabled; fell back to factory",
+ "Disabled; platform unavailable": "Disabled; platform unavailable",
"Disabling...": "Disabling...",
"Disclaimer: Personal use only. Do not distribute or share any credentials. This channel has prerequisites and requires prior setup; use it only if you understand the flow and risks, and comply with OpenAI's terms and policies. Credentials and configuration are for Codex CLI integration only, and are not intended for any other client, platform, or channel.": "Disclaimer: Personal use only. Do not distribute or share any credentials. This channel has prerequisites and requires prior setup; use it only if you understand the flow and risks, and comply with OpenAI's terms and policies. Credentials and configuration are for Codex CLI integration only, and are not intended for any other client, platform, or channel.",
"Discord": "Discord",
@@ -1480,6 +1528,7 @@
"Drawing Logs": "Drawing Logs",
"Drawing task polling": "Drawing task polling",
"Drawing task records": "Drawing task records",
+ "Dry run result": "Dry run result",
"Duplicate": "Duplicate",
"Duplicate group names: {{names}}": "Duplicate group names: {{names}}",
"Duplicate model in route models": "Duplicate model in route models",
@@ -1540,7 +1589,9 @@
"Each item must have exactly one key-value pair.": "Each item must have exactly one key-value pair.",
"Each line represents one keyword. Leave blank to disable the list but keep the switch states.": "Each line represents one keyword. Leave blank to disable the list but keep the switch states.",
"Each matrix cell is one rule: users of this row group pay this ratio when billed as this column group. In JSON the row is the outer key and the column is the inner key.": "Each matrix cell is one rule: users of this row group pay this ratio when billed as this column group. In JSON the row is the outer key and the column is the inner key.",
+ "Each row prices one combination of {{fields}}.": "Each row prices one combination of {{fields}}.",
"Each rule reads as a sentence: users of one group pay a special ratio when billed as another group. Without a rule, the billing group base ratio applies.": "Each rule reads as a sentence: users of one group pay a special ratio when billed as another group. Without a rule, the billing group base ratio applies.",
+ "Each source serves an index.json listing installable plugins. Indexes are fetched by your browser; the gateway makes no outbound requests.": "Each source serves an index.json listing installable plugins. Indexes are fetched by your browser; the gateway makes no outbound requests.",
"Each tier supports 0~2 conditions (over len, p, c); the last tier is the catch-all without conditions. Use len (full input length, including cache hits) for tier conditions to avoid mis-routing when cache hits reduce p.": "Each tier supports 0~2 conditions (over len, p, c); the last tier is the catch-all without conditions. Use len (full input length, including cache hits) for tier conditions to avoid mis-routing when cache hits reduce p.",
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.",
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "Each tier supports up to 2 conditions. The last tier without conditions is the fallback.",
@@ -1598,6 +1649,8 @@
"Enable 2FA": "Enable 2FA",
"Enable All": "Enable All",
"Enable check-in feature": "Enable check-in feature",
+ "Enable custom task plugins": "Enable custom task plugins",
+ "Enable task plugins": "Enable task plugins",
"Enable Data Dashboard": "Enable Data Dashboard",
"Enable demo mode with limited functionality": "Enable demo mode with limited functionality",
"Enable Discord OAuth": "Enable Discord OAuth",
@@ -1617,6 +1670,7 @@
"Enable or disable this model": "Enable or disable this model",
"Enable Passkey": "Enable Passkey",
"Enable Performance Monitoring": "Enable Performance Monitoring",
+ "Enable plugin {{key}}": "Enable plugin {{key}}",
"Enable rate limiting": "Enable rate limiting",
"Enable Request Passthrough": "Enable Request Passthrough",
"Enable selected channels": "Enable selected channels",
@@ -1670,6 +1724,8 @@
"Enter a value and press Enter": "Enter a value and press Enter",
"Enter amount in {{currency}}": "Enter amount in {{currency}}",
"Enter amount in tokens": "Enter amount in tokens",
+ "Enter an absolute HTTP(S) URL without credentials, query parameters, or fragments": "Enter an absolute HTTP(S) URL without credentials, query parameters, or fragments",
+ "Enter an absolute http(s) URL.": "Enter an absolute http(s) URL.",
"Enter announcement content (supports Markdown & HTML)": "Enter announcement content (supports Markdown & HTML)",
"Enter announcement content (supports Markdown/HTML)": "Enter announcement content (supports Markdown/HTML)",
"Enter API Key": "Enter API Key",
@@ -1733,6 +1789,9 @@
"Enterprise Account": "Enterprise Account",
"Enterprise-grade security with comprehensive permission management": "Enterprise-grade security with comprehensive permission management",
"Entrypoint (space separated)": "Entrypoint (space separated)",
+ "Enum": "Enum",
+ "Boolean": "Boolean",
+ "Enum values": "Enum values",
"Env (JSON object)": "Env (JSON object)",
"Environment variables": "Environment variables",
"Environment variables (JSON)": "Environment variables (JSON)",
@@ -1764,6 +1823,8 @@
"Example": "Example",
"Example (all channels):": "Example (all channels):",
"Example (specific channels):": "Example (specific channels):",
+ "Example price": "Example price",
+ "Example spec": "Example spec",
"Example:": "Example:",
"example.com
blocked-site.com": "example.com
blocked-site.com",
"example.com
company.com": "example.com
company.com",
@@ -1792,6 +1853,7 @@
"Expose ratio API": "Expose ratio API",
"Exposes the pricing/models catalog in the top navigation.": "Exposes the pricing/models catalog in the top navigation.",
"Expression": "Expression",
+ "Expression - Task pricing": "Expression - Task pricing",
"Expression based": "Expression based",
"Expression billing": "Expression billing",
"Expression editor": "Expression editor",
@@ -1811,6 +1873,9 @@
"Extra visible": "Extra visible",
"Extra visible to {{group}}": "Extra visible to {{group}}",
"extras": "extras",
+ "Factory": "Factory",
+ "Factory and custom plugin behavior": "Factory and custom plugin behavior",
+ "Factory plugins cannot be deleted or disabled individually. A custom version can override them; deleting or disabling that version restores the factory plugin. Third-party-only platforms become unavailable when their plugin is deleted or disabled.": "Factory plugins cannot be deleted or disabled individually. A custom version can override them; deleting or disabling that version restores the factory plugin. Third-party-only platforms become unavailable when their plugin is deleted or disabled.",
"Fail Reason": "Fail Reason",
"Fail Reason Details": "Fail Reason Details",
"failed": "failed",
@@ -1879,6 +1944,7 @@
"Failed to initialize system": "Failed to initialize system",
"Failed to load": "Failed to load",
"Failed to load API keys": "Failed to load API keys",
+ "Failed to load artifacts": "Failed to load artifacts",
"Failed to load billing history": "Failed to load billing history",
"Failed to load enabled models": "Failed to load enabled models",
"Failed to load home page content": "Failed to load home page content",
@@ -1968,15 +2034,20 @@
"Feature in development": "Feature in development",
"Fee": "Fee",
"Fee Amount": "Fee Amount",
+ "Fetch": "Fetch",
"Fetch available models for:": "Fetch available models for:",
"Fetch available models from upstream": "Fetch available models from upstream",
"Fetch from Upstream": "Fetch from Upstream",
"Fetch Models": "Fetch Models",
+ "Fetch mode": "Fetch mode",
"Fetched {{count}} model(s) from upstream": "Fetched {{count}} model(s) from upstream",
"Fetched {{count}} models": "Fetched {{count}} models",
+ "Fetched in your browser and placed in the source field below for review. GitHub and gist page URLs are rewritten to their raw URL automatically.": "Fetched in your browser and placed in the source field below for review. GitHub and gist page URLs are rewritten to their raw URL automatically.",
+ "Fetching plugin source...": "Fetching plugin source...",
"Fetching prefill groups...": "Fetching prefill groups...",
"Fetching upstream prices...": "Fetching upstream prices...",
"Fetching upstream ratios...": "Fetching upstream ratios...",
+ "Fetching...": "Fetching...",
"field": "field",
"Field Mapping": "Field Mapping",
"Field passthrough controls": "Field passthrough controls",
@@ -1986,6 +2057,7 @@
"Files to Retain": "Files to Retain",
"Fill All Models": "Fill All Models",
"Fill Codex CLI / Claude CLI Templates": "Fill Codex CLI / Claude CLI Templates",
+ "Fill entire column": "Fill entire column",
"Fill example (all channels)": "Fill example (all channels)",
"Fill example (specific channels)": "Fill example (specific channels)",
"Fill in": "Fill in",
@@ -2025,6 +2097,7 @@
"Filter models by provider, group, type, endpoint, and tags.": "Filter models by provider, group, type, endpoint, and tags.",
"Filter models by type, endpoint, vendor, group and tags": "Filter models by type, endpoint, vendor, group and tags",
"Filter models...": "Filter models...",
+ "Filter plugins...": "Filter plugins...",
"Filter the model analytics view by time range and user.": "Filter the model analytics view by time range and user.",
"Filter the traffic flow view by time range and user.": "Filter the traffic flow view by time range and user.",
"Filter...": "Filter...",
@@ -2078,6 +2151,7 @@
"Force Format": "Force Format",
"Force format response to OpenAI standard (OpenAI channel only)": "Force format response to OpenAI standard (OpenAI channel only)",
"Force JSON object or schema-conforming output": "Force JSON object or schema-conforming output",
+ "Force operation": "Force operation",
"Force SMTP authentication using AUTH LOGIN method": "Force SMTP authentication using AUTH LOGIN method",
"Force-disabled two-factor authentication for the user": "Force-disabled two-factor authentication for the user",
"Forest Whisper": "Forest Whisper",
@@ -2251,6 +2325,7 @@
"Home": "Home",
"Home Page Content": "Home Page Content",
"Homepage URL": "Homepage URL",
+ "Hook": "Hook",
"Hostname or IP of your SMTP provider": "Hostname or IP of your SMTP provider",
"Hour": "Hour",
"Hour of day": "Hour of day",
@@ -2329,6 +2404,7 @@
"Image to Video": "Image to Video",
"Image Tokens": "Image Tokens",
"Imagine the pricing table has three groups: default (ratio 1.0), premium (ratio 0.5), and vip (ratio 0.8). Users whose account is in the vip group get user-level perks, and premium is a cheaper channel pool that users can pick for their tokens.": "Imagine the pricing table has three groups: default (ratio 1.0), premium (ratio 0.5), and vip (ratio 0.8). Users whose account is in the vip group get user-level perks, and premium is a cheaper channel pool that users can pick for their tokens.",
+ "Import from URL": "Import from URL",
"Import to CC Switch": "Import to CC Switch",
"Important": "Important",
"In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.",
@@ -2352,6 +2428,8 @@
"Incomplete": "Incomplete",
"Increased user quota by {{quota}}": "Increased user quota by {{quota}}",
"Index": "Index",
+ "Index request failed with HTTP {{status}}": "Index request failed with HTTP {{status}}",
+ "Index URL": "Index URL",
"Inherit global Auto order": "Inherit global Auto order",
"Initial quota given to new users": "Initial quota given to new users",
"Initial quota given to new users ({{formattedQuota}})": "Initial quota given to new users ({{formattedQuota}})",
@@ -2369,10 +2447,21 @@
"Inset": "Inset",
"Inspect requests, errors, and billing details": "Inspect requests, errors, and billing details",
"Inspect user prompts": "Inspect user prompts",
+ "Install": "Install",
+ "Install {{name}}": "Install {{name}}",
+ "Install and enable": "Install and enable",
+ "Installed": "Installed",
+ "Installed {{name}} v{{version}}": "Installed {{name}} v{{version}}",
+ "Installed v{{from}} → marketplace v{{to}}": "Installed v{{from}} → marketplace v{{to}}",
+ "Installed v{{installed}} not listed": "Installed v{{installed}} not listed",
+ "Installed version is not in this index": "Installed version is not in this index",
+ "Installing...": "Installing...",
"Instance": "Instance",
"Instances": "Instances",
"Insufficient balance": "Insufficient balance",
"Integrations": "Integrations",
+ "Integrity check failed": "Integrity check failed",
+ "Integrity hash": "Integrity hash",
"Inter-group overrides": "Inter-group overrides",
"Inter-group ratio overrides": "Inter-group ratio overrides",
"Interface Language": "Interface Language",
@@ -2425,6 +2514,7 @@
"It seems like the page you're looking for": "It seems like the page you're looking for",
"Items": "Items",
"Japanese": "Japanese",
+ "JavaScript file": "JavaScript file",
"Jimeng": "Jimeng",
"Jina": "Jina",
"JSON": "JSON",
@@ -2489,6 +2579,7 @@
"Latency short": "Lat.",
"Latency trend (last 24h)": "Latency trend (last 24h)",
"Latest platform updates and notices": "Latest platform updates and notices",
+ "Latest version": "Latest version",
"Lavender Dream": "Lavender Dream",
"Layout": "Layout",
"lead": "lead",
@@ -2541,6 +2632,7 @@
"LinuxDO Client Secret": "LinuxDO Client Secret",
"List of models supported by this channel. Use comma to separate multiple models.": "List of models supported by this channel. Use comma to separate multiple models.",
"List of origins (one per line) allowed for Passkey registration and authentication.": "List of origins (one per line) allowed for Passkey registration and authentication.",
+ "List registered task plugins and bind them when creating or editing task plugin channels.": "List registered task plugins and bind them when creating or editing task plugin channels.",
"List view": "List view",
"Live refresh pauses when no task is running": "Live refresh pauses when no task is running",
"LLM Leaderboard": "LLM Leaderboard",
@@ -2555,6 +2647,7 @@
"Loading conversation...": "Loading conversation...",
"Loading current models...": "Loading current models...",
"Loading failed": "Loading failed",
+ "Loading installed source...": "Loading installed source...",
"Loading maintenance settings...": "Loading maintenance settings...",
"Loading settings...": "Loading settings...",
"Loading setup status…": "Loading setup status…",
@@ -2606,6 +2699,7 @@
"Manage multi-key status and configuration for this channel": "Manage multi-key status and configuration for this channel",
"Manage Ollama Models": "Manage Ollama Models",
"Manage server log files. Log files accumulate over time; regular cleanup is recommended to free disk space.": "Manage server log files. Log files accumulate over time; regular cleanup is recommended to free disk space.",
+ "Manage sources": "Manage sources",
"Manage subscription plans and pricing.": "Manage subscription plans and pricing.",
"Manage Subscriptions": "Manage Subscriptions",
"Manage Vendors": "Manage Vendors",
@@ -2619,6 +2713,10 @@
"Map upstream status codes to different codes": "Map upstream status codes to different codes",
"Market Share": "Market Share",
"Marketing": "Marketing",
+ "Marketplace": "Marketplace",
+ "Marketplace installs never force past a conflict. Resolve it on the task plugins page, then install again.": "Marketplace installs never force past a conflict. Resolve it on the task plugins page, then install again.",
+ "Marketplace sources": "Marketplace sources",
+ "Marketplace sources updated": "Marketplace sources updated",
"Master instances run scheduled background tasks.": "Master instances run scheduled background tasks.",
"Match All (AND)": "Match All (AND)",
"Match Any (OR)": "Match Any (OR)",
@@ -2663,6 +2761,8 @@
"Maximum tokens per user": "Maximum tokens per user",
"maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647": "maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647",
"May be used for training by upstream provider": "May be used for training by upstream provider",
+ "Media access expired. Please try again.": "Media access expired. Please try again.",
+ "Media preview failed. Please try again.": "Media preview failed. Please try again.",
"Media pricing": "Media pricing",
"Median time-to-first-token (TTFT) sampled hourly per group": "Median time-to-first-token (TTFT) sampled hourly per group",
"Medical Q&A, mental health support": "Medical Q&A, mental health support",
@@ -2862,6 +2962,7 @@
"Native Claude Messages plus OpenAI Chat compatibility forwarding.": "Native Claude Messages plus OpenAI Chat compatibility forwarding.",
"Native format": "Native format",
"Native forwarding": "Native forwarding",
+ "Native routes": "Native routes",
"Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.",
"Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "Native OpenAI routes plus optional Claude and Gemini compatibility routes.",
"Need a redemption code?": "Need a redemption code?",
@@ -2916,6 +3017,7 @@
"No available Web chat links": "No available Web chat links",
"No backup": "No backup",
"No base input price": "No base input price",
+ "No billing parameters declared": "No billing parameters declared",
"No billing records found": "No billing records found",
"No capabilities reported for this model.": "No capabilities reported for this model.",
"No Change": "No Change",
@@ -2964,6 +3066,8 @@
"No incidents in the last 24 hours": "No incidents in the last 24 hours",
"No incidents in the last 30 days": "No incidents in the last 30 days",
"No instances have reported yet.": "No instances have reported yet.",
+ "No integrity hash": "No integrity hash",
+ "No integrity verification": "No integrity verification",
"No Inviter": "No Inviter",
"No keys found": "No keys found",
"No latency data available": "No latency data available",
@@ -2971,6 +3075,7 @@
"No logs": "No logs",
"No Logs Found": "No Logs Found",
"No mappings configured. Click \"Add Row\" to get started.": "No mappings configured. Click \"Add Row\" to get started.",
+ "No marketplace sources configured.": "No marketplace sources configured.",
"No matches found": "No matches found",
"No matching items": "No matching items",
"No matching results": "No matching results",
@@ -3047,6 +3152,7 @@
"No Sync": "No Sync",
"No system announcements": "No system announcements",
"No system tasks yet.": "No system tasks yet.",
+ "No task plugins found": "No task plugins found",
"No token found.": "No token found.",
"No tools configured": "No tools configured",
"No Upgrade": "No Upgrade",
@@ -3078,9 +3184,13 @@
"Not backed up": "Not backed up",
"Not bound": "Not bound",
"Not configured": "Not configured",
+ "Not declared": "Not declared",
"Not Equals": "Not Equals",
"Not in pricing table": "Not in pricing table",
"Not included": "Not included",
+ "Not installed": "Not installed",
+ "Not provided by this source": "Not provided by this source",
+ "Not registered": "Not registered",
"Not set": "Not set",
"Not Set": "Not Set",
"Not set yet": "Not set yet",
@@ -3096,6 +3206,7 @@
"Notifications": "Notifications",
"Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "Now a user whose user group is vip creates tokens with different groups and makes one call with each:",
"Nucleus sampling probability mass": "Nucleus sampling probability mass",
+ "Number": "Number",
"Number of codes to create": "Number of codes to create",
"Number of completions to generate": "Number of completions to generate",
"Number of images to generate": "Number of images to generate",
@@ -3104,6 +3215,7 @@
"Number of tokens per unit quota": "Number of tokens per unit quota",
"Number of top log probabilities returned per token": "Number of top log probabilities returned per token",
"Number of users invited": "Number of users invited",
+ "OAuth": "OAuth",
"OAuth binding timed out. Please try again.": "OAuth binding timed out. Please try again.",
"OAuth binding window is no longer available": "OAuth binding window is no longer available",
"OAuth callback URL": "OAuth callback URL",
@@ -3223,6 +3335,7 @@
"Optional notes about this channel": "Optional notes about this channel",
"Optional notes about when to use this group": "Optional notes about when to use this group",
"Optional ratio used when upstream cache hits occur.": "Optional ratio used when upstream cache hits occur.",
+ "Optional request-rule multiplier expression. Leave empty when no request rule applies.": "Optional request-rule multiplier expression. Leave empty when no request rule applies.",
"Optional rule description": "Optional rule description",
"Optional settings for advanced container configuration.": "Optional settings for advanced container configuration.",
"Optional supplementary information (max 100 characters)": "Optional supplementary information (max 100 characters)",
@@ -3291,6 +3404,7 @@
"parameter.": "parameter.",
"Parameters": "Parameters",
"Parsed {{count}} service account file(s)": "Parsed {{count}} service account file(s)",
+ "Parsed plugin metadata": "Parsed plugin metadata",
"Partial Submission": "Partial Submission",
"Pass Headers": "Pass Headers",
"Pass request body directly to upstream": "Pass request body directly to upstream",
@@ -3344,6 +3458,7 @@
"Passwords do not match": "Passwords do not match",
"Passwords don't match.": "Passwords don't match.",
"Paste Connection Info": "Paste Connection Info",
+ "Paste JavaScript source here...": "Paste JavaScript source here...",
"Path": "Path",
"Path not set": "Path not set",
"Path Regex (one per line)": "Path Regex (one per line)",
@@ -3383,6 +3498,8 @@
"per request": "per request",
"Per request": "Per request",
"Per Request": "Per Request",
+ "Per Second": "Per Second",
+ "Per Unit": "Per Unit",
"Per-call": "Per-call",
"Per-feature metered windows split by model or capability.": "Per-feature metered windows split by model or capability.",
"Per-group performance": "Per-group performance",
@@ -3487,6 +3604,23 @@
"Please wait a moment, human check is initializing...": "Please wait a moment, human check is initializing...",
"Please wait before editing to avoid overwriting saved values.": "Please wait before editing to avoid overwriting saved values.",
"Please wait for the current generation to complete": "Please wait for the current generation to complete",
+ "Plugin": "Plugin",
+ "Plugin author": "Plugin author",
+ "Plugin Generation": "Plugin Generation",
+ "Plugin indexes are fetched by your browser. Installing runs the same review and admission pipeline as a manual upload.": "Plugin indexes are fetched by your browser. Installing runs the same review and admission pipeline as a manual upload.",
+ "Plugin is still in use": "Plugin is still in use",
+ "Plugin key": "Plugin key",
+ "Plugin metadata": "Plugin metadata",
+ "Plugin source": "Plugin source",
+ "Choose file": "Choose file",
+ "Choose another file": "Choose another file",
+ "Drop a JavaScript plugin file here": "Drop a JavaScript plugin file here",
+ "Single .js file, up to 1 MiB. Its source is shown below before upload.": "Single .js file, up to 1 MiB. Its source is shown below before upload.",
+ "Optional note describing this version": "Optional note describing this version",
+ "Plugin source exceeds the 1 MiB limit.": "Plugin source exceeds the 1 MiB limit.",
+ "Plugin uploaded successfully": "Plugin uploaded successfully",
+ "Plugin version activated": "Plugin version activated",
+ "Plugin version deleted": "Plugin version deleted",
"Policy JSON": "Policy JSON",
"Polling": "Polling",
"Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded": "Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded",
@@ -3541,6 +3675,9 @@
"Press Enter to use \"{{value}}\"": "Press Enter to use \"{{value}}\"",
"Prevent server-side request forgery attacks": "Prevent server-side request forgery attacks",
"Preview": "Preview",
+ "Preview excludes group ratios and request rule multipliers.": "Preview excludes group ratios and request rule multipliers.",
+ "Preview is unavailable for custom expressions.": "Preview is unavailable for custom expressions.",
+ "Preview unavailable": "Preview unavailable",
"Previous": "Previous",
"Previous branch": "Previous branch",
"Previous page": "Previous page",
@@ -3551,6 +3688,7 @@
"Price display mode": "Price display mode",
"Price estimation": "Price estimation",
"Price estimation description": "After completing the hardware type, deployment location, replica count, etc., the price will be automatically calculated.",
+ "Price examples": "Price examples",
"Price ID": "Price ID",
"Price mode (USD per 1M tokens)": "Price mode (USD per 1M tokens)",
"Price summary": "Price summary",
@@ -3559,6 +3697,7 @@
"Price: High to Low": "Price: High to Low",
"Price: Low to High": "Price: Low to High",
"Prices shown per": "Prices shown per",
+ "Prices shown per usage unit": "Prices shown per usage unit",
"Prices synced successfully": "Prices synced successfully",
"Prices vary by usage tier and request conditions": "Prices vary by usage tier and request conditions",
"Pricing": "Pricing",
@@ -3623,6 +3762,7 @@
"Prune Object Items": "Prune Object Items",
"Prune object items by conditions": "Prune object items by conditions",
"Prune Rule (string or JSON object)": "Prune Rule (string or JSON object)",
+ "Public base URL for async task media. Supports a dedicated media domain, port, or Nginx path prefix; falls back to Server Address when empty.": "Public base URL for async task media. Supports a dedicated media domain, port, or Nginx path prefix; falls back to Server Address when empty.",
"Public model catalog and pricing page.": "Public model catalog and pricing page.",
"Public rankings page based on live usage data.": "Public rankings page based on live usage data.",
"Publish Date": "Publish Date",
@@ -3773,12 +3913,14 @@
"Regex Replace": "Regex Replace",
"Register each URL into the matching Test Mode / Production Mode webhook slot in the Pancake dashboard. Separate endpoints prevent test traffic from accidentally crediting production accounts.": "Register each URL into the matching Test Mode / Production Mode webhook slot in the Pancake dashboard. Separate endpoints prevent test traffic from accidentally crediting production accounts.",
"Register Passkey": "Register Passkey",
+ "Registered": "Registered",
"Registered a passkey": "Registered a passkey",
"Registration Enabled": "Registration Enabled",
"Registration flow expired. Please try again.": "Registration flow expired. Please try again.",
"Registry (optional)": "Registry (optional)",
"Registry secret": "Registry secret",
"Registry username": "Registry username",
+ "Reinstall latest": "Reinstall latest",
"Reject Reason": "Reject Reason",
"Release details": "Release details",
"Released": "Released",
@@ -3806,6 +3948,7 @@
"Remove Passkey": "Remove Passkey",
"Remove Passkey?": "Remove Passkey?",
"Remove rule group": "Remove rule group",
+ "Remove source {{name}}": "Remove source {{name}}",
"Remove string prefix": "Remove string prefix",
"Remove string suffix": "Remove string suffix",
"Remove the target field": "Remove the target field",
@@ -3855,8 +3998,10 @@
"Request Model": "Request Model",
"Request Model:": "Request Model:",
"Request overrides, routing behavior, and upstream model automation": "Request overrides, routing behavior, and upstream model automation",
+ "Request Path": "Request Path",
"Request retry": "Request retry",
"Request rule pricing": "Request rule pricing",
+ "Request rules apply on top of this amount.": "Request rules apply on top of this amount.",
"Request success rate sampled over the last 24 hours": "Request success rate sampled over the last 24 hours",
"Request success rate; {{incidents}} incident buckets in the last 24 hours": "Request success rate; {{incidents}} incident buckets in the last 24 hours",
"Request timed out, please refresh and restart GitHub login": "Request timed out, please refresh and restart GitHub login",
@@ -3919,6 +4064,7 @@
"Reset usage window": "Reset usage window",
"Resets in:": "Resets in:",
"Resetting...": "Resetting...",
+ "Resize column": "Resize column",
"Resolve Conflicts": "Resolve Conflicts",
"Resource Configuration": "Resource Configuration",
"Resources": "Resources",
@@ -3951,6 +4097,7 @@
"Revenue": "Revenue",
"Review & initialize": "Review & initialize",
"Review and sign out devices currently using your account.": "Review and sign out devices currently using your account.",
+ "Review and upgrade": "Review and upgrade",
"Review model rates before scaling traffic": "Review model rates before scaling traffic",
"Review your payment details": "Review your payment details",
"Review your purchase details before proceeding.": "Review your purchase details before proceeding.",
@@ -3962,6 +4109,7 @@
"Role": "Role",
"Roleplay": "Roleplay",
"Root": "Root",
+ "Root Diagnostics": "Root Diagnostics",
"Rose Garden": "Rose Garden",
"Route": "Route",
"Route active": "Route active",
@@ -4001,16 +4149,20 @@
"Rules JSON": "Rules JSON",
"Rules JSON must be an array": "Rules JSON must be an array",
"Rules match the original model value from the client request body.": "Rules match the original model value from the client request body.",
+ "Run dry run": "Run dry run",
"Run GC": "Run GC",
"Run tests for the selected models": "Run tests for the selected models",
"running": "running",
"Running": "Running",
+ "Running dry run": "Running dry run",
"Runtime": "Runtime",
+ "Runtime status": "Runtime status",
"Runway": "Runway",
"s": "s",
"Safety Settings": "Safety Settings",
"Same as Local": "Same as Local",
"Sampling temperature; lower is more deterministic": "Sampling temperature; lower is more deterministic",
+ "Sandbox": "Sandbox",
"Sandbox mode": "Sandbox mode",
"Save": "Save",
"Save & Submit": "Save & Submit",
@@ -4091,6 +4243,8 @@
"Search the public web at inference time": "Search the public web at inference time",
"Search vendors...": "Search vendors...",
"Search...": "Search...",
+ "second": "second",
+ "Second": "Second",
"seconds": "seconds",
"Secret env (JSON object)": "Secret env (JSON object)",
"Secret environment variables (JSON)": "Secret environment variables (JSON)",
@@ -4116,6 +4270,7 @@
"Select a timestamp before clearing logs.": "Select a timestamp before clearing logs.",
"Select a usage mode to continue": "Select a usage mode to continue",
"Select a verification method first": "Select a verification method first",
+ "Select a version to compare": "Select a version to compare",
"Select active subscription plan": "Select active subscription plan",
"Select all": "Select all",
"Select all (filtered)": "Select all (filtered)",
@@ -4176,6 +4331,7 @@
"Select sync channels to compare prices": "Select sync channels to compare prices",
"Select sync channels to compare ratios": "Select sync channels to compare ratios",
"Select Sync Source": "Select Sync Source",
+ "Select task plugin": "Select task plugin",
"Select the API endpoint region": "Select the API endpoint region",
"Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.": "Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.",
"Select theme preference": "Select theme preference",
@@ -4189,6 +4345,7 @@
"Selected conflicts were overwritten successfully.": "Selected conflicts were overwritten successfully.",
"Selected nodes": "Selected nodes",
"Selected when creating a token and used as the default billing group for API calls.": "Selected when creating a token and used as the default billing group for API calls.",
+ "Selecting a plugin fills its declared models.": "Selecting a plugin fills its declared models.",
"Self-Use Mode": "Self-Use Mode",
"Send": "Send",
"Send a request": "Send a request",
@@ -4321,12 +4478,15 @@
"Sort by ID": "Sort by ID",
"Sort Order": "Sort Order",
"Source": "Source",
+ "Source diff": "Source diff",
"Source Endpoint": "Source Endpoint",
"Source Field": "Source Field",
"Source Header": "Source Header",
+ "Source name": "Source name",
"sources": "sources",
"Space-separated OAuth scopes": "Space-separated OAuth scopes",
"Spark model version, e.g., v2.1 (version number in API URL)": "Spark model version, e.g., v2.1 (version number in API URL)",
+ "Spec": "Spec",
"Special billing expression": "Special billing expression",
"Special group": "Special group",
"Special ratio rules": "Special ratio rules",
@@ -4508,11 +4668,21 @@
"Target Path (optional)": "Target Path (optional)",
"Target User": "Target User",
"Task": "Task",
+ "Task billing": "Task billing",
+ "Task Details": "Task Details",
"Task History": "Task History",
"Task ID": "Task ID",
"Task ID:": "Task ID:",
"Task logs": "Task logs",
"Task Logs": "Task Logs",
+ "Task Plugin": "Task Plugin",
+ "Task plugin setting updated": "Task plugin setting updated",
+ "Task plugin *": "Task plugin *",
+ "Task Plugins": "Task Plugins",
+ "Task pricing": "Task pricing",
+ "Task pricing not configured": "Task pricing not configured",
+ "Task usage prices are USD per declared unit. They are not token prices and are not divided by one million.": "Task usage prices are USD per declared unit. They are not token prices and are not divided by one million.",
+ "Task usage prices are USD per declared unit. Token fields use dollars per 1M tokens; the editor writes / 1000000 into the expression. Other units are not divided by one million.": "Task usage prices are USD per declared unit. Token fields use dollars per 1M tokens; the editor writes / 1000000 into the expression. Other units are not divided by one million.",
"Tasks currently pending or running.": "Tasks currently pending or running.",
"Team Collaboration": "Team Collaboration",
"Technical Support": "Technical Support",
@@ -4565,12 +4735,15 @@
"The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.",
"The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.",
"The deployment node that handled the requests": "The deployment node that handled the requests",
+ "The downloaded source does not match the sha256 declared in the index. Do not install it.": "The downloaded source does not match the sha256 declared in the index. Do not install it.",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "The effective domain for Passkey registration. Must match the current domain or be its parent domain.",
"The entered text does not match the required text.": "The entered text does not match the required text.",
"The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.",
"The exact model identifier as used in API requests.": "The exact model identifier as used in API requests.",
"The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.",
"The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:",
+ "The gateway rejected this plugin": "The gateway rejected this plugin",
+ "The index could not be fetched or parsed: {{message}}. The host may block cross-origin requests.": "The index could not be fetched or parsed: {{message}}. The host may block cross-origin requests.",
"The login session that started this Telegram binding is no longer valid.": "The login session that started this Telegram binding is no longer valid.",
"The mapped upstream model(s)": "The mapped upstream model(s)",
"The model that was requested": "The model that was requested",
@@ -4594,6 +4767,7 @@
"The upstream natively supports all three protocols; every selected route is forwarded without conversion.": "The upstream natively supports all three protocols; every selected route is forwarded without conversion.",
"The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.": "The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.",
"The URL for this chat client.": "The URL for this chat client.",
+ "The URL returned HTTP {{status}}. Check the address, or download the file and paste its source below.": "The URL returned HTTP {{status}}. Check the address, or download the file and paste its source below.",
"The user group applied to the requests": "The user group applied to the requests",
"The user who made the requests": "The user who made the requests",
"Theme": "Theme",
@@ -4603,11 +4777,18 @@
"There is a rule for vip billed as premium → use its ratio 0.3": "There is a rule for vip billed as premium → use its ratio 0.3",
"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.": "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.",
"These toggles affect whether certain request fields are passed through to the upstream provider.": "These toggles affect whether certain request fields are passed through to the upstream provider.",
+ "These values come from the source index and are shown for review only. The gateway admits the plugin based on the metadata compiled from its source.": "These values come from the source index and are shown for review only. The gateway admits the plugin based on the metadata compiled from its source.",
"Thinking Suffix Adapter": "Thinking Suffix Adapter",
"Thinking to Content": "Thinking to Content",
"Thinking...": "Thinking...",
+ "Third-party": "Third-party",
+ "Third-party — use at your own risk": "Third-party — use at your own risk",
"Third-party account bindings (read-only, managed by user in profile settings)": "Third-party account bindings (read-only, managed by user in profile settings)",
"Third-party Payment Config": "Third-party Payment Config",
+ "Third-party plugin risk": "Third-party plugin risk",
+ "Third-party source risk": "Third-party source risk",
+ "Third-party-only plugins become unavailable immediately. In-flight tasks will be handled by timeout cleanup.": "Third-party-only plugins become unavailable immediately. In-flight tasks will be handled by timeout cleanup.",
+ "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.": "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.",
"This action cannot be undone.": "This action cannot be undone.",
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "This action cannot be undone. This will permanently delete your account and remove all your data from our servers.",
"This action will permanently remove 2FA protection from your account.": "This action will permanently remove 2FA protection from your account.",
@@ -4618,11 +4799,13 @@
"This channel is not an Ollama channel.": "This channel is not an Ollama channel.",
"This channel type does not support fetching models": "This channel type does not support fetching models",
"This channel type requires additional configuration": "This channel type requires additional configuration",
+ "This combination will be billed as free.": "This combination will be billed as free.",
"This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.": "This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.",
"This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.": "This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.",
"This data may be unreliable, use with caution": "This data may be unreliable, use with caution",
"This device does not support Passkey": "This device does not support Passkey",
"This device does not support Passkey verification.": "This device does not support Passkey verification.",
+ "This expression does not price each combination exactly once, so it opens as a raw expression. Sparse or custom pricing stays in this editor.": "This expression does not price each combination exactly once, so it opens as a raw expression. Sparse or custom pricing stays in this editor.",
"This expression is too complex for the visual editor. Please switch to expression mode to edit.": "This expression is too complex for the visual editor. Please switch to expression mode to edit.",
"This FAQ entry will be removed from the list.": "This FAQ entry will be removed from the list.",
"This feature is experimental. Configuration format and behavior may change.": "This feature is experimental. Configuration format and behavior may change.",
@@ -4630,15 +4813,19 @@
"This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.",
"This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.",
"This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.": "This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.",
+ "This is a task model billed by usage (e.g. seconds, resolution). Prices entered here act as a per-call base rate, not per-token prices.": "This is a task model billed by usage (e.g. seconds, resolution). Prices entered here act as a per-call base rate, not per-token prices.",
"This may cause cache failures.": "This may cause cache failures.",
"This may take a few moments while we validate the request and update your session.": "This may take a few moments while we validate the request and update your session.",
"This model has both fixed price and ratio billing conflicts": "This model has both fixed price and ratio billing conflicts",
"This model has both fixed-price and ratio settings. Saving the current mode will rewrite the conflicting fields.": "This model has both fixed-price and ratio settings. Saving the current mode will rewrite the conflicting fields.",
"This model has both fixed-price and token-price settings. Saving the current mode will rewrite the conflicting fields.": "This model has both fixed-price and token-price settings. Saving the current mode will rewrite the conflicting fields.",
+ "This model is billed by usage, but the administrator has not configured its pricing yet.": "This model is billed by usage, but the administrator has not configured its pricing yet.",
"This model is not available in any group, or no group pricing information is configured.": "This model is not available in any group, or no group pricing information is configured.",
"This month": "This month",
"This page has not been created yet.": "This page has not been created yet.",
"This plan does not allow balance redemption": "This plan does not allow balance redemption",
+ "This plugin has no factory fallback. Deleting or disabling it makes this platform unavailable.": "This plugin has no factory fallback. Deleting or disabling it makes this platform unavailable.",
+ "This plugin path does not resolve within the source repository.": "This plugin path does not resolve within the source repository.",
"This project must be used in compliance with the": "This project must be used in compliance with the",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "This removes {{count}} failed models from this channel. This action cannot be undone.",
"This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "This route discovers upstream OpenAI models and cannot be split or matched by client model rules.",
@@ -4646,6 +4833,8 @@
"This route is used only by channel management to query the upstream balance.": "This route is used only by channel management to query the upstream balance.",
"This session will lose access immediately and must sign in again.": "This session will lose access immediately and must sign in again.",
"This site currently has {{count}} models enabled": "This site currently has {{count}} models enabled",
+ "This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.": "This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.",
+ "This source lists no installable task plugins.": "This source lists no installable task plugins.",
"This Telegram account is already bound.": "This Telegram account is already bound.",
"This Telegram binding request has expired or has already been used.": "This Telegram binding request has expired or has already been used.",
"This tier catches any request that did not match earlier tiers.": "This tier catches any request that did not match earlier tiers.",
@@ -4704,6 +4893,7 @@
"times": "times",
"Timing": "Timing",
"Tip": "Tip",
+ "Tip: after configuring one model, select others in the table and use bulk copy.": "Tip: after configuring one model, select others in the table and use bulk copy.",
"to access this resource.": "to access this resource.",
"To Anthropic Messages": "To Anthropic Messages",
"to confirm": "to confirm",
@@ -4722,7 +4912,9 @@
"Toggle navigation menu": "Toggle navigation menu",
"Toggle plan": "Toggle plan",
"Toggle theme": "Toggle theme",
+ "token": "token",
"Token": "Token",
+ "token (unit)": "token",
"Token Breakdown": "Token Breakdown",
"Token Endpoint": "Token Endpoint",
"Token Endpoint (Optional)": "Token Endpoint (Optional)",
@@ -4885,6 +5077,8 @@
"Unexpected release payload": "Unexpected release payload",
"Unified API Gateway for": "Unified API Gateway for",
"Unique identifier for this group.": "Unique identifier for this group.",
+ "unit": "unit",
+ "Unit": "Unit",
"Unit price (local currency / USD)": "Unit price (local currency / USD)",
"Unit price (USD)": "Unit price (USD)",
"Unit price must be greater than 0": "Unit price must be greater than 0",
@@ -4903,6 +5097,7 @@
"Untrusted upstream data:": "Untrusted upstream data:",
"Unused": "Unused",
"Up to 4 strings that stop generation": "Up to 4 strings that stop generation",
+ "Up to date": "Up to date",
"Update": "Update",
"Update All Balances": "Update All Balances",
"Update API Key": "Update API Key",
@@ -4941,15 +5136,26 @@
"Updated user {{username}} (ID: {{id}})": "Updated user {{username}} (ID: {{id}})",
"Updating all channel balances. This may take a while. Please refresh to see results.": "Updating all channel balances. This may take a while. Please refresh to see results.",
"Updating...": "Updating...",
+ "Upgrade {{name}}": "Upgrade {{name}}",
+ "Upgrade and enable": "Upgrade and enable",
+ "Upgrade available: v{{installed}} to v{{latest}}": "Upgrade available: v{{installed}} to v{{latest}}",
"Upgrade Group": "Upgrade Group",
"Upgrade plaintext SMTP connection with STARTTLS before authentication": "Upgrade plaintext SMTP connection with STARTTLS before authentication",
"Upload": "Upload",
+ "Upload a JavaScript task platform plugin.": "Upload a JavaScript task platform plugin.",
"Upload a single service account JSON file": "Upload a single service account JSON file",
+ "Upload a task plugin to add a platform.": "Upload a task plugin to add a platform.",
"Upload file": "Upload file",
"Upload files": "Upload files",
"Upload multiple JSON files in batch modes": "Upload multiple JSON files in batch modes",
+ "Upload new plugin version": "Upload new plugin version",
+ "Upload new version": "Upload new version",
"Upload or reference a local configuration file.": "Upload or reference a local configuration file.",
"Upload photo": "Upload photo",
+ "Upload plugin": "Upload plugin",
+ "Upload task plugin": "Upload task plugin",
+ "Uploading a plugin is an administrator-level trust decision. A plugin can access channel credentials and shape upstream requests. Review its source and diff before activation.": "Uploading a plugin is an administrator-level trust decision. A plugin can access channel credentials and shape upstream requests. Review its source and diff before activation.",
+ "Uploading...": "Uploading...",
"Upscale": "Upscale",
"Upstream": "Upstream",
"Upstream did not return reset credit details.": "Upstream did not return reset credit details.",
@@ -4975,6 +5181,7 @@
"Upstream Response (billing-usage-openai-estimated)": "Upstream Response (billing-usage-openai-estimated)",
"Upstream Response (billing-usage-openai)": "Upstream Response (billing-usage-openai)",
"upstream services integrated": "upstream services integrated",
+ "Upstream Task ID": "Upstream Task ID",
"Upstream Updates": "Upstream Updates",
"Upstream URL": "Upstream URL",
"Upstream URL must be a full URL": "Upstream URL must be a full URL",
@@ -4995,7 +5202,11 @@
"Usage logs": "Usage logs",
"Usage Logs": "Usage Logs",
"Usage mode": "Usage mode",
+ "Usage parameters": "Usage parameters",
+ "Usage prices": "Usage prices",
"Usage-based": "Usage-based",
+ "Usage-based billing": "Usage-based billing",
+ "Usage-based billing · price not configured": "Usage-based billing · price not configured",
"USD": "USD",
"USD Exchange Rate": "USD Exchange Rate",
"USD price per 1M input tokens.": "USD price per 1M input tokens.",
@@ -5084,6 +5295,7 @@
"Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.",
"uses": "uses",
"Using the complete global Auto order ({{count}} groups)": "Using the complete global Auto order ({{count}} groups)",
+ "v{{installed}} is installed but this source does not list it. Installing replaces it with v{{target}}.": "v{{installed}} is installed but this source does not list it. Installing replaces it with v{{target}}.",
"Validity": "Validity",
"Validity Period": "Validity Period",
"Value": "Value",
@@ -5124,7 +5336,9 @@
"Verify your database connection": "Verify your database connection",
"Verifying credentials and pulling stores from your Pancake account...": "Verifying credentials and pulling stores from your Pancake account...",
"Version": "Version",
+ "Version history": "Version history",
"Version Overrides": "Version Overrides",
+ "Versions": "Versions",
"Vertex AI": "Vertex AI",
"Vertex AI API Key mode does not support batch creation": "Vertex AI API Key mode does not support batch creation",
"Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.": "Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.",
@@ -5148,6 +5362,7 @@
"View Pricing": "View Pricing",
"View the complete details for this": "View the complete details for this",
"View the complete details for this log entry": "View the complete details for this log entry",
+ "View the complete details for this task": "View the complete details for this task",
"View the complete error message and details": "View the complete error message and details",
"View the complete prompt and its English translation": "View the complete prompt and its English translation",
"View the generated image": "View the generated image",
@@ -5240,6 +5455,8 @@
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.",
"When billed as {{group}}": "When billed as {{group}}",
"When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.": "When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.",
+ "When disabled, all uploaded custom plugins are ignored and every platform falls back to its built-in factory plugin.": "When disabled, all uploaded custom plugins are ignored and every platform falls back to its built-in factory plugin.",
+ "When disabled, the entire task plugin system stops serving, including factory and custom plugins.": "When disabled, the entire task plugin system stops serving, including factory and custom plugins.",
"When enabled, if channels in the current group fail, it will try channels in the next group in order.": "When enabled, if channels in the current group fail, it will try channels in the next group in order.",
"When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. Leave it off to delete the entry and select another channel.": "When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. Leave it off to delete the entry and select another channel.",
"When enabled, large request bodies are temporarily stored on disk instead of memory, significantly reducing memory usage. SSD recommended.": "When enabled, large request bodies are temporarily stored on disk instead of memory, significantly reducing memory usage. SSD recommended.",
diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json
index 4c51ddb6d3a8..901736b038b4 100644
--- a/web/src/i18n/locales/fr.json
+++ b/web/src/i18n/locales/fr.json
@@ -29,7 +29,9 @@
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]": "[{\"name\":\"Alipay\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]",
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]": "[{\"name\":\"Alipay\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]",
"{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
+ "{{bytes}} bytes": "{{bytes}} octets",
"{{category}} Models": "Modèles {{category}}",
+ "{{channels}} channels, {{tasks}} in-flight tasks": "{{channels}} canaux, {{tasks}} tâches en cours",
"{{completed}}/{{total}} completed": "{{completed}}/{{total}} terminé(s)",
"{{count}} / {{max}} groups selected": "{{count}} groupes sélectionnés sur {{max}}",
"{{count}} announcements will be removed from the list.": "{{count}} annonces seront retirées de la liste.",
@@ -39,9 +41,11 @@
"{{count}} channel(s) enabled": "{{count}} canal(canaux) activé(s)",
"{{count}} channel(s) failed to disable": "{{count}} canal(canaux) n'ont pas pu être désactivé(s)",
"{{count}} channel(s) failed to enable": "{{count}} canal(canaux) n'ont pas pu être activé(s)",
+ "{{count}} combinations": "{{count}} combinaisons",
"{{count}} days ago": "il y a {{count}} jours",
"{{count}} days remaining": "{{count}} days remaining",
"{{count}} disabled channel(s) deleted": "{{count}} canal(canaux) désactivé(s) supprimé(s)",
+ "{{count}} enabled channels and {{tasks}} in-flight tasks still use this plugin.": "{{count}} canaux actifs et {{tasks}} tâches en cours utilisent encore ce plugin.",
"{{count}} FAQ entries will be removed from the list.": "{{count}} entrées de FAQ seront retirées de la liste.",
"{{count}} hours ago": "il y a {{count}} heures",
"{{count}} incidents": "{{count}} incidents",
@@ -60,6 +64,7 @@
"{{count}} weeks ago": "il y a {{count}} semaines",
"{{field}} updated to {{value}}": "{{field}} mis à jour en {{value}}",
"{{field}} updated to {{value}} for tag: {{tag}}": "{{field}} mis à jour en {{value}} pour le tag : {{tag}}",
+ "{{key}} · version {{version}} · from {{source}}": "{{key}} · version {{version}} · depuis {{source}}",
"{{method}} {{route}}": "{{method}} {{route}}",
"{{modality}} not supported": "{{modality}} non pris en charge",
"{{modality}} supported": "{{modality}} pris en charge",
@@ -104,6 +109,7 @@
"14 Days": "14 jours",
"192.168.1.1
10.0.0.0/8": "192.168.1.1
10.0.0.0/8",
"1M": "1M",
+ "1M token": "1M token",
"1W": "1S",
"2. Copy the application token": "2. Copiez le jeton de l'application",
"20 / page": "20 / page",
@@ -147,6 +153,7 @@
"Action": "Action",
"Action confirmation": "Confirmation de l'action",
"Actions": "Actions",
+ "Activate / Roll back": "Activer / Restaurer",
"active": "actif",
"Active": "Actif",
"Active apps": "Applications actives",
@@ -155,6 +162,7 @@
"Active models": "Modèles actifs",
"Active Tasks": "Tâches actives",
"active users": "utilisateurs actifs",
+ "Active version": "Version active",
"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",
@@ -171,6 +179,7 @@
"Add a new user by providing necessary info.": "Ajouter un nouvel utilisateur en fournissant les informations nécessaires.",
"Add a new vendor to the system": "Ajouter un nouveau fournisseur au système",
"Add an extra layer of security to your account": "Ajouter une couche de sécurité supplémentaire à votre compte",
+ "Add an index URL to browse installable plugins.": "Ajoutez une URL d’index pour parcourir les plugins installables.",
"Add and submit": "Ajouter et soumettre",
"Add Announcement": "Ajouter une annonce",
"Add API": "Ajouter une API",
@@ -217,6 +226,7 @@
"Add rule group": "Ajouter un groupe de règles",
"Add rules for a user group": "Ajouter des règles pour un groupe d’utilisateurs",
"Add selectable group": "Ajouter un groupe sélectionnable",
+ "Add source": "Ajouter une source",
"Add split": "Ajouter une branche",
"Add subscription": "Ajouter un abonnement",
"Add tags...": "Ajouter des étiquettes...",
@@ -292,6 +302,7 @@
"All": "Tout",
"All API tokens": "Tous les jetons API",
"All categories": "Toutes catégories",
+ "All combinations are priced at zero. Matching requests will be billed as free.": "Toutes les combinaisons ont un prix nul. Les requêtes correspondantes ne seront pas facturées.",
"All conditions must match before this tier is used.": "Toutes les conditions doivent correspondre avant que ce palier soit utilisé.",
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "Toutes les modifications sont des opérations d'écrasement. Laissez les champs vides pour conserver les valeurs actuelles inchangées.",
"All files exceed the maximum size.": "Tous les fichiers dépassent la taille maximale.",
@@ -348,6 +359,7 @@
"Allow using models without price configuration": "Autoriser l'utilisation de modèles sans configuration de prix",
"Allow wallet balance after quota used up": "Autoriser le solde du portefeuille une fois le quota épuisé",
"Allowed": "Autorisé",
+ "Allowed hosts": "Hôtes autorisés",
"Allowed Origins": "Origines autorisées",
"Allowed Ports": "Ports autorisés",
"Already have an account?": "Vous avez déjà un compte ?",
@@ -380,6 +392,7 @@
"Anthropic": "Anthropic",
"Anthropic Messages to OpenAI Chat": "Anthropic Messages vers OpenAI Chat",
"Any Match (OR)": "N'importe laquelle (OR)",
+ "Anyone can publish an index. A plugin installed from a third-party source has the same access as one you upload by hand: review its source before installing.": "N’importe qui peut publier un index. Un plugin installé depuis une source tierce a exactement les mêmes accès qu’un plugin importé à la main : examinez son code avant de l’installer.",
"API": "API",
"API Access": "Accès API",
"API Addresses": "Adresses API",
@@ -414,6 +427,8 @@
"API token management": "Gestion des tokens API",
"API URL": "URL de l'API",
"API usage records": "Historique d'utilisation de l'API",
+ "API version": "Version API",
+ "API Version": "Version de l'API",
"API2GPT": "API2GPT",
"App": "Application",
"App rankings shown here are simulated for preview purposes and will be replaced with live usage data once the backend integration is complete.": "Les classements d'applications présentés ici sont simulés à des fins de prévisualisation et seront remplacés par des données réelles une fois l'intégration du backend terminée.",
@@ -437,8 +452,10 @@
"Apply plan": "Appliquer le plan",
"Apply reset": "Appliquer la réinitialisation",
"Apply Sync": "Appliquer la synchronisation",
+ "Apply to all rows": "Appliquer à toutes les lignes",
"Applying...": "Application en cours...",
"Approx.": "Environ.",
+ "Approximate prices for common specs.": "Prix approximatifs pour les spécifications courantes.",
"apps": "applications",
"Apps": "Applications",
"apps tracked": "applications suivies",
@@ -461,12 +478,17 @@
"Are you sure?": "Êtes-vous sûr ?",
"Area Chart": "Graphique en aires",
"Args (space separated)": "Arguments (séparés par des espaces)",
+ "Arguments JSON": "Arguments JSON",
+ "Arguments must be a JSON array": "Les arguments doivent être un tableau JSON",
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Tableau de préréglages de clients de chat. Chaque élément est un objet avec une paire clé-valeur : nom du client et son URL.",
+ "Artifacts": "Artefacts",
"Asc": "Asc",
"Ask anything": "Demandez n'importe quoi",
"Assigned by administrator only": "Attribué uniquement par l'administrateur",
"Assigned by administrators and used to represent a user level, such as default or vip.": "Attribué par les administrateurs pour représenter un niveau utilisateur, comme default ou vip.",
+ "Async": "Asynchrone",
"Async task polling": "Interrogation des tâches asynchrones",
+ "Async Task Public Address": "Adresse publique des tâches asynchrones",
"Async task refund": "Remboursement de tâche asynchrone",
"At least one model regex pattern is required": "Au moins un modèle de regex est requis",
"At least one valid key source is required": "Au moins une source de clé valide est requise",
@@ -585,8 +607,10 @@
"Balance updated: {{balance}}": "Solde mis à jour : {{balance}}",
"Bar Chart": "Graphique en barres",
"Bark Push URL": "URL de notification Bark",
+ "Base": "Base",
"Base address provided by your Epay service": "Adresse de base fournie par votre service Epay",
"Base amount. Actual deduction = base amount × system group rate.": "Montant de base. Déduction réelle = montant de base × taux du groupe système.",
+ "Base charge": "Frais de base",
"Base input and output token prices for this tier.": "Prix de base des tokens en entrée et en sortie pour ce palier.",
"Base input price only": "Prix d’entrée de base uniquement",
"Base Limits": "Limites de base",
@@ -594,6 +618,7 @@
"Base Price": "Prix de base",
"Base rate limit windows for this account.": "Fenêtres de limitation de débit de base pour ce compte.",
"Base URL": "URL de base",
+ "Base URL *": "URL de base *",
"Base URL is required for this channel type": "L'URL de base est requise pour ce type de canal",
"Base URL is required when an advanced route uses an upstream path": "La Base URL est requise lorsqu’une route avancée utilise un chemin amont",
"Base URL of your Uptime Kuma instance": "URL de base de votre instance Uptime Kuma",
@@ -638,6 +663,7 @@
"Billing group = vip (the token has no group, so use the user group)": "Groupe de facturation = vip (le jeton n’a pas de groupe, on utilise le groupe de l’utilisateur)",
"Billing History": "Historique de facturation",
"Billing Mode": "Mode de facturation",
+ "Billing parameters": "Paramètres de facturation",
"Billing Path": "Chemin de facturation",
"Billing Process": "Processus de facturation",
"Billing rule: each call is billed as the token group (falling back to the user group when the token has none). The base ratio always comes from that billing group, not from the user group. To give a user group a special price on another billing group, add an entry in the override matrix.": "Règle de facturation : chaque appel est facturé selon le groupe du jeton (à défaut, le groupe de l’utilisateur). Le taux de base provient toujours de ce groupe de facturation, pas du groupe de l’utilisateur. Pour accorder à un groupe d’utilisateurs un tarif spécial sur un autre groupe de facturation, ajoutez une entrée dans la matrice de remplacement.",
@@ -648,6 +674,7 @@
"Bind Email": "Lier l'e-mail",
"Bind Telegram Account": "Lier le compte Telegram",
"Bind WeChat Account": "Lier le compte WeChat",
+ "Bind task plugins": "Lier des plugins de tâche",
"Binding Information": "Informations de liaison",
"Binding successful!": "Liaison réussie !",
"Binding your {{provider}} account": "Liaison de votre compte {{provider}}",
@@ -686,6 +713,7 @@
"Built for developers,": "Conçu pour les développeurs,",
"Built-in": "Intégré",
"Built-in Device": "Appareil intégré",
+ "Built-in v{{factory}} / marketplace v{{market}}": "Intégré v{{factory}} / marché v{{market}}",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Intégré : empreinte digitale/visage du téléphone, ou Windows Hello ; Externe : clé de sécurité USB",
"by": "par",
"By category": "Par catégorie",
@@ -738,6 +766,7 @@
"Caps the response length": "Limite la longueur de la réponse",
"Capture a reusable bundle of models, tags, or endpoints.": "Capturez un ensemble réutilisable de modèles, d'étiquettes ou de points de terminaison.",
"Card view": "Vue cartes",
+ "Cascade disable channels": "Désactiver aussi les canaux",
"Catch-all route must be last for the same incoming path": "Le routage de secours doit être le dernier pour le même chemin d'entrée",
"Category": "Catégorie",
"Category Name": "Nom de la catégorie",
@@ -779,7 +808,9 @@
"Channel test concurrency": "Parallélisme des tests de canaux",
"Channel test concurrency must be between 1 and 32": "Le parallélisme des tests de canaux doit être compris entre 1 et 32",
"Channel test mode": "Mode de test des canaux",
+ "Channel type": "Type de canal",
"Channel type is required": "Le type de canal est requis",
+ "Channel types": "Types de canaux",
"Channel updated successfully": "Canal mis à jour avec succès",
"Channel-specific settings (JSON format)": "Paramètres spécifiques aux canaux (format JSON)",
"Channel:": "Canal :",
@@ -950,6 +981,7 @@
"Compare the most popular models on the platform": "Comparez les modèles les plus populaires de la plateforme",
"compatible API routes": "routes API compatibles",
"Compatible API routes for common AI application workflows": "Routes API compatibles pour les workflows courants des applications d'IA",
+ "Compilation failed": "Échec de compilation",
"Complete API documentation with multi-language SDK support": "Documentation API complète avec support SDK multilingue",
"Complete Order": "Compléter la commande",
"Complete these steps to finish the initial installation.": "Suivez ces étapes pour terminer l'installation initiale.",
@@ -1000,6 +1032,7 @@
"Configure pricing ratios for a specific model.": "Configurer les ratios de tarification pour un modèle spécifique.",
"Configure rate limiting rules for a specific user group.": "Configurer les règles de limitation de débit pour un groupe d'utilisateurs spécifique.",
"Configure routes": "Configurer les routes",
+ "Configure task pricing": "Configurer la tarification des tâches",
"Configure the ratio for this group.": "Configurer le ratio pour ce groupe.",
"Configure upstream providers and routing.": "Configurer les fournisseurs en amont et le routage.",
"Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "Configurer l'intégration du parcours de paiement hébergé Waffo Pancake pour les rechargements en USD",
@@ -1143,6 +1176,10 @@
"Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "Coût = prix du modèle × ce seul taux. Rien d’autre dans les réglages de groupes n’entre dans la formule.",
"Cost in USD per request, regardless of tokens used.": "Coût en USD par requête, quel que soit le nombre de jetons utilisés.",
"Cost Tracking": "Suivi des coûts",
+ "Could not fetch the plugin source from this browser. The host may block cross-origin requests or be unreachable.": "Impossible de récupérer le code du plugin depuis ce navigateur. L’hôte bloque peut-être les requêtes cross-origin ou est injoignable.",
+ "Could not fetch this URL from the browser. The host may block cross-origin requests or be unreachable. Download the file and paste its source below.": "Impossible de récupérer cette URL depuis le navigateur. L’hôte bloque peut-être les requêtes cross-origin ou est injoignable. Téléchargez le fichier et collez son code ci-dessous.",
+ "Could not load this source": "Impossible de charger cette source",
+ "Count": "Quantité",
"Count must be between {{min}} and {{max}}": "Le nombre doit être compris entre {{min}} et {{max}}",
"Coze": "Coze",
"CPU": "Processeur",
@@ -1199,6 +1236,7 @@
"Credentials": "Identifiants",
"Credentials verification failed": "Échec de la vérification des identifiants",
"Credentials verification failed — double-check Merchant ID and API private key.": "Échec de la vérification des identifiants — vérifiez l’ID marchand et la clé privée API.",
+ "credit": "credit",
"Credit remaining": "Crédit restant",
"Creem API key (leave blank unless updating)": "Clé API Creem (laissez vide sauf si mise à jour)",
"Creem Gateway": "Passerelle Creem",
@@ -1226,6 +1264,7 @@
"Current version": "Version actuelle",
"Current:": "Actuel :",
"Custom": "Personnalisé",
+ "Custom (overrides factory {{version}})": "Personnalisé (remplace la version intégrée {{version}})",
"Custom (seconds)": "Personnalisé (secondes)",
"Custom Amount": "Montant personnalisé",
"Custom API base URL. For official channels, New API has built-in addresses. Only fill this for third-party proxy sites or special endpoints. Do not add /v1 or trailing slash.": "URL de base API personnalisée. Pour les canaux officiels, New API dispose d'adresses intégrées. Ne remplissez ceci que pour les sites proxy tiers ou les points de terminaison spéciaux. N'ajoutez pas /v1 ou de barre oblique finale.",
@@ -1245,6 +1284,7 @@
"Custom OAuth Providers": "Fournisseurs OAuth personnalisés",
"Custom Seconds": "Secondes personnalisées",
"Custom sidebar section": "Section de barre latérale personnalisée",
+ "Custom task plugin setting updated": "Paramètre des plugins de tâches personnalisés mis à jour",
"Custom Time Range": "Plage horaire personnalisée",
"Custom Zoom": "Zoom personnalisé",
"Customize sidebar display content": "Personnaliser le contenu affiché dans la barre latérale",
@@ -1274,6 +1314,7 @@
"Days to Retain": "Jours à conserver",
"decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.": "détermine le taux de recharge, les groupes que l’utilisateur peut choisir pour ses jetons, et si un taux de remplacement s’applique.",
"decides which channels are used and which base ratio applies.": "détermine les canaux utilisés et le taux de base appliqué.",
+ "Declared capabilities": "Capacités déclarées",
"Decreased user quota by {{quota}}": "Quota de l'utilisateur diminué de {{quota}}",
"Deducted by subscription": "Déduit par abonnement",
"DeepSeek": "DeepSeek",
@@ -1306,6 +1347,7 @@
"Delete {{count}} stale instance records? Online instances will not be deleted.": "Supprimer {{count}} enregistrement(s) d'instance expirée ? Les instances en ligne ne seront pas supprimées.",
"Delete a runtime request header": "Supprimer un en-tête de requête à l'exécution",
"Delete Account": "Supprimer le compte",
+ "Delete active custom version": "Supprimer la version personnalisée active",
"Delete All Disabled": "Supprimer tout ce qui est désactivé",
"Delete All Disabled Channels?": "Supprimer tous les canaux désactivés ?",
"Delete all stale": "Supprimer toutes les expirées",
@@ -1329,6 +1371,7 @@
"Delete mapping": "Supprimer le mappage",
"Delete Model": "Supprimer le modèle",
"Delete Models?": "Supprimer les modèles ?",
+ "Delete plugin version?": "Supprimer cette version du plugin ?",
"Delete Provider": "Supprimer le fournisseur",
"Delete Request Header": "Supprimer un en-tête de requête",
"Delete selected API keys": "Supprimer les clés API sélectionnées",
@@ -1355,6 +1398,7 @@
"Deleted stale instance": "Instance expirée supprimée",
"Deleted successfully": "Supprimé avec succès",
"Deleted user {{username}} (ID: {{id}})": "Utilisateur {{username}} supprimé (ID : {{id}})",
+ "Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.": "La suppression de cette version personnalisée ne désactive pas la plateforme. Le plugin intégré du même nom sera restauré automatiquement.",
"Deleting will permanently remove this subscription record (including benefit details). Continue?": "La suppression supprimera définitivement cet enregistrement d'abonnement (y compris les détails des avantages). Continuer ?",
"Deleting...": "Suppression...",
"Demo site": "Site de démonstration",
@@ -1401,6 +1445,8 @@
"Disable": "Désactiver",
"Disable 2FA": "Désactiver la 2FA",
"Disable All": "Désactiver tout",
+ "Disable custom task plugins?": "Désactiver les plugins personnalisés ?",
+ "Disable task plugins?": "Désactiver les plugins de tâche ?",
"Disable on failure": "Désactiver en cas d'échec",
"Disable selected channels": "Désactiver les canaux sélectionnés",
"Disable selected models": "Désactiver les modèles sélectionnés",
@@ -1415,6 +1461,8 @@
"Disabled lanes are omitted on save.": "Les voies désactivées sont omises à l’enregistrement.",
"Disabled Reason": "Raison de la désactivation",
"Disabled Time": "Heure de désactivation",
+ "Disabled; fell back to factory": "Désactivé ; retour à la version intégrée",
+ "Disabled; platform unavailable": "Désactivé ; plateforme indisponible",
"Disabling...": "Désactivation en cours...",
"Disclaimer: Personal use only. Do not distribute or share any credentials. This channel has prerequisites and requires prior setup; use it only if you understand the flow and risks, and comply with OpenAI's terms and policies. Credentials and configuration are for Codex CLI integration only, and are not intended for any other client, platform, or channel.": "Avertissement : usage personnel uniquement. Ne distribuez ni ne partagez aucun identifiant. Ce canal a des prerequis et necessite une configuration prealable ; utilisez-le uniquement si vous comprenez la procedure et les risques, et respectez les conditions et politiques d'OpenAI. Les identifiants et la configuration sont reserves a l'integration Codex CLI et ne sont pas destines a d'autres clients, plateformes ou canaux.",
"Discord": "Discord",
@@ -1480,6 +1528,7 @@
"Drawing Logs": "Journaux de dessin",
"Drawing task polling": "Interrogation des tâches de dessin",
"Drawing task records": "Historique des tâches de dessin",
+ "Dry run result": "Résultat de la simulation",
"Duplicate": "Dupliquer",
"Duplicate group names: {{names}}": "Noms de groupe en double : {{names}}",
"Duplicate model in route models": "Modèle dupliqué dans les modèles de route",
@@ -1540,7 +1589,9 @@
"Each item must have exactly one key-value pair.": "Chaque élément doit avoir exactement une paire clé-valeur.",
"Each line represents one keyword. Leave blank to disable the list but keep the switch states.": "Chaque ligne représente un mot-clé. Laissez vide pour désactiver la liste mais conserver les états des interrupteurs.",
"Each matrix cell is one rule: users of this row group pay this ratio when billed as this column group. In JSON the row is the outer key and the column is the inner key.": "Chaque cellule de la matrice est une règle : les utilisateurs du groupe de la ligne paient ce taux lorsqu’ils sont facturés sous le groupe de la colonne. En JSON, la ligne est la clé externe et la colonne la clé interne.",
+ "Each row prices one combination of {{fields}}.": "Chaque ligne définit le prix d’une combinaison de {{fields}}.",
"Each rule reads as a sentence: users of one group pay a special ratio when billed as another group. Without a rule, the billing group base ratio applies.": "Chaque règle se lit comme une phrase : les utilisateurs d’un groupe paient un taux spécial lorsqu’ils sont facturés sous un autre groupe. Sans règle, le taux de base du groupe de facturation s’applique.",
+ "Each source serves an index.json listing installable plugins. Indexes are fetched by your browser; the gateway makes no outbound requests.": "Chaque source expose un index.json listant les plugins installables. Les index sont récupérés par votre navigateur ; la passerelle n’émet aucune requête sortante.",
"Each tier supports 0~2 conditions (over len, p, c); the last tier is the catch-all without conditions. Use len (full input length, including cache hits) for tier conditions to avoid mis-routing when cache hits reduce p.": "Chaque palier accepte 0 à 2 conditions (sur len, p, c) ; le dernier palier est le filet de sécurité sans condition. Utilisez len (longueur d'entrée complète, y compris les cache hits) pour les conditions de palier afin d'éviter les routages erronés lorsque les cache hits réduisent p.",
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "Chaque palier accepte jusqu’à 2 conditions ; le dernier palier sert de repli sans condition. Utilisez la longueur complète de l’entrée pour éviter un mauvais aiguillage lorsque les lectures de cache réduisent les tokens d’entrée facturables.",
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "Chaque palier prend en charge jusqu’à 2 conditions. Le dernier palier sans condition sert de repli.",
@@ -1598,6 +1649,8 @@
"Enable 2FA": "Activer 2FA",
"Enable All": "Tout activer",
"Enable check-in feature": "Activer la fonction de connexion",
+ "Enable custom task plugins": "Activer les plugins de tâches personnalisés",
+ "Enable task plugins": "Activer les plugins de tâche",
"Enable Data Dashboard": "Activer le tableau de bord des données",
"Enable demo mode with limited functionality": "Activer le mode démo avec des fonctionnalités limitées",
"Enable Discord OAuth": "Activer OAuth Discord",
@@ -1617,6 +1670,7 @@
"Enable or disable this model": "Activer ou désactiver ce modèle",
"Enable Passkey": "Activer Passkey",
"Enable Performance Monitoring": "Activer la surveillance des performances",
+ "Enable plugin {{key}}": "Activer le plugin {{key}}",
"Enable rate limiting": "Activer la limitation de débit",
"Enable Request Passthrough": "Activer le Passthrough de requêtes",
"Enable selected channels": "Activer les canaux sélectionnés",
@@ -1670,6 +1724,8 @@
"Enter a value and press Enter": "Saisir une valeur et appuyer sur Entrée",
"Enter amount in {{currency}}": "Entrez le montant en {{currency}}",
"Enter amount in tokens": "Entrez le montant en tokens",
+ "Enter an absolute HTTP(S) URL without credentials, query parameters, or fragments": "Saisissez une URL HTTP(S) absolue sans identifiants, paramètres de requête ni fragment",
+ "Enter an absolute http(s) URL.": "Saisissez une URL http(s) absolue.",
"Enter announcement content (supports Markdown & HTML)": "Saisir le contenu de l'annonce (prend en charge Markdown et HTML)",
"Enter announcement content (supports Markdown/HTML)": "Saisir le contenu de l'annonce (prend en charge Markdown/HTML)",
"Enter API Key": "Saisir la clé API",
@@ -1733,6 +1789,9 @@
"Enterprise Account": "Compte d'entreprise",
"Enterprise-grade security with comprehensive permission management": "Sécurité de niveau entreprise avec gestion complète des autorisations",
"Entrypoint (space separated)": "Point d'entrée (séparés par des espaces)",
+ "Enum": "Énumération",
+ "Boolean": "Booléen",
+ "Enum values": "Valeurs de l'énumération",
"Env (JSON object)": "Env (objet JSON)",
"Environment variables": "Variables d'environnement",
"Environment variables (JSON)": "Variables d'environnement (JSON)",
@@ -1764,6 +1823,8 @@
"Example": "Exemple",
"Example (all channels):": "Exemple (tous les canaux) :",
"Example (specific channels):": "Exemple (canaux spécifiques) :",
+ "Example price": "Prix d'exemple",
+ "Example spec": "Spécification d'exemple",
"Example:": "Exemple :",
"example.com
blocked-site.com": "example.com
blocked-site.com",
"example.com
company.com": "example.com
company.com",
@@ -1792,6 +1853,7 @@
"Expose ratio API": "Exposer l'API de ratio",
"Exposes the pricing/models catalog in the top navigation.": "Expose le catalogue des prix/modèles dans la navigation supérieure.",
"Expression": "Expression",
+ "Expression - Task pricing": "Expression - Tarification des tâches",
"Expression based": "Basé sur une expression",
"Expression billing": "Facturation par expression",
"Expression editor": "Éditeur d’expression",
@@ -1811,6 +1873,9 @@
"Extra visible": "Visible en plus",
"Extra visible to {{group}}": "Visible en plus pour {{group}}",
"extras": "suppléments",
+ "Factory": "Intégré",
+ "Factory and custom plugin behavior": "Comportement des plugins intégrés et personnalisés",
+ "Factory plugins cannot be deleted or disabled individually. A custom version can override them; deleting or disabling that version restores the factory plugin. Third-party-only platforms become unavailable when their plugin is deleted or disabled.": "Les plugins intégrés ne peuvent pas être supprimés ni désactivés individuellement. Une version personnalisée peut les remplacer ; sa suppression ou désactivation restaure le plugin intégré. Une plateforme tierce devient indisponible si son plugin est supprimé ou désactivé.",
"Fail Reason": "Raison de l'échec",
"Fail Reason Details": "Détails de la raison de l'échec",
"failed": "échoué",
@@ -1879,6 +1944,7 @@
"Failed to initialize system": "Échec de l'initialisation du système",
"Failed to load": "Échec du chargement",
"Failed to load API keys": "Échec du chargement des Clés API",
+ "Failed to load artifacts": "Échec du chargement des artefacts",
"Failed to load billing history": "Échec du chargement de l'historique de facturation",
"Failed to load enabled models": "Échec du chargement des modèles activés",
"Failed to load home page content": "Échec du chargement du contenu de la page d'accueil",
@@ -1968,15 +2034,20 @@
"Feature in development": "Fonctionnalité en développement",
"Fee": "Frais",
"Fee Amount": "Montant des frais",
+ "Fetch": "Récupérer",
"Fetch available models for:": "Récupérer les modèles disponibles pour :",
"Fetch available models from upstream": "Récupérer les modèles disponibles en amont",
"Fetch from Upstream": "Récupérer depuis l'amont",
"Fetch Models": "Récupérer les modèles",
+ "Fetch mode": "Mode de récupération",
"Fetched {{count}} model(s) from upstream": "{{count}} modèle(s) récupéré(s) depuis l'amont",
"Fetched {{count}} models": "{{count}} modèles récupérés",
+ "Fetched in your browser and placed in the source field below for review. GitHub and gist page URLs are rewritten to their raw URL automatically.": "Récupéré par votre navigateur et placé dans le champ de code ci-dessous pour examen. Les URL de pages GitHub et gist sont automatiquement réécrites en URL raw.",
+ "Fetching plugin source...": "Récupération du code du plugin…",
"Fetching prefill groups...": "Récupération des groupes de préremplissage...",
"Fetching upstream prices...": "Récupération des prix amont...",
"Fetching upstream ratios...": "Récupération des ratios amont...",
+ "Fetching...": "Récupération…",
"field": "champ",
"Field Mapping": "Mappage de champs",
"Field passthrough controls": "Contrôles de transmission des champs",
@@ -1986,6 +2057,7 @@
"Files to Retain": "Fichiers à conserver",
"Fill All Models": "Remplir tous les modèles",
"Fill Codex CLI / Claude CLI Templates": "Remplir les modèles Codex CLI / Claude CLI",
+ "Fill entire column": "Remplir toute la colonne",
"Fill example (all channels)": "Remplir l'exemple (tous les canaux)",
"Fill example (specific channels)": "Remplir l'exemple (canaux spécifiques)",
"Fill in": "Remplir",
@@ -2025,6 +2097,7 @@
"Filter models by provider, group, type, endpoint, and tags.": "Filtrer les modèles par fournisseur, groupe, type, endpoint et tags.",
"Filter models by type, endpoint, vendor, group and tags": "Filtrer les modèles par type, point d'accès, fournisseur, groupe et tags",
"Filter models...": "Filtrer les modèles...",
+ "Filter plugins...": "Filtrer les plugins...",
"Filter the model analytics view by time range and user.": "Filtrez la vue d’analyse des modèles par période et utilisateur.",
"Filter the traffic flow view by time range and user.": "Filtrez la vue du flux de trafic par plage horaire et utilisateur.",
"Filter...": "Filtrer...",
@@ -2078,6 +2151,7 @@
"Force Format": "Forcer le format",
"Force format response to OpenAI standard (OpenAI channel only)": "Forcer la réponse au format standard OpenAI (canal OpenAI uniquement)",
"Force JSON object or schema-conforming output": "Forcer une sortie JSON ou conforme à un schéma",
+ "Force operation": "Forcer l’opération",
"Force SMTP authentication using AUTH LOGIN method": "Forcer l'authentification SMTP en utilisant la méthode AUTH LOGIN",
"Force-disabled two-factor authentication for the user": "Authentification à deux facteurs désactivée de force pour l'utilisateur",
"Forest Whisper": "Murmure forestier",
@@ -2251,6 +2325,7 @@
"Home": "Accueil",
"Home Page Content": "Contenu de la page d'accueil",
"Homepage URL": "URL de la page d'accueil",
+ "Hook": "Hook",
"Hostname or IP of your SMTP provider": "Nom d'hôte ou IP de votre fournisseur SMTP",
"Hour": "Heure",
"Hour of day": "Heure du jour",
@@ -2329,6 +2404,7 @@
"Image to Video": "Image vers vidéo",
"Image Tokens": "Tokens image",
"Imagine the pricing table has three groups: default (ratio 1.0), premium (ratio 0.5), and vip (ratio 0.8). Users whose account is in the vip group get user-level perks, and premium is a cheaper channel pool that users can pick for their tokens.": "Imaginez que le tableau tarifaire contient trois groupes : default (taux 1,0), premium (taux 0,5) et vip (taux 0,8). Les utilisateurs du groupe vip bénéficient d’avantages au niveau du compte, et premium est un pool de canaux moins cher que les utilisateurs peuvent choisir pour leurs jetons.",
+ "Import from URL": "Importer depuis une URL",
"Import to CC Switch": "Importer vers CC Switch",
"Important": "Important",
"In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "En JSON, le groupe d’utilisateurs est la clé externe et le groupe de facturation la clé interne. L’exemple ci-dessous signifie : les utilisateurs vip paient 0,8 sous standard et 0,3 sous premium.",
@@ -2352,6 +2428,8 @@
"Incomplete": "Incomplet",
"Increased user quota by {{quota}}": "Quota de l'utilisateur augmenté de {{quota}}",
"Index": "Index",
+ "Index request failed with HTTP {{status}}": "Échec de la requête d’index : HTTP {{status}}",
+ "Index URL": "URL de l’index",
"Inherit global Auto order": "Hériter de l’ordre Auto global",
"Initial quota given to new users": "Quota initial donné aux nouveaux utilisateurs",
"Initial quota given to new users ({{formattedQuota}})": "Quota initial donné aux nouveaux utilisateurs ({{formattedQuota}})",
@@ -2369,10 +2447,21 @@
"Inset": "Encastré",
"Inspect requests, errors, and billing details": "Inspecter les requêtes, les erreurs et les détails de facturation",
"Inspect user prompts": "Inspecter les invites utilisateur",
+ "Install": "Installer",
+ "Install {{name}}": "Installer {{name}}",
+ "Install and enable": "Installer et activer",
+ "Installed": "Installés",
+ "Installed {{name}} v{{version}}": "{{name}} v{{version}} installé",
+ "Installed v{{from}} → marketplace v{{to}}": "Installé v{{from}} → marché v{{to}}",
+ "Installed v{{installed}} not listed": "v{{installed}} installée, absente de l’index",
+ "Installed version is not in this index": "La version installée est absente de cet index",
+ "Installing...": "Installation…",
"Instance": "Instance",
"Instances": "Instances",
"Insufficient balance": "Solde insuffisant",
"Integrations": "Intégrations",
+ "Integrity check failed": "Échec du contrôle d’intégrité",
+ "Integrity hash": "Empreinte d’intégrité",
"Inter-group overrides": "Dérogations inter-groupes",
"Inter-group ratio overrides": "Dérogations de ratio inter-groupes",
"Interface Language": "Langue de l'interface",
@@ -2425,6 +2514,7 @@
"It seems like the page you're looking for": "Il semble que la page que vous recherchez",
"Items": "Éléments",
"Japanese": "Japonais",
+ "JavaScript file": "Fichier JavaScript",
"Jimeng": "Jimeng",
"Jina": "Jina",
"JSON": "JSON",
@@ -2489,6 +2579,7 @@
"Latency short": "Lat.",
"Latency trend (last 24h)": "Tendance de latence (24 dernières heures)",
"Latest platform updates and notices": "Dernières mises à jour et annonces de la plateforme",
+ "Latest version": "Dernière version",
"Lavender Dream": "Rêve de lavande",
"Layout": "Disposition",
"lead": "tête",
@@ -2541,6 +2632,7 @@
"LinuxDO Client Secret": "Secret client LinuxDO",
"List of models supported by this channel. Use comma to separate multiple models.": "Liste des modèles pris en charge par ce canal. Utilisez une virgule pour séparer plusieurs modèles.",
"List of origins (one per line) allowed for Passkey registration and authentication.": "Liste des origines (une par ligne) autorisées pour l'enregistrement et l'authentification des clés d'accès (Passkey).",
+ "List registered task plugins and bind them when creating or editing task plugin channels.": "Lister les plugins de tâche enregistrés et les lier à la création ou à la modification de canaux.",
"List view": "Vue en liste",
"Live refresh pauses when no task is running": "L'actualisation en direct est suspendue lorsqu'aucune tâche n'est en cours",
"LLM Leaderboard": "Classement des LLM",
@@ -2555,6 +2647,7 @@
"Loading conversation...": "Chargement de la conversation...",
"Loading current models...": "Chargement des modèles actuels...",
"Loading failed": "Échec du chargement",
+ "Loading installed source...": "Chargement du code installé…",
"Loading maintenance settings...": "Chargement des paramètres de maintenance...",
"Loading settings...": "Chargement des paramètres...",
"Loading setup status…": "Chargement du statut de configuration…",
@@ -2606,6 +2699,7 @@
"Manage multi-key status and configuration for this channel": "Gérer le statut multi-clés et la configuration pour ce canal",
"Manage Ollama Models": "Gérer les modèles Ollama",
"Manage server log files. Log files accumulate over time; regular cleanup is recommended to free disk space.": "Gérer les fichiers journaux du serveur. Les fichiers journaux s'accumulent au fil du temps ; un nettoyage régulier est recommandé.",
+ "Manage sources": "Gérer les sources",
"Manage subscription plans and pricing.": "Gérer les plans d'abonnement et les tarifs.",
"Manage Subscriptions": "Gérer les abonnements",
"Manage Vendors": "Gérer les fournisseurs",
@@ -2619,6 +2713,10 @@
"Map upstream status codes to different codes": "Mapper les codes de statut amont à différents codes",
"Market Share": "Part de marché",
"Marketing": "Marketing",
+ "Marketplace": "Marché",
+ "Marketplace installs never force past a conflict. Resolve it on the task plugins page, then install again.": "Une installation depuis le marché ne force jamais malgré un conflit. Résolvez-le sur la page des plugins de tâches, puis réinstallez.",
+ "Marketplace sources": "Sources du marché",
+ "Marketplace sources updated": "Sources du marché mises à jour",
"Master instances run scheduled background tasks.": "Les instances master exécutent les tâches planifiées en arrière-plan.",
"Match All (AND)": "Toutes (AND)",
"Match Any (OR)": "N'importe laquelle (OR)",
@@ -2663,6 +2761,8 @@
"Maximum tokens per user": "Nombre maximum de jetons par utilisateur",
"maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647": "maxRequests ≥ 0, maxSuccess ≥ 1, les deux ≤ 2 147 483 647",
"May be used for training by upstream provider": "Peut être utilisé pour l'entraînement par le fournisseur amont",
+ "Media access expired. Please try again.": "L’accès au média a expiré. Veuillez réessayer.",
+ "Media preview failed. Please try again.": "Échec de l’aperçu du média. Veuillez réessayer.",
"Media pricing": "Tarification multimédia",
"Median time-to-first-token (TTFT) sampled hourly per group": "Latence médiane jusqu'au premier jeton (TTFT) échantillonnée par heure et par groupe",
"Medical Q&A, mental health support": "Q&R médicales, soutien en santé mentale",
@@ -2862,6 +2962,7 @@
"Native Claude Messages plus OpenAI Chat compatibility forwarding.": "Claude Messages natif avec transfert compatible OpenAI Chat.",
"Native format": "Format natif",
"Native forwarding": "Transfert natif",
+ "Native routes": "Routes natives",
"Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Routes Gemini natives avec transfert compatible OpenAI Chat et Responses.",
"Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "Routes OpenAI natives avec compatibilité Claude et Gemini en option.",
"Need a redemption code?": "Besoin d'un code d'échange ?",
@@ -2916,6 +3017,7 @@
"No available Web chat links": "Aucun lien de chat Web disponible",
"No backup": "Pas de sauvegarde",
"No base input price": "Aucun prix d’entrée de base",
+ "No billing parameters declared": "Aucun paramètre de facturation déclaré",
"No billing records found": "Aucun enregistrement de facturation trouvé",
"No capabilities reported for this model.": "Aucune capacité n'a été signalée pour ce modèle.",
"No Change": "Aucun changement",
@@ -2964,6 +3066,8 @@
"No incidents in the last 24 hours": "Aucun incident au cours des dernières 24 heures",
"No incidents in the last 30 days": "Aucun incident sur les 30 derniers jours",
"No instances have reported yet.": "Aucune instance ne s’est encore signalée.",
+ "No integrity hash": "Aucune empreinte d’intégrité",
+ "No integrity verification": "Aucun contrôle d’intégrité",
"No Inviter": "Pas d'inviteur",
"No keys found": "Aucune clé trouvée",
"No latency data available": "Aucune donnée de latence disponible",
@@ -2971,6 +3075,7 @@
"No logs": "Aucun journal",
"No Logs Found": "Aucun journal trouvé",
"No mappings configured. Click \"Add Row\" to get started.": "Aucun mappage configuré. Cliquez sur « Ajouter une ligne » pour commencer.",
+ "No marketplace sources configured.": "Aucune source de marché configurée.",
"No matches found": "Aucune correspondance trouvée",
"No matching items": "Aucun élément correspondant",
"No matching results": "Aucun résultat correspondant",
@@ -3047,6 +3152,7 @@
"No Sync": "Pas de synchronisation",
"No system announcements": "Aucune annonce système",
"No system tasks yet.": "Aucune tâche système pour le moment.",
+ "No task plugins found": "Aucun plugin de tâche trouvé",
"No token found.": "Aucun jeton trouvé.",
"No tools configured": "Aucun outil configuré",
"No Upgrade": "Pas de mise à niveau",
@@ -3078,9 +3184,13 @@
"Not backed up": "Non sauvegardé",
"Not bound": "Non lié",
"Not configured": "Non configuré",
+ "Not declared": "Non déclaré",
"Not Equals": "Différent de",
"Not in pricing table": "Absent du tableau tarifaire",
"Not included": "Non inclus",
+ "Not installed": "Non installé",
+ "Not provided by this source": "Non fourni par cette source",
+ "Not registered": "Non enregistré",
"Not set": "Non défini",
"Not Set": "Non défini",
"Not set yet": "Non défini",
@@ -3096,6 +3206,7 @@
"Notifications": "Notifications",
"Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "Un utilisateur du groupe vip crée maintenant des jetons avec différents groupes et effectue un appel avec chacun :",
"Nucleus sampling probability mass": "Masse probabiliste de l'échantillonnage nucleus",
+ "Number": "Nombre",
"Number of codes to create": "Nombre de codes à créer",
"Number of completions to generate": "Nombre de complétions à générer",
"Number of images to generate": "Nombre d'images à générer",
@@ -3104,6 +3215,7 @@
"Number of tokens per unit quota": "Nombre de jetons par unité de quota",
"Number of top log probabilities returned per token": "Nombre de log-probabilités retournées par jeton",
"Number of users invited": "Nombre d'utilisateurs invités",
+ "OAuth": "OAuth",
"OAuth binding timed out. Please try again.": "La liaison OAuth a expiré. Veuillez réessayer.",
"OAuth binding window is no longer available": "La fenêtre d’association OAuth n’est plus disponible",
"OAuth callback URL": "URL de rappel OAuth",
@@ -3223,6 +3335,7 @@
"Optional notes about this channel": "Notes optionnelles sur ce canal",
"Optional notes about when to use this group": "Notes optionnelles sur le moment d'utiliser ce groupe",
"Optional ratio used when upstream cache hits occur.": "Ratio optionnel utilisé en cas de succès du cache en amont.",
+ "Optional request-rule multiplier expression. Leave empty when no request rule applies.": "Expression multiplicatrice facultative pour les règles de requête. Laissez vide si aucune règle ne s'applique.",
"Optional rule description": "Description facultative de la règle",
"Optional settings for advanced container configuration.": "Paramètres optionnels pour la configuration avancée du conteneur.",
"Optional supplementary information (max 100 characters)": "Informations supplémentaires optionnelles (max 100 caractères)",
@@ -3291,6 +3404,7 @@
"parameter.": "paramètre.",
"Parameters": "Paramètres",
"Parsed {{count}} service account file(s)": "{{count}} fichier(s) de compte de service analysé(s)",
+ "Parsed plugin metadata": "Métadonnées du plugin analysées",
"Partial Submission": "Soumission partielle",
"Pass Headers": "Transmettre les en-têtes",
"Pass request body directly to upstream": "Transmettre le corps de la requête directement à l'upstream",
@@ -3344,6 +3458,7 @@
"Passwords do not match": "Les mots de passe ne correspondent pas",
"Passwords don't match.": "Les mots de passe ne correspondent pas.",
"Paste Connection Info": "Coller les infos de connexion",
+ "Paste JavaScript source here...": "Collez le code JavaScript ici...",
"Path": "Chemin",
"Path not set": "Chemin non défini",
"Path Regex (one per line)": "Regex du chemin (un par ligne)",
@@ -3383,6 +3498,8 @@
"per request": "par requête",
"Per request": "Par requête",
"Per Request": "Par demande",
+ "Per Second": "Par seconde",
+ "Per Unit": "Par unité",
"Per-call": "Par appel",
"Per-feature metered windows split by model or capability.": "Fenêtres mesurées par fonction, réparties par modèle ou capacité.",
"Per-group performance": "Performance par groupe",
@@ -3487,6 +3604,23 @@
"Please wait a moment, human check is initializing...": "Veuillez patienter un instant, la vérification humaine s'initialise...",
"Please wait before editing to avoid overwriting saved values.": "Veuillez patienter avant de modifier afin d'éviter d'écraser les valeurs enregistrées.",
"Please wait for the current generation to complete": "Veuillez attendre la fin de la génération en cours",
+ "Plugin": "Plugin",
+ "Plugin author": "Auteur du plugin",
+ "Plugin Generation": "Génération du plugin",
+ "Plugin indexes are fetched by your browser. Installing runs the same review and admission pipeline as a manual upload.": "Les index de plugins sont récupérés par votre navigateur. L’installation suit exactement le même circuit d’examen et d’admission qu’un import manuel.",
+ "Plugin is still in use": "Le plugin est encore utilisé",
+ "Plugin key": "Clé du plugin",
+ "Plugin metadata": "Métadonnées du plugin",
+ "Plugin source": "Source du plugin",
+ "Choose file": "Choisir un fichier",
+ "Choose another file": "Choisir un autre fichier",
+ "Drop a JavaScript plugin file here": "Déposez ici un fichier de plugin JavaScript",
+ "Single .js file, up to 1 MiB. Its source is shown below before upload.": "Un seul fichier .js, jusqu’à 1 Mio. Sa source est affichée ci-dessous avant l’envoi.",
+ "Optional note describing this version": "Note facultative décrivant cette version",
+ "Plugin source exceeds the 1 MiB limit.": "Le code du plugin dépasse la limite de 1 Mio.",
+ "Plugin uploaded successfully": "Plugin importé avec succès",
+ "Plugin version activated": "Version du plugin activée",
+ "Plugin version deleted": "Version du plugin supprimée",
"Policy JSON": "JSON de stratégie",
"Polling": "Sondage",
"Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded": "Le mode d'interrogation nécessite Redis et un cache mémoire, sinon les performances seront considérablement dégradées",
@@ -3541,6 +3675,9 @@
"Press Enter to use \"{{value}}\"": "Appuyez sur Entrée pour utiliser « {{value}} »",
"Prevent server-side request forgery attacks": "Prévenir les attaques de falsification de requêtes côté serveur",
"Preview": "Aperçu",
+ "Preview excludes group ratios and request rule multipliers.": "L’aperçu exclut les coefficients de groupe et les multiplicateurs des règles de requête.",
+ "Preview is unavailable for custom expressions.": "L’aperçu n’est pas disponible pour les expressions personnalisées.",
+ "Preview unavailable": "Aperçu indisponible",
"Previous": "Précédent",
"Previous branch": "Branche précédente",
"Previous page": "Page précédente",
@@ -3551,6 +3688,7 @@
"Price display mode": "Mode d'affichage des prix",
"Price estimation": "Estimation du prix",
"Price estimation description": "Après avoir configuré le type de matériel, l'emplacement de déploiement, le nombre de réplicas, etc., le prix sera calculé automatiquement.",
+ "Price examples": "Exemples de prix",
"Price ID": "ID du prix",
"Price mode (USD per 1M tokens)": "Mode de tarification (USD par 1M de jetons)",
"Price summary": "Résumé des prix",
@@ -3559,6 +3697,7 @@
"Price: High to Low": "Prix : Du plus élevé au plus bas",
"Price: Low to High": "Prix : Du plus bas au plus élevé",
"Prices shown per": "Prix affichés par",
+ "Prices shown per usage unit": "Prix affichés par unité d'utilisation",
"Prices synced successfully": "Prix synchronisés avec succès",
"Prices vary by usage tier and request conditions": "Les prix varient selon le palier d’utilisation et les conditions de requête",
"Pricing": "Tarification",
@@ -3623,6 +3762,7 @@
"Prune Object Items": "Nettoyer les éléments objet",
"Prune object items by conditions": "Nettoyer les éléments d'objets par conditions",
"Prune Rule (string or JSON object)": "Règle de nettoyage (chaîne ou objet JSON)",
+ "Public base URL for async task media. Supports a dedicated media domain, port, or Nginx path prefix; falls back to Server Address when empty.": "URL de base publique des médias des tâches asynchrones. Prend en charge un domaine média, un port ou un préfixe de chemin Nginx dédié ; utilise l’adresse du serveur si ce champ est vide.",
"Public model catalog and pricing page.": "Page publique du catalogue des modèles et des tarifs.",
"Public rankings page based on live usage data.": "Page publique des classements basée sur les données d'utilisation réelles.",
"Publish Date": "Date de publication",
@@ -3773,12 +3913,14 @@
"Regex Replace": "Remplacement regex",
"Register each URL into the matching Test Mode / Production Mode webhook slot in the Pancake dashboard. Separate endpoints prevent test traffic from accidentally crediting production accounts.": "Enregistrez chaque URL dans l’emplacement webhook correspondant au mode test ou production dans le tableau de bord Pancake. Des points de terminaison séparés évitent que le trafic de test crédite accidentellement les comptes de production.",
"Register Passkey": "Enregistrer un Passkey",
+ "Registered": "Enregistré",
"Registered a passkey": "Passkey enregistré",
"Registration Enabled": "Inscription activée",
"Registration flow expired. Please try again.": "Le processus d’inscription a expiré. Veuillez réessayer.",
"Registry (optional)": "Registre (optionnel)",
"Registry secret": "Secret du registre",
"Registry username": "Nom d'utilisateur du registre",
+ "Reinstall latest": "Réinstaller la dernière",
"Reject Reason": "Raison du rejet",
"Release details": "Détails de la version",
"Released": "Sorti",
@@ -3806,6 +3948,7 @@
"Remove Passkey": "Supprimer le Passkey",
"Remove Passkey?": "Supprimer la clé d'accès ?",
"Remove rule group": "Supprimer le groupe de règles",
+ "Remove source {{name}}": "Supprimer la source {{name}}",
"Remove string prefix": "Supprimer le préfixe de la chaîne",
"Remove string suffix": "Supprimer le suffixe de la chaîne",
"Remove the target field": "Supprimer le champ cible",
@@ -3855,8 +3998,10 @@
"Request Model": "Modèle demandé",
"Request Model:": "Modèle demandé :",
"Request overrides, routing behavior, and upstream model automation": "Surcharges de requête, comportement de routage et automatisation des modèles amont",
+ "Request Path": "Chemin de requête",
"Request retry": "Relance des requêtes",
"Request rule pricing": "Règles de tarification de requête",
+ "Request rules apply on top of this amount.": "Les règles de requête s’appliquent en plus de ce montant.",
"Request success rate sampled over the last 24 hours": "Taux de réussite des requêtes échantillonné sur les dernières 24 heures",
"Request success rate; {{incidents}} incident buckets in the last 24 hours": "Taux de réussite des requêtes ; {{incidents}} créneaux avec incident sur les dernières 24 heures",
"Request timed out, please refresh and restart GitHub login": "Délai dépassé, veuillez actualiser la page puis relancer la connexion GitHub",
@@ -3919,6 +4064,7 @@
"Reset usage window": "Réinitialiser la fenêtre d’utilisation",
"Resets in:": "Réinitialise dans :",
"Resetting...": "Réinitialisation...",
+ "Resize column": "Redimensionner la colonne",
"Resolve Conflicts": "Résoudre les conflits",
"Resource Configuration": "Configuration des ressources",
"Resources": "Ressources",
@@ -3951,6 +4097,7 @@
"Revenue": "Revenu",
"Review & initialize": "Vérifier et initialiser",
"Review and sign out devices currently using your account.": "Consultez et déconnectez les appareils qui utilisent actuellement votre compte.",
+ "Review and upgrade": "Examiner et mettre à jour",
"Review model rates before scaling traffic": "Consulter les tarifs des modèles avant d'augmenter le trafic",
"Review your payment details": "Vérifier vos détails de paiement",
"Review your purchase details before proceeding.": "Vérifiez les détails de votre achat avant de continuer.",
@@ -3962,6 +4109,7 @@
"Role": "Rôle",
"Roleplay": "Roleplay",
"Root": "Root",
+ "Root Diagnostics": "Diagnostics Root",
"Rose Garden": "Jardin de roses",
"Route": "Route",
"Route active": "Route active",
@@ -4001,16 +4149,20 @@
"Rules JSON": "Règles JSON",
"Rules JSON must be an array": "Le JSON des règles doit être un tableau",
"Rules match the original model value from the client request body.": "Les règles correspondent à la valeur model originale du corps de la requête client.",
+ "Run dry run": "Lancer la simulation",
"Run GC": "Exécuter le GC",
"Run tests for the selected models": "Exécuter les tests pour les modèles sélectionnés",
"running": "en cours",
"Running": "En cours",
+ "Running dry run": "Simulation en cours",
"Runtime": "Environnement",
+ "Runtime status": "État d’exécution",
"Runway": "Durée restante",
"s": "s",
"Safety Settings": "Paramètres de sécurité",
"Same as Local": "Identique au local",
"Sampling temperature; lower is more deterministic": "Température d'échantillonnage ; plus c'est bas, plus c'est déterministe",
+ "Sandbox": "Bac à sable",
"Sandbox mode": "Mode sandbox",
"Save": "Enregistrer",
"Save & Submit": "Enregistrer et envoyer",
@@ -4091,6 +4243,8 @@
"Search the public web at inference time": "Rechercher sur le web public lors de l'inférence",
"Search vendors...": "Rechercher des fournisseurs...",
"Search...": "Rechercher...",
+ "second": "seconde",
+ "Second": "Seconde",
"seconds": "secondes",
"Secret env (JSON object)": "Environnement secret (objet JSON)",
"Secret environment variables (JSON)": "Variables d'environnement secrètes (JSON)",
@@ -4116,6 +4270,7 @@
"Select a timestamp before clearing logs.": "Sélectionnez un horodatage avant de vider les journaux.",
"Select a usage mode to continue": "Sélectionnez un mode d'utilisation pour continuer",
"Select a verification method first": "Sélectionnez d'abord une méthode de vérification",
+ "Select a version to compare": "Sélectionnez une version à comparer",
"Select active subscription plan": "Sélectionner un forfait actif",
"Select all": "Tout sélectionner",
"Select all (filtered)": "Tout sélectionner (filtré)",
@@ -4176,6 +4331,7 @@
"Select sync channels to compare prices": "Sélectionner les canaux de synchronisation pour comparer les prix",
"Select sync channels to compare ratios": "Sélectionner les canaux de synchronisation pour comparer les ratios",
"Select Sync Source": "Sélectionner la source de synchronisation",
+ "Select task plugin": "Sélectionner un plugin",
"Select the API endpoint region": "Sélectionner la région du point de terminaison API",
"Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.": "Sélectionnez les champs que vous souhaitez écraser avec les données en amont. Les champs non sélectionnés conservent leurs valeurs locales.",
"Select theme preference": "Sélectionner la préférence de thème",
@@ -4189,6 +4345,7 @@
"Selected conflicts were overwritten successfully.": "Les conflits sélectionnés ont été écrasés avec succès.",
"Selected nodes": "Nœuds sélectionnés",
"Selected when creating a token and used as the default billing group for API calls.": "Sélectionné lors de la création d’un jeton et utilisé comme groupe de facturation par défaut pour les appels API.",
+ "Selecting a plugin fills its declared models.": "La sélection remplit les modèles déclarés.",
"Self-Use Mode": "Mode d'utilisation personnelle",
"Send": "Envoyer",
"Send a request": "Envoyer une requête",
@@ -4321,12 +4478,15 @@
"Sort by ID": "Trier par ID",
"Sort Order": "Ordre de tri",
"Source": "Source",
+ "Source diff": "Différence du code source",
"Source Endpoint": "Point source",
"Source Field": "Champ source",
"Source Header": "En-tête source",
+ "Source name": "Nom de la source",
"sources": "sources",
"Space-separated OAuth scopes": "Scopes OAuth séparés par des espaces",
"Spark model version, e.g., v2.1 (version number in API URL)": "Version du modèle Spark, par exemple v2.1 (numéro de version dans l'URL de l'API)",
+ "Spec": "Spécification",
"Special billing expression": "Expression de facturation spéciale",
"Special group": "Groupe spécial",
"Special ratio rules": "Règles de ratio spéciales",
@@ -4508,11 +4668,21 @@
"Target Path (optional)": "Chemin cible (optionnel)",
"Target User": "Utilisateur cible",
"Task": "Tâche",
+ "Task billing": "Facturation de tâche",
+ "Task Details": "Détails de la tâche",
"Task History": "Historique des tâches",
"Task ID": "ID de la tâche",
"Task ID:": "ID de tâche :",
"Task logs": "Journaux des tâches",
"Task Logs": "Journaux de tâches",
+ "Task Plugin": "Plugin de tâche",
+ "Task plugin setting updated": "Paramètre des plugins de tâche mis à jour",
+ "Task plugin *": "Plugin de tâche *",
+ "Task Plugins": "Plugins de tâches",
+ "Task pricing": "Tarification des tâches",
+ "Task pricing not configured": "Tarification des tâches non configurée",
+ "Task usage prices are USD per declared unit. They are not token prices and are not divided by one million.": "Les tarifs d'utilisation sont exprimés en USD par unité déclarée. Ce ne sont pas des tarifs par jeton et ils ne sont pas divisés par un million.",
+ "Task usage prices are USD per declared unit. Token fields use dollars per 1M tokens; the editor writes / 1000000 into the expression. Other units are not divided by one million.": "Les prix d'usage des tâches sont en USD par unité déclarée. Les champs token utilisent des dollars pour 1 M de tokens ; l'éditeur écrit / 1000000 dans l'expression. Les autres unités ne sont pas divisées par un million.",
"Tasks currently pending or running.": "Tâches actuellement en attente ou en cours d’exécution.",
"Team Collaboration": "Collaboration d'équipe",
"Technical Support": "Support technique",
@@ -4565,12 +4735,15 @@
"The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "Le produit associé alimente les recharges de portefeuille : lorsqu’un utilisateur saisit un montant, new-api lance le paiement sur ce produit Pancake unique et remplace le prix pour la session, sans devoir précréer des SKU de 1 $, 5 $ ou 10 $.",
"The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "La boutique associée est le conteneur parent de tous les produits Pancake que new-api crée depuis cette administration, y compris le produit de recharge de portefeuille et les produits de forfaits d’abonnement. Une seule boutique suffit ; choisissez-en une autre uniquement si vous gérez réellement des catalogues Pancake séparés.",
"The deployment node that handled the requests": "Le nœud de déploiement ayant traité les requêtes",
+ "The downloaded source does not match the sha256 declared in the index. Do not install it.": "Le code téléchargé ne correspond pas au sha256 déclaré dans l’index. Ne l’installez pas.",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "Le domaine effectif pour l'enregistrement de la clé d'accès. Doit correspondre au domaine actuel ou être son domaine parent.",
"The entered text does not match the required text.": "Le texte saisi ne correspond pas au texte requis.",
"The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "L’environnement (test ou production) est déterminé par la clé collée ici : utilisez la clé de test pendant l’intégration, puis remplacez-la par la clé de production lors de la mise en ligne.",
"The exact model identifier as used in API requests.": "L'identifiant exact du modèle tel qu'utilisé dans les requêtes API.",
"The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "Les modèles suivants présentent des conflits de type de facturation (prix fixe vs facturation au ratio). Confirmez pour procéder aux changements.",
"The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "Les modèles suivants dans la redirection du modèle n'ont pas été ajoutés à la liste \"Modèles\" et peuvent échouer lors de l'invocation en raison de modèles disponibles manquants :",
+ "The gateway rejected this plugin": "La passerelle a rejeté ce plugin",
+ "The index could not be fetched or parsed: {{message}}. The host may block cross-origin requests.": "Impossible de récupérer ou d’analyser l’index : {{message}}. L’hôte bloque peut-être les requêtes cross-origin.",
"The login session that started this Telegram binding is no longer valid.": "La session de connexion ayant lancé cette liaison Telegram n’est plus valide.",
"The mapped upstream model(s)": "Le(s) modèle(s) amont mappé(s)",
"The model that was requested": "Le modèle qui a été demandé",
@@ -4594,6 +4767,7 @@
"The upstream natively supports all three protocols; every selected route is forwarded without conversion.": "Le service amont prend nativement en charge les trois protocoles ; chaque route sélectionnée est transférée sans conversion.",
"The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.": "La réponse amont est un JSON valide, mais ne correspond pas au format OpenAI credit_summary. Le solde du canal n'a pas été mis à jour.",
"The URL for this chat client.": "L'URL de ce client de discussion.",
+ "The URL returned HTTP {{status}}. Check the address, or download the file and paste its source below.": "L’URL a renvoyé HTTP {{status}}. Vérifiez l’adresse, ou téléchargez le fichier et collez son code ci-dessous.",
"The user group applied to the requests": "Le groupe d'utilisateurs appliqué aux requêtes",
"The user who made the requests": "L'utilisateur à l'origine des requêtes",
"Theme": "Thème",
@@ -4603,11 +4777,18 @@
"There is a rule for vip billed as premium → use its ratio 0.3": "Il existe une règle pour vip facturé sous premium → son taux 0,3 s’applique",
"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.": "Ces modèles restent encore sélectionnés mais ne figurent pas dans la liste renvoyée par l'amont ; les noms qui sont uniquement des clés sources de model_mapping sont exclus. Modifiez la sélection avant d'enregistrer.",
"These toggles affect whether certain request fields are passed through to the upstream provider.": "Ces bascules déterminent si certains champs de demande sont transmis au fournisseur en amont.",
+ "These values come from the source index and are shown for review only. The gateway admits the plugin based on the metadata compiled from its source.": "Ces valeurs proviennent de l’index de la source et sont affichées à titre d’examen uniquement. La passerelle admet le plugin d’après les métadonnées compilées depuis son code.",
"Thinking Suffix Adapter": "Adaptateur de suffixe thinking",
"Thinking to Content": "Réflexion vers Contenu",
"Thinking...": "Réflexion...",
+ "Third-party": "Tiers",
+ "Third-party — use at your own risk": "Tiers — à vos risques et périls",
"Third-party account bindings (read-only, managed by user in profile settings)": "Liaisons de comptes tiers (lecture seule, gérées par l'utilisateur dans les paramètres de profil)",
"Third-party Payment Config": "Configuration de paiement tiers",
+ "Third-party plugin risk": "Risque des plugins tiers",
+ "Third-party source risk": "Risque des sources tierces",
+ "Third-party-only plugins become unavailable immediately. In-flight tasks will be handled by timeout cleanup.": "Les plugins tiers deviennent immédiatement indisponibles. Les tâches en cours seront gérées par le nettoyage après expiration.",
+ "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.": "Les plugins d’usine et personnalisés cessent immédiatement de servir. Les tâches en cours seront traitées par le nettoyage des délais d’attente.",
"This action cannot be undone.": "Cette action est irréversible.",
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "Cette action est irréversible. Cela supprimera définitivement votre compte et toutes vos données de nos serveurs.",
"This action will permanently remove 2FA protection from your account.": "Cette action supprimera définitivement la protection 2FA de votre compte.",
@@ -4618,11 +4799,13 @@
"This channel is not an Ollama channel.": "Ce canal n'est pas un canal Ollama.",
"This channel type does not support fetching models": "Ce type de canal ne prend pas en charge la récupération de modèles",
"This channel type requires additional configuration": "Ce type de canal nécessite une configuration supplémentaire",
+ "This combination will be billed as free.": "Cette combinaison ne sera pas facturée.",
"This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.": "Cette confirmation déverrouille les fonctionnalités de paiement, de codes de兑换, de forfaits d’abonnement et de récompenses d’invitation. Veuillez lire attentivement les déclarations.",
"This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.": "Ce réglage contrôle la limitation des requêtes de modèles. La limitation des routes Web/API se configure via les variables d'environnement et peut encore renvoyer 429.",
"This data may be unreliable, use with caution": "Ces données peuvent être peu fiables, utilisez-les avec prudence",
"This device does not support Passkey": "Cet appareil ne prend pas en charge Passkey",
"This device does not support Passkey verification.": "Cet appareil ne prend pas en charge la vérification par clé d'accès.",
+ "This expression does not price each combination exactly once, so it opens as a raw expression. Sparse or custom pricing stays in this editor.": "Cette expression ne définit pas exactement un prix pour chaque combinaison ; elle s’ouvre donc dans l’éditeur d’expression brute. Les tarifs partiels ou personnalisés restent dans cet éditeur.",
"This expression is too complex for the visual editor. Please switch to expression mode to edit.": "Cette expression est trop complexe pour l'éditeur visuel. Passez en mode expression pour la modifier.",
"This FAQ entry will be removed from the list.": "Cette entrée de FAQ sera retirée de la liste.",
"This feature is experimental. Configuration format and behavior may change.": "Cette fonctionnalité est expérimentale. Le format de configuration et le comportement peuvent changer.",
@@ -4630,15 +4813,19 @@
"This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "Cet enregistrement historique date d'avant le suivi des informations d'audit et ne peut pas être complété rétroactivement. La version actuelle enregistre déjà l'IP du serveur, l'IP de rappel, le mode de paiement et la version du système pour les nouveaux paiements à venir.",
"This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "Cet identifiant est envoyé au backend de paiement lors de la création d’une commande. Utilisez alipay pour Alipay, wxpay pour WeChat Pay, stripe pour Stripe. Les valeurs personnalisées doivent être prises en charge par votre fournisseur de paiement.",
"This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.": "Cette instance utilise un nom d’hôte automatique. Définissez NODE_NAME sur une valeur stable et unique pour la gestion multi-instance.",
+ "This is a task model billed by usage (e.g. seconds, resolution). Prices entered here act as a per-call base rate, not per-token prices.": "Ce modèle de tâche est facturé selon l’utilisation (p. ex. secondes, résolution). Les prix saisis ici servent de tarif de base par appel, et non de prix par jeton.",
"This may cause cache failures.": "Cela peut provoquer des échecs de cache.",
"This may take a few moments while we validate the request and update your session.": "Cela peut prendre quelques instants pendant que nous validons la requête et mettons à jour votre session.",
"This model has both fixed price and ratio billing conflicts": "Ce modèle présente des conflits de facturation à la fois en prix fixe et au ratio",
"This model has both fixed-price and ratio settings. Saving the current mode will rewrite the conflicting fields.": "Ce modèle possède à la fois un prix fixe et des paramètres de ratio. L’enregistrement du mode actuel réécrira les champs en conflit.",
"This model has both fixed-price and token-price settings. Saving the current mode will rewrite the conflicting fields.": "Ce modèle possède à la fois un prix fixe et des prix par token. L’enregistrement du mode actuel réécrira les champs en conflit.",
+ "This model is billed by usage, but the administrator has not configured its pricing yet.": "Ce modèle est facturé selon l’utilisation, mais l’administrateur n’a pas encore configuré sa tarification.",
"This model is not available in any group, or no group pricing information is configured.": "Ce modèle n'est disponible dans aucun groupe, ou aucune information de tarification de groupe n'est configurée.",
"This month": "Ce mois-ci",
"This page has not been created yet.": "Cette page n'a pas encore été créée.",
"This plan does not allow balance redemption": "Ce forfait ne permet pas le paiement avec le solde",
+ "This plugin has no factory fallback. Deleting or disabling it makes this platform unavailable.": "Ce plugin ne dispose d’aucune version intégrée de secours. Sa suppression ou désactivation rend la plateforme indisponible.",
+ "This plugin path does not resolve within the source repository.": "Ce chemin de plugin ne pointe pas dans le dépôt de la source.",
"This project must be used in compliance with the": "Ce projet doit être utilisé conformément aux",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "Cela supprime {{count}} modèles en échec de ce canal. Cette action est irréversible.",
"This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "Cette route découvre les modèles OpenAI en amont et ne peut être ni divisée ni associée par des règles de modèles clients.",
@@ -4646,6 +4833,8 @@
"This route is used only by channel management to query the upstream balance.": "Cette route sert uniquement à la gestion du canal pour consulter le solde amont.",
"This session will lose access immediately and must sign in again.": "Cette session perdra immédiatement l’accès ; vous devrez vous reconnecter.",
"This site currently has {{count}} models enabled": "Ce site compte actuellement {{count}} modèles activés",
+ "This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.": "Cette source ne publie pas de sha256 pour cette version : impossible de garantir que le code téléchargé est bien celui qu’elle a publié.",
+ "This source lists no installable task plugins.": "Cette source ne liste aucun plugin de tâches installable.",
"This Telegram account is already bound.": "Ce compte Telegram est déjà lié.",
"This Telegram binding request has expired or has already been used.": "Cette demande de liaison Telegram a expiré ou a déjà été utilisée.",
"This tier catches any request that did not match earlier tiers.": "Ce palier récupère toute requête qui ne correspond à aucun palier précédent.",
@@ -4704,6 +4893,7 @@
"times": "Fois",
"Timing": "Durée",
"Tip": "Astuce",
+ "Tip: after configuring one model, select others in the table and use bulk copy.": "Astuce : après avoir configuré un modèle, sélectionnez-en d’autres dans le tableau et utilisez la copie groupée.",
"to access this resource.": "pour accéder à cette ressource.",
"To Anthropic Messages": "Vers Anthropic Messages",
"to confirm": "pour confirmer",
@@ -4722,7 +4912,9 @@
"Toggle navigation menu": "Basculer le menu de navigation",
"Toggle plan": "Basculer le plan",
"Toggle theme": "Basculer le thème",
+ "token": "jeton",
"Token": "Jeton",
+ "token (unit)": "token",
"Token Breakdown": "Détails des tokens",
"Token Endpoint": "Point de terminaison de jeton",
"Token Endpoint (Optional)": "Point de terminaison du jeton (Facultatif)",
@@ -4885,6 +5077,8 @@
"Unexpected release payload": "Format de version inattendu",
"Unified API Gateway for": "Passerelle API unifiée pour",
"Unique identifier for this group.": "Identifiant unique pour ce groupe.",
+ "unit": "unité",
+ "Unit": "Unité",
"Unit price (local currency / USD)": "Prix unitaire (devise locale / USD)",
"Unit price (USD)": "Prix unitaire (USD)",
"Unit price must be greater than 0": "Le prix unitaire doit être supérieur à 0",
@@ -4903,6 +5097,7 @@
"Untrusted upstream data:": "Données amont non fiables :",
"Unused": "Inutilisé",
"Up to 4 strings that stop generation": "Jusqu'à 4 chaînes qui arrêtent la génération",
+ "Up to date": "À jour",
"Update": "Mettre à jour",
"Update All Balances": "Mettre à jour tous les soldes",
"Update API Key": "Mettre à jour la clé API",
@@ -4941,15 +5136,26 @@
"Updated user {{username}} (ID: {{id}})": "Utilisateur {{username}} mis à jour (ID : {{id}})",
"Updating all channel balances. This may take a while. Please refresh to see results.": "Mise à jour de tous les soldes des canaux. Cela peut prendre un certain temps. Veuillez actualiser pour voir les résultats.",
"Updating...": "Mise à jour...",
+ "Upgrade {{name}}": "Mettre à jour {{name}}",
+ "Upgrade and enable": "Mettre à jour et activer",
+ "Upgrade available: v{{installed}} to v{{latest}}": "Mise à jour disponible : v{{installed}} vers v{{latest}}",
"Upgrade Group": "Groupe de mise à niveau",
"Upgrade plaintext SMTP connection with STARTTLS before authentication": "Mettre à niveau la connexion SMTP en clair avec STARTTLS avant l'authentification",
"Upload": "Téléverser",
+ "Upload a JavaScript task platform plugin.": "Importez un plugin JavaScript de plateforme de tâches.",
"Upload a single service account JSON file": "Télécharger un seul fichier JSON de compte de service",
+ "Upload a task plugin to add a platform.": "Importez un plugin de tâche pour ajouter une plateforme.",
"Upload file": "Téléverser un fichier",
"Upload files": "Téléverser des fichiers",
"Upload multiple JSON files in batch modes": "Télécharger plusieurs fichiers JSON en mode batch",
+ "Upload new plugin version": "Importer une nouvelle version du plugin",
+ "Upload new version": "Importer une nouvelle version",
"Upload or reference a local configuration file.": "Chargez ou référencez un fichier de configuration local.",
"Upload photo": "Téléverser une photo",
+ "Upload plugin": "Importer un plugin",
+ "Upload task plugin": "Importer un plugin de tâche",
+ "Uploading a plugin is an administrator-level trust decision. A plugin can access channel credentials and shape upstream requests. Review its source and diff before activation.": "Importer un plugin est une décision de confiance de niveau administrateur. Il peut accéder aux identifiants des canaux et façonner les requêtes en amont. Examinez son code et ses différences avant activation.",
+ "Uploading...": "Importation...",
"Upscale": "Agrandir",
"Upstream": "Amont",
"Upstream did not return reset credit details.": "L'amont n'a renvoyé aucun détail de crédit de réinitialisation.",
@@ -4975,6 +5181,7 @@
"Upstream Response (billing-usage-openai-estimated)": "Réponse amont (billing-usage-openai-estimated)",
"Upstream Response (billing-usage-openai)": "Réponse amont (billing-usage-openai)",
"upstream services integrated": "services en amont intégrés",
+ "Upstream Task ID": "ID de tâche en amont",
"Upstream Updates": "Mises à jour en amont",
"Upstream URL": "URL amont",
"Upstream URL must be a full URL": "L’URL amont doit etre une URL complete",
@@ -4995,7 +5202,11 @@
"Usage logs": "Journaux d'utilisation",
"Usage Logs": "Journaux d'utilisation",
"Usage mode": "Mode d'utilisation",
+ "Usage parameters": "Paramètres d'utilisation",
+ "Usage prices": "Tarifs d'utilisation",
"Usage-based": "Basé sur l'utilisation",
+ "Usage-based billing": "Facturation à l’usage",
+ "Usage-based billing · price not configured": "Facturation à l’usage · prix non configuré",
"USD": "USD",
"USD Exchange Rate": "Taux de change USD",
"USD price per 1M input tokens.": "Prix en USD par million de tokens d’entrée.",
@@ -5084,6 +5295,7 @@
"Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "Les utilisateurs ne voient que les groupes marqués comme sélectionnables. Les groupes non sélectionnables peuvent toujours être attribués par les administrateurs.",
"uses": "utilisations",
"Using the complete global Auto order ({{count}} groups)": "Utilisation de l’ordre Auto global complet ({{count}} groupes)",
+ "v{{installed}} is installed but this source does not list it. Installing replaces it with v{{target}}.": "La version v{{installed}} est installée mais absente de cette source. L’installation la remplacera par v{{target}}.",
"Validity": "Validité",
"Validity Period": "Période de validité",
"Value": "Valeur",
@@ -5124,7 +5336,9 @@
"Verify your database connection": "Vérifiez votre connexion à la base de données",
"Verifying credentials and pulling stores from your Pancake account...": "Vérification des identifiants et récupération des boutiques depuis votre compte Pancake...",
"Version": "Version",
+ "Version history": "Historique des versions",
"Version Overrides": "Remplacements de version",
+ "Versions": "Versions",
"Vertex AI": "Vertex AI",
"Vertex AI API Key mode does not support batch creation": "Le mode clé API Vertex AI ne prend pas en charge la création par lot",
"Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.": "Vertex AI ne prend pas en charge functionResponse.id. Activez ceci pour supprimer automatiquement ce champ.",
@@ -5148,6 +5362,7 @@
"View Pricing": "Voir les tarifs",
"View the complete details for this": "Voir les détails complets de ce",
"View the complete details for this log entry": "Voir les détails complets de cette entrée de journal",
+ "View the complete details for this task": "Afficher tous les détails de cette tâche",
"View the complete error message and details": "Voir le message d'erreur et les détails complets",
"View the complete prompt and its English translation": "Voir l'invite complète et sa traduction anglaise",
"View the generated image": "Voir l'image générée",
@@ -5240,6 +5455,8 @@
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "Quand un jeton utilise le groupe auto, le système essaie les groupes de haut en bas jusqu’à trouver un groupe disponible.",
"When billed as {{group}}": "Facturé sous {{group}}",
"When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.": "Si les conditions sont remplies, le prix final est multiplié par X. Plusieurs correspondances se multiplient ; les valeurs < 1 agissent comme des remises.",
+ "When disabled, all uploaded custom plugins are ignored and every platform falls back to its built-in factory plugin.": "Si cette option est désactivée, tous les plugins personnalisés téléversés sont ignorés et chaque plateforme utilise son plugin d’usine intégré.",
+ "When disabled, the entire task plugin system stops serving, including factory and custom plugins.": "Une fois désactivé, tout le système de plugins de tâche s’arrête, y compris les plugins d’usine et personnalisés.",
"When enabled, if channels in the current group fail, it will try channels in the next group in order.": "Lorsqu'elle est activée, si les canaux du groupe actuel échouent, le système essaiera les canaux du groupe suivant dans l'ordre.",
"When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. Leave it off to delete the entry and select another channel.": "Lorsque cette option est activée, conserver l'entrée d'affinité même si le canal affinitaire est désactivé ou n'est plus utilisable pour le groupe/modèle actuel. Laissez-la désactivée pour supprimer l'entrée et sélectionner un autre canal.",
"When enabled, large request bodies are temporarily stored on disk instead of memory, significantly reducing memory usage. SSD recommended.": "Lorsqu'activé, les corps de requête volumineux sont temporairement stockés sur disque, réduisant considérablement l'utilisation mémoire. SSD recommandé.",
diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json
index a4de579bb89d..3cf134dae1a9 100644
--- a/web/src/i18n/locales/ja.json
+++ b/web/src/i18n/locales/ja.json
@@ -29,7 +29,9 @@
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]": "[{\"name\":\"Alipay\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]",
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]": "[{\"name\":\"Alipay\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]",
"{\"original-model\": \"replacement-model\"}": "{\" original - model \":\" replacement - model \"}",
+ "{{bytes}} bytes": "{{bytes}} バイト",
"{{category}} Models": "{{category}} モデル",
+ "{{channels}} channels, {{tasks}} in-flight tasks": "{{channels}} チャンネル、処理中 {{tasks}} 件",
"{{completed}}/{{total}} completed": "{{completed}}/{{total}} 完了",
"{{count}} / {{max}} groups selected": "{{count}} / {{max}} グループを選択済み",
"{{count}} announcements will be removed from the list.": "{{count}} 件のお知らせがリストから削除されます。",
@@ -39,9 +41,11 @@
"{{count}} channel(s) enabled": "{{count}} 個のチャネルを有効にしました",
"{{count}} channel(s) failed to disable": "{{count}} 個のチャネルの無効化に失敗しました",
"{{count}} channel(s) failed to enable": "{{count}} 個のチャネルの有効化に失敗しました",
+ "{{count}} combinations": "{{count}} 通りの組み合わせ",
"{{count}} days ago": "{{count}} 日前",
"{{count}} days remaining": "残り {{count}} 日",
"{{count}} disabled channel(s) deleted": "{{count}} 個の無効チャネルを削除しました",
+ "{{count}} enabled channels and {{tasks}} in-flight tasks still use this plugin.": "有効な {{count}} チャンネルと処理中の {{tasks}} タスクがこのプラグインを使用中です。",
"{{count}} FAQ entries will be removed from the list.": "{{count}} 件の FAQ 項目がリストから削除されます。",
"{{count}} hours ago": "{{count}} 時間前",
"{{count}} incidents": "{{count}} 件のインシデント",
@@ -60,6 +64,7 @@
"{{count}} weeks ago": "{{count}} 週間前",
"{{field}} updated to {{value}}": "{{field}} を {{value}} に更新しました",
"{{field}} updated to {{value}} for tag: {{tag}}": "タグ「{{tag}}」の {{field}} を {{value}} に更新しました",
+ "{{key}} · version {{version}} · from {{source}}": "{{key}} · バージョン {{version}} · 提供元 {{source}}",
"{{method}} {{route}}": "{{method}} {{route}}",
"{{modality}} not supported": "{{modality}} はサポートされていません",
"{{modality}} supported": "{{modality}} をサポート",
@@ -104,6 +109,7 @@
"14 Days": "14日",
"192.168.1.1
10.0.0.0/8": "192.168.1.1
10.0.0.0/8",
"1M": "1M",
+ "1M token": "1M token",
"1W": "1W",
"2. Copy the application token": "2. アプリケーショントークンをコピーします",
"20 / page": "20 / ページ",
@@ -147,6 +153,7 @@
"Action": "アクション",
"Action confirmation": "操作確認",
"Actions": "操作",
+ "Activate / Roll back": "有効化 / ロールバック",
"active": "有効",
"Active": "有効",
"Active apps": "アクティブなアプリ",
@@ -155,6 +162,7 @@
"Active models": "アクティブなモデル",
"Active Tasks": "進行中のタスク",
"active users": "アクティブユーザー",
+ "Active version": "有効なバージョン",
"Actively check all channels": "すべてのチャネルを定期チェック",
"Actively check auto-disable-enabled channels": "自動無効化が有効なチャネルを定期チェック",
"Actual Amount": "実際の金額",
@@ -171,6 +179,7 @@
"Add a new user by providing necessary info.": "必要な情報を提供して新しいユーザーを追加します。",
"Add a new vendor to the system": "システムに新しいベンダーを追加",
"Add an extra layer of security to your account": "アカウントにセキュリティの追加レイヤーを追加します",
+ "Add an index URL to browse installable plugins.": "インデックス URL を追加すると、インストール可能なプラグインを閲覧できます。",
"Add and submit": "追加して送信",
"Add Announcement": "お知らせを追加",
"Add API": "API追加",
@@ -217,6 +226,7 @@
"Add rule group": "ルールグループを追加",
"Add rules for a user group": "ユーザーグループにルールを追加",
"Add selectable group": "選択可能なグループを追加",
+ "Add source": "ソースを追加",
"Add split": "分岐を追加",
"Add subscription": "サブスクリプションを追加",
"Add tags...": "タグを追加...",
@@ -292,6 +302,7 @@
"All": "すべて",
"All API tokens": "すべての API キー",
"All categories": "すべてのカテゴリ",
+ "All combinations are priced at zero. Matching requests will be billed as free.": "すべての組み合わせの価格がゼロです。一致するリクエストは無料になります。",
"All conditions must match before this tier is used.": "この段階を使用するには、すべての条件に一致する必要があります。",
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "すべての編集は上書き操作です。現在の値を変更しないままにするには、フィールドを空のままにしてください。",
"All files exceed the maximum size.": "すべてのファイルが最大サイズを超えています。",
@@ -348,6 +359,7 @@
"Allow using models without price configuration": "価格設定なしでモデルの使用を許可",
"Allow wallet balance after quota used up": "クォータ使い切り後にウォレット残高の使用を許可",
"Allowed": "許可",
+ "Allowed hosts": "許可されたホスト",
"Allowed Origins": "許可するオリジン",
"Allowed Ports": "許可するポート",
"Already have an account?": "アカウントをお持ちの方?",
@@ -380,6 +392,7 @@
"Anthropic": "Anthropic",
"Anthropic Messages to OpenAI Chat": "Anthropic Messages から OpenAI Chat",
"Any Match (OR)": "いずれか一致(OR)",
+ "Anyone can publish an index. A plugin installed from a third-party source has the same access as one you upload by hand: review its source before installing.": "インデックスは誰でも公開できます。サードパーティのソースからインストールしたプラグインは、手動でアップロードしたものと全く同じ権限を持ちます。インストール前にソースを確認してください。",
"API": "API",
"API Access": "API アクセス",
"API Addresses": "APIアドレス",
@@ -414,6 +427,8 @@
"API token management": "APIトークン管理",
"API URL": "API URL",
"API usage records": "API使用記録",
+ "API version": "API バージョン",
+ "API Version": "API バージョン",
"API2GPT": "API2GPT",
"App": "アプリ",
"App rankings shown here are simulated for preview purposes and will be replaced with live usage data once the backend integration is complete.": "ここに表示されているアプリランキングはプレビュー用のシミュレーションデータです。バックエンド連携の完了後、実データに置き換えられます。",
@@ -437,8 +452,10 @@
"Apply plan": "プランを適用",
"Apply reset": "リセットを実行",
"Apply Sync": "同期を適用",
+ "Apply to all rows": "すべての行に適用",
"Applying...": "適用中...",
"Approx.": "約",
+ "Approximate prices for common specs.": "一般的な仕様の参考価格です。",
"apps": "アプリ",
"Apps": "アプリ",
"apps tracked": "個のアプリを追跡",
@@ -461,12 +478,17 @@
"Are you sure?": "よろしいですか?",
"Area Chart": "面グラフ",
"Args (space separated)": "引数 (スペース区切り)",
+ "Arguments JSON": "引数 JSON",
+ "Arguments must be a JSON array": "引数は JSON 配列である必要があります",
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "チャットクライアントプリセットの配列。各項目は、クライアント名とそのURLという1つのキーと値のペアを持つオブジェクトです。",
+ "Artifacts": "成果物",
"Asc": "昇順",
"Ask anything": "何でも質問する",
"Assigned by administrator only": "管理者のみ割り当て",
"Assigned by administrators and used to represent a user level, such as default or vip.": "管理者が割り当て、default や vip などのユーザーレベルを表します。",
+ "Async": "非同期",
"Async task polling": "非同期タスクのポーリング",
+ "Async Task Public Address": "非同期タスクの公開アドレス",
"Async task refund": "非同期タスク返金",
"At least one model regex pattern is required": "少なくとも1つのモデル正規表現パターンが必要です",
"At least one valid key source is required": "少なくとも1つの有効なキーソースが必要です",
@@ -585,8 +607,10 @@
"Balance updated: {{balance}}": "残高更新:{{balance}}",
"Bar Chart": "棒グラフ",
"Bark Push URL": "BarkプッシュURL",
+ "Base": "基本",
"Base address provided by your Epay service": "Epayサービスによって提供されるベースアドレス",
"Base amount. Actual deduction = base amount × system group rate.": "基本金額。実際の控除 = 基本金額 × システムグループ倍率。",
+ "Base charge": "基本料金",
"Base input and output token prices for this tier.": "この段階の入力および出力トークンの基本価格です。",
"Base input price only": "基本入力価格のみ",
"Base Limits": "基本枠",
@@ -594,6 +618,7 @@
"Base Price": "基本価格",
"Base rate limit windows for this account.": "このアカウント向けの基本レート制限ウィンドウ。",
"Base URL": "ベースURL",
+ "Base URL *": "ベース URL *",
"Base URL is required for this channel type": "このチャネルタイプには Base URL が必要です",
"Base URL is required when an advanced route uses an upstream path": "高度なルートで上流パスを使う場合は Base URL が必要です",
"Base URL of your Uptime Kuma instance": "Uptime KumaインスタンスのベースURL",
@@ -638,6 +663,7 @@
"Billing group = vip (the token has no group, so use the user group)": "課金グループ = vip(トークンにグループがないのでユーザーグループを使用)",
"Billing History": "請求履歴",
"Billing Mode": "課金モード",
+ "Billing parameters": "課金パラメータ",
"Billing Path": "課金パス",
"Billing Process": "課金プロセス",
"Billing rule: each call is billed as the token group (falling back to the user group when the token has none). The base ratio always comes from that billing group, not from the user group. To give a user group a special price on another billing group, add an entry in the override matrix.": "課金ルール:各呼び出しはトークングループとして課金されます(トークンにグループがない場合はユーザーグループにフォールバック)。基本倍率は常にその課金グループから取得され、ユーザーグループの倍率は適用されません。特定のユーザーグループに別の課金グループでの特別価格を設定するには、上書きマトリクスにエントリを追加してください。",
@@ -648,6 +674,7 @@
"Bind Email": "メールアドレス連携",
"Bind Telegram Account": "Telegram連携",
"Bind WeChat Account": "WeChatアカウント連携",
+ "Bind task plugins": "タスクプラグインを紐付け",
"Binding Information": "連携情報",
"Binding successful!": "紐付けが成功しました!",
"Binding your {{provider}} account": "{{provider}} アカウントをバインド中",
@@ -686,6 +713,7 @@
"Built for developers,": "開発者のために構築、",
"Built-in": "組み込み",
"Built-in Device": "内蔵デバイス",
+ "Built-in v{{factory}} / marketplace v{{market}}": "組み込み v{{factory}} / マーケット v{{market}}",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "内蔵: 電話の指紋/顔認証、またはWindows Hello。外部: USBセキュリティキー",
"by": "によって",
"By category": "カテゴリ別",
@@ -738,6 +766,7 @@
"Caps the response length": "応答の長さを制限します",
"Capture a reusable bundle of models, tags, or endpoints.": "モデル、タグ、またはエンドポイントの再利用可能なバンドルを保存。",
"Card view": "カード表示",
+ "Cascade disable channels": "チャンネルも無効化",
"Catch-all route must be last for the same incoming path": "同じ入力パスのキャッチオールルートは最後に配置してください",
"Category": "カテゴリ",
"Category Name": "分類名称",
@@ -779,7 +808,9 @@
"Channel test concurrency": "チャンネルテストの同時実行数",
"Channel test concurrency must be between 1 and 32": "チャンネルテストの同時実行数は1~32にしてください",
"Channel test mode": "チャネルテストモード",
+ "Channel type": "チャネルタイプ",
"Channel type is required": "チャネルタイプが必要です",
+ "Channel types": "チャネルタイプ",
"Channel updated successfully": "チャネルが正常に更新されました",
"Channel-specific settings (JSON format)": "チャネル固有の設定 (JSON 形式)",
"Channel:": "チャネル:",
@@ -950,6 +981,7 @@
"Compare the most popular models on the platform": "プラットフォームで最も人気のあるモデルを比較",
"compatible API routes": "互換APIルート",
"Compatible API routes for common AI application workflows": "一般的なAIアプリケーションワークフロー向けの互換APIルート",
+ "Compilation failed": "コンパイル失敗",
"Complete API documentation with multi-language SDK support": "多言語SDKをサポートする完全なAPIドキュメント",
"Complete Order": "手動チャージ",
"Complete these steps to finish the initial installation.": "初期インストールを完了するには、これらの手順を完了してください。",
@@ -1000,6 +1032,7 @@
"Configure pricing ratios for a specific model.": "特定のモデルの料金比率を設定します。",
"Configure rate limiting rules for a specific user group.": "特定のユーザーグループのレート制限ルールを設定します。",
"Configure routes": "ルートを設定",
+ "Configure task pricing": "タスク料金を設定",
"Configure the ratio for this group.": "このグループの比率を設定します。",
"Configure upstream providers and routing.": "アップストリームプロバイダーとルーティングを設定。",
"Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "USD 建てのチャージ用に Waffo Pancake のホスト型チェックアウト連携を設定",
@@ -1143,6 +1176,10 @@
"Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "費用 = モデル価格 × この1つの倍率。グループ設定の他の項目は計算式に入りません。",
"Cost in USD per request, regardless of tokens used.": "使用されたトークンに関係なく、リクエストあたりのUSDでのコスト。",
"Cost Tracking": "コスト追跡",
+ "Could not fetch the plugin source from this browser. The host may block cross-origin requests or be unreachable.": "このブラウザからプラグインのソースを取得できませんでした。ホストがクロスオリジンリクエストを拒否しているか、到達できない可能性があります。",
+ "Could not fetch this URL from the browser. The host may block cross-origin requests or be unreachable. Download the file and paste its source below.": "ブラウザからこの URL を取得できませんでした。ホストがクロスオリジンリクエストを拒否しているか、到達できない可能性があります。ファイルをダウンロードし、下のソース欄に貼り付けてください。",
+ "Could not load this source": "このソースを読み込めませんでした",
+ "Count": "回数",
"Count must be between {{min}} and {{max}}": "カウントは{{min}}から{{max}}の間である必要があります",
"Coze": "Coze",
"CPU": "CPU",
@@ -1199,6 +1236,7 @@
"Credentials": "認証情報",
"Credentials verification failed": "認証情報の検証に失敗しました",
"Credentials verification failed — double-check Merchant ID and API private key.": "認証情報の検証に失敗しました。Merchant ID と API 秘密鍵を再確認してください。",
+ "credit": "credit",
"Credit remaining": "残りクレジット",
"Creem API key (leave blank unless updating)": "Creem API キー (更新しない限り空白のまま)",
"Creem Gateway": "Creem ゲートウェイ",
@@ -1226,6 +1264,7 @@
"Current version": "現在のバージョン",
"Current:": "現在:",
"Custom": "カスタム",
+ "Custom (overrides factory {{version}})": "カスタム(組み込み {{version}} を上書き)",
"Custom (seconds)": "カスタム(秒)",
"Custom Amount": "カスタム金額",
"Custom API base URL. For official channels, New API has built-in addresses. Only fill this for third-party proxy sites or special endpoints. Do not add /v1 or trailing slash.": "カスタムAPIベースURL。公式チャネルの場合、New APIには組み込みのアドレスがあります。これは、サードパーティのプロキシサイトまたは特別なエンドポイントに対してのみ入力してください。/v1 や末尾のスラッシュを追加しないでください。",
@@ -1245,6 +1284,7 @@
"Custom OAuth Providers": "カスタムOAuthプロバイダー",
"Custom Seconds": "カスタム秒数",
"Custom sidebar section": "カスタムサイドバーセクション",
+ "Custom task plugin setting updated": "カスタムタスクプラグイン設定を更新しました",
"Custom Time Range": "カスタム時間範囲",
"Custom Zoom": "カスタムズーム",
"Customize sidebar display content": "サイドバーの表示内容をカスタマイズ",
@@ -1274,6 +1314,7 @@
"Days to Retain": "保持日数",
"decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.": "チャージ倍率、トークン作成時に選べるグループ、上書き倍率の適用有無を決めます。",
"decides which channels are used and which base ratio applies.": "使用するチャネルと適用される基本倍率を決めます。",
+ "Declared capabilities": "宣言された権限",
"Decreased user quota by {{quota}}": "ユーザーのクォータを {{quota}} 減らしました",
"Deducted by subscription": "サブスクリプションで控除",
"DeepSeek": "DeepSeek",
@@ -1306,6 +1347,7 @@
"Delete {{count}} stale instance records? Online instances will not be deleted.": "期限切れインスタンスレコードを {{count}} 件削除しますか?オンラインのインスタンスは削除されません。",
"Delete a runtime request header": "ランタイムリクエストヘッダーを削除",
"Delete Account": "アカウント削除",
+ "Delete active custom version": "有効なカスタムバージョンを削除",
"Delete All Disabled": "すべての無効なものを削除",
"Delete All Disabled Channels?": "すべての無効なチャネルを削除しますか?",
"Delete all stale": "期限切れをすべて削除",
@@ -1329,6 +1371,7 @@
"Delete mapping": "マッピングを削除",
"Delete Model": "モデルを削除",
"Delete Models?": "モデルを削除しますか?",
+ "Delete plugin version?": "プラグインのバージョンを削除しますか?",
"Delete Provider": "プロバイダーを削除",
"Delete Request Header": "リクエストヘッダーを削除",
"Delete selected API keys": "選択したAPIキーを削除",
@@ -1355,6 +1398,7 @@
"Deleted stale instance": "期限切れインスタンスを削除しました",
"Deleted successfully": "削除しました",
"Deleted user {{username}} (ID: {{id}})": "ユーザー {{username}} を削除しました(ID: {{id}})",
+ "Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.": "このカスタムバージョンを削除してもプラットフォームは停止しません。同名の組み込みプラグインが自動的に復元されます。",
"Deleting will permanently remove this subscription record (including benefit details). Continue?": "削除するとこのサブスクリプション記録(特典詳細を含む)が完全に削除されます。続行しますか?",
"Deleting...": "削除中...",
"Demo site": "デモサイト",
@@ -1401,6 +1445,8 @@
"Disable": "無効にする",
"Disable 2FA": "2FAを無効にする",
"Disable All": "すべて無効にする",
+ "Disable custom task plugins?": "カスタムタスクプラグインを無効化しますか?",
+ "Disable task plugins?": "タスクプラグインを無効化しますか?",
"Disable on failure": "失敗時に無効にする",
"Disable selected channels": "選択したチャネルを無効にする",
"Disable selected models": "選択したモデルを無効にする",
@@ -1415,6 +1461,8 @@
"Disabled lanes are omitted on save.": "無効な価格レーンは保存時に省略されます。",
"Disabled Reason": "無効化の理由",
"Disabled Time": "無効化された時刻",
+ "Disabled; fell back to factory": "無効;組み込み版へフォールバック",
+ "Disabled; platform unavailable": "無効;プラットフォーム利用不可",
"Disabling...": "無効化中...",
"Disclaimer: Personal use only. Do not distribute or share any credentials. This channel has prerequisites and requires prior setup; use it only if you understand the flow and risks, and comply with OpenAI's terms and policies. Credentials and configuration are for Codex CLI integration only, and are not intended for any other client, platform, or channel.": "免責事項:個人利用に限ります。認証情報を配布・共有しないでください。このチャネルには前提条件があり、事前の設定が必要です。手順とリスクを理解した上で利用し、OpenAI の利用規約および関連ポリシーを遵守してください。認証情報と設定は Codex CLI 連携専用であり、他のクライアント、プラットフォーム、またはチャネルでは利用できません。",
"Discord": "Discord",
@@ -1480,6 +1528,7 @@
"Drawing Logs": "画像生成履歴",
"Drawing task polling": "描画タスクのポーリング",
"Drawing task records": "描画タスク記録",
+ "Dry run result": "ドライラン結果",
"Duplicate": "複製",
"Duplicate group names: {{names}}": "重複するグループ名: {{names}}",
"Duplicate model in route models": "ルートモデルに重複したモデルがあります",
@@ -1540,7 +1589,9 @@
"Each item must have exactly one key-value pair.": "各項目には正確に 1 つのキーと値のペアが必要です。",
"Each line represents one keyword. Leave blank to disable the list but keep the switch states.": "各行は1つのキーワードを表します。リストを無効にするが、スイッチの状態を維持するには、空白のままにしてください。",
"Each matrix cell is one rule: users of this row group pay this ratio when billed as this column group. In JSON the row is the outer key and the column is the inner key.": "マトリクスの各セルが1つのルールです。行グループのユーザーが列グループとして課金されるとき、この倍率を支払います。JSONでは行が外側のキー、列が内側のキーです。",
+ "Each row prices one combination of {{fields}}.": "各行で {{fields}} の組み合わせを1つずつ料金設定します。",
"Each rule reads as a sentence: users of one group pay a special ratio when billed as another group. Without a rule, the billing group base ratio applies.": "各ルールは文として読めます:あるグループのユーザーが別のグループとして課金されるとき、特別な倍率を支払います。ルールがなければ課金グループの基本倍率が適用されます。",
+ "Each source serves an index.json listing installable plugins. Indexes are fetched by your browser; the gateway makes no outbound requests.": "各ソースはインストール可能なプラグインを列挙する index.json を提供します。インデックスはブラウザが取得し、ゲートウェイは外部へリクエストを送信しません。",
"Each tier supports 0~2 conditions (over len, p, c); the last tier is the catch-all without conditions. Use len (full input length, including cache hits) for tier conditions to avoid mis-routing when cache hits reduce p.": "各層は 0~2 個の条件(len、p、c に対して)を設定でき、最後の層は条件なしのキャッチオール層です。キャッシュヒットによって p が下がり層が誤判定されるのを防ぐため、層の条件には len(キャッシュヒットを含む完全な入力長)を使用してください。",
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "各階層は最大2つの条件をサポートします。最後の階層は条件なしのフォールバックです。キャッシュヒットで課金対象の入力トークンが減っても誤った階層にならないよう、条件には完全な入力長を使用してください。",
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "各段階は最大 2 つの条件に対応します。条件のない最後の段階がフォールバックです。",
@@ -1598,6 +1649,8 @@
"Enable 2FA": "2FA を有効にする",
"Enable All": "すべて有効にする",
"Enable check-in feature": "チェックイン機能を有効にする",
+ "Enable custom task plugins": "カスタムタスクプラグインを有効化",
+ "Enable task plugins": "タスクプラグインを有効化",
"Enable Data Dashboard": "データダッシュボードを有効にする",
"Enable demo mode with limited functionality": "機能が制限されたデモモードを有効にする",
"Enable Discord OAuth": "Discord OAuthを有効にする",
@@ -1617,6 +1670,7 @@
"Enable or disable this model": "このモデルを有効または無効にする",
"Enable Passkey": "Passkeyを有効にする",
"Enable Performance Monitoring": "パフォーマンス監視を有効にする",
+ "Enable plugin {{key}}": "プラグイン {{key}} を有効化",
"Enable rate limiting": "レート制限を有効にする",
"Enable Request Passthrough": "リクエストパススルーを有効にする",
"Enable selected channels": "選択したチャネルを有効にする",
@@ -1670,6 +1724,8 @@
"Enter a value and press Enter": "値を入力してEnterを押してください",
"Enter amount in {{currency}}": "{{currency}}で金額を入力",
"Enter amount in tokens": "トークンで金額を入力",
+ "Enter an absolute HTTP(S) URL without credentials, query parameters, or fragments": "認証情報、クエリパラメータ、フラグメントを含まない絶対 HTTP(S) URL を入力してください",
+ "Enter an absolute http(s) URL.": "完全な http(s) URL を入力してください。",
"Enter announcement content (supports Markdown & HTML)": "アナウンス内容を入力(Markdown & HTML対応)",
"Enter announcement content (supports Markdown/HTML)": "アナウンス内容を入力(Markdown/HTML対応)",
"Enter API Key": "API キーを入力",
@@ -1733,6 +1789,9 @@
"Enterprise Account": "エンタープライズアカウント",
"Enterprise-grade security with comprehensive permission management": "包括的な権限管理を備えたエンタープライズグレードのセキュリティ",
"Entrypoint (space separated)": "Entrypoint (スペース区切り)",
+ "Enum": "列挙",
+ "Boolean": "真偽値",
+ "Enum values": "列挙値",
"Env (JSON object)": "Env (JSON オブジェクト)",
"Environment variables": "環境変数",
"Environment variables (JSON)": "環境変数(JSON)",
@@ -1764,6 +1823,8 @@
"Example": "サンプル",
"Example (all channels):": "例(全チャネル):",
"Example (specific channels):": "例(特定チャネル):",
+ "Example price": "例の価格",
+ "Example spec": "例の仕様",
"Example:": "例:",
"example.com
blocked-site.com": "example.com
blocked-site.com",
"example.com
company.com": "example.com
company.com",
@@ -1792,6 +1853,7 @@
"Expose ratio API": "倍率APIを公開",
"Exposes the pricing/models catalog in the top navigation.": "価格/モデルカタログをトップナビゲーションに表示します。",
"Expression": "式",
+ "Expression - Task pricing": "式 - タスク料金",
"Expression based": "式ベース",
"Expression billing": "式による課金",
"Expression editor": "式エディター",
@@ -1811,6 +1873,9 @@
"Extra visible": "追加表示",
"Extra visible to {{group}}": "{{group}} に追加表示",
"extras": "追加項目",
+ "Factory": "組み込み",
+ "Factory and custom plugin behavior": "組み込み版とカスタム版の動作",
+ "Factory plugins cannot be deleted or disabled individually. A custom version can override them; deleting or disabling that version restores the factory plugin. Third-party-only platforms become unavailable when their plugin is deleted or disabled.": "組み込みプラグインは個別に削除・無効化できません。カスタム版で上書きでき、その版を削除または無効化すると組み込み版に戻ります。サードパーティ専用プラットフォームはプラグインを削除または無効化すると利用できなくなります。",
"Fail Reason": "失敗理由",
"Fail Reason Details": "失敗理由の詳細",
"failed": "失敗",
@@ -1879,6 +1944,7 @@
"Failed to initialize system": "システムの初期化に失敗しました",
"Failed to load": "読み込みに失敗しました",
"Failed to load API keys": "APIキーの読み込みに失敗しました",
+ "Failed to load artifacts": "成果物の読み込みに失敗しました",
"Failed to load billing history": "請求履歴の読み込みに失敗しました",
"Failed to load enabled models": "有効なモデルの取得に失敗しました",
"Failed to load home page content": "ホームページの内容の読み込みに失敗しました",
@@ -1968,15 +2034,20 @@
"Feature in development": "開発中の機能です",
"Fee": "手数料",
"Fee Amount": "料金額",
+ "Fetch": "取得",
"Fetch available models for:": "利用可能なモデルを取得:",
"Fetch available models from upstream": "アップストリームから利用可能なモデルを取得する",
"Fetch from Upstream": "Upstreamからフェッチ",
"Fetch Models": "モデルを取得",
+ "Fetch mode": "取得モード",
"Fetched {{count}} model(s) from upstream": "上流から {{count}} 個のモデルを取得しました",
"Fetched {{count}} models": "{{count}} 個のモデルを取得しました",
+ "Fetched in your browser and placed in the source field below for review. GitHub and gist page URLs are rewritten to their raw URL automatically.": "ブラウザで取得し、確認用に下のソース欄へ挿入します。GitHub と gist のページ URL は自動的に raw URL へ書き換えられます。",
+ "Fetching plugin source...": "プラグインのソースを取得中…",
"Fetching prefill groups...": "プリフィルグループをフェッチ中...",
"Fetching upstream prices...": "上流価格を取得中...",
"Fetching upstream ratios...": "アップストリーム比率をフェッチ中...",
+ "Fetching...": "取得中…",
"field": "フィールド",
"Field Mapping": "フィールドマッピング",
"Field passthrough controls": "フィールドパススルーコントロール",
@@ -1986,6 +2057,7 @@
"Files to Retain": "保持ファイル数",
"Fill All Models": "すべてのモデルを埋める",
"Fill Codex CLI / Claude CLI Templates": "Codex CLI / Claude CLI テンプレートを入力",
+ "Fill entire column": "列全体に入力",
"Fill example (all channels)": "例を入力(全チャネル)",
"Fill example (specific channels)": "例を入力(特定チャネル)",
"Fill in": "入力",
@@ -2025,6 +2097,7 @@
"Filter models by provider, group, type, endpoint, and tags.": "プロバイダー、グループ、タイプ、エンドポイント、タグでモデルを絞り込みます。",
"Filter models by type, endpoint, vendor, group and tags": "タイプ、エンドポイント、ベンダー、グループ、タグでモデルをフィルタリング",
"Filter models...": "モデルをフィルタリング...",
+ "Filter plugins...": "プラグインを絞り込み...",
"Filter the model analytics view by time range and user.": "時間範囲とユーザーでモデル分析ビューを絞り込みます。",
"Filter the traffic flow view by time range and user.": "時間範囲とユーザーでトラフィックフロー表示を絞り込みます。",
"Filter...": "フィルター…",
@@ -2078,6 +2151,7 @@
"Force Format": "強制フォーマット",
"Force format response to OpenAI standard (OpenAI channel only)": "応答をOpenAI標準に強制フォーマット (OpenAIチャネルのみ)",
"Force JSON object or schema-conforming output": "JSON オブジェクトまたはスキーマ準拠の出力を強制します",
+ "Force operation": "強制実行",
"Force SMTP authentication using AUTH LOGIN method": "AUTH LOGIN方式を使用してSMTP認証を強制する",
"Force-disabled two-factor authentication for the user": "ユーザーの二段階認証を強制的に無効化しました",
"Forest Whisper": "フォレストウィスパー",
@@ -2251,6 +2325,7 @@
"Home": "ホーム",
"Home Page Content": "ホームコンテンツ",
"Homepage URL": "ホームページ URL",
+ "Hook": "フック",
"Hostname or IP of your SMTP provider": "SMTP プロバイダーのホスト名または IP",
"Hour": "時間",
"Hour of day": "時刻(時)",
@@ -2329,6 +2404,7 @@
"Image to Video": "画像から動画",
"Image Tokens": "画像トークン",
"Imagine the pricing table has three groups: default (ratio 1.0), premium (ratio 0.5), and vip (ratio 0.8). Users whose account is in the vip group get user-level perks, and premium is a cheaper channel pool that users can pick for their tokens.": "料金表に3つのグループがあるとします:default(倍率 1.0)、premium(倍率 0.5)、vip(倍率 0.8)。アカウントが vip グループのユーザーはユーザーレベルの特典を受けられ、premium はユーザーがトークン用に選べる安いチャネルプールです。",
+ "Import from URL": "URL からインポート",
"Import to CC Switch": "CC Switch にインポート",
"Important": "重要",
"In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "JSONでは外側のキーがユーザーグループ、内側のキーが課金グループです。以下の例は、vip ユーザーが standard として課金されると 0.8、premium として課金されると 0.3 を意味します。",
@@ -2352,6 +2428,8 @@
"Incomplete": "未完了",
"Increased user quota by {{quota}}": "ユーザーのクォータを {{quota}} 増やしました",
"Index": "インデックス",
+ "Index request failed with HTTP {{status}}": "インデックスの取得に失敗しました(HTTP {{status}})",
+ "Index URL": "インデックス URL",
"Inherit global Auto order": "グローバル Auto 順序を継承",
"Initial quota given to new users": "新規ユーザーに付与される初期クォータ",
"Initial quota given to new users ({{formattedQuota}})": "新規ユーザーに付与される初期クォータ({{formattedQuota}})",
@@ -2369,10 +2447,21 @@
"Inset": "インセット",
"Inspect requests, errors, and billing details": "リクエスト、エラー、請求詳細を確認",
"Inspect user prompts": "ユーザープロンプトの検査",
+ "Install": "インストール",
+ "Install {{name}}": "{{name}} をインストール",
+ "Install and enable": "インストールして有効化",
+ "Installed": "インストール済み",
+ "Installed {{name}} v{{version}}": "{{name}} v{{version}} をインストールしました",
+ "Installed v{{from}} → marketplace v{{to}}": "インストール済み v{{from}} → マーケット v{{to}}",
+ "Installed v{{installed}} not listed": "導入済み v{{installed}} は未掲載",
+ "Installed version is not in this index": "インストール済みのバージョンはこのインデックスにありません",
+ "Installing...": "インストール中…",
"Instance": "インスタンス",
"Instances": "インスタンス",
"Insufficient balance": "残高が不足しています",
"Integrations": "統合",
+ "Integrity check failed": "整合性チェックに失敗しました",
+ "Integrity hash": "整合性ハッシュ",
"Inter-group overrides": "グループ間上書き",
"Inter-group ratio overrides": "グループ間比率上書き",
"Interface Language": "インターフェース言語",
@@ -2425,6 +2514,7 @@
"It seems like the page you're looking for": "お探しのページは",
"Items": "項目",
"Japanese": "日本語",
+ "JavaScript file": "JavaScript ファイル",
"Jimeng": "Jimeng",
"Jina": "Jina",
"JSON": "JSON",
@@ -2489,6 +2579,7 @@
"Latency short": "遅延",
"Latency trend (last 24h)": "レイテンシ推移(直近 24 時間)",
"Latest platform updates and notices": "最新のプラットフォーム更新と通知",
+ "Latest version": "最新バージョン",
"Lavender Dream": "ラベンダードリーム",
"Layout": "レイアウト",
"lead": "リード",
@@ -2541,6 +2632,7 @@
"LinuxDO Client Secret": "LinuxDO クライアントシークレット",
"List of models supported by this channel. Use comma to separate multiple models.": "このチャネルがサポートするモデルのリストです。複数のモデルはカンマで区切ってください。",
"List of origins (one per line) allowed for Passkey registration and authentication.": "Passkeyの登録と認証が許可されているオリジン(1行に1つ)のリスト。",
+ "List registered task plugins and bind them when creating or editing task plugin channels.": "登録済みのタスクプラグインを一覧し、チャネル作成・編集時に紐付けます。",
"List view": "リスト表示",
"Live refresh pauses when no task is running": "実行中のタスクがない場合、自動更新は一時停止します",
"LLM Leaderboard": "LLM リーダーボード",
@@ -2555,6 +2647,7 @@
"Loading conversation...": "会話を読み込み中...",
"Loading current models...": "現在のモデルをロード中...",
"Loading failed": "読み込みに失敗しました",
+ "Loading installed source...": "インストール済みのソースを読み込み中…",
"Loading maintenance settings...": "メンテナンス設定をロード中...",
"Loading settings...": "設定をロード中...",
"Loading setup status…": "セットアップステータスをロード中…",
@@ -2606,6 +2699,7 @@
"Manage multi-key status and configuration for this channel": "このチャネルのマルチキーのステータスと構成を管理する",
"Manage Ollama Models": "オラマモデルの管理",
"Manage server log files. Log files accumulate over time; regular cleanup is recommended to free disk space.": "サーバーログファイルを管理します。ログファイルは時間とともに蓄積されるため、定期的なクリーンアップでディスク容量を解放することを推奨します。",
+ "Manage sources": "ソースを管理",
"Manage subscription plans and pricing.": "サブスクリプションプランと価格設定を管理します。",
"Manage Subscriptions": "サブスクリプションの管理",
"Manage Vendors": "ベンダーの管理",
@@ -2619,6 +2713,10 @@
"Map upstream status codes to different codes": "アップストリームのステータスコードを別のコードにマッピングする",
"Market Share": "マーケットシェア",
"Marketing": "マーケティング",
+ "Marketplace": "マーケット",
+ "Marketplace installs never force past a conflict. Resolve it on the task plugins page, then install again.": "マーケットからのインストールは競合を強制的に無視しません。タスクプラグインのページで競合を解消してから、再度インストールしてください。",
+ "Marketplace sources": "マーケットのソース",
+ "Marketplace sources updated": "マーケットのソースを更新しました",
"Master instances run scheduled background tasks.": "master インスタンスはスケジュールされたバックグラウンドタスクを実行します。",
"Match All (AND)": "すべて一致(AND)",
"Match Any (OR)": "いずれか一致(OR)",
@@ -2663,6 +2761,8 @@
"Maximum tokens per user": "ユーザーあたりの最大トークン数",
"maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647": "maxRequests ≥ 0、maxSuccess ≥ 1、両方とも ≤ 2,147,483,647",
"May be used for training by upstream provider": "上流プロバイダーが学習に利用する可能性があります",
+ "Media access expired. Please try again.": "メディアへのアクセス期限が切れました。もう一度お試しください。",
+ "Media preview failed. Please try again.": "メディアのプレビューに失敗しました。もう一度お試しください。",
"Media pricing": "メディア料金",
"Median time-to-first-token (TTFT) sampled hourly per group": "グループ別に毎時サンプリングした最初のトークンまでの中央値レイテンシ (TTFT)",
"Medical Q&A, mental health support": "医療Q&A・メンタルヘルスサポート",
@@ -2862,6 +2962,7 @@
"Native Claude Messages plus OpenAI Chat compatibility forwarding.": "Claude Messages ネイティブ転送と OpenAI Chat 互換転送。",
"Native format": "ネイティブ形式",
"Native forwarding": "ネイティブ転送",
+ "Native routes": "ネイティブルート",
"Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Gemini ネイティブルートと OpenAI Chat / Responses 互換転送。",
"Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "OpenAI ネイティブルートと、任意の Claude / Gemini 互換ルート。",
"Need a redemption code?": "引き換えコードが必要ですか?",
@@ -2916,6 +3017,7 @@
"No available Web chat links": "利用可能なWebチャットリンクがありません",
"No backup": "バックアップなし",
"No base input price": "基本入力価格なし",
+ "No billing parameters declared": "課金パラメータは宣言されていません",
"No billing records found": "請求記録が見つかりません",
"No capabilities reported for this model.": "このモデルには報告されている機能がありません。",
"No Change": "変更なし",
@@ -2964,6 +3066,8 @@
"No incidents in the last 24 hours": "過去 24 時間にインシデントはありません",
"No incidents in the last 30 days": "過去 30 日間でインシデントはありません",
"No instances have reported yet.": "まだ報告されたインスタンスはありません。",
+ "No integrity hash": "整合性ハッシュなし",
+ "No integrity verification": "整合性検証なし",
"No Inviter": "招待者なし",
"No keys found": "キーが見つかりません",
"No latency data available": "レイテンシデータがありません",
@@ -2971,6 +3075,7 @@
"No logs": "ログがありません",
"No Logs Found": "ログが見つかりません",
"No mappings configured. Click \"Add Row\" to get started.": "マッピングが設定されていません。「行を追加」をクリックして開始してください。",
+ "No marketplace sources configured.": "マーケットのソースが設定されていません。",
"No matches found": "一致するものが見つかりません",
"No matching items": "一致する項目がありません",
"No matching results": "一致する結果がありません",
@@ -3047,6 +3152,7 @@
"No Sync": "同期なし",
"No system announcements": "システムのお知らせがありません",
"No system tasks yet.": "システムタスクはまだありません。",
+ "No task plugins found": "タスクプラグインがありません",
"No token found.": "トークンが見つかりません。",
"No tools configured": "ツールが未設定です",
"No Upgrade": "アップグレードなし",
@@ -3078,9 +3184,13 @@
"Not backed up": "未バックアップ",
"Not bound": "未バインド",
"Not configured": "未設定",
+ "Not declared": "未宣言",
"Not Equals": "等しくない",
"Not in pricing table": "料金グループ表にありません",
"Not included": "未登録",
+ "Not installed": "未インストール",
+ "Not provided by this source": "このソースでは提供されていません",
+ "Not registered": "未登録",
"Not set": "未設定",
"Not Set": "未設定",
"Not set yet": "未設定",
@@ -3096,6 +3206,7 @@
"Notifications": "通知",
"Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "ここで、ユーザーグループが vip のユーザーが異なるグループのトークンを作成し、それぞれ1回ずつ呼び出します:",
"Nucleus sampling probability mass": "核サンプリングの累積確率",
+ "Number": "数値",
"Number of codes to create": "作成するコードの数",
"Number of completions to generate": "生成する候補数",
"Number of images to generate": "生成する画像枚数",
@@ -3104,6 +3215,7 @@
"Number of tokens per unit quota": "単位クォータあたりのトークン数",
"Number of top log probabilities returned per token": "トークンごとに返される上位対数確率の数",
"Number of users invited": "招待されたユーザー数",
+ "OAuth": "OAuth",
"OAuth binding timed out. Please try again.": "OAuth 連携がタイムアウトしました。もう一度お試しください。",
"OAuth binding window is no longer available": "OAuth 連携ウィンドウは利用できなくなりました",
"OAuth callback URL": "OAuth コールバック URL",
@@ -3223,6 +3335,7 @@
"Optional notes about this channel": "このチャネルに関するオプションのノート",
"Optional notes about when to use this group": "このグループを使用する時期に関するオプションのメモ",
"Optional ratio used when upstream cache hits occur.": "アップストリームキャッシュヒットが発生したときに使用されるオプションの比率。",
+ "Optional request-rule multiplier expression. Leave empty when no request rule applies.": "任意のリクエストルール乗数式です。ルールがない場合は空欄にしてください。",
"Optional rule description": "任意のルール説明",
"Optional settings for advanced container configuration.": "高度なコンテナ設定のためのオプション設定。",
"Optional supplementary information (max 100 characters)": "オプションの補足情報 (最大100文字)",
@@ -3291,6 +3404,7 @@
"parameter.": "パラメーター。",
"Parameters": "パラメータ",
"Parsed {{count}} service account file(s)": "__ PH_0 __サービスアカウントファイルを解析しました",
+ "Parsed plugin metadata": "解析されたプラグインメタデータ",
"Partial Submission": "部分送信",
"Pass Headers": "ヘッダーをパススルー",
"Pass request body directly to upstream": "リクエストボディを直接アップストリームに渡す",
@@ -3344,6 +3458,7 @@
"Passwords do not match": "パスワードが一致しません",
"Passwords don't match.": "パスワードが一致しません。",
"Paste Connection Info": "接続情報を貼り付け",
+ "Paste JavaScript source here...": "JavaScript ソースを貼り付け...",
"Path": "パス",
"Path not set": "パス未設定",
"Path Regex (one per line)": "パス正規表現(1行に1つ)",
@@ -3383,6 +3498,8 @@
"per request": "リクエストごと",
"Per request": "リクエストごと",
"Per Request": "リクエストごと",
+ "Per Second": "秒単位",
+ "Per Unit": "単位ごと",
"Per-call": "呼び出しごと",
"Per-feature metered windows split by model or capability.": "機能ごとの従量制ウィンドウ。モデルまたは能力別に分かれます。",
"Per-group performance": "グループ別パフォーマンス",
@@ -3487,6 +3604,23 @@
"Please wait a moment, human check is initializing...": "しばらくお待ちください、人間チェックを初期化中です...",
"Please wait before editing to avoid overwriting saved values.": "保存済みの値を上書きしないよう、編集前に読み込み完了をお待ちください。",
"Please wait for the current generation to complete": "現在の生成が完了するまでお待ちください",
+ "Plugin": "プラグイン",
+ "Plugin author": "プラグイン作成者",
+ "Plugin Generation": "プラグイン世代",
+ "Plugin indexes are fetched by your browser. Installing runs the same review and admission pipeline as a manual upload.": "プラグインのインデックスはブラウザが取得します。インストール時の確認と受け入れの流れは手動アップロードと全く同じです。",
+ "Plugin is still in use": "プラグインは使用中です",
+ "Plugin key": "プラグインキー",
+ "Plugin metadata": "プラグインメタデータ",
+ "Plugin source": "プラグインソース",
+ "Choose file": "ファイルを選択",
+ "Choose another file": "別のファイルを選択",
+ "Drop a JavaScript plugin file here": "JavaScript プラグインファイルをここにドロップ",
+ "Single .js file, up to 1 MiB. Its source is shown below before upload.": "単一の .js ファイル、最大 1 MiB。アップロード前に下部でソースを確認できます。",
+ "Optional note describing this version": "このバージョンを説明する任意のメモ",
+ "Plugin source exceeds the 1 MiB limit.": "プラグインのソースが 1 MiB の上限を超えています。",
+ "Plugin uploaded successfully": "プラグインをアップロードしました",
+ "Plugin version activated": "プラグインバージョンを有効化しました",
+ "Plugin version deleted": "プラグインバージョンを削除しました",
"Policy JSON": "ポリシーJSON",
"Polling": "ポーリング",
"Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded": "ポーリングモードにはRedisとメモリキャッシュが必要です。そうでない場合、パフォーマンスが大幅に低下します",
@@ -3541,6 +3675,9 @@
"Press Enter to use \"{{value}}\"": "Enter キーを押して「{{value}}」を使用",
"Prevent server-side request forgery attacks": "サーバーサイドリクエストフォージェリ攻撃を防ぐ",
"Preview": "プレビュー",
+ "Preview excludes group ratios and request rule multipliers.": "プレビューにはグループ倍率とリクエストルールの倍率は含まれません。",
+ "Preview is unavailable for custom expressions.": "カスタム式はプレビューできません。",
+ "Preview unavailable": "プレビューを利用できません",
"Previous": "前へ",
"Previous branch": "前のブランチ",
"Previous page": "前のページ",
@@ -3551,6 +3688,7 @@
"Price display mode": "価格表示モード",
"Price estimation": "料金見積もり",
"Price estimation description": "ハードウェアタイプ、デプロイ場所、レプリカ数などを設定すると、料金が自動的に計算されます。",
+ "Price examples": "価格例",
"Price ID": "価格 ID",
"Price mode (USD per 1M tokens)": "価格モード (100万トークンあたりのUSD)",
"Price summary": "価格概要",
@@ -3559,6 +3697,7 @@
"Price: High to Low": "価格:高い順",
"Price: Low to High": "価格:低い順",
"Prices shown per": "価格表示単位",
+ "Prices shown per usage unit": "使用単位ごとの価格を表示",
"Prices synced successfully": "価格が正常に同期されました",
"Prices vary by usage tier and request conditions": "価格は利用ティアとリクエスト条件で変動します",
"Pricing": "価格設定",
@@ -3623,6 +3762,7 @@
"Prune Object Items": "オブジェクト項目を整理",
"Prune object items by conditions": "条件に基づいてオブジェクト項目を削除",
"Prune Rule (string or JSON object)": "削除ルール(文字列またはJSONオブジェクト)",
+ "Public base URL for async task media. Supports a dedicated media domain, port, or Nginx path prefix; falls back to Server Address when empty.": "非同期タスクのメディア用公開ベース URL。専用のメディアドメイン、ポート、または Nginx パスプレフィックスを指定できます。空の場合はサーバーアドレスを使用します。",
"Public model catalog and pricing page.": "モデルカタログと料金の公開ページ。",
"Public rankings page based on live usage data.": "実際の利用データに基づく公開ランキングページ。",
"Publish Date": "公開日",
@@ -3773,12 +3913,14 @@
"Regex Replace": "正規表現置換",
"Register each URL into the matching Test Mode / Production Mode webhook slot in the Pancake dashboard. Separate endpoints prevent test traffic from accidentally crediting production accounts.": "Pancake ダッシュボードで、各 URL を対応するテストモードまたは本番モードの Webhook スロットに登録してください。エンドポイントを分けることで、テスト通信が誤って本番アカウントに入金されることを防ぎます。",
"Register Passkey": "Passkeyの登録",
+ "Registered": "登録済み",
"Registered a passkey": "パスキーを登録しました",
"Registration Enabled": "登録が有効",
"Registration flow expired. Please try again.": "登録手続きの有効期限が切れました。もう一度お試しください。",
"Registry (optional)": "レジストリ (オプション)",
"Registry secret": "レジストリ シークレット",
"Registry username": "レジストリ ユーザー名",
+ "Reinstall latest": "最新版を再インストール",
"Reject Reason": "拒否理由",
"Release details": "リリース詳細",
"Released": "公開日",
@@ -3806,6 +3948,7 @@
"Remove Passkey": "Passkey連携解除",
"Remove Passkey?": "Passkeyを削除しますか?",
"Remove rule group": "ルールグループを削除",
+ "Remove source {{name}}": "ソース {{name}} を削除",
"Remove string prefix": "文字列のプレフィックスを除去",
"Remove string suffix": "文字列のサフィックスを除去",
"Remove the target field": "ターゲットフィールドを削除",
@@ -3855,8 +3998,10 @@
"Request Model": "リクエストモデル",
"Request Model:": "リクエストモデル:",
"Request overrides, routing behavior, and upstream model automation": "リクエスト上書き、ルーティング動作、上流モデル自動化",
+ "Request Path": "リクエストパス",
"Request retry": "リクエスト再試行",
"Request rule pricing": "リクエストルールの課金",
+ "Request rules apply on top of this amount.": "リクエストルールはこの金額に追加で適用されます。",
"Request success rate sampled over the last 24 hours": "過去 24 時間にサンプリングされたリクエスト成功率",
"Request success rate; {{incidents}} incident buckets in the last 24 hours": "リクエスト成功率;過去 24 時間に {{incidents}} 個のインシデント時間枠",
"Request timed out, please refresh and restart GitHub login": "タイムアウトしました。ページをリロードして GitHub ログインをやり直してください",
@@ -3919,6 +4064,7 @@
"Reset usage window": "使用量ウィンドウをリセット",
"Resets in:": "リセットまで:",
"Resetting...": "リセット中...",
+ "Resize column": "列幅を変更",
"Resolve Conflicts": "競合を解決",
"Resource Configuration": "リソース設定",
"Resources": "リソース",
@@ -3951,6 +4097,7 @@
"Revenue": "収益",
"Review & initialize": "確認して初期化",
"Review and sign out devices currently using your account.": "現在アカウントを使用しているデバイスを確認し、サインアウトできます。",
+ "Review and upgrade": "確認してアップグレード",
"Review model rates before scaling traffic": "トラフィック拡大前にモデル料金を確認",
"Review your payment details": "支払い詳細を確認",
"Review your purchase details before proceeding.": "続行前に購入詳細を確認してください。",
@@ -3962,6 +4109,7 @@
"Role": "ロール",
"Roleplay": "ロールプレイ",
"Root": "Root",
+ "Root Diagnostics": "Root 診断",
"Rose Garden": "ローズガーデン",
"Route": "ルート",
"Route active": "ルート有効",
@@ -4001,16 +4149,20 @@
"Rules JSON": "ルール JSON",
"Rules JSON must be an array": "ルール JSON は配列である必要があります",
"Rules match the original model value from the client request body.": "ルールはクライアントリクエスト本文の元の model 値に一致します。",
+ "Run dry run": "ドライランを実行",
"Run GC": "GC 実行",
"Run tests for the selected models": "選択したモデルのテストを実行",
"running": "実行中",
"Running": "実行中",
+ "Running dry run": "ドライランを実行中",
"Runtime": "実行環境",
+ "Runtime status": "実行状態",
"Runway": "残り期間",
"s": "s",
"Safety Settings": "安全設定",
"Same as Local": "ローカルと同じ",
"Sampling temperature; lower is more deterministic": "サンプリング温度。低いほど決定論的になります",
+ "Sandbox": "サンドボックス",
"Sandbox mode": "サンドボックスモード",
"Save": "保存",
"Save & Submit": "保存して送信",
@@ -4091,6 +4243,8 @@
"Search the public web at inference time": "推論時に公開ウェブを検索",
"Search vendors...": "ベンダーを検索...",
"Search...": "検索...",
+ "second": "秒",
+ "Second": "秒",
"seconds": "秒",
"Secret env (JSON object)": "シークレット env (JSON オブジェクト)",
"Secret environment variables (JSON)": "シークレット環境変数(JSON)",
@@ -4116,6 +4270,7 @@
"Select a timestamp before clearing logs.": "ログをクリアする前にタイムスタンプを選択してください。",
"Select a usage mode to continue": "続行するには使用モードを選択してください",
"Select a verification method first": "まず検証方法を選択してください",
+ "Select a version to compare": "比較するバージョンを選択",
"Select active subscription plan": "有効なサブスクリプションプランを選択",
"Select all": "すべて選択",
"Select all (filtered)": "フィルタ結果をすべて選択(S)",
@@ -4176,6 +4331,7 @@
"Select sync channels to compare prices": "価格比較のために同期チャネルを選択してください",
"Select sync channels to compare ratios": "比率を比較するために同期チャネルを選択",
"Select Sync Source": "同期元を選択",
+ "Select task plugin": "タスクプラグインを選択",
"Select the API endpoint region": "APIエンドポイントのリージョンを選択",
"Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.": "アップストリームデータで上書きしたいフィールドを選択してください。選択されていないフィールドはローカル値を保持します。",
"Select theme preference": "テーマの好みを選択",
@@ -4189,6 +4345,7 @@
"Selected conflicts were overwritten successfully.": "選択した競合が正常に上書きされました。",
"Selected nodes": "選択したノード",
"Selected when creating a token and used as the default billing group for API calls.": "トークン作成時に選択され、API 呼び出しのデフォルト課金グループとして使われます。",
+ "Selecting a plugin fills its declared models.": "プラグインを選択すると宣言済みモデルが入力されます。",
"Self-Use Mode": "セルフユースモード",
"Send": "送信",
"Send a request": "リクエストを送信",
@@ -4321,12 +4478,15 @@
"Sort by ID": "IDでソート",
"Sort Order": "並び順",
"Source": "ソース",
+ "Source diff": "ソース差分",
"Source Endpoint": "ソースエンドポイント",
"Source Field": "コピー元フィールド",
"Source Header": "コピー元ヘッダー",
+ "Source name": "ソース名",
"sources": "ソース",
"Space-separated OAuth scopes": "スペース区切りのOAuthスコープ",
"Spark model version, e.g., v2.1 (version number in API URL)": "Sparkモデルバージョン(例:v2.1、API URLのバージョン番号)",
+ "Spec": "仕様",
"Special billing expression": "特殊な課金式",
"Special group": "特別グループ",
"Special ratio rules": "特殊な倍率ルール",
@@ -4508,11 +4668,21 @@
"Target Path (optional)": "ターゲットパス(任意)",
"Target User": "対象ユーザー",
"Task": "タスク",
+ "Task billing": "タスク課金",
+ "Task Details": "タスク詳細",
"Task History": "タスク履歴",
"Task ID": "タスクID",
"Task ID:": "タスクID:",
"Task logs": "タスクログ",
"Task Logs": "タスクログ",
+ "Task Plugin": "タスクプラグイン",
+ "Task plugin setting updated": "タスクプラグイン設定を更新しました",
+ "Task plugin *": "タスクプラグイン *",
+ "Task Plugins": "タスクプラグイン",
+ "Task pricing": "タスク料金",
+ "Task pricing not configured": "タスク料金が未設定",
+ "Task usage prices are USD per declared unit. They are not token prices and are not divided by one million.": "タスク使用量料金は、宣言された単位ごとの米ドル額です。トークン料金ではなく、100万で除算されません。",
+ "Task usage prices are USD per declared unit. Token fields use dollars per 1M tokens; the editor writes / 1000000 into the expression. Other units are not divided by one million.": "タスク使用量の価格は宣言された単位あたりの米ドルです。token フィールドは 100 万 token あたりのドルで、エディターは式に / 1000000 を書き込みます。他の単位は 100 万では割りません。",
"Tasks currently pending or running.": "現在待機中または実行中のタスクです。",
"Team Collaboration": "チームコラボレーション",
"Technical Support": "テクニカルサポート",
@@ -4565,12 +4735,15 @@
"The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "紐付け済み商品はウォレットチャージに使用されます。ユーザーが任意の金額を入力すると、new-api はこの単一の Pancake 商品でチェックアウトを実行し、セッションごとに価格を上書きします。$1 / $5 / $10 の SKU を事前作成する必要はありません。",
"The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "紐付け済みストアは、この管理画面から new-api が作成するすべての Pancake 商品の親コンテナです。ウォレットチャージ商品とサブスクリプションプラン商品が含まれます。通常は 1 つのストアで十分です。別々の Pancake カタログを本当に運用する場合のみ別のストアを固定してください。",
"The deployment node that handled the requests": "リクエストを処理したデプロイノード",
+ "The downloaded source does not match the sha256 declared in the index. Do not install it.": "ダウンロードしたソースがインデックスに記載された sha256 と一致しません。インストールしないでください。",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "Passkey登録のための有効なドメイン。現在のドメインまたはその親ドメインと一致する必要があります。",
"The entered text does not match the required text.": "入力したテキストが必要なテキストと一致しません。",
"The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "環境(テスト/本番)はここに貼り付けるキーで決まります。統合中はテストキーを使用し、本番公開時に本番キーへ切り替えてください。",
"The exact model identifier as used in API requests.": "APIリクエストで使用される正確なモデル識別子。",
"The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "以下のモデルには請求タイプ(固定価格 vs 比率請求)の競合があります。変更を続行するには確認してください。",
"The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "モデルリダイレクト内の以下のモデルは\"モデル\"リストに追加されていないため、利用可能なモデルが不足して呼び出しが失敗する可能性があります:",
+ "The gateway rejected this plugin": "ゲートウェイがこのプラグインを拒否しました",
+ "The index could not be fetched or parsed: {{message}}. The host may block cross-origin requests.": "インデックスの取得または解析に失敗しました:{{message}}。ホストがクロスオリジンリクエストを拒否している可能性があります。",
"The login session that started this Telegram binding is no longer valid.": "この Telegram 連携を開始したログインセッションは無効になりました。",
"The mapped upstream model(s)": "マッピングされたアップストリームモデル",
"The model that was requested": "リクエストされたモデル",
@@ -4594,6 +4767,7 @@
"The upstream natively supports all three protocols; every selected route is forwarded without conversion.": "上流が3つのプロトコルをネイティブ対応し、選択したルートを変換せず転送します。",
"The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.": "上流レスポンスは有効な JSON ですが、OpenAI credit_summary 形式ではありません。チャネル残高は更新されていません。",
"The URL for this chat client.": "このチャットクライアントのURL。",
+ "The URL returned HTTP {{status}}. Check the address, or download the file and paste its source below.": "URL が HTTP {{status}} を返しました。アドレスを確認するか、ファイルをダウンロードして下のソース欄に貼り付けてください。",
"The user group applied to the requests": "リクエストに適用されたユーザーグループ",
"The user who made the requests": "リクエストを行ったユーザー",
"Theme": "テーマ",
@@ -4603,11 +4777,18 @@
"There is a rule for vip billed as premium → use its ratio 0.3": "「vip が premium として課金」のルールあり → ルールの 0.3 を使用",
"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.": "これらはまだ選択中ですが上流のリストにありません。model_mapping にのみソース別名として載る名前は除外されています。保存前に選択を調整してください。",
"These toggles affect whether certain request fields are passed through to the upstream provider.": "これらの切り替えは、特定の要求フィールドがアップストリームプロバイダーに渡されるかどうかに影響します。",
+ "These values come from the source index and are shown for review only. The gateway admits the plugin based on the metadata compiled from its source.": "これらの値はソースのインデックス由来で、確認のために表示しているだけです。ゲートウェイはソースからコンパイルされた実際のメタデータに基づいてプラグインを受け入れます。",
"Thinking Suffix Adapter": "思考サフィックスアダプター",
"Thinking to Content": "思考からコンテンツへ",
"Thinking...": "思考中...",
+ "Third-party": "サードパーティ",
+ "Third-party — use at your own risk": "サードパーティ — 自己責任",
"Third-party account bindings (read-only, managed by user in profile settings)": "サードパーティアカウントのバインディング(読み取り専用、プロファイル設定でユーザーが管理)",
"Third-party Payment Config": "サードパーティ決済設定",
+ "Third-party plugin risk": "サードパーティプラグインのリスク",
+ "Third-party source risk": "サードパーティソースのリスク",
+ "Third-party-only plugins become unavailable immediately. In-flight tasks will be handled by timeout cleanup.": "サードパーティ専用プラグインは直ちに利用不可になります。処理中タスクはタイムアウト清掃で処理されます。",
+ "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.": "ファクトリープラグインとカスタムプラグインは直ちに停止します。実行中のタスクはタイムアウトクリーンアップで処理されます。",
"This action cannot be undone.": "この操作は元に戻せません。",
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "この操作は元に戻せません。これにより、あなたのアカウントは完全に削除され、すべてのデータがサーバーから削除されます。",
"This action will permanently remove 2FA protection from your account.": "この操作により、アカウントから2FA保護が完全に削除されます。",
@@ -4618,11 +4799,13 @@
"This channel is not an Ollama channel.": "このチャネルはOllamaチャネルではありません。",
"This channel type does not support fetching models": "このチャネルタイプはモデルの取得をサポートしていません",
"This channel type requires additional configuration": "このチャネルタイプには追加設定が必要です",
+ "This combination will be billed as free.": "この組み合わせの料金は発生しません。",
"This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.": "この確認により、支払い、引換コード、サブスクリプションプラン、招待報酬の機能が解除されます。各項目をよく読んでください。",
"This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.": "これはモデルリクエストのレート制限を制御します。Web/API ルートのスロットリングは環境変数で設定され、引き続き 429 を返す場合があります。",
"This data may be unreliable, use with caution": "このデータは信頼できない可能性があります。注意して使用してください",
"This device does not support Passkey": "このデバイスはPasskeyをサポートしていません",
"This device does not support Passkey verification.": "このデバイスはPasskey認証をサポートしていません。",
+ "This expression does not price each combination exactly once, so it opens as a raw expression. Sparse or custom pricing stays in this editor.": "この式では各組み合わせの料金がちょうど1回ずつ定義されていないため、生の式として開きます。疎な料金設定やカスタム料金設定はこのエディターに保持されます。",
"This expression is too complex for the visual editor. Please switch to expression mode to edit.": "この式はビジュアルエディタでは扱いにくいです。式モードに切り替えて編集してください。",
"This FAQ entry will be removed from the list.": "この FAQ 項目はリストから削除されます。",
"This feature is experimental. Configuration format and behavior may change.": "この機能は実験的です。設定フォーマットや動作は変更される可能性があります。",
@@ -4630,15 +4813,19 @@
"This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "このレコードは監査情報の記録に対応する前の履歴データのため、監査情報がありません。現在のバージョンではサーバーIP、コールバックIP、支払い方法、システムバージョンなどの監査情報を記録できますが、これらは今後新しく作成されるレコードにのみ適用され、過去のレコードを遡って補完することはできません。",
"This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "注文作成時に、この識別子が決済バックエンドへ送信されます。Alipay は alipay、WeChat Pay は wxpay、Stripe は stripe を使ってください。カスタム値は決済サービス側で対応している必要があります。",
"This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.": "このインスタンスは自動ホスト名を使用しています。マルチインスタンス管理のために、安定した一意の NODE_NAME を設定してください。",
+ "This is a task model billed by usage (e.g. seconds, resolution). Prices entered here act as a per-call base rate, not per-token prices.": "これは使用量(秒数、解像度など)に基づいて課金されるタスクモデルです。ここで入力した価格は、トークン単価ではなく、1回の呼び出しごとの基本料金として適用されます。",
"This may cause cache failures.": "これによりキャッシュ障害が発生する可能性があります。",
"This may take a few moments while we validate the request and update your session.": "リクエストを検証し、セッションを更新するのに数分かかる場合があります。",
"This model has both fixed price and ratio billing conflicts": "このモデルには固定価格と比率請求の両方の競合があります",
"This model has both fixed-price and ratio settings. Saving the current mode will rewrite the conflicting fields.": "このモデルには固定価格と比率設定の両方があります。現在のモードで保存すると、競合するフィールドが上書きされます。",
"This model has both fixed-price and token-price settings. Saving the current mode will rewrite the conflicting fields.": "このモデルには固定価格とトークン価格設定の両方があります。現在のモードで保存すると、競合するフィールドが上書きされます。",
+ "This model is billed by usage, but the administrator has not configured its pricing yet.": "このモデルは使用量に基づいて課金されますが、管理者がまだ料金を設定していません。",
"This model is not available in any group, or no group pricing information is configured.": "このモデルはどのグループでも利用できないか、グループの料金情報が設定されていません。",
"This month": "今月",
"This page has not been created yet.": "このページはまだ作成されていません。",
"This plan does not allow balance redemption": "このプランでは残高での交換は許可されていません",
+ "This plugin has no factory fallback. Deleting or disabling it makes this platform unavailable.": "このプラグインには組み込みのフォールバックがありません。削除または無効化するとプラットフォームが利用できなくなります。",
+ "This plugin path does not resolve within the source repository.": "このプラグインのパスはソースリポジトリ内を指していません。",
"This project must be used in compliance with the": "このプロジェクトは、以下を遵守して使用する必要があります",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "この操作はこのチャンネルから失敗した {{count}} 個のモデルを削除します。元に戻せません。",
"This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "このルートはアップストリームの OpenAI モデルを検出するためのもので、分割やクライアントモデルルールによる照合はできません。",
@@ -4646,6 +4833,8 @@
"This route is used only by channel management to query the upstream balance.": "このルートは、チャネル管理で上流残高を照会するためだけに使用されます。",
"This session will lose access immediately and must sign in again.": "このセッションは直ちにアクセスできなくなり、再度サインインが必要になります。",
"This site currently has {{count}} models enabled": "このサイトでは現在 {{count}} 個のモデルが有効です",
+ "This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.": "このソースはこのバージョンの sha256 を公開していないため、ダウンロードしたソースがソース側の意図した内容と一致するか確認できません。",
+ "This source lists no installable task plugins.": "このソースにはインストール可能なタスクプラグインがありません。",
"This Telegram account is already bound.": "この Telegram アカウントはすでに連携されています。",
"This Telegram binding request has expired or has already been used.": "この Telegram 連携リクエストは期限切れか、すでに使用されています。",
"This tier catches any request that did not match earlier tiers.": "この段階は、前の段階に一致しなかったすべてのリクエストを受け取ります。",
@@ -4704,6 +4893,7 @@
"times": "回",
"Timing": "所要時間",
"Tip": "ヒント",
+ "Tip: after configuring one model, select others in the table and use bulk copy.": "ヒント:1つのモデルを設定した後、表で他のモデルを選択して一括コピーを使用できます。",
"to access this resource.": "このリソースにアクセスするには。",
"To Anthropic Messages": "Anthropic Messages へ",
"to confirm": "確認する",
@@ -4722,7 +4912,9 @@
"Toggle navigation menu": "ナビゲーションメニューの切り替え",
"Toggle plan": "プランの切り替え",
"Toggle theme": "テーマの切り替え",
+ "token": "トークン",
"Token": "トークン",
+ "token (unit)": "token",
"Token Breakdown": "トークン内訳",
"Token Endpoint": "トークンエンドポイント",
"Token Endpoint (Optional)": "トークンエンドポイント (オプション)",
@@ -4885,6 +5077,8 @@
"Unexpected release payload": "予期しないリリースデータ",
"Unified API Gateway for": "統合APIゲートウェイ -",
"Unique identifier for this group.": "このグループの一意の識別子。",
+ "unit": "回",
+ "Unit": "単位",
"Unit price (local currency / USD)": "単価 (現地通貨 / USD)",
"Unit price (USD)": "単価 (USD)",
"Unit price must be greater than 0": "単価は 0 より大きい必要があります",
@@ -4903,6 +5097,7 @@
"Untrusted upstream data:": "信頼されていないアップストリームデータ:",
"Unused": "未使用",
"Up to 4 strings that stop generation": "生成を停止する文字列を最大 4 個まで",
+ "Up to date": "最新",
"Update": "更新",
"Update All Balances": "すべての残高を更新",
"Update API Key": "API キーを更新",
@@ -4941,15 +5136,26 @@
"Updated user {{username}} (ID: {{id}})": "ユーザー {{username}} を更新しました(ID: {{id}})",
"Updating all channel balances. This may take a while. Please refresh to see results.": "すべてのチャネル残高を更新中です。これには少し時間がかかる場合があります。結果を確認するには更新してください。",
"Updating...": "更新中...",
+ "Upgrade {{name}}": "{{name}} をアップグレード",
+ "Upgrade and enable": "アップグレードして有効化",
+ "Upgrade available: v{{installed}} to v{{latest}}": "アップグレード可能:v{{installed}} から v{{latest}}",
"Upgrade Group": "グループをアップグレード",
"Upgrade plaintext SMTP connection with STARTTLS before authentication": "認証前に STARTTLS で平文の SMTP 接続を暗号化する",
"Upload": "アップロード",
+ "Upload a JavaScript task platform plugin.": "JavaScript タスクプラットフォームプラグインをアップロードします。",
"Upload a single service account JSON file": "単一のサービスアカウントJSONファイルをアップロードする",
+ "Upload a task plugin to add a platform.": "タスクプラグインをアップロードしてプラットフォームを追加します。",
"Upload file": "ファイルをアップロード",
"Upload files": "ファイルをアップロード",
"Upload multiple JSON files in batch modes": "バッチモードで複数のJSONファイルをアップロードする",
+ "Upload new plugin version": "プラグインの新バージョンをアップロード",
+ "Upload new version": "新バージョンをアップロード",
"Upload or reference a local configuration file.": "ローカルの構成ファイルをアップロードまたは参照してください。",
"Upload photo": "写真をアップロード",
+ "Upload plugin": "プラグインをアップロード",
+ "Upload task plugin": "タスクプラグインをアップロード",
+ "Uploading a plugin is an administrator-level trust decision. A plugin can access channel credentials and shape upstream requests. Review its source and diff before activation.": "プラグインのアップロードは管理者レベルの信頼判断です。プラグインはチャネル認証情報にアクセスし、上流リクエストを構成できます。有効化前にソースと差分を確認してください。",
+ "Uploading...": "アップロード中...",
"Upscale": "アップスケール",
"Upstream": "アップストリーム",
"Upstream did not return reset credit details.": "上流からリセット回数の詳細が返されませんでした。",
@@ -4975,6 +5181,7 @@
"Upstream Response (billing-usage-openai-estimated)": "アップストリームレスポンス (billing-usage-openai-estimated)",
"Upstream Response (billing-usage-openai)": "アップストリームレスポンス (billing-usage-openai)",
"upstream services integrated": "アップストリームサービス連携",
+ "Upstream Task ID": "アップストリームタスク ID",
"Upstream Updates": "アップストリーム更新",
"Upstream URL": "上流 URL",
"Upstream URL must be a full URL": "上流 URL は完全な URL である必要があります",
@@ -4995,7 +5202,11 @@
"Usage logs": "使用ログ",
"Usage Logs": "利用履歴",
"Usage mode": "利用モード",
+ "Usage parameters": "使用量パラメータ",
+ "Usage prices": "使用量料金",
"Usage-based": "使用量ベース",
+ "Usage-based billing": "使用量ベースの課金",
+ "Usage-based billing · price not configured": "使用量ベースの課金 · 料金未設定",
"USD": "USD",
"USD Exchange Rate": "USD 為替レート",
"USD price per 1M input tokens.": "100万入力トークンあたりのUSD価格。",
@@ -5084,6 +5295,7 @@
"Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "ユーザーにはユーザー選択可のグループだけが表示されます。選択不可グループも管理者は割り当てできます。",
"uses": "使用回数",
"Using the complete global Auto order ({{count}} groups)": "グローバル Auto の全順序を使用中({{count}} グループ)",
+ "v{{installed}} is installed but this source does not list it. Installing replaces it with v{{target}}.": "v{{installed}} がインストールされていますが、このソースには掲載されていません。インストールすると v{{target}} に置き換わります。",
"Validity": "有効期間",
"Validity Period": "有効期間",
"Value": "値",
@@ -5124,7 +5336,9 @@
"Verify your database connection": "データベース接続を確認",
"Verifying credentials and pulling stores from your Pancake account...": "認証情報を検証し、Pancake アカウントからストアを取得しています...",
"Version": "バージョン",
+ "Version history": "バージョン履歴",
"Version Overrides": "バージョンオーバーライド",
+ "Versions": "バージョン",
"Vertex AI": "Vertex AI",
"Vertex AI API Key mode does not support batch creation": "Vertex AI API Key モードは一括作成をサポートしていません",
"Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.": "Vertex AI は functionResponse.id フィールドをサポートしません。有効にすると自動的に削除します。",
@@ -5148,6 +5362,7 @@
"View Pricing": "価格を見る",
"View the complete details for this": "この",
"View the complete details for this log entry": "このログエントリの完全な詳細を表示",
+ "View the complete details for this task": "このタスクの完全な詳細を表示します",
"View the complete error message and details": "エラーメッセージと詳細を表示",
"View the complete prompt and its English translation": "プロンプト全文と英語訳を表示",
"View the generated image": "生成された画像を表示",
@@ -5240,6 +5455,8 @@
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "トークンが auto グループを使用すると、システムは上から順に利用可能なグループを探します。",
"When billed as {{group}}": "{{group}} として課金時",
"When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.": "条件に一致したとき、最終価格に X を掛けます。複数一致は掛け合わさり、1 未満は割引として効きます。",
+ "When disabled, all uploaded custom plugins are ignored and every platform falls back to its built-in factory plugin.": "無効にすると、アップロード済みのカスタムプラグインはすべて無視され、各プラットフォームは内蔵のファクトリープラグインにフォールバックします。",
+ "When disabled, the entire task plugin system stops serving, including factory and custom plugins.": "無効にすると、ファクトリープラグインとカスタムプラグインを含むタスクプラグインシステム全体が停止します。",
"When enabled, if channels in the current group fail, it will try channels in the next group in order.": "有効にすると、現在のグループのチャネルが失敗した場合、次のグループのチャネルを順番に試します。",
"When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. Leave it off to delete the entry and select another channel.": "有効にすると、アフィニティチャネルが無効化された、または現在のグループ/モデルで利用できなくなった場合でも、そのアフィニティエントリを保持します。無効のままにすると、エントリを削除して別のチャネルを選択します。",
"When enabled, large request bodies are temporarily stored on disk instead of memory, significantly reducing memory usage. SSD recommended.": "有効にすると、大きなリクエストボディはメモリではなくディスクに一時保存され、メモリ使用量が大幅に削減されます。SSD環境での使用を推奨します。",
diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json
index af284c52e5da..97932b58910d 100644
--- a/web/src/i18n/locales/ru.json
+++ b/web/src/i18n/locales/ru.json
@@ -29,7 +29,9 @@
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]": "[{\"name\":\"Alipay\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]",
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]": "[{\"name\":\"Alipay\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]",
"{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
+ "{{bytes}} bytes": "{{bytes}} байт",
"{{category}} Models": "Модели {{category}}",
+ "{{channels}} channels, {{tasks}} in-flight tasks": "Каналов: {{channels}}, активных задач: {{tasks}}",
"{{completed}}/{{total}} completed": "{{completed}}/{{total}} завершено",
"{{count}} / {{max}} groups selected": "Выбрано групп: {{count}} из {{max}}",
"{{count}} announcements will be removed from the list.": "{{count}} объявлений будут удалены из списка.",
@@ -39,9 +41,11 @@
"{{count}} channel(s) enabled": "Включено {{count}} каналов",
"{{count}} channel(s) failed to disable": "Не удалось отключить {{count}} каналов",
"{{count}} channel(s) failed to enable": "Не удалось включить {{count}} каналов",
+ "{{count}} combinations": "Комбинаций: {{count}}",
"{{count}} days ago": "{{count}} дней назад",
"{{count}} days remaining": "Осталось {{count}} дней",
"{{count}} disabled channel(s) deleted": "Удалено {{count}} отключённых каналов",
+ "{{count}} enabled channels and {{tasks}} in-flight tasks still use this plugin.": "Этот плагин используют {{count}} включённых каналов и {{tasks}} активных задач.",
"{{count}} FAQ entries will be removed from the list.": "{{count}} записей FAQ будут удалены из списка.",
"{{count}} hours ago": "{{count}} часов назад",
"{{count}} incidents": "{{count}} инцидентов",
@@ -60,6 +64,7 @@
"{{count}} weeks ago": "{{count}} недель назад",
"{{field}} updated to {{value}}": "{{field}} обновлено на {{value}}",
"{{field}} updated to {{value}} for tag: {{tag}}": "{{field}} обновлено на {{value}} для тега: {{tag}}",
+ "{{key}} · version {{version}} · from {{source}}": "{{key}} · версия {{version}} · из {{source}}",
"{{method}} {{route}}": "{{method}} {{route}}",
"{{modality}} not supported": "{{modality}} не поддерживается",
"{{modality}} supported": "{{modality}} поддерживается",
@@ -104,6 +109,7 @@
"14 Days": "14 дней",
"192.168.1.1
10.0.0.0/8": "192.168.1.1
10.0.0.0/8",
"1M": "1М",
+ "1M token": "1M token",
"1W": "1Н",
"2. Copy the application token": "2. Скопируйте токен приложения",
"20 / page": "20 / страница",
@@ -147,6 +153,7 @@
"Action": "Действие",
"Action confirmation": "Подтверждение действия",
"Actions": "Операции",
+ "Activate / Roll back": "Активировать / Откатить",
"active": "активный",
"Active": "Активна",
"Active apps": "Активные приложения",
@@ -155,6 +162,7 @@
"Active models": "Активные модели",
"Active Tasks": "Активные задачи",
"active users": "активных пользователей",
+ "Active version": "Активная версия",
"Actively check all channels": "Активно проверять все каналы",
"Actively check auto-disable-enabled channels": "Активно проверять каналы с автоотключением",
"Actual Amount": "Фактическая сумма",
@@ -171,6 +179,7 @@
"Add a new user by providing necessary info.": "Добавьте нового пользователя, предоставив необходимую информацию.",
"Add a new vendor to the system": "Добавить нового поставщика в систему",
"Add an extra layer of security to your account": "Добавьте дополнительный уровень безопасности к вашей учетной записи",
+ "Add an index URL to browse installable plugins.": "Добавьте URL индекса, чтобы просматривать доступные для установки плагины.",
"Add and submit": "Добавить и отправить",
"Add Announcement": "Добавить объявление",
"Add API": "Добавить API",
@@ -217,6 +226,7 @@
"Add rule group": "Добавить группу правил",
"Add rules for a user group": "Добавить правила для группы пользователей",
"Add selectable group": "Добавить выбираемую группу",
+ "Add source": "Добавить источник",
"Add split": "Добавить ветку",
"Add subscription": "Добавить подписку",
"Add tags...": "Добавить теги...",
@@ -292,6 +302,7 @@
"All": "Все",
"All API tokens": "Все API-ключи",
"All categories": "Все категории",
+ "All combinations are priced at zero. Matching requests will be billed as free.": "Для всех комбинаций установлена нулевая цена. Соответствующие запросы будут тарифицироваться бесплатно.",
"All conditions must match before this tier is used.": "Все условия должны совпасть, прежде чем будет использован этот уровень.",
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "Все изменения являются операциями перезаписи. Оставьте поля пустыми, чтобы сохранить текущие значения без изменений.",
"All files exceed the maximum size.": "Все файлы превышают максимальный размер.",
@@ -348,6 +359,7 @@
"Allow using models without price configuration": "Разрешить использование моделей без настройки цен",
"Allow wallet balance after quota used up": "Разрешить использование баланса кошелька после исчерпания квоты",
"Allowed": "Разрешено",
+ "Allowed hosts": "Разрешённые хосты",
"Allowed Origins": "Разрешенные Origins",
"Allowed Ports": "Разрешенные порты",
"Already have an account?": "Уже есть аккаунт?",
@@ -380,6 +392,7 @@
"Anthropic": "Anthropic",
"Anthropic Messages to OpenAI Chat": "Anthropic Messages в OpenAI Chat",
"Any Match (OR)": "Любое совпадение (OR)",
+ "Anyone can publish an index. A plugin installed from a third-party source has the same access as one you upload by hand: review its source before installing.": "Публиковать индекс может кто угодно. Плагин, установленный из стороннего источника, получает те же права, что и загруженный вручную: изучите его код перед установкой.",
"API": "API",
"API Access": "Доступ к API",
"API Addresses": "Адреса API",
@@ -414,6 +427,8 @@
"API token management": "Управление API токенами",
"API URL": "URL API",
"API usage records": "Записи использования API",
+ "API version": "Версия API",
+ "API Version": "Версия API",
"API2GPT": "API2GPT",
"App": "Приложение",
"App rankings shown here are simulated for preview purposes and will be replaced with live usage data once the backend integration is complete.": "Показанный рейтинг приложений сгенерирован для предпросмотра и будет заменён реальными данными после интеграции бэкенда.",
@@ -437,8 +452,10 @@
"Apply plan": "Применить схему",
"Apply reset": "Выполнить сброс",
"Apply Sync": "Применить синхронизацию",
+ "Apply to all rows": "Применить ко всем строкам",
"Applying...": "Применение...",
"Approx.": "Примерно.",
+ "Approximate prices for common specs.": "Ориентировочные цены для типичных конфигураций.",
"apps": "приложений",
"Apps": "Приложения",
"apps tracked": "приложений отслеживается",
@@ -461,12 +478,17 @@
"Are you sure?": "Вы уверены?",
"Area Chart": "Диаграмма с областями",
"Args (space separated)": "Аргументы (разделённые пробелами)",
+ "Arguments JSON": "Аргументы JSON",
+ "Arguments must be a JSON array": "Аргументы должны быть массивом JSON",
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Массив предустановок чат-клиентов. Каждый элемент представляет собой объект с одной парой ключ-значение: имя клиента и его URL.",
+ "Artifacts": "Артефакты",
"Asc": "По возрастанию",
"Ask anything": "Спросите что угодно",
"Assigned by administrator only": "Назначается только администратором",
"Assigned by administrators and used to represent a user level, such as default or vip.": "Назначается администраторами и обозначает уровень пользователя, например default или vip.",
+ "Async": "Асинхронный",
"Async task polling": "Опрос асинхронных задач",
+ "Async Task Public Address": "Публичный адрес асинхронных задач",
"Async task refund": "Возврат асинхронной задачи",
"At least one model regex pattern is required": "Требуется хотя бы один шаблон регулярного выражения модели",
"At least one valid key source is required": "Требуется хотя бы один действительный источник ключа",
@@ -585,8 +607,10 @@
"Balance updated: {{balance}}": "Баланс обновлён: {{balance}}",
"Bar Chart": "Столбчатая диаграмма",
"Bark Push URL": "URL для push-уведомлений Bark",
+ "Base": "Базовая",
"Base address provided by your Epay service": "Базовый адрес, предоставленный вашим сервисом Epay",
"Base amount. Actual deduction = base amount × system group rate.": "Базовая сумма. Фактический вычет = базовая сумма × коэффициент группы.",
+ "Base charge": "Базовая плата",
"Base input and output token prices for this tier.": "Базовые цены входных и выходных токенов для этого уровня.",
"Base input price only": "Только базовая цена входа",
"Base Limits": "Базовые лимиты",
@@ -594,6 +618,7 @@
"Base Price": "Базовая цена",
"Base rate limit windows for this account.": "Окна базовых лимитов для этого аккаунта.",
"Base URL": "Адрес API",
+ "Base URL *": "Базовый URL *",
"Base URL is required for this channel type": "Для этого типа канала требуется Base URL",
"Base URL is required when an advanced route uses an upstream path": "Base URL требуется, когда расширенный маршрут использует путь upstream",
"Base URL of your Uptime Kuma instance": "Базовый URL вашего экземпляра Uptime Kuma",
@@ -638,6 +663,7 @@
"Billing group = vip (the token has no group, so use the user group)": "Тарифная группа = vip (у токена нет группы, используем группу пользователя)",
"Billing History": "История биллинга",
"Billing Mode": "Режим биллинга",
+ "Billing parameters": "Параметры тарификации",
"Billing Path": "Путь тарификации",
"Billing Process": "Процесс тарификации",
"Billing rule: each call is billed as the token group (falling back to the user group when the token has none). The base ratio always comes from that billing group, not from the user group. To give a user group a special price on another billing group, add an entry in the override matrix.": "Правило тарификации: каждый вызов тарифицируется по группе токена (если у токена нет группы — по группе пользователя). Базовый коэффициент всегда берётся из этой тарифной группы, а не из группы пользователя. Чтобы задать группе пользователей особую цену для другой тарифной группы, добавьте запись в матрицу переопределений.",
@@ -648,6 +674,7 @@
"Bind Email": "Привязать Email",
"Bind Telegram Account": "Привязать аккаунт Telegram",
"Bind WeChat Account": "Привязка аккаунта WeChat",
+ "Bind task plugins": "Привязать плагины задач",
"Binding Information": "Информация о привязке",
"Binding successful!": "Привязка успешна!",
"Binding your {{provider}} account": "Привязка вашего аккаунта {{provider}}",
@@ -686,6 +713,7 @@
"Built for developers,": "Создано для разработчиков,",
"Built-in": "Встроенный",
"Built-in Device": "Встроенное устройство",
+ "Built-in v{{factory}} / marketplace v{{market}}": "Встроенный v{{factory}} / магазин v{{market}}",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Встроенное: отпечаток пальца/лицо телефона или Windows Hello; Внешнее: USB-ключ безопасности",
"by": "от",
"By category": "По категориям",
@@ -738,6 +766,7 @@
"Caps the response length": "Ограничивает длину ответа",
"Capture a reusable bundle of models, tags, or endpoints.": "Создайте повторно используемый набор моделей, тегов или конечных точек.",
"Card view": "Карточки",
+ "Cascade disable channels": "Отключить также каналы",
"Catch-all route must be last for the same incoming path": "Резервный маршрут должен быть последним для этого входного пути",
"Category": "Категория",
"Category Name": "Название категории",
@@ -779,7 +808,9 @@
"Channel test concurrency": "Параллельность проверки каналов",
"Channel test concurrency must be between 1 and 32": "Параллельность проверки каналов должна быть от 1 до 32",
"Channel test mode": "Режим проверки каналов",
+ "Channel type": "Тип канала",
"Channel type is required": "Тип канала обязателен",
+ "Channel types": "Типы каналов",
"Channel updated successfully": "Канал успешно обновлён",
"Channel-specific settings (JSON format)": "Настройки, специфичные для канала (формат JSON)",
"Channel:": "Канал:",
@@ -950,6 +981,7 @@
"Compare the most popular models on the platform": "Сравните самые популярные модели на платформе",
"compatible API routes": "совместимых API-маршрутов",
"Compatible API routes for common AI application workflows": "Совместимые API-маршруты для типовых сценариев ИИ-приложений",
+ "Compilation failed": "Ошибка компиляции",
"Complete API documentation with multi-language SDK support": "Полная документация API с поддержкой SDK на нескольких языках",
"Complete Order": "Вывод заказа",
"Complete these steps to finish the initial installation.": "Выполните эти шаги, чтобы завершить начальную установку.",
@@ -1000,6 +1032,7 @@
"Configure pricing ratios for a specific model.": "Настроить коэффициенты ценообразования для конкретной модели.",
"Configure rate limiting rules for a specific user group.": "Настроить правила ограничения скорости для конкретной группы пользователей.",
"Configure routes": "Настроить маршруты",
+ "Configure task pricing": "Настроить тарификацию задач",
"Configure the ratio for this group.": "Настроить коэффициент для этой группы.",
"Configure upstream providers and routing.": "Настроить провайдеров верхнего уровня и маршрутизацию.",
"Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "Настроить хостовую интеграцию Waffo Pancake (hosted checkout) для пополнений в USD",
@@ -1143,6 +1176,10 @@
"Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "Стоимость = цена модели × этот единственный коэффициент. Другие настройки групп в формуле не участвуют.",
"Cost in USD per request, regardless of tokens used.": "Стоимость в долларах США за запрос, независимо от использованных токенов.",
"Cost Tracking": "Отслеживание затрат",
+ "Could not fetch the plugin source from this browser. The host may block cross-origin requests or be unreachable.": "Не удалось загрузить код плагина из браузера. Хост может блокировать кросс-доменные запросы или быть недоступен.",
+ "Could not fetch this URL from the browser. The host may block cross-origin requests or be unreachable. Download the file and paste its source below.": "Не удалось загрузить этот URL из браузера. Хост может блокировать кросс-доменные запросы или быть недоступен. Скачайте файл и вставьте его код ниже.",
+ "Could not load this source": "Не удалось загрузить этот источник",
+ "Count": "Количество",
"Count must be between {{min}} and {{max}}": "Количество должно быть от {{min}} до {{max}}",
"Coze": "Coze",
"CPU": "ЦП",
@@ -1199,6 +1236,7 @@
"Credentials": "Учетные данные",
"Credentials verification failed": "Не удалось проверить учетные данные",
"Credentials verification failed — double-check Merchant ID and API private key.": "Не удалось проверить учетные данные — проверьте Merchant ID и приватный ключ API.",
+ "credit": "credit",
"Credit remaining": "Остаток средств",
"Creem API key (leave blank unless updating)": "Ключ API Creem (оставьте пустым, если не обновляете)",
"Creem Gateway": "Шлюз Creem",
@@ -1226,6 +1264,7 @@
"Current version": "Текущая версия",
"Current:": "Текущий:",
"Custom": "Пользовательский",
+ "Custom (overrides factory {{version}})": "Пользовательский (заменяет встроенный {{version}})",
"Custom (seconds)": "Пользовательский (секунды)",
"Custom Amount": "Пользовательская сумма",
"Custom API base URL. For official channels, New API has built-in addresses. Only fill this for third-party proxy sites or special endpoints. Do not add /v1 or trailing slash.": "Пользовательский базовый URL API. Для официальных каналов New API имеет встроенные адреса. Заполняйте это поле только для сторонних прокси-сайтов или специальных конечных точек. Не добавляйте /v1 или завершающий слэш.",
@@ -1245,6 +1284,7 @@
"Custom OAuth Providers": "Пользовательские OAuth-провайдеры",
"Custom Seconds": "Пользовательские секунды",
"Custom sidebar section": "Пользовательский раздел боковой панели",
+ "Custom task plugin setting updated": "Настройка пользовательских плагинов задач обновлена",
"Custom Time Range": "Пользовательский диапазон времени",
"Custom Zoom": "Пользовательский зум",
"Customize sidebar display content": "Настроить содержимое боковой панели",
@@ -1274,6 +1314,7 @@
"Days to Retain": "Дней хранения",
"decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.": "определяет коэффициент пополнения, какие группы пользователь может выбирать для токенов и применяется ли переопределение коэффициента.",
"decides which channels are used and which base ratio applies.": "определяет используемые каналы и применяемый базовый коэффициент.",
+ "Declared capabilities": "Заявленные возможности",
"Decreased user quota by {{quota}}": "Квота пользователя уменьшена на {{quota}}",
"Deducted by subscription": "Списано по подписке",
"DeepSeek": "DeepSeek",
@@ -1306,6 +1347,7 @@
"Delete {{count}} stale instance records? Online instances will not be deleted.": "Удалить {{count}} записей устаревших экземпляров? Онлайн-экземпляры не будут удалены.",
"Delete a runtime request header": "Удалить заголовок запроса во время выполнения",
"Delete Account": "Удалить аккаунт",
+ "Delete active custom version": "Удалить активную пользовательскую версию",
"Delete All Disabled": "Удалить все отключенные",
"Delete All Disabled Channels?": "Удалить все отключенные каналы?",
"Delete all stale": "Удалить все устаревшие",
@@ -1329,6 +1371,7 @@
"Delete mapping": "Удалить сопоставление",
"Delete Model": "Удалить модель",
"Delete Models?": "Удалить модели?",
+ "Delete plugin version?": "Удалить версию плагина?",
"Delete Provider": "Удалить провайдер",
"Delete Request Header": "Удалить заголовок запроса",
"Delete selected API keys": "Удалить выбранные ключи API",
@@ -1355,6 +1398,7 @@
"Deleted stale instance": "Устаревший экземпляр удален",
"Deleted successfully": "Удалено успешно",
"Deleted user {{username}} (ID: {{id}})": "Удалён пользователь {{username}} (ID: {{id}})",
+ "Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.": "Удаление этой пользовательской версии не отключит платформу. Одноимённый встроенный плагин будет восстановлен автоматически.",
"Deleting will permanently remove this subscription record (including benefit details). Continue?": "Удаление безвозвратно удалит запись подписки (включая детали льгот). Продолжить?",
"Deleting...": "Удаление...",
"Demo site": "Демо-сайт",
@@ -1401,6 +1445,8 @@
"Disable": "Отключить",
"Disable 2FA": "Отключить 2FA",
"Disable All": "Отключить все",
+ "Disable custom task plugins?": "Отключить пользовательские плагины задач?",
+ "Disable task plugins?": "Отключить плагины задач?",
"Disable on failure": "Отключить при сбое",
"Disable selected channels": "Отключить выбранные каналы",
"Disable selected models": "Отключить выбранные модели",
@@ -1415,6 +1461,8 @@
"Disabled lanes are omitted on save.": "Отключённые каналы цен не сохраняются.",
"Disabled Reason": "Причина отключения",
"Disabled Time": "Время отключения",
+ "Disabled; fell back to factory": "Отключён; используется встроенная версия",
+ "Disabled; platform unavailable": "Отключён; платформа недоступна",
"Disabling...": "Отключение...",
"Disclaimer: Personal use only. Do not distribute or share any credentials. This channel has prerequisites and requires prior setup; use it only if you understand the flow and risks, and comply with OpenAI's terms and policies. Credentials and configuration are for Codex CLI integration only, and are not intended for any other client, platform, or channel.": "Предупреждение: только для личного использования. Не распространяйте и не передавайте учетные данные. Для этого канала требуются предварительные условия и начальная настройка; используйте его только если понимаете процедуру и риски, и соблюдайте условия и политики OpenAI. Учетные данные и конфигурация предназначены только для интеграции с Codex CLI и не предназначены для других клиентов, платформ или каналов.",
"Discord": "Discord",
@@ -1480,6 +1528,7 @@
"Drawing Logs": "Журнал рисования",
"Drawing task polling": "Опрос задач рисования",
"Drawing task records": "Записи задач рисования",
+ "Dry run result": "Результат пробного запуска",
"Duplicate": "Дублировать",
"Duplicate group names: {{names}}": "Повторяющиеся имена групп: {{names}}",
"Duplicate model in route models": "В моделях маршрута есть дубликат модели",
@@ -1540,7 +1589,9 @@
"Each item must have exactly one key-value pair.": "Каждый элемент должен иметь ровно одну пару ключ-значение.",
"Each line represents one keyword. Leave blank to disable the list but keep the switch states.": "Каждая строка представляет одно ключевое слово. Оставьте пустым, чтобы отключить список, но сохранить состояния переключателей.",
"Each matrix cell is one rule: users of this row group pay this ratio when billed as this column group. In JSON the row is the outer key and the column is the inner key.": "Каждая ячейка матрицы — одно правило: пользователи группы строки платят этот коэффициент при тарификации по группе столбца. В JSON строка — внешний ключ, столбец — внутренний.",
+ "Each row prices one combination of {{fields}}.": "В каждой строке задаётся цена для одной комбинации {{fields}}.",
"Each rule reads as a sentence: users of one group pay a special ratio when billed as another group. Without a rule, the billing group base ratio applies.": "Каждое правило читается как предложение: пользователи одной группы платят особый коэффициент при тарификации по другой группе. Без правила применяется базовый коэффициент тарифной группы.",
+ "Each source serves an index.json listing installable plugins. Indexes are fetched by your browser; the gateway makes no outbound requests.": "Каждый источник отдаёт index.json со списком доступных плагинов. Индексы загружает ваш браузер; шлюз не выполняет исходящих запросов.",
"Each tier supports 0~2 conditions (over len, p, c); the last tier is the catch-all without conditions. Use len (full input length, including cache hits) for tier conditions to avoid mis-routing when cache hits reduce p.": "Каждый уровень поддерживает 0–2 условия (по len, p, c); последний уровень — резервный, без условий. Используйте len (полная длина ввода, включая попадания в кэш) для условий уровня, чтобы избежать ошибочной маршрутизации, когда попадания в кэш уменьшают p.",
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "Каждый уровень поддерживает до 2 условий; последний уровень является резервным и не содержит условий. Используйте полную длину входа для условий уровня, чтобы кэш-попадания не снижали оплачиваемые входные токены и не приводили к неверному маршруту.",
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "Каждый уровень поддерживает до 2 условий. Последний уровень без условий используется как резервный.",
@@ -1598,6 +1649,8 @@
"Enable 2FA": "Включить 2FA",
"Enable All": "Включить все",
"Enable check-in feature": "Включить функцию прибытия",
+ "Enable custom task plugins": "Включить пользовательские плагины задач",
+ "Enable task plugins": "Включить плагины задач",
"Enable Data Dashboard": "Включить панель данных",
"Enable demo mode with limited functionality": "Включить демонстрационный режим с ограниченной функциональностью",
"Enable Discord OAuth": "Включить Discord OAuth",
@@ -1617,6 +1670,7 @@
"Enable or disable this model": "Включить или отключить эту модель",
"Enable Passkey": "Включить Passkey",
"Enable Performance Monitoring": "Включить мониторинг производительности",
+ "Enable plugin {{key}}": "Включить плагин {{key}}",
"Enable rate limiting": "Включить ограничение скорости",
"Enable Request Passthrough": "Включить сквозную передачу запросов",
"Enable selected channels": "Включить выбранные каналы",
@@ -1670,6 +1724,8 @@
"Enter a value and press Enter": "Введите значение и нажмите Enter",
"Enter amount in {{currency}}": "Введите сумму в {{currency}}",
"Enter amount in tokens": "Введите сумму в токенах",
+ "Enter an absolute HTTP(S) URL without credentials, query parameters, or fragments": "Введите абсолютный URL HTTP(S) без учётных данных, параметров запроса и фрагмента",
+ "Enter an absolute http(s) URL.": "Укажите полный http(s) URL.",
"Enter announcement content (supports Markdown & HTML)": "Введите содержимое объявления (поддерживает Markdown и HTML)",
"Enter announcement content (supports Markdown/HTML)": "Введите содержимое объявления (поддерживает Markdown/HTML)",
"Enter API Key": "Введите API-ключ",
@@ -1733,6 +1789,9 @@
"Enterprise Account": "Корпоративная учетная запись",
"Enterprise-grade security with comprehensive permission management": "Безопасность корпоративного уровня с комплексным управлением разрешениями",
"Entrypoint (space separated)": "Точка входа (через пробелы)",
+ "Enum": "Перечисление",
+ "Boolean": "Логический",
+ "Enum values": "Значения перечисления",
"Env (JSON object)": "Env (объект JSON)",
"Environment variables": "Переменные окружения",
"Environment variables (JSON)": "Переменные окружения (JSON)",
@@ -1764,6 +1823,8 @@
"Example": "Пример",
"Example (all channels):": "Пример (все каналы):",
"Example (specific channels):": "Пример (указанные каналы):",
+ "Example price": "Пример цены",
+ "Example spec": "Пример спецификации",
"Example:": "Пример:",
"example.com
blocked-site.com": "example.com
blocked-site.com",
"example.com
company.com": "example.com
company.com",
@@ -1792,6 +1853,7 @@
"Expose ratio API": "Интерфейс экспонирования коэффициента",
"Exposes the pricing/models catalog in the top navigation.": "Отображает каталог цен/моделей в верхней навигации.",
"Expression": "Выражение",
+ "Expression - Task pricing": "Выражение — тарификация задач",
"Expression based": "На основе выражения",
"Expression billing": "Тарификация по выражению",
"Expression editor": "Редактор выражения",
@@ -1811,6 +1873,9 @@
"Extra visible": "Дополнительно видимая",
"Extra visible to {{group}}": "Дополнительно видима для {{group}}",
"extras": "доп. пункты",
+ "Factory": "Встроенный",
+ "Factory and custom plugin behavior": "Поведение встроенных и пользовательских плагинов",
+ "Factory plugins cannot be deleted or disabled individually. A custom version can override them; deleting or disabling that version restores the factory plugin. Third-party-only platforms become unavailable when their plugin is deleted or disabled.": "Встроенные плагины нельзя удалять или отключать отдельно. Пользовательская версия может заменить их; её удаление или отключение восстанавливает встроенный плагин. Сторонняя платформа становится недоступной при удалении или отключении её плагина.",
"Fail Reason": "Причина сбоя",
"Fail Reason Details": "Детали причины сбоя",
"failed": "ошибка",
@@ -1879,6 +1944,7 @@
"Failed to initialize system": "Не удалось инициализировать систему",
"Failed to load": "Не удалось загрузить",
"Failed to load API keys": "Не удалось загрузить API ключи",
+ "Failed to load artifacts": "Не удалось загрузить артефакты",
"Failed to load billing history": "Не удалось загрузить историю платежей",
"Failed to load enabled models": "Не удалось загрузить включённые модели",
"Failed to load home page content": "Не удалось загрузить содержимое главной страницы",
@@ -1968,15 +2034,20 @@
"Feature in development": "Функция в разработке",
"Fee": "Сбор",
"Fee Amount": "Сумма сбора",
+ "Fetch": "Загрузить",
"Fetch available models for:": "Получить доступные модели для:",
"Fetch available models from upstream": "Получить доступные модели от вышестоящего поставщика",
"Fetch from Upstream": "Получить из Upstream",
"Fetch Models": "Получить модели",
+ "Fetch mode": "Режим получения",
"Fetched {{count}} model(s) from upstream": "Получено {{count}} моделей из upstream",
"Fetched {{count}} models": "Получено {{count}} моделей",
+ "Fetched in your browser and placed in the source field below for review. GitHub and gist page URLs are rewritten to their raw URL automatically.": "Загружается браузером и помещается в поле кода ниже для проверки. URL страниц GitHub и gist автоматически преобразуются в raw URL.",
+ "Fetching plugin source...": "Загрузка кода плагина…",
"Fetching prefill groups...": "Загрузка групп предварительного заполнения...",
"Fetching upstream prices...": "Получение цен провайдера...",
"Fetching upstream ratios...": "Загрузка коэффициентов upstream...",
+ "Fetching...": "Загрузка…",
"field": "поле",
"Field Mapping": "Сопоставление полей",
"Field passthrough controls": "Полевые сквозные элементы управления",
@@ -1986,6 +2057,7 @@
"Files to Retain": "Файлов для хранения",
"Fill All Models": "Заполнить все модели",
"Fill Codex CLI / Claude CLI Templates": "Заполнить шаблоны Codex CLI / Claude CLI",
+ "Fill entire column": "Заполнить весь столбец",
"Fill example (all channels)": "Подставить пример (все каналы)",
"Fill example (specific channels)": "Подставить пример (указанные каналы)",
"Fill in": "Заполнить",
@@ -2025,6 +2097,7 @@
"Filter models by provider, group, type, endpoint, and tags.": "Фильтруйте модели по поставщику, группе, типу, endpoint и тегам.",
"Filter models by type, endpoint, vendor, group and tags": "Фильтровать модели по типу, точке доступа, поставщику, группе и тегам",
"Filter models...": "Фильтровать модели...",
+ "Filter plugins...": "Фильтр плагинов...",
"Filter the model analytics view by time range and user.": "Фильтруйте представление аналитики моделей по периоду и пользователю.",
"Filter the traffic flow view by time range and user.": "Фильтруйте представление потока трафика по диапазону времени и пользователю.",
"Filter...": "Фильтр...",
@@ -2078,6 +2151,7 @@
"Force Format": "Принудительный формат",
"Force format response to OpenAI standard (OpenAI channel only)": "Принудительно форматировать ответ в соответствии со стандартом OpenAI (только для канала OpenAI)",
"Force JSON object or schema-conforming output": "Принудительно вернуть JSON или соответствующий схеме вывод",
+ "Force operation": "Выполнить принудительно",
"Force SMTP authentication using AUTH LOGIN method": "Принудительная аутентификация SMTP с использованием метода AUTH LOGIN",
"Force-disabled two-factor authentication for the user": "Двухфакторная аутентификация пользователя принудительно отключена",
"Forest Whisper": "Лесной шёпот",
@@ -2251,6 +2325,7 @@
"Home": "Главная",
"Home Page Content": "Содержимое главной страницы",
"Homepage URL": "URL главной страницы",
+ "Hook": "Хук",
"Hostname or IP of your SMTP provider": "Имя хоста или IP-адрес вашего SMTP-провайдера",
"Hour": "Час",
"Hour of day": "Час суток",
@@ -2329,6 +2404,7 @@
"Image to Video": "Изображение в видео",
"Image Tokens": "Токены изображений",
"Imagine the pricing table has three groups: default (ratio 1.0), premium (ratio 0.5), and vip (ratio 0.8). Users whose account is in the vip group get user-level perks, and premium is a cheaper channel pool that users can pick for their tokens.": "Представьте, что в таблице тарифов три группы: default (коэффициент 1,0), premium (коэффициент 0,5) и vip (коэффициент 0,8). Пользователи из группы vip получают привилегии на уровне аккаунта, а premium — более дешёвый пул каналов, который пользователи могут выбирать для своих токенов.",
+ "Import from URL": "Импорт по URL",
"Import to CC Switch": "Импорт в CC Switch",
"Important": "Важно",
"In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "В JSON внешний ключ — группа пользователя, внутренний — тарифная группа. Пример ниже означает: пользователи vip платят 0,8 по standard и 0,3 по premium.",
@@ -2352,6 +2428,8 @@
"Incomplete": "Не завершено",
"Increased user quota by {{quota}}": "Квота пользователя увеличена на {{quota}}",
"Index": "Индекс",
+ "Index request failed with HTTP {{status}}": "Запрос индекса завершился ошибкой HTTP {{status}}",
+ "Index URL": "URL индекса",
"Inherit global Auto order": "Наследовать глобальный порядок Auto",
"Initial quota given to new users": "Начальная квота, предоставляемая новым пользователям",
"Initial quota given to new users ({{formattedQuota}})": "Начальная квота, предоставляемая новым пользователям ({{formattedQuota}})",
@@ -2369,10 +2447,21 @@
"Inset": "Встроенная",
"Inspect requests, errors, and billing details": "Проверяйте запросы, ошибки и детали оплаты",
"Inspect user prompts": "Просмотр запросов пользователя",
+ "Install": "Установить",
+ "Install {{name}}": "Установка {{name}}",
+ "Install and enable": "Установить и включить",
+ "Installed": "Установленные",
+ "Installed {{name}} v{{version}}": "{{name}} v{{version}} установлен",
+ "Installed v{{from}} → marketplace v{{to}}": "Установлено v{{from}} → магазин v{{to}}",
+ "Installed v{{installed}} not listed": "v{{installed}} установлена, нет в индексе",
+ "Installed version is not in this index": "Установленной версии нет в этом индексе",
+ "Installing...": "Установка…",
"Instance": "Экземпляр",
"Instances": "Экземпляры",
"Insufficient balance": "Недостаточно средств",
"Integrations": "Интеграции",
+ "Integrity check failed": "Проверка целостности не пройдена",
+ "Integrity hash": "Хеш целостности",
"Inter-group overrides": "Переопределения между группами",
"Inter-group ratio overrides": "Переопределения соотношений между группами",
"Interface Language": "Язык интерфейса",
@@ -2425,6 +2514,7 @@
"It seems like the page you're looking for": "Похоже, страница, которую вы ищете",
"Items": "Элементы",
"Japanese": "Японский",
+ "JavaScript file": "Файл JavaScript",
"Jimeng": "Jimeng",
"Jina": "Jina",
"JSON": "JSON",
@@ -2489,6 +2579,7 @@
"Latency short": "Зад.",
"Latency trend (last 24h)": "Тренд задержки (за 24 часа)",
"Latest platform updates and notices": "Последние обновления и уведомления платформы",
+ "Latest version": "Последняя версия",
"Lavender Dream": "Лавандовая мечта",
"Layout": "Макет",
"lead": "лидер",
@@ -2541,6 +2632,7 @@
"LinuxDO Client Secret": "Секрет клиента LinuxDO",
"List of models supported by this channel. Use comma to separate multiple models.": "Список моделей, поддерживаемых этим каналом. Используйте запятую для разделения нескольких моделей.",
"List of origins (one per line) allowed for Passkey registration and authentication.": "Список источников (один на строку), разрешенных для регистрации и аутентификации Passkey.",
+ "List registered task plugins and bind them when creating or editing task plugin channels.": "Просматривать зарегистрированные плагины задач и привязывать их при создании или изменении каналов.",
"List view": "Вид списка",
"Live refresh pauses when no task is running": "Автообновление приостанавливается, когда нет выполняемых задач",
"LLM Leaderboard": "Рейтинг LLM",
@@ -2555,6 +2647,7 @@
"Loading conversation...": "Загрузка диалога...",
"Loading current models...": "Загрузка текущих моделей...",
"Loading failed": "Ошибка загрузки",
+ "Loading installed source...": "Загрузка установленного кода…",
"Loading maintenance settings...": "Загрузка настроек обслуживания...",
"Loading settings...": "Загрузка настроек...",
"Loading setup status…": "Загрузка статуса установки...",
@@ -2606,6 +2699,7 @@
"Manage multi-key status and configuration for this channel": "Управление статусом и конфигурацией нескольких ключей для этого канала",
"Manage Ollama Models": "Управление моделями Ollama",
"Manage server log files. Log files accumulate over time; regular cleanup is recommended to free disk space.": "Управление файлами журналов сервера. Файлы журналов накапливаются со временем; рекомендуется регулярная очистка.",
+ "Manage sources": "Управление источниками",
"Manage subscription plans and pricing.": "Управление планами подписок и ценообразованием.",
"Manage Subscriptions": "Управление подписками",
"Manage Vendors": "Управление поставщиками",
@@ -2619,6 +2713,10 @@
"Map upstream status codes to different codes": "Сопоставить коды статуса вышестоящего сервера с различными кодами",
"Market Share": "Доля рынка",
"Marketing": "Маркетинг",
+ "Marketplace": "Магазин",
+ "Marketplace installs never force past a conflict. Resolve it on the task plugins page, then install again.": "Установка из магазина никогда не игнорирует конфликт принудительно. Устраните его на странице плагинов задач и повторите установку.",
+ "Marketplace sources": "Источники магазина",
+ "Marketplace sources updated": "Источники магазина обновлены",
"Master instances run scheduled background tasks.": "Экземпляры master выполняют плановые фоновые задачи.",
"Match All (AND)": "Все совпадения (AND)",
"Match Any (OR)": "Любое совпадение (OR)",
@@ -2663,6 +2761,8 @@
"Maximum tokens per user": "Максимальное количество токенов на пользователя",
"maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647": "maxRequests ≥ 0, maxSuccess ≥ 1, оба ≤ 2,147,483,647",
"May be used for training by upstream provider": "Может использоваться поставщиком для обучения",
+ "Media access expired. Please try again.": "Срок доступа к медиафайлу истёк. Повторите попытку.",
+ "Media preview failed. Please try again.": "Не удалось просмотреть медиафайл. Повторите попытку.",
"Media pricing": "Цены для медиа",
"Median time-to-first-token (TTFT) sampled hourly per group": "Медианная задержка первого токена (TTFT), измеряемая ежечасно по группам",
"Medical Q&A, mental health support": "Медицинские Q&A, поддержка ментального здоровья",
@@ -2862,6 +2962,7 @@
"Native Claude Messages plus OpenAI Chat compatibility forwarding.": "Нативный Claude Messages и совместимая пересылка OpenAI Chat.",
"Native format": "Собственный формат",
"Native forwarding": "Нативная пересылка",
+ "Native routes": "Нативные маршруты",
"Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Нативные маршруты Gemini и совместимая пересылка OpenAI Chat и Responses.",
"Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "Нативные маршруты OpenAI и дополнительные маршруты совместимости Claude и Gemini.",
"Need a redemption code?": "Нужен код активации?",
@@ -2916,6 +3017,7 @@
"No available Web chat links": "Нет доступных веб-ссылок для чата",
"No backup": "Нет резервной копии",
"No base input price": "Нет базовой цены входа",
+ "No billing parameters declared": "Параметры тарификации не объявлены",
"No billing records found": "Записи о выставлении счетов не найдены",
"No capabilities reported for this model.": "Для этой модели не указаны возможности.",
"No Change": "Без изменений",
@@ -2964,6 +3066,8 @@
"No incidents in the last 24 hours": "За последние 24 часа инцидентов не было",
"No incidents in the last 30 days": "За последние 30 дней инцидентов не было",
"No instances have reported yet.": "Экземпляры еще не отправляли данные.",
+ "No integrity hash": "Нет хеша целостности",
+ "No integrity verification": "Без проверки целостности",
"No Inviter": "Нет пригласившего",
"No keys found": "Ключи не найдены",
"No latency data available": "Данные о задержке недоступны",
@@ -2971,6 +3075,7 @@
"No logs": "Нет логов",
"No Logs Found": "Логи не найдены",
"No mappings configured. Click \"Add Row\" to get started.": "Нет настроенных сопоставлений. Нажмите \"Добавить строку\", чтобы начать.",
+ "No marketplace sources configured.": "Источники магазина не настроены.",
"No matches found": "Совпадений не найдено",
"No matching items": "Нет подходящих элементов",
"No matching results": "Нет совпадений",
@@ -3047,6 +3152,7 @@
"No Sync": "Без синхронизации",
"No system announcements": "Нет системных объявлений",
"No system tasks yet.": "Пока нет системных задач.",
+ "No task plugins found": "Плагины задач не найдены",
"No token found.": "Токен не найден.",
"No tools configured": "Нет настроенных инструментов",
"No Upgrade": "Без повышения",
@@ -3078,9 +3184,13 @@
"Not backed up": "Не сохранено",
"Not bound": "Не привязан",
"Not configured": "Не настроено",
+ "Not declared": "Не заявлено",
"Not Equals": "Не равно",
"Not in pricing table": "Нет в таблице тарифных групп",
"Not included": "Не включена",
+ "Not installed": "Не установлен",
+ "Not provided by this source": "Не предоставлено этим источником",
+ "Not registered": "Не зарегистрирован",
"Not set": "Не задано",
"Not Set": "Не установлено",
"Not set yet": "Ещё не задано",
@@ -3096,6 +3206,7 @@
"Notifications": "Уведомления",
"Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "Теперь пользователь с группой vip создаёт токены с разными группами и делает по одному вызову с каждым:",
"Nucleus sampling probability mass": "Накопленная вероятность для nucleus-сэмплинга",
+ "Number": "Число",
"Number of codes to create": "Количество кодов для создания",
"Number of completions to generate": "Число генерируемых вариантов",
"Number of images to generate": "Количество изображений",
@@ -3104,6 +3215,7 @@
"Number of tokens per unit quota": "Количество токенов на единицу квоты",
"Number of top log probabilities returned per token": "Количество top-вероятностей на токен",
"Number of users invited": "Количество приглашенных пользователей",
+ "OAuth": "OAuth",
"OAuth binding timed out. Please try again.": "Время ожидания привязки OAuth истекло. Повторите попытку.",
"OAuth binding window is no longer available": "Окно привязки OAuth больше недоступно",
"OAuth callback URL": "URL обратного вызова OAuth",
@@ -3223,6 +3335,7 @@
"Optional notes about this channel": "Необязательные заметки об этом канале",
"Optional notes about when to use this group": "Необязательные примечания о том, когда использовать эту группу",
"Optional ratio used when upstream cache hits occur.": "Необязательное соотношение, используемое при попаданиях в вышестоящий кэш.",
+ "Optional request-rule multiplier expression. Leave empty when no request rule applies.": "Необязательное выражение множителя для правил запроса. Оставьте пустым, если правила не применяются.",
"Optional rule description": "Необязательное описание правила",
"Optional settings for advanced container configuration.": "Дополнительные настройки для расширенной конфигурации контейнера.",
"Optional supplementary information (max 100 characters)": "Необязательная дополнительная информация (макс. 100 символов)",
@@ -3291,6 +3404,7 @@
"parameter.": "параметр.",
"Parameters": "Параметры",
"Parsed {{count}} service account file(s)": "Проанализировано файлов сервисного аккаунта {{count}}",
+ "Parsed plugin metadata": "Распознанные метаданные плагина",
"Partial Submission": "Частичная отправка",
"Pass Headers": "Пропустить заголовки",
"Pass request body directly to upstream": "Передать тело запроса напрямую вышестоящему серверу",
@@ -3344,6 +3458,7 @@
"Passwords do not match": "Пароли не совпадают",
"Passwords don't match.": "Пароли не совпадают.",
"Paste Connection Info": "Вставить данные подключения",
+ "Paste JavaScript source here...": "Вставьте исходный код JavaScript...",
"Path": "Путь",
"Path not set": "Путь не задан",
"Path Regex (one per line)": "Регулярное выражение пути (по одному на строку)",
@@ -3383,6 +3498,8 @@
"per request": "за запрос",
"Per request": "За запрос",
"Per Request": "За запрос",
+ "Per Second": "За секунду",
+ "Per Unit": "За единицу",
"Per-call": "По вызову",
"Per-feature metered windows split by model or capability.": "Окна с поминутной тарификацией по фиче, в разбивке по модели или возможностям.",
"Per-group performance": "Производительность по группам",
@@ -3487,6 +3604,23 @@
"Please wait a moment, human check is initializing...": "Пожалуйста, подождите немного, инициализация проверки человеком...",
"Please wait before editing to avoid overwriting saved values.": "Дождитесь загрузки перед редактированием, чтобы не перезаписать сохраненные значения.",
"Please wait for the current generation to complete": "Дождитесь завершения текущей генерации",
+ "Plugin": "Плагин",
+ "Plugin author": "Автор плагина",
+ "Plugin Generation": "Поколение плагина",
+ "Plugin indexes are fetched by your browser. Installing runs the same review and admission pipeline as a manual upload.": "Индексы плагинов загружает ваш браузер. Установка проходит тот же путь проверки и допуска, что и загрузка вручную.",
+ "Plugin is still in use": "Плагин всё ещё используется",
+ "Plugin key": "Ключ плагина",
+ "Plugin metadata": "Метаданные плагина",
+ "Plugin source": "Исходный код плагина",
+ "Choose file": "Выбрать файл",
+ "Choose another file": "Выбрать другой файл",
+ "Drop a JavaScript plugin file here": "Перетащите сюда файл плагина JavaScript",
+ "Single .js file, up to 1 MiB. Its source is shown below before upload.": "Один файл .js размером до 1 МиБ. Его исходный код показан ниже перед загрузкой.",
+ "Optional note describing this version": "Необязательное примечание к этой версии",
+ "Plugin source exceeds the 1 MiB limit.": "Код плагина превышает лимит 1 МиБ.",
+ "Plugin uploaded successfully": "Плагин успешно загружен",
+ "Plugin version activated": "Версия плагина активирована",
+ "Plugin version deleted": "Версия плагина удалена",
"Policy JSON": "JSON политики",
"Polling": "Опрос",
"Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded": "Режим опроса требует Redis и кэш памяти, в противном случае производительность будет значительно снижена",
@@ -3541,6 +3675,9 @@
"Press Enter to use \"{{value}}\"": "Нажмите Enter, чтобы использовать «{{value}}»",
"Prevent server-side request forgery attacks": "Предотвращение атак подделки запросов на стороне сервера",
"Preview": "Предварительный просмотр",
+ "Preview excludes group ratios and request rule multipliers.": "Предпросмотр не учитывает коэффициенты групп и множители правил запроса.",
+ "Preview is unavailable for custom expressions.": "Предпросмотр недоступен для пользовательских выражений.",
+ "Preview unavailable": "Предпросмотр недоступен",
"Previous": "Предыдущий шаг",
"Previous branch": "Предыдущая ветка",
"Previous page": "Предыдущая страница",
@@ -3551,6 +3688,7 @@
"Price display mode": "Режим отображения цены",
"Price estimation": "Оценка стоимости",
"Price estimation description": "После настройки типа оборудования, места размещения, количества реплик и т.д. стоимость будет рассчитана автоматически.",
+ "Price examples": "Примеры цен",
"Price ID": "ID цены",
"Price mode (USD per 1M tokens)": "Режим ценообразования (USD за 1 млн токенов)",
"Price summary": "Сводка цен",
@@ -3559,6 +3697,7 @@
"Price: High to Low": "Цена: от высокой к низкой",
"Price: Low to High": "Цена: от низкой к высокой",
"Prices shown per": "Цены указаны за",
+ "Prices shown per usage unit": "Цены указаны за единицу использования",
"Prices synced successfully": "Цены успешно синхронизированы",
"Prices vary by usage tier and request conditions": "Цена зависит от уровня использования и условий запроса",
"Pricing": "Ценообразование",
@@ -3623,6 +3762,7 @@
"Prune Object Items": "Очистить элементы объекта",
"Prune object items by conditions": "Удалить элементы объекта по условиям",
"Prune Rule (string or JSON object)": "Правило очистки (строка или JSON-объект)",
+ "Public base URL for async task media. Supports a dedicated media domain, port, or Nginx path prefix; falls back to Server Address when empty.": "Публичный базовый URL для медиафайлов асинхронных задач. Поддерживает отдельный медиадомен, порт или префикс пути Nginx; если поле пусто, используется адрес сервера.",
"Public model catalog and pricing page.": "Публичная страница каталога моделей и цен.",
"Public rankings page based on live usage data.": "Публичная страница рейтингов на основе реальных данных использования.",
"Publish Date": "Дата публикации",
@@ -3773,12 +3913,14 @@
"Regex Replace": "Замена по regex",
"Register each URL into the matching Test Mode / Production Mode webhook slot in the Pancake dashboard. Separate endpoints prevent test traffic from accidentally crediting production accounts.": "Зарегистрируйте каждый URL в соответствующем слоте вебхука Test Mode / Production Mode в панели Pancake. Раздельные конечные точки предотвращают случайное зачисление тестового трафика на производственные аккаунты.",
"Register Passkey": "Регистрация Passkey",
+ "Registered": "Зарегистрирован",
"Registered a passkey": "Ключ доступа зарегистрирован",
"Registration Enabled": "Регистрация включена",
"Registration flow expired. Please try again.": "Процесс регистрации истёк. Повторите попытку.",
"Registry (optional)": "Реестр (необязательно)",
"Registry secret": "Секрет реестра",
"Registry username": "Имя пользователя реестра",
+ "Reinstall latest": "Переустановить последнюю",
"Reject Reason": "Причина отклонения",
"Release details": "Детали релиза",
"Released": "Выпущено",
@@ -3806,6 +3948,7 @@
"Remove Passkey": "Отвязать Passkey",
"Remove Passkey?": "Удалить ключ доступа?",
"Remove rule group": "Удалить группу правил",
+ "Remove source {{name}}": "Удалить источник {{name}}",
"Remove string prefix": "Удалить префикс строки",
"Remove string suffix": "Удалить суффикс строки",
"Remove the target field": "Удалить целевое поле",
@@ -3855,8 +3998,10 @@
"Request Model": "Запрошенная модель",
"Request Model:": "Модель запроса:",
"Request overrides, routing behavior, and upstream model automation": "Переопределения запросов, маршрутизация и автоматизация upstream-моделей",
+ "Request Path": "Путь запроса",
"Request retry": "Повтор запросов",
"Request rule pricing": "Правила ценообразования по запросу",
+ "Request rules apply on top of this amount.": "Правила запроса применяются дополнительно к этой сумме.",
"Request success rate sampled over the last 24 hours": "Доля успешных запросов по выборкам за последние 24 часа",
"Request success rate; {{incidents}} incident buckets in the last 24 hours": "Доля успешных запросов; {{incidents}} интервалов с инцидентами за последние 24 часа",
"Request timed out, please refresh and restart GitHub login": "Время ожидания истекло, обновите страницу и снова запустите вход через GitHub",
@@ -3919,6 +4064,7 @@
"Reset usage window": "Сбросить окно использования",
"Resets in:": "Сброс через:",
"Resetting...": "Сброс...",
+ "Resize column": "Изменить ширину столбца",
"Resolve Conflicts": "Разрешить конфликты",
"Resource Configuration": "Конфигурация ресурсов",
"Resources": "Ресурсы",
@@ -3951,6 +4097,7 @@
"Revenue": "Доход",
"Review & initialize": "Проверить и инициализировать",
"Review and sign out devices currently using your account.": "Просмотрите устройства, использующие вашу учётную запись, и завершите их сеансы.",
+ "Review and upgrade": "Проверить и обновить",
"Review model rates before scaling traffic": "Проверьте тарифы моделей перед масштабированием трафика",
"Review your payment details": "Проверьте свои платежные данные",
"Review your purchase details before proceeding.": "Просмотрите детали покупки перед продолжением.",
@@ -3962,6 +4109,7 @@
"Role": "Роль",
"Roleplay": "Ролевые игры",
"Root": "Root",
+ "Root Diagnostics": "Диагностика Root",
"Rose Garden": "Розовый сад",
"Route": "Маршрут",
"Route active": "Маршрут активен",
@@ -4001,16 +4149,20 @@
"Rules JSON": "Правила JSON",
"Rules JSON must be an array": "JSON правил должен быть массивом",
"Rules match the original model value from the client request body.": "Правила сопоставляются с исходным значением model из тела клиентского запроса.",
+ "Run dry run": "Пробный запуск",
"Run GC": "Запустить GC",
"Run tests for the selected models": "Запустить тесты для выбранных моделей",
"running": "выполняется",
"Running": "Выполняется",
+ "Running dry run": "Выполняется пробный запуск",
"Runtime": "Среда выполнения",
+ "Runtime status": "Состояние выполнения",
"Runway": "Запас",
"s": "s",
"Safety Settings": "Настройки безопасности",
"Same as Local": "То же, что и локальный",
"Sampling temperature; lower is more deterministic": "Температура сэмплирования; чем ниже, тем детерминированнее",
+ "Sandbox": "Песочница",
"Sandbox mode": "Режим песочницы",
"Save": "Сохранить",
"Save & Submit": "Сохранить и отправить",
@@ -4091,6 +4243,8 @@
"Search the public web at inference time": "Искать в общедоступной сети во время инференса",
"Search vendors...": "Поиск поставщиков...",
"Search...": "Поиск...",
+ "second": "секунда",
+ "Second": "Секунда",
"seconds": "секунды",
"Secret env (JSON object)": "Секретные переменные окружения (объект JSON)",
"Secret environment variables (JSON)": "Секретные переменные окружения (JSON)",
@@ -4116,6 +4270,7 @@
"Select a timestamp before clearing logs.": "Выберите временную метку перед очисткой журналов.",
"Select a usage mode to continue": "Выберите режим использования для продолжения",
"Select a verification method first": "Сначала выберите метод верификации",
+ "Select a version to compare": "Выберите версию для сравнения",
"Select active subscription plan": "Выберите активный тариф подписки",
"Select all": "Выбрать все",
"Select all (filtered)": "& Выбрать все отфильтрованные",
@@ -4176,6 +4331,7 @@
"Select sync channels to compare prices": "Выберите каналы синхронизации для сравнения цен",
"Select sync channels to compare ratios": "Выбрать каналы синхронизации для сравнения соотношений",
"Select Sync Source": "Выбрать источник синхронизации",
+ "Select task plugin": "Выберите плагин задач",
"Select the API endpoint region": "Выбрать регион конечной точки API",
"Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.": "Выберите поля, которые вы хотите перезаписать данными из вышестоящего источника. Невыбранные поля сохранят свои локальные значения.",
"Select theme preference": "Выбрать предпочтение темы",
@@ -4189,6 +4345,7 @@
"Selected conflicts were overwritten successfully.": "Выбранные конфликты успешно перезаписаны.",
"Selected nodes": "Выбранные узлы",
"Selected when creating a token and used as the default billing group for API calls.": "Выбирается при создании токена и используется как группа тарификации по умолчанию для вызовов API.",
+ "Selecting a plugin fills its declared models.": "Выбор плагина заполняет заявленные модели.",
"Self-Use Mode": "Режим самоиспользования",
"Send": "Отправить",
"Send a request": "Отправить запрос",
@@ -4321,12 +4478,15 @@
"Sort by ID": "Сортировать по ID",
"Sort Order": "Порядок сортировки",
"Source": "Источник",
+ "Source diff": "Различия исходного кода",
"Source Endpoint": "Исходная точка",
"Source Field": "Исходное поле",
"Source Header": "Исходный заголовок",
+ "Source name": "Название источника",
"sources": "источники",
"Space-separated OAuth scopes": "Области доступа OAuth, разделенные пробелами",
"Spark model version, e.g., v2.1 (version number in API URL)": "Версия модели Spark, например, v2.1 (номер версии в URL API)",
+ "Spec": "Спецификация",
"Special billing expression": "Специальное выражение тарификации",
"Special group": "Специальная группа",
"Special ratio rules": "Специальные правила коэффициентов",
@@ -4508,11 +4668,21 @@
"Target Path (optional)": "Целевой путь (необязательно)",
"Target User": "Целевой пользователь",
"Task": "Задача",
+ "Task billing": "Тарификация задач",
+ "Task Details": "Сведения о задаче",
"Task History": "История задач",
"Task ID": "ID задачи",
"Task ID:": "ID задачи:",
"Task logs": "Журналы задач",
"Task Logs": "Журнал задач",
+ "Task Plugin": "Плагин задач",
+ "Task plugin setting updated": "Настройка плагинов задач обновлена",
+ "Task plugin *": "Плагин задач *",
+ "Task Plugins": "Плагины задач",
+ "Task pricing": "Тарификация задач",
+ "Task pricing not configured": "Тарификация задач не настроена",
+ "Task usage prices are USD per declared unit. They are not token prices and are not divided by one million.": "Цены за использование указываются в USD за объявленную единицу. Это не цены за токены, и они не делятся на миллион.",
+ "Task usage prices are USD per declared unit. Token fields use dollars per 1M tokens; the editor writes / 1000000 into the expression. Other units are not divided by one million.": "Цены за использование задач указаны в USD за объявленную единицу. Поля token задаются в долларах за 1 млн token; редактор записывает / 1000000 в выражение. Остальные единицы на миллион не делятся.",
"Tasks currently pending or running.": "Задачи, которые ожидают выполнения или выполняются сейчас.",
"Team Collaboration": "Совместная работа в команде",
"Technical Support": "Техническая поддержка",
@@ -4565,12 +4735,15 @@
"The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "Привязанный продукт используется для пополнения кошелька: когда пользователь вводит любую сумму, new-api запускает оплату через этот единственный продукт Pancake и переопределяет цену для каждой сессии — не нужно заранее создавать SKU на $1 / $5 / $10.",
"The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "Привязанный магазин является родительским контейнером для всех продуктов Pancake, которые new-api создает из этой админки: как продукта пополнения кошелька, так и продуктов планов подписки. Одного магазина достаточно; выбирайте другой только если действительно ведете отдельные каталоги Pancake.",
"The deployment node that handled the requests": "Узел развёртывания, обработавший запросы",
+ "The downloaded source does not match the sha256 declared in the index. Do not install it.": "Загруженный код не соответствует sha256, указанному в индексе. Не устанавливайте его.",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "Действующий домен для регистрации Passkey. Должен совпадать с текущим доменом или быть его родительским доменом.",
"The entered text does not match the required text.": "Введенный текст не совпадает с требуемым.",
"The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "Окружение (тестовое или рабочее) определяется ключом, который вы вставляете здесь: используйте тестовый ключ при интеграции, затем замените его на рабочий при запуске.",
"The exact model identifier as used in API requests.": "Точный идентификатор модели, используемый в запросах API.",
"The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "Следующие модели имеют конфликты типов тарификации (фиксированная цена против тарификации по соотношению). Подтвердите, чтобы продолжить изменения.",
"The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "Следующие модели в перенаправлении модели не были добавлены в список \"Модели\" и могут не работать при вызове из-за отсутствия доступных моделей:",
+ "The gateway rejected this plugin": "Шлюз отклонил этот плагин",
+ "The index could not be fetched or parsed: {{message}}. The host may block cross-origin requests.": "Не удалось загрузить или разобрать индекс: {{message}}. Хост может блокировать кросс-доменные запросы.",
"The login session that started this Telegram binding is no longer valid.": "Сеанс входа, из которого была начата привязка Telegram, больше недействителен.",
"The mapped upstream model(s)": "Сопоставленные upstream модель(и)",
"The model that was requested": "Запрошенная модель",
@@ -4594,6 +4767,7 @@
"The upstream natively supports all three protocols; every selected route is forwarded without conversion.": "Поставщик нативно поддерживает все три протокола; выбранные маршруты пересылаются без преобразования.",
"The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.": "Ответ поставщика содержит допустимый JSON, но не соответствует формату OpenAI credit_summary. Баланс канала не обновлён.",
"The URL for this chat client.": "URL для этого чат-клиента.",
+ "The URL returned HTTP {{status}}. Check the address, or download the file and paste its source below.": "URL вернул HTTP {{status}}. Проверьте адрес или скачайте файл и вставьте его код ниже.",
"The user group applied to the requests": "Группа пользователей, применённая к запросам",
"The user who made the requests": "Пользователь, отправивший запросы",
"Theme": "Тема",
@@ -4603,11 +4777,18 @@
"There is a rule for vip billed as premium → use its ratio 0.3": "Есть правило «vip по premium» → используется его коэффициент 0,3",
"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.": "Эти имена всё ещё отмечены в выборе, но не возвращены в списке upstream; ключи только как источники model_mapping исключены. Скорректируйте выбор перед сохранением.",
"These toggles affect whether certain request fields are passed through to the upstream provider.": "Эти переключатели влияют на то, передаются ли определенные поля запроса вышестоящему поставщику.",
+ "These values come from the source index and are shown for review only. The gateway admits the plugin based on the metadata compiled from its source.": "Эти значения взяты из индекса источника и показаны только для проверки. Шлюз допускает плагин по метаданным, скомпилированным из его кода.",
"Thinking Suffix Adapter": "Адаптер суффикса thinking",
"Thinking to Content": "Мышление в контент",
"Thinking...": "Размышление...",
+ "Third-party": "Сторонний",
+ "Third-party — use at your own risk": "Сторонний — на ваш риск",
"Third-party account bindings (read-only, managed by user in profile settings)": "Привязки сторонних учетных записей (только для чтения, управляется пользователем в настройках профиля)",
"Third-party Payment Config": "Настройка стороннего платежа",
+ "Third-party plugin risk": "Риск стороннего плагина",
+ "Third-party source risk": "Риск сторонних источников",
+ "Third-party-only plugins become unavailable immediately. In-flight tasks will be handled by timeout cleanup.": "Сторонние плагины сразу станут недоступны. Активные задачи обработает очистка по тайм-ауту.",
+ "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.": "Заводские и пользовательские плагины сразу перестают обслуживать запросы. Текущие задачи обработает очистка по таймауту.",
"This action cannot be undone.": "Это действие невозможно отменить.",
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "Это действие невозможно отменить. Это безвозвратно удалит вашу учетную запись и все ваши данные с наших серверов.",
"This action will permanently remove 2FA protection from your account.": "Это действие безвозвратно удалит защиту 2FA из вашей учетной записи.",
@@ -4618,11 +4799,13 @@
"This channel is not an Ollama channel.": "Этот канал не является каналом Ollama.",
"This channel type does not support fetching models": "Этот тип канала не поддерживает получение моделей",
"This channel type requires additional configuration": "Для этого типа канала требуется дополнительная конфигурация",
+ "This combination will be billed as free.": "Эта комбинация будет тарифицироваться бесплатно.",
"This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.": "Это подтверждение разблокирует функции платежей, кодов пополнения, планов подписки и наград за приглашения. Внимательно прочитайте заявления.",
"This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.": "Этот параметр управляет ограничением частоты запросов к моделям. Ограничение маршрутов Web/API настраивается переменными окружения и всё ещё может возвращать 429.",
"This data may be unreliable, use with caution": "Эти данные могут быть ненадежными, используйте с осторожностью",
"This device does not support Passkey": "Это устройство не поддерживает Passkey",
"This device does not support Passkey verification.": "Это устройство не поддерживает проверку с помощью Passkey.",
+ "This expression does not price each combination exactly once, so it opens as a raw expression. Sparse or custom pricing stays in this editor.": "Это выражение не задаёт цену ровно один раз для каждой комбинации, поэтому оно открыто в режиме исходного выражения. Неполные или пользовательские правила ценообразования остаются в этом редакторе.",
"This expression is too complex for the visual editor. Please switch to expression mode to edit.": "Для визуального редактора это выражение слишком сложно. Переключитесь в режим выражения для правки.",
"This FAQ entry will be removed from the list.": "Эта запись FAQ будет удалена из списка.",
"This feature is experimental. Configuration format and behavior may change.": "Эта функция является экспериментальной. Формат конфигурации и поведение могут измениться.",
@@ -4630,15 +4813,19 @@
"This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "Эта историческая запись была создана до появления функции аудита и не содержит данных аудита. Текущая версия уже поддерживает запись IP-адреса сервера, IP обратного вызова, способа оплаты и версии системы, но эти поля будут заполняться только в новых записях — восполнить их в старых записях задним числом невозможно.",
"This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "Этот идентификатор отправляется в платежный backend при создании заказа. Для Alipay используйте alipay, для WeChat Pay — wxpay, для Stripe — stripe. Пользовательские значения должны поддерживаться вашим платежным провайдером.",
"This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.": "Этот экземпляр использует автоматическое имя хоста. Задайте стабильное уникальное значение NODE_NAME для управления несколькими экземплярами.",
+ "This is a task model billed by usage (e.g. seconds, resolution). Prices entered here act as a per-call base rate, not per-token prices.": "Эта модель задач оплачивается по объёму использования (например, секундам или разрешению). Указанные здесь цены действуют как базовая ставка за вызов, а не как цена за токены.",
"This may cause cache failures.": "Это может привести к сбоям кэша.",
"This may take a few moments while we validate the request and update your session.": "Это может занять несколько мгновений, пока мы проверяем запрос и обновляем вашу сессию.",
"This model has both fixed price and ratio billing conflicts": "Эта модель имеет конфликты как фиксированной цены, так и пропорциональной тарификации",
"This model has both fixed-price and ratio settings. Saving the current mode will rewrite the conflicting fields.": "У этой модели одновременно заданы фиксированная цена и коэффициенты. Сохранение текущего режима перезапишет конфликтующие поля.",
"This model has both fixed-price and token-price settings. Saving the current mode will rewrite the conflicting fields.": "У этой модели одновременно заданы фиксированная цена и цены за токены. Сохранение текущего режима перезапишет конфликтующие поля.",
+ "This model is billed by usage, but the administrator has not configured its pricing yet.": "Эта модель оплачивается по объёму использования, но администратор ещё не настроил её цену.",
"This model is not available in any group, or no group pricing information is configured.": "Эта модель недоступна ни в одной группе, или информация о ценах для групп не настроена.",
"This month": "В этом месяце",
"This page has not been created yet.": "Эта страница еще не создана.",
"This plan does not allow balance redemption": "Этот план не разрешает оплату балансом",
+ "This plugin has no factory fallback. Deleting or disabling it makes this platform unavailable.": "У этого плагина нет встроенной резервной версии. Его удаление или отключение сделает платформу недоступной.",
+ "This plugin path does not resolve within the source repository.": "Этот путь плагина не ведёт внутрь репозитория источника.",
"This project must be used in compliance with the": "Этот проект должен использоваться в соответствии с",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "Это удалит {{count}} неуспешных моделей из этого канала. Действие необратимо.",
"This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "Этот маршрут обнаруживает модели OpenAI вышестоящего сервиса; его нельзя разделять или сопоставлять по правилам клиентских моделей.",
@@ -4646,6 +4833,8 @@
"This route is used only by channel management to query the upstream balance.": "Этот маршрут используется только управлением каналами для запроса баланса поставщика.",
"This session will lose access immediately and must sign in again.": "Этот сеанс немедленно потеряет доступ, и потребуется повторный вход.",
"This site currently has {{count}} models enabled": "На этом сайте сейчас включено моделей: {{count}}",
+ "This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.": "Источник не публикует sha256 для этой версии, поэтому нельзя убедиться, что загруженный код совпадает с опубликованным.",
+ "This source lists no installable task plugins.": "В этом источнике нет плагинов задач, доступных для установки.",
"This Telegram account is already bound.": "Эта учётная запись Telegram уже привязана.",
"This Telegram binding request has expired or has already been used.": "Этот запрос на привязку Telegram истёк или уже был использован.",
"This tier catches any request that did not match earlier tiers.": "Этот уровень обрабатывает все запросы, которые не совпали с предыдущими уровнями.",
@@ -4704,6 +4893,7 @@
"times": "раз",
"Timing": "Время",
"Tip": "Совет",
+ "Tip: after configuring one model, select others in the table and use bulk copy.": "Совет: настроив одну модель, выберите другие в таблице и воспользуйтесь массовым копированием.",
"to access this resource.": "для доступа к этому ресурсу.",
"To Anthropic Messages": "В Anthropic Messages",
"to confirm": "для подтверждения",
@@ -4722,7 +4912,9 @@
"Toggle navigation menu": "Переключить меню навигации",
"Toggle plan": "Переключить план",
"Toggle theme": "Переключить тему",
+ "token": "токен",
"Token": "Токен",
+ "token (unit)": "token",
"Token Breakdown": "Детализация токенов",
"Token Endpoint": "Конечная точка токена",
"Token Endpoint (Optional)": "Конечная точка токена (необязательно)",
@@ -4885,6 +5077,8 @@
"Unexpected release payload": "Неожиданный формат данных релиза",
"Unified API Gateway for": "Единый API-шлюз для",
"Unique identifier for this group.": "Уникальный идентификатор для этой группы.",
+ "unit": "ед.",
+ "Unit": "Единица",
"Unit price (local currency / USD)": "Цена за единицу (местная валюта / USD)",
"Unit price (USD)": "Цена за единицу (USD)",
"Unit price must be greater than 0": "Цена за единицу должна быть больше 0",
@@ -4903,6 +5097,7 @@
"Untrusted upstream data:": "Недоверенные вышестоящие данные:",
"Unused": "Неиспользованные",
"Up to 4 strings that stop generation": "До 4 строк, останавливающих генерацию",
+ "Up to date": "Актуален",
"Update": "Обновить",
"Update All Balances": "Обновить все балансы",
"Update API Key": "Обновить API-ключ",
@@ -4941,15 +5136,26 @@
"Updated user {{username}} (ID: {{id}})": "Обновлён пользователь {{username}} (ID: {{id}})",
"Updating all channel balances. This may take a while. Please refresh to see results.": "Обновление балансов всех каналов. Это может занять некоторое время. Пожалуйста, обновите страницу, чтобы увидеть результаты.",
"Updating...": "Обновление...",
+ "Upgrade {{name}}": "Обновление {{name}}",
+ "Upgrade and enable": "Обновить и включить",
+ "Upgrade available: v{{installed}} to v{{latest}}": "Доступно обновление: с v{{installed}} до v{{latest}}",
"Upgrade Group": "Повысить группу",
"Upgrade plaintext SMTP connection with STARTTLS before authentication": "Перед аутентификацией повысить открытое SMTP-соединение до STARTTLS",
"Upload": "Загрузка",
+ "Upload a JavaScript task platform plugin.": "Загрузите JavaScript-плагин платформы задач.",
"Upload a single service account JSON file": "Загрузите JSON-файл одного сервисного аккаунта",
+ "Upload a task plugin to add a platform.": "Загрузите плагин задач, чтобы добавить платформу.",
"Upload file": "Загрузить файл",
"Upload files": "Загрузить файлы",
"Upload multiple JSON files in batch modes": "Загрузка нескольких файлов JSON в пакетных режимах",
+ "Upload new plugin version": "Загрузить новую версию плагина",
+ "Upload new version": "Загрузить новую версию",
"Upload or reference a local configuration file.": "Загрузить или сослаться на локальный файл конфигурации.",
"Upload photo": "Загрузить фото",
+ "Upload plugin": "Загрузить плагин",
+ "Upload task plugin": "Загрузить плагин задач",
+ "Uploading a plugin is an administrator-level trust decision. A plugin can access channel credentials and shape upstream requests. Review its source and diff before activation.": "Загрузка плагина — решение об уровне доверия администратора. Плагин может получать доступ к данным каналов и формировать запросы к провайдеру. Перед активацией проверьте код и различия.",
+ "Uploading...": "Загрузка...",
"Upscale": "Увеличение",
"Upstream": "Источник",
"Upstream did not return reset credit details.": "Вышестоящий сервис не вернул сведения о сбросах лимита.",
@@ -4975,6 +5181,7 @@
"Upstream Response (billing-usage-openai-estimated)": "Ответ upstream (billing-usage-openai-estimated)",
"Upstream Response (billing-usage-openai)": "Ответ upstream (billing-usage-openai)",
"upstream services integrated": "интеграций с вышестоящими сервисами",
+ "Upstream Task ID": "ID задачи вышестоящего сервиса",
"Upstream Updates": "Обновления вышестоящих моделей",
"Upstream URL": "URL вышестоящего сервиса",
"Upstream URL must be a full URL": "Upstream URL должен быть полным URL",
@@ -4995,7 +5202,11 @@
"Usage logs": "Журналы использования",
"Usage Logs": "Журнал использования",
"Usage mode": "Режим использования",
+ "Usage parameters": "Параметры использования",
+ "Usage prices": "Цены за использование",
"Usage-based": "На основе использования",
+ "Usage-based billing": "Оплата по объёму",
+ "Usage-based billing · price not configured": "Оплата по объёму · цена не настроена",
"USD": "USD",
"USD Exchange Rate": "Обменный курс USD",
"USD price per 1M input tokens.": "Цена в USD за 1 млн входных токенов.",
@@ -5084,6 +5295,7 @@
"Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "Пользователи видят только группы, отмеченные как доступные для выбора. Недоступные для выбора группы всё равно могут назначаться администраторами.",
"uses": "использует",
"Using the complete global Auto order ({{count}} groups)": "Используется полный глобальный порядок Auto (групп: {{count}})",
+ "v{{installed}} is installed but this source does not list it. Installing replaces it with v{{target}}.": "Установлена версия v{{installed}}, но этот источник её не содержит. Установка заменит её на v{{target}}.",
"Validity": "Срок действия",
"Validity Period": "Срок действия",
"Value": "Значение",
@@ -5124,7 +5336,9 @@
"Verify your database connection": "Проверьте подключение к базе данных",
"Verifying credentials and pulling stores from your Pancake account...": "Проверяем учетные данные и загружаем магазины из вашего аккаунта Pancake...",
"Version": "Версия",
+ "Version history": "История версий",
"Version Overrides": "Переопределения версий",
+ "Versions": "Версии",
"Vertex AI": "Vertex AI",
"Vertex AI API Key mode does not support batch creation": "Режим API Key Vertex AI не поддерживает пакетное создание",
"Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.": "Vertex AI не поддерживает functionResponse.id. Включите, чтобы автоматически удалить это поле.",
@@ -5148,6 +5362,7 @@
"View Pricing": "Посмотреть цены",
"View the complete details for this": "Просмотр полных деталей этой",
"View the complete details for this log entry": "Просмотр полной информации об этой записи журнала",
+ "View the complete details for this task": "Просмотреть полные сведения об этой задаче",
"View the complete error message and details": "Просмотр полного сообщения об ошибке и деталей",
"View the complete prompt and its English translation": "Просмотр полного промпта и его перевода на английский",
"View the generated image": "Просмотр сгенерированного изображения",
@@ -5240,6 +5455,8 @@
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "Когда токен использует группу auto, система перебирает группы сверху вниз, пока не найдёт доступную.",
"When billed as {{group}}": "При тарификации по {{group}}",
"When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.": "При совпадении условий итоговая цена умножается на X. Несколько совпадений умножаются вместе; значения < 1 действуют как скидки.",
+ "When disabled, all uploaded custom plugins are ignored and every platform falls back to its built-in factory plugin.": "При отключении все загруженные пользовательские плагины игнорируются, а каждая платформа использует встроенный заводской плагин.",
+ "When disabled, the entire task plugin system stops serving, including factory and custom plugins.": "При отключении вся система плагинов задач перестаёт обслуживать запросы, включая заводские и пользовательские плагины.",
"When enabled, if channels in the current group fail, it will try channels in the next group in order.": "Если включено, при сбое каналов в текущей группе система попробует каналы следующей группы по порядку.",
"When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. Leave it off to delete the entry and select another channel.": "Если включено, запись привязки сохраняется, даже когда привязанный канал отключён или больше не подходит для текущей группы/модели. Оставьте выключенным, чтобы удалять запись и выбирать другой канал.",
"When enabled, large request bodies are temporarily stored on disk instead of memory, significantly reducing memory usage. SSD recommended.": "При включении большие тела запросов временно сохраняются на диске, что значительно снижает использование памяти. Рекомендуется SSD.",
diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json
index 53e2ab47e471..7e1bc1ea31e6 100644
--- a/web/src/i18n/locales/vi.json
+++ b/web/src/i18n/locales/vi.json
@@ -29,7 +29,9 @@
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]": "[{\"name\":\"Alipay\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]",
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]": "[{\"name\":\"Alipay\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]",
"{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
+ "{{bytes}} bytes": "{{bytes}} byte",
"{{category}} Models": "Mô hình {{category}}",
+ "{{channels}} channels, {{tasks}} in-flight tasks": "{{channels}} kênh, {{tasks}} tác vụ đang chạy",
"{{completed}}/{{total}} completed": "Đã hoàn tất {{completed}}/{{total}}",
"{{count}} / {{max}} groups selected": "Đã chọn {{count}} / {{max}} nhóm",
"{{count}} announcements will be removed from the list.": "{{count}} thông báo sẽ bị xóa khỏi danh sách.",
@@ -39,9 +41,11 @@
"{{count}} channel(s) enabled": "Đã bật {{count}} kênh",
"{{count}} channel(s) failed to disable": "{{count}} kênh không thể tắt",
"{{count}} channel(s) failed to enable": "{{count}} kênh không thể bật",
+ "{{count}} combinations": "{{count}} tổ hợp",
"{{count}} days ago": "{{count}} ngày trước",
"{{count}} days remaining": "{{count}} days remaining",
"{{count}} disabled channel(s) deleted": "Đã xóa {{count}} kênh đã tắt",
+ "{{count}} enabled channels and {{tasks}} in-flight tasks still use this plugin.": "{{count}} kênh đang bật và {{tasks}} tác vụ đang chạy vẫn dùng plugin này.",
"{{count}} FAQ entries will be removed from the list.": "{{count}} mục FAQ sẽ bị xóa khỏi danh sách.",
"{{count}} hours ago": "{{count}} giờ trước",
"{{count}} incidents": "{{count}} sự cố",
@@ -60,6 +64,7 @@
"{{count}} weeks ago": "{{count}} tuần trước",
"{{field}} updated to {{value}}": "{{field}} đã cập nhật thành {{value}}",
"{{field}} updated to {{value}} for tag: {{tag}}": "{{field}} đã cập nhật thành {{value}} cho nhãn: {{tag}}",
+ "{{key}} · version {{version}} · from {{source}}": "{{key}} · phiên bản {{version}} · từ {{source}}",
"{{method}} {{route}}": "{{method}} {{route}}",
"{{modality}} not supported": "Không hỗ trợ {{modality}}",
"{{modality}} supported": "Hỗ trợ {{modality}}",
@@ -104,6 +109,7 @@
"14 Days": "14 ngày",
"192.168.1.1
10.0.0.0/8": "192.168.1.1
10.0.0.0/8",
"1M": "1M",
+ "1M token": "1M token",
"1W": "1W",
"2. Copy the application token": "2. Sao chép token ứng dụng",
"20 / page": "20 / trang",
@@ -147,6 +153,7 @@
"Action": "Hành động",
"Action confirmation": "Xác nhận hành động",
"Actions": "Hành động",
+ "Activate / Roll back": "Kích hoạt / Khôi phục",
"active": "hoạt động",
"Active": "Hoạt động",
"Active apps": "Ứng dụng đang hoạt động",
@@ -155,6 +162,7 @@
"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",
+ "Active version": "Phiên bản đang hoạt động",
"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ế",
@@ -171,6 +179,7 @@
"Add a new user by providing necessary info.": "Thêm người dùng mới bằng cách cung cấp thông tin cần thiết.",
"Add a new vendor to the system": "Thêm một nhà cung cấp mới vào hệ thống",
"Add an extra layer of security to your account": "Thêm một lớp bảo mật bổ sung cho tài khoản của bạn",
+ "Add an index URL to browse installable plugins.": "Thêm một URL chỉ mục để xem các plugin có thể cài đặt.",
"Add and submit": "Thêm và gửi",
"Add Announcement": "Thêm Thông báo",
"Add API": "Thêm API",
@@ -217,6 +226,7 @@
"Add rule group": "Thêm nhóm quy tắc",
"Add rules for a user group": "Thêm quy tắc cho nhóm người dùng",
"Add selectable group": "Thêm nhóm có thể chọn",
+ "Add source": "Thêm nguồn",
"Add split": "Thêm nhánh",
"Add subscription": "Thêm đăng ký",
"Add tags...": "Thêm thẻ...",
@@ -292,6 +302,7 @@
"All": "All",
"All API tokens": "Tất cả khóa API",
"All categories": "Tất cả danh mục",
+ "All combinations are priced at zero. Matching requests will be billed as free.": "Tất cả tổ hợp đều có giá bằng 0. Các yêu cầu khớp sẽ không phát sinh phí.",
"All conditions must match before this tier is used.": "Tất cả điều kiện phải khớp trước khi tầng này được sử dụng.",
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "Tất cả các chỉnh sửa đều là thao tác ghi đè. Để trống các trường để giữ nguyên giá trị hiện tại.",
"All files exceed the maximum size.": "Tất cả các tệp vượt quá kích thước tối đa.",
@@ -348,6 +359,7 @@
"Allow using models without price configuration": "Cho phép sử dụng mô hình không có cấu hình giá",
"Allow wallet balance after quota used up": "Cho phép dùng số dư ví sau khi dùng hết hạn ngạch",
"Allowed": "Cho phép",
+ "Allowed hosts": "Máy chủ được phép",
"Allowed Origins": "Nguồn gốc được phép",
"Allowed Ports": "Cổng được phép",
"Already have an account?": "Đã có tài khoản?",
@@ -380,6 +392,7 @@
"Anthropic": "Anthropic",
"Anthropic Messages to OpenAI Chat": "Anthropic Messages sang OpenAI Chat",
"Any Match (OR)": "Bất kỳ khớp (OR)",
+ "Anyone can publish an index. A plugin installed from a third-party source has the same access as one you upload by hand: review its source before installing.": "Bất kỳ ai cũng có thể công bố một chỉ mục. Plugin cài từ nguồn bên thứ ba có quyền truy cập hệt như plugin bạn tự tải lên: hãy xem mã nguồn trước khi cài.",
"API": "API",
"API Access": "Truy cập API",
"API Addresses": "Địa chỉ API",
@@ -414,6 +427,8 @@
"API token management": "Quản lý token API",
"API URL": "API URL",
"API usage records": "Lịch sử sử dụng API",
+ "API version": "Phiên bản API",
+ "API Version": "Phiên bản API",
"API2GPT": "API2GPT",
"App": "Ứng dụng",
"App rankings shown here are simulated for preview purposes and will be replaced with live usage data once the backend integration is complete.": "Bảng xếp hạng ứng dụng hiển thị tại đây là dữ liệu mô phỏng để xem trước và sẽ được thay thế bằng dữ liệu thực sau khi tích hợp backend.",
@@ -437,8 +452,10 @@
"Apply plan": "Áp dụng cấu hình",
"Apply reset": "Thực hiện đặt lại",
"Apply Sync": "Áp dụng đồng bộ",
+ "Apply to all rows": "Áp dụng cho tất cả hàng",
"Applying...": "Đang áp dụng...",
"Approx.": "Xấp xỉ.",
+ "Approximate prices for common specs.": "Giá tham khảo cho các cấu hình phổ biến.",
"apps": "ứng dụng",
"Apps": "Ứng dụng",
"apps tracked": "ứng dụng được theo dõi",
@@ -461,12 +478,17 @@
"Are you sure?": "Bạn có chắc không?",
"Area Chart": "Biểu đồ vùng",
"Args (space separated)": "Đối số (cách nhau bằng khoảng trắng)",
+ "Arguments JSON": "Đối số JSON",
+ "Arguments must be a JSON array": "Đối số phải là một mảng JSON",
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Mảng các thiết lập sẵn của ứng dụng trò chuyện. Mỗi mục là một đối tượng với",
+ "Artifacts": "Sản phẩm",
"Asc": "Asc",
"Ask anything": "Hỏi gì cũng được",
"Assigned by administrator only": "Chỉ quản trị viên gán",
"Assigned by administrators and used to represent a user level, such as default or vip.": "Do quản trị viên gán và dùng để biểu thị cấp người dùng, ví dụ default hoặc vip.",
+ "Async": "Bất đồng bộ",
"Async task polling": "Thăm dò tác vụ bất đồng bộ",
+ "Async Task Public Address": "Địa chỉ công khai của tác vụ bất đồng bộ",
"Async task refund": "Hoàn tiền tác vụ bất đồng bộ",
"At least one model regex pattern is required": "Cần ít nhất một mẫu regex mô hình",
"At least one valid key source is required": "Cần ít nhất một nguồn khóa hợp lệ",
@@ -585,8 +607,10 @@
"Balance updated: {{balance}}": "Số dư đã cập nhật: {{balance}}",
"Bar Chart": "Biểu đồ cột",
"Bark Push URL": "URL đẩy Bark",
+ "Base": "Cơ bản",
"Base address provided by your Epay service": "Địa chỉ cơ sở được cung cấp bởi dịch vụ Epay của bạn",
"Base amount. Actual deduction = base amount × system group rate.": "Số tiền cơ sở. Số tiền trừ thực tế = số tiền cơ sở × tỷ lệ nhóm hệ thống.",
+ "Base charge": "Phí cơ bản",
"Base input and output token prices for this tier.": "Giá token đầu vào và đầu ra cơ bản cho tầng này.",
"Base input price only": "Chỉ có giá đầu vào cơ bản",
"Base Limits": "Giới hạn cơ bản",
@@ -594,6 +618,7 @@
"Base Price": "Giá cơ bản",
"Base rate limit windows for this account.": "Cửa sổ giới hạn tốc độ cơ bản cho tài khoản này.",
"Base URL": "URL cơ sở",
+ "Base URL *": "URL cơ sở *",
"Base URL is required for this channel type": "Loại kênh này yêu cầu Base URL",
"Base URL is required when an advanced route uses an upstream path": "Cần Base URL khi tuyến nâng cao dùng đường dẫn upstream",
"Base URL of your Uptime Kuma instance": "URL cơ sở của phiên bản Uptime Kuma của bạn",
@@ -638,6 +663,7 @@
"Billing group = vip (the token has no group, so use the user group)": "Nhóm tính phí = vip (token không có nhóm nên dùng nhóm người dùng)",
"Billing History": "Lịch sử thanh toán",
"Billing Mode": "Chế độ thanh toán",
+ "Billing parameters": "Tham số tính phí",
"Billing Path": "Đường dẫn tính phí",
"Billing Process": "Quá trình tính phí",
"Billing rule: each call is billed as the token group (falling back to the user group when the token has none). The base ratio always comes from that billing group, not from the user group. To give a user group a special price on another billing group, add an entry in the override matrix.": "Quy tắc tính phí: mỗi cuộc gọi được tính phí theo nhóm token (nếu token không có nhóm thì dùng nhóm người dùng). Hệ số cơ bản luôn lấy từ nhóm tính phí đó, không phải từ nhóm người dùng. Để cho một nhóm người dùng giá đặc biệt trên nhóm tính phí khác, hãy thêm mục vào ma trận ghi đè.",
@@ -648,6 +674,7 @@
"Bind Email": "Liên kết Email",
"Bind Telegram Account": "Liên kết tài khoản Telegram",
"Bind WeChat Account": "Liên kết tài khoản WeChat",
+ "Bind task plugins": "Gắn plugin tác vụ",
"Binding Information": "Thông tin Ràng buộc",
"Binding successful!": "Liên kết thành công!",
"Binding your {{provider}} account": "Đang liên kết tài khoản {{provider}} của bạn",
@@ -686,6 +713,7 @@
"Built for developers,": "Được xây dựng cho nhà phát triển,",
"Built-in": "Tích hợp sẵn",
"Built-in Device": "Thiết bị tích hợp",
+ "Built-in v{{factory}} / marketplace v{{market}}": "Tích hợp v{{factory}} / chợ v{{market}}",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Tích hợp sẵn: vân tay/khuôn mặt điện thoại, hoặc Windows Hello; Bên ngoài: khóa bảo mật USB",
"by": "by",
"By category": "Theo danh mục",
@@ -738,6 +766,7 @@
"Caps the response length": "Giới hạn độ dài phản hồi",
"Capture a reusable bundle of models, tags, or endpoints.": "Đóng gói một bộ có thể tái sử dụng gồm các mô hình, thẻ hoặc điểm cuối.",
"Card view": "Dạng thẻ",
+ "Cascade disable channels": "Tắt kèm các kênh",
"Catch-all route must be last for the same incoming path": "Tuyến dự phòng phải đứng cuối cho cùng đường dẫn đầu vào",
"Category": "Danh mục",
"Category Name": "Tên danh mục",
@@ -779,7 +808,9 @@
"Channel test concurrency": "Mức đồng thời khi kiểm tra kênh",
"Channel test concurrency must be between 1 and 32": "Mức đồng thời khi kiểm tra kênh phải từ 1 đến 32",
"Channel test mode": "Chế độ kiểm tra kênh",
+ "Channel type": "Loại kênh",
"Channel type is required": "Loại kênh là bắt buộc",
+ "Channel types": "Loại kênh",
"Channel updated successfully": "Kênh đã được cập nhật thành công",
"Channel-specific settings (JSON format)": "Cài đặt dành riêng cho kênh (định dạng JSON)",
"Channel:": "Kênh:",
@@ -950,6 +981,7 @@
"Compare the most popular models on the platform": "So sánh các mô hình phổ biến nhất trên nền tảng",
"compatible API routes": "tuyến API tương thích",
"Compatible API routes for common AI application workflows": "Các tuyến API tương thích cho quy trình ứng dụng AI phổ biến",
+ "Compilation failed": "Biên dịch thất bại",
"Complete API documentation with multi-language SDK support": "Tài liệu API đầy đủ với hỗ trợ SDK đa ngôn ngữ",
"Complete Order": "Hoàn thành đơn hàng",
"Complete these steps to finish the initial installation.": "Hoàn thành các bước này để hoàn tất quá trình cài đặt ban đầu.",
@@ -1000,6 +1032,7 @@
"Configure pricing ratios for a specific model.": "Cấu hình tỷ lệ định giá cho một mô hình cụ thể.",
"Configure rate limiting rules for a specific user group.": "Cấu hình quy tắc giới hạn tốc độ cho một nhóm người dùng cụ thể.",
"Configure routes": "Cấu hình route",
+ "Configure task pricing": "Cấu hình giá tác vụ",
"Configure the ratio for this group.": "Cấu hình tỷ lệ cho nhóm này.",
"Configure upstream providers and routing.": "Cấu hình nhà cung cấp upstream và định tuyến.",
"Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "Cấu hình tích hợp thanh toán Waffo Pancake (hosted checkout) cho nạp tiền theo USD",
@@ -1143,6 +1176,10 @@
"Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "Chi phí = giá mô hình × đúng một hệ số đó. Không có mục nào khác trong cài đặt nhóm tham gia công thức.",
"Cost in USD per request, regardless of tokens used.": "Chi phí bằng USD cho mỗi yêu cầu, bất kể số lượng token được sử dụng.",
"Cost Tracking": "Theo dõi chi phí",
+ "Could not fetch the plugin source from this browser. The host may block cross-origin requests or be unreachable.": "Không thể tải mã nguồn plugin từ trình duyệt này. Máy chủ có thể chặn yêu cầu cross-origin hoặc không truy cập được.",
+ "Could not fetch this URL from the browser. The host may block cross-origin requests or be unreachable. Download the file and paste its source below.": "Không thể tải URL này từ trình duyệt. Máy chủ có thể chặn yêu cầu cross-origin hoặc không truy cập được. Hãy tải tệp về và dán mã nguồn vào bên dưới.",
+ "Could not load this source": "Không thể tải nguồn này",
+ "Count": "Số lượng",
"Count must be between {{min}} and {{max}}": "Số lượng phải nằm trong khoảng từ {{min}} đến {{max}}.",
"Coze": "Coze",
"CPU": "CPU",
@@ -1199,6 +1236,7 @@
"Credentials": "Thông tin xác thực",
"Credentials verification failed": "Xác minh thông tin xác thực thất bại",
"Credentials verification failed — double-check Merchant ID and API private key.": "Xác minh thông tin xác thực thất bại — hãy kiểm tra lại Merchant ID và khóa riêng API.",
+ "credit": "credit",
"Credit remaining": "Tín dụng còn lại",
"Creem API key (leave blank unless updating)": "Khóa API Creem (để trống trừ khi cập nhật)",
"Creem Gateway": "Cổng Creem",
@@ -1226,6 +1264,7 @@
"Current version": "Phiên bản hiện tại",
"Current:": "Hiện tại:",
"Custom": "Tùy chỉnh",
+ "Custom (overrides factory {{version}})": "Tùy chỉnh (ghi đè bản tích hợp {{version}})",
"Custom (seconds)": "Tùy chỉnh (giây)",
"Custom Amount": "Số tiền tùy chỉnh",
"Custom API base URL. For official channels, New API has built-in addresses. Only fill this for third-party proxy sites or special endpoints. Do not add /v1 or trailing slash.": "URL cơ sở API tùy chỉnh. Đối với các kênh chính thức, New API có địa chỉ tích hợp sẵn. Chỉ điền thông tin này cho các trang proxy của bên thứ ba hoặc các điểm cuối đặc biệt. Không thêm /v1 hoặc dấu gạch chéo cuối cùng.",
@@ -1245,6 +1284,7 @@
"Custom OAuth Providers": "Nhà cung cấp OAuth tùy chỉnh",
"Custom Seconds": "Giây tùy chỉnh",
"Custom sidebar section": "Phần thanh bên tùy chỉnh",
+ "Custom task plugin setting updated": "Đã cập nhật cài đặt plugin tác vụ tùy chỉnh",
"Custom Time Range": "Khoảng thời gian tùy chỉnh",
"Custom Zoom": "Thu phóng tùy chỉnh",
"Customize sidebar display content": "Tùy chỉnh nội dung hiển thị thanh bên",
@@ -1274,6 +1314,7 @@
"Days to Retain": "Số ngày giữ lại",
"decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.": "quyết định hệ số nạp tiền, các nhóm người dùng có thể chọn cho token, và có áp dụng hệ số ghi đè hay không.",
"decides which channels are used and which base ratio applies.": "quyết định dùng kênh nào và áp dụng hệ số cơ bản nào.",
+ "Declared capabilities": "Quyền được khai báo",
"Decreased user quota by {{quota}}": "Đã giảm hạn mức người dùng {{quota}}",
"Deducted by subscription": "Khấu trừ bởi gói đăng ký",
"DeepSeek": "DeepSeek",
@@ -1306,6 +1347,7 @@
"Delete {{count}} stale instance records? Online instances will not be deleted.": "Xóa {{count}} bản ghi phiên bản mất kết nối? Các phiên bản đang trực tuyến sẽ không bị xóa.",
"Delete a runtime request header": "Xóa header yêu cầu runtime",
"Delete Account": "Xóa tài khoản",
+ "Delete active custom version": "Xóa phiên bản tùy chỉnh đang hoạt động",
"Delete All Disabled": "Xóa Tất Cả Đã Tắt",
"Delete All Disabled Channels?": "Xóa tất cả kênh đã vô hiệu hóa?",
"Delete all stale": "Xóa tất cả mất kết nối",
@@ -1329,6 +1371,7 @@
"Delete mapping": "Xóa ánh xạ",
"Delete Model": "Xóa Mô hình",
"Delete Models?": "Xóa mô hình?",
+ "Delete plugin version?": "Xóa phiên bản plugin?",
"Delete Provider": "Xóa nhà cung cấp",
"Delete Request Header": "Xóa header yêu cầu",
"Delete selected API keys": "Xóa các khóa API đã chọn",
@@ -1355,6 +1398,7 @@
"Deleted stale instance": "Đã xóa phiên bản mất kết nối",
"Deleted successfully": "Xóa thành công",
"Deleted user {{username}} (ID: {{id}})": "Đã xóa người dùng {{username}} (ID: {{id}})",
+ "Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.": "Xóa phiên bản tùy chỉnh này không làm vô hiệu nền tảng. Plugin tích hợp cùng tên sẽ tự động được khôi phục.",
"Deleting will permanently remove this subscription record (including benefit details). Continue?": "Xóa sẽ xóa vĩnh viễn bản ghi đăng ký này (bao gồm chi tiết quyền lợi). Tiếp tục?",
"Deleting...": "Đang xóa...",
"Demo site": "Trang demo",
@@ -1401,6 +1445,8 @@
"Disable": "Vô hiệu hóa",
"Disable 2FA": "Tắt 2FA",
"Disable All": "Vô hiệu hóa tất cả",
+ "Disable custom task plugins?": "Tắt plugin tác vụ tùy chỉnh?",
+ "Disable task plugins?": "Tắt plugin tác vụ?",
"Disable on failure": "Vô hiệu hóa khi lỗi",
"Disable selected channels": "Vô hiệu hóa các kênh đã chọn",
"Disable selected models": "Vô hiệu hóa các mô hình đã chọn",
@@ -1415,6 +1461,8 @@
"Disabled lanes are omitted on save.": "Các kênh bị tắt sẽ được bỏ qua khi lưu.",
"Disabled Reason": "Lý do vô hiệu hóa",
"Disabled Time": "Thời gian vô hiệu hóa",
+ "Disabled; fell back to factory": "Đã tắt; dùng lại bản tích hợp",
+ "Disabled; platform unavailable": "Đã tắt; nền tảng không khả dụng",
"Disabling...": "Đang vô hiệu hóa...",
"Disclaimer: Personal use only. Do not distribute or share any credentials. This channel has prerequisites and requires prior setup; use it only if you understand the flow and risks, and comply with OpenAI's terms and policies. Credentials and configuration are for Codex CLI integration only, and are not intended for any other client, platform, or channel.": "Tuyên bố miễn trừ: Chỉ dùng cho mục đích cá nhân. Không phân phối hoặc chia sẻ bất kỳ thông tin xác thực nào. Kênh này có điều kiện tiên quyết và yêu cầu thiết lập trước; chỉ sử dụng khi bạn hiểu rõ quy trình và rủi ro, và tuân thủ điều khoản và chính sách của OpenAI. Thông tin xác thực và cấu hình chỉ dành cho tích hợp Codex CLI, không áp dụng cho các client, nền tảng hoặc kênh khác.",
"Discord": "Discord",
@@ -1480,6 +1528,7 @@
"Drawing Logs": "Nhật ký bản vẽ",
"Drawing task polling": "Thăm dò tác vụ vẽ",
"Drawing task records": "Lịch sử tác vụ vẽ",
+ "Dry run result": "Kết quả chạy thử",
"Duplicate": "Nhân bản",
"Duplicate group names: {{names}}": "Tên nhóm bị trùng: {{names}}",
"Duplicate model in route models": "Mô hình bị lặp trong danh sách mô hình tuyến",
@@ -1540,7 +1589,9 @@
"Each item must have exactly one key-value pair.": "Mỗi mục phải có chính xác một cặp khóa-giá trị.",
"Each line represents one keyword. Leave blank to disable the list but keep the switch states.": "Mỗi dòng đại diện cho một từ khóa. Để trống để tắt danh sách nhưng vẫn giữ trạng thái công tắc.",
"Each matrix cell is one rule: users of this row group pay this ratio when billed as this column group. In JSON the row is the outer key and the column is the inner key.": "Mỗi ô ma trận là một quy tắc: người dùng của nhóm hàng trả hệ số này khi được tính phí theo nhóm cột. Trong JSON, hàng là khóa ngoài và cột là khóa trong.",
+ "Each row prices one combination of {{fields}}.": "Mỗi hàng định giá cho một tổ hợp {{fields}}.",
"Each rule reads as a sentence: users of one group pay a special ratio when billed as another group. Without a rule, the billing group base ratio applies.": "Mỗi quy tắc đọc như một câu: người dùng của một nhóm trả hệ số đặc biệt khi được tính phí theo nhóm khác. Không có quy tắc thì áp dụng hệ số cơ bản của nhóm tính phí.",
+ "Each source serves an index.json listing installable plugins. Indexes are fetched by your browser; the gateway makes no outbound requests.": "Mỗi nguồn cung cấp một tệp index.json liệt kê các plugin có thể cài đặt. Chỉ mục do trình duyệt của bạn tải; cổng không gửi bất kỳ yêu cầu ra ngoài nào.",
"Each tier supports 0~2 conditions (over len, p, c); the last tier is the catch-all without conditions. Use len (full input length, including cache hits) for tier conditions to avoid mis-routing when cache hits reduce p.": "Mỗi bậc hỗ trợ 0~2 điều kiện (đối với len, p, c); bậc cuối là bậc dự phòng không cần điều kiện. Hãy dùng len (độ dài đầu vào đầy đủ, bao gồm cả cache hits) cho điều kiện bậc để tránh định tuyến sai khi cache hits làm giảm p.",
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "Mỗi tầng hỗ trợ tối đa 2 điều kiện; tầng cuối cùng là tầng dự phòng không có điều kiện. Hãy dùng độ dài đầu vào đầy đủ cho điều kiện tầng để tránh chọn sai tầng khi cache hit làm giảm token đầu vào tính phí.",
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "Mỗi tầng hỗ trợ tối đa 2 điều kiện. Tầng cuối cùng không có điều kiện là tầng dự phòng.",
@@ -1598,6 +1649,8 @@
"Enable 2FA": "Bật 2FA",
"Enable All": "Bật tất cả",
"Enable check-in feature": "Bật tính năng điểm danh",
+ "Enable custom task plugins": "Bật plugin tác vụ tùy chỉnh",
+ "Enable task plugins": "Bật plugin tác vụ",
"Enable Data Dashboard": "Kích hoạt Trang tổng quan Dữ liệu",
"Enable demo mode with limited functionality": "Bật chế độ demo với chức năng hạn chế",
"Enable Discord OAuth": "Bật Discord OAuth",
@@ -1617,6 +1670,7 @@
"Enable or disable this model": "Bật hoặc tắt mô hình này",
"Enable Passkey": "Bật khóa truy cập",
"Enable Performance Monitoring": "Bật giám sát hiệu suất",
+ "Enable plugin {{key}}": "Bật plugin {{key}}",
"Enable rate limiting": "Bật giới hạn tốc độ",
"Enable Request Passthrough": "Bật Truyền qua Yêu cầu",
"Enable selected channels": "Kích hoạt các kênh đã chọn",
@@ -1670,6 +1724,8 @@
"Enter a value and press Enter": "Nhập giá trị và nhấn Enter",
"Enter amount in {{currency}}": "Nhập số tiền bằng {{currency}}",
"Enter amount in tokens": "Nhập số lượng token",
+ "Enter an absolute HTTP(S) URL without credentials, query parameters, or fragments": "Nhập URL HTTP(S) tuyệt đối không chứa thông tin xác thực, tham số truy vấn hoặc phân mảnh",
+ "Enter an absolute http(s) URL.": "Hãy nhập URL http(s) đầy đủ.",
"Enter announcement content (supports Markdown & HTML)": "Nhập nội dung thông báo (hỗ trợ Markdown & HTML)",
"Enter announcement content (supports Markdown/HTML)": "Nhập nội dung thông báo (hỗ trợ Markdown/HTML)",
"Enter API Key": "Nhập khóa API",
@@ -1733,6 +1789,9 @@
"Enterprise Account": "Business account",
"Enterprise-grade security with comprehensive permission management": "Bảo mật cấp doanh nghiệp với quản lý quyền toàn diện",
"Entrypoint (space separated)": "Entrypoint (cách nhau bằng dấu cách)",
+ "Enum": "Liệt kê",
+ "Boolean": "Boolean",
+ "Enum values": "Các giá trị liệt kê",
"Env (JSON object)": "Env (đối tượng JSON)",
"Environment variables": "Biến môi trường",
"Environment variables (JSON)": "Biến môi trường (JSON)",
@@ -1764,6 +1823,8 @@
"Example": "Ví dụ",
"Example (all channels):": "Ví dụ (tất cả kênh):",
"Example (specific channels):": "Ví dụ (kênh cụ thể):",
+ "Example price": "Giá ví dụ",
+ "Example spec": "Thông số ví dụ",
"Example:": "Ví dụ:",
"example.com
blocked-site.com": "example.com\nblocked-site.com",
"example.com
company.com": "example.com\ncompany.com",
@@ -1792,6 +1853,7 @@
"Expose ratio API": "Cung cấp API tỷ lệ",
"Exposes the pricing/models catalog in the top navigation.": "Hiển thị danh mục giá/mô hình trên thanh điều hướng đầu trang.",
"Expression": "Biểu thức",
+ "Expression - Task pricing": "Biểu thức - Giá tác vụ",
"Expression based": "Dựa trên biểu thức",
"Expression billing": "Tính phí biểu thức",
"Expression editor": "Trình sửa biểu thức",
@@ -1811,6 +1873,9 @@
"Extra visible": "Hiển thị thêm",
"Extra visible to {{group}}": "Hiển thị thêm cho {{group}}",
"extras": "mục bổ sung",
+ "Factory": "Tích hợp",
+ "Factory and custom plugin behavior": "Cách hoạt động của plugin tích hợp và tùy chỉnh",
+ "Factory plugins cannot be deleted or disabled individually. A custom version can override them; deleting or disabling that version restores the factory plugin. Third-party-only platforms become unavailable when their plugin is deleted or disabled.": "Không thể xóa hoặc tắt riêng plugin tích hợp. Phiên bản tùy chỉnh có thể ghi đè; xóa hoặc tắt phiên bản đó sẽ khôi phục plugin tích hợp. Nền tảng chỉ có plugin bên thứ ba sẽ không khả dụng khi plugin bị xóa hoặc tắt.",
"Fail Reason": "Lý do thất bại",
"Fail Reason Details": "Chi tiết lý do thất bại",
"failed": "thất bại",
@@ -1879,6 +1944,7 @@
"Failed to initialize system": "Không thể khởi tạo hệ thống",
"Failed to load": "Tải thất bại",
"Failed to load API keys": "Không thể tải khóa API",
+ "Failed to load artifacts": "Không thể tải sản phẩm",
"Failed to load billing history": "Không thể tải lịch sử thanh toán",
"Failed to load enabled models": "Không thể tải các mô hình đã bật",
"Failed to load home page content": "Không thể tải nội dung trang chủ",
@@ -1968,15 +2034,20 @@
"Feature in development": "Tính năng đang phát triển",
"Fee": "Phí",
"Fee Amount": "Số tiền phí",
+ "Fetch": "Tải về",
"Fetch available models for:": "Tìm nạp các mô hình khả dụng cho:",
"Fetch available models from upstream": "Lấy các mô hình khả dụng từ nguồn trên",
"Fetch from Upstream": "Lấy từ nguồn",
"Fetch Models": "Tìm nạp Mô hình",
+ "Fetch mode": "Chế độ lấy dữ liệu",
"Fetched {{count}} model(s) from upstream": "Đã lấy {{count}} mô hình từ upstream",
"Fetched {{count}} models": "Đã lấy {{count}} mô hình",
+ "Fetched in your browser and placed in the source field below for review. GitHub and gist page URLs are rewritten to their raw URL automatically.": "Được trình duyệt tải về và đưa vào ô mã nguồn bên dưới để bạn xem lại. URL trang GitHub và gist sẽ tự động được đổi thành URL raw.",
+ "Fetching plugin source...": "Đang tải mã nguồn plugin…",
"Fetching prefill groups...": "Đang tải nhóm điền sẵn...",
"Fetching upstream prices...": "Đang lấy giá upstream...",
"Fetching upstream ratios...": "Đang lấy tỷ lệ thượng nguồn...",
+ "Fetching...": "Đang tải…",
"field": "trường",
"Field Mapping": "Ánh Xạ Trường",
"Field passthrough controls": "Điều khiển chuyển tiếp trường",
@@ -1986,6 +2057,7 @@
"Files to Retain": "Số tệp giữ lại",
"Fill All Models": "Điền Tất Cả Mô Hình",
"Fill Codex CLI / Claude CLI Templates": "Điền mẫu Codex CLI / Claude CLI",
+ "Fill entire column": "Điền toàn bộ cột",
"Fill example (all channels)": "Điền ví dụ (tất cả kênh)",
"Fill example (specific channels)": "Điền ví dụ (kênh cụ thể)",
"Fill in": "Điền",
@@ -2025,6 +2097,7 @@
"Filter models by provider, group, type, endpoint, and tags.": "Lọc mô hình theo nhà cung cấp, nhóm, loại, endpoint và thẻ.",
"Filter models by type, endpoint, vendor, group and tags": "Lọc mô hình theo loại, endpoint, nhà cung cấp, nhóm và thẻ",
"Filter models...": "Lọc mô hình...",
+ "Filter plugins...": "Lọc plugin...",
"Filter the model analytics view by time range and user.": "Lọc chế độ xem phân tích mô hình theo khoảng thời gian và người dùng.",
"Filter the traffic flow view by time range and user.": "Lọc chế độ xem luồng lưu lượng theo khoảng thời gian và người dùng.",
"Filter...": "Lọc...",
@@ -2078,6 +2151,7 @@
"Force Format": "Buộc định dạng",
"Force format response to OpenAI standard (OpenAI channel only)": "Buộc định dạng phản hồi theo tiêu chuẩn OpenAI (chỉ kênh OpenAI)",
"Force JSON object or schema-conforming output": "Bắt buộc xuất JSON hoặc theo schema",
+ "Force operation": "Buộc thực hiện",
"Force SMTP authentication using AUTH LOGIN method": "Bắt buộc xác thực SMTP sử dụng phương thức AUTH LOGIN",
"Force-disabled two-factor authentication for the user": "Đã buộc tắt xác thực hai yếu tố của người dùng",
"Forest Whisper": "Tiếng thì thầm rừng cây",
@@ -2251,6 +2325,7 @@
"Home": "Trang chủ",
"Home Page Content": "Nội dung Trang chủ",
"Homepage URL": "URL trang chủ",
+ "Hook": "Hook",
"Hostname or IP of your SMTP provider": "Tên máy chủ hoặc IP của nhà cung cấp SMTP của bạn",
"Hour": "Giờ",
"Hour of day": "Giờ trong ngày",
@@ -2329,6 +2404,7 @@
"Image to Video": "Ảnh sang video",
"Image Tokens": "Token hình ảnh",
"Imagine the pricing table has three groups: default (ratio 1.0), premium (ratio 0.5), and vip (ratio 0.8). Users whose account is in the vip group get user-level perks, and premium is a cheaper channel pool that users can pick for their tokens.": "Giả sử bảng định giá có ba nhóm: default (hệ số 1.0), premium (hệ số 0.5) và vip (hệ số 0.8). Người dùng có tài khoản thuộc nhóm vip nhận ưu đãi cấp người dùng, còn premium là một nhóm kênh rẻ hơn mà người dùng có thể chọn cho token của mình.",
+ "Import from URL": "Nhập từ URL",
"Import to CC Switch": "Nhập vào CC Switch",
"Important": "Quan trọng",
"In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "Trong JSON, khóa ngoài là nhóm người dùng, khóa trong là nhóm tính phí. Ví dụ dưới đây nghĩa là: người dùng vip trả 0.8 khi tính phí theo standard và 0.3 khi theo premium.",
@@ -2352,6 +2428,8 @@
"Incomplete": "Chưa hoàn tất",
"Increased user quota by {{quota}}": "Đã tăng hạn mức người dùng thêm {{quota}}",
"Index": "Chỉ mục",
+ "Index request failed with HTTP {{status}}": "Yêu cầu chỉ mục thất bại với HTTP {{status}}",
+ "Index URL": "URL chỉ mục",
"Inherit global Auto order": "Kế thừa thứ tự Auto toàn cục",
"Initial quota given to new users": "Hạn mức ban đầu cấp cho người dùng mới",
"Initial quota given to new users ({{formattedQuota}})": "Hạn mức ban đầu cấp cho người dùng mới ({{formattedQuota}})",
@@ -2369,10 +2447,21 @@
"Inset": "Khung trong",
"Inspect requests, errors, and billing details": "Kiểm tra yêu cầu, lỗi và chi tiết thanh toán",
"Inspect user prompts": "Kiểm tra lời nhắc của người dùng",
+ "Install": "Cài đặt",
+ "Install {{name}}": "Cài đặt {{name}}",
+ "Install and enable": "Cài đặt và bật",
+ "Installed": "Đã cài đặt",
+ "Installed {{name}} v{{version}}": "Đã cài {{name}} v{{version}}",
+ "Installed v{{from}} → marketplace v{{to}}": "Đã cài v{{from}} → chợ v{{to}}",
+ "Installed v{{installed}} not listed": "Đã cài v{{installed}}, không có trong chỉ mục",
+ "Installed version is not in this index": "Phiên bản đã cài không có trong chỉ mục này",
+ "Installing...": "Đang cài đặt…",
"Instance": "Phiên bản",
"Instances": "Phiên bản",
"Insufficient balance": "Số dư không đủ",
"Integrations": "Tích hợp",
+ "Integrity check failed": "Kiểm tra tính toàn vẹn thất bại",
+ "Integrity hash": "Mã băm toàn vẹn",
"Inter-group overrides": "Ghi đè liên nhóm",
"Inter-group ratio overrides": "Tỷ lệ liên nhóm ghi đè",
"Interface Language": "Ngôn ngữ giao diện",
@@ -2425,6 +2514,7 @@
"It seems like the page you're looking for": "Có vẻ như trang bạn đang tìm kiếm",
"Items": "Mục",
"Japanese": "Nhật Bản",
+ "JavaScript file": "Tệp JavaScript",
"Jimeng": "Jimeng",
"Jina": "Jina",
"JSON": "JSON",
@@ -2489,6 +2579,7 @@
"Latency short": "Trễ",
"Latency trend (last 24h)": "Xu hướng độ trễ (24 giờ qua)",
"Latest platform updates and notices": "Cập nhật và thông báo nền tảng mới nhất",
+ "Latest version": "Phiên bản mới nhất",
"Lavender Dream": "Mộng hoa oải hương",
"Layout": "Bố cục",
"lead": "dẫn đầu",
@@ -2541,6 +2632,7 @@
"LinuxDO Client Secret": "LinuxDO Bí mật máy khách",
"List of models supported by this channel. Use comma to separate multiple models.": "Danh sách các mô hình được hỗ trợ bởi kênh này. Sử dụng dấu phẩy để phân tách nhiều mô hình.",
"List of origins (one per line) allowed for Passkey registration and authentication.": "Danh sách các nguồn gốc (mỗi dòng một mục) được phép đăng ký và xác thực Passkey.",
+ "List registered task plugins and bind them when creating or editing task plugin channels.": "Liệt kê plugin tác vụ đã đăng ký và gắn chúng khi tạo hoặc sửa kênh.",
"List view": "Xem dạng danh sách",
"Live refresh pauses when no task is running": "Tự động làm mới tạm dừng khi không có tác vụ nào đang chạy",
"LLM Leaderboard": "Bảng xếp hạng LLM",
@@ -2555,6 +2647,7 @@
"Loading conversation...": "Đang tải cuộc trò chuyện...",
"Loading current models...": "Đang tải các mô hình hiện tại...",
"Loading failed": "Tải thất bại",
+ "Loading installed source...": "Đang tải mã nguồn đã cài…",
"Loading maintenance settings...": "Đang tải cài đặt bảo trì...",
"Loading settings...": "Đang tải cài đặt...",
"Loading setup status…": "Đang tải trạng thái cài đặt…",
@@ -2606,6 +2699,7 @@
"Manage multi-key status and configuration for this channel": "Quản lý trạng thái và cấu hình đa khóa cho kênh này",
"Manage Ollama Models": "Quản lý mô hình Ollama",
"Manage server log files. Log files accumulate over time; regular cleanup is recommended to free disk space.": "Quản lý tệp nhật ký máy chủ. Tệp nhật ký tích lũy theo thời gian; nên dọn dẹp định kỳ để giải phóng dung lượng đĩa.",
+ "Manage sources": "Quản lý nguồn",
"Manage subscription plans and pricing.": "Quản lý gói đăng ký và giá cả.",
"Manage Subscriptions": "Quản lý đăng ký",
"Manage Vendors": "Quản lý Nhà cung cấp",
@@ -2619,6 +2713,10 @@
"Map upstream status codes to different codes": "Ánh xạ mã trạng thái upstream sang các mã khác",
"Market Share": "Thị phần",
"Marketing": "Tiếp thị",
+ "Marketplace": "Chợ plugin",
+ "Marketplace installs never force past a conflict. Resolve it on the task plugins page, then install again.": "Cài đặt từ chợ không bao giờ bỏ qua xung đột bằng cách cưỡng chế. Hãy xử lý xung đột ở trang plugin tác vụ rồi cài lại.",
+ "Marketplace sources": "Nguồn chợ plugin",
+ "Marketplace sources updated": "Đã cập nhật nguồn chợ plugin",
"Master instances run scheduled background tasks.": "Phiên bản master chạy các tác vụ nền theo lịch.",
"Match All (AND)": "Tất cả khớp (AND)",
"Match Any (OR)": "Bất kỳ khớp (OR)",
@@ -2663,6 +2761,8 @@
"Maximum tokens per user": "Số token tối đa trên mỗi người dùng",
"maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647": "maxRequests ≥ 0, maxSuccess ≥ 1, cả hai đều ≤ 2,147,483,647",
"May be used for training by upstream provider": "Có thể được nhà cung cấp dùng để huấn luyện",
+ "Media access expired. Please try again.": "Quyền truy cập nội dung đa phương tiện đã hết hạn. Vui lòng thử lại.",
+ "Media preview failed. Please try again.": "Không thể xem trước nội dung đa phương tiện. Vui lòng thử lại.",
"Media pricing": "Giá phương tiện",
"Median time-to-first-token (TTFT) sampled hourly per group": "Độ trễ token đầu tiên trung vị (TTFT) lấy mẫu mỗi giờ theo nhóm",
"Medical Q&A, mental health support": "Hỏi đáp y tế, hỗ trợ sức khỏe tinh thần",
@@ -2862,6 +2962,7 @@
"Native Claude Messages plus OpenAI Chat compatibility forwarding.": "Chuyển tiếp Claude Messages nguyên bản và tương thích OpenAI Chat.",
"Native format": "Định dạng gốc",
"Native forwarding": "Chuyển tiếp nguyên bản",
+ "Native routes": "Tuyến nguyên bản",
"Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Route Gemini nguyên bản cùng chuyển tiếp tương thích OpenAI Chat và Responses.",
"Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "Route OpenAI nguyên bản cùng các route tương thích Claude và Gemini tùy chọn.",
"Need a redemption code?": "Cần mã đổi thưởng?",
@@ -2916,6 +3017,7 @@
"No available Web chat links": "Không có liên kết Web chat khả dụng",
"No backup": "Chưa sao lưu",
"No base input price": "Chưa có giá đầu vào cơ bản",
+ "No billing parameters declared": "Chưa khai báo tham số tính phí",
"No billing records found": "Không tìm thấy hồ sơ thanh toán",
"No capabilities reported for this model.": "Chưa có khả năng nào được báo cáo cho mô hình này.",
"No Change": "Không thay đổi",
@@ -2964,6 +3066,8 @@
"No incidents in the last 24 hours": "Không có sự cố trong 24 giờ qua",
"No incidents in the last 30 days": "Không có sự cố trong 30 ngày qua",
"No instances have reported yet.": "Chưa có phiên bản nào báo cáo.",
+ "No integrity hash": "Không có mã băm toàn vẹn",
+ "No integrity verification": "Không kiểm tra toàn vẹn",
"No Inviter": "Không có người mời",
"No keys found": "Không tìm thấy khóa",
"No latency data available": "Không có dữ liệu độ trễ",
@@ -2971,6 +3075,7 @@
"No logs": "Không có nhật ký",
"No Logs Found": "Không tìm thấy nhật ký",
"No mappings configured. Click \"Add Row\" to get started.": "Chưa có ánh xạ nào được cấu hình. Nhấp vào \"Thêm hàng\" để bắt đầu.",
+ "No marketplace sources configured.": "Chưa cấu hình nguồn chợ plugin nào.",
"No matches found": "Không tìm thấy kết quả nào",
"No matching items": "Không có mục phù hợp",
"No matching results": "Không có kết quả phù hợp",
@@ -3047,6 +3152,7 @@
"No Sync": "Không đồng bộ",
"No system announcements": "Không có thông báo hệ thống",
"No system tasks yet.": "Chưa có tác vụ hệ thống nào.",
+ "No task plugins found": "Không tìm thấy plugin tác vụ",
"No token found.": "Không tìm thấy mã thông báo.",
"No tools configured": "Chưa cấu hình công cụ nào",
"No Upgrade": "Không nâng cấp",
@@ -3078,9 +3184,13 @@
"Not backed up": "Chưa sao lưu",
"Not bound": "Không bị ràng buộc",
"Not configured": "Chưa cấu hình",
+ "Not declared": "Không khai báo",
"Not Equals": "Không bằng",
"Not in pricing table": "Không có trong bảng định giá",
"Not included": "Không bao gồm",
+ "Not installed": "Chưa cài đặt",
+ "Not provided by this source": "Nguồn này không cung cấp",
+ "Not registered": "Chưa đăng ký",
"Not set": "Chưa đặt",
"Not Set": "Chưa đặt",
"Not set yet": "Chưa thiết lập",
@@ -3096,6 +3206,7 @@
"Notifications": "Thông báo",
"Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "Bây giờ, một người dùng có nhóm người dùng là vip tạo các token với nhóm khác nhau và gọi mỗi token một lần:",
"Nucleus sampling probability mass": "Tổng xác suất cho nucleus sampling",
+ "Number": "Số",
"Number of codes to create": "Số mã cần tạo",
"Number of completions to generate": "Số lượng phản hồi cần sinh",
"Number of images to generate": "Số ảnh cần sinh",
@@ -3104,6 +3215,7 @@
"Number of tokens per unit quota": "Số token trên đơn vị hạn mức",
"Number of top log probabilities returned per token": "Số log probabilities hàng đầu trên mỗi token",
"Number of users invited": "Số người dùng được mời",
+ "OAuth": "OAuth",
"OAuth binding timed out. Please try again.": "Liên kết OAuth đã hết thời gian chờ. Vui lòng thử lại.",
"OAuth binding window is no longer available": "Cửa sổ liên kết OAuth không còn khả dụng",
"OAuth callback URL": "URL callback OAuth",
@@ -3223,6 +3335,7 @@
"Optional notes about this channel": "Ghi chú tùy chọn về kênh này",
"Optional notes about when to use this group": "Các ghi chú tùy chọn về thời điểm sử dụng nhóm này",
"Optional ratio used when upstream cache hits occur.": "Tỷ lệ tùy chọn được sử dụng khi xảy ra các lượt truy cập bộ nhớ đệm ngược dòng.",
+ "Optional request-rule multiplier expression. Leave empty when no request rule applies.": "Biểu thức hệ số tùy chọn cho quy tắc yêu cầu. Để trống khi không áp dụng quy tắc.",
"Optional rule description": "Mô tả quy tắc tùy chọn",
"Optional settings for advanced container configuration.": "Cài đặt tùy chọn cho cấu hình container nâng cao.",
"Optional supplementary information (max 100 characters)": "Thông tin bổ sung tùy chọn (tối đa 100 ký tự)",
@@ -3291,6 +3404,7 @@
"parameter.": "tham số",
"Parameters": "Tham số",
"Parsed {{count}} service account file(s)": "Đã phân tích {{count}} tệp tài khoản dịch vụ",
+ "Parsed plugin metadata": "Siêu dữ liệu plugin đã phân tích",
"Partial Submission": "Gửi một phần",
"Pass Headers": "Chuyển tiếp tiêu đề",
"Pass request body directly to upstream": "Truyền phần thân yêu cầu trực tiếp lên upstream",
@@ -3344,6 +3458,7 @@
"Passwords do not match": "Mật khẩu không khớp",
"Passwords don't match.": "Mật khẩu không khớp.",
"Paste Connection Info": "Dán thông tin kết nối",
+ "Paste JavaScript source here...": "Dán mã nguồn JavaScript tại đây...",
"Path": "Đường dẫn",
"Path not set": "Chưa đặt đường dẫn",
"Path Regex (one per line)": "Regex đường dẫn (mỗi dòng một mục)",
@@ -3383,6 +3498,8 @@
"per request": "theo yêu cầu",
"Per request": "Mỗi yêu cầu",
"Per Request": "Theo yêu cầu",
+ "Per Second": "Theo giây",
+ "Per Unit": "Theo đơn vị",
"Per-call": "Mỗi lần gọi",
"Per-feature metered windows split by model or capability.": "Cửa sổ tính phí theo từng tính năng, tách theo mô hình hoặc năng lực.",
"Per-group performance": "Hiệu năng theo nhóm",
@@ -3487,6 +3604,23 @@
"Please wait a moment, human check is initializing...": "Vui lòng đợi một chút, kiểm tra con người đang khởi tạo...",
"Please wait before editing to avoid overwriting saved values.": "Vui lòng chờ trước khi chỉnh sửa để tránh ghi đè các giá trị đã lưu.",
"Please wait for the current generation to complete": "Vui lòng đợi lượt tạo hiện tại hoàn tất",
+ "Plugin": "Plugin",
+ "Plugin author": "Tác giả plugin",
+ "Plugin Generation": "Thế hệ plugin",
+ "Plugin indexes are fetched by your browser. Installing runs the same review and admission pipeline as a manual upload.": "Chỉ mục plugin do trình duyệt của bạn tải. Việc cài đặt đi qua đúng quy trình xem xét và kiểm duyệt như khi tải lên thủ công.",
+ "Plugin is still in use": "Plugin vẫn đang được sử dụng",
+ "Plugin key": "Khóa plugin",
+ "Plugin metadata": "Siêu dữ liệu plugin",
+ "Plugin source": "Mã nguồn plugin",
+ "Choose file": "Chọn tệp",
+ "Choose another file": "Chọn tệp khác",
+ "Drop a JavaScript plugin file here": "Kéo tệp plugin JavaScript vào đây",
+ "Single .js file, up to 1 MiB. Its source is shown below before upload.": "Một tệp .js duy nhất, tối đa 1 MiB. Mã nguồn được hiển thị bên dưới trước khi tải lên.",
+ "Optional note describing this version": "Ghi chú tùy chọn mô tả phiên bản này",
+ "Plugin source exceeds the 1 MiB limit.": "Mã nguồn plugin vượt giới hạn 1 MiB.",
+ "Plugin uploaded successfully": "Đã tải plugin lên",
+ "Plugin version activated": "Đã kích hoạt phiên bản plugin",
+ "Plugin version deleted": "Đã xóa phiên bản plugin",
"Policy JSON": "JSON chính sách",
"Polling": "Thăm dò",
"Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded": "Chế độ thăm dò yêu cầu Redis và bộ nhớ đệm, nếu không hiệu suất sẽ bị suy giảm đáng kể.",
@@ -3541,6 +3675,9 @@
"Press Enter to use \"{{value}}\"": "Nhấn Enter để dùng \"{{value}}\"",
"Prevent server-side request forgery attacks": "Ngăn chặn các cuộc tấn công giả mạo yêu cầu phía máy chủ",
"Preview": "Xem trước",
+ "Preview excludes group ratios and request rule multipliers.": "Bản xem trước không bao gồm hệ số nhóm và hệ số nhân của quy tắc yêu cầu.",
+ "Preview is unavailable for custom expressions.": "Không thể xem trước biểu thức tùy chỉnh.",
+ "Preview unavailable": "Không thể xem trước",
"Previous": "Trước",
"Previous branch": "Nhánh trước",
"Previous page": "Trang trước",
@@ -3551,6 +3688,7 @@
"Price display mode": "Chế độ hiển thị giá",
"Price estimation": "Ước tính chi phí",
"Price estimation description": "Sau khi hoàn thành loại phần cứng, vị trí triển khai, số lượng bản sao, v.v., giá sẽ được tính toán tự động.",
+ "Price examples": "Ví dụ giá",
"Price ID": "Mã giá",
"Price mode (USD per 1M tokens)": "Chế độ giá (USD mỗi 1 triệu token)",
"Price summary": "Tóm tắt giá",
@@ -3559,6 +3697,7 @@
"Price: High to Low": "Giá: Từ cao đến thấp",
"Price: Low to High": "Giá: Thấp đến Cao",
"Prices shown per": "Giá hiển thị theo",
+ "Prices shown per usage unit": "Giá hiển thị theo từng đơn vị sử dụng",
"Prices synced successfully": "Đồng bộ giá thành công",
"Prices vary by usage tier and request conditions": "Giá thay đổi theo bậc dùng và điều kiện yêu cầu",
"Pricing": "Giá cả",
@@ -3623,6 +3762,7 @@
"Prune Object Items": "Dọn mục đối tượng",
"Prune object items by conditions": "Dọn dẹp các mục đối tượng theo điều kiện",
"Prune Rule (string or JSON object)": "Quy tắc dọn dẹp (chuỗi hoặc đối tượng JSON)",
+ "Public base URL for async task media. Supports a dedicated media domain, port, or Nginx path prefix; falls back to Server Address when empty.": "URL cơ sở công khai cho nội dung đa phương tiện của tác vụ bất đồng bộ. Hỗ trợ miền đa phương tiện, cổng hoặc tiền tố đường dẫn Nginx riêng; nếu để trống sẽ dùng địa chỉ máy chủ.",
"Public model catalog and pricing page.": "Trang công khai cho danh mục mô hình và giá.",
"Public rankings page based on live usage data.": "Trang bảng xếp hạng công khai dựa trên dữ liệu sử dụng thực.",
"Publish Date": "Ngày xuất bản",
@@ -3773,12 +3913,14 @@
"Regex Replace": "Thay thế regex",
"Register each URL into the matching Test Mode / Production Mode webhook slot in the Pancake dashboard. Separate endpoints prevent test traffic from accidentally crediting production accounts.": "Đăng ký từng URL vào ô webhook Test Mode / Production Mode tương ứng trong bảng điều khiển Pancake. Endpoint riêng biệt giúp tránh việc lưu lượng thử nghiệm vô tình cộng tiền vào tài khoản sản xuất.",
"Register Passkey": "Đăng ký Passkey",
+ "Registered": "Đã đăng ký",
"Registered a passkey": "Đã đăng ký một passkey",
"Registration Enabled": "Đăng ký đã bật",
"Registration flow expired. Please try again.": "Quy trình đăng ký đã hết hạn. Vui lòng thử lại.",
"Registry (optional)": "Registry (tùy chọn)",
"Registry secret": "Bí mật Registry",
"Registry username": "Tên người dùng Registry",
+ "Reinstall latest": "Cài lại bản mới nhất",
"Reject Reason": "Lý do từ chối",
"Release details": "Chi tiết phiên bản",
"Released": "Phát hành",
@@ -3806,6 +3948,7 @@
"Remove Passkey": "Xóa Khóa truy cập",
"Remove Passkey?": "Xóa khóa truy cập?",
"Remove rule group": "Gỡ nhóm quy tắc",
+ "Remove source {{name}}": "Xóa nguồn {{name}}",
"Remove string prefix": "Xóa tiền tố chuỗi",
"Remove string suffix": "Xóa hậu tố chuỗi",
"Remove the target field": "Xóa trường đích",
@@ -3855,8 +3998,10 @@
"Request Model": "Mô hình yêu cầu",
"Request Model:": "Mô hình yêu cầu:",
"Request overrides, routing behavior, and upstream model automation": "Ghi đè yêu cầu, hành vi định tuyến và tự động hóa mô hình upstream",
+ "Request Path": "Đường dẫn yêu cầu",
"Request retry": "Thử lại yêu cầu",
"Request rule pricing": "Quy tắc tính giá theo request",
+ "Request rules apply on top of this amount.": "Các quy tắc yêu cầu được áp dụng thêm trên số tiền này.",
"Request success rate sampled over the last 24 hours": "Tỷ lệ yêu cầu thành công được lấy mẫu trong 24 giờ qua",
"Request success rate; {{incidents}} incident buckets in the last 24 hours": "Tỷ lệ yêu cầu thành công; {{incidents}} khoảng có sự cố trong 24 giờ qua",
"Request timed out, please refresh and restart GitHub login": "Yêu cầu đã hết thời gian chờ, vui lòng làm mới và đăng nhập lại GitHub",
@@ -3919,6 +4064,7 @@
"Reset usage window": "Đặt lại cửa sổ mức dùng",
"Resets in:": "Đặt lại sau:",
"Resetting...": "Đang đặt lại...",
+ "Resize column": "Thay đổi kích thước cột",
"Resolve Conflicts": "Giải quyết Xung đột",
"Resource Configuration": "Cấu hình tài nguyên",
"Resources": "Tài nguyên",
@@ -3951,6 +4097,7 @@
"Revenue": "Doanh thu",
"Review & initialize": "Xem lại và khởi tạo",
"Review and sign out devices currently using your account.": "Xem lại và đăng xuất các thiết bị hiện đang sử dụng tài khoản của bạn.",
+ "Review and upgrade": "Xem lại và nâng cấp",
"Review model rates before scaling traffic": "Xem giá mô hình trước khi mở rộng lưu lượng",
"Review your payment details": "Xem lại chi tiết thanh toán của bạn",
"Review your purchase details before proceeding.": "Xem lại chi tiết mua hàng trước khi tiếp tục.",
@@ -3962,6 +4109,7 @@
"Role": "Vai trò",
"Roleplay": "Nhập vai",
"Root": "Root",
+ "Root Diagnostics": "Chẩn đoán Root",
"Rose Garden": "Vườn hoa hồng",
"Route": "Tuyến đường",
"Route active": "Tuyến đang hoạt động",
@@ -4001,16 +4149,20 @@
"Rules JSON": "JSON quy tắc",
"Rules JSON must be an array": "JSON quy tắc phải là một mảng",
"Rules match the original model value from the client request body.": "Quy tắc khớp với giá trị model gốc trong thân yêu cầu của client.",
+ "Run dry run": "Chạy thử",
"Run GC": "Chạy GC",
"Run tests for the selected models": "Chạy kiểm thử cho các mô hình đã chọn",
"running": "đang chạy",
"Running": "Đang chạy",
+ "Running dry run": "Đang chạy thử",
"Runtime": "Môi trường chạy",
+ "Runtime status": "Trạng thái chạy",
"Runway": "Thời gian còn lại",
"s": "s",
"Safety Settings": "Cài đặt an toàn",
"Same as Local": "Giống như địa phương",
"Sampling temperature; lower is more deterministic": "Nhiệt độ lấy mẫu; càng thấp càng ổn định",
+ "Sandbox": "Hộp cát",
"Sandbox mode": "Chế độ sandbox",
"Save": "Lưu",
"Save & Submit": "Lưu và gửi",
@@ -4091,6 +4243,8 @@
"Search the public web at inference time": "Tìm kiếm web công khai trong khi suy luận",
"Search vendors...": "Tìm nhà cung cấp...",
"Search...": "Tìm kiếm...",
+ "second": "giây",
+ "Second": "Giây",
"seconds": "giây",
"Secret env (JSON object)": "Biến môi trường bí mật (đối tượng JSON)",
"Secret environment variables (JSON)": "Biến môi trường bí mật (JSON)",
@@ -4116,6 +4270,7 @@
"Select a timestamp before clearing logs.": "Chọn một dấu thời gian trước khi xóa nhật ký.",
"Select a usage mode to continue": "Chọn chế độ sử dụng để tiếp tục",
"Select a verification method first": "Vui lòng chọn phương thức xác thực trước",
+ "Select a version to compare": "Chọn phiên bản để so sánh",
"Select active subscription plan": "Chọn gói đăng ký đang hoạt động",
"Select all": "Chọn tất cả",
"Select all (filtered)": "Chọn tất cả (đã lọc)",
@@ -4176,6 +4331,7 @@
"Select sync channels to compare prices": "Chọn kênh đồng bộ để so sánh giá",
"Select sync channels to compare ratios": "Chọn kênh đồng bộ để so sánh tỷ lệ",
"Select Sync Source": "Chọn Nguồn Đồng Bộ",
+ "Select task plugin": "Chọn plugin tác vụ",
"Select the API endpoint region": "Chọn khu vực điểm cuối API",
"Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.": "Chọn các trường bạn muốn ghi đè bằng dữ liệu thượng nguồn. Các trường không được chọn sẽ giữ nguyên giá trị cục bộ của chúng.",
"Select theme preference": "Chọn chủ đề ưu tiên",
@@ -4189,6 +4345,7 @@
"Selected conflicts were overwritten successfully.": "Các xung đột được chọn đã được ghi đè thành công.",
"Selected nodes": "Nút đã chọn",
"Selected when creating a token and used as the default billing group for API calls.": "Được chọn khi tạo token và dùng làm nhóm tính phí mặc định cho các lệnh gọi API.",
+ "Selecting a plugin fills its declared models.": "Chọn plugin sẽ điền các model đã khai báo.",
"Self-Use Mode": "Chế độ tự sử dụng",
"Send": "Gửi",
"Send a request": "Gửi yêu cầu",
@@ -4321,12 +4478,15 @@
"Sort by ID": "Sắp xếp theo ID",
"Sort Order": "Thứ tự sắp xếp",
"Source": "Nguồn",
+ "Source diff": "So sánh mã nguồn",
"Source Endpoint": "Điểm nguồn",
"Source Field": "Trường nguồn",
"Source Header": "Header nguồn",
+ "Source name": "Tên nguồn",
"sources": "nguồn",
"Space-separated OAuth scopes": "Phạm vi OAuth phân cách bằng dấu cách",
"Spark model version, e.g., v2.1 (version number in API URL)": "Phiên bản mô hình Spark, ví dụ: v2.1 (số phiên bản trong URL API)",
+ "Spec": "Thông số",
"Special billing expression": "Biểu thức tính phí đặc biệt",
"Special group": "Nhóm đặc biệt",
"Special ratio rules": "Quy tắc tỷ lệ đặc biệt",
@@ -4508,11 +4668,21 @@
"Target Path (optional)": "Đường dẫn đích (tùy chọn)",
"Target User": "Người dùng mục tiêu",
"Task": "Nhiệm vụ",
+ "Task billing": "Tính phí tác vụ",
+ "Task Details": "Chi tiết tác vụ",
"Task History": "Lịch sử tác vụ",
"Task ID": "Mã nhiệm vụ",
"Task ID:": "ID nhiệm vụ:",
"Task logs": "Nhật ký tác vụ",
"Task Logs": "Nhật ký tác vụ",
+ "Task Plugin": "Plugin tác vụ",
+ "Task plugin setting updated": "Đã cập nhật cài đặt plugin tác vụ",
+ "Task plugin *": "Plugin tác vụ *",
+ "Task Plugins": "Plugin tác vụ",
+ "Task pricing": "Giá tác vụ",
+ "Task pricing not configured": "Chưa cấu hình giá tác vụ",
+ "Task usage prices are USD per declared unit. They are not token prices and are not divided by one million.": "Giá theo mức sử dụng được tính bằng USD cho mỗi đơn vị đã khai báo. Đây không phải giá token và không được chia cho một triệu.",
+ "Task usage prices are USD per declared unit. Token fields use dollars per 1M tokens; the editor writes / 1000000 into the expression. Other units are not divided by one million.": "Giá sử dụng tác vụ tính bằng USD trên đơn vị đã khai báo. Trường token dùng USD trên 1 triệu token; trình soạn thảo ghi / 1000000 vào biểu thức. Các đơn vị khác không chia cho một triệu.",
"Tasks currently pending or running.": "Các tác vụ hiện đang chờ hoặc đang chạy.",
"Team Collaboration": "Teamwork",
"Technical Support": "Hỗ trợ kỹ thuật",
@@ -4565,12 +4735,15 @@
"The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "Sản phẩm đã liên kết dùng cho nạp ví: khi người dùng nhập bất kỳ số tiền nào, new-api chạy thanh toán trên một sản phẩm Pancake duy nhất này và ghi đè giá theo từng phiên — không cần tạo trước SKU $1 / $5 / $10.",
"The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "Cửa hàng đã liên kết là vùng chứa cha cho mọi sản phẩm Pancake mà new-api tạo từ trang quản trị này — bao gồm sản phẩm nạp ví và mọi sản phẩm gói đăng ký. Một cửa hàng là đủ; chỉ ghim cửa hàng khác nếu bạn thực sự vận hành các catalog Pancake riêng.",
"The deployment node that handled the requests": "Nút triển khai đã xử lý các yêu cầu",
+ "The downloaded source does not match the sha256 declared in the index. Do not install it.": "Mã nguồn đã tải không khớp với sha256 khai báo trong chỉ mục. Đừng cài đặt nó.",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "Mi",
"The entered text does not match the required text.": "Văn bản đã nhập không khớp với văn bản yêu cầu.",
"The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "Môi trường (test hay production) được quyết định bởi khóa bạn dán tại đây — dùng khóa Test khi tích hợp, sau đó đổi sang khóa Production khi chạy chính thức.",
"The exact model identifier as used in API requests.": "Mã định danh mô hình chính xác như được sử dụng trong các yêu cầu API.",
"The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "Các mô hình sau có xung đột loại thanh toán (giá cố định so với thanh toán theo tỷ lệ). Xác nhận để tiếp tục với các thay đổi.",
"The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "Các mô hình sau trong chuyển hướng mô hình chưa được thêm vào danh sách \"Mô hình\" và có thể gọi thất bại do thiếu các mô hình có sẵn:",
+ "The gateway rejected this plugin": "Cổng đã từ chối plugin này",
+ "The index could not be fetched or parsed: {{message}}. The host may block cross-origin requests.": "Không thể tải hoặc phân tích chỉ mục: {{message}}. Máy chủ có thể chặn yêu cầu cross-origin.",
"The login session that started this Telegram binding is no longer valid.": "Phiên đăng nhập đã bắt đầu liên kết Telegram này không còn hợp lệ.",
"The mapped upstream model(s)": "Mô hình(s) thượng nguồn được ánh xạ",
"The model that was requested": "Mô hình đã được yêu cầu",
@@ -4594,6 +4767,7 @@
"The upstream natively supports all three protocols; every selected route is forwarded without conversion.": "Thượng nguồn hỗ trợ nguyên bản cả ba giao thức; mọi route đã chọn được chuyển tiếp mà không chuyển đổi.",
"The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.": "Phản hồi thượng nguồn là JSON hợp lệ nhưng không khớp định dạng OpenAI credit_summary. Số dư kênh chưa được cập nhật.",
"The URL for this chat client.": "URL của ứng dụng chat này.",
+ "The URL returned HTTP {{status}}. Check the address, or download the file and paste its source below.": "URL trả về HTTP {{status}}. Hãy kiểm tra địa chỉ, hoặc tải tệp về và dán mã nguồn vào bên dưới.",
"The user group applied to the requests": "Nhóm người dùng được áp dụng cho các yêu cầu",
"The user who made the requests": "Người dùng đã thực hiện các yêu cầu",
"Theme": "Chủ đề",
@@ -4603,11 +4777,18 @@
"There is a rule for vip billed as premium → use its ratio 0.3": "Có quy tắc «vip theo premium» → dùng hệ số 0.3 của quy tắc",
"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.": "Các model này vẫn được chọn nhưng không còn xuất hiện trong danh sách upstream; tên chỉ là khóa nguồn trong model_mapping đã được loại. Điều chỉnh trước khi lưu.",
"These toggles affect whether certain request fields are passed through to the upstream provider.": "Các chuyển đổi này ảnh hưởng đến việc các trường yêu cầu nhất định có được chuyển đến nhà cung cấp dịch vụ đầu vào hay không.",
+ "These values come from the source index and are shown for review only. The gateway admits the plugin based on the metadata compiled from its source.": "Các giá trị này lấy từ chỉ mục của nguồn và chỉ để bạn xem xét. Cổng quyết định chấp nhận plugin dựa trên metadata biên dịch từ mã nguồn của nó.",
"Thinking Suffix Adapter": "Adapter hậu tố thinking",
"Thinking to Content": "Suy nghĩ thành Nội dung",
"Thinking...": "Đang suy nghĩ...",
+ "Third-party": "Bên thứ ba",
+ "Third-party — use at your own risk": "Bên thứ ba — tự chịu rủi ro",
"Third-party account bindings (read-only, managed by user in profile settings)": "Liên kết tài khoản bên thứ ba (chỉ đọc, do người dùng quản lý trong cài đặt hồ sơ)",
"Third-party Payment Config": "Cấu hình thanh toán bên thứ ba",
+ "Third-party plugin risk": "Rủi ro plugin bên thứ ba",
+ "Third-party source risk": "Rủi ro nguồn bên thứ ba",
+ "Third-party-only plugins become unavailable immediately. In-flight tasks will be handled by timeout cleanup.": "Plugin chỉ có từ bên thứ ba sẽ ngừng hoạt động ngay. Tác vụ đang chạy sẽ được xử lý khi dọn dẹp quá hạn.",
+ "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.": "Plugin gốc và plugin tùy chỉnh sẽ ngay lập tức ngừng phục vụ. Các tác vụ đang chạy sẽ được xử lý bằng dọn dẹp hết hạn.",
"This action cannot be undone.": "Hành động này không thể hoàn tác.",
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "Hành động này không thể hoàn tác. Việc này sẽ xóa vĩnh viễn tài khoản của bạn và loại bỏ tất cả dữ liệu của bạn khỏi máy chủ của chúng tôi.",
"This action will permanently remove 2FA protection from your account.": "Hành động này sẽ vĩnh viễn gỡ bỏ tính năng bảo vệ",
@@ -4618,11 +4799,13 @@
"This channel is not an Ollama channel.": "Kênh này không phải là kênh Ollama.",
"This channel type does not support fetching models": "Loại kênh này không hỗ trợ lấy mô hình",
"This channel type requires additional configuration": "Loại kênh này yêu cầu cấu hình bổ sung",
+ "This combination will be billed as free.": "Tổ hợp này sẽ không phát sinh phí.",
"This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.": "Xác nhận này mở khóa các tính năng thanh toán, mã đổi thưởng, gói đăng ký và phần thưởng mời. Vui lòng đọc kỹ các tuyên bố.",
"This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.": "Thiết lập này kiểm soát giới hạn tốc độ yêu cầu mô hình. Giới hạn tuyến Web/API được cấu hình bằng biến môi trường và vẫn có thể trả về 429.",
"This data may be unreliable, use with caution": "Dữ liệu này có thể không đáng tin cậy, sử dụng thận trọng",
"This device does not support Passkey": "Thiết bị này không hỗ trợ Passkey",
"This device does not support Passkey verification.": "Thiết bị này không hỗ trợ xác minh Passkey.",
+ "This expression does not price each combination exactly once, so it opens as a raw expression. Sparse or custom pricing stays in this editor.": "Biểu thức này không định giá chính xác một lần cho từng tổ hợp, vì vậy nó được mở dưới dạng biểu thức thô. Cách định giá không đầy đủ hoặc tùy chỉnh vẫn được giữ trong trình chỉnh sửa này.",
"This expression is too complex for the visual editor. Please switch to expression mode to edit.": "Biểu thức này quá phức tạp cho trình sửa trực quan. Hãy chuyển sang chế độ biểu thức để chỉnh sửa.",
"This FAQ entry will be removed from the list.": "Mục FAQ này sẽ bị xóa khỏi danh sách.",
"This feature is experimental. Configuration format and behavior may change.": "Tính năng này đang ở giai đoạn thử nghiệm. Định dạng cấu hình và hành vi có thể thay đổi.",
@@ -4630,15 +4813,19 @@
"This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "Bản ghi lịch sử này được tạo trước khi tính năng thông tin kiểm toán ra đời nên thiếu dữ liệu kiểm toán. Phiên bản hiện tại đã hỗ trợ ghi lại IP máy chủ, IP gọi lại, phương thức thanh toán và phiên bản hệ thống, nhưng các trường này chỉ được ghi cho các bản ghi mới về sau — không thể bổ sung hồi tố cho bản ghi cũ.",
"This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "Mã định danh này được gửi tới backend thanh toán khi tạo đơn hàng. Dùng alipay cho Alipay, wxpay cho WeChat Pay, stripe cho Stripe. Giá trị tùy chỉnh phải được nhà cung cấp thanh toán hỗ trợ.",
"This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.": "Phiên bản này đang dùng hostname tự động. Hãy đặt NODE_NAME thành một giá trị ổn định và duy nhất để quản lý nhiều phiên bản.",
+ "This is a task model billed by usage (e.g. seconds, resolution). Prices entered here act as a per-call base rate, not per-token prices.": "Đây là mô hình tác vụ được tính phí theo mức sử dụng (ví dụ: số giây, độ phân giải). Giá nhập tại đây đóng vai trò là mức giá cơ sở cho mỗi lượt gọi, không phải giá theo token.",
"This may cause cache failures.": "Điều này có thể gây ra lỗi bộ nhớ đệm.",
"This may take a few moments while we validate the request and update your session.": "Việc này có thể mất vài phút trong khi chúng tôi xác thực yêu cầu và cập nhật phiên của bạn.",
"This model has both fixed price and ratio billing conflicts": "Mô hình này có cả mâu thuẫn về thanh toán theo giá cố định và theo tỷ lệ.",
"This model has both fixed-price and ratio settings. Saving the current mode will rewrite the conflicting fields.": "Mô hình này có cả giá cố định và cài đặt tỷ lệ. Lưu chế độ hiện tại sẽ ghi lại các trường xung đột.",
"This model has both fixed-price and token-price settings. Saving the current mode will rewrite the conflicting fields.": "Mô hình này có cả giá cố định và cài đặt giá theo token. Lưu chế độ hiện tại sẽ ghi lại các trường xung đột.",
+ "This model is billed by usage, but the administrator has not configured its pricing yet.": "Mô hình này được tính phí theo mức sử dụng, nhưng quản trị viên chưa cấu hình giá.",
"This model is not available in any group, or no group pricing information is configured.": "Mô hình này không khả dụng trong bất kỳ nhóm nào, hoặc thông tin giá nhóm chưa được cấu hình.",
"This month": "Tháng này",
"This page has not been created yet.": "Trang này chưa được tạo.",
"This plan does not allow balance redemption": "Gói này không cho phép thanh toán bằng số dư",
+ "This plugin has no factory fallback. Deleting or disabling it makes this platform unavailable.": "Plugin này không có bản tích hợp dự phòng. Xóa hoặc tắt plugin sẽ khiến nền tảng không khả dụng.",
+ "This plugin path does not resolve within the source repository.": "Đường dẫn plugin này không nằm trong kho của nguồn.",
"This project must be used in compliance with the": "Dự án này phải được sử dụng tuân thủ theo",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "Thao tác này sẽ xóa {{count}} mô hình thất bại khỏi kênh này. Không thể hoàn tác.",
"This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "Tuyến này khám phá các mô hình OpenAI thượng nguồn và không thể tách hoặc đối sánh bằng quy tắc mô hình phía máy khách.",
@@ -4646,6 +4833,8 @@
"This route is used only by channel management to query the upstream balance.": "Route này chỉ được quản lý kênh dùng để truy vấn số dư thượng nguồn.",
"This session will lose access immediately and must sign in again.": "Phiên này sẽ mất quyền truy cập ngay lập tức và phải đăng nhập lại.",
"This site currently has {{count}} models enabled": "Trang này hiện đã bật {{count}} mô hình",
+ "This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.": "Nguồn này không công bố sha256 cho phiên bản đó, nên không thể xác nhận mã nguồn đã tải đúng với nội dung nguồn phát hành.",
+ "This source lists no installable task plugins.": "Nguồn này không liệt kê plugin tác vụ nào có thể cài đặt.",
"This Telegram account is already bound.": "Tài khoản Telegram này đã được liên kết.",
"This Telegram binding request has expired or has already been used.": "Yêu cầu liên kết Telegram này đã hết hạn hoặc đã được sử dụng.",
"This tier catches any request that did not match earlier tiers.": "Tầng này bắt mọi yêu cầu không khớp với các tầng trước.",
@@ -4704,6 +4893,7 @@
"times": "lần",
"Timing": "Thời gian",
"Tip": "Mẹo",
+ "Tip: after configuring one model, select others in the table and use bulk copy.": "Mẹo: sau khi cấu hình một mô hình, hãy chọn các mô hình khác trong bảng và dùng tính năng sao chép hàng loạt.",
"to access this resource.": "để truy cập tài nguyên này.",
"To Anthropic Messages": "Sang Anthropic Messages",
"to confirm": "Chờ xác nhận",
@@ -4722,7 +4912,9 @@
"Toggle navigation menu": "Chuyển đổi menu điều hướng",
"Toggle plan": "Chuyển đổi kế hoạch",
"Toggle theme": "Chuyển đổi giao diện",
+ "token": "token",
"Token": "Mã thông báo",
+ "token (unit)": "token",
"Token Breakdown": "Chi tiết token",
"Token Endpoint": "Điểm cuối Token",
"Token Endpoint (Optional)": "Điểm cuối Token (Tùy chọn)",
@@ -4885,6 +5077,8 @@
"Unexpected release payload": "Dữ liệu phiên bản không mong đợi",
"Unified API Gateway for": "Cổng API thống nhất cho",
"Unique identifier for this group.": "Mã định danh duy nhất cho nhóm này.",
+ "unit": "đơn vị",
+ "Unit": "Đơn vị",
"Unit price (local currency / USD)": "Đơn giá (tiền tệ địa phương / USD)",
"Unit price (USD)": "Đơn giá (USD)",
"Unit price must be greater than 0": "Đơn giá phải lớn hơn 0",
@@ -4903,6 +5097,7 @@
"Untrusted upstream data:": "Dữ liệu nguồn không đáng tin cậy:",
"Unused": "Chưa sử dụng",
"Up to 4 strings that stop generation": "Tối đa 4 chuỗi để dừng sinh",
+ "Up to date": "Đã mới nhất",
"Update": "Cập nhật",
"Update All Balances": "Cập nhật tất cả số dư",
"Update API Key": "Cập nhật Khóa API",
@@ -4941,15 +5136,26 @@
"Updated user {{username}} (ID: {{id}})": "Đã cập nhật người dùng {{username}} (ID: {{id}})",
"Updating all channel balances. This may take a while. Please refresh to see results.": "Đang cập nhật tất cả số dư kênh. Quá trình này có thể mất một chút thời gian. Vui lòng làm mới để xem kết quả.",
"Updating...": "Đang cập nhật...",
+ "Upgrade {{name}}": "Nâng cấp {{name}}",
+ "Upgrade and enable": "Nâng cấp và bật",
+ "Upgrade available: v{{installed}} to v{{latest}}": "Có bản nâng cấp: v{{installed}} lên v{{latest}}",
"Upgrade Group": "Nhóm nâng cấp",
"Upgrade plaintext SMTP connection with STARTTLS before authentication": "Nâng cấp kết nối SMTP dạng rõ bằng STARTTLS trước khi xác thực",
"Upload": "Tải lên",
+ "Upload a JavaScript task platform plugin.": "Tải lên plugin JavaScript cho nền tảng tác vụ.",
"Upload a single service account JSON file": "Tải lên một tệp JSON tài khoản dịch vụ",
+ "Upload a task plugin to add a platform.": "Tải plugin tác vụ lên để thêm nền tảng.",
"Upload file": "Tải tệp lên",
"Upload files": "Tải tệp lên",
"Upload multiple JSON files in batch modes": "Tải lên nhiều tệp JSON trong chế độ hàng loạt",
+ "Upload new plugin version": "Tải phiên bản plugin mới",
+ "Upload new version": "Tải phiên bản mới",
"Upload or reference a local configuration file.": "Tải lên hoặc tham chiếu tệp cấu hình cục bộ.",
"Upload photo": "Tải ảnh lên",
+ "Upload plugin": "Tải plugin lên",
+ "Upload task plugin": "Tải plugin tác vụ lên",
+ "Uploading a plugin is an administrator-level trust decision. A plugin can access channel credentials and shape upstream requests. Review its source and diff before activation.": "Tải plugin lên là quyết định tin cậy ở cấp quản trị viên. Plugin có thể truy cập thông tin xác thực của kênh và tạo yêu cầu tới thượng nguồn. Hãy xem mã nguồn và phần khác biệt trước khi kích hoạt.",
+ "Uploading...": "Đang tải lên...",
"Upscale": "Phóng to",
"Upstream": "Thượng nguồn",
"Upstream did not return reset credit details.": "Upstream không trả về chi tiết lượt đặt lại.",
@@ -4975,6 +5181,7 @@
"Upstream Response (billing-usage-openai-estimated)": "Phản hồi upstream (billing-usage-openai-estimated)",
"Upstream Response (billing-usage-openai)": "Phản hồi upstream (billing-usage-openai)",
"upstream services integrated": "dịch vụ thượng nguồn tích hợp",
+ "Upstream Task ID": "ID tác vụ thượng nguồn",
"Upstream Updates": "Cập nhật nguồn",
"Upstream URL": "URL upstream",
"Upstream URL must be a full URL": "URL upstream phải là URL đầy đủ",
@@ -4995,7 +5202,11 @@
"Usage logs": "Nhật ký sử dụng",
"Usage Logs": "Nhật ký sử dụng",
"Usage mode": "Chế độ sử dụng",
+ "Usage parameters": "Tham số sử dụng",
+ "Usage prices": "Giá sử dụng",
"Usage-based": "Dựa trên sử dụng",
+ "Usage-based billing": "Tính phí theo mức sử dụng",
+ "Usage-based billing · price not configured": "Tính phí theo mức sử dụng · chưa cấu hình giá",
"USD": "USD",
"USD Exchange Rate": "Tỷ giá USD",
"USD price per 1M input tokens.": "Giá USD cho mỗi 1 triệu token đầu vào.",
@@ -5084,6 +5295,7 @@
"Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "Người dùng chỉ thấy các nhóm được đánh dấu là có thể chọn. Nhóm không thể chọn vẫn có thể do quản trị viên gán.",
"uses": "sử dụng",
"Using the complete global Auto order ({{count}} groups)": "Đang dùng thứ tự Auto toàn cục đầy đủ ({{count}} nhóm)",
+ "v{{installed}} is installed but this source does not list it. Installing replaces it with v{{target}}.": "Đang cài v{{installed}} nhưng nguồn này không liệt kê phiên bản đó. Việc cài đặt sẽ thay thế nó bằng v{{target}}.",
"Validity": "Hiệu lực",
"Validity Period": "Thời hạn hiệu lực",
"Value": "Giá trị",
@@ -5124,7 +5336,9 @@
"Verify your database connection": "Xác minh kết nối cơ sở dữ liệu của bạn",
"Verifying credentials and pulling stores from your Pancake account...": "Đang xác minh thông tin xác thực và lấy cửa hàng từ tài khoản Pancake của bạn...",
"Version": "Phiên bản",
+ "Version history": "Lịch sử phiên bản",
"Version Overrides": "Ghi đè phiên bản",
+ "Versions": "Phiên bản",
"Vertex AI": "Vertex AI",
"Vertex AI API Key mode does not support batch creation": "Chế độ API Key của Vertex AI không hỗ trợ tạo hàng loạt",
"Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.": "Vertex AI không hỗ trợ functionResponse.id. Bật để tự động loại bỏ trường này.",
@@ -5148,6 +5362,7 @@
"View Pricing": "View price",
"View the complete details for this": "Xem chi tiết đầy đủ của",
"View the complete details for this log entry": "Xem chi tiết đầy đủ cho mục nhật ký này",
+ "View the complete details for this task": "Xem đầy đủ chi tiết của tác vụ này",
"View the complete error message and details": "Xem toàn bộ thông báo lỗi và chi tiết",
"View the complete prompt and its English translation": "Xem toàn bộ lời nhắc và bản dịch tiếng Anh",
"View the generated image": "Xem ảnh đã tạo",
@@ -5240,6 +5455,8 @@
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "Khi token dùng nhóm auto, hệ thống thử các nhóm từ trên xuống dưới cho đến khi tìm được nhóm khả dụng.",
"When billed as {{group}}": "Khi tính phí theo {{group}}",
"When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.": "Khi thỏa điều kiện, giá cuối nhân với X. Nhiều điều kiện khớp nhân lại với nhau; giá trị < 1 hoạt động như giảm giá.",
+ "When disabled, all uploaded custom plugins are ignored and every platform falls back to its built-in factory plugin.": "Khi tắt, mọi plugin tùy chỉnh đã tải lên sẽ bị bỏ qua và mỗi nền tảng sẽ dùng plugin gốc tích hợp sẵn.",
+ "When disabled, the entire task plugin system stops serving, including factory and custom plugins.": "Khi tắt, toàn bộ hệ thống plugin tác vụ ngừng phục vụ, gồm cả plugin gốc và plugin tùy chỉnh.",
"When enabled, if channels in the current group fail, it will try channels in the next group in order.": "Khi được bật, nếu các kênh trong nhóm hiện tại thất bại, hệ thống sẽ thử các kênh của nhóm tiếp theo theo thứ tự.",
"When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. Leave it off to delete the entry and select another channel.": "Khi bật, giữ mục ưu tiên ngay cả khi kênh ưu tiên bị tắt hoặc không còn dùng được cho nhóm/mô hình hiện tại. Để tắt để xóa mục đó và chọn kênh khác.",
"When enabled, large request bodies are temporarily stored on disk instead of memory, significantly reducing memory usage. SSD recommended.": "Khi bật, nội dung yêu cầu lớn sẽ được lưu tạm trên đĩa thay vì bộ nhớ, giảm đáng kể việc sử dụng bộ nhớ. Khuyến nghị dùng SSD.",
diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json
index c42ce58a196a..6cc8c1d6b485 100644
--- a/web/src/i18n/locales/zh-TW.json
+++ b/web/src/i18n/locales/zh-TW.json
@@ -29,7 +29,9 @@
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]": "[{\"name\":\"支付寶\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]",
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]": "[{\"name\":\"支付寶\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]",
"{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
+ "{{bytes}} bytes": "{{bytes}} 位元組",
"{{category}} Models": "{{category}} 模型",
+ "{{channels}} channels, {{tasks}} in-flight tasks": "{{channels}} 個渠道,{{tasks}} 個進行中任務",
"{{completed}}/{{total}} completed": "已完成 {{completed}}/{{total}}",
"{{count}} / {{max}} groups selected": "已選擇 {{count}} / {{max}} 個分組",
"{{count}} announcements will be removed from the list.": "將從列表中移除 {{count}} 條公告。",
@@ -39,9 +41,11 @@
"{{count}} channel(s) enabled": "已啟用 {{count}} 個渠道",
"{{count}} channel(s) failed to disable": "{{count}} 個渠道停用失敗",
"{{count}} channel(s) failed to enable": "{{count}} 個渠道啟用失敗",
+ "{{count}} combinations": "{{count}} 種組合",
"{{count}} days ago": "{{count}} 日前",
"{{count}} days remaining": "剩餘 {{count}} 日",
"{{count}} disabled channel(s) deleted": "已刪除 {{count}} 個已停用的渠道",
+ "{{count}} enabled channels and {{tasks}} in-flight tasks still use this plugin.": "仍有 {{count}} 個啟用渠道和 {{tasks}} 個進行中任務使用此外掛。",
"{{count}} FAQ entries will be removed from the list.": "將從列表中移除 {{count}} 個 FAQ 條目。",
"{{count}} hours ago": "{{count}} 小時前",
"{{count}} incidents": "{{count}} 宗事件",
@@ -60,6 +64,7 @@
"{{count}} weeks ago": "{{count}} 週前",
"{{field}} updated to {{value}}": "{{field}} 已更新為 {{value}}",
"{{field}} updated to {{value}} for tag: {{tag}}": "標籤「{{tag}}」的 {{field}} 已更新為 {{value}}",
+ "{{key}} · version {{version}} · from {{source}}": "{{key}} · 版本 {{version}} · 來自 {{source}}",
"{{method}} {{route}}": "{{method}} {{route}}",
"{{modality}} not supported": "不支援 {{modality}}",
"{{modality}} supported": "支援 {{modality}}",
@@ -104,6 +109,7 @@
"14 Days": "14 日",
"192.168.1.1
10.0.0.0/8": "192.168.1.1
10.0.0.0/8",
"1M": "1 個月",
+ "1M token": "1M token",
"1W": "1 週",
"2. Copy the application token": "2. 複製套用程式令牌",
"20 / page": "20 條/頁",
@@ -147,6 +153,7 @@
"Action": "操作",
"Action confirmation": "操作確認",
"Actions": "操作",
+ "Activate / Roll back": "激活 / 回滚",
"active": "活躍",
"Active": "生效",
"Active apps": "活躍套用程式",
@@ -155,6 +162,7 @@
"Active models": "活躍模型",
"Active Tasks": "進行中任務",
"active users": "活躍用戶",
+ "Active version": "激活版本",
"Actively check all channels": "主動檢查全部渠道",
"Actively check auto-disable-enabled channels": "主動檢查已啟用自動停用的渠道",
"Actual Amount": "實付金額",
@@ -171,6 +179,7 @@
"Add a new user by providing necessary info.": "透過提供必要資訊新增用戶。",
"Add a new vendor to the system": "向系統新增供應商",
"Add an extra layer of security to your account": "為您的用戶添加額外的安全層",
+ "Add an index URL to browse installable plugins.": "新增一個索引 URL 即可瀏覽可安裝的外掛。",
"Add and submit": "新增並提交",
"Add Announcement": "新增公告",
"Add API": "新增 API",
@@ -217,6 +226,7 @@
"Add rule group": "新增規則組",
"Add rules for a user group": "為用戶分組新增規則",
"Add selectable group": "新增可選分組",
+ "Add source": "新增來源",
"Add split": "新增分流",
"Add subscription": "新增訂閱",
"Add tags...": "新增標籤...",
@@ -292,6 +302,7 @@
"All": "全部",
"All API tokens": "全部 API 金鑰",
"All categories": "全部分類",
+ "All combinations are priced at zero. Matching requests will be billed as free.": "所有組合的價格皆為零。符合的請求將免費計費。",
"All conditions must match before this tier is used.": "所有條件都匹配後才會使用此階梯。",
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "所有編輯都是覆蓋操作。留空欄位將保持目前值不變。",
"All files exceed the maximum size.": "所有檔案都超過最大尺寸。",
@@ -348,6 +359,7 @@
"Allow using models without price configuration": "允許使用未設定價格的模型",
"Allow wallet balance after quota used up": "額度用盡後允許使用錢包餘額",
"Allowed": "允許",
+ "Allowed hosts": "允許存取的主機",
"Allowed Origins": "允許的 Origins",
"Allowed Ports": "允許的端口",
"Already have an account?": "已有用戶?",
@@ -380,6 +392,7 @@
"Anthropic": "Anthropic",
"Anthropic Messages to OpenAI Chat": "Anthropic Messages 到 OpenAI Chat",
"Any Match (OR)": "任一滿足(OR)",
+ "Anyone can publish an index. A plugin installed from a third-party source has the same access as one you upload by hand: review its source before installing.": "任何人都可以發布索引。從第三方來源安裝的外掛與你手動上傳的外掛擁有完全相同的權限,請在安裝前審查其原始碼。",
"API": "API",
"API Access": "API 存取",
"API Addresses": "API 地址",
@@ -414,6 +427,8 @@
"API token management": "API令牌管理",
"API URL": "API URL",
"API usage records": "API使用記錄",
+ "API version": "API 版本",
+ "API Version": "API 版本",
"API2GPT": "API2GPT",
"App": "套用程式",
"App rankings shown here are simulated for preview purposes and will be replaced with live usage data once the backend integration is complete.": "此處展示的套用排行僅為預覽用模擬數據,待後端對接完成後將替換為真實數據。",
@@ -437,8 +452,10 @@
"Apply plan": "套用方案",
"Apply reset": "執行重置",
"Apply Sync": "套用同步",
+ "Apply to all rows": "套用至所有列",
"Applying...": "正在套用...",
"Approx.": "約",
+ "Approximate prices for common specs.": "常見規格的參考價。",
"apps": "個套用",
"Apps": "套用",
"apps tracked": "個套用追蹤中",
@@ -461,12 +478,17 @@
"Are you sure?": "您確定嗎?",
"Area Chart": "面積圖",
"Args (space separated)": "參數 (空格分隔)",
+ "Arguments JSON": "參數 JSON",
+ "Arguments must be a JSON array": "參數必須是 JSON 陣列",
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "聊天用戶端預設陣列。每個項目都是一個物件,包含一個鍵值對:用戶端名稱及其 URL。",
+ "Artifacts": "製品",
"Asc": "升序",
"Ask anything": "隨便問",
"Assigned by administrator only": "僅管理員分配",
"Assigned by administrators and used to represent a user level, such as default or vip.": "由管理員分配,用於表示用戶等級,例如 default 或 vip。",
+ "Async": "異步",
"Async task polling": "異步任務輪詢",
+ "Async Task Public Address": "非同步任務對外地址",
"Async task refund": "異步任務退款",
"At least one model regex pattern is required": "至少需要一個模型正則匹配模式",
"At least one valid key source is required": "至少需要一個有效的金鑰來源",
@@ -585,8 +607,10 @@
"Balance updated: {{balance}}": "餘額已更新:{{balance}}",
"Bar Chart": "柱狀圖",
"Bark Push URL": "Bark 推送 URL",
+ "Base": "基礎",
"Base address provided by your Epay service": "您的 Epay 服務提供的基礎地址",
"Base amount. Actual deduction = base amount × system group rate.": "基礎金額,實際扣費 = 基礎金額 × 系統分組倍率。",
+ "Base charge": "基礎費用",
"Base input and output token prices for this tier.": "此階梯的基礎輸入和輸出 token 價格。",
"Base input price only": "僅基礎輸入價格",
"Base Limits": "基礎額度",
@@ -594,6 +618,7 @@
"Base Price": "基礎價格",
"Base rate limit windows for this account.": "目前賬號的基礎額度窗口。",
"Base URL": "API 地址",
+ "Base URL *": "基礎 URL *",
"Base URL is required for this channel type": "此渠道類型需要填寫 Base URL",
"Base URL is required when an advanced route uses an upstream path": "進階路由使用上游路徑時必須填寫 Base URL",
"Base URL of your Uptime Kuma instance": "您的 Uptime Kuma 實例的基礎 URL",
@@ -638,6 +663,7 @@
"Billing group = vip (the token has no group, so use the user group)": "收費分組 = vip(令牌沒設定分組,就用用戶自己的分組)",
"Billing History": "收費歷史",
"Billing Mode": "收費模式",
+ "Billing parameters": "計費參數",
"Billing Path": "收費路徑",
"Billing Process": "收費過程",
"Billing rule: each call is billed as the token group (falling back to the user group when the token has none). The base ratio always comes from that billing group, not from the user group. To give a user group a special price on another billing group, add an entry in the override matrix.": "收費規則:每次呼叫按令牌分組收費(令牌未設定分組時回退到用戶分組)。基礎倍率始終取該收費分組的倍率,而不是用戶分組的倍率。若要讓某用戶分組在使用其他收費分組時享受特殊價格,請在覆蓋矩陣中添加條目。",
@@ -648,6 +674,7 @@
"Bind Email": "連結電郵",
"Bind Telegram Account": "連結 Telegram 用戶",
"Bind WeChat Account": "連結微信用戶",
+ "Bind task plugins": "綁定任務外掛",
"Binding Information": "連結資訊",
"Binding successful!": "連結成功!",
"Binding your {{provider}} account": "正在連結您的 {{provider}} 賬號",
@@ -686,6 +713,7 @@
"Built for developers,": "為開發者打造,",
"Built-in": "內置",
"Built-in Device": "內置設備",
+ "Built-in v{{factory}} / marketplace v{{market}}": "內建 v{{factory}} / 市集 v{{market}}",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "內置:手機指紋/面部,或 Windows Hello;外部:USB 安全金鑰",
"by": "由",
"By category": "按行業",
@@ -738,6 +766,7 @@
"Caps the response length": "限制回覆長度",
"Capture a reusable bundle of models, tags, or endpoints.": "捕捉可重用的模型、標籤或端點捆綁包。",
"Card view": "卡片檢視",
+ "Cascade disable channels": "連動停用渠道",
"Catch-all route must be last for the same incoming path": "同一入口路徑的兜底路由必須放在最後",
"Category": "分類",
"Category Name": "分類名稱",
@@ -779,7 +808,9 @@
"Channel test concurrency": "渠道測試並行數",
"Channel test concurrency must be between 1 and 32": "渠道測試並行數必須介於 1 到 32 之間",
"Channel test mode": "渠道測試模式",
+ "Channel type": "渠道類型",
"Channel type is required": "渠道類型是必填的",
+ "Channel types": "渠道類型",
"Channel updated successfully": "渠道更新成功",
"Channel-specific settings (JSON format)": "渠道特定設定(JSON 格式)",
"Channel:": "渠道:",
@@ -950,6 +981,7 @@
"Compare the most popular models on the platform": "對比平台上最受歡迎的模型",
"compatible API routes": "兼容 API 路由",
"Compatible API routes for common AI application workflows": "兼容常見 AI 套用工作流的 API 路由",
+ "Compilation failed": "编译失败",
"Complete API documentation with multi-language SDK support": "完整的 API 文件,支援多語言 SDK",
"Complete Order": "補單",
"Complete these steps to finish the initial installation.": "完成這些步驟以完成初始安裝。",
@@ -1000,6 +1032,7 @@
"Configure pricing ratios for a specific model.": "設定特定模型的定價比例。",
"Configure rate limiting rules for a specific user group.": "設定特定用戶分組的速率限制規則。",
"Configure routes": "設定路由",
+ "Configure task pricing": "設定任務定價",
"Configure the ratio for this group.": "設定此分組的比例。",
"Configure upstream providers and routing.": "設定上游提供者和路由。",
"Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "設定 Waffo Pancake 託管結帳,用於美元計價的儲值",
@@ -1143,6 +1176,10 @@
"Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "費用 = 模型價格 × 這一個倍率。分組設定裡的其他項都不參與該公式。",
"Cost in USD per request, regardless of tokens used.": "每請求的美元費用,不考慮使用的令牌數。",
"Cost Tracking": "成本追蹤",
+ "Could not fetch the plugin source from this browser. The host may block cross-origin requests or be unreachable.": "無法在瀏覽器中取得外掛原始碼。該主機可能禁止跨來源請求或無法連線。",
+ "Could not fetch this URL from the browser. The host may block cross-origin requests or be unreachable. Download the file and paste its source below.": "無法在瀏覽器中取得該 URL。該主機可能禁止跨來源請求或無法連線。請下載檔案後將原始碼貼到下方。",
+ "Could not load this source": "無法載入該來源",
+ "Count": "數量",
"Count must be between {{min}} and {{max}}": "計數必須介於{{min}}和{{max}}之間",
"Coze": "Coze",
"CPU": "CPU",
@@ -1199,6 +1236,7 @@
"Credentials": "憑證",
"Credentials verification failed": "憑證驗證失敗",
"Credentials verification failed — double-check Merchant ID and API private key.": "憑證驗證失敗,請檢查 Merchant ID 和 API 私鑰。",
+ "credit": "credit",
"Credit remaining": "剩餘額度",
"Creem API key (leave blank unless updating)": "Creem API 金鑰(除非更新,否則留空)",
"Creem Gateway": "Creem 閘道",
@@ -1226,6 +1264,7 @@
"Current version": "目前版本",
"Current:": "目前:",
"Custom": "自訂",
+ "Custom (overrides factory {{version}})": "自定义(覆盖內建版 {{version}})",
"Custom (seconds)": "自訂(秒)",
"Custom Amount": "自訂金額",
"Custom API base URL. For official channels, New API has built-in addresses. Only fill this for third-party proxy sites or special endpoints. Do not add /v1 or trailing slash.": "自訂 API 基礎 URL。對於官方渠道,New API 具有內置地址。僅針對第三方代理站點或特殊端點填寫此項。請勿添加 /v1 或尾部斜線。",
@@ -1245,6 +1284,7 @@
"Custom OAuth Providers": "自訂 OAuth 供應商",
"Custom Seconds": "自訂秒數",
"Custom sidebar section": "自訂側邊欄部分",
+ "Custom task plugin setting updated": "自訂任務外掛設定已更新",
"Custom Time Range": "自訂時間範圍",
"Custom Zoom": "自訂縮放",
"Customize sidebar display content": "個人化設定左側邊欄的顯示內容",
@@ -1274,6 +1314,7 @@
"Days to Retain": "保留天數",
"decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.": "決定儲值倍率、用戶建令牌時可選哪些分組,以及是否命中覆蓋倍率。",
"decides which channels are used and which base ratio applies.": "決定走哪些渠道、用哪個基礎倍率。",
+ "Declared capabilities": "宣告的能力",
"Decreased user quota by {{quota}}": "減少用戶額度 {{quota}}",
"Deducted by subscription": "由訂閱抵扣",
"DeepSeek": "DeepSeek",
@@ -1306,6 +1347,7 @@
"Delete {{count}} stale instance records? Online instances will not be deleted.": "刪除 {{count}} 筆失聯實例記錄?線上實例不會被刪除。",
"Delete a runtime request header": "刪除運行期請求頭",
"Delete Account": "刪除用戶",
+ "Delete active custom version": "刪除当前自定义版本",
"Delete All Disabled": "刪除所有已停用",
"Delete All Disabled Channels?": "刪除所有已停用的渠道?",
"Delete all stale": "刪除所有失聯",
@@ -1329,6 +1371,7 @@
"Delete mapping": "刪除映射",
"Delete Model": "刪除模型",
"Delete Models?": "刪除模型?",
+ "Delete plugin version?": "刪除外掛版本?",
"Delete Provider": "刪除供應商",
"Delete Request Header": "刪除請求頭",
"Delete selected API keys": "刪除選定的 API 金鑰",
@@ -1355,6 +1398,7 @@
"Deleted stale instance": "已刪除失聯實例",
"Deleted successfully": "刪除成功",
"Deleted user {{username}} (ID: {{id}})": "刪除用戶 {{username}}(ID: {{id}})",
+ "Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.": "刪除此自定义版本不会停用平台,同名內建外掛将自动恢复。",
"Deleting will permanently remove this subscription record (including benefit details). Continue?": "刪除會徹底移除該訂閱記錄(含權益明細)。是否繼續?",
"Deleting...": "刪除中...",
"Demo site": "演示站點",
@@ -1401,6 +1445,8 @@
"Disable": "停用",
"Disable 2FA": "停用 2FA",
"Disable All": "停用全部",
+ "Disable custom task plugins?": "停用自訂任務外掛?",
+ "Disable task plugins?": "停用任務外掛?",
"Disable on failure": "失敗時停用",
"Disable selected channels": "停用選定的渠道",
"Disable selected models": "停用選定的模型",
@@ -1415,6 +1461,8 @@
"Disabled lanes are omitted on save.": "關閉的價格通道儲存時會被省略。",
"Disabled Reason": "停用原因",
"Disabled Time": "停用時間",
+ "Disabled; fell back to factory": "已禁用;已回落內建版",
+ "Disabled; platform unavailable": "已禁用;平台不可用",
"Disabling...": "停用中...",
"Disclaimer: Personal use only. Do not distribute or share any credentials. This channel has prerequisites and requires prior setup; use it only if you understand the flow and risks, and comply with OpenAI's terms and policies. Credentials and configuration are for Codex CLI integration only, and are not intended for any other client, platform, or channel.": "免責聲明:僅限個人使用,請勿分發或共享任何憑證。該渠道存在前置條件與使用門檻,請在充分了解流程與風險後使用,並遵守 OpenAI 的相關條款與政策。相關憑證與設定僅限接入 Codex CLI 使用,不適用於其他用戶端、平台或渠道。",
"Discord": "Discord",
@@ -1480,6 +1528,7 @@
"Drawing Logs": "繪圖日誌",
"Drawing task polling": "繪圖任務輪詢",
"Drawing task records": "繪圖任務記錄",
+ "Dry run result": "試跑結果",
"Duplicate": "重複",
"Duplicate group names: {{names}}": "存在重複的分組名稱:{{names}}",
"Duplicate model in route models": "路由模型中存在重複模型",
@@ -1540,7 +1589,9 @@
"Each item must have exactly one key-value pair.": "每個條目必須恰好包含一個鍵值對。",
"Each line represents one keyword. Leave blank to disable the list but keep the switch states.": "每行代表一個關鍵詞。留空以停用清單,但保留開關狀態。",
"Each matrix cell is one rule: users of this row group pay this ratio when billed as this column group. In JSON the row is the outer key and the column is the inner key.": "矩陣的每個單元格是一條規則:該行用戶分組的用戶按該列分組收費時使用此倍率。在 JSON 中行是外層鍵,列是內層鍵。",
+ "Each row prices one combination of {{fields}}.": "每列分別為一種 {{fields}} 組合定價。",
"Each rule reads as a sentence: users of one group pay a special ratio when billed as another group. Without a rule, the billing group base ratio applies.": "每條規則就是一句話:某分組的用戶按另一分組收費時享受特殊倍率。沒有規則時,使用收費分組的基礎倍率。",
+ "Each source serves an index.json listing installable plugins. Indexes are fetched by your browser; the gateway makes no outbound requests.": "每個來源都提供一份 index.json,列出可安裝的外掛。索引由你的瀏覽器取得,閘道不會發出任何對外請求。",
"Each tier supports 0~2 conditions (over len, p, c); the last tier is the catch-all without conditions. Use len (full input length, including cache hits) for tier conditions to avoid mis-routing when cache hits reduce p.": "每個檔位支援 0~2 個條件(針對 len、p、c),最後一檔為兜底檔無需條件。建議條件使用 len(完整輸入長度,含緩存命中),避免緩存命中降低 p 導致檔位誤判。",
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "每個檔位最多支援 2 個條件;最後一個檔位是不帶條件的兜底檔。建議使用完整輸入長度作為檔位條件,避免緩存命中減少收費輸入 token 後誤判檔位。",
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "每個階梯最多支援 2 個條件。最後一個無條件階梯作為兜底。",
@@ -1598,6 +1649,8 @@
"Enable 2FA": "啟用 2FA",
"Enable All": "啟用全部",
"Enable check-in feature": "啟用簽到功能",
+ "Enable custom task plugins": "啟用自訂任務外掛",
+ "Enable task plugins": "啟用任務外掛",
"Enable Data Dashboard": "啟用數據儀表板",
"Enable demo mode with limited functionality": "啟用功能受限的演示模式",
"Enable Discord OAuth": "啟用 Discord OAuth",
@@ -1617,6 +1670,7 @@
"Enable or disable this model": "啟用或停用此模型",
"Enable Passkey": "啟用 Passkey",
"Enable Performance Monitoring": "啟用效能監控",
+ "Enable plugin {{key}}": "啟用外掛 {{key}}",
"Enable rate limiting": "啟用速率限制",
"Enable Request Passthrough": "啟用請求透傳",
"Enable selected channels": "啟用選定的渠道",
@@ -1670,6 +1724,8 @@
"Enter a value and press Enter": "輸入值並按 Enter 鍵",
"Enter amount in {{currency}}": "輸入金額({{currency}})",
"Enter amount in tokens": "輸入額度(Token)",
+ "Enter an absolute HTTP(S) URL without credentials, query parameters, or fragments": "請輸入不含驗證資訊、查詢參數或片段的 HTTP(S) 絕對 URL",
+ "Enter an absolute http(s) URL.": "請輸入完整的 http(s) URL。",
"Enter announcement content (supports Markdown & HTML)": "輸入公告內容(支援 Markdown 和 HTML)",
"Enter announcement content (supports Markdown/HTML)": "輸入公告內容(支援 Markdown/HTML)",
"Enter API Key": "請輸入 API Key",
@@ -1733,6 +1789,9 @@
"Enterprise Account": "企業用戶",
"Enterprise-grade security with comprehensive permission management": "企業級安全性,提供全面的權限管理",
"Entrypoint (space separated)": "入口點 (空格分隔)",
+ "Enum": "列舉",
+ "Boolean": "布林",
+ "Enum values": "列舉值",
"Env (JSON object)": "環境變數 (JSON 物件)",
"Environment variables": "環境變數",
"Environment variables (JSON)": "環境變數 (JSON)",
@@ -1764,6 +1823,8 @@
"Example": "示例",
"Example (all channels):": "示例(全部渠道):",
"Example (specific channels):": "示例(指定渠道):",
+ "Example price": "示例價格",
+ "Example spec": "示例規格",
"Example:": "示例:",
"example.com
blocked-site.com": "example.com
blocked-site.com",
"example.com
company.com": "example.com
company.com",
@@ -1792,6 +1853,7 @@
"Expose ratio API": "暴露倍率接口",
"Exposes the pricing/models catalog in the top navigation.": "在頂部導航中顯示定價/模型目錄。",
"Expression": "表達式",
+ "Expression - Task pricing": "表達式-任務定價",
"Expression based": "基於表達式",
"Expression billing": "表達式收費",
"Expression editor": "表達式編輯器",
@@ -1811,6 +1873,9 @@
"Extra visible": "額外可見",
"Extra visible to {{group}}": "對 {{group}} 額外可見",
"extras": "額外項",
+ "Factory": "內建",
+ "Factory and custom plugin behavior": "內建与自定义外掛行为",
+ "Factory plugins cannot be deleted or disabled individually. A custom version can override them; deleting or disabling that version restores the factory plugin. Third-party-only platforms become unavailable when their plugin is deleted or disabled.": "內建外掛不可刪除或单独禁用。自定义版本可覆盖內建版;刪除或禁用自定义版会恢复內建版。纯第三方平台的外掛被刪除或禁用后将不可用。",
"Fail Reason": "失敗原因",
"Fail Reason Details": "失敗原因詳情",
"failed": "已失敗",
@@ -1879,6 +1944,7 @@
"Failed to initialize system": "系統初始化失敗",
"Failed to load": "載入失敗",
"Failed to load API keys": "載入 API 金鑰失敗",
+ "Failed to load artifacts": "製品載入失敗",
"Failed to load billing history": "載入收費歷史失敗",
"Failed to load enabled models": "獲取啟用模型失敗",
"Failed to load home page content": "載入首頁內容失敗",
@@ -1968,15 +2034,20 @@
"Feature in development": "功能開發中",
"Fee": "扣費",
"Fee Amount": "扣費金額",
+ "Fetch": "取得",
"Fetch available models for:": "獲取可用模型:",
"Fetch available models from upstream": "從上游獲取可用模型",
"Fetch from Upstream": "從上游獲取",
"Fetch Models": "獲取模型",
+ "Fetch mode": "拉取模式",
"Fetched {{count}} model(s) from upstream": "從上游獲取了 {{count}} 個模型",
"Fetched {{count}} models": "已獲取 {{count}} 個模型",
+ "Fetched in your browser and placed in the source field below for review. GitHub and gist page URLs are rewritten to their raw URL automatically.": "由瀏覽器取得並填入下方原始碼欄位供你審查。GitHub 與 gist 頁面 URL 會自動改寫為 raw URL。",
+ "Fetching plugin source...": "正在取得外掛原始碼…",
"Fetching prefill groups...": "正在獲取預填充分組...",
"Fetching upstream prices...": "正在獲取上游價格...",
"Fetching upstream ratios...": "正在獲取上游比例...",
+ "Fetching...": "正在取得…",
"field": "欄位",
"Field Mapping": "欄位映射",
"Field passthrough controls": "欄位透傳控制",
@@ -1986,6 +2057,7 @@
"Files to Retain": "保留檔案數",
"Fill All Models": "填充所有模型",
"Fill Codex CLI / Claude CLI Templates": "填充 Codex CLI / Claude CLI 模板",
+ "Fill entire column": "填滿整欄",
"Fill example (all channels)": "填充示例(全部渠道)",
"Fill example (specific channels)": "填充示例(指定渠道)",
"Fill in": "填入",
@@ -2025,6 +2097,7 @@
"Filter models by provider, group, type, endpoint, and tags.": "按供應商、分組、類型、端點和標籤篩選模型。",
"Filter models by type, endpoint, vendor, group and tags": "按類型、端點、供應商、分組和標籤篩選模型",
"Filter models...": "篩選模型...",
+ "Filter plugins...": "筛选外掛...",
"Filter the model analytics view by time range and user.": "按時間範圍和用戶篩選模型分析視圖。",
"Filter the traffic flow view by time range and user.": "按時間範圍和用戶篩選分流圖視圖。",
"Filter...": "篩選...",
@@ -2078,6 +2151,7 @@
"Force Format": "強制格式化",
"Force format response to OpenAI standard (OpenAI channel only)": "強制將回應格式化為 OpenAI 標準(僅限 OpenAI 渠道)",
"Force JSON object or schema-conforming output": "強制輸出 JSON 物件或符合 Schema 的結果",
+ "Force operation": "強制操作",
"Force SMTP authentication using AUTH LOGIN method": "強制使用 AUTH LOGIN 方法進行 SMTP 認證",
"Force-disabled two-factor authentication for the user": "強制關閉了用戶的兩步驗證",
"Forest Whisper": "森林低語",
@@ -2251,6 +2325,7 @@
"Home": "主頁",
"Home Page Content": "首頁內容",
"Homepage URL": "首頁 URL",
+ "Hook": "鉤子",
"Hostname or IP of your SMTP provider": "您的 SMTP 供應商的主機名稱或 IP",
"Hour": "小時",
"Hour of day": "小時",
@@ -2329,6 +2404,7 @@
"Image to Video": "圖生影片",
"Image Tokens": "圖像 Token",
"Imagine the pricing table has three groups: default (ratio 1.0), premium (ratio 0.5), and vip (ratio 0.8). Users whose account is in the vip group get user-level perks, and premium is a cheaper channel pool that users can pick for their tokens.": "假設定價分組表裡有三個分組:default(倍率 1.0)、premium(倍率 0.5)、vip(倍率 0.8)。賬號在 vip 分組的用戶享受用戶級待遇,premium 則是一個更便宜的渠道池,用戶建令牌時可以選它。",
+ "Import from URL": "從 URL 匯入",
"Import to CC Switch": "填入 CC Switch",
"Important": "重要",
"In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "在 JSON 中,外層鍵是用戶分組,內層鍵是收費分組。下面的示例表示:vip 用戶按 standard 收費時用 0.8,按 premium 收費時用 0.3。",
@@ -2352,6 +2428,8 @@
"Incomplete": "未完成",
"Increased user quota by {{quota}}": "增加用戶額度 {{quota}}",
"Index": "索引",
+ "Index request failed with HTTP {{status}}": "索引請求失敗,HTTP {{status}}",
+ "Index URL": "索引 URL",
"Inherit global Auto order": "繼承全域 Auto 順序",
"Initial quota given to new users": "授予新用戶的初始配額",
"Initial quota given to new users ({{formattedQuota}})": "授予新用戶的初始配額({{formattedQuota}})",
@@ -2369,10 +2447,21 @@
"Inset": "內嵌",
"Inspect requests, errors, and billing details": "查看請求、錯誤和收費詳情",
"Inspect user prompts": "檢查用戶提示",
+ "Install": "安裝",
+ "Install {{name}}": "安裝 {{name}}",
+ "Install and enable": "安裝並啟用",
+ "Installed": "已安裝",
+ "Installed {{name}} v{{version}}": "已安裝 {{name}} v{{version}}",
+ "Installed v{{from}} → marketplace v{{to}}": "已安裝 v{{from}} → 市集 v{{to}}",
+ "Installed v{{installed}} not listed": "已裝 v{{installed}} 不在索引中",
+ "Installed version is not in this index": "已安裝的版本不在該索引中",
+ "Installing...": "正在安裝…",
"Instance": "實例",
"Instances": "實例",
"Insufficient balance": "餘額不足",
"Integrations": "整合",
+ "Integrity check failed": "完整性驗證失敗",
+ "Integrity hash": "完整性雜湊",
"Inter-group overrides": "分組間覆蓋",
"Inter-group ratio overrides": "分組間比例覆蓋",
"Interface Language": "介面語言",
@@ -2425,6 +2514,7 @@
"It seems like the page you're looking for": "您要查找的頁面似乎",
"Items": "條目",
"Japanese": "日語",
+ "JavaScript file": "JavaScript 文件",
"Jimeng": "Jimeng",
"Jina": "Jina",
"JSON": "JSON",
@@ -2489,6 +2579,7 @@
"Latency short": "延遲",
"Latency trend (last 24h)": "延遲趨勢(最近 24 小時)",
"Latest platform updates and notices": "最新平台更新和通知",
+ "Latest version": "最新版本",
"Lavender Dream": "薰衣草夢",
"Layout": "佈局",
"lead": "領頭",
@@ -2541,6 +2632,7 @@
"LinuxDO Client Secret": "LinuxDO 用戶端密鑰",
"List of models supported by this channel. Use comma to separate multiple models.": "此渠道支援的模型清單。使用逗號分隔多個模型。",
"List of origins (one per line) allowed for Passkey registration and authentication.": "允許用於 Passkey 註冊和身份驗證的來源清單(每行一個)。",
+ "List registered task plugins and bind them when creating or editing task plugin channels.": "列出已註冊的任務外掛,並在建立或編輯任務外掛渠道時綁定它們。",
"List view": "列表檢視",
"Live refresh pauses when no task is running": "無任務執行時暫停自動重新整理",
"LLM Leaderboard": "LLM 排行榜",
@@ -2555,6 +2647,7 @@
"Loading conversation...": "正在載入對話...",
"Loading current models...": "正在載入目前模型...",
"Loading failed": "載入失敗",
+ "Loading installed source...": "正在載入已安裝的原始碼…",
"Loading maintenance settings...": "正在載入維護設定...",
"Loading settings...": "正在載入設定...",
"Loading setup status…": "正在載入設定狀態…",
@@ -2606,6 +2699,7 @@
"Manage multi-key status and configuration for this channel": "管理此渠道的多金鑰狀態和設定",
"Manage Ollama Models": "管理 Ollama 模型",
"Manage server log files. Log files accumulate over time; regular cleanup is recommended to free disk space.": "管理伺服器執行日誌檔案。日誌檔案會隨執行時間不斷累積,建議定期清理以釋放磁碟空間。",
+ "Manage sources": "管理來源",
"Manage subscription plans and pricing.": "管理訂閱計劃和定價。",
"Manage Subscriptions": "管理訂閱",
"Manage Vendors": "管理供應商",
@@ -2619,6 +2713,10 @@
"Map upstream status codes to different codes": "將上游狀態碼映射到不同的代碼",
"Market Share": "市場份額",
"Marketing": "市場營銷",
+ "Marketplace": "外掛市集",
+ "Marketplace installs never force past a conflict. Resolve it on the task plugins page, then install again.": "市集安裝絕不會強制略過衝突。請先在任務外掛頁面處理衝突,再重新安裝。",
+ "Marketplace sources": "市集來源",
+ "Marketplace sources updated": "市集來源已更新",
"Master instances run scheduled background tasks.": "master 實例執行排程背景任務。",
"Match All (AND)": "必須全部滿足(AND)",
"Match Any (OR)": "滿足任一條件(OR)",
@@ -2663,6 +2761,8 @@
"Maximum tokens per user": "每個用戶的最大令牌數",
"maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647": "maxRequests ≥ 0, maxSuccess ≥ 1,兩者均 ≤ 2,147,483,647",
"May be used for training by upstream provider": "可能被上游供應商用於訓練",
+ "Media access expired. Please try again.": "媒體存取已過期,請再試一次。",
+ "Media preview failed. Please try again.": "媒體預覽失敗,請再試一次。",
"Media pricing": "媒體定價",
"Median time-to-first-token (TTFT) sampled hourly per group": "按小時採樣的各分組首 token 延遲(TTFT)中位數",
"Medical Q&A, mental health support": "醫療問答與心理健康支援",
@@ -2862,6 +2962,7 @@
"Native Claude Messages plus OpenAI Chat compatibility forwarding.": "Claude Messages 原生轉發,並相容 OpenAI Chat 轉換。",
"Native format": "原生格式",
"Native forwarding": "原生轉發",
+ "Native routes": "原生路由",
"Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Gemini 原生轉發,並相容 OpenAI Chat 和 Responses 轉換。",
"Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "OpenAI 原生轉發,並提供可選的 Claude 和 Gemini 相容轉換。",
"Need a redemption code?": "需要兌換碼?",
@@ -2916,6 +3017,7 @@
"No available Web chat links": "沒有可用的 Web 聊天連結",
"No backup": "無備份",
"No base input price": "未設定基礎輸入價格",
+ "No billing parameters declared": "未宣告計費參數",
"No billing records found": "未找到賬單記錄",
"No capabilities reported for this model.": "該模型暫未報告任何能力。",
"No Change": "無變化",
@@ -2964,6 +3066,8 @@
"No incidents in the last 24 hours": "最近 24 小時無異常",
"No incidents in the last 30 days": "最近 30 天無事件",
"No instances have reported yet.": "暫無實例上報。",
+ "No integrity hash": "無完整性雜湊",
+ "No integrity verification": "無完整性驗證",
"No Inviter": "無邀請人",
"No keys found": "未找到金鑰",
"No latency data available": "暫無延遲數據",
@@ -2971,6 +3075,7 @@
"No logs": "暫無日誌",
"No Logs Found": "未找到日誌",
"No mappings configured. Click \"Add Row\" to get started.": "未設定映射。點擊「新增列」開始。",
+ "No marketplace sources configured.": "尚未設定任何市集來源。",
"No matches found": "未找到匹配項",
"No matching items": "沒有匹配項",
"No matching results": "無匹配結果",
@@ -3047,6 +3152,7 @@
"No Sync": "不同步",
"No system announcements": "暫無系統公告",
"No system tasks yet.": "暫無系統任務。",
+ "No task plugins found": "未找到任务外掛",
"No token found.": "未找到令牌。",
"No tools configured": "未設定工具",
"No Upgrade": "不升級",
@@ -3078,9 +3184,13 @@
"Not backed up": "未備份",
"Not bound": "未連結",
"Not configured": "未設定",
+ "Not declared": "未宣告",
"Not Equals": "不等於",
"Not in pricing table": "不在定價分組表中",
"Not included": "未加入",
+ "Not installed": "未安裝",
+ "Not provided by this source": "該來源未提供",
+ "Not registered": "未注册",
"Not set": "未設定",
"Not Set": "未設定",
"Not set yet": "尚未設定",
@@ -3096,6 +3206,7 @@
"Notifications": "通知",
"Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "現在,一個用戶分組為 vip 的用戶建立了不同分組的令牌,各呼叫一次:",
"Nucleus sampling probability mass": "核採樣累積概率",
+ "Number": "數值",
"Number of codes to create": "要建立的代碼數量",
"Number of completions to generate": "生成的候選條數",
"Number of images to generate": "生成的圖像數量",
@@ -3104,6 +3215,7 @@
"Number of tokens per unit quota": "每單位配額的令牌數",
"Number of top log probabilities returned per token": "每個 token 返回的 top 概率數量",
"Number of users invited": "已邀請的用戶數量",
+ "OAuth": "OAuth",
"OAuth binding timed out. Please try again.": "OAuth 綁定逾時,請重試。",
"OAuth binding window is no longer available": "OAuth 綁定視窗已無法使用",
"OAuth callback URL": "OAuth 回呼 URL",
@@ -3223,6 +3335,7 @@
"Optional notes about this channel": "關於此渠道的可選備註",
"Optional notes about when to use this group": "關於何時使用此分組的可選說明",
"Optional ratio used when upstream cache hits occur.": "上游緩存命中時使用的可選比率。",
+ "Optional request-rule multiplier expression. Leave empty when no request rule applies.": "可選的請求規則倍率運算式。沒有請求規則時留空。",
"Optional rule description": "可選規則說明",
"Optional settings for advanced container configuration.": "進階容器設定的可選設定。",
"Optional supplementary information (max 100 characters)": "可選補充資訊 (最多 100 個字元)",
@@ -3291,6 +3404,7 @@
"parameter.": "參數。",
"Parameters": "參數",
"Parsed {{count}} service account file(s)": "已解析 {{count}} 個服務賬號檔案",
+ "Parsed plugin metadata": "解析出的外掛元数据",
"Partial Submission": "部分提交確認",
"Pass Headers": "透傳請求頭",
"Pass request body directly to upstream": "將請求體直接傳遞給上游",
@@ -3344,6 +3458,7 @@
"Passwords do not match": "密碼不匹配",
"Passwords don't match.": "兩次輸入的密碼不一致。",
"Paste Connection Info": "貼上連線資訊",
+ "Paste JavaScript source here...": "在此粘贴 JavaScript 原始碼...",
"Path": "路徑",
"Path not set": "未設定路徑",
"Path Regex (one per line)": "路徑正則(每行一個)",
@@ -3383,6 +3498,8 @@
"per request": "每次請求",
"Per request": "每次請求",
"Per Request": "按次計費",
+ "Per Second": "按秒",
+ "Per Unit": "按次",
"Per-call": "每次呼叫",
"Per-feature metered windows split by model or capability.": "按模型或能力拆分的附加收費能力窗口。",
"Per-group performance": "各分組效能",
@@ -3487,6 +3604,23 @@
"Please wait a moment, human check is initializing...": "請稍等,人機驗證正在初始化...",
"Please wait before editing to avoid overwriting saved values.": "請等待載入完成後再編輯,以免覆蓋已儲存的值。",
"Please wait for the current generation to complete": "請等待目前生成完成",
+ "Plugin": "外掛",
+ "Plugin author": "外掛作者",
+ "Plugin Generation": "外掛執行代次",
+ "Plugin indexes are fetched by your browser. Installing runs the same review and admission pipeline as a manual upload.": "外掛索引由你的瀏覽器取得。安裝時走的審查與准入流程與手動上傳完全一致。",
+ "Plugin is still in use": "外掛仍在使用中",
+ "Plugin key": "外掛键",
+ "Plugin metadata": "外掛元数据",
+ "Plugin source": "外掛原始碼",
+ "Choose file": "選擇檔案",
+ "Choose another file": "重新選擇檔案",
+ "Drop a JavaScript plugin file here": "將 JavaScript 外掛檔案拖放到此處",
+ "Single .js file, up to 1 MiB. Its source is shown below before upload.": "單一 .js 檔案,最大 1 MiB。上傳前會在下方顯示其原始碼。",
+ "Optional note describing this version": "選填,用於描述該版本的備註",
+ "Plugin source exceeds the 1 MiB limit.": "外掛原始碼超過 1 MiB 上限。",
+ "Plugin uploaded successfully": "外掛上傳成功",
+ "Plugin version activated": "外掛版本已激活",
+ "Plugin version deleted": "外掛版本已刪除",
"Policy JSON": "政策 JSON",
"Polling": "輪詢",
"Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded": "輪詢模式需要 Redis 和記憶體緩存,否則效能將顯著下降",
@@ -3541,6 +3675,9 @@
"Press Enter to use \"{{value}}\"": "按 Enter 使用「{{value}}」",
"Prevent server-side request forgery attacks": "防止伺服器端請求偽造攻擊",
"Preview": "預覽",
+ "Preview excludes group ratios and request rule multipliers.": "預覽不包含群組倍率與請求規則倍率。",
+ "Preview is unavailable for custom expressions.": "自訂表達式無法預覽。",
+ "Preview unavailable": "無法預覽",
"Previous": "上一步",
"Previous branch": "上一分支",
"Previous page": "上一頁",
@@ -3551,6 +3688,7 @@
"Price display mode": "價格顯示模式",
"Price estimation": "價格估算",
"Price estimation description": "完成硬件類型、部署位置、副本數量等設定後,價格將自動計算。",
+ "Price examples": "價格示例",
"Price ID": "價格 ID",
"Price mode (USD per 1M tokens)": "價格模式(每 100 萬個 token 的美元價格)",
"Price summary": "價格摘要",
@@ -3559,6 +3697,7 @@
"Price: High to Low": "價格:從高到低",
"Price: Low to High": "價格:從低到高",
"Prices shown per": "價格顯示單位",
+ "Prices shown per usage unit": "價格按每個用量單位顯示",
"Prices synced successfully": "價格同步成功",
"Prices vary by usage tier and request conditions": "價格根據用量檔位和請求條件動態調整",
"Pricing": "定價",
@@ -3623,6 +3762,7 @@
"Prune Object Items": "清理物件項",
"Prune object items by conditions": "按條件清理物件中的子項",
"Prune Rule (string or JSON object)": "清理規則(字串或 JSON 物件)",
+ "Public base URL for async task media. Supports a dedicated media domain, port, or Nginx path prefix; falls back to Server Address when empty.": "非同步任務媒體的公開基礎地址。支援獨立媒體網域、連接埠或 Nginx 路徑前綴;留空時回退至伺服器地址。",
"Public model catalog and pricing page.": "公開模型目錄和價格頁面。",
"Public rankings page based on live usage data.": "基於真實用量數據的公開排行榜頁面。",
"Publish Date": "發佈日期",
@@ -3773,12 +3913,14 @@
"Regex Replace": "正則替換",
"Register each URL into the matching Test Mode / Production Mode webhook slot in the Pancake dashboard. Separate endpoints prevent test traffic from accidentally crediting production accounts.": "請在 Pancake 控制台中將每個 URL 註冊到對應的測試模式/生產模式 webhook 槽位。分離端點可以避免測試流量誤入生產用戶。",
"Register Passkey": "註冊 Passkey",
+ "Registered": "已注册",
"Registered a passkey": "註冊了一個 Passkey",
"Registration Enabled": "註冊已啟用",
"Registration flow expired. Please try again.": "註冊流程已過期,請再試一次。",
"Registry (optional)": "註冊表 (可選)",
"Registry secret": "註冊表金鑰",
"Registry username": "註冊表用戶名",
+ "Reinstall latest": "重新安裝最新版",
"Reject Reason": "拒絕原因",
"Release details": "版本詳情",
"Released": "發佈於",
@@ -3806,6 +3948,7 @@
"Remove Passkey": "解綁 Passkey",
"Remove Passkey?": "移除通行金鑰?",
"Remove rule group": "移除規則組",
+ "Remove source {{name}}": "移除來源 {{name}}",
"Remove string prefix": "去掉字串前綴",
"Remove string suffix": "去掉字串後綴",
"Remove the target field": "刪除目標欄位",
@@ -3855,8 +3998,10 @@
"Request Model": "請求模型",
"Request Model:": "請求模型:",
"Request overrides, routing behavior, and upstream model automation": "請求覆蓋、路由行為和上游模型自動化",
+ "Request Path": "請求路徑",
"Request retry": "請求重試",
"Request rule pricing": "請求規則收費",
+ "Request rules apply on top of this amount.": "請求規則會在此金額之上繼續套用。",
"Request success rate sampled over the last 24 hours": "最近 24 小時按時間桶採樣的請求成功率",
"Request success rate; {{incidents}} incident buckets in the last 24 hours": "請求成功率;最近 24 小時 {{incidents}} 個異常桶",
"Request timed out, please refresh and restart GitHub login": "請求逾時,請重新整理頁面後重新發起 GitHub 登入",
@@ -3919,6 +4064,7 @@
"Reset usage window": "重置用量窗口",
"Resets in:": "將於以下時間重置:",
"Resetting...": "重置中...",
+ "Resize column": "調整欄寬",
"Resolve Conflicts": "解決衝突",
"Resource Configuration": "資源設定",
"Resources": "資源",
@@ -3951,6 +4097,7 @@
"Revenue": "收入",
"Review & initialize": "審核並初始化",
"Review and sign out devices currently using your account.": "查看並登出目前正在使用您帳號的裝置。",
+ "Review and upgrade": "審查並升級",
"Review model rates before scaling traffic": "擴展流量前查看模型費率",
"Review your payment details": "查看您的付款詳情",
"Review your purchase details before proceeding.": "在繼續之前,請審閱您的購買詳情。",
@@ -3962,6 +4109,7 @@
"Role": "角色",
"Roleplay": "角色扮演",
"Root": "Root",
+ "Root Diagnostics": "Root 診斷",
"Rose Garden": "玫瑰花園",
"Route": "路由",
"Route active": "路由已啟用",
@@ -4001,16 +4149,20 @@
"Rules JSON": "規則 JSON",
"Rules JSON must be an array": "規則 JSON 必須是陣列",
"Rules match the original model value from the client request body.": "規則匹配客戶端請求體裡的原始 model 值。",
+ "Run dry run": "執行試跑",
"Run GC": "執行 GC",
"Run tests for the selected models": "執行所選模型的測試",
"running": "執行中",
"Running": "執行中",
+ "Running dry run": "正在執行試跑",
"Runtime": "執行環境",
+ "Runtime status": "运行状态",
"Runway": "可用時長",
"s": "秒",
"Safety Settings": "安全設定",
"Same as Local": "與本地相同",
"Sampling temperature; lower is more deterministic": "採樣溫度;越低越穩定",
+ "Sandbox": "沙盤",
"Sandbox mode": "沙盒模式",
"Save": "儲存",
"Save & Submit": "儲存並提交",
@@ -4091,6 +4243,8 @@
"Search the public web at inference time": "推理時檢索公開互聯網",
"Search vendors...": "搜尋供應商...",
"Search...": "搜尋...",
+ "second": "秒",
+ "Second": "秒",
"seconds": "秒",
"Secret env (JSON object)": "金鑰環境 (JSON 物件)",
"Secret environment variables (JSON)": "金鑰環境變數 (JSON)",
@@ -4116,6 +4270,7 @@
"Select a timestamp before clearing logs.": "清除日誌前請選擇一個時間戳。",
"Select a usage mode to continue": "選擇使用模式以繼續",
"Select a verification method first": "請先選擇驗證方式",
+ "Select a version to compare": "选择要比较的版本",
"Select active subscription plan": "選擇有效訂閱套餐",
"Select all": "全選",
"Select all (filtered)": "全選(篩選結果)",
@@ -4176,6 +4331,7 @@
"Select sync channels to compare prices": "選擇同步渠道以對比價格",
"Select sync channels to compare ratios": "選擇同步渠道以比較比率",
"Select Sync Source": "選擇同步源",
+ "Select task plugin": "選擇任務外掛",
"Select the API endpoint region": "選擇 API 終端節點區域",
"Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.": "選擇要使用上游數據覆蓋的欄位。未選擇的欄位將保留其本地值。",
"Select theme preference": "選擇主題偏好",
@@ -4189,6 +4345,7 @@
"Selected conflicts were overwritten successfully.": "選中的衝突已成功覆蓋。",
"Selected nodes": "已選節點",
"Selected when creating a token and used as the default billing group for API calls.": "建立令牌時選擇,用作 API 呼叫的預設收費分組。",
+ "Selecting a plugin fills its declared models.": "選擇外掛後將自動填入其宣告的模型。",
"Self-Use Mode": "自用模式",
"Send": "發送",
"Send a request": "發送請求",
@@ -4321,12 +4478,15 @@
"Sort by ID": "使用 ID 排序",
"Sort Order": "排序",
"Source": "來源",
+ "Source diff": "原始碼差异",
"Source Endpoint": "來源端點",
"Source Field": "來源欄位",
"Source Header": "來源請求頭",
+ "Source name": "來源名稱",
"sources": "來源",
"Space-separated OAuth scopes": "以空格分隔的OAuth作用域",
"Spark model version, e.g., v2.1 (version number in API URL)": "Spark 模型版本,例如 v2.1(API URL 中的版本號)",
+ "Spec": "規格",
"Special billing expression": "特殊收費表達式",
"Special group": "特殊分組",
"Special ratio rules": "特殊倍率規則",
@@ -4508,11 +4668,21 @@
"Target Path (optional)": "目標路徑(可選)",
"Target User": "目標用戶",
"Task": "任務",
+ "Task billing": "任務計費",
+ "Task Details": "任務詳情",
"Task History": "歷史任務",
"Task ID": "任務 ID",
"Task ID:": "任務 ID:",
"Task logs": "任務日誌",
"Task Logs": "任務日誌",
+ "Task Plugin": "任務外掛",
+ "Task plugin setting updated": "任務外掛設定已更新",
+ "Task plugin *": "任務外掛 *",
+ "Task Plugins": "任务外掛",
+ "Task pricing": "任務定價",
+ "Task pricing not configured": "尚未設定任務定價",
+ "Task usage prices are USD per declared unit. They are not token prices and are not divided by one million.": "任務用量價格以每個已宣告單位的美元金額計價,不是 token 價格,也不會除以一百萬。",
+ "Task usage prices are USD per declared unit. Token fields use dollars per 1M tokens; the editor writes / 1000000 into the expression. Other units are not divided by one million.": "任務用量價格依宣告單位以美元計。token 欄位依每 100 萬 token 的美元價輸入,編輯器會在表達式中寫入 / 1000000。其他單位不會除以一百萬。",
"Tasks currently pending or running.": "目前等待中或執行中的任務。",
"Team Collaboration": "團隊協作",
"Technical Support": "技術支援",
@@ -4565,12 +4735,15 @@
"The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "已連結產品用於錢包儲值:當用戶輸入任意金額時,new-api 會基於這個單一 Pancake 產品發起結帳,並按對話覆蓋價格,無需預先建立 $1 / $5 / $10 的 SKU。",
"The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "已連結店鋪是 new-api 從此管理端建立的所有 Pancake 產品的父容器,包括錢包儲值產品和訂閱套餐產品。一個店鋪通常足夠;只有在確實運營多個 Pancake 目錄時才需要連結不同店鋪。",
"The deployment node that handled the requests": "處理請求的部署節點",
+ "The downloaded source does not match the sha256 declared in the index. Do not install it.": "下載到的原始碼與索引宣告的 sha256 不一致,請勿安裝。",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "用於 Passkey 註冊的有效域。必須與目前域匹配或為其父域。",
"The entered text does not match the required text.": "輸入文字與要求文字不匹配。",
"The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "環境(測試或生產)由你在此貼上的金鑰決定。整合期間使用測試金鑰,上線時再切換為生產金鑰。",
"The exact model identifier as used in API requests.": "API 請求中使用的確切模型標識符。",
"The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "以下模型存在收費類型衝突(固定價格 vs 比例收費)。確認以繼續更改。",
"The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "模型重新導向裡的下列模型尚未新增到「模型」列表,呼叫時會因為缺少可用模型而失敗:",
+ "The gateway rejected this plugin": "閘道拒絕了該外掛",
+ "The index could not be fetched or parsed: {{message}}. The host may block cross-origin requests.": "索引無法取得或解析:{{message}}。該主機可能禁止跨來源請求。",
"The login session that started this Telegram binding is no longer valid.": "發起此 Telegram 綁定的登入工作階段已失效。",
"The mapped upstream model(s)": "映射的上游模型",
"The model that was requested": "被請求的模型",
@@ -4594,6 +4767,7 @@
"The upstream natively supports all three protocols; every selected route is forwarded without conversion.": "上游原生支援三種協議,所選路由均不經轉換直接轉發。",
"The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.": "上游回應是有效 JSON,但不符合 OpenAI credit_summary 格式,渠道餘額未更新。",
"The URL for this chat client.": "此聊天用戶端的 URL。",
+ "The URL returned HTTP {{status}}. Check the address, or download the file and paste its source below.": "該 URL 回傳 HTTP {{status}}。請檢查網址,或下載檔案後將原始碼貼到下方。",
"The user group applied to the requests": "請求所套用的用戶分組",
"The user who made the requests": "發起請求的用戶",
"Theme": "主題",
@@ -4603,11 +4777,18 @@
"There is a rule for vip billed as premium → use its ratio 0.3": "存在「vip 按 premium 收費」的規則 → 用規則裡的 0.3",
"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.": "這些模型仍然在您的勾選列表中,但上游已不再返回該名稱;僅作為 model_mapping 來源鍵而不會出現在 upstream 列表的別名已從本視圖排除,請在儲存前調整勾選。",
"These toggles affect whether certain request fields are passed through to the upstream provider.": "這些開關控制某些請求欄位是否透傳到上游服務。",
+ "These values come from the source index and are shown for review only. The gateway admits the plugin based on the metadata compiled from its source.": "這些值來自來源索引,僅供審查參考。閘道的准入判定只依據從原始碼編譯出的真實中介資料。",
"Thinking Suffix Adapter": "思考後綴配接器",
"Thinking to Content": "思維到內容",
"Thinking...": "思考中...",
+ "Third-party": "第三方",
+ "Third-party — use at your own risk": "第三方 — 風險自負",
"Third-party account bindings (read-only, managed by user in profile settings)": "第三方用戶連結(唯讀,由用戶在個人資料設定中管理)",
"Third-party Payment Config": "第三方支付設定",
+ "Third-party plugin risk": "第三方外掛风险",
+ "Third-party source risk": "第三方來源風險",
+ "Third-party-only plugins become unavailable immediately. In-flight tasks will be handled by timeout cleanup.": "純第三方外掛將立即不可用,進行中的任務將由逾時清理處理。",
+ "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.": "出廠外掛與自訂外掛將立即停止服務。進行中的任務將由逾時清理處理。",
"This action cannot be undone.": "此操作無法撤銷。",
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "此操作無法撤銷。這將永久刪除您的用戶並從我們的伺服器中移除您的所有數據。",
"This action will permanently remove 2FA protection from your account.": "此操作將永久移除您用戶的 2FA 保護。",
@@ -4618,11 +4799,13 @@
"This channel is not an Ollama channel.": "該渠道不是 Ollama 渠道。",
"This channel type does not support fetching models": "此渠道類型不支援獲取模型",
"This channel type requires additional configuration": "此渠道類型需要填寫額外設定",
+ "This combination will be billed as free.": "此組合將免費計費。",
"This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.": "此確認會解鎖支付、兌換碼、訂閱套餐和邀請獎勵功能。請仔細閱讀相關聲明。",
"This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.": "此處僅控制模型請求速率限制。Web/API 路由限流由環境變數設定,仍可能返回 429。",
"This data may be unreliable, use with caution": "此數據可能不可靠,請謹慎使用",
"This device does not support Passkey": "此設備不支援 Passkey",
"This device does not support Passkey verification.": "此設備不支援 Passkey 驗證。",
+ "This expression does not price each combination exactly once, so it opens as a raw expression. Sparse or custom pricing stays in this editor.": "此運算式未對每種組合恰好定價一次,因此將以原始運算式模式開啟。稀疏或自訂定價會保留在此編輯器中。",
"This expression is too complex for the visual editor. Please switch to expression mode to edit.": "此表達式對可視化編輯器過於複雜,請切換到表達式模式進行編輯。",
"This FAQ entry will be removed from the list.": "此 FAQ 條目將從列表中移除。",
"This feature is experimental. Configuration format and behavior may change.": "此功能為實驗性功能。設定格式和行為可能會發生變化。",
@@ -4630,15 +4813,19 @@
"This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "該條歷史記錄缺少審計欄位。目前版本已支援記錄伺服器 IP、Callback IP、支付方式與系統版本等審計資訊;這些欄位僅會寫入後續新產生的記錄,歷史記錄無法自動補齊。",
"This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "建立訂單時會把這個標識提交給支付後端。支付寶填 alipay,微信填 wxpay,Stripe 填 stripe。自訂值必須是支付服務支援的標識。",
"This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.": "該實例正在使用自動主機名稱。請設定穩定且唯一的 NODE_NAME,以便進行多實例管理。",
+ "This is a task model billed by usage (e.g. seconds, resolution). Prices entered here act as a per-call base rate, not per-token prices.": "這是按用量(例如秒數、解析度)計費的任務模型。此處輸入的價格會作為每次呼叫的基礎費率,而不是按 token 計價。",
"This may cause cache failures.": "這可能導致緩存故障。",
"This may take a few moments while we validate the request and update your session.": "這可能需要一些時間,因為我們正在驗證請求並更新您的對話。",
"This model has both fixed price and ratio billing conflicts": "此模型同時存在固定價格和比例收費衝突",
"This model has both fixed-price and ratio settings. Saving the current mode will rewrite the conflicting fields.": "該模型同時存在固定價格和比例設定。儲存目前模式會重寫衝突欄位。",
"This model has both fixed-price and token-price settings. Saving the current mode will rewrite the conflicting fields.": "該模型同時存在固定價格和按 token 價格設定。儲存目前模式會重寫衝突欄位。",
+ "This model is billed by usage, but the administrator has not configured its pricing yet.": "此模型按用量計費,但管理員尚未設定價格。",
"This model is not available in any group, or no group pricing information is configured.": "此模型在任何分組中均不可用,或未設定分組定價資訊。",
"This month": "本月獲得",
"This page has not been created yet.": "此頁面尚未建立。",
"This plan does not allow balance redemption": "該套餐不允許使用餘額兌換",
+ "This plugin has no factory fallback. Deleting or disabling it makes this platform unavailable.": "此外掛没有內建回落版本,刪除或禁用后该平台将不可用。",
+ "This plugin path does not resolve within the source repository.": "該外掛路徑並未指向來源儲存庫內的位置。",
"This project must be used in compliance with the": "此項目的使用必須遵守",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "此操作將從該渠道移除 {{count}} 個測試失敗的模型,且無法撤銷。",
"This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "此路由用於探索上游 OpenAI 模型,無法拆分或使用用戶端模型規則配對。",
@@ -4646,6 +4833,8 @@
"This route is used only by channel management to query the upstream balance.": "此路由僅供渠道管理查詢上游餘額。",
"This session will lose access immediately and must sign in again.": "此工作階段將立即失去存取權限,且必須重新登入。",
"This site currently has {{count}} models enabled": "本站目前已啟用模型,總計 {{count}} 個",
+ "This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.": "該來源未為此版本提供 sha256,因此無法確認下載到的原始碼與該來源所發布的內容一致。",
+ "This source lists no installable task plugins.": "該來源未列出任何可安裝的任務外掛。",
"This Telegram account is already bound.": "此 Telegram 帳號已綁定。",
"This Telegram binding request has expired or has already been used.": "此 Telegram 綁定要求已過期或已使用。",
"This tier catches any request that did not match earlier tiers.": "此階梯會兜底處理未匹配前面階梯的請求。",
@@ -4704,6 +4893,7 @@
"times": "次",
"Timing": "耗時",
"Tip": "提示",
+ "Tip: after configuring one model, select others in the table and use bulk copy.": "提示:設定一個模型後,可在表格中選取其他模型並使用批次複製。",
"to access this resource.": "存取此資源。",
"To Anthropic Messages": "轉 Anthropic Messages",
"to confirm": "以確認",
@@ -4722,7 +4912,9 @@
"Toggle navigation menu": "切換導航選單",
"Toggle plan": "切換計劃",
"Toggle theme": "切換主題",
+ "token": "令牌",
"Token": "令牌",
+ "token (unit)": "token",
"Token Breakdown": "Token 明細",
"Token Endpoint": "令牌端點",
"Token Endpoint (Optional)": "Token 端點(可選)",
@@ -4885,6 +5077,8 @@
"Unexpected release payload": "意外的版本數據格式",
"Unified API Gateway for": "統一 API 閘道,服務於",
"Unique identifier for this group.": "此組的唯一標識符。",
+ "unit": "次",
+ "Unit": "單位",
"Unit price (local currency / USD)": "單價(本地貨幣 / USD)",
"Unit price (USD)": "單價 (USD)",
"Unit price must be greater than 0": "單價必須大於 0",
@@ -4903,6 +5097,7 @@
"Untrusted upstream data:": "不受信任的上游數據:",
"Unused": "未使用",
"Up to 4 strings that stop generation": "最多 4 個停止生成的字串",
+ "Up to date": "已是最新",
"Update": "更新",
"Update All Balances": "更新所有餘額",
"Update API Key": "更新 API 金鑰",
@@ -4941,15 +5136,26 @@
"Updated user {{username}} (ID: {{id}})": "更新用戶 {{username}}(ID: {{id}})",
"Updating all channel balances. This may take a while. Please refresh to see results.": "正在更新所有渠道餘額。這可能需要一段時間。請重新整理以查看結果。",
"Updating...": "正在更新...",
+ "Upgrade {{name}}": "升級 {{name}}",
+ "Upgrade and enable": "升級並啟用",
+ "Upgrade available: v{{installed}} to v{{latest}}": "可升級:v{{installed}} → v{{latest}}",
"Upgrade Group": "升級分組",
"Upgrade plaintext SMTP connection with STARTTLS before authentication": "在身份驗證前使用 STARTTLS 升級明文 SMTP 連接",
"Upload": "上傳",
+ "Upload a JavaScript task platform plugin.": "上傳 JavaScript 任务平台外掛。",
"Upload a single service account JSON file": "上傳單個服務賬號 JSON 檔案",
+ "Upload a task plugin to add a platform.": "上傳任务外掛以添加平台。",
"Upload file": "上傳檔案",
"Upload files": "上傳檔案",
"Upload multiple JSON files in batch modes": "大量模式下可上傳多個 JSON 檔案",
+ "Upload new plugin version": "上傳外掛新版本",
+ "Upload new version": "上傳新版本",
"Upload or reference a local configuration file.": "上傳或引用本地設定文件。",
"Upload photo": "上傳相片",
+ "Upload plugin": "上傳外掛",
+ "Upload task plugin": "上傳任务外掛",
+ "Uploading a plugin is an administrator-level trust decision. A plugin can access channel credentials and shape upstream requests. Review its source and diff before activation.": "上傳外掛属于管理员级信任行为。外掛可访问渠道凭据并构造上游请求,请在激活前审查原始碼及差异。",
+ "Uploading...": "上傳中...",
"Upscale": "放大",
"Upstream": "上游",
"Upstream did not return reset credit details.": "上游未返回重置次數詳情。",
@@ -4975,6 +5181,7 @@
"Upstream Response (billing-usage-openai-estimated)": "上游返回(billing-usage-openai-estimated)",
"Upstream Response (billing-usage-openai)": "上游返回(billing-usage-openai)",
"upstream services integrated": "上游服務適配",
+ "Upstream Task ID": "上游任務 ID",
"Upstream Updates": "上游更新",
"Upstream URL": "上游 URL",
"Upstream URL must be a full URL": "上游 URL 必須是完整 URL",
@@ -4995,7 +5202,11 @@
"Usage logs": "使用日誌",
"Usage Logs": "使用日誌",
"Usage mode": "使用模式",
+ "Usage parameters": "用量參數",
+ "Usage prices": "用量價格",
"Usage-based": "基於使用量",
+ "Usage-based billing": "按用量計費",
+ "Usage-based billing · price not configured": "按用量計費 · 尚未設定價格",
"USD": "USD",
"USD Exchange Rate": "美元匯率",
"USD price per 1M input tokens.": "每 100 萬輸入 token 的美元價格。",
@@ -5084,6 +5295,7 @@
"Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "用戶只能看到標記為用戶可選的分組。不可選分組仍可由管理員分配。",
"uses": "使用次數",
"Using the complete global Auto order ({{count}} groups)": "正在使用完整的全域 Auto 順序({{count}} 個分組)",
+ "v{{installed}} is installed but this source does not list it. Installing replaces it with v{{target}}.": "目前已安裝 v{{installed}},但該來源未列出此版本。安裝將會以 v{{target}} 取代它。",
"Validity": "有效期",
"Validity Period": "有效期",
"Value": "值",
@@ -5124,7 +5336,9 @@
"Verify your database connection": "驗證資料庫連接",
"Verifying credentials and pulling stores from your Pancake account...": "正在驗證憑證並從你的 Pancake 用戶拉取店鋪...",
"Version": "版本",
+ "Version history": "版本历史",
"Version Overrides": "版本覆蓋",
+ "Versions": "版本",
"Vertex AI": "Vertex AI",
"Vertex AI API Key mode does not support batch creation": "Vertex AI API Key 模式不支援大量建立",
"Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.": "Vertex AI 不支援 functionResponse.id 欄位,開啟後將自動移除該欄位",
@@ -5148,6 +5362,7 @@
"View Pricing": "查看定價",
"View the complete details for this": "查看此條",
"View the complete details for this log entry": "查看此日誌條目的完整詳情",
+ "View the complete details for this task": "查看此任務的完整詳情",
"View the complete error message and details": "查看完整錯誤資訊與詳情",
"View the complete prompt and its English translation": "查看完整提示詞及其英文翻譯",
"View the generated image": "查看生成的圖片",
@@ -5240,6 +5455,8 @@
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "當令牌使用 auto 分組時,系統會按從上到下的順序嘗試,直到找到可用分組。",
"When billed as {{group}}": "按 {{group}} 收費時",
"When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.": "條件滿足時,最終價格乘以 X;多條命中的倍率會相乘;小於 1 的值為折扣。",
+ "When disabled, all uploaded custom plugins are ignored and every platform falls back to its built-in factory plugin.": "關閉後,所有上傳的自訂外掛都會被忽略,每個平台均回復使用內建的出廠外掛。",
+ "When disabled, the entire task plugin system stops serving, including factory and custom plugins.": "關閉後,整個任務外掛系統停止服務,包括出廠外掛與自訂外掛。",
"When enabled, if channels in the current group fail, it will try channels in the next group in order.": "開啟後,目前分組渠道失敗時會按順序嘗試下一個分組的渠道。",
"When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. Leave it off to delete the entry and select another channel.": "開啟後,親和到的渠道被停用,或不再適用於目前分組/模型時,仍保留這條親和;關閉時會刪除並重新選擇渠道。",
"When enabled, large request bodies are temporarily stored on disk instead of memory, significantly reducing memory usage. SSD recommended.": "啟用磁碟緩存後,大請求體將臨時儲存到磁碟而非記憶體,可顯著降低記憶體佔用。建議在 SSD 環境下使用。",
diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json
index e0f35b410feb..90dec7d69a80 100644
--- a/web/src/i18n/locales/zh.json
+++ b/web/src/i18n/locales/zh.json
@@ -29,7 +29,9 @@
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]": "[{\"name\":\"支付宝\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]",
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]": "[{\"name\":\"支付宝\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]",
"{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
+ "{{bytes}} bytes": "{{bytes}} 字节",
"{{category}} Models": "{{category}} 模型",
+ "{{channels}} channels, {{tasks}} in-flight tasks": "{{channels}} 个渠道,{{tasks}} 个在途任务",
"{{completed}}/{{total}} completed": "已完成 {{completed}}/{{total}}",
"{{count}} / {{max}} groups selected": "已选择 {{count}} / {{max}} 个分组",
"{{count}} announcements will be removed from the list.": "将从列表中移除 {{count}} 条公告。",
@@ -39,9 +41,11 @@
"{{count}} channel(s) enabled": "已启用 {{count}} 个渠道",
"{{count}} channel(s) failed to disable": "{{count}} 个渠道禁用失败",
"{{count}} channel(s) failed to enable": "{{count}} 个渠道启用失败",
+ "{{count}} combinations": "{{count}} 个组合",
"{{count}} days ago": "{{count}} 天前",
"{{count}} days remaining": "剩余 {{count}} 天",
"{{count}} disabled channel(s) deleted": "已删除 {{count}} 个已禁用的渠道",
+ "{{count}} enabled channels and {{tasks}} in-flight tasks still use this plugin.": "仍有 {{count}} 个启用渠道和 {{tasks}} 个在途任务使用此插件。",
"{{count}} FAQ entries will be removed from the list.": "将从列表中移除 {{count}} 个 FAQ 条目。",
"{{count}} hours ago": "{{count}} 小时前",
"{{count}} incidents": "{{count}} 起事件",
@@ -60,6 +64,7 @@
"{{count}} weeks ago": "{{count}} 周前",
"{{field}} updated to {{value}}": "{{field}} 已更新为 {{value}}",
"{{field}} updated to {{value}} for tag: {{tag}}": "标签「{{tag}}」的 {{field}} 已更新为 {{value}}",
+ "{{key}} · version {{version}} · from {{source}}": "{{key}} · 版本 {{version}} · 来自 {{source}}",
"{{method}} {{route}}": "{{method}} {{route}}",
"{{modality}} not supported": "不支持 {{modality}}",
"{{modality}} supported": "支持 {{modality}}",
@@ -104,6 +109,7 @@
"14 Days": "14 天",
"192.168.1.1
10.0.0.0/8": "192.168.1.1
10.0.0.0/8",
"1M": "1 个月",
+ "1M token": "1M token",
"1W": "1 周",
"2. Copy the application token": "2. 复制应用程序令牌",
"20 / page": "20 条/页",
@@ -147,6 +153,7 @@
"Action": "操作",
"Action confirmation": "操作确认",
"Actions": "操作",
+ "Activate / Roll back": "激活 / 回滚",
"active": "活跃",
"Active": "生效",
"Active apps": "活跃应用",
@@ -155,6 +162,7 @@
"Active models": "活跃模型",
"Active Tasks": "进行中任务",
"active users": "活跃用户",
+ "Active version": "激活版本",
"Actively check all channels": "主动检查全部渠道",
"Actively check auto-disable-enabled channels": "主动检查已开启自动禁用的渠道",
"Actual Amount": "实付金额",
@@ -171,6 +179,7 @@
"Add a new user by providing necessary info.": "通过提供必要信息来添加新用户。",
"Add a new vendor to the system": "向系统添加新供应商",
"Add an extra layer of security to your account": "为您的账户添加额外的安全层",
+ "Add an index URL to browse installable plugins.": "添加一个索引 URL 即可浏览可安装的插件。",
"Add and submit": "添加后提交",
"Add Announcement": "添加公告",
"Add API": "添加 API",
@@ -217,6 +226,7 @@
"Add rule group": "新增规则组",
"Add rules for a user group": "为用户分组添加规则",
"Add selectable group": "添加可选分组",
+ "Add source": "添加源",
"Add split": "添加分流",
"Add subscription": "新增订阅",
"Add tags...": "添加标签...",
@@ -292,6 +302,7 @@
"All": "全部",
"All API tokens": "全部 API 密钥",
"All categories": "全部分类",
+ "All combinations are priced at zero. Matching requests will be billed as free.": "所有组合的价格均为零。匹配的请求将免费计费。",
"All conditions must match before this tier is used.": "所有条件都匹配后才会使用此阶梯。",
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "所有编辑都是覆盖操作。留空字段将保持当前值不变。",
"All files exceed the maximum size.": "所有文件都超过最大尺寸。",
@@ -348,6 +359,7 @@
"Allow using models without price configuration": "允许使用未配置价格的模型",
"Allow wallet balance after quota used up": "额度用尽后允许使用钱包余额",
"Allowed": "允许",
+ "Allowed hosts": "允许访问的主机",
"Allowed Origins": "允许的 Origins",
"Allowed Ports": "允许的端口",
"Already have an account?": "已有账户?",
@@ -380,6 +392,7 @@
"Anthropic": "Anthropic",
"Anthropic Messages to OpenAI Chat": "Anthropic Messages 到 OpenAI Chat",
"Any Match (OR)": "任一满足(OR)",
+ "Anyone can publish an index. A plugin installed from a third-party source has the same access as one you upload by hand: review its source before installing.": "任何人都可以发布索引。从第三方源安装的插件与你手动上传的插件拥有完全相同的权限,请在安装前审查其源码。",
"API": "API",
"API Access": "API 访问",
"API Addresses": "API 地址",
@@ -414,6 +427,8 @@
"API token management": "API令牌管理",
"API URL": "API URL",
"API usage records": "API使用记录",
+ "API version": "API 版本",
+ "API Version": "API 版本",
"API2GPT": "API2GPT",
"App": "应用",
"App rankings shown here are simulated for preview purposes and will be replaced with live usage data once the backend integration is complete.": "此处展示的应用排行为预览模拟数据,待后端对接完成后将替换为真实数据。",
@@ -437,8 +452,10 @@
"Apply plan": "应用方案",
"Apply reset": "执行重置",
"Apply Sync": "应用同步",
+ "Apply to all rows": "应用到所有行",
"Applying...": "正在应用...",
"Approx.": "约",
+ "Approximate prices for common specs.": "常见规格的参考价。",
"apps": "个应用",
"Apps": "应用",
"apps tracked": "个应用追踪中",
@@ -461,12 +478,17 @@
"Are you sure?": "您确定吗?",
"Area Chart": "面积图",
"Args (space separated)": "参数 (空格分隔)",
+ "Arguments JSON": "参数 JSON",
+ "Arguments must be a JSON array": "参数必须是 JSON 数组",
"Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "聊天客户端预设数组。每个项目都是一个对象,包含一个键值对:客户端名称及其 URL。",
+ "Artifacts": "制品",
"Asc": "升序",
"Ask anything": "随便问",
"Assigned by administrator only": "仅管理员分配",
"Assigned by administrators and used to represent a user level, such as default or vip.": "由管理员分配,用于表示用户等级,例如 default 或 vip。",
+ "Async": "异步",
"Async task polling": "异步任务轮询",
+ "Async Task Public Address": "异步任务对外地址",
"Async task refund": "异步任务退款",
"At least one model regex pattern is required": "至少需要一个模型正则匹配模式",
"At least one valid key source is required": "至少需要一个有效的密钥来源",
@@ -585,8 +607,10 @@
"Balance updated: {{balance}}": "余额已更新:{{balance}}",
"Bar Chart": "柱状图",
"Bark Push URL": "Bark 推送 URL",
+ "Base": "基础",
"Base address provided by your Epay service": "您的 Epay 服务提供的基础地址",
"Base amount. Actual deduction = base amount × system group rate.": "基础金额,实际扣费 = 基础金额 × 系统分组倍率。",
+ "Base charge": "基础费用",
"Base input and output token prices for this tier.": "此阶梯的基础输入和输出 token 价格。",
"Base input price only": "仅基础输入价格",
"Base Limits": "基础额度",
@@ -594,6 +618,7 @@
"Base Price": "基础价格",
"Base rate limit windows for this account.": "当前账号的基础额度窗口。",
"Base URL": "API 地址",
+ "Base URL *": "基础 URL *",
"Base URL is required for this channel type": "此渠道类型需要填写 Base URL",
"Base URL is required when an advanced route uses an upstream path": "高级路由使用上游路径时必须填写 Base URL",
"Base URL of your Uptime Kuma instance": "您的 Uptime Kuma 实例的基础 URL",
@@ -638,6 +663,7 @@
"Billing group = vip (the token has no group, so use the user group)": "计费分组 = vip(令牌没设置分组,就用用户自己的分组)",
"Billing History": "计费历史",
"Billing Mode": "计费模式",
+ "Billing parameters": "计费参数",
"Billing Path": "计费路径",
"Billing Process": "计费过程",
"Billing rule: each call is billed as the token group (falling back to the user group when the token has none). The base ratio always comes from that billing group, not from the user group. To give a user group a special price on another billing group, add an entry in the override matrix.": "计费规则:每次调用按令牌分组计费(令牌未设置分组时回退到用户分组)。基础倍率始终取该计费分组的倍率,而不是用户分组的倍率。若要让某用户分组在使用其他计费分组时享受特殊价格,请在覆盖矩阵中添加条目。",
@@ -648,6 +674,7 @@
"Bind Email": "绑定邮箱",
"Bind Telegram Account": "绑定 Telegram 账户",
"Bind WeChat Account": "绑定微信账户",
+ "Bind task plugins": "绑定任务插件",
"Binding Information": "绑定信息",
"Binding successful!": "绑定成功!",
"Binding your {{provider}} account": "正在绑定您的 {{provider}} 账号",
@@ -686,6 +713,7 @@
"Built for developers,": "为开发者打造,",
"Built-in": "内置",
"Built-in Device": "内置设备",
+ "Built-in v{{factory}} / marketplace v{{market}}": "内置 v{{factory}} / 市场 v{{market}}",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "内置:手机指纹/面部,或 Windows Hello;外部:USB 安全密钥",
"by": "由",
"By category": "按行业",
@@ -738,6 +766,7 @@
"Caps the response length": "限制回复长度",
"Capture a reusable bundle of models, tags, or endpoints.": "捕获可重用的模型、标签或端点捆绑包。",
"Card view": "卡片视图",
+ "Cascade disable channels": "级联禁用渠道",
"Catch-all route must be last for the same incoming path": "同一入口路径的兜底路由必须放在最后",
"Category": "分类",
"Category Name": "分类名称",
@@ -779,7 +808,9 @@
"Channel test concurrency": "渠道测试并发数",
"Channel test concurrency must be between 1 and 32": "渠道测试并发数必须在 1 到 32 之间",
"Channel test mode": "渠道测试模式",
+ "Channel type": "渠道类型",
"Channel type is required": "渠道类型是必填的",
+ "Channel types": "渠道类型",
"Channel updated successfully": "渠道更新成功",
"Channel-specific settings (JSON format)": "渠道特定设置(JSON 格式)",
"Channel:": "渠道:",
@@ -950,6 +981,7 @@
"Compare the most popular models on the platform": "对比平台上最受欢迎的模型",
"compatible API routes": "兼容 API 路由",
"Compatible API routes for common AI application workflows": "兼容常见 AI 应用工作流的 API 路由",
+ "Compilation failed": "编译失败",
"Complete API documentation with multi-language SDK support": "完整的 API 文档,支持多语言 SDK",
"Complete Order": "补单",
"Complete these steps to finish the initial installation.": "完成这些步骤以完成初始安装。",
@@ -1000,6 +1032,7 @@
"Configure pricing ratios for a specific model.": "配置特定模型的定价比例。",
"Configure rate limiting rules for a specific user group.": "配置特定用户分组的速率限制规则。",
"Configure routes": "配置路由",
+ "Configure task pricing": "配置任务定价",
"Configure the ratio for this group.": "配置此分组的比例。",
"Configure upstream providers and routing.": "配置上游提供者和路由。",
"Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "配置 Waffo Pancake 托管结账,用于美元计价的充值",
@@ -1143,6 +1176,10 @@
"Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "费用 = 模型价格 × 这一个倍率。分组设置里的其他项都不参与该公式。",
"Cost in USD per request, regardless of tokens used.": "每请求的美元费用,不考虑使用的令牌数。",
"Cost Tracking": "成本跟踪",
+ "Could not fetch the plugin source from this browser. The host may block cross-origin requests or be unreachable.": "无法在浏览器中拉取插件源码。该主机可能禁止跨域请求或无法访问。",
+ "Could not fetch this URL from the browser. The host may block cross-origin requests or be unreachable. Download the file and paste its source below.": "无法在浏览器中拉取该 URL。该主机可能禁止跨域请求或无法访问。请下载文件后将源码粘贴到下方。",
+ "Could not load this source": "无法加载该源",
+ "Count": "数量",
"Count must be between {{min}} and {{max}}": "计数必须介于{{min}}和{{max}}之间",
"Coze": "Coze",
"CPU": "CPU",
@@ -1199,6 +1236,7 @@
"Credentials": "凭证",
"Credentials verification failed": "凭证验证失败",
"Credentials verification failed — double-check Merchant ID and API private key.": "凭证验证失败,请检查 Merchant ID 和 API 私钥。",
+ "credit": "credit",
"Credit remaining": "剩余额度",
"Creem API key (leave blank unless updating)": "Creem API 密钥(除非更新,否则留空)",
"Creem Gateway": "Creem 网关",
@@ -1226,6 +1264,7 @@
"Current version": "当前版本",
"Current:": "当前:",
"Custom": "自定义",
+ "Custom (overrides factory {{version}})": "自定义(覆盖出厂版 {{version}})",
"Custom (seconds)": "自定义(秒)",
"Custom Amount": "自定义金额",
"Custom API base URL. For official channels, New API has built-in addresses. Only fill this for third-party proxy sites or special endpoints. Do not add /v1 or trailing slash.": "自定义 API 基础 URL。对于官方渠道,New API 具有内置地址。仅针对第三方代理站点或特殊端点填写此项。请勿添加 /v1 或尾部斜杠。",
@@ -1245,6 +1284,7 @@
"Custom OAuth Providers": "自定义OAuth提供商",
"Custom Seconds": "自定义秒数",
"Custom sidebar section": "自定义侧边栏部分",
+ "Custom task plugin setting updated": "自定义任务插件设置已更新",
"Custom Time Range": "自定义时间范围",
"Custom Zoom": "自定义缩放",
"Customize sidebar display content": "个性化设置左侧边栏的显示内容",
@@ -1274,6 +1314,7 @@
"Days to Retain": "保留天数",
"decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.": "决定充值倍率、用户建令牌时可选哪些分组,以及是否命中覆盖倍率。",
"decides which channels are used and which base ratio applies.": "决定走哪些渠道、用哪个基础倍率。",
+ "Declared capabilities": "声明的能力",
"Decreased user quota by {{quota}}": "减少用户额度 {{quota}}",
"Deducted by subscription": "由订阅抵扣",
"DeepSeek": "DeepSeek",
@@ -1306,6 +1347,7 @@
"Delete {{count}} stale instance records? Online instances will not be deleted.": "删除 {{count}} 条失联实例记录?在线实例不会被删除。",
"Delete a runtime request header": "删除运行期请求头",
"Delete Account": "删除账户",
+ "Delete active custom version": "删除当前自定义版本",
"Delete All Disabled": "删除所有已禁用",
"Delete All Disabled Channels?": "删除所有已禁用的渠道?",
"Delete all stale": "删除所有失联",
@@ -1329,6 +1371,7 @@
"Delete mapping": "删除映射",
"Delete Model": "删除模型",
"Delete Models?": "删除模型?",
+ "Delete plugin version?": "删除插件版本?",
"Delete Provider": "删除提供商",
"Delete Request Header": "删除请求头",
"Delete selected API keys": "删除选定的 API 密钥",
@@ -1355,6 +1398,7 @@
"Deleted stale instance": "已删除失联实例",
"Deleted successfully": "删除成功",
"Deleted user {{username}} (ID: {{id}})": "删除用户 {{username}}(ID: {{id}})",
+ "Deleting this custom version does not disable the platform. The same-name factory plugin will be restored automatically.": "删除此自定义版本不会停用平台,同名出厂插件将自动恢复。",
"Deleting will permanently remove this subscription record (including benefit details). Continue?": "删除会彻底移除该订阅记录(含权益明细)。是否继续?",
"Deleting...": "删除中...",
"Demo site": "演示站点",
@@ -1401,6 +1445,8 @@
"Disable": "禁用",
"Disable 2FA": "禁用 2FA",
"Disable All": "禁用全部",
+ "Disable custom task plugins?": "禁用自定义任务插件?",
+ "Disable task plugins?": "禁用任务插件?",
"Disable on failure": "失败时禁用",
"Disable selected channels": "禁用选定的渠道",
"Disable selected models": "禁用选定的模型",
@@ -1415,6 +1461,8 @@
"Disabled lanes are omitted on save.": "关闭的价格通道保存时会被省略。",
"Disabled Reason": "禁用原因",
"Disabled Time": "禁用时间",
+ "Disabled; fell back to factory": "已禁用;已回落出厂版",
+ "Disabled; platform unavailable": "已禁用;平台不可用",
"Disabling...": "禁用中...",
"Disclaimer: Personal use only. Do not distribute or share any credentials. This channel has prerequisites and requires prior setup; use it only if you understand the flow and risks, and comply with OpenAI's terms and policies. Credentials and configuration are for Codex CLI integration only, and are not intended for any other client, platform, or channel.": "免责声明:仅限个人使用,请勿分发或共享任何凭证。该渠道存在前置条件与使用门槛,请在充分了解流程与风险后使用,并遵守 OpenAI 的相关条款与政策。相关凭证与配置仅限接入 Codex CLI 使用,不适用于其他客户端、平台或渠道。",
"Discord": "Discord",
@@ -1480,6 +1528,7 @@
"Drawing Logs": "绘图日志",
"Drawing task polling": "绘图任务轮询",
"Drawing task records": "绘图任务记录",
+ "Dry run result": "干跑结果",
"Duplicate": "重复",
"Duplicate group names: {{names}}": "存在重复的分组名称:{{names}}",
"Duplicate model in route models": "路由模型中存在重复模型",
@@ -1540,7 +1589,9 @@
"Each item must have exactly one key-value pair.": "每个条目必须恰好包含一个键值对。",
"Each line represents one keyword. Leave blank to disable the list but keep the switch states.": "每行代表一个关键词。留空以禁用列表,但保留开关状态。",
"Each matrix cell is one rule: users of this row group pay this ratio when billed as this column group. In JSON the row is the outer key and the column is the inner key.": "矩阵的每个单元格是一条规则:该行用户分组的用户按该列分组计费时使用此倍率。在 JSON 中行是外层键,列是内层键。",
+ "Each row prices one combination of {{fields}}.": "每行分别为一种 {{fields}} 组合定价。",
"Each rule reads as a sentence: users of one group pay a special ratio when billed as another group. Without a rule, the billing group base ratio applies.": "每条规则就是一句话:某分组的用户按另一分组计费时享受特殊倍率。没有规则时,使用计费分组的基础倍率。",
+ "Each source serves an index.json listing installable plugins. Indexes are fetched by your browser; the gateway makes no outbound requests.": "每个源都提供一个 index.json,列出可安装的插件。索引由你的浏览器拉取,网关不会发起任何外部请求。",
"Each tier supports 0~2 conditions (over len, p, c); the last tier is the catch-all without conditions. Use len (full input length, including cache hits) for tier conditions to avoid mis-routing when cache hits reduce p.": "每个档位支持 0~2 个条件(针对 len、p、c),最后一档为兜底档无需条件。建议条件使用 len(完整输入长度,含缓存命中),避免缓存命中降低 p 导致档位误判。",
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "每个档位最多支持 2 个条件;最后一个档位是不带条件的兜底档。建议使用完整输入长度作为档位条件,避免缓存命中减少计费输入 token 后误判档位。",
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "每个阶梯最多支持 2 个条件。最后一个无条件阶梯作为兜底。",
@@ -1598,6 +1649,8 @@
"Enable 2FA": "启用 2FA",
"Enable All": "启用全部",
"Enable check-in feature": "启用签到功能",
+ "Enable custom task plugins": "启用自定义任务插件",
+ "Enable task plugins": "启用任务插件",
"Enable Data Dashboard": "启用数据仪表板",
"Enable demo mode with limited functionality": "启用功能受限的演示模式",
"Enable Discord OAuth": "启用 Discord OAuth",
@@ -1617,6 +1670,7 @@
"Enable or disable this model": "启用或禁用此模型",
"Enable Passkey": "启用 Passkey",
"Enable Performance Monitoring": "启用性能监控",
+ "Enable plugin {{key}}": "启用插件 {{key}}",
"Enable rate limiting": "启用速率限制",
"Enable Request Passthrough": "启用请求透传",
"Enable selected channels": "启用选定的渠道",
@@ -1670,6 +1724,8 @@
"Enter a value and press Enter": "输入值并按回车键",
"Enter amount in {{currency}}": "输入金额({{currency}})",
"Enter amount in tokens": "输入额度(Token)",
+ "Enter an absolute HTTP(S) URL without credentials, query parameters, or fragments": "请输入不含认证信息、查询参数或片段的 HTTP(S) 绝对 URL",
+ "Enter an absolute http(s) URL.": "请输入完整的 http(s) URL。",
"Enter announcement content (supports Markdown & HTML)": "输入公告内容(支持 Markdown 和 HTML)",
"Enter announcement content (supports Markdown/HTML)": "输入公告内容(支持 Markdown/HTML)",
"Enter API Key": "请输入 API Key",
@@ -1733,6 +1789,9 @@
"Enterprise Account": "企业账户",
"Enterprise-grade security with comprehensive permission management": "企业级安全性,提供全面的权限管理",
"Entrypoint (space separated)": "入口点 (空格分隔)",
+ "Enum": "枚举",
+ "Boolean": "布尔",
+ "Enum values": "枚举值",
"Env (JSON object)": "环境变量 (JSON 对象)",
"Environment variables": "环境变量",
"Environment variables (JSON)": "环境变量 (JSON)",
@@ -1764,6 +1823,8 @@
"Example": "示例",
"Example (all channels):": "示例(全部渠道):",
"Example (specific channels):": "示例(指定渠道):",
+ "Example price": "示例价格",
+ "Example spec": "示例规格",
"Example:": "示例:",
"example.com
blocked-site.com": "example.com
blocked-site.com",
"example.com
company.com": "example.com
company.com",
@@ -1792,6 +1853,7 @@
"Expose ratio API": "暴露倍率接口",
"Exposes the pricing/models catalog in the top navigation.": "在顶部导航中显示定价/模型目录。",
"Expression": "表达式",
+ "Expression - Task pricing": "表达式-任务定价",
"Expression based": "基于表达式",
"Expression billing": "表达式计费",
"Expression editor": "表达式编辑器",
@@ -1811,6 +1873,9 @@
"Extra visible": "额外可见",
"Extra visible to {{group}}": "对 {{group}} 额外可见",
"extras": "额外项",
+ "Factory": "出厂",
+ "Factory and custom plugin behavior": "出厂与自定义插件行为",
+ "Factory plugins cannot be deleted or disabled individually. A custom version can override them; deleting or disabling that version restores the factory plugin. Third-party-only platforms become unavailable when their plugin is deleted or disabled.": "出厂插件不可删除或单独禁用。自定义版本可覆盖出厂版;删除或禁用自定义版会恢复出厂版。纯第三方平台的插件被删除或禁用后将不可用。",
"Fail Reason": "失败原因",
"Fail Reason Details": "失败原因详情",
"failed": "已失败",
@@ -1879,6 +1944,7 @@
"Failed to initialize system": "系统初始化失败",
"Failed to load": "加载失败",
"Failed to load API keys": "加载 API 密钥失败",
+ "Failed to load artifacts": "制品加载失败",
"Failed to load billing history": "加载计费历史失败",
"Failed to load enabled models": "获取启用模型失败",
"Failed to load home page content": "加载首页内容失败",
@@ -1968,15 +2034,20 @@
"Feature in development": "功能开发中",
"Fee": "扣费",
"Fee Amount": "扣费金额",
+ "Fetch": "拉取",
"Fetch available models for:": "获取可用模型:",
"Fetch available models from upstream": "从上游获取可用模型",
"Fetch from Upstream": "从上游获取",
"Fetch Models": "获取模型",
+ "Fetch mode": "拉取模式",
"Fetched {{count}} model(s) from upstream": "从上游获取了 {{count}} 个模型",
"Fetched {{count}} models": "已获取 {{count}} 个模型",
+ "Fetched in your browser and placed in the source field below for review. GitHub and gist page URLs are rewritten to their raw URL automatically.": "由浏览器拉取并填入下方源码框供你审查。GitHub 与 gist 页面 URL 会自动改写为 raw URL。",
+ "Fetching plugin source...": "正在拉取插件源码…",
"Fetching prefill groups...": "正在获取预填充分组...",
"Fetching upstream prices...": "正在获取上游价格...",
"Fetching upstream ratios...": "正在获取上游比例...",
+ "Fetching...": "正在拉取…",
"field": "字段",
"Field Mapping": "字段映射",
"Field passthrough controls": "字段透传控制",
@@ -1986,6 +2057,7 @@
"Files to Retain": "保留文件数",
"Fill All Models": "填充所有模型",
"Fill Codex CLI / Claude CLI Templates": "填充 Codex CLI / Claude CLI 模板",
+ "Fill entire column": "填充整列",
"Fill example (all channels)": "填充示例(全部渠道)",
"Fill example (specific channels)": "填充示例(指定渠道)",
"Fill in": "填入",
@@ -2025,6 +2097,7 @@
"Filter models by provider, group, type, endpoint, and tags.": "按供应商、分组、类型、端点和标签筛选模型。",
"Filter models by type, endpoint, vendor, group and tags": "按类型、端点、供应商、分组和标签筛选模型",
"Filter models...": "筛选模型...",
+ "Filter plugins...": "筛选插件...",
"Filter the model analytics view by time range and user.": "按时间范围和用户筛选模型分析视图。",
"Filter the traffic flow view by time range and user.": "按时间范围和用户筛选分流图视图。",
"Filter...": "筛选...",
@@ -2078,6 +2151,7 @@
"Force Format": "强制格式化",
"Force format response to OpenAI standard (OpenAI channel only)": "强制将响应格式化为 OpenAI 标准(仅限 OpenAI 渠道)",
"Force JSON object or schema-conforming output": "强制输出 JSON 对象或符合 Schema 的结果",
+ "Force operation": "强制操作",
"Force SMTP authentication using AUTH LOGIN method": "强制使用 AUTH LOGIN 方法进行 SMTP 认证",
"Force-disabled two-factor authentication for the user": "强制关闭了用户的两步验证",
"Forest Whisper": "森林低语",
@@ -2251,6 +2325,7 @@
"Home": "主页",
"Home Page Content": "首页内容",
"Homepage URL": "主页 URL",
+ "Hook": "钩子",
"Hostname or IP of your SMTP provider": "您的 SMTP 提供商的主机名或 IP",
"Hour": "小时",
"Hour of day": "小时",
@@ -2329,6 +2404,7 @@
"Image to Video": "图生视频",
"Image Tokens": "图像 Token",
"Imagine the pricing table has three groups: default (ratio 1.0), premium (ratio 0.5), and vip (ratio 0.8). Users whose account is in the vip group get user-level perks, and premium is a cheaper channel pool that users can pick for their tokens.": "假设定价分组表里有三个分组:default(倍率 1.0)、premium(倍率 0.5)、vip(倍率 0.8)。账号在 vip 分组的用户享受用户级待遇,premium 则是一个更便宜的渠道池,用户建令牌时可以选它。",
+ "Import from URL": "从 URL 导入",
"Import to CC Switch": "填入 CC Switch",
"Important": "重要",
"In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "在 JSON 中,外层键是用户分组,内层键是计费分组。下面的示例表示:vip 用户按 standard 计费时用 0.8,按 premium 计费时用 0.3。",
@@ -2352,6 +2428,8 @@
"Incomplete": "未完成",
"Increased user quota by {{quota}}": "增加用户额度 {{quota}}",
"Index": "索引",
+ "Index request failed with HTTP {{status}}": "索引请求失败,HTTP {{status}}",
+ "Index URL": "索引 URL",
"Inherit global Auto order": "继承全局 Auto 顺序",
"Initial quota given to new users": "授予新用户的初始配额",
"Initial quota given to new users ({{formattedQuota}})": "授予新用户的初始配额({{formattedQuota}})",
@@ -2369,10 +2447,21 @@
"Inset": "内嵌",
"Inspect requests, errors, and billing details": "查看请求、错误和计费详情",
"Inspect user prompts": "检查用户提示",
+ "Install": "安装",
+ "Install {{name}}": "安装 {{name}}",
+ "Install and enable": "安装并启用",
+ "Installed": "已安装",
+ "Installed {{name}} v{{version}}": "已安装 {{name}} v{{version}}",
+ "Installed v{{from}} → marketplace v{{to}}": "已安装 v{{from}} → 市场 v{{to}}",
+ "Installed v{{installed}} not listed": "已装 v{{installed}} 不在索引中",
+ "Installed version is not in this index": "已安装的版本不在该索引中",
+ "Installing...": "正在安装…",
"Instance": "实例",
"Instances": "实例",
"Insufficient balance": "余额不足",
"Integrations": "集成",
+ "Integrity check failed": "完整性校验失败",
+ "Integrity hash": "完整性哈希",
"Inter-group overrides": "分组间覆盖",
"Inter-group ratio overrides": "分组间比例覆盖",
"Interface Language": "界面语言",
@@ -2425,6 +2514,7 @@
"It seems like the page you're looking for": "您要查找的页面似乎",
"Items": "条目",
"Japanese": "日语",
+ "JavaScript file": "JavaScript 文件",
"Jimeng": "Jimeng",
"Jina": "Jina",
"JSON": "JSON",
@@ -2489,6 +2579,7 @@
"Latency short": "延迟",
"Latency trend (last 24h)": "延迟趋势(最近 24 小时)",
"Latest platform updates and notices": "最新平台更新和通知",
+ "Latest version": "最新版本",
"Lavender Dream": "薰衣草梦",
"Layout": "布局",
"lead": "领头",
@@ -2541,6 +2632,7 @@
"LinuxDO Client Secret": "LinuxDO 客户端密钥",
"List of models supported by this channel. Use comma to separate multiple models.": "此渠道支持的模型列表。使用逗号分隔多个模型。",
"List of origins (one per line) allowed for Passkey registration and authentication.": "允许用于 Passkey 注册和身份验证的来源列表(每行一个)。",
+ "List registered task plugins and bind them when creating or editing task plugin channels.": "列出已注册的任务插件,并在创建或编辑任务插件渠道时绑定它们。",
"List view": "列表视图",
"Live refresh pauses when no task is running": "无任务运行时暂停自动刷新",
"LLM Leaderboard": "LLM 排行榜",
@@ -2555,6 +2647,7 @@
"Loading conversation...": "正在加载对话...",
"Loading current models...": "正在加载当前模型...",
"Loading failed": "加载失败",
+ "Loading installed source...": "正在加载已安装的源码…",
"Loading maintenance settings...": "正在加载维护设置...",
"Loading settings...": "正在加载设置...",
"Loading setup status…": "正在加载设置状态…",
@@ -2606,6 +2699,7 @@
"Manage multi-key status and configuration for this channel": "管理此渠道的多密钥状态和配置",
"Manage Ollama Models": "管理 Ollama 模型",
"Manage server log files. Log files accumulate over time; regular cleanup is recommended to free disk space.": "管理服务器运行日志文件。日志文件会随运行时间不断累积,建议定期清理以释放磁盘空间。",
+ "Manage sources": "管理源",
"Manage subscription plans and pricing.": "管理订阅计划和定价。",
"Manage Subscriptions": "管理订阅",
"Manage Vendors": "管理供应商",
@@ -2619,6 +2713,10 @@
"Map upstream status codes to different codes": "将上游状态码映射到不同的代码",
"Market Share": "市场份额",
"Marketing": "市场营销",
+ "Marketplace": "插件市场",
+ "Marketplace installs never force past a conflict. Resolve it on the task plugins page, then install again.": "市场安装绝不会强制跳过冲突。请先在任务插件页面处理冲突,然后重新安装。",
+ "Marketplace sources": "市场源",
+ "Marketplace sources updated": "市场源已更新",
"Master instances run scheduled background tasks.": "master 实例执行定时后台任务。",
"Match All (AND)": "必须全部满足(AND)",
"Match Any (OR)": "满足任一条件(OR)",
@@ -2663,6 +2761,8 @@
"Maximum tokens per user": "每个用户的最大令牌数",
"maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647": "maxRequests ≥ 0, maxSuccess ≥ 1,两者均 ≤ 2,147,483,647",
"May be used for training by upstream provider": "可能被上游提供商用于训练",
+ "Media access expired. Please try again.": "媒体访问已过期,请重试。",
+ "Media preview failed. Please try again.": "媒体预览失败,请重试。",
"Media pricing": "媒体定价",
"Median time-to-first-token (TTFT) sampled hourly per group": "按小时采样的各分组首 token 延迟(TTFT)中位数",
"Medical Q&A, mental health support": "医疗问答与心理健康支持",
@@ -2862,6 +2962,7 @@
"Native Claude Messages plus OpenAI Chat compatibility forwarding.": "Claude Messages 原生转发,并兼容 OpenAI Chat 转换。",
"Native format": "原生格式",
"Native forwarding": "原生转发",
+ "Native routes": "原生路由",
"Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Gemini 原生转发,并兼容 OpenAI Chat 和 Responses 转换。",
"Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "OpenAI 原生转发,并提供可选的 Claude 和 Gemini 兼容转换。",
"Need a redemption code?": "需要兑换码?",
@@ -2916,6 +3017,7 @@
"No available Web chat links": "没有可用的 Web 聊天链接",
"No backup": "无备份",
"No base input price": "未设置基础输入价格",
+ "No billing parameters declared": "未声明计费参数",
"No billing records found": "未找到账单记录",
"No capabilities reported for this model.": "该模型暂未报告任何能力。",
"No Change": "无变化",
@@ -2964,6 +3066,8 @@
"No incidents in the last 24 hours": "最近 24 小时无异常",
"No incidents in the last 30 days": "最近 30 天无事件",
"No instances have reported yet.": "暂无实例上报。",
+ "No integrity hash": "无完整性哈希",
+ "No integrity verification": "无完整性校验",
"No Inviter": "无邀请人",
"No keys found": "未找到密钥",
"No latency data available": "暂无延迟数据",
@@ -2971,6 +3075,7 @@
"No logs": "暂无日志",
"No Logs Found": "未找到日志",
"No mappings configured. Click \"Add Row\" to get started.": "未配置映射。点击 \"添加行\" 开始。",
+ "No marketplace sources configured.": "尚未配置任何市场源。",
"No matches found": "未找到匹配项",
"No matching items": "没有匹配项",
"No matching results": "无匹配结果",
@@ -3047,6 +3152,7 @@
"No Sync": "不同步",
"No system announcements": "暂无系统公告",
"No system tasks yet.": "暂无系统任务。",
+ "No task plugins found": "未找到任务插件",
"No token found.": "未找到令牌。",
"No tools configured": "未配置工具",
"No Upgrade": "不升级",
@@ -3078,9 +3184,13 @@
"Not backed up": "未备份",
"Not bound": "未绑定",
"Not configured": "未配置",
+ "Not declared": "未声明",
"Not Equals": "不等于",
"Not in pricing table": "不在定价分组表中",
"Not included": "未加入",
+ "Not installed": "未安装",
+ "Not provided by this source": "该源未提供",
+ "Not registered": "未注册",
"Not set": "未设置",
"Not Set": "未设置",
"Not set yet": "尚未设置",
@@ -3096,6 +3206,7 @@
"Notifications": "通知",
"Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "现在,一个用户分组为 vip 的用户创建了不同分组的令牌,各调用一次:",
"Nucleus sampling probability mass": "核采样累计概率",
+ "Number": "数值",
"Number of codes to create": "要创建的代码数量",
"Number of completions to generate": "生成的候选条数",
"Number of images to generate": "生成的图像数量",
@@ -3104,6 +3215,7 @@
"Number of tokens per unit quota": "每单位配额的令牌数",
"Number of top log probabilities returned per token": "每个 token 返回的 top 概率数量",
"Number of users invited": "已邀请的用户数量",
+ "OAuth": "OAuth",
"OAuth binding timed out. Please try again.": "OAuth 绑定超时,请重试。",
"OAuth binding window is no longer available": "OAuth 绑定窗口已不可用",
"OAuth callback URL": "OAuth 回调 URL",
@@ -3223,6 +3335,7 @@
"Optional notes about this channel": "关于此渠道的可选备注",
"Optional notes about when to use this group": "关于何时使用此分组的可选说明",
"Optional ratio used when upstream cache hits occur.": "上游缓存命中时使用的可选比率。",
+ "Optional request-rule multiplier expression. Leave empty when no request rule applies.": "可选的请求规则倍率表达式。无请求规则时留空。",
"Optional rule description": "可选规则说明",
"Optional settings for advanced container configuration.": "高级容器配置的可选设置。",
"Optional supplementary information (max 100 characters)": "可选补充信息 (最多 100 个字符)",
@@ -3291,6 +3404,7 @@
"parameter.": "参数。",
"Parameters": "参数",
"Parsed {{count}} service account file(s)": "已解析 {{count}} 个服务账号文件",
+ "Parsed plugin metadata": "解析出的插件元数据",
"Partial Submission": "部分提交确认",
"Pass Headers": "透传请求头",
"Pass request body directly to upstream": "将请求体直接传递给上游",
@@ -3344,6 +3458,7 @@
"Passwords do not match": "密码不匹配",
"Passwords don't match.": "两次输入的密码不一致。",
"Paste Connection Info": "粘贴连接信息",
+ "Paste JavaScript source here...": "在此粘贴 JavaScript 源码...",
"Path": "路径",
"Path not set": "未设置路径",
"Path Regex (one per line)": "路径正则(每行一个)",
@@ -3383,6 +3498,8 @@
"per request": "每次请求",
"Per request": "每次请求",
"Per Request": "按次计费",
+ "Per Second": "按秒",
+ "Per Unit": "按次",
"Per-call": "每次调用",
"Per-feature metered windows split by model or capability.": "按模型或能力拆分的附加计费能力窗口。",
"Per-group performance": "各分组性能",
@@ -3487,6 +3604,23 @@
"Please wait a moment, human check is initializing...": "请稍等,人机验证正在初始化...",
"Please wait before editing to avoid overwriting saved values.": "请等待加载完成后再编辑,以免覆盖已保存的值。",
"Please wait for the current generation to complete": "请等待当前生成完成",
+ "Plugin": "插件",
+ "Plugin author": "插件作者",
+ "Plugin Generation": "插件运行代次",
+ "Plugin indexes are fetched by your browser. Installing runs the same review and admission pipeline as a manual upload.": "插件索引由你的浏览器拉取。安装时走的审查与准入流程与手动上传完全一致。",
+ "Plugin is still in use": "插件仍在使用中",
+ "Plugin key": "插件键",
+ "Plugin metadata": "插件元数据",
+ "Plugin source": "插件源码",
+ "Choose file": "选择文件",
+ "Choose another file": "重新选择文件",
+ "Drop a JavaScript plugin file here": "将 JavaScript 插件文件拖放到此处",
+ "Single .js file, up to 1 MiB. Its source is shown below before upload.": "单个 .js 文件,最大 1 MiB。上传前会在下方显示其源码。",
+ "Optional note describing this version": "可选,用于描述该版本的备注",
+ "Plugin source exceeds the 1 MiB limit.": "插件源码超过 1 MiB 上限。",
+ "Plugin uploaded successfully": "插件上传成功",
+ "Plugin version activated": "插件版本已激活",
+ "Plugin version deleted": "插件版本已删除",
"Policy JSON": "策略 JSON",
"Polling": "轮询",
"Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded": "轮询模式需要 Redis 和内存缓存,否则性能将显著下降",
@@ -3541,6 +3675,9 @@
"Press Enter to use \"{{value}}\"": "按 Enter 使用「{{value}}」",
"Prevent server-side request forgery attacks": "防止服务器端请求伪造攻击",
"Preview": "预览",
+ "Preview excludes group ratios and request rule multipliers.": "预览不包含分组倍率和请求规则倍率。",
+ "Preview is unavailable for custom expressions.": "自定义表达式无法预览。",
+ "Preview unavailable": "无法预览",
"Previous": "上一步",
"Previous branch": "上一分支",
"Previous page": "上一页",
@@ -3551,6 +3688,7 @@
"Price display mode": "价格显示模式",
"Price estimation": "价格预估",
"Price estimation description": "完成硬件类型、部署位置、副本数量等设置后,价格将自动计算。",
+ "Price examples": "价格示例",
"Price ID": "价格 ID",
"Price mode (USD per 1M tokens)": "价格模式(每 100 万个 token 的美元价格)",
"Price summary": "价格摘要",
@@ -3559,6 +3697,7 @@
"Price: High to Low": "价格:从高到低",
"Price: Low to High": "价格:从低到高",
"Prices shown per": "价格显示单位",
+ "Prices shown per usage unit": "价格按每个用量单位显示",
"Prices synced successfully": "价格同步成功",
"Prices vary by usage tier and request conditions": "价格根据用量档位和请求条件动态调整",
"Pricing": "定价",
@@ -3623,6 +3762,7 @@
"Prune Object Items": "清理对象项",
"Prune object items by conditions": "按条件清理对象中的子项",
"Prune Rule (string or JSON object)": "清理规则(字符串或 JSON 对象)",
+ "Public base URL for async task media. Supports a dedicated media domain, port, or Nginx path prefix; falls back to Server Address when empty.": "异步任务媒体的公开基础地址。支持独立媒体域名、端口或 Nginx 路径前缀;留空时回退到服务器地址。",
"Public model catalog and pricing page.": "公开模型目录和价格页面。",
"Public rankings page based on live usage data.": "基于真实用量数据的公开排行榜页面。",
"Publish Date": "发布日期",
@@ -3773,12 +3913,14 @@
"Regex Replace": "正则替换",
"Register each URL into the matching Test Mode / Production Mode webhook slot in the Pancake dashboard. Separate endpoints prevent test traffic from accidentally crediting production accounts.": "请在 Pancake 控制台中将每个 URL 注册到对应的测试模式/生产模式 webhook 槽位。分离端点可以避免测试流量误入生产账户。",
"Register Passkey": "注册 Passkey",
+ "Registered": "已注册",
"Registered a passkey": "注册了一个 Passkey",
"Registration Enabled": "注册已启用",
"Registration flow expired. Please try again.": "注册流程已过期,请重试。",
"Registry (optional)": "注册表 (可选)",
"Registry secret": "注册表密钥",
"Registry username": "注册表用户名",
+ "Reinstall latest": "重装最新版",
"Reject Reason": "拒绝原因",
"Release details": "版本详情",
"Released": "发布于",
@@ -3806,6 +3948,7 @@
"Remove Passkey": "解绑 Passkey",
"Remove Passkey?": "移除通行密钥?",
"Remove rule group": "移除规则组",
+ "Remove source {{name}}": "移除源 {{name}}",
"Remove string prefix": "去掉字符串前缀",
"Remove string suffix": "去掉字符串后缀",
"Remove the target field": "删除目标字段",
@@ -3855,8 +3998,10 @@
"Request Model": "请求模型",
"Request Model:": "请求模型:",
"Request overrides, routing behavior, and upstream model automation": "请求覆盖、路由行为和上游模型自动化",
+ "Request Path": "请求路径",
"Request retry": "请求重试",
"Request rule pricing": "请求规则计费",
+ "Request rules apply on top of this amount.": "请求规则会在此金额之上继续应用。",
"Request success rate sampled over the last 24 hours": "最近 24 小时按时间桶采样的请求成功率",
"Request success rate; {{incidents}} incident buckets in the last 24 hours": "请求成功率;最近 24 小时 {{incidents}} 个异常桶",
"Request timed out, please refresh and restart GitHub login": "请求超时,请刷新页面后重新发起 GitHub 登录",
@@ -3919,6 +4064,7 @@
"Reset usage window": "重置用量窗口",
"Resets in:": "将于以下时间重置:",
"Resetting...": "重置中...",
+ "Resize column": "调整列宽",
"Resolve Conflicts": "解决冲突",
"Resource Configuration": "资源配置",
"Resources": "资源",
@@ -3951,6 +4097,7 @@
"Revenue": "收入",
"Review & initialize": "审核并初始化",
"Review and sign out devices currently using your account.": "查看并退出当前正在使用您账号的设备。",
+ "Review and upgrade": "审查并升级",
"Review model rates before scaling traffic": "扩展流量前查看模型费率",
"Review your payment details": "查看您的付款详情",
"Review your purchase details before proceeding.": "在继续之前,请审阅您的购买详情。",
@@ -3962,6 +4109,7 @@
"Role": "角色",
"Roleplay": "角色扮演",
"Root": "Root",
+ "Root Diagnostics": "Root 诊断",
"Rose Garden": "玫瑰花园",
"Route": "路由",
"Route active": "路由已启用",
@@ -4001,16 +4149,20 @@
"Rules JSON": "规则 JSON",
"Rules JSON must be an array": "规则 JSON 必须是数组",
"Rules match the original model value from the client request body.": "规则匹配客户端请求体里的原始 model 值。",
+ "Run dry run": "运行干跑",
"Run GC": "执行 GC",
"Run tests for the selected models": "运行所选模型的测试",
"running": "运行中",
"Running": "运行中",
+ "Running dry run": "正在运行干跑",
"Runtime": "运行环境",
+ "Runtime status": "运行状态",
"Runway": "可用时长",
"s": "秒",
"Safety Settings": "安全设置",
"Same as Local": "与本地相同",
"Sampling temperature; lower is more deterministic": "采样温度;越低越稳定",
+ "Sandbox": "沙盘",
"Sandbox mode": "沙盒模式",
"Save": "保存",
"Save & Submit": "保存并提交",
@@ -4091,6 +4243,8 @@
"Search the public web at inference time": "推理时检索公开互联网",
"Search vendors...": "搜索供应商...",
"Search...": "搜索...",
+ "second": "秒",
+ "Second": "秒",
"seconds": "秒",
"Secret env (JSON object)": "密钥环境 (JSON 对象)",
"Secret environment variables (JSON)": "密钥环境变量 (JSON)",
@@ -4116,6 +4270,7 @@
"Select a timestamp before clearing logs.": "清除日志前请选择一个时间戳。",
"Select a usage mode to continue": "选择使用模式以继续",
"Select a verification method first": "请先选择验证方式",
+ "Select a version to compare": "选择要比较的版本",
"Select active subscription plan": "选择有效订阅套餐",
"Select all": "全选",
"Select all (filtered)": "全选(筛选结果)",
@@ -4176,6 +4331,7 @@
"Select sync channels to compare prices": "选择同步渠道以对比价格",
"Select sync channels to compare ratios": "选择同步渠道以比较比率",
"Select Sync Source": "选择同步源",
+ "Select task plugin": "选择任务插件",
"Select the API endpoint region": "选择 API 终端节点区域",
"Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.": "选择要使用上游数据覆盖的字段。未选择的字段将保留其本地值。",
"Select theme preference": "选择主题偏好",
@@ -4189,6 +4345,7 @@
"Selected conflicts were overwritten successfully.": "选中的冲突已成功覆盖。",
"Selected nodes": "已选节点",
"Selected when creating a token and used as the default billing group for API calls.": "创建令牌时选择,用作 API 调用的默认计费分组。",
+ "Selecting a plugin fills its declared models.": "选择插件后将自动填入其声明的模型。",
"Self-Use Mode": "自用模式",
"Send": "发送",
"Send a request": "发送请求",
@@ -4321,12 +4478,15 @@
"Sort by ID": "使用 ID 排序",
"Sort Order": "排序",
"Source": "来源",
+ "Source diff": "源码差异",
"Source Endpoint": "来源端点",
"Source Field": "来源字段",
"Source Header": "来源请求头",
+ "Source name": "源名称",
"sources": "来源",
"Space-separated OAuth scopes": "以空格分隔的OAuth作用域",
"Spark model version, e.g., v2.1 (version number in API URL)": "Spark 模型版本,例如 v2.1(API URL 中的版本号)",
+ "Spec": "规格",
"Special billing expression": "特殊计费表达式",
"Special group": "特殊分组",
"Special ratio rules": "特殊倍率规则",
@@ -4508,11 +4668,21 @@
"Target Path (optional)": "目标路径(可选)",
"Target User": "目标用户",
"Task": "任务",
+ "Task billing": "任务计费",
+ "Task Details": "任务详情",
"Task History": "历史任务",
"Task ID": "任务 ID",
"Task ID:": "任务 ID:",
"Task logs": "任务日志",
"Task Logs": "任务日志",
+ "Task Plugin": "任务插件",
+ "Task plugin setting updated": "任务插件设置已更新",
+ "Task plugin *": "任务插件 *",
+ "Task Plugins": "任务插件",
+ "Task pricing": "任务定价",
+ "Task pricing not configured": "尚未配置任务定价",
+ "Task usage prices are USD per declared unit. They are not token prices and are not divided by one million.": "任务用量价格以每个已声明单位的美元金额计价,不是 token 价格,也不会除以一百万。",
+ "Task usage prices are USD per declared unit. Token fields use dollars per 1M tokens; the editor writes / 1000000 into the expression. Other units are not divided by one million.": "任务用量价格按声明单位以美元计。token 字段按每 100 万 token 的美元价输入,编辑器会在表达式中写入 / 1000000。其他单位不会除以一百万。",
"Tasks currently pending or running.": "当前等待中或运行中的任务。",
"Team Collaboration": "团队协作",
"Technical Support": "技术支持",
@@ -4565,12 +4735,15 @@
"The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "已绑定产品用于钱包充值:当用户输入任意金额时,new-api 会基于这个单一 Pancake 产品发起结账,并按会话覆盖价格,无需预先创建 $1 / $5 / $10 的 SKU。",
"The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "已绑定店铺是 new-api 从此管理端创建的所有 Pancake 产品的父容器,包括钱包充值产品和订阅套餐产品。一个店铺通常足够;只有在确实运营多个 Pancake 目录时才需要绑定不同店铺。",
"The deployment node that handled the requests": "处理请求的部署节点",
+ "The downloaded source does not match the sha256 declared in the index. Do not install it.": "下载到的源码与索引声明的 sha256 不一致,请勿安装。",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "用于 Passkey 注册的有效域。必须与当前域匹配或为其父域。",
"The entered text does not match the required text.": "输入文本与要求文本不匹配。",
"The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "环境(测试或生产)由你在此粘贴的密钥决定。集成期间使用测试密钥,上线时再切换为生产密钥。",
"The exact model identifier as used in API requests.": "API 请求中使用的确切模型标识符。",
"The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "以下模型存在计费类型冲突(固定价格 vs 比例计费)。确认以继续更改。",
"The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "模型重定向里的下列模型尚未添加到\"模型\"列表,调用时会因为缺少可用模型而失败:",
+ "The gateway rejected this plugin": "网关拒绝了该插件",
+ "The index could not be fetched or parsed: {{message}}. The host may block cross-origin requests.": "索引无法拉取或解析:{{message}}。该主机可能禁止跨域请求。",
"The login session that started this Telegram binding is no longer valid.": "发起此 Telegram 绑定的登录会话已失效。",
"The mapped upstream model(s)": "映射的上游模型",
"The model that was requested": "被请求的模型",
@@ -4594,6 +4767,7 @@
"The upstream natively supports all three protocols; every selected route is forwarded without conversion.": "上游原生支持三种协议,所选路由均不经转换直接转发。",
"The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.": "上游响应是有效 JSON,但不符合 OpenAI credit_summary 格式,渠道余额未更新。",
"The URL for this chat client.": "此聊天客户端的 URL。",
+ "The URL returned HTTP {{status}}. Check the address, or download the file and paste its source below.": "该 URL 返回 HTTP {{status}}。请检查地址,或下载文件后将源码粘贴到下方。",
"The user group applied to the requests": "请求所应用的用户分组",
"The user who made the requests": "发起请求的用户",
"Theme": "主题",
@@ -4603,11 +4777,18 @@
"There is a rule for vip billed as premium → use its ratio 0.3": "存在「vip 按 premium 计费」的规则 → 用规则里的 0.3",
"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.": "这些模型仍然在您的勾选列表中,但上游已不再返回该名称;仅作为 model_mapping 来源键而不会出现在 upstream 列表的别名已从本视图排除,请在保存前调整勾选。",
"These toggles affect whether certain request fields are passed through to the upstream provider.": "这些开关控制某些请求字段是否透传到上游服务。",
+ "These values come from the source index and are shown for review only. The gateway admits the plugin based on the metadata compiled from its source.": "这些值来自源索引,仅供审查参考。网关的准入判定只依据从源码编译出的真实元数据。",
"Thinking Suffix Adapter": "思考后缀适配器",
"Thinking to Content": "思维到内容",
"Thinking...": "思考中...",
+ "Third-party": "第三方",
+ "Third-party — use at your own risk": "第三方 — 风险自担",
"Third-party account bindings (read-only, managed by user in profile settings)": "第三方账户绑定(只读,由用户在个人资料设置中管理)",
"Third-party Payment Config": "第三方支付配置",
+ "Third-party plugin risk": "第三方插件风险",
+ "Third-party source risk": "第三方源风险",
+ "Third-party-only plugins become unavailable immediately. In-flight tasks will be handled by timeout cleanup.": "纯第三方插件将立即不可用,在途任务将由超时清理处理。",
+ "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.": "出厂插件和自定义插件将立即停止服务。进行中的任务将由超时清理处理。",
"This action cannot be undone.": "此操作无法撤消。",
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "此操作无法撤消。这将永久删除您的账户并从我们的服务器中移除您的所有数据。",
"This action will permanently remove 2FA protection from your account.": "此操作将永久移除您账户的 2FA 保护。",
@@ -4618,11 +4799,13 @@
"This channel is not an Ollama channel.": "该渠道不是 Ollama 渠道。",
"This channel type does not support fetching models": "此渠道类型不支持获取模型",
"This channel type requires additional configuration": "此渠道类型需要填写额外配置",
+ "This combination will be billed as free.": "此组合将免费计费。",
"This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.": "此确认会解锁支付、兑换码、订阅套餐和邀请奖励功能。请仔细阅读相关声明。",
"This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.": "此处仅控制模型请求速率限制。Web/API 路由限流由环境变量配置,仍可能返回 429。",
"This data may be unreliable, use with caution": "此数据可能不可靠,请谨慎使用",
"This device does not support Passkey": "此设备不支持 Passkey",
"This device does not support Passkey verification.": "此设备不支持 Passkey 验证。",
+ "This expression does not price each combination exactly once, so it opens as a raw expression. Sparse or custom pricing stays in this editor.": "此表达式未对每种组合恰好定价一次,因此将以原始表达式模式打开。稀疏或自定义定价会保留在此编辑器中。",
"This expression is too complex for the visual editor. Please switch to expression mode to edit.": "此表达式对可视化编辑器过于复杂,请切换到表达式模式进行编辑。",
"This FAQ entry will be removed from the list.": "此 FAQ 条目将从列表中移除。",
"This feature is experimental. Configuration format and behavior may change.": "此功能为实验性功能。配置格式和行为可能会发生变化。",
@@ -4630,15 +4813,19 @@
"This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "该条历史记录缺少审计字段。当前版本已支持记录服务器 IP、回调 IP、支付方式与系统版本等审计信息;这些字段仅会写入后续新产生的记录,历史记录无法自动补齐。",
"This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "创建订单时会把这个标识提交给支付后端。支付宝填 alipay,微信填 wxpay,Stripe 填 stripe。自定义值必须是支付服务支持的标识。",
"This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.": "该实例正在使用自动主机名。请设置稳定且唯一的 NODE_NAME,以便进行多实例管理。",
+ "This is a task model billed by usage (e.g. seconds, resolution). Prices entered here act as a per-call base rate, not per-token prices.": "这是一个按用量(例如秒数、分辨率)计费的任务模型。此处输入的价格会作为每次调用的基础费率,而不是按 token 计价。",
"This may cause cache failures.": "这可能导致缓存故障。",
"This may take a few moments while we validate the request and update your session.": "这可能需要一些时间,因为我们正在验证请求并更新您的会话。",
"This model has both fixed price and ratio billing conflicts": "此模型同时存在固定价格和比例计费冲突",
"This model has both fixed-price and ratio settings. Saving the current mode will rewrite the conflicting fields.": "该模型同时存在固定价格和比例设置。保存当前模式会重写冲突字段。",
"This model has both fixed-price and token-price settings. Saving the current mode will rewrite the conflicting fields.": "该模型同时存在固定价格和按 token 价格设置。保存当前模式会重写冲突字段。",
+ "This model is billed by usage, but the administrator has not configured its pricing yet.": "此模型按用量计费,但管理员尚未配置价格。",
"This model is not available in any group, or no group pricing information is configured.": "此模型在任何分组中均不可用,或未配置分组定价信息。",
"This month": "本月获得",
"This page has not been created yet.": "此页面尚未创建。",
"This plan does not allow balance redemption": "该套餐不允许使用余额兑换",
+ "This plugin has no factory fallback. Deleting or disabling it makes this platform unavailable.": "此插件没有出厂回落版本,删除或禁用后该平台将不可用。",
+ "This plugin path does not resolve within the source repository.": "该插件路径未指向源仓库内的位置。",
"This project must be used in compliance with the": "此项目的使用必须遵守",
"This removes {{count}} failed models from this channel. This action cannot be undone.": "此操作将从该渠道移除 {{count}} 个测试失败的模型,且无法撤销。",
"This route discovers upstream OpenAI models and cannot be split or matched by client model rules.": "此路由用于发现上游 OpenAI 模型,不能拆分或使用客户端模型规则匹配。",
@@ -4646,6 +4833,8 @@
"This route is used only by channel management to query the upstream balance.": "此路由仅供渠道管理查询上游余额。",
"This session will lose access immediately and must sign in again.": "此会话将立即失去访问权限,并且必须重新登录。",
"This site currently has {{count}} models enabled": "本站当前已启用模型,总计 {{count}} 个",
+ "This source does not publish a sha256 for this version, so the downloaded source cannot be pinned to what the source intended.": "该源未为此版本提供 sha256,因此无法确认下载到的源码与该源所发布的内容一致。",
+ "This source lists no installable task plugins.": "该源未列出任何可安装的任务插件。",
"This Telegram account is already bound.": "此 Telegram 账户已被绑定。",
"This Telegram binding request has expired or has already been used.": "此 Telegram 绑定请求已过期或已使用。",
"This tier catches any request that did not match earlier tiers.": "此阶梯会兜底处理未匹配前面阶梯的请求。",
@@ -4704,6 +4893,7 @@
"times": "次",
"Timing": "耗时",
"Tip": "提示",
+ "Tip: after configuring one model, select others in the table and use bulk copy.": "提示:配置一个模型后,可在表格中选择其他模型并使用批量复制。",
"to access this resource.": "访问此资源。",
"To Anthropic Messages": "转 Anthropic Messages",
"to confirm": "以确认",
@@ -4722,7 +4912,9 @@
"Toggle navigation menu": "切换导航菜单",
"Toggle plan": "切换计划",
"Toggle theme": "切换主题",
+ "token": "令牌",
"Token": "令牌",
+ "token (unit)": "token",
"Token Breakdown": "Token 明细",
"Token Endpoint": "令牌端点",
"Token Endpoint (Optional)": "Token 端点(可选)",
@@ -4885,6 +5077,8 @@
"Unexpected release payload": "意外的版本数据格式",
"Unified API Gateway for": "统一 API 网关,服务于",
"Unique identifier for this group.": "此组的唯一标识符。",
+ "unit": "次",
+ "Unit": "单位",
"Unit price (local currency / USD)": "单价(本地货币 / USD)",
"Unit price (USD)": "单价 (USD)",
"Unit price must be greater than 0": "单价必须大于 0",
@@ -4903,6 +5097,7 @@
"Untrusted upstream data:": "不受信任的上游数据:",
"Unused": "未使用",
"Up to 4 strings that stop generation": "最多 4 个停止生成的字符串",
+ "Up to date": "已是最新",
"Update": "更新",
"Update All Balances": "更新所有余额",
"Update API Key": "更新 API 密钥",
@@ -4941,15 +5136,26 @@
"Updated user {{username}} (ID: {{id}})": "更新用户 {{username}}(ID: {{id}})",
"Updating all channel balances. This may take a while. Please refresh to see results.": "正在更新所有渠道余额。这可能需要一段时间。请刷新以查看结果。",
"Updating...": "正在更新...",
+ "Upgrade {{name}}": "升级 {{name}}",
+ "Upgrade and enable": "升级并启用",
+ "Upgrade available: v{{installed}} to v{{latest}}": "可升级:v{{installed}} → v{{latest}}",
"Upgrade Group": "升级分组",
"Upgrade plaintext SMTP connection with STARTTLS before authentication": "在身份验证前使用 STARTTLS 升级明文 SMTP 连接",
"Upload": "上传",
+ "Upload a JavaScript task platform plugin.": "上传 JavaScript 任务平台插件。",
"Upload a single service account JSON file": "上传单个服务账号 JSON 文件",
+ "Upload a task plugin to add a platform.": "上传任务插件以添加平台。",
"Upload file": "上传文件",
"Upload files": "上传文件",
"Upload multiple JSON files in batch modes": "批量模式下可上传多个 JSON 文件",
+ "Upload new plugin version": "上传插件新版本",
+ "Upload new version": "上传新版本",
"Upload or reference a local configuration file.": "上传或引用本地配置文件。",
"Upload photo": "上传照片",
+ "Upload plugin": "上传插件",
+ "Upload task plugin": "上传任务插件",
+ "Uploading a plugin is an administrator-level trust decision. A plugin can access channel credentials and shape upstream requests. Review its source and diff before activation.": "上传插件属于管理员级信任行为。插件可访问渠道凭据并构造上游请求,请在激活前审查源码及差异。",
+ "Uploading...": "上传中...",
"Upscale": "放大",
"Upstream": "上游",
"Upstream did not return reset credit details.": "上游未返回重置次数详情。",
@@ -4975,6 +5181,7 @@
"Upstream Response (billing-usage-openai-estimated)": "上游返回(billing-usage-openai-estimated)",
"Upstream Response (billing-usage-openai)": "上游返回(billing-usage-openai)",
"upstream services integrated": "上游服务适配",
+ "Upstream Task ID": "上游任务 ID",
"Upstream Updates": "上游更新",
"Upstream URL": "上游 URL",
"Upstream URL must be a full URL": "上游 URL 必须是完整 URL",
@@ -4995,7 +5202,11 @@
"Usage logs": "使用日志",
"Usage Logs": "使用日志",
"Usage mode": "使用模式",
+ "Usage parameters": "用量参数",
+ "Usage prices": "用量价格",
"Usage-based": "基于使用量",
+ "Usage-based billing": "按用量计费",
+ "Usage-based billing · price not configured": "按用量计费 · 尚未配置价格",
"USD": "USD",
"USD Exchange Rate": "美元汇率",
"USD price per 1M input tokens.": "每 100 万输入 token 的美元价格。",
@@ -5084,6 +5295,7 @@
"Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "用户只能看到标记为用户可选的分组。不可选分组仍可由管理员分配。",
"uses": "使用次数",
"Using the complete global Auto order ({{count}} groups)": "正在使用完整全局 Auto 顺序({{count}} 个分组)",
+ "v{{installed}} is installed but this source does not list it. Installing replaces it with v{{target}}.": "当前已安装 v{{installed}},但该源未列出此版本。安装将把它替换为 v{{target}}。",
"Validity": "有效期",
"Validity Period": "有效期",
"Value": "值",
@@ -5124,7 +5336,9 @@
"Verify your database connection": "验证数据库连接",
"Verifying credentials and pulling stores from your Pancake account...": "正在验证凭证并从你的 Pancake 账户拉取店铺...",
"Version": "版本",
+ "Version history": "版本历史",
"Version Overrides": "版本覆盖",
+ "Versions": "版本",
"Vertex AI": "Vertex AI",
"Vertex AI API Key mode does not support batch creation": "Vertex AI API Key 模式不支持批量创建",
"Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.": "Vertex AI 不支持 functionResponse.id 字段,开启后将自动移除该字段",
@@ -5148,6 +5362,7 @@
"View Pricing": "查看定价",
"View the complete details for this": "查看此条",
"View the complete details for this log entry": "查看此日志条目的完整详情",
+ "View the complete details for this task": "查看此任务的完整详情",
"View the complete error message and details": "查看完整错误信息与详情",
"View the complete prompt and its English translation": "查看完整提示词及其英文翻译",
"View the generated image": "查看生成的图片",
@@ -5240,6 +5455,8 @@
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "当令牌使用 auto 分组时,系统会按从上到下的顺序尝试,直到找到可用分组。",
"When billed as {{group}}": "按 {{group}} 计费时",
"When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.": "条件满足时,最终价格乘以 X;多条命中的倍率会相乘;小于 1 的值为折扣。",
+ "When disabled, all uploaded custom plugins are ignored and every platform falls back to its built-in factory plugin.": "关闭后,所有上传的自定义插件都会被忽略,每个平台均回落到内置的出厂插件。",
+ "When disabled, the entire task plugin system stops serving, including factory and custom plugins.": "关闭后,整个任务插件系统停止服务,包括出厂插件和自定义插件。",
"When enabled, if channels in the current group fail, it will try channels in the next group in order.": "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道。",
"When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. Leave it off to delete the entry and select another channel.": "开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。",
"When enabled, large request bodies are temporarily stored on disk instead of memory, significantly reducing memory usage. SSD recommended.": "启用磁盘缓存后,大请求体将临时存储到磁盘而非内存,可显著降低内存占用。建议在 SSD 环境下使用。",
diff --git a/web/src/i18n/static-keys.ts b/web/src/i18n/static-keys.ts
index a435b0471155..f4807fa5f417 100644
--- a/web/src/i18n/static-keys.ts
+++ b/web/src/i18n/static-keys.ts
@@ -59,6 +59,7 @@ export const STATIC_I18N_KEYS = [
'All Models',
'Token-based',
'Per Request',
+ 'Task billing',
'All Types',
'Chat',
'Response',
diff --git a/web/src/lib/__tests__/localized-text.test.ts b/web/src/lib/__tests__/localized-text.test.ts
new file mode 100644
index 000000000000..5da3c3929232
--- /dev/null
+++ b/web/src/lib/__tests__/localized-text.test.ts
@@ -0,0 +1,116 @@
+/*
+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 { describe, expect, test } from 'vitest'
+
+import {
+ resolveLocalizedText,
+ type LocalizedTextValue,
+} from '../localized-text'
+
+type ResolveCase = {
+ name: string
+ value: LocalizedTextValue | null | undefined
+ language: string
+ expected: string
+}
+
+const KLING = {
+ en: 'Video generation via Kling API',
+ zh: '可灵视频生成',
+ 'zh-TW': '可靈影片生成',
+} as const
+
+describe('resolveLocalizedText', () => {
+ test.each([
+ {
+ name: 'returns a bare string unchanged so legacy marketplace indexes still render',
+ value: 'Video generation via Kling API',
+ language: 'zhCN',
+ expected: 'Video generation via Kling API',
+ },
+ {
+ name: 'returns the exact BCP-47 tag when the map contains zh-TW',
+ value: KLING,
+ language: 'zh-TW',
+ expected: '可靈影片生成',
+ },
+ {
+ name: 'matches zh-TW case-insensitively when i18next language is zh-tw',
+ value: KLING,
+ language: 'zh-tw',
+ expected: '可靈影片生成',
+ },
+ {
+ name: 'maps the project i18next code zhTW onto the zh-TW map key',
+ value: KLING,
+ language: 'zhTW',
+ expected: '可靈影片生成',
+ },
+ {
+ name: 'falls back from zh-TW to the zh primary subtag when zh-TW is absent',
+ value: { en: KLING.en, zh: KLING.zh },
+ language: 'zh-TW',
+ expected: '可灵视频生成',
+ },
+ {
+ name: 'maps the project i18next code zhCN onto the zh primary subtag',
+ value: { en: KLING.en, zh: KLING.zh },
+ language: 'zhCN',
+ expected: '可灵视频生成',
+ },
+ {
+ name: 'falls back from en-US to en when only the primary tag exists',
+ value: { en: KLING.en, zh: KLING.zh },
+ language: 'en-US',
+ expected: 'Video generation via Kling API',
+ },
+ {
+ name: 'falls back to en when the requested language and its primary tag are absent',
+ value: { en: KLING.en, ja: 'Kling で動画生成' },
+ language: 'fr',
+ expected: 'Video generation via Kling API',
+ },
+ {
+ name: 'uses the first sorted key when en and the requested language are both absent',
+ value: { ja: 'Kling で動画生成', fr: 'Génération vidéo Kling' },
+ language: 'ru',
+ expected: 'Génération vidéo Kling',
+ },
+ {
+ name: 'returns an empty string when the value is null',
+ value: null,
+ language: 'en',
+ expected: '',
+ },
+ {
+ name: 'returns an empty string when the value is undefined',
+ value: undefined,
+ language: 'en',
+ expected: '',
+ },
+ {
+ name: 'returns an empty string when the map has no usable entries',
+ value: {},
+ language: 'zhCN',
+ expected: '',
+ },
+ ])('$name', ({ value, language, expected }) => {
+ expect(resolveLocalizedText(value, language)).toBe(expected)
+ })
+})
diff --git a/web/src/lib/admin-permissions.ts b/web/src/lib/admin-permissions.ts
index dd883f87a94e..4c28653ed320 100644
--- a/web/src/lib/admin-permissions.ts
+++ b/web/src/lib/admin-permissions.ts
@@ -25,6 +25,7 @@ export type AdminCapabilities = AdminPermissionMatrix
export const ADMIN_PERMISSION_RESOURCES = {
CHANNEL: 'channel',
+ TASK_PLUGIN: 'task_plugin',
} as const
export const ADMIN_PERMISSION_ACTIONS = {
@@ -33,6 +34,7 @@ export const ADMIN_PERMISSION_ACTIONS = {
WRITE: 'write',
SENSITIVE_WRITE: 'sensitive_write',
SECRET_VIEW: 'secret_view',
+ BIND: 'bind',
} as const
// The role whose baseline grants are used as defaults in the permission editor.
diff --git a/web/src/lib/localized-text.ts b/web/src/lib/localized-text.ts
new file mode 100644
index 000000000000..551841e8ab07
--- /dev/null
+++ b/web/src/lib/localized-text.ts
@@ -0,0 +1,82 @@
+/*
+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
+*/
+
+/**
+ * Plugin / marketplace copy that may be a bare string (legacy marketplace
+ * index) or a BCP-47 map. Gateway APIs always emit the map form.
+ */
+export type LocalizedTextValue = string | Record
+
+/**
+ * Resolve LocalizedText against an i18next language code.
+ *
+ * This project's `i18n.language` values are `en` / `zhCN` / `zhTW` / `fr` /
+ * `ru` / `ja` / `vi` (see `web/src/i18n/config.ts`). Backend keys are BCP-47
+ * (`en`, `zh`, `zh-TW`). Matching is case-insensitive and also accepts
+ * hyphenated tags (`zh-TW`, `en-US`) so callers can pass either shape.
+ *
+ * Fallback: exact tag → primary subtag → `en` → first key in sorted order → `''`.
+ */
+export function resolveLocalizedText(
+ value: LocalizedTextValue | undefined | null,
+ language: string
+): string {
+ if (value == null) return ''
+ if (typeof value === 'string') return value
+ if (typeof value !== 'object' || Array.isArray(value)) return ''
+
+ const texts = new Map()
+ for (const [key, text] of Object.entries(value)) {
+ if (typeof text !== 'string' || text.trim() === '') continue
+ const locale = key.trim().replaceAll('_', '-').toLowerCase()
+ if (!locale) continue
+ texts.set(locale, text)
+ }
+ if (texts.size === 0) return ''
+
+ for (const candidate of localeFallbackKeys(language)) {
+ const hit = texts.get(candidate)
+ if (hit !== undefined) return hit
+ }
+
+ const firstKey = [...texts.keys()].sort((left, right) =>
+ left.localeCompare(right)
+ )[0]
+ return firstKey ? (texts.get(firstKey) ?? '') : ''
+}
+
+function localeFallbackKeys(language: string): string[] {
+ const normalized = language.trim().replaceAll('_', '-').toLowerCase()
+ const keys: string[] = []
+ const add = (tag: string) => {
+ if (tag && !keys.includes(tag)) keys.push(tag)
+ }
+
+ add(normalized)
+ if (normalized === 'zhcn') add('zh-cn')
+ if (normalized === 'zhtw') add('zh-tw')
+
+ if (normalized.includes('-')) {
+ add(normalized.slice(0, normalized.indexOf('-')))
+ } else if (normalized === 'zhcn' || normalized === 'zhtw') {
+ add('zh')
+ }
+ add('en')
+ return keys
+}
diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts
index a72e146cd06d..e216b885f55a 100644
--- a/web/src/routeTree.gen.ts
+++ b/web/src/routeTree.gen.ts
@@ -48,6 +48,7 @@ import { Route as AuthenticatedRedemptionCodesIndexRouteImport } from './routes/
import { Route as AuthenticatedSubscriptionsIndexRouteImport } from './routes/_authenticated/subscriptions/index'
import { Route as AuthenticatedSystemInfoIndexRouteImport } from './routes/_authenticated/system-info/index'
import { Route as AuthenticatedSystemSettingsIndexRouteImport } from './routes/_authenticated/system-settings/index'
+import { Route as AuthenticatedTaskPluginsIndexRouteImport } from './routes/_authenticated/task-plugins/index'
import { Route as AuthenticatedUsageLogsIndexRouteImport } from './routes/_authenticated/usage-logs/index'
import { Route as AuthenticatedUsageLogsSectionRouteImport } from './routes/_authenticated/usage-logs/$section'
import { Route as AuthenticatedUsersIndexRouteImport } from './routes/_authenticated/users/index'
@@ -274,6 +275,12 @@ const AuthenticatedSystemSettingsIndexRoute =
path: '/',
getParentRoute: () => AuthenticatedSystemSettingsRouteRoute,
} as any)
+const AuthenticatedTaskPluginsIndexRoute =
+ AuthenticatedTaskPluginsIndexRouteImport.update({
+ id: '/task-plugins/',
+ path: '/task-plugins/',
+ getParentRoute: () => AuthenticatedRouteRoute,
+ } as any)
const AuthenticatedUsageLogsIndexRoute =
AuthenticatedUsageLogsIndexRouteImport.update({
id: '/usage-logs/',
@@ -426,6 +433,7 @@ export interface FileRoutesByFullPath {
'/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
'/system-info/': typeof AuthenticatedSystemInfoIndexRoute
'/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
+ '/task-plugins/': typeof AuthenticatedTaskPluginsIndexRoute
'/usage-logs/': typeof AuthenticatedUsageLogsIndexRoute
'/users/': typeof AuthenticatedUsersIndexRoute
'/wallet/': typeof AuthenticatedWalletIndexRoute
@@ -483,6 +491,7 @@ export interface FileRoutesByTo {
'/subscriptions': typeof AuthenticatedSubscriptionsIndexRoute
'/system-info': typeof AuthenticatedSystemInfoIndexRoute
'/system-settings': typeof AuthenticatedSystemSettingsIndexRoute
+ '/task-plugins': typeof AuthenticatedTaskPluginsIndexRoute
'/usage-logs': typeof AuthenticatedUsageLogsIndexRoute
'/users': typeof AuthenticatedUsersIndexRoute
'/wallet': typeof AuthenticatedWalletIndexRoute
@@ -544,6 +553,7 @@ export interface FileRoutesById {
'/_authenticated/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
'/_authenticated/system-info/': typeof AuthenticatedSystemInfoIndexRoute
'/_authenticated/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
+ '/_authenticated/task-plugins/': typeof AuthenticatedTaskPluginsIndexRoute
'/_authenticated/usage-logs/': typeof AuthenticatedUsageLogsIndexRoute
'/_authenticated/users/': typeof AuthenticatedUsersIndexRoute
'/_authenticated/wallet/': typeof AuthenticatedWalletIndexRoute
@@ -604,6 +614,7 @@ export interface FileRouteTypes {
| '/subscriptions/'
| '/system-info/'
| '/system-settings/'
+ | '/task-plugins/'
| '/usage-logs/'
| '/users/'
| '/wallet/'
@@ -661,6 +672,7 @@ export interface FileRouteTypes {
| '/subscriptions'
| '/system-info'
| '/system-settings'
+ | '/task-plugins'
| '/usage-logs'
| '/users'
| '/wallet'
@@ -721,6 +733,7 @@ export interface FileRouteTypes {
| '/_authenticated/subscriptions/'
| '/_authenticated/system-info/'
| '/_authenticated/system-settings/'
+ | '/_authenticated/task-plugins/'
| '/_authenticated/usage-logs/'
| '/_authenticated/users/'
| '/_authenticated/wallet/'
@@ -1035,6 +1048,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedSystemSettingsIndexRouteImport
parentRoute: typeof AuthenticatedSystemSettingsRouteRoute
}
+ '/_authenticated/task-plugins/': {
+ id: '/_authenticated/task-plugins/'
+ path: '/task-plugins'
+ fullPath: '/task-plugins/'
+ preLoaderRoute: typeof AuthenticatedTaskPluginsIndexRouteImport
+ parentRoute: typeof AuthenticatedRouteRoute
+ }
'/_authenticated/usage-logs/': {
id: '/_authenticated/usage-logs/'
path: '/usage-logs'
@@ -1271,6 +1291,7 @@ interface AuthenticatedRouteRouteChildren {
AuthenticatedRedemptionCodesIndexRoute: typeof AuthenticatedRedemptionCodesIndexRoute
AuthenticatedSubscriptionsIndexRoute: typeof AuthenticatedSubscriptionsIndexRoute
AuthenticatedSystemInfoIndexRoute: typeof AuthenticatedSystemInfoIndexRoute
+ AuthenticatedTaskPluginsIndexRoute: typeof AuthenticatedTaskPluginsIndexRoute
AuthenticatedUsageLogsIndexRoute: typeof AuthenticatedUsageLogsIndexRoute
AuthenticatedUsersIndexRoute: typeof AuthenticatedUsersIndexRoute
AuthenticatedWalletIndexRoute: typeof AuthenticatedWalletIndexRoute
@@ -1295,6 +1316,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
AuthenticatedRedemptionCodesIndexRoute,
AuthenticatedSubscriptionsIndexRoute: AuthenticatedSubscriptionsIndexRoute,
AuthenticatedSystemInfoIndexRoute: AuthenticatedSystemInfoIndexRoute,
+ AuthenticatedTaskPluginsIndexRoute: AuthenticatedTaskPluginsIndexRoute,
AuthenticatedUsageLogsIndexRoute: AuthenticatedUsageLogsIndexRoute,
AuthenticatedUsersIndexRoute: AuthenticatedUsersIndexRoute,
AuthenticatedWalletIndexRoute: AuthenticatedWalletIndexRoute,
diff --git a/web/src/routes/_authenticated/task-plugins/index.tsx b/web/src/routes/_authenticated/task-plugins/index.tsx
new file mode 100644
index 000000000000..9b648e5a3070
--- /dev/null
+++ b/web/src/routes/_authenticated/task-plugins/index.tsx
@@ -0,0 +1,31 @@
+/*
+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 { createFileRoute, redirect } from '@tanstack/react-router'
+
+import { TaskPlugins } from '@/features/task-plugins'
+import { ROLE } from '@/lib/roles'
+import { useAuthStore } from '@/stores/auth-store'
+
+export const Route = createFileRoute('/_authenticated/task-plugins/')({
+ beforeLoad: () => {
+ const { auth } = useAuthStore.getState()
+ if (auth.user?.role !== ROLE.SUPER_ADMIN) throw redirect({ to: '/403' })
+ },
+ component: TaskPlugins,
+})
From 0f2a2075ab072ea7e20ffa5dd5d58dbf1b6b5b22 Mon Sep 17 00:00:00 2001
From: Alex Xiang
Date: Sat, 29 Aug 2026 19:21:09 +0800
Subject: [PATCH 62/99] =?UTF-8?q?fix(relay):=20=E8=AF=B7=E6=B1=82=E5=8F=82?=
=?UTF-8?q?=E6=95=B0=E6=A0=A1=E9=AA=8C=E9=94=99=E8=AF=AF=E8=BF=94=E5=9B=9E?=
=?UTF-8?q?=20HTTP=20400=20(#6774)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(relay): return 400 for invalid request parameters
---
controller/relay.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/controller/relay.go b/controller/relay.go
index 0f7792efd970..a678888d9346 100644
--- a/controller/relay.go
+++ b/controller/relay.go
@@ -117,7 +117,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
if common.IsRequestBodyTooLargeError(err) || errors.Is(err, common.ErrRequestBodyTooLarge) {
newAPIError = types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusRequestEntityTooLarge, types.ErrOptionWithSkipRetry())
} else {
- newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest)
+ newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithStatusCode(http.StatusBadRequest), types.ErrOptionWithSkipRetry())
}
return
}
From 98d50d5383a33432ff6c30b129461b170e5cbffc Mon Sep 17 00:00:00 2001
From: Seefs <40468931+seefs001@users.noreply.github.com>
Date: Sat, 29 Aug 2026 19:24:14 +0800
Subject: [PATCH 63/99] fix(web): recheck setup status after page reload
(#6968)
---
web/src/routes/__root.tsx | 42 +++++++--------------------------------
1 file changed, 7 insertions(+), 35 deletions(-)
diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx
index 8407b9b74cb9..41b52cdadfb3 100644
--- a/web/src/routes/__root.tsx
+++ b/web/src/routes/__root.tsx
@@ -107,37 +107,8 @@ function RootComponent() {
)
}
-// 缓存 setup 状态检查结果,避免每次导航都重复调用 API
-// 使用 localStorage 持久化,避免页面刷新后重复检查
-const SETUP_CHECKED_KEY = 'setup_status_checked'
-
-function getSetupStatusFromCache(): boolean {
- try {
- if (typeof window !== 'undefined') {
- return window.localStorage.getItem(SETUP_CHECKED_KEY) === 'true'
- }
- } catch {
- /* empty */
- }
- return false
-}
-
-function setSetupStatusCache(value: boolean): void {
- try {
- if (typeof window !== 'undefined') {
- if (value) {
- window.localStorage.setItem(SETUP_CHECKED_KEY, 'true')
- } else {
- window.localStorage.removeItem(SETUP_CHECKED_KEY)
- }
- }
- } catch {
- /* empty */
- }
-}
-
-// 内存中的标记,避免同一会话中重复检查
-let setupStatusChecked = getSetupStatusFromCache()
+// 同一页面会话内避免重复检查;刷新后重新校验当前服务实例。
+let setupStatusChecked = false
export const Route = createRootRouteWithContext<{
queryClient: QueryClient
@@ -167,11 +138,12 @@ export const Route = createRootRouteWithContext<{
authBootstrap,
])
- if (status?.success && status.data && !status.data.status) {
- throw redirect({ to: '/setup' })
+ if (status?.success && status.data) {
+ if (!status.data.status) {
+ throw redirect({ to: '/setup' })
+ }
+ setupStatusChecked = true
}
- setupStatusChecked = true
- setSetupStatusCache(true)
} else {
await authBootstrap
}
From b80d633cf586b001cfbb4200bae93e65abe57c2b Mon Sep 17 00:00:00 2001
From: CaIon
Date: Sat, 29 Aug 2026 20:11:05 +0800
Subject: [PATCH 64/99] feat(auth): encrypt password login transport
Closes #6743
---
common/password_crypto.go | 115 +++++++++++++++
controller/user.go | 25 +++-
main.go | 4 +
model/main.go | 2 +
model/password_crypto.go | 60 ++++++++
router/api-router.go | 1 +
web/bun.lock | 6 +
web/package.json | 2 +
web/src/features/auth/api.ts | 34 +++--
.../features/auth/lib/password-encryption.ts | 135 ++++++++++++++++++
10 files changed, 372 insertions(+), 12 deletions(-)
create mode 100644 common/password_crypto.go
create mode 100644 model/password_crypto.go
create mode 100644 web/src/features/auth/lib/password-encryption.ts
diff --git a/common/password_crypto.go b/common/password_crypto.go
new file mode 100644
index 000000000000..efbb97acbd34
--- /dev/null
+++ b/common/password_crypto.go
@@ -0,0 +1,115 @@
+package common
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "strings"
+ "sync"
+)
+
+const passwordEncryptionKeyBits = 2048
+
+var ErrPasswordEncryptionInvalid = errors.New("password encryption payload is invalid")
+
+var passwordEncryptionState struct {
+ sync.RWMutex
+ privateKey *rsa.PrivateKey
+ publicKey string
+ keyID string
+}
+
+// GeneratePasswordEncryptionPrivateKey creates the server key used to decrypt
+// browser login passwords. The caller is responsible for persisting the PEM.
+func GeneratePasswordEncryptionPrivateKey() (string, error) {
+ privateKey, err := rsa.GenerateKey(rand.Reader, passwordEncryptionKeyBits)
+ if err != nil {
+ return "", fmt.Errorf("generate password encryption key: %w", err)
+ }
+ privateKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
+ if err != nil {
+ return "", fmt.Errorf("marshal password encryption key: %w", err)
+ }
+ return string(pem.EncodeToMemory(&pem.Block{
+ Type: "PRIVATE KEY",
+ Bytes: privateKeyDER,
+ })), nil
+}
+
+// LoadPasswordEncryptionPrivateKey validates a persisted key before replacing
+// the active in-memory key used by request handlers.
+func LoadPasswordEncryptionPrivateKey(privateKeyPEM string) error {
+ block, rest := pem.Decode([]byte(privateKeyPEM))
+ if block == nil || block.Type != "PRIVATE KEY" || strings.TrimSpace(string(rest)) != "" {
+ return errors.New("password encryption key is not valid PKCS#8 PEM")
+ }
+ parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+ if err != nil {
+ return fmt.Errorf("parse password encryption key: %w", err)
+ }
+ privateKey, ok := parsed.(*rsa.PrivateKey)
+ if !ok {
+ return errors.New("password encryption key is not RSA")
+ }
+ if privateKey.N == nil || privateKey.N.BitLen() < passwordEncryptionKeyBits {
+ return fmt.Errorf("password encryption key must be at least %d bits", passwordEncryptionKeyBits)
+ }
+ if err := privateKey.Validate(); err != nil {
+ return fmt.Errorf("validate password encryption key: %w", err)
+ }
+ privateKey.Precompute()
+
+ publicKeyDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
+ if err != nil {
+ return fmt.Errorf("marshal password encryption public key: %w", err)
+ }
+ publicKeyPEM := string(pem.EncodeToMemory(&pem.Block{
+ Type: "PUBLIC KEY",
+ Bytes: publicKeyDER,
+ }))
+ keyDigest := sha256.Sum256(publicKeyDER)
+ keyID := hex.EncodeToString(keyDigest[:16])
+
+ passwordEncryptionState.Lock()
+ defer passwordEncryptionState.Unlock()
+ passwordEncryptionState.privateKey = privateKey
+ passwordEncryptionState.publicKey = publicKeyPEM
+ passwordEncryptionState.keyID = keyID
+ return nil
+}
+
+// PasswordEncryptionPublicKey returns the active key identifier and SPKI PEM
+// public key exposed to browser clients.
+func PasswordEncryptionPublicKey() (keyID string, publicKeyPEM string) {
+ passwordEncryptionState.RLock()
+ defer passwordEncryptionState.RUnlock()
+ return passwordEncryptionState.keyID, passwordEncryptionState.publicKey
+}
+
+// DecryptPassword decrypts a base64 RSA-OAEP/SHA-256 password submitted by a
+// browser. All malformed inputs share one error so callers do not expose
+// cryptographic details to unauthenticated clients.
+func DecryptPassword(ciphertextBase64 string, keyID string) (string, error) {
+ passwordEncryptionState.RLock()
+ privateKey := passwordEncryptionState.privateKey
+ activeKeyID := passwordEncryptionState.keyID
+ passwordEncryptionState.RUnlock()
+ if privateKey == nil || keyID == "" || keyID != activeKeyID {
+ return "", ErrPasswordEncryptionInvalid
+ }
+ ciphertext, err := base64.StdEncoding.DecodeString(ciphertextBase64)
+ if err != nil || len(ciphertext) != privateKey.Size() {
+ return "", ErrPasswordEncryptionInvalid
+ }
+ plaintext, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, ciphertext, nil)
+ if err != nil || len(plaintext) == 0 {
+ return "", ErrPasswordEncryptionInvalid
+ }
+ return string(plaintext), nil
+}
diff --git a/controller/user.go b/controller/user.go
index 7020f1c6a369..1f3b3b35b067 100644
--- a/controller/user.go
+++ b/controller/user.go
@@ -28,8 +28,10 @@ import (
)
type LoginRequest struct {
- Username string `json:"username"`
- Password string `json:"password"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ PasswordEncrypted string `json:"password_encrypted"`
+ EncryptionKeyID string `json:"encryption_key_id"`
}
var (
@@ -37,6 +39,18 @@ var (
errOriginalPasswordFail = errors.New("original password is incorrect")
)
+func GetPasswordEncryptionKey(c *gin.Context) {
+ keyID, publicKey := common.PasswordEncryptionPublicKey()
+ if keyID == "" || publicKey == "" {
+ common.ApiErrorI18n(c, i18n.MsgDatabaseError)
+ return
+ }
+ common.ApiSuccess(c, gin.H{
+ "kid": keyID,
+ "public_key": publicKey,
+ })
+}
+
func Login(c *gin.Context) {
if !common.PasswordLoginEnabled {
common.ApiErrorI18n(c, i18n.MsgUserPasswordLoginDisabled)
@@ -50,6 +64,13 @@ func Login(c *gin.Context) {
}
username := loginRequest.Username
password := loginRequest.Password
+ if loginRequest.PasswordEncrypted != "" {
+ password, err = common.DecryptPassword(loginRequest.PasswordEncrypted, loginRequest.EncryptionKeyID)
+ if err != nil {
+ common.ApiErrorI18n(c, i18n.MsgUserUsernameOrPasswordError)
+ return
+ }
+ }
if username == "" || password == "" {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
diff --git a/main.go b/main.go
index ac4e6afc3ac2..d82918988d2b 100644
--- a/main.go
+++ b/main.go
@@ -318,6 +318,10 @@ func InitResources() error {
common.FatalLog("failed to initialize authorization: " + err.Error())
return err
}
+ if err = model.InitPasswordEncryption(); err != nil {
+ common.FatalLog("failed to initialize password encryption: " + err.Error())
+ return err
+ }
model.CheckSetup()
diff --git a/model/main.go b/model/main.go
index 4b72871fec5c..877b2019d9d8 100644
--- a/model/main.go
+++ b/model/main.go
@@ -316,6 +316,7 @@ func migrateDB() error {
&ExternalIdentityClaim{},
&PasskeyCredential{},
&Option{},
+ &LoginEncryptionKey{},
&Redemption{},
&Ability{},
&Log{},
@@ -380,6 +381,7 @@ func migrateDBFast() error {
{&ExternalIdentityClaim{}, "ExternalIdentityClaim"},
{&PasskeyCredential{}, "PasskeyCredential"},
{&Option{}, "Option"},
+ {&LoginEncryptionKey{}, "LoginEncryptionKey"},
{&Redemption{}, "Redemption"},
{&Ability{}, "Ability"},
{&Log{}, "Log"},
diff --git a/model/password_crypto.go b/model/password_crypto.go
new file mode 100644
index 000000000000..9177471810fd
--- /dev/null
+++ b/model/password_crypto.go
@@ -0,0 +1,60 @@
+package model
+
+import (
+ "errors"
+ "fmt"
+
+ "github.com/QuantumNous/new-api/common"
+
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+const activeLoginEncryptionKeySlot = "active"
+
+// LoginEncryptionKey stores internal key material used by the browser login
+// protocol. It is deliberately separate from administrator-facing options.
+type LoginEncryptionKey struct {
+ ID uint `json:"-" gorm:"primaryKey"`
+ Slot string `json:"-" gorm:"type:varchar(32);not null;uniqueIndex"`
+ PrivateKeyPEM string `json:"-" gorm:"type:text;not null"`
+}
+
+// InitPasswordEncryption loads the shared login-encryption key from its
+// dedicated store. Concurrent replicas converge through the unique slot.
+func InitPasswordEncryption() error {
+ var stored LoginEncryptionKey
+ queryErr := DB.Where("slot = ?", activeLoginEncryptionKeySlot).First(&stored).Error
+ if queryErr == nil {
+ if err := common.LoadPasswordEncryptionPrivateKey(stored.PrivateKeyPEM); err != nil {
+ return fmt.Errorf("load persisted password encryption key: %w", err)
+ }
+ return nil
+ }
+ if !errors.Is(queryErr, gorm.ErrRecordNotFound) {
+ return fmt.Errorf("read password encryption key: %w", queryErr)
+ }
+
+ privateKeyPEM, err := common.GeneratePasswordEncryptionPrivateKey()
+ if err != nil {
+ return err
+ }
+ candidate := LoginEncryptionKey{
+ Slot: activeLoginEncryptionKeySlot,
+ PrivateKeyPEM: privateKeyPEM,
+ }
+ if err := DB.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "slot"}},
+ DoNothing: true,
+ }).Create(&candidate).Error; err != nil {
+ return fmt.Errorf("persist password encryption key: %w", err)
+ }
+
+ if err := DB.Where("slot = ?", activeLoginEncryptionKeySlot).First(&stored).Error; err != nil {
+ return fmt.Errorf("reload password encryption key: %w", err)
+ }
+ if err := common.LoadPasswordEncryptionPrivateKey(stored.PrivateKeyPEM); err != nil {
+ return fmt.Errorf("load persisted password encryption key: %w", err)
+ }
+ return nil
+}
diff --git a/router/api-router.go b/router/api-router.go
index 092600aa06cc..18074a932546 100644
--- a/router/api-router.go
+++ b/router/api-router.go
@@ -71,6 +71,7 @@ func SetApiRouter(router *gin.Engine) {
userRoute.POST("/auth/refresh", middleware.SessionCookieOriginGuard(), middleware.CriticalRateLimit(), middleware.DisableCache(), controller.RefreshAuth)
userRoute.POST("/auth/logout", middleware.SessionCookieOriginGuard(), middleware.CriticalRateLimit(), middleware.DisableCache(), controller.AuthLogout)
userRoute.POST("/register", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, middleware.TurnstileCheck(), controller.Register)
+ userRoute.GET("/login/encryption-key", middleware.DisableCache(), controller.GetPasswordEncryptionKey)
userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, middleware.TurnstileCheck(), controller.Login)
userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.Verify2FALogin)
userRoute.POST("/passkey/login/begin", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.PasskeyLoginBegin)
diff --git a/web/bun.lock b/web/bun.lock
index 763317b26dda..686e4fd8d4ee 100644
--- a/web/bun.lock
+++ b/web/bun.lock
@@ -41,6 +41,7 @@
"motion": "^12.42.2",
"nanoid": "^5.1.16",
"next-themes": "^0.4.6",
+ "node-forge": "^1.4.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.7",
"react-day-picker": "^10.0.1",
@@ -76,6 +77,7 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^26.1.0",
+ "@types/node-forge": "^1.3.14",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "^7.0.0-dev.20260702.3",
@@ -992,6 +994,8 @@
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
+ "@types/node-forge": ["@types/node-forge@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw=="],
+
"@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
@@ -1992,6 +1996,8 @@
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
+ "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="],
+
"node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="],
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
diff --git a/web/package.json b/web/package.json
index d2d858c51dac..6f181e87731d 100644
--- a/web/package.json
+++ b/web/package.json
@@ -60,6 +60,7 @@
"motion": "^12.42.2",
"nanoid": "^5.1.16",
"next-themes": "^0.4.6",
+ "node-forge": "^1.4.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.7",
"react-day-picker": "^10.0.1",
@@ -95,6 +96,7 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^26.1.0",
+ "@types/node-forge": "^1.3.14",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "^7.0.0-dev.20260702.3",
diff --git a/web/src/features/auth/api.ts b/web/src/features/auth/api.ts
index 7a3ec38b5607..bf329d76fcff 100644
--- a/web/src/features/auth/api.ts
+++ b/web/src/features/auth/api.ts
@@ -21,6 +21,10 @@ import axios from 'axios'
import { api, refreshAuthentication, type RefreshOutcome } from '@/lib/api'
import { useAuthStore } from '@/stores/auth-store'
+import {
+ clearPasswordEncryptionCache,
+ encryptPassword,
+} from './lib/password-encryption'
import { getAffiliateCode } from './lib/storage'
import type { TelegramAuthorization } from './lib/telegram-login'
import type {
@@ -41,17 +45,27 @@ import type {
// ----------------------------------------------------------------------------
// User login with username and password
-export async function login(payload: LoginPayload) {
+export async function login(payload: LoginPayload): Promise {
const turnstile = payload.turnstile ?? ''
- const res = await api.post(
- `/api/user/login?turnstile=${turnstile}`,
- {
- username: payload.username,
- password: payload.password,
- },
- { skipAuthRefresh: true }
- )
- return res.data
+ try {
+ const encryptedPassword = await encryptPassword(payload.password)
+ const res = await api.post(
+ `/api/user/login?turnstile=${turnstile}`,
+ {
+ username: payload.username,
+ password_encrypted: encryptedPassword.password_encrypted,
+ encryption_key_id: encryptedPassword.encryption_key_id,
+ },
+ { skipAuthRefresh: true }
+ )
+ if (!res.data?.success) {
+ clearPasswordEncryptionCache()
+ }
+ return res.data
+ } catch (error: unknown) {
+ clearPasswordEncryptionCache()
+ throw error
+ }
}
// Two-factor authentication login
diff --git a/web/src/features/auth/lib/password-encryption.ts b/web/src/features/auth/lib/password-encryption.ts
new file mode 100644
index 000000000000..854c79563bdd
--- /dev/null
+++ b/web/src/features/auth/lib/password-encryption.ts
@@ -0,0 +1,135 @@
+/*
+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 { t } from 'i18next'
+
+import { api } from '@/lib/api'
+
+interface PasswordEncryptionKey {
+ kid: string
+ public_key: string
+}
+
+export interface EncryptedPassword {
+ password_encrypted: string
+ encryption_key_id: string
+}
+
+const KEY_CACHE_TTL_MS = 5 * 60_000
+
+let cachedKey: PasswordEncryptionKey | null = null
+let cachedAt = 0
+
+export function clearPasswordEncryptionCache(): void {
+ cachedKey = null
+ cachedAt = 0
+}
+
+export async function encryptPassword(
+ password: string
+): Promise {
+ try {
+ const key = await getPasswordEncryptionKey()
+ const ciphertext = await rsaOaepEncrypt(password, key.public_key)
+ return {
+ password_encrypted: ciphertext,
+ encryption_key_id: key.kid,
+ }
+ } catch (error: unknown) {
+ clearPasswordEncryptionCache()
+ throw new Error(t('Login failed'), { cause: error })
+ }
+}
+
+async function getPasswordEncryptionKey(): Promise {
+ const now = Date.now()
+ if (cachedKey && now - cachedAt < KEY_CACHE_TTL_MS) {
+ return cachedKey
+ }
+
+ const response = await api.get<{
+ success: boolean
+ data?: PasswordEncryptionKey
+ }>('/api/user/login/encryption-key')
+ const key = response.data?.data
+ if (!response.data?.success || !key?.kid || !key.public_key) {
+ throw new Error('Password encryption key is unavailable')
+ }
+ cachedKey = key
+ cachedAt = now
+ return key
+}
+
+async function rsaOaepEncrypt(
+ password: string,
+ publicKeyPEM: string
+): Promise {
+ if (typeof globalThis.crypto?.subtle !== 'undefined') {
+ try {
+ const publicKey = await globalThis.crypto.subtle.importKey(
+ 'spki',
+ pemToDER(publicKeyPEM),
+ { name: 'RSA-OAEP', hash: 'SHA-256' },
+ false,
+ ['encrypt']
+ )
+ const ciphertext = await globalThis.crypto.subtle.encrypt(
+ { name: 'RSA-OAEP' },
+ publicKey,
+ new TextEncoder().encode(password)
+ )
+ return arrayBufferToBase64(ciphertext)
+ } catch {
+ // Older implementations may expose SubtleCrypto without supporting the
+ // required RSA-OAEP parameters; the HTTP-compatible fallback handles it.
+ }
+ }
+
+ // Web Crypto is restricted to secure contexts in browsers. Lazy-loading
+ // forge keeps the normal HTTPS bundle small while supporting HTTP intranets.
+ const forge = await import('node-forge')
+ const publicKey = forge.pki.publicKeyFromPem(publicKeyPEM)
+ const ciphertext = publicKey.encrypt(
+ forge.util.encodeUtf8(password),
+ 'RSA-OAEP',
+ { md: forge.md.sha256.create() }
+ )
+ return forge.util.encode64(ciphertext)
+}
+
+function pemToDER(pem: string): ArrayBuffer {
+ const body = pem
+ .replace('-----BEGIN PUBLIC KEY-----', '')
+ .replace('-----END PUBLIC KEY-----', '')
+ .replaceAll(/\s+/g, '')
+ const binary = atob(body)
+ const bytes = new Uint8Array(binary.length)
+ for (let index = 0; index < binary.length; index += 1) {
+ bytes[index] = binary.charCodeAt(index)
+ }
+ return bytes.buffer
+}
+
+function arrayBufferToBase64(buffer: ArrayBuffer): string {
+ const bytes = new Uint8Array(buffer)
+ let binary = ''
+ for (const byte of bytes) {
+ binary += String.fromCharCode(byte)
+ }
+ return btoa(binary)
+}
From 8454082f930f44593e92791c2581ffc63eb30a59 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=86=A7=E6=86=ACLicoy?=
Date: Sat, 29 Aug 2026 20:34:56 +0800
Subject: [PATCH 65/99] feat(chat): add AQBot preset (#7079)
---
setting/chat.go | 3 +++
web/src/features/chat/lib/chat-links.ts | 13 ++++++++++++-
2 files changed, 15 insertions(+), 1 deletion(-)
diff --git a/setting/chat.go b/setting/chat.go
index bb8a99771939..cec1d5792f0b 100644
--- a/setting/chat.go
+++ b/setting/chat.go
@@ -25,6 +25,9 @@ var Chats = []map[string]string{
{
"DeepChat": "deepchat://provider/install?v=1&data={deepchatConfig}",
},
+ {
+ "AQBot": "aqbot://providers?{aqbotConfig}",
+ },
{
"Lobe Chat 官方示例": "https://chat-preview.lobehub.com/?settings={\"keyVaults\":{\"openai\":{\"apiKey\":\"{key}\",\"baseURL\":\"{address}/v1\"}}}",
},
diff --git a/web/src/features/chat/lib/chat-links.ts b/web/src/features/chat/lib/chat-links.ts
index 729a59fef4ef..770d0acb7fe4 100644
--- a/web/src/features/chat/lib/chat-links.ts
+++ b/web/src/features/chat/lib/chat-links.ts
@@ -89,7 +89,8 @@ export function chatLinkRequiresApiKey(url: string): boolean {
url.includes('{key}') ||
url.includes('{cherryConfig}') ||
url.includes('{aionuiConfig}') ||
- url.includes('{deepchatConfig}')
+ url.includes('{deepchatConfig}') ||
+ url.includes('{aqbotConfig}')
)
}
@@ -189,6 +190,16 @@ export function resolveChatUrl({
return replaceToken(url, '{deepchatConfig}', encoded)
}
+ if (url.includes('{aqbotConfig}')) {
+ const query = [
+ `name=${encodeURIComponent('New API')}`,
+ `baseurl=${encodeURIComponent(safeServerAddress)}`,
+ `apikey=${encodeURIComponent(safeApiKey)}`,
+ 'type=openai',
+ ].join('&')
+ return replaceToken(url, '{aqbotConfig}', query)
+ }
+
if (safeServerAddress) {
const encodedAddress = encodeURIComponent(safeServerAddress)
url = replaceToken(url, '{address}', encodedAddress)
From 918427d8ab41f6adaa4113d0496f1f8621855b70 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Sat, 29 Aug 2026 20:53:42 +0800
Subject: [PATCH 66/99] feat(auth): make password encryption opt-in #6743
---
.env.example | 2 ++
common/constants.go | 1 +
common/init.go | 1 +
controller/misc.go | 2 ++
controller/user.go | 11 +++++++++-
main.go | 8 ++++---
web/src/features/auth/api.ts | 22 ++++++++++++++-----
.../sign-in/components/user-auth-form.tsx | 5 +++++
web/src/features/auth/types.ts | 3 +++
9 files changed, 46 insertions(+), 9 deletions(-)
diff --git a/.env.example b/.env.example
index e2f287431665..2c25b62abbae 100644
--- a/.env.example
+++ b/.env.example
@@ -79,6 +79,8 @@
# 会话密钥
# SESSION_SECRET=random_string
+# 登录密码请求体 RSA-OAEP 加密;默认关闭,且不能替代 HTTPS
+# PASSWORD_LOGIN_ENCRYPTION_ENABLED=true
# false/未配置:本地 HTTP 模式,关闭 refresh/logout OriginGuard,且不得设置 TRUSTED_URL;兼容本地开发代理。
# true:启用 Secure Refresh Cookie 和严格 OriginGuard,必须同时列出全部可信 HTTPS Origin。
# SESSION_COOKIE_TRUSTED_URL 多项用英文逗号分隔;不支持通配符、路径或域名后缀匹配。
diff --git a/common/constants.go b/common/constants.go
index d6b4fb52284c..0b6e9ce82da5 100644
--- a/common/constants.go
+++ b/common/constants.go
@@ -60,6 +60,7 @@ var ItemsPerPage = 10
var MaxRecentItems = 1000
var PasswordLoginEnabled = true
+var PasswordLoginEncryptionEnabled = false
var PasswordRegisterEnabled = true
var EmailVerificationEnabled = false
var GitHubOAuthEnabled = false
diff --git a/common/init.go b/common/init.go
index 323fd207dddd..5ca1a4ab0c94 100644
--- a/common/init.go
+++ b/common/init.go
@@ -87,6 +87,7 @@ func InitEnv() {
DebugEnabled = os.Getenv("DEBUG") == "true"
MemoryCacheEnabled = os.Getenv("MEMORY_CACHE_ENABLED") == "true"
IsMasterNode = os.Getenv("NODE_TYPE") != "slave"
+ PasswordLoginEncryptionEnabled = GetEnvOrDefaultBool("PASSWORD_LOGIN_ENCRYPTION_ENABLED", false)
initNodeNameIdentity()
TLSInsecureSkipVerify = GetEnvOrDefaultBool("TLS_INSECURE_SKIP_VERIFY", false)
if TLSInsecureSkipVerify {
diff --git a/controller/misc.go b/controller/misc.go
index 7343b12f10a3..572c8f9ffd18 100644
--- a/controller/misc.go
+++ b/controller/misc.go
@@ -94,6 +94,8 @@ func GetStatus(c *gin.Context) {
"password_register_enabled": common.PasswordRegisterEnabled,
"default_use_auto_group": setting.DefaultUseAutoGroup,
+ "password_login_encryption_enabled": common.PasswordLoginEncryptionEnabled,
+
"usd_exchange_rate": operation_setting.USDExchangeRate,
"price": operation_setting.Price,
"stripe_unit_price": setting.StripeUnitPrice,
diff --git a/controller/user.go b/controller/user.go
index 1f3b3b35b067..f2eb20a56356 100644
--- a/controller/user.go
+++ b/controller/user.go
@@ -40,12 +40,17 @@ var (
)
func GetPasswordEncryptionKey(c *gin.Context) {
+ if !common.PasswordLoginEncryptionEnabled {
+ common.ApiSuccess(c, gin.H{"enabled": false})
+ return
+ }
keyID, publicKey := common.PasswordEncryptionPublicKey()
if keyID == "" || publicKey == "" {
common.ApiErrorI18n(c, i18n.MsgDatabaseError)
return
}
common.ApiSuccess(c, gin.H{
+ "enabled": true,
"kid": keyID,
"public_key": publicKey,
})
@@ -64,7 +69,11 @@ func Login(c *gin.Context) {
}
username := loginRequest.Username
password := loginRequest.Password
- if loginRequest.PasswordEncrypted != "" {
+ if common.PasswordLoginEncryptionEnabled {
+ if loginRequest.PasswordEncrypted == "" || loginRequest.EncryptionKeyID == "" {
+ common.ApiErrorI18n(c, i18n.MsgInvalidParams)
+ return
+ }
password, err = common.DecryptPassword(loginRequest.PasswordEncrypted, loginRequest.EncryptionKeyID)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgUserUsernameOrPasswordError)
diff --git a/main.go b/main.go
index d82918988d2b..c302c7a0b883 100644
--- a/main.go
+++ b/main.go
@@ -318,9 +318,11 @@ func InitResources() error {
common.FatalLog("failed to initialize authorization: " + err.Error())
return err
}
- if err = model.InitPasswordEncryption(); err != nil {
- common.FatalLog("failed to initialize password encryption: " + err.Error())
- return err
+ if common.PasswordLoginEncryptionEnabled {
+ if err = model.InitPasswordEncryption(); err != nil {
+ common.FatalLog("failed to initialize password encryption: " + err.Error())
+ return err
+ }
}
model.CheckSetup()
diff --git a/web/src/features/auth/api.ts b/web/src/features/auth/api.ts
index bf329d76fcff..d1257483b91b 100644
--- a/web/src/features/auth/api.ts
+++ b/web/src/features/auth/api.ts
@@ -48,22 +48,34 @@ import type {
export async function login(payload: LoginPayload): Promise {
const turnstile = payload.turnstile ?? ''
try {
- const encryptedPassword = await encryptPassword(payload.password)
+ let passwordFields:
+ | { password: string }
+ | { password_encrypted: string; encryption_key_id: string }
+ if (payload.passwordEncryptionEnabled) {
+ const encryptedPassword = await encryptPassword(payload.password)
+ passwordFields = {
+ password_encrypted: encryptedPassword.password_encrypted,
+ encryption_key_id: encryptedPassword.encryption_key_id,
+ }
+ } else {
+ passwordFields = { password: payload.password }
+ }
const res = await api.post(
`/api/user/login?turnstile=${turnstile}`,
{
username: payload.username,
- password_encrypted: encryptedPassword.password_encrypted,
- encryption_key_id: encryptedPassword.encryption_key_id,
+ ...passwordFields,
},
{ skipAuthRefresh: true }
)
- if (!res.data?.success) {
+ if (payload.passwordEncryptionEnabled && !res.data?.success) {
clearPasswordEncryptionCache()
}
return res.data
} catch (error: unknown) {
- clearPasswordEncryptionCache()
+ if (payload.passwordEncryptionEnabled) {
+ clearPasswordEncryptionCache()
+ }
throw error
}
}
diff --git a/web/src/features/auth/sign-in/components/user-auth-form.tsx b/web/src/features/auth/sign-in/components/user-auth-form.tsx
index cbd61a7b3349..7acdb02f130c 100644
--- a/web/src/features/auth/sign-in/components/user-auth-form.tsx
+++ b/web/src/features/auth/sign-in/components/user-auth-form.tsx
@@ -84,6 +84,10 @@ export function UserAuthForm({
(status?.password_login_enabled ??
status?.data?.password_login_enabled ??
true) !== false
+ const passwordLoginEncryptionEnabled =
+ (status?.password_login_encryption_enabled ??
+ status?.data?.password_login_encryption_enabled ??
+ false) === true
const {
isTurnstileEnabled,
turnstileSiteKey,
@@ -171,6 +175,7 @@ export function UserAuthForm({
username: data.username,
password: data.password,
turnstile: submittedTurnstileToken,
+ passwordEncryptionEnabled: passwordLoginEncryptionEnabled,
})
if (res.success) {
diff --git a/web/src/features/auth/types.ts b/web/src/features/auth/types.ts
index afaa4b716042..1d6b89c322df 100644
--- a/web/src/features/auth/types.ts
+++ b/web/src/features/auth/types.ts
@@ -26,6 +26,7 @@ export interface LoginPayload {
username: string
password: string
turnstile?: string
+ passwordEncryptionEnabled?: boolean
}
export interface TwoFAPayload {
@@ -133,6 +134,7 @@ export interface SystemStatus {
oauth_register_enabled?: boolean
register_enabled?: boolean
password_login_enabled?: boolean
+ password_login_encryption_enabled?: boolean
password_register_enabled?: boolean
custom_oauth_providers?: CustomOAuthProviderInfo[]
[key: string]: unknown
@@ -178,6 +180,7 @@ export interface SystemStatus {
oauth_register_enabled?: boolean
register_enabled?: boolean
password_login_enabled?: boolean
+ password_login_encryption_enabled?: boolean
password_register_enabled?: boolean
custom_oauth_providers?: CustomOAuthProviderInfo[]
[key: string]: unknown
From 6c22550ea325d4fea0e0ece52412cb1e4449291c Mon Sep 17 00:00:00 2001
From: CaIon
Date: Sun, 30 Aug 2026 19:13:51 +0800
Subject: [PATCH 67/99] feat(task): resolve channel-mapped aliases and case
variants for plugin models
Channel model_mapping keys exposed in a channel's model list now act as
first-class aliases for task-plugin models across the whole line:
- Derived alias view (model/task_model_alias.go): built from enabled
channels' model_mapping, chain-following with cycle detection, declared
names always win, cross-plugin conflicts dropped. Rebuilt on channel
cache refresh, registry generation change, and a 60s TTL.
- Request path: PinTaskPluginEndpoint resolves declared-name case folds
and mapping aliases before endpoint lookup (never rewriting the body
until the endpoint is claimed), pins with MappedModel, and the decode
contract accepts alias echoes without loosening model ownership for
normal pins. Legacy /v1/tasks submit folds case variants the same way.
Fixes aliases on POST /v1/responses silently falling through to the
main relay against task channels.
- Mapping order: ModelMappedHelper now runs before the plugin submit
hook builds and caches the upstream body, so channel model_mapping
actually reaches the upstream request. Plugins receive the mapped
name as ctx.upstreamModel in both decode and submit contexts.
- Billing: identity stays the origin name; when the alias has no tiered
expression, the selected channel's mapping tail expression applies.
Pricing page and billing-expr smoke tests resolve aliases to the
owning plugin's usage schema.
- Case folding: ASCII-only fold with exact-match priority; same-plugin
and cross-plugin fold collisions rejected at registration.
- Plugins: model-keyed rate tables, req_key derivation, and combo
validation in doubao/kling/jimeng/hailuo/vidu/sunoapi now key on
ctx.upstreamModel || ctx.model; render/echo paths keep ctx.model.
---
controller/billing_option_test.go | 103 ++++++++++
controller/option.go | 6 +
controller/plugin_protocol_test.go | 26 +++
docs/plugin-api/v1.md | 2 +
middleware/task_plugin.go | 85 +++++++-
middleware/task_plugin_model.go | 45 ++++
middleware/task_plugin_test.go | 8 +-
model/channel_cache.go | 4 +-
model/pricing.go | 15 +-
model/pricing_usage_schema_test.go | 70 +++++++
model/task_model_alias.go | 212 +++++++++++++++++++
pkg/jsplugin/model_fold.go | 26 +++
pkg/jsplugin/registry.go | 7 +-
pkg/jsplugin/routing.go | 91 ++++++---
plugins/tasks/doubao/plugin.js | 2 +-
plugins/tasks/hailuo/plugin.js | 3 +-
plugins/tasks/jimeng/plugin.js | 5 +-
plugins/tasks/kling/plugin.js | 4 +-
plugins/tasks/sunoapi/plugin.js | 2 +-
plugins/tasks/vidu/plugin.js | 2 +-
relay/channel/task/jsplugin/adaptor_test.go | 54 +++++
relay/relay_task.go | 41 +++-
relay/relay_task_test.go | 214 ++++++++++++++++++++
23 files changed, 968 insertions(+), 59 deletions(-)
create mode 100644 middleware/task_plugin_model.go
create mode 100644 model/task_model_alias.go
create mode 100644 pkg/jsplugin/model_fold.go
diff --git a/controller/billing_option_test.go b/controller/billing_option_test.go
index c02334d52ab0..cf6b37ea53f1 100644
--- a/controller/billing_option_test.go
+++ b/controller/billing_option_test.go
@@ -7,10 +7,14 @@ import (
"testing"
"github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/QuantumNous/new-api/setting/config"
"github.com/gin-gonic/gin"
+ "github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "gorm.io/gorm"
)
func TestUpdateOptionRejectsInvalidTaskBillingExpressions(t *testing.T) {
@@ -98,3 +102,102 @@ func TestUpdateOptionRejectsUsageExpressionWithoutTaskPlugin(t *testing.T) {
assert.Contains(t, recorder.Body.String(), "mode")
assert.Contains(t, recorder.Body.String(), "no task plugin usage schema")
}
+
+func setupBillingAliasOptionDB(t *testing.T) {
+ t.Helper()
+ previousDB := model.DB
+ previousLogDB := model.LOG_DB
+ previousType := common.MainDatabaseType()
+ previousCache := common.MemoryCacheEnabled
+ previousMap := common.OptionMap
+ previousRedis := common.RedisEnabled
+ database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Option{}, &model.Log{}, &model.User{}))
+ model.DB = database
+ model.LOG_DB = database
+ common.SetMainDatabaseType(common.DatabaseTypeSQLite)
+ common.MemoryCacheEnabled = false
+ common.RedisEnabled = false
+ common.OptionMap = map[string]string{}
+ t.Cleanup(func() {
+ model.DB = previousDB
+ model.LOG_DB = previousLogDB
+ common.SetMainDatabaseType(previousType)
+ common.MemoryCacheEnabled = previousCache
+ common.OptionMap = previousMap
+ common.RedisEnabled = previousRedis
+ model.InitChannelCache()
+ })
+}
+
+func TestUpdateOptionAliasBillingExprUsesPluginSchema(t *testing.T) {
+ setupBillingAliasOptionDB(t)
+ const pluginKey = "billing-alias-probe"
+ source := `
+export const meta = {
+ apiVersion: 1, key: "billing-alias-probe", name: "Billing Alias Probe", version: "1.0.0", author: {name: "Test"},
+ models: ["declared-model"], fetchMode: "per_task",
+ usageSchema: {seconds: {type: "number", unit: "second"}}
+};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+`
+ _, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(pluginKey) })
+
+ mapping := `{"alias-model":"declared-model"}`
+ require.NoError(t, model.DB.Create(&model.Channel{
+ Id: 1,
+ Type: 54,
+ Key: "key-1",
+ Status: common.ChannelStatusEnabled,
+ Name: "ch-1",
+ Group: "default",
+ Models: "alias-model,declared-model",
+ ModelMapping: &mapping,
+ }).Error)
+ model.InitChannelCache()
+
+ saved := map[string]string{}
+ require.NoError(t, config.GlobalConfig.SaveToDB(func(key, value string) error {
+ saved[key] = value
+ return nil
+ }))
+ t.Cleanup(func() {
+ require.NoError(t, config.GlobalConfig.LoadFromDB(saved))
+ })
+
+ putExpr := func(modelName, expression string) *httptest.ResponseRecorder {
+ t.Helper()
+ expressions, marshalErr := common.Marshal(map[string]string{modelName: expression})
+ require.NoError(t, marshalErr)
+ body, marshalErr := common.Marshal(OptionUpdateRequest{
+ Key: "billing_setting.billing_expr",
+ Value: string(expressions),
+ })
+ require.NoError(t, marshalErr)
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Request = httptest.NewRequest(http.MethodPut, "/api/option/", strings.NewReader(string(body)))
+ UpdateOption(context)
+ return recorder
+ }
+
+ accepted := putExpr("alias-model", `u("seconds")`)
+ assert.Equal(t, http.StatusOK, accepted.Code)
+ assert.Contains(t, accepted.Body.String(), `"success":true`)
+
+ rejectedKey := putExpr("alias-model", `u("clips")`)
+ assert.Equal(t, http.StatusOK, rejectedKey.Code)
+ assert.Contains(t, rejectedKey.Body.String(), `"success":false`)
+ assert.Contains(t, rejectedKey.Body.String(), `usage key \"clips\" is not declared`)
+
+ unresolvable := putExpr("unknown-alias-model", `u("seconds")`)
+ assert.Equal(t, http.StatusOK, unresolvable.Code)
+ assert.Contains(t, unresolvable.Body.String(), `"success":false`)
+ assert.Contains(t, unresolvable.Body.String(), "no task plugin usage schema")
+}
diff --git a/controller/option.go b/controller/option.go
index 70a5f1921894..1feb4a818d37 100644
--- a/controller/option.go
+++ b/controller/option.go
@@ -352,6 +352,12 @@ func UpdateOption(c *gin.Context) {
expression := expressions[modelName]
if plugin, ok := generation.GetByModel(modelName); ok {
err = billing_setting.SmokeTestTaskExpr(expression, plugin.Meta.UsageSchema)
+ } else if target, resolved := model.ResolveTaskModelAlias(generation, modelName); resolved {
+ if plugin, ok := generation.Get(target.PluginKey); ok {
+ err = billing_setting.SmokeTestTaskExpr(expression, plugin.Meta.UsageSchema)
+ } else {
+ err = billing_setting.SmokeTestExpr(expression)
+ }
} else {
err = billing_setting.SmokeTestExpr(expression)
}
diff --git a/controller/plugin_protocol_test.go b/controller/plugin_protocol_test.go
index a6a5fdc1bdbe..edc64034aa89 100644
--- a/controller/plugin_protocol_test.go
+++ b/controller/plugin_protocol_test.go
@@ -1088,6 +1088,7 @@ func TestRetrieveTaskPluginResponsePendingSkipsRenderFinal(t *testing.T) {
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.Equal(t, "resp_retrieve_pending", response["id"])
assert.Equal(t, "in_progress", response["status"])
+ assert.Equal(t, "video-model", response["model"])
assert.Equal(t, true, response["background"])
assert.Nil(t, response["completed_at"])
assert.Empty(t, response["output"])
@@ -1096,6 +1097,31 @@ func TestRetrieveTaskPluginResponsePendingSkipsRenderFinal(t *testing.T) {
assert.Equal(t, "/v1/responses/resp_retrieve_pending", metadata["retrieval_path"])
}
+func TestRetrieveTaskPluginResponseEchoesOriginModelName(t *testing.T) {
+ pinned := compilePluginProtocolRetrieveEndpoint(t, "retrieve-alias-echo", `
+ export const protocols = {openai_responses: {
+ renderEvents: function() { throw new Error("pending retrieve called renderEvents"); },
+ renderFinal: function() { throw new Error("pending retrieve called renderFinal"); }
+ }};
+ `, pluginruntime.Options{})
+ c, recorder := newPluginProtocolRetrieveContext("resp_retrieve_alias")
+ deps := pluginProtocolRetrieveDeps(pinned, &model.Task{
+ TaskID: "task_retrieve_alias",
+ Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
+ UserId: 71,
+ Status: model.TaskStatusInProgress,
+ Properties: model.Properties{OriginModelName: "alias-model"},
+ CreatedAt: 1_710_000_000,
+ }, true, nil)
+
+ retrieveTaskPluginResponse(c, deps)
+
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ var response map[string]any
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.Equal(t, "alias-model", response["model"])
+}
+
func TestRetrieveTaskPluginResponseSuccessRendersFinal(t *testing.T) {
logs := make([]string, 0, 1)
pinned := compilePluginProtocolRetrieveEndpoint(t, "retrieve-success", `
diff --git a/docs/plugin-api/v1.md b/docs/plugin-api/v1.md
index 814513b06ffa..62fba917a100 100644
--- a/docs/plugin-api/v1.md
+++ b/docs/plugin-api/v1.md
@@ -138,3 +138,5 @@ Protocol media uses host-injected `ctx.artifacts[key].url`. Provider URLs from `
## Persisted data and driver hooks
The persisted field remains `task.data`; there is no `task.raw` alias. Driver hooks (`buildSubmitRequest`, `parseSubmitResponse`, query/result, usage, artifact, and content hooks) stay flat and must not branch on the client path or protocol.
+
+`ctx.model` is the billing and display identity (the origin name the client sent, including a channel-mapping alias). `ctx.upstreamModel` is the machine identity after channel `model_mapping`. Rate tables and model-keyed usage facts must use `ctx.upstreamModel || ctx.model`. Decode and render hooks that echo the client model must keep `ctx.model`. `buildSubmitRequest` must not set descriptor top-level `model` on a mapped pin; the host requires the plugin to echo the alias verbatim.
diff --git a/middleware/task_plugin.go b/middleware/task_plugin.go
index 3ca33ab7329b..7d271c77231d 100644
--- a/middleware/task_plugin.go
+++ b/middleware/task_plugin.go
@@ -332,18 +332,50 @@ func PinTaskPluginEndpoint() gin.HandlerFunc {
c.Next()
return
}
- c.Set(contextKeyTaskPluginEndpointModel, *modelRequest)
claimedModel := modelRequest.Model
if strings.TrimSpace(claimedModel) == "" {
+ c.Set(contextKeyTaskPluginEndpointModel, *modelRequest)
c.Next()
return
}
- binding, found := generation.LookupEndpoint(c.Request.Method, c.Request.URL.Path, claimedModel)
+ lookupModel := claimedModel
+ pinModel := claimedModel
+ mappedModel := ""
+ rewriteTo := ""
+ if declared, ok := generation.CanonicalModel(claimedModel); ok {
+ lookupModel = declared
+ pinModel = declared
+ if claimedModel != declared {
+ rewriteTo = declared
+ }
+ } else if target, ok := model.ResolveTaskModelAlias(generation, claimedModel); ok {
+ if target.Declared == "" {
+ c.Set(contextKeyTaskPluginEndpointModel, *modelRequest)
+ c.Next()
+ return
+ }
+ lookupModel = target.Declared
+ pinModel = target.Alias
+ mappedModel = target.Declared
+ if claimedModel != target.Alias {
+ rewriteTo = target.Alias
+ }
+ }
+ binding, found := generation.LookupEndpoint(c.Request.Method, c.Request.URL.Path, lookupModel)
if !found || binding.Plugin == nil {
+ c.Set(contextKeyTaskPluginEndpointModel, *modelRequest)
c.Next()
return
}
- candidates := generation.LookupEndpointCandidates(c.Request.Method, c.Request.URL.Path, claimedModel)
+ if rewriteTo != "" {
+ if rewriteErr := rewriteTaskPluginJSONModel(c, rewriteTo); rewriteErr != nil {
+ abortWithOpenAiMessage(c, http.StatusBadRequest, "Invalid task protocol request")
+ return
+ }
+ }
+ modelRequest.Model = pinModel
+ c.Set(contextKeyTaskPluginEndpointModel, *modelRequest)
+ candidates := generation.LookupEndpointCandidates(c.Request.Method, c.Request.URL.Path, lookupModel)
if len(candidates) == 0 {
candidates = []pluginruntime.ProtocolBinding{binding}
}
@@ -386,12 +418,13 @@ func PinTaskPluginEndpoint() gin.HandlerFunc {
pin := pluginruntime.PinnedPlugin{Generation: generation, Plugin: binding.Plugin}
pinnedEndpoint := pluginruntime.PinnedEndpoint{
- Generation: generation,
- Plugin: binding.Plugin,
- Protocol: binding.Protocol,
- Operation: binding.Operation,
- Model: claimedModel,
- Candidates: candidates,
+ Generation: generation,
+ Plugin: binding.Plugin,
+ Protocol: binding.Protocol,
+ Operation: binding.Operation,
+ Model: pinModel,
+ MappedModel: mappedModel,
+ Candidates: candidates,
}
c.Set(pluginruntime.ContextKeyPinnedPlugin, pin)
c.Set(pluginruntime.ContextKeyPinnedEndpoint, pinnedEndpoint)
@@ -403,7 +436,7 @@ func PinTaskPluginEndpoint() gin.HandlerFunc {
binding.Plugin.Meta.Version,
binding.Operation.Methods[0],
binding.Protocol,
- claimedModel,
+ pinModel,
)
c.Next()
}
@@ -515,6 +548,13 @@ func PrepareTaskPluginEndpoint() gin.HandlerFunc {
abortWithOpenAiMessage(c, status, err.Error())
return
}
+ if body, ok := requestContext.Body.(map[string]any); ok {
+ if fields, ok := body["fields"].(map[string][]string); ok {
+ if values := fields["model"]; len(values) > 0 && values[0] != pinned.Model {
+ fields["model"][0] = pinned.Model
+ }
+ }
+ }
bodyObject, _ := requestContext.Body.(map[string]any)
bodyKind, _ := bodyObject["kind"].(string)
allowedBody := false
@@ -561,6 +601,7 @@ func PrepareTaskPluginEndpoint() gin.HandlerFunc {
Protocol: pinned.Protocol,
Operation: pinned.Operation.Name,
Model: pinned.Model,
+ UpstreamModel: pinned.MappedModel,
Stream: stream,
}
c.Set(pluginruntime.ContextKeyProtocolRequest, protocolContext)
@@ -624,7 +665,8 @@ func PrepareTaskPluginEndpoint() gin.HandlerFunc {
return
}
modelOwned := slices.Contains(pinned.Plugin.Meta.Models, resolvedModel)
- if !modelOwned || resolvedModel != pinned.Model {
+ mappedPin := pinned.MappedModel != ""
+ if resolvedModel != pinned.Model || (!modelOwned && !mappedPin) {
logger.LogWarn(
c,
"task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=parse_request reason=resolved_model_not_owned claimed_model=%q resolved_model=%q",
@@ -1380,6 +1422,27 @@ func PrepareTaskPluginSubmit() gin.HandlerFunc {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": "model is required", "type": "invalid_request_error"}})
return
}
+ exactOwned := slices.Contains(plugin.Meta.Models, modelName)
+ exactAlias := false
+ if target, resolved := model.ResolveTaskModelAlias(generation, modelName); resolved && target.Alias == modelName && target.PluginKey == plugin.Meta.Key {
+ exactAlias = true
+ }
+ if !exactOwned && !exactAlias {
+ folded := ""
+ if declared, ok := generation.CanonicalModel(modelName); ok && slices.Contains(plugin.Meta.Models, declared) && declared != modelName {
+ folded = declared
+ } else if target, resolved := model.ResolveTaskModelAlias(generation, modelName); resolved && target.PluginKey == plugin.Meta.Key && target.Alias != "" && target.Alias != modelName {
+ folded = target.Alias
+ }
+ if folded != "" {
+ if rewriteErr := rewriteTaskPluginJSONModel(c, folded); rewriteErr != nil {
+ c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": rewriteErr.Error(), "type": "invalid_request_error"}})
+ return
+ }
+ requestBody["model"] = folded
+ modelName = folded
+ }
+ }
c.Set("task_request", requestBody)
c.Set("resolved_task_model", modelName)
c.Set("expected_task_plugin_key", pluginKey)
diff --git a/middleware/task_plugin_model.go b/middleware/task_plugin_model.go
new file mode 100644
index 000000000000..e7bcba753957
--- /dev/null
+++ b/middleware/task_plugin_model.go
@@ -0,0 +1,45 @@
+package middleware
+
+import (
+ "io"
+ "mime"
+ "strings"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/gin-gonic/gin"
+ "github.com/tidwall/sjson"
+)
+
+// rewriteTaskPluginJSONModel patches the top-level JSON "model" field and
+// replaces BodyStorage. Non-JSON bodies are left untouched.
+func rewriteTaskPluginJSONModel(c *gin.Context, spelling string) error {
+ mediaType, _, err := mime.ParseMediaType(c.GetHeader("Content-Type"))
+ if err != nil {
+ return nil
+ }
+ if mediaType != "application/json" && !strings.HasSuffix(mediaType, "+json") {
+ return nil
+ }
+ storage, err := common.GetBodyStorage(c)
+ if err != nil {
+ return err
+ }
+ raw, err := storage.Bytes()
+ if err != nil {
+ return err
+ }
+ patched, err := sjson.SetBytes(raw, "model", spelling)
+ if err != nil {
+ return err
+ }
+ newStorage, err := common.CreateBodyStorage(patched)
+ if err != nil {
+ return err
+ }
+ _ = storage.Close()
+ c.Set(common.KeyBodyStorage, newStorage)
+ c.Set(common.KeyRequestBody, nil)
+ c.Request.Body = io.NopCloser(newStorage)
+ c.Request.ContentLength = int64(len(patched))
+ return nil
+}
diff --git a/middleware/task_plugin_test.go b/middleware/task_plugin_test.go
index c2e8cbfc14af..c7e43e7de8f1 100644
--- a/middleware/task_plugin_test.go
+++ b/middleware/task_plugin_test.go
@@ -630,9 +630,11 @@ func TestTaskPluginEndpointMissPreservesOrdinaryRequestBody(t *testing.T) {
func(c *gin.Context) {
_, pinned := c.Get(jsplugin.ContextKeyPinnedEndpoint)
assert.False(t, pinned)
- var body map[string]any
- require.NoError(t, common.UnmarshalBodyReusable(c, &body))
- assert.Equal(t, "ordinary-model", body["model"])
+ storage, storageErr := common.GetBodyStorage(c)
+ require.NoError(t, storageErr)
+ raw, bytesErr := storage.Bytes()
+ require.NoError(t, bytesErr)
+ assert.Equal(t, []byte(`{"model":"ordinary-model","input":"hello"}`), raw)
c.Status(http.StatusNoContent)
},
)
diff --git a/model/channel_cache.go b/model/channel_cache.go
index 97b80cac447c..a992c1961c8b 100644
--- a/model/channel_cache.go
+++ b/model/channel_cache.go
@@ -11,8 +11,8 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
- "github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/dto"
+ "github.com/QuantumNous/new-api/logger"
kitdto "github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/ratio_setting"
)
@@ -27,6 +27,7 @@ var channelSyncLock sync.RWMutex
func InitChannelCache() {
if !common.MemoryCacheEnabled {
InvalidatePricingCache()
+ rebuildTaskAliasView()
return
}
newChannelId2channel := make(map[int]*Channel)
@@ -101,6 +102,7 @@ func InitChannelCache() {
// loadPricingAdvancedCustomConfigs. channelSyncLock MUST be released before
// invalidating the pricing cache, otherwise the reversed order deadlocks.
InvalidatePricingCache()
+ rebuildTaskAliasView()
common.SysLog("channels synced from database")
}
diff --git a/model/pricing.go b/model/pricing.go
index 9d9f5c50e38c..4cec75f3e14a 100644
--- a/model/pricing.go
+++ b/model/pricing.go
@@ -410,8 +410,21 @@ func updatePricing() {
pricing.BillingMode = billingMode
pricing.BillingExpr = expr
}
+ } else if target, resolved := ResolveTaskModelAlias(pluginGeneration, model); resolved && target.Declared != "" {
+ if tailMode := billing_setting.GetBillingMode(target.Declared); tailMode == "tiered_expr" {
+ if expr, ok := billing_setting.GetBillingExpr(target.Declared); ok && strings.TrimSpace(expr) != "" {
+ pricing.BillingMode = tailMode
+ pricing.BillingExpr = expr
+ }
+ }
+ }
+ plugin, ok := pluginGeneration.GetByModel(model)
+ if !ok {
+ if target, resolved := ResolveTaskModelAlias(pluginGeneration, model); resolved {
+ plugin, ok = pluginGeneration.Get(target.PluginKey)
+ }
}
- if plugin, ok := pluginGeneration.GetByModel(model); ok && len(plugin.Meta.UsageSchema) > 0 {
+ if ok && plugin != nil && len(plugin.Meta.UsageSchema) > 0 {
pricing.BillingUsageSchema = make(map[string]jsplugin.UsageFieldSchema, len(plugin.Meta.UsageSchema))
for key, field := range plugin.Meta.UsageSchema {
field.Enum = append([]string(nil), field.Enum...)
diff --git a/model/pricing_usage_schema_test.go b/model/pricing_usage_schema_test.go
index 593bc1df55f8..b947b9749cf7 100644
--- a/model/pricing_usage_schema_test.go
+++ b/model/pricing_usage_schema_test.go
@@ -8,6 +8,7 @@ import (
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/setting/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -60,6 +61,75 @@ func TestPricingCarriesTaskUsageSchemaAndRefreshesWithPluginGeneration(t *testin
assert.Equal(t, "count", refreshedPricing["pricing-usage-model"].BillingUsageSchema["clips"].Unit)
}
+func TestPricingAliasCarriesPluginUsageSchemaAndTailExpr(t *testing.T) {
+ resetPricingEndpointTestTables(t)
+ const pluginKey = "pricing-usage-probe"
+ source := pricingUsagePluginSource("1.0.0", `{
+ seconds: {type: "number", unit: "second", description: "Estimated duration."}
+}`)
+ _, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(pluginKey) })
+
+ mapping := `{"alias-model":"pricing-usage-model"}`
+ channel := &Channel{
+ Id: 910,
+ Type: constant.ChannelTypeTaskPlugin,
+ Key: "key-910",
+ Status: 1,
+ Name: "channel-910",
+ Models: "alias-model,pricing-usage-model",
+ ModelMapping: &mapping,
+ }
+ require.NoError(t, DB.Create(channel).Error)
+ insertPricingEndpointAbility(t, 910, "alias-model")
+ insertPricingEndpointAbility(t, 910, "pricing-usage-model")
+ InitChannelCache()
+
+ saved := map[string]string{}
+ require.NoError(t, config.GlobalConfig.SaveToDB(func(key, value string) error {
+ saved[key] = value
+ return nil
+ }))
+ t.Cleanup(func() {
+ require.NoError(t, config.GlobalConfig.LoadFromDB(saved))
+ })
+ require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
+ "billing_setting.billing_mode": `{"pricing-usage-model":"tiered_expr","alias-own-expr":"tiered_expr"}`,
+ "billing_setting.billing_expr": `{"pricing-usage-model":"u(\"seconds\")","alias-own-expr":"u(\"seconds\") * 2"}`,
+ }))
+ InvalidatePricingCache()
+
+ pricing := pricingByModel(GetPricing())
+ require.Contains(t, pricing, "alias-model")
+ require.Contains(t, pricing, "pricing-usage-model")
+ assert.Equal(t, "second", pricing["alias-model"].BillingUsageSchema["seconds"].Unit)
+ assert.Equal(t, "Estimated duration.", pricing["alias-model"].BillingUsageSchema["seconds"].Description["en"])
+ assert.Equal(t, "tiered_expr", pricing["alias-model"].BillingMode)
+ assert.Equal(t, `u("seconds")`, pricing["alias-model"].BillingExpr)
+ assert.Equal(t, "tiered_expr", pricing["pricing-usage-model"].BillingMode)
+ assert.Equal(t, `u("seconds")`, pricing["pricing-usage-model"].BillingExpr)
+
+ ownMapping := `{"alias-own-expr":"pricing-usage-model"}`
+ own := &Channel{
+ Id: 911,
+ Type: constant.ChannelTypeTaskPlugin,
+ Key: "key-911",
+ Status: 1,
+ Name: "channel-911",
+ Models: "alias-own-expr,pricing-usage-model",
+ ModelMapping: &ownMapping,
+ }
+ require.NoError(t, DB.Create(own).Error)
+ insertPricingEndpointAbility(t, 911, "alias-own-expr")
+ InitChannelCache()
+ InvalidatePricingCache()
+
+ refreshed := pricingByModel(GetPricing())
+ assert.Equal(t, `u("seconds") * 2`, refreshed["alias-own-expr"].BillingExpr)
+ assert.Equal(t, "second", refreshed["alias-own-expr"].BillingUsageSchema["seconds"].Unit)
+}
+
func pricingByModel(pricings []Pricing) map[string]Pricing {
result := make(map[string]Pricing, len(pricings))
for _, pricing := range pricings {
diff --git a/model/task_model_alias.go b/model/task_model_alias.go
new file mode 100644
index 000000000000..c975386ede6d
--- /dev/null
+++ b/model/task_model_alias.go
@@ -0,0 +1,212 @@
+package model
+
+import (
+ "fmt"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+)
+
+// TaskAliasTarget is one mapping-derived alias after cross-channel aggregation.
+// Declared is empty when the same plugin resolves the alias to more than one
+// declared tail (display-only; pin is disabled).
+type TaskAliasTarget struct {
+ Alias string
+ Declared string
+ PluginKey string
+}
+
+type taskAliasView struct {
+ generation uint64
+ expiresAt time.Time
+ byFold map[string]TaskAliasTarget
+}
+
+const taskAliasViewTTL = 60 * time.Second
+
+var (
+ taskAliasViewPtr atomic.Pointer[taskAliasView]
+ taskAliasRebuildMu sync.Mutex
+)
+
+// ResolveTaskModelAlias returns the mapping-derived alias target for name
+// (exact or ASCII-folded). g is the caller's routing generation: a Number
+// mismatch or TTL expiry rebuilds the view against that generation.
+func ResolveTaskModelAlias(g *jsplugin.RoutingGeneration, name string) (TaskAliasTarget, bool) {
+ if g == nil || name == "" {
+ return TaskAliasTarget{}, false
+ }
+ view := loadFreshTaskAliasView(g)
+ if view == nil {
+ return TaskAliasTarget{}, false
+ }
+ target, ok := view.byFold[jsplugin.ASCIIFold(name)]
+ return target, ok
+}
+
+func loadFreshTaskAliasView(g *jsplugin.RoutingGeneration) *taskAliasView {
+ view := taskAliasViewPtr.Load()
+ if taskAliasViewFresh(view, g.Number) {
+ return view
+ }
+ taskAliasRebuildMu.Lock()
+ defer taskAliasRebuildMu.Unlock()
+ view = taskAliasViewPtr.Load()
+ if taskAliasViewFresh(view, g.Number) {
+ return view
+ }
+ rebuilt := buildTaskAliasView(g)
+ taskAliasViewPtr.Store(rebuilt)
+ return rebuilt
+}
+
+func taskAliasViewFresh(view *taskAliasView, generation uint64) bool {
+ return view != nil && view.generation == generation && time.Now().Before(view.expiresAt)
+}
+
+func rebuildTaskAliasView() {
+ taskAliasRebuildMu.Lock()
+ defer taskAliasRebuildMu.Unlock()
+ taskAliasViewPtr.Store(buildTaskAliasView(jsplugin.DefaultRegistry.Generation()))
+}
+
+type taskAliasDraft struct {
+ spellings []string
+ byPlugin map[string]map[string]struct{}
+}
+
+func buildTaskAliasView(generation *jsplugin.RoutingGeneration) *taskAliasView {
+ genNum := uint64(0)
+ if generation != nil {
+ genNum = generation.Number
+ }
+ view := &taskAliasView{
+ generation: genNum,
+ expiresAt: time.Now().Add(taskAliasViewTTL),
+ byFold: make(map[string]TaskAliasTarget),
+ }
+ if DB == nil {
+ return view
+ }
+
+ var channels []Channel
+ err := DB.Select("id", "type", "models", "model_mapping").
+ Where("status = ?", common.ChannelStatusEnabled).
+ Find(&channels).Error
+ if err != nil {
+ common.SysError(fmt.Sprintf("rebuild task alias view: %s", err.Error()))
+ return view
+ }
+
+ drafts := make(map[string]*taskAliasDraft)
+ for i := range channels {
+ channel := &channels[i]
+ mappingJSON := channel.GetModelMapping()
+ if mappingJSON == "" || mappingJSON == "{}" {
+ continue
+ }
+ modelMap := make(map[string]string)
+ if err := common.UnmarshalJsonStr(mappingJSON, &modelMap); err != nil {
+ common.SysError(fmt.Sprintf("task alias view: channel %d model_mapping: %s", channel.Id, err.Error()))
+ continue
+ }
+ inModels := make(map[string]struct{})
+ for _, modelName := range channel.GetModels() {
+ inModels[modelName] = struct{}{}
+ }
+ for alias, mapped := range modelMap {
+ if mapped == "" {
+ continue
+ }
+ if _, exposed := inModels[alias]; !exposed {
+ continue
+ }
+ if _, declared := generation.CanonicalModel(alias); declared {
+ continue
+ }
+ tail, cyclic := followChannelModelMapping(modelMap, alias)
+ if cyclic {
+ common.SysError(fmt.Sprintf("task alias mapping cycle dropped: channel=%d key=%q", channel.Id, alias))
+ continue
+ }
+ declared, ok := generation.CanonicalModel(tail)
+ if !ok {
+ continue
+ }
+ plugin, ok := generation.GetByModel(declared)
+ if !ok {
+ continue
+ }
+ fold := jsplugin.ASCIIFold(alias)
+ draft := drafts[fold]
+ if draft == nil {
+ draft = &taskAliasDraft{byPlugin: make(map[string]map[string]struct{})}
+ drafts[fold] = draft
+ }
+ draft.spellings = append(draft.spellings, alias)
+ declareds := draft.byPlugin[plugin.Meta.Key]
+ if declareds == nil {
+ declareds = make(map[string]struct{})
+ draft.byPlugin[plugin.Meta.Key] = declareds
+ }
+ declareds[declared] = struct{}{}
+ }
+ }
+
+ for _, draft := range drafts {
+ alias := draft.spellings[0]
+ for _, spelling := range draft.spellings[1:] {
+ if spelling < alias {
+ alias = spelling
+ }
+ }
+ if len(draft.byPlugin) != 1 {
+ pluginKeys := make([]string, 0, len(draft.byPlugin))
+ for key := range draft.byPlugin {
+ pluginKeys = append(pluginKeys, key)
+ }
+ common.SysLog(fmt.Sprintf("task model alias %q dropped: maps to multiple plugins %v", alias, pluginKeys))
+ continue
+ }
+ var pluginKey, declared string
+ for key, declareds := range draft.byPlugin {
+ pluginKey = key
+ if len(declareds) == 1 {
+ for name := range declareds {
+ declared = name
+ }
+ }
+ }
+ view.byFold[jsplugin.ASCIIFold(alias)] = TaskAliasTarget{
+ Alias: alias,
+ Declared: declared,
+ PluginKey: pluginKey,
+ }
+ }
+ return view
+}
+
+// followChannelModelMapping walks one channel's mapping the same way
+// ModelMappedHelper does: visited-set cycle detection, self-map stops at
+// the current hop, a non-self cycle is reported to the caller.
+func followChannelModelMapping(modelMap map[string]string, start string) (string, bool) {
+ current := start
+ visited := map[string]bool{current: true}
+ for {
+ mapped, exists := modelMap[current]
+ if !exists || mapped == "" {
+ return current, false
+ }
+ if visited[mapped] {
+ if mapped == current {
+ return current, false
+ }
+ return "", true
+ }
+ visited[mapped] = true
+ current = mapped
+ }
+}
diff --git a/pkg/jsplugin/model_fold.go b/pkg/jsplugin/model_fold.go
new file mode 100644
index 000000000000..5e4cb037d1e1
--- /dev/null
+++ b/pkg/jsplugin/model_fold.go
@@ -0,0 +1,26 @@
+package jsplugin
+
+// asciiFold maps only 'A'–'Z' onto 'a'–'z'. Every other byte is unchanged.
+// Unicode case folding is intentionally not applied: U+212A (KELVIN SIGN)
+// must not become 'k' and impersonate an ASCII model name.
+func asciiFold(s string) string {
+ var buf []byte
+ for i := 0; i < len(s); i++ {
+ c := s[i]
+ if c >= 'A' && c <= 'Z' {
+ if buf == nil {
+ buf = []byte(s)
+ }
+ buf[i] = c + ('a' - 'A')
+ }
+ }
+ if buf == nil {
+ return s
+ }
+ return string(buf)
+}
+
+// ASCIIFold is the exported form of asciiFold for consumers outside this package.
+func ASCIIFold(s string) string {
+ return asciiFold(s)
+}
diff --git a/pkg/jsplugin/registry.go b/pkg/jsplugin/registry.go
index e16b7041b422..1ad50e54dbad 100644
--- a/pkg/jsplugin/registry.go
+++ b/pkg/jsplugin/registry.go
@@ -1144,13 +1144,16 @@ func normalizeV1Meta(meta *Meta) error {
seenChannelTypes[channelType] = struct{}{}
}
models := make(map[string]struct{}, len(meta.Models))
+ seenFold := make(map[string]struct{}, len(meta.Models))
for _, model := range meta.Models {
if strings.TrimSpace(model) == "" || strings.TrimSpace(model) != model {
return fmt.Errorf("plugin meta models must contain non-empty canonical names")
}
- if _, exists := models[model]; exists {
- return fmt.Errorf("plugin meta models must be unique")
+ folded := asciiFold(model)
+ if _, exists := seenFold[folded]; exists {
+ return fmt.Errorf("plugin meta models must be unique case-insensitively")
}
+ seenFold[folded] = struct{}{}
models[model] = struct{}{}
}
hosts := make(map[string]struct{}, len(meta.AllowedHosts))
diff --git a/pkg/jsplugin/routing.go b/pkg/jsplugin/routing.go
index 3faac1b1fe9a..03845e7e1be4 100644
--- a/pkg/jsplugin/routing.go
+++ b/pkg/jsplugin/routing.go
@@ -219,12 +219,13 @@ type PinnedRoute struct {
// distribution may rebind it to another candidate from the same generation
// when multiple legacy providers expose the same model.
type PinnedEndpoint struct {
- Generation *RoutingGeneration
- Plugin *LoadedPlugin
- Protocol string
- Operation HostProtocolOperation
- Model string
- Candidates []ProtocolBinding
+ Generation *RoutingGeneration
+ Plugin *LoadedPlugin
+ Protocol string
+ Operation HostProtocolOperation
+ Model string
+ MappedModel string
+ Candidates []ProtocolBinding
}
// RouteRequestContext is the canonical request view exposed to declarative
@@ -296,7 +297,11 @@ type ProtocolRequestContext struct {
Protocol string `json:"protocol"`
Operation string `json:"operation"`
Model string `json:"model"`
- Stream bool `json:"stream"`
+ // UpstreamModel is the declared machine identity when Model is a
+ // channel-mapping alias; empty otherwise. Decode hooks that key rate
+ // tables or request shaping by model must use it over Model.
+ UpstreamModel string `json:"upstreamModel,omitempty"`
+ Stream bool `json:"stream"`
}
func (p ProtocolRequestContext) JSValue() map[string]any {
@@ -304,6 +309,9 @@ func (p ProtocolRequestContext) JSValue() map[string]any {
value["protocol"] = p.Protocol
value["operation"] = p.Operation
value["model"] = p.Model
+ if p.UpstreamModel != "" {
+ value["upstreamModel"] = p.UpstreamModel
+ }
value["stream"] = p.Stream
return value
}
@@ -318,15 +326,16 @@ type RoutingGeneration struct {
Number uint64
PublishedAt time.Time
- byKey map[string]*LoadedPlugin
- byModel map[string]*LoadedPlugin
- byChannelType map[int]*LoadedPlugin
- routeIndex map[string]RouteBinding
- protocolIndex map[string][]ProtocolBinding
- plugins []*LoadedPlugin
- routes []RouteBinding
- runtime http.Handler
- retainCurrent map[string]struct{}
+ byKey map[string]*LoadedPlugin
+ byModel map[string]*LoadedPlugin
+ canonicalModelByFold map[string]string
+ byChannelType map[int]*LoadedPlugin
+ routeIndex map[string]RouteBinding
+ protocolIndex map[string][]ProtocolBinding
+ plugins []*LoadedPlugin
+ routes []RouteBinding
+ runtime http.Handler
+ retainCurrent map[string]struct{}
}
var (
@@ -409,6 +418,20 @@ func (g *RoutingGeneration) GetByModel(model string) (*LoadedPlugin, bool) {
return plugin, ok
}
+// CanonicalModel returns the declared spelling for model. An exact byModel
+// hit wins and returns the input unchanged; otherwise the ASCII-folded
+// index is consulted. Miss and nil-receiver return ("", false).
+func (g *RoutingGeneration) CanonicalModel(model string) (string, bool) {
+ if g == nil || model == "" {
+ return "", false
+ }
+ if _, ok := g.byModel[model]; ok {
+ return model, true
+ }
+ declared, ok := g.canonicalModelByFold[asciiFold(model)]
+ return declared, ok
+}
+
// LookupDeclaredRoute resolves a manifest path declaration. It does not match
// an incoming concrete URL; runtime matching is delegated to Gin.
func (g *RoutingGeneration) LookupDeclaredRoute(method, path string) (RouteBinding, bool) {
@@ -721,10 +744,11 @@ func validateModelScope(models []string, subject string) error {
if strings.TrimSpace(model) == "" || strings.TrimSpace(model) != model {
return fmt.Errorf("plugin %s models must contain non-empty canonical names", subject)
}
- if _, duplicate := seen[model]; duplicate {
- return fmt.Errorf("plugin %s models must be unique", subject)
+ folded := asciiFold(model)
+ if _, duplicate := seen[folded]; duplicate {
+ return fmt.Errorf("plugin %s models must be unique case-insensitively", subject)
}
- seen[model] = struct{}{}
+ seen[folded] = struct{}{}
}
return nil
}
@@ -836,14 +860,15 @@ func buildRoutingGenerationFromPlugins(effective map[string]*LoadedPlugin, numbe
sort.Strings(keys)
generation := &RoutingGeneration{
- Number: number,
- PublishedAt: time.Now(),
- byKey: make(map[string]*LoadedPlugin, len(effective)),
- byModel: make(map[string]*LoadedPlugin),
- byChannelType: make(map[int]*LoadedPlugin),
- routeIndex: make(map[string]RouteBinding),
- protocolIndex: make(map[string][]ProtocolBinding),
- plugins: make([]*LoadedPlugin, 0, len(effective)),
+ Number: number,
+ PublishedAt: time.Now(),
+ byKey: make(map[string]*LoadedPlugin, len(effective)),
+ byModel: make(map[string]*LoadedPlugin),
+ canonicalModelByFold: make(map[string]string),
+ byChannelType: make(map[int]*LoadedPlugin),
+ routeIndex: make(map[string]RouteBinding),
+ protocolIndex: make(map[string][]ProtocolBinding),
+ plugins: make([]*LoadedPlugin, 0, len(effective)),
}
for _, key := range keys {
plugin := effective[key]
@@ -853,6 +878,18 @@ func buildRoutingGenerationFromPlugins(effective map[string]*LoadedPlugin, numbe
if _, exists := generation.byModel[model]; !exists {
generation.byModel[model] = plugin
}
+ folded := asciiFold(model)
+ if existing, exists := generation.canonicalModelByFold[folded]; exists {
+ if existing != model {
+ otherKey := plugin.Meta.Key
+ if other, ok := generation.byModel[existing]; ok {
+ otherKey = other.Meta.Key
+ }
+ return nil, fmt.Errorf("plugin %s model %q conflicts with plugin %s model %q", plugin.Meta.Key, model, otherKey, existing)
+ }
+ continue
+ }
+ generation.canonicalModelByFold[folded] = model
}
for _, channelType := range plugin.Meta.ChannelTypes {
diff --git a/plugins/tasks/doubao/plugin.js b/plugins/tasks/doubao/plugin.js
index 07afceaaf45f..e6dd7e9ed7a3 100644
--- a/plugins/tasks/doubao/plugin.js
+++ b/plugins/tasks/doubao/plugin.js
@@ -279,7 +279,7 @@ export function extractUsage(ctx) {
const req = ctx.requestBody || {};
const metadata = req.metadata || {};
if (ctx.usagePurpose === "billing_ratios") {
- const ratio = videoInputRatio(ctx.model, metadata.resolution, metadata.content);
+ const ratio = videoInputRatio(ctx.upstreamModel || ctx.model, metadata.resolution, metadata.content);
return ratio === 1 ? null : { video_input_ratio: ratio };
}
let seconds = Number(req.seconds || req.duration || metadata.duration || 0);
diff --git a/plugins/tasks/hailuo/plugin.js b/plugins/tasks/hailuo/plugin.js
index e54b72438cc5..b26397609e84 100644
--- a/plugins/tasks/hailuo/plugin.js
+++ b/plugins/tasks/hailuo/plugin.js
@@ -388,7 +388,8 @@ protocols.openai_video = {
}
const hasImage = hasHailuoImage(req, hasInputReferenceFile);
const duration = req.duration === undefined ? undefined : Number(req.duration);
- validateHailuoCombo(ctx.model, duration, outboundResolution(req, ctx.model), hasImage);
+ const comboModel = ctx.upstreamModel || ctx.model;
+ validateHailuoCombo(comboModel, duration, outboundResolution(req, comboModel), hasImage);
return {
kind: "submit",
model: ctx.model,
diff --git a/plugins/tasks/jimeng/plugin.js b/plugins/tasks/jimeng/plugin.js
index 5aaa9ff8741a..f7714b16704c 100644
--- a/plugins/tasks/jimeng/plugin.js
+++ b/plugins/tasks/jimeng/plugin.js
@@ -533,7 +533,10 @@ protocols.openai_video = {
}
const seconds = req.seconds === undefined ? req.duration : req.seconds;
if (seconds !== undefined) {
- req.duration = validateSecondsForReqKey(convertedReqKey(String(ctx.model || req.model || ""), decodeImageCount(req, hasInputReferenceFile)), seconds);
+ req.duration = validateSecondsForReqKey(
+ convertedReqKey(String(ctx.upstreamModel || ctx.model || req.model || ""), decodeImageCount(req, hasInputReferenceFile)),
+ seconds
+ );
}
return {
kind: "submit",
diff --git a/plugins/tasks/kling/plugin.js b/plugins/tasks/kling/plugin.js
index 74a9c11d8e3b..c61ca925d22a 100644
--- a/plugins/tasks/kling/plugin.js
+++ b/plugins/tasks/kling/plugin.js
@@ -357,7 +357,7 @@ export const protocols = {
if (!prompt && images.length === 0) throw new Error("input is required");
const metadata = Object.assign({}, req.metadata || {});
if (Object.prototype.hasOwnProperty.call(req, "mode")) metadata.mode = req.mode;
- metadata.mode = resolveKlingMode(model, metadata.mode);
+ metadata.mode = resolveKlingMode(ctx.upstreamModel || model, metadata.mode);
if (images.length > 1 && !metadata.image_tail) metadata.image_tail = images[1];
const requestBody = { model: model, prompt: prompt, metadata: metadata };
if (images.length) requestBody.image = images[0];
@@ -443,7 +443,7 @@ export const protocols = {
const image = trimmed(req.input_reference || req.image);
if (image) req.image = image;
}
- const model = ctx.model || req.model || "kling-v1";
+ const model = ctx.upstreamModel || ctx.model || req.model || "kling-v1";
const metadata = req.metadata || {};
req.mode = resolveKlingMode(model, req.mode || metadata.mode);
const hasImage = hasKlingImage(req, hasInputReferenceFile);
diff --git a/plugins/tasks/sunoapi/plugin.js b/plugins/tasks/sunoapi/plugin.js
index 240009d5e712..3749b2c08ff8 100644
--- a/plugins/tasks/sunoapi/plugin.js
+++ b/plugins/tasks/sunoapi/plugin.js
@@ -128,7 +128,7 @@ export function parseSubmitResponse(ctx, resp) {
export function extractUsage(ctx) {
if (ctx.usagePurpose === "billing_ratios") return null;
- const model = trimmed(ctx.model || (ctx.requestBody || {}).model).toLowerCase();
+ const model = trimmed(ctx.upstreamModel || ctx.model || (ctx.requestBody || {}).model).toLowerCase();
const action = actionName(ctx).toLowerCase() || (model === "suno_lyrics" ? "lyrics" : "music");
return { clips: action === "lyrics" ? 1 : 2, action: action };
}
diff --git a/plugins/tasks/vidu/plugin.js b/plugins/tasks/vidu/plugin.js
index 99fffbdf1839..3b529f66a52e 100644
--- a/plugins/tasks/vidu/plugin.js
+++ b/plugins/tasks/vidu/plugin.js
@@ -417,7 +417,7 @@ protocols.openai_video = {
if (req.seconds !== undefined) req.seconds = Number(req.seconds);
else if (req.duration !== undefined) req.seconds = Number(req.duration);
}
- const model = ctx.model || req.model;
+ const model = ctx.upstreamModel || ctx.model || req.model;
const seconds = req.seconds === undefined ? req.duration : req.seconds;
if (seconds !== undefined) req.duration = Number(seconds);
else req.duration = defaultDuration(model);
diff --git a/relay/channel/task/jsplugin/adaptor_test.go b/relay/channel/task/jsplugin/adaptor_test.go
index f7a48df304cc..136844f640c2 100644
--- a/relay/channel/task/jsplugin/adaptor_test.go
+++ b/relay/channel/task/jsplugin/adaptor_test.go
@@ -19,6 +19,7 @@ import (
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relay/channel"
relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
@@ -1154,3 +1155,56 @@ func TestTaskAdaptorBatchBridge(t *testing.T) {
assert.Equal(t, "40%", pending.TaskInfo.Progress)
assert.Empty(t, pending.TaskInfo.Url)
}
+
+const mappingOrderAdaptorPlugin = `
+export const meta = {apiVersion:1,key:"map-order-adaptor",name:"Map Order Adaptor",version:"1.0.0",author:{name:"Test"},models:["declared-model"],fetchMode:"per_task"};
+export function buildSubmitRequest(ctx) {
+ return {url: ctx.baseUrl+"/submit", method:"POST", body:{upstreamModel: ctx.upstreamModel, model: ctx.model}};
+}
+export function parseSubmitResponse(){return {taskId:"1"};}
+export function buildQueryRequest(){return {url:"https://provider.example"};}
+export function parseTaskResult(){return {status:"SUCCESS"};}
+`
+
+func mappingOrderSubmitBody(t *testing.T, origin, mapping string) []byte {
+ t.Helper()
+ plugin, err := pluginruntime.NewRegistry().Register(mappingOrderAdaptorPlugin, pluginruntime.Options{})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+ info := &relaycommon.RelayInfo{
+ ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example"},
+ TaskRelayInfo: &relaycommon.TaskRelayInfo{},
+ OriginModelName: origin,
+ }
+ adaptor.Init(info)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
+ if mapping != "" {
+ c.Set("model_mapping", mapping)
+ }
+ c.Set("task_request", map[string]any{"prompt": "p"})
+ info.UpstreamModelName = info.OriginModelName
+ require.NoError(t, helper.ModelMappedHelper(c, info, nil))
+ require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
+ body, err := adaptor.BuildRequestBody(c, info)
+ require.NoError(t, err)
+ raw, err := io.ReadAll(body)
+ require.NoError(t, err)
+ return raw
+}
+
+func TestTaskAdaptorBuildSubmitReceivesMappedUpstreamModel(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ mapped := mappingOrderSubmitBody(t, "alias-model", `{"alias-model":"mid-model","mid-model":"declared-model"}`)
+ var decoded map[string]any
+ require.NoError(t, common.Unmarshal(mapped, &decoded))
+ assert.Equal(t, "declared-model", decoded["upstreamModel"])
+ assert.Equal(t, "alias-model", decoded["model"])
+
+ withoutMapping := mappingOrderSubmitBody(t, "declared-model", "")
+ emptyMapping := mappingOrderSubmitBody(t, "declared-model", "{}")
+ assert.Equal(t, withoutMapping, emptyMapping)
+ require.NoError(t, common.Unmarshal(withoutMapping, &decoded))
+ assert.Equal(t, "declared-model", decoded["upstreamModel"])
+ assert.Equal(t, "declared-model", decoded["model"])
+}
diff --git a/relay/relay_task.go b/relay/relay_task.go
index 63270b5defc4..ed3957060623 100644
--- a/relay/relay_task.go
+++ b/relay/relay_task.go
@@ -212,6 +212,19 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe
info.PublicTaskID = model.GenerateTaskID()
}
adaptor.Init(info)
+ // Plugin submit hooks run during ValidateRequestAndSetAction and cache the
+ // upstream body. OriginModelName is already seeded on that line (protocol
+ // resolved_task_model, legacy submit, or GenRelayInfo original_model), so
+ // map before validation. The empty-name CoverTaskActionToModelName
+ // synthesis happens after validate and cannot move; skip the late block
+ // when early mapping ran so a chain is never applied twice.
+ mappedBeforeValidate := info.OriginModelName != ""
+ if mappedBeforeValidate {
+ info.UpstreamModelName = info.OriginModelName
+ if err := helper.ModelMappedHelper(c, info, nil); err != nil {
+ return nil, service.TaskErrorWrapperLocal(err, "model_mapping_failed", http.StatusBadRequest)
+ }
+ }
if taskErr := adaptor.ValidateRequestAndSetAction(c, info); taskErr != nil {
return nil, taskErr
}
@@ -222,19 +235,33 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe
modelName = service.CoverTaskActionToModelName(platform, info.Action)
}
- // 2.5 应用渠道的模型映射(与同步任务对齐)
- info.OriginModelName = modelName
- info.UpstreamModelName = modelName
- if err := helper.ModelMappedHelper(c, info, nil); err != nil {
- return nil, service.TaskErrorWrapperLocal(err, "model_mapping_failed", http.StatusBadRequest)
+ if !mappedBeforeValidate {
+ info.OriginModelName = modelName
+ info.UpstreamModelName = modelName
+ if err := helper.ModelMappedHelper(c, info, nil); err != nil {
+ return nil, service.TaskErrorWrapperLocal(err, "model_mapping_failed", http.StatusBadRequest)
+ }
}
// 4. 价格计算:基础模型价格
info.OriginModelName = modelName
var priceData types.PriceData
var err error
- if billing_setting.GetBillingMode(modelName) == billing_setting.BillingModeTieredExpr {
- exprStr, exists := billing_setting.GetBillingExpr(modelName)
+ useTiered := billing_setting.GetBillingMode(modelName) == billing_setting.BillingModeTieredExpr
+ var exprStr string
+ var exists bool
+ if useTiered {
+ exprStr, exists = billing_setting.GetBillingExpr(modelName)
+ } else if info.IsModelMapped {
+ if billing_setting.GetBillingMode(info.UpstreamModelName) == billing_setting.BillingModeTieredExpr {
+ if tailExpr, tailOK := billing_setting.GetBillingExpr(info.UpstreamModelName); tailOK && strings.TrimSpace(tailExpr) != "" {
+ exprStr = tailExpr
+ exists = true
+ useTiered = true
+ }
+ }
+ }
+ if useTiered {
provider, supported := adaptor.(channel.TaskUsageFactsProvider)
if !exists || !supported {
return nil, service.TaskErrorWrapper(fmt.Errorf("task model %s has no usage expression or meter", modelName), "model_price_error", http.StatusBadRequest)
diff --git a/relay/relay_task_test.go b/relay/relay_task_test.go
index 39e0fd19459e..8bd046d7a488 100644
--- a/relay/relay_task_test.go
+++ b/relay/relay_task_test.go
@@ -1,11 +1,22 @@
package relay
import (
+ "net/http"
+ "net/http/httptest"
"testing"
+ "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/pkg/billingexpr"
+ pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/setting/billing_setting"
+ "github.com/QuantumNous/new-api/setting/config"
+ "github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestTaskModel2DtoNormalizesLegacyAction(t *testing.T) {
@@ -16,3 +27,206 @@ func TestTaskModel2DtoNormalizesLegacyAction(t *testing.T) {
assert.Equal(t, constant.TaskActionFirstTailToVideo, dtoTask.Action)
assert.Equal(t, "firstTailGenerate", task.Action)
}
+
+const mappingOrderSubmitPlugin = `
+export const meta = {apiVersion:1,key:"maporder",name:"Map Order",version:"1.0.0",author:{name:"Test"},models:["declared-model"],fetchMode:"per_task"};
+export function buildSubmitRequest(ctx) {
+ return {url: ctx.baseUrl+"/submit", method:"POST", body:{upstreamModel: ctx.upstreamModel, model: ctx.model}, action:"text_to_video"};
+}
+export function parseSubmitResponse(){return {taskId:"1"};}
+export function buildQueryRequest(){return {url:"https://provider.example"};}
+export function parseTaskResult(){return {status:"SUCCESS"};}
+`
+
+const mappingOrderRewritePlugin = `
+export const meta = {apiVersion:1,key:"maporder-rw",name:"Map Order RW",version:"1.0.0",author:{name:"Test"},models:["declared-model"],fetchMode:"per_task"};
+export function buildSubmitRequest(ctx) {
+ return {url: ctx.baseUrl+"/submit", method:"POST", body:{upstreamModel: ctx.upstreamModel}, rewriteModel:"rewritten"};
+}
+export function parseSubmitResponse(){return {taskId:"1"};}
+export function buildQueryRequest(){return {url:"https://provider.example"};}
+export function parseTaskResult(){return {status:"SUCCESS"};}
+`
+
+func pinMappingOrderPlugin(t *testing.T, c *gin.Context, source string) {
+ t.Helper()
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+ c.Set(pluginruntime.ContextKeyPinnedPlugin, pluginruntime.PinnedPlugin{Plugin: plugin})
+}
+
+func newTaskSubmitContext(t *testing.T, originalModel, mapping string) (*gin.Context, *relaycommon.RelayInfo) {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
+ common.SetContextKey(c, constant.ContextKeyOriginalModel, originalModel)
+ common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, "https://provider.example")
+ if mapping != "" {
+ c.Set("model_mapping", mapping)
+ }
+ c.Set("task_request", map[string]any{"prompt": "p"})
+ return c, &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
+}
+
+func TestRelayTaskSubmitMapsBeforeValidateWhenOriginSet(t *testing.T) {
+ const mapping = `{"alias-model":"mid-model","mid-model":"declared-model"}`
+
+ c, info := newTaskSubmitContext(t, "alias-model", mapping)
+ pinMappingOrderPlugin(t, c, mappingOrderSubmitPlugin)
+ info.OriginModelName = "alias-model"
+
+ _, taskErr := RelayTaskSubmit(c, info)
+ require.NotNil(t, taskErr)
+ assert.Equal(t, "model_price_error", taskErr.Code)
+ assert.Equal(t, "alias-model", info.OriginModelName)
+ assert.Equal(t, "declared-model", info.UpstreamModelName)
+ assert.True(t, info.IsModelMapped)
+}
+
+func TestRelayTaskSubmitDeclaredNameWithoutMappingIsUnchanged(t *testing.T) {
+ c, info := newTaskSubmitContext(t, "declared-model", "")
+ pinMappingOrderPlugin(t, c, mappingOrderSubmitPlugin)
+ info.OriginModelName = "declared-model"
+
+ _, taskErr := RelayTaskSubmit(c, info)
+ require.NotNil(t, taskErr)
+ assert.Equal(t, "model_price_error", taskErr.Code)
+ assert.Equal(t, "declared-model", info.OriginModelName)
+ assert.Equal(t, "declared-model", info.UpstreamModelName)
+ assert.False(t, info.IsModelMapped)
+}
+
+func TestRelayTaskSubmitDoesNotApplyMappingTwice(t *testing.T) {
+ c, info := newTaskSubmitContext(t, "alias-model", `{"alias-model":"declared-model"}`)
+ pinMappingOrderPlugin(t, c, mappingOrderRewritePlugin)
+ info.OriginModelName = "alias-model"
+
+ _, taskErr := RelayTaskSubmit(c, info)
+ require.NotNil(t, taskErr)
+ assert.Equal(t, "model_price_error", taskErr.Code)
+ assert.Equal(t, "rewritten", info.UpstreamModelName, "late mapping would overwrite rewriteModel with the chain tail")
+ assert.Equal(t, "alias-model", info.OriginModelName)
+}
+
+func TestRelayTaskSubmitEmptyOriginKeepsLateMapping(t *testing.T) {
+ plugin, err := pluginruntime.NewRegistry().Register(mappingOrderSubmitPlugin, pluginruntime.Options{})
+ require.NoError(t, err)
+ synthesized := service.CoverTaskActionToModelName(constant.TaskPlatform(plugin.Meta.Key), "text_to_video")
+ c, info := newTaskSubmitContext(t, "pre-validate-upstream",
+ `{"pre-validate-upstream":"should-not-apply-early","`+synthesized+`":"legacy-tail"}`)
+ c.Set(pluginruntime.ContextKeyPinnedPlugin, pluginruntime.PinnedPlugin{Plugin: plugin})
+ info.OriginModelName = ""
+
+ _, taskErr := RelayTaskSubmit(c, info)
+ require.NotNil(t, taskErr)
+ assert.Equal(t, "model_price_error", taskErr.Code)
+ assert.Equal(t, synthesized, info.OriginModelName)
+ assert.Equal(t, "legacy-tail", info.UpstreamModelName)
+ assert.True(t, info.IsModelMapped)
+}
+
+const billingFallbackPlugin = `
+export const meta = {apiVersion:1,key:"bill-fallback",name:"Bill Fallback",version:"1.0.0",author:{name:"Test"},models:["declared-model"],fetchMode:"per_task"};
+export function buildSubmitRequest(ctx) {
+ return {url: ctx.baseUrl+"/submit", method:"POST", body:{upstreamModel: ctx.upstreamModel, model: ctx.model}, action:"text_to_video"};
+}
+export function parseSubmitResponse(){return {taskId:"1"};}
+export function buildQueryRequest(){return {url:"https://provider.example"};}
+export function parseTaskResult(){return {status:"SUCCESS"};}
+`
+
+func saveBillingConfig(t *testing.T) {
+ t.Helper()
+ saved := map[string]string{}
+ require.NoError(t, config.GlobalConfig.SaveToDB(func(key, value string) error {
+ saved[key] = value
+ return nil
+ }))
+ t.Cleanup(func() {
+ require.NoError(t, config.GlobalConfig.LoadFromDB(saved))
+ })
+}
+
+func TestRelayTaskSubmitAliasBillingIdentityAndExprFallback(t *testing.T) {
+ const mapping = `{"alias-model":"declared-model"}`
+ const aliasExpr = `tier("alias", 2)`
+ const tailExpr = `tier("tail", 3)`
+
+ tests := []struct {
+ name string
+ modes map[string]string
+ exprs map[string]string
+ wantTiered bool
+ wantExpr string
+ }{
+ {
+ name: "alias own tiered wins",
+ modes: map[string]string{"alias-model": "tiered_expr", "declared-model": "tiered_expr"},
+ exprs: map[string]string{"alias-model": aliasExpr, "declared-model": tailExpr},
+ wantTiered: true,
+ wantExpr: aliasExpr,
+ },
+ {
+ name: "fallback uses tail expr",
+ modes: map[string]string{"declared-model": "tiered_expr"},
+ exprs: map[string]string{"declared-model": tailExpr},
+ wantTiered: true,
+ wantExpr: tailExpr,
+ },
+ {
+ name: "neither tiered uses ordinary pricing",
+ wantTiered: false,
+ },
+ }
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ saveBillingConfig(t)
+ if len(testCase.modes) > 0 {
+ modeJSON, marshalErr := common.Marshal(testCase.modes)
+ require.NoError(t, marshalErr)
+ exprJSON, marshalErr := common.Marshal(testCase.exprs)
+ require.NoError(t, marshalErr)
+ require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
+ "billing_setting.billing_mode": string(modeJSON),
+ "billing_setting.billing_expr": string(exprJSON),
+ }))
+ if testCase.wantExpr == aliasExpr {
+ require.Equal(t, billing_setting.BillingModeTieredExpr, billing_setting.GetBillingMode("alias-model"))
+ } else {
+ require.Equal(t, billing_setting.BillingModeRatio, billing_setting.GetBillingMode("alias-model"))
+ require.Equal(t, billing_setting.BillingModeTieredExpr, billing_setting.GetBillingMode("declared-model"))
+ }
+ }
+
+ c, info := newTaskSubmitContext(t, "alias-model", mapping)
+ c.Set("group", "default")
+ info.UserGroup = "default"
+ info.UsingGroup = "default"
+ pinMappingOrderPlugin(t, c, billingFallbackPlugin)
+ info.OriginModelName = "alias-model"
+
+ _, taskErr := RelayTaskSubmit(c, info)
+ require.NotNil(t, taskErr)
+ assert.Equal(t, "alias-model", info.OriginModelName)
+ assert.Equal(t, "declared-model", info.UpstreamModelName)
+ assert.True(t, info.IsModelMapped)
+
+ task := model.InitTask(constant.TaskPlatform("bill-fallback"), info)
+ assert.Equal(t, "alias-model", task.Properties.OriginModelName)
+ assert.Equal(t, "declared-model", task.Properties.UpstreamModelName)
+
+ if testCase.wantTiered {
+ require.NotNil(t, info.TieredBillingSnapshot)
+ assert.Equal(t, "alias-model", info.TieredBillingSnapshot.ModelName)
+ assert.Equal(t, testCase.wantExpr, info.TieredBillingSnapshot.ExprString)
+ assert.Equal(t, billingexpr.ExprHashString(testCase.wantExpr), info.TieredBillingSnapshot.ExprHash)
+ assert.NotEqual(t, "model_price_error", taskErr.Code)
+ } else {
+ assert.Nil(t, info.TieredBillingSnapshot)
+ assert.Equal(t, "model_price_error", taskErr.Code)
+ }
+ })
+ }
+}
From 66031a09d99f2ac4e0b94e2c41f04ed691a79304 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Sun, 30 Aug 2026 20:06:32 +0800
Subject: [PATCH 68/99] fix(model): disable PostgreSQL prepared statements for
pooler compatibility
GORM v1.25.2 closes cached prepared statements asynchronously on any SQL
error and immediately re-Parses the same deterministic name (pgx's
stmt_) on the same client connection. Transaction-pooling proxies
(PgBouncer >=1.21 with max_prepared_statements, Neon, Supabase) respond
with FATAL "prepared statement name is already in use" (SQLSTATE 08P01)
and drop the connection. PreferSimpleProtocol only disables pgx's
implicit prepare and never covered GORM's explicit PrepareStmt cache.
- PostgreSQL now runs with PrepareStmt disabled entirely; named prepared
statements are fundamentally session state and cannot be made safe
under transaction pooling. Parse/plan cost is noise for this workload.
- Upgrade gorm to v1.25.12 so MySQL/SQLite statement caches (still
enabled) no longer churn close/re-prepare on ordinary SQL errors;
v1.25.9+ restricts eviction to driver.ErrBadConn. Deliberately not
v1.26+, whose LRU eviction has an open use-after-close race (#7831).
- sanitizeDBError now attaches a remediation hint on 08P01/42P05 so
affected deployments can self-diagnose from the log line.
---
go.mod | 2 +-
go.sum | 2 ++
model/gorm_logger.go | 5 +++++
model/gorm_logger_test.go | 6 ++++++
model/main.go | 6 ++++--
5 files changed, 18 insertions(+), 3 deletions(-)
diff --git a/go.mod b/go.mod
index 0f181866cef3..420395003dbc 100644
--- a/go.mod
+++ b/go.mod
@@ -58,7 +58,7 @@ require (
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.4.3
gorm.io/driver/postgres v1.5.2
- gorm.io/gorm v1.25.2
+ gorm.io/gorm v1.25.12
)
require (
diff --git a/go.sum b/go.sum
index 8265831e0ce3..d476af2e5169 100644
--- a/go.sum
+++ b/go.sum
@@ -3030,6 +3030,8 @@ gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.24.6/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.2 h1:gs1o6Vsa+oVKG/a9ElL3XgyGfghFfkKA2SInQaCyMho=
gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
+gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
+gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
diff --git a/model/gorm_logger.go b/model/gorm_logger.go
index 8143073023da..e48f057b73ee 100644
--- a/model/gorm_logger.go
+++ b/model/gorm_logger.go
@@ -72,6 +72,11 @@ func sanitizeDBError(err error) error {
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
+ // 08P01 是 PgBouncer 对同连接重名 Parse 的 FATAL,42P05 是原生 PostgreSQL 的
+ // duplicate_prepared_statement;都指向预处理语句与事务池代理不兼容。
+ if pgErr.Code == "08P01" || pgErr.Code == "42P05" {
+ return fmt.Errorf("postgres error SQLSTATE %s: prepared statement conflict with a transaction-pooling proxy (PgBouncer/Neon/Supabase); other clients sharing this database must disable prepared statements, or upgrade PgBouncer to >=1.21 with max_prepared_statements enabled", pgErr.Code)
+ }
return fmt.Errorf("postgres error SQLSTATE %s", pgErr.Code)
}
var chErr *proto.Exception
diff --git a/model/gorm_logger_test.go b/model/gorm_logger_test.go
index 6095e8beb3f9..7531b9ce319e 100644
--- a/model/gorm_logger_test.go
+++ b/model/gorm_logger_test.go
@@ -35,6 +35,12 @@ func TestSanitizeDBErrorStripsDriverMessage(t *testing.T) {
want: "postgres error SQLSTATE 23505",
leaked: "secret-value",
},
+ {
+ name: "postgres pooler prepared statement conflict gets remediation hint",
+ err: &pgconn.PgError{Severity: "FATAL", Code: "08P01", Message: "prepared statement name is already in use: stmt_secret-value"},
+ want: "postgres error SQLSTATE 08P01: prepared statement conflict with a transaction-pooling proxy (PgBouncer/Neon/Supabase); other clients sharing this database must disable prepared statements, or upgrade PgBouncer to >=1.21 with max_prepared_statements enabled",
+ leaked: "secret-value",
+ },
{
name: "clickhouse exception",
err: &proto.Exception{Code: 241, Message: "Memory limit exceeded while processing 'secret-value'"},
diff --git a/model/main.go b/model/main.go
index 877b2019d9d8..3c10c3d36b28 100644
--- a/model/main.go
+++ b/model/main.go
@@ -138,10 +138,12 @@ func chooseDB(envName string, isLog bool) (*gorm.DB, common.DatabaseType, error)
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
// Use PostgreSQL
common.SysLog("using PostgreSQL as database")
+ // 同时关闭 pgx 隐式与 GORM 显式预处理语句:命名 prepared statement 与
+ // 事务池代理(PgBouncer/Neon/Supabase)不兼容,会触发 FATAL 08P01/42P05。
db, err := gorm.Open(postgres.New(postgres.Config{
DSN: dsn,
- PreferSimpleProtocol: true, // disables implicit prepared statement usage
- }), newGormConfig(true))
+ PreferSimpleProtocol: true,
+ }), newGormConfig(false))
return db, common.DatabaseTypePostgreSQL, err
}
if strings.HasPrefix(dsn, "local") {
From 0bee5d4410296e972bf0076414ade786c2c799c8 Mon Sep 17 00:00:00 2001
From: PuppetKL <154485567+PuppetKL@users.noreply.github.com>
Date: Sun, 30 Aug 2026 20:33:15 +0800
Subject: [PATCH 69/99] fix(ali): honor image response format (#5513) (#7048)
---
relay/channel/ali/adaptor_test.go | 87 +++++++++++++++++++++++++++++++
relay/channel/ali/image.go | 5 +-
2 files changed, 91 insertions(+), 1 deletion(-)
diff --git a/relay/channel/ali/adaptor_test.go b/relay/channel/ali/adaptor_test.go
index 08bc959acded..ec44c5968aab 100644
--- a/relay/channel/ali/adaptor_test.go
+++ b/relay/channel/ali/adaptor_test.go
@@ -1,16 +1,25 @@
package ali
import (
+ "encoding/base64"
"encoding/json"
+ "fmt"
+ "io"
"net/http"
"net/http/httptest"
+ "strings"
+ "sync/atomic"
"testing"
+ "time"
"github.com/QuantumNous/new-api/common"
+ rootconstant "github.com/QuantumNous/new-api/constant"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/constant"
relayhelper "github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -159,3 +168,81 @@ func TestMappedAliImageModelUsesUpstreamProtocol(t *testing.T) {
assert.True(t, adaptor.IsSyncImageModel)
assert.IsType(t, &AliImageRequest{}, converted)
}
+
+func TestAliImageHandlerHonorsRequestResponseFormat(t *testing.T) {
+ imageBytes := []byte("ali-image")
+ var downloads atomic.Int32
+ imageServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ downloads.Add(1)
+ w.Header().Set("Content-Type", "image/png")
+ _, _ = w.Write(imageBytes)
+ }))
+ t.Cleanup(imageServer.Close)
+
+ fetchSetting := system_setting.GetFetchSetting()
+ require.NotNil(t, fetchSetting)
+ originalFetchSetting := *fetchSetting
+ fetchSetting.EnableSSRFProtection = false
+ t.Cleanup(func() {
+ *fetchSetting = originalFetchSetting
+ })
+ originalMaxFileDownloadMB := rootconstant.MaxFileDownloadMB
+ rootconstant.MaxFileDownloadMB = 1
+ t.Cleanup(func() {
+ rootconstant.MaxFileDownloadMB = originalMaxFileDownloadMB
+ })
+ service.InitHttpClient()
+
+ tests := []struct {
+ name string
+ responseFormat string
+ wantBase64 string
+ wantDownloads int32
+ }{
+ {
+ name: "base64",
+ responseFormat: "b64_json",
+ wantBase64: base64.StdEncoding.EncodeToString(imageBytes),
+ wantDownloads: 1,
+ },
+ {
+ name: "url",
+ responseFormat: "url",
+ },
+ {
+ name: "default",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ downloads.Store(0)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ info := &relaycommon.RelayInfo{
+ RelayMode: constant.RelayModeImagesGenerations,
+ StartTime: time.Unix(1, 0),
+ Request: &dto.ImageRequest{
+ ResponseFormat: tt.responseFormat,
+ },
+ }
+ responseBody := fmt.Sprintf(`{"output":{"results":[{"url":%q}]}}`, imageServer.URL)
+ resp := &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{},
+ Body: io.NopCloser(strings.NewReader(responseBody)),
+ }
+
+ newAPIError, usage := aliImageHandler(&Adaptor{IsSyncImageModel: true}, c, resp, info)
+ require.Nil(t, newAPIError)
+ require.NotNil(t, usage)
+
+ var imageResponse dto.ImageResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &imageResponse))
+ require.Len(t, imageResponse.Data, 1)
+ assert.Equal(t, imageServer.URL, imageResponse.Data[0].Url)
+ assert.Equal(t, tt.wantBase64, imageResponse.Data[0].B64Json)
+ assert.Equal(t, tt.wantDownloads, downloads.Load())
+ })
+ }
+}
diff --git a/relay/channel/ali/image.go b/relay/channel/ali/image.go
index 6913fa346aa6..a2828ac47be0 100644
--- a/relay/channel/ali/image.go
+++ b/relay/channel/ali/image.go
@@ -284,7 +284,10 @@ func responseAli2OpenAIImage(c *gin.Context, response *AliResponse, originBody [
}
func aliImageHandler(a *Adaptor, c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) {
- responseFormat := c.GetString("response_format")
+ responseFormat := ""
+ if imageReq, ok := info.Request.(*dto.ImageRequest); ok {
+ responseFormat = imageReq.ResponseFormat
+ }
var aliTaskResponse AliResponse
responseBody, err := io.ReadAll(resp.Body)
From dc4732cfed712b004d3d3d94a414d3ff127a93cc Mon Sep 17 00:00:00 2001
From: CaIon
Date: Sun, 30 Aug 2026 20:35:38 +0800
Subject: [PATCH 70/99] feat(web): factory task plugins update only with the
system
Marketplace install/upgrade on a factory-served plugin actually created
a permanent override shadowing every future built-in release. The card
now shows an informational "Updates with the system" badge instead of
the action, while keeping the built-in vs marketplace version line and
the upgradable state badge visible. Deliberate overrides are untouched:
upload and marketplace actions on overridden or third-party plugins
behave as before, and the plugins table now hints when an override
lags behind the shipped built-in version so operators know deleting it
restores the newer factory plugin.
---
.../__tests__/marketplace.test.ts | 118 +++++++++++++++++-
.../components/marketplace-plugin-card.tsx | 39 +++---
.../task-plugins/components/plugins-table.tsx | 25 +++-
.../features/task-plugins/lib/marketplace.ts | 40 ++++++
web/src/i18n/locales/en.json | 2 +
web/src/i18n/locales/fr.json | 2 +
web/src/i18n/locales/ja.json | 2 +
web/src/i18n/locales/ru.json | 2 +
web/src/i18n/locales/vi.json | 2 +
web/src/i18n/locales/zh-TW.json | 2 +
web/src/i18n/locales/zh.json | 2 +
11 files changed, 216 insertions(+), 20 deletions(-)
diff --git a/web/src/features/task-plugins/__tests__/marketplace.test.ts b/web/src/features/task-plugins/__tests__/marketplace.test.ts
index 852a228dfa05..bfc0a4c6d5b3 100644
--- a/web/src/features/task-plugins/__tests__/marketplace.test.ts
+++ b/web/src/features/task-plugins/__tests__/marketplace.test.ts
@@ -26,7 +26,10 @@ import {
GITHUB_MARKETPLACE_INDEX_URL,
indexHasIntegrityHashes,
isDefaultMarketplaceSource,
+ isStaleFactoryOverride,
+ marketplaceBuiltInVersion,
parseMarketplaceIndex,
+ resolveMarketplaceActionPolicy,
resolvePluginSourceUrl,
} from '../lib/marketplace'
import type {
@@ -52,7 +55,23 @@ function marketplacePlugin(
}
}
-function installedPlugin(key: string, version: string): TaskPluginListItem {
+function factoryMeta(key: string, version: string) {
+ return {
+ apiVersion: 1,
+ key,
+ name: key,
+ version,
+ author: { name: 'test' as const },
+ models: null,
+ fetchMode: 'poll',
+ }
+}
+
+function installedPlugin(
+ key: string,
+ version: string,
+ overrides: Partial = {}
+): TaskPluginListItem {
return {
meta: {
apiVersion: 1,
@@ -71,6 +90,7 @@ function installedPlugin(key: string, version: string): TaskPluginListItem {
runtime_status: 'registered',
channel_count: 0,
in_flight_count: 0,
+ ...overrides,
}
}
@@ -399,6 +419,102 @@ describe('install state derivation', () => {
})
})
+describe('marketplace action policy', () => {
+ test('factory-served plugin returns the informational system-update state', () => {
+ assert.deepEqual(
+ resolveMarketplaceActionPolicy(
+ installedPlugin('doubao', '1.0.0', { source: 'factory' })
+ ),
+ { kind: 'system_update' }
+ )
+ })
+
+ test('overridden factory plugin still allows marketplace install', () => {
+ assert.deepEqual(
+ resolveMarketplaceActionPolicy(
+ installedPlugin('doubao', '1.2.0', {
+ source: 'override_over_factory',
+ factory_meta: factoryMeta('doubao', '1.0.0'),
+ })
+ ),
+ { kind: 'install' }
+ )
+ })
+
+ test('third-party plugin still allows marketplace install', () => {
+ assert.deepEqual(
+ resolveMarketplaceActionPolicy(installedPlugin('doubao', '1.0.0')),
+ { kind: 'install' }
+ )
+ })
+
+ test('uninstalled plugin still allows marketplace install', () => {
+ assert.deepEqual(resolveMarketplaceActionPolicy(undefined), {
+ kind: 'install',
+ })
+ })
+
+ test('factory-served built-in version is the installed meta version', () => {
+ assert.equal(
+ marketplaceBuiltInVersion(
+ installedPlugin('doubao', '1.0.0', { source: 'factory' })
+ ),
+ '1.0.0'
+ )
+ })
+
+ test('overridden factory built-in version comes from factory_meta', () => {
+ assert.equal(
+ marketplaceBuiltInVersion(
+ installedPlugin('doubao', '1.2.0', {
+ source: 'override_over_factory',
+ factory_meta: factoryMeta('doubao', '1.0.0'),
+ })
+ ),
+ '1.0.0'
+ )
+ })
+})
+
+describe('stale factory override', () => {
+ test('is stale when override version differs from built-in', () => {
+ assert.equal(
+ isStaleFactoryOverride(
+ installedPlugin('doubao', '1.2.0', {
+ source: 'override_over_factory',
+ factory_meta: factoryMeta('doubao', '1.0.0'),
+ })
+ ),
+ true
+ )
+ })
+
+ test('is not stale when override version matches built-in', () => {
+ assert.equal(
+ isStaleFactoryOverride(
+ installedPlugin('doubao', '1.0.0', {
+ source: 'override_over_factory',
+ factory_meta: factoryMeta('doubao', '1.0.0'),
+ })
+ ),
+ false
+ )
+ })
+
+ test('is not stale for factory-served or third-party plugins', () => {
+ assert.equal(
+ isStaleFactoryOverride(
+ installedPlugin('doubao', '1.0.0', { source: 'factory' })
+ ),
+ false
+ )
+ assert.equal(
+ isStaleFactoryOverride(installedPlugin('doubao', '1.0.0')),
+ false
+ )
+ })
+})
+
describe('marketplace version lookup', () => {
test('finds the entry matching a version', () => {
assert.equal(
diff --git a/web/src/features/task-plugins/components/marketplace-plugin-card.tsx b/web/src/features/task-plugins/components/marketplace-plugin-card.tsx
index 8361417ddf6b..a3967c3664c6 100644
--- a/web/src/features/task-plugins/components/marketplace-plugin-card.tsx
+++ b/web/src/features/task-plugins/components/marketplace-plugin-card.tsx
@@ -29,7 +29,12 @@ import { Button } from '@/components/ui/button'
import { getChannelTypeLabel } from '@/features/channels/lib'
import { resolveLocalizedText } from '@/lib/localized-text'
-import { findMarketplaceVersion, type InstallState } from '../lib/marketplace'
+import {
+ findMarketplaceVersion,
+ marketplaceBuiltInVersion,
+ resolveMarketplaceActionPolicy,
+ type InstallState,
+} from '../lib/marketplace'
import type { MarketplacePlugin, TaskPluginListItem } from '../types'
import { PluginIcon } from './plugin-icon'
@@ -47,6 +52,8 @@ export function MarketplacePluginCard(props: MarketplacePluginCardProps) {
const channelTypes = plugin.channelTypes ?? []
const latestEntry = findMarketplaceVersion(plugin, plugin.latest)
const labelClass = 'text-muted-foreground text-[11px] font-medium select-none'
+ const actionPolicy = resolveMarketplaceActionPolicy(props.installed)
+ const builtInVersion = marketplaceBuiltInVersion(props.installed)
return (
@@ -90,12 +97,12 @@ export function MarketplacePluginCard(props: MarketplacePluginCardProps) {
- {props.installed?.factory_meta && (
+ {builtInVersion && (
{t('Versions')} {' '}
{t('Built-in v{{factory}} / marketplace v{{market}}', {
- factory: props.installed.factory_meta.version,
+ factory: builtInVersion,
market: plugin.latest,
})}
@@ -110,17 +117,21 @@ export function MarketplacePluginCard(props: MarketplacePluginCardProps) {
)}
-
-
- {getActionLabel(props.installState, t)}
-
+ {actionPolicy.kind === 'system_update' ? (
+ {t('Updates with the system')}
+ ) : (
+
+
+ {getActionLabel(props.installState, t)}
+
+ )}
)
diff --git a/web/src/features/task-plugins/components/plugins-table.tsx b/web/src/features/task-plugins/components/plugins-table.tsx
index 5edfa5481c37..5d0c579d3ed9 100644
--- a/web/src/features/task-plugins/components/plugins-table.tsx
+++ b/web/src/features/task-plugins/components/plugins-table.tsx
@@ -43,6 +43,7 @@ import {
setTaskPluginStatus,
TaskPluginUsageError,
} from '../api'
+import { isStaleFactoryOverride } from '../lib/marketplace'
import type { TaskPluginListItem, TaskPluginUsage } from '../types'
import { PluginCard } from './plugin-card'
import { PluginIcon } from './plugin-icon'
@@ -158,12 +159,26 @@ export function PluginsTable(props: PluginsTableProps) {
return {t('Factory')}
}
if (row.original.source === 'override_over_factory') {
+ const factoryVersion = row.original.factory_meta?.version
+ const staleHint = isStaleFactoryOverride(row.original)
+ ? t(
+ 'Built-in is v{{factory}}; delete the custom version to return to it',
+ { factory: factoryVersion }
+ )
+ : undefined
return (
-
- {t('Custom (overrides factory {{version}})', {
- version: row.original.factory_meta?.version,
- })}
-
+
+
+ {t('Custom (overrides factory {{version}})', {
+ version: factoryVersion,
+ })}
+
+ {staleHint ? (
+
+ {staleHint}
+
+ ) : null}
+
)
}
return {t('Third-party')}
diff --git a/web/src/features/task-plugins/lib/marketplace.ts b/web/src/features/task-plugins/lib/marketplace.ts
index 8777a86c28cf..c250f7e54a8f 100644
--- a/web/src/features/task-plugins/lib/marketplace.ts
+++ b/web/src/features/task-plugins/lib/marketplace.ts
@@ -234,6 +234,46 @@ export function deriveInstallState(
}
}
+export type MarketplaceActionPolicy =
+ | { kind: 'install' }
+ | { kind: 'system_update' }
+
+/**
+ * Factory-served plugins are compiled into the binary and must only update
+ * with a system release. Marketplace install would create a permanent override
+ * that shadows every future built-in update — that action is suppressed.
+ * Overrides and third-party plugins still install/upgrade normally.
+ */
+export function resolveMarketplaceActionPolicy(
+ installed?: TaskPluginListItem
+): MarketplaceActionPolicy {
+ if (installed?.source === 'factory') {
+ return { kind: 'system_update' }
+ }
+ return { kind: 'install' }
+}
+
+/**
+ * Built-in version shown next to the marketplace latest. Factory-served items
+ * do not carry `factory_meta` (their `meta` *is* the factory meta); overridden
+ * factory plugins expose the shadowed built-in on `factory_meta`.
+ */
+export function marketplaceBuiltInVersion(
+ installed?: TaskPluginListItem
+): string | undefined {
+ if (!installed) return undefined
+ if (installed.source === 'factory') return installed.meta.version
+ return installed.factory_meta?.version
+}
+
+export function isStaleFactoryOverride(item: TaskPluginListItem): boolean {
+ return (
+ item.source === 'override_over_factory' &&
+ item.factory_meta != null &&
+ item.factory_meta.version !== item.meta.version
+ )
+}
+
/**
* A source is only integrity-checked when every listed version carries a
* sha256. Anything less and installs from it cannot be pinned, so the UI warns.
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index a65405c6be6e..f1a410f7e76a 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -714,6 +714,8 @@
"Built-in": "Built-in",
"Built-in Device": "Built-in Device",
"Built-in v{{factory}} / marketplace v{{market}}": "Built-in v{{factory}} / marketplace v{{market}}",
+ "Updates with the system": "Updates with the system",
+ "Built-in is v{{factory}}; delete the custom version to return to it": "Built-in is v{{factory}}; delete the custom version to return to it",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key",
"by": "by",
"By category": "By category",
diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json
index 901736b038b4..e81b95fceea0 100644
--- a/web/src/i18n/locales/fr.json
+++ b/web/src/i18n/locales/fr.json
@@ -714,6 +714,8 @@
"Built-in": "Intégré",
"Built-in Device": "Appareil intégré",
"Built-in v{{factory}} / marketplace v{{market}}": "Intégré v{{factory}} / marché v{{market}}",
+ "Updates with the system": "Mise à jour système",
+ "Built-in is v{{factory}}; delete the custom version to return to it": "La version intégrée est v{{factory}} ; supprimez la version personnalisée pour y revenir",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Intégré : empreinte digitale/visage du téléphone, ou Windows Hello ; Externe : clé de sécurité USB",
"by": "par",
"By category": "Par catégorie",
diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json
index 3cf134dae1a9..5a9a00a665ec 100644
--- a/web/src/i18n/locales/ja.json
+++ b/web/src/i18n/locales/ja.json
@@ -714,6 +714,8 @@
"Built-in": "組み込み",
"Built-in Device": "内蔵デバイス",
"Built-in v{{factory}} / marketplace v{{market}}": "組み込み v{{factory}} / マーケット v{{market}}",
+ "Updates with the system": "システムとともに更新",
+ "Built-in is v{{factory}}; delete the custom version to return to it": "組み込みは v{{factory}} です。カスタム版を削除すると戻ります",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "内蔵: 電話の指紋/顔認証、またはWindows Hello。外部: USBセキュリティキー",
"by": "によって",
"By category": "カテゴリ別",
diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json
index 97932b58910d..25752478332e 100644
--- a/web/src/i18n/locales/ru.json
+++ b/web/src/i18n/locales/ru.json
@@ -714,6 +714,8 @@
"Built-in": "Встроенный",
"Built-in Device": "Встроенное устройство",
"Built-in v{{factory}} / marketplace v{{market}}": "Встроенный v{{factory}} / магазин v{{market}}",
+ "Updates with the system": "Обновляется с системой",
+ "Built-in is v{{factory}}; delete the custom version to return to it": "Встроенная версия — v{{factory}}; удалите свою, чтобы вернуться к ней",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Встроенное: отпечаток пальца/лицо телефона или Windows Hello; Внешнее: USB-ключ безопасности",
"by": "от",
"By category": "По категориям",
diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json
index 7e1bc1ea31e6..f55829019487 100644
--- a/web/src/i18n/locales/vi.json
+++ b/web/src/i18n/locales/vi.json
@@ -714,6 +714,8 @@
"Built-in": "Tích hợp sẵn",
"Built-in Device": "Thiết bị tích hợp",
"Built-in v{{factory}} / marketplace v{{market}}": "Tích hợp v{{factory}} / chợ v{{market}}",
+ "Updates with the system": "Cập nhật cùng hệ thống",
+ "Built-in is v{{factory}}; delete the custom version to return to it": "Bản tích hợp là v{{factory}}; xóa bản tùy chỉnh để trở lại",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Tích hợp sẵn: vân tay/khuôn mặt điện thoại, hoặc Windows Hello; Bên ngoài: khóa bảo mật USB",
"by": "by",
"By category": "Theo danh mục",
diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json
index 6cc8c1d6b485..94899c98cc74 100644
--- a/web/src/i18n/locales/zh-TW.json
+++ b/web/src/i18n/locales/zh-TW.json
@@ -714,6 +714,8 @@
"Built-in": "內置",
"Built-in Device": "內置設備",
"Built-in v{{factory}} / marketplace v{{market}}": "內建 v{{factory}} / 市集 v{{market}}",
+ "Updates with the system": "隨系統更新",
+ "Built-in is v{{factory}}; delete the custom version to return to it": "內建版本為 v{{factory}};刪除自訂版本即可恢復",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "內置:手機指紋/面部,或 Windows Hello;外部:USB 安全金鑰",
"by": "由",
"By category": "按行業",
diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json
index 90dec7d69a80..85278c6308cf 100644
--- a/web/src/i18n/locales/zh.json
+++ b/web/src/i18n/locales/zh.json
@@ -714,6 +714,8 @@
"Built-in": "内置",
"Built-in Device": "内置设备",
"Built-in v{{factory}} / marketplace v{{market}}": "内置 v{{factory}} / 市场 v{{market}}",
+ "Updates with the system": "随系统更新",
+ "Built-in is v{{factory}}; delete the custom version to return to it": "内置版本为 v{{factory}};删除自定义版本即可恢复",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "内置:手机指纹/面部,或 Windows Hello;外部:USB 安全密钥",
"by": "由",
"By category": "按行业",
From b5b94bc685fd2251551df826dab3575ab262f6dc Mon Sep 17 00:00:00 2001
From: ruiyunzhao <91191418+CR-Yun@users.noreply.github.com>
Date: Sun, 30 Aug 2026 20:45:24 +0800
Subject: [PATCH 71/99] =?UTF-8?q?fix(subscription):=20=E6=97=A0=E6=9C=89?=
=?UTF-8?q?=E6=95=88=E8=AE=A2=E9=98=85=E6=97=B6=E5=89=8D=E7=AB=AF=E5=A6=82?=
=?UTF-8?q?=E5=AE=9E=E6=98=BE=E7=A4=BA=E3=80=8C=E4=BB=85=E7=94=A8=E8=AE=A2?=
=?UTF-8?q?=E9=98=85=E3=80=8D=E5=81=8F=E5=A5=BD=20(#6222)=20(#7086)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Claude
---
.../components/subscription-plans-card.tsx | 24 +++++++++----------
web/src/i18n/locales/en.json | 3 ++-
web/src/i18n/locales/fr.json | 3 ++-
web/src/i18n/locales/ja.json | 3 ++-
web/src/i18n/locales/ru.json | 3 ++-
web/src/i18n/locales/vi.json | 3 ++-
web/src/i18n/locales/zh-TW.json | 3 ++-
web/src/i18n/locales/zh.json | 3 ++-
8 files changed, 25 insertions(+), 20 deletions(-)
diff --git a/web/src/features/wallet/components/subscription-plans-card.tsx b/web/src/features/wallet/components/subscription-plans-card.tsx
index 5c7dd54e8724..2600c0502d11 100644
--- a/web/src/features/wallet/components/subscription-plans-card.tsx
+++ b/web/src/features/wallet/components/subscription-plans-card.tsx
@@ -194,8 +194,6 @@ export function SubscriptionPlansCard({
const isSubPref =
billingPreference === 'subscription_first' ||
billingPreference === 'subscription_only'
- const displayPref =
- disablePref && isSubPref ? 'wallet_first' : billingPreference
const planPurchaseCountMap = useMemo(() => {
const map = new Map()
@@ -332,12 +330,12 @@ export function SubscriptionPlansCard({
label: getBillingPreferenceLabel('wallet_only', t),
},
]}
- value={displayPref}
+ value={billingPreference}
onValueChange={(v) => v !== null && handlePreferenceChange(v)}
>
- {getBillingPreferenceLabel(displayPref, t)}
+ {getBillingPreferenceLabel(billingPreference, t)}
@@ -381,15 +379,15 @@ export function SubscriptionPlansCard({
{disablePref && isSubPref && (
- {t(
- 'Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.',
- {
- pref:
- billingPreference === 'subscription_only'
- ? t('Subscription Only')
- : t('Subscription First'),
- }
- )}
+ {billingPreference === 'subscription_only'
+ ? t(
+ 'Preference saved as {{pref}}, but no active subscription. Requests will be rejected.',
+ { pref: t('Subscription Only') }
+ )
+ : t(
+ 'Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.',
+ { pref: t('Subscription First') }
+ )}
)}
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index f1a410f7e76a..63f6efe3b75d 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -3640,6 +3640,7 @@
"Pre-consumed": "Pre-consumed",
"Pre-Consumed Quota": "Pre-Consumed Quota",
"Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.": "Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.",
+ "Preference saved as {{pref}}, but no active subscription. Requests will be rejected.": "Preference saved as {{pref}}, but no active subscription. Requests will be rejected.",
"Preferences": "Preferences",
"Prefill Group Management": "Prefill Group Management",
"Prefill Groups": "Prefill Groups",
@@ -5538,4 +5539,4 @@
"Zhipu V4": "Zhipu V4",
"Zoom": "Zoom"
}
-}
+}
\ No newline at end of file
diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json
index e81b95fceea0..176d3100cc4e 100644
--- a/web/src/i18n/locales/fr.json
+++ b/web/src/i18n/locales/fr.json
@@ -3640,6 +3640,7 @@
"Pre-consumed": "Pré-consommé",
"Pre-Consumed Quota": "Quota pré-consommé",
"Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.": "Préférence enregistrée comme {{pref}}, mais aucun abonnement actif. Le portefeuille sera utilisé automatiquement.",
+ "Preference saved as {{pref}}, but no active subscription. Requests will be rejected.": "Préférence enregistrée comme {{pref}}, mais aucun abonnement actif. Les demandes seront rejetées.",
"Preferences": "Préférences",
"Prefill Group Management": "Gestion des groupes de préremplissage",
"Prefill Groups": "Groupes de préremplissage",
@@ -5538,4 +5539,4 @@
"Zhipu V4": "Zhipu V4",
"Zoom": "Zoom"
}
-}
+}
\ No newline at end of file
diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json
index 5a9a00a665ec..e07847df1b16 100644
--- a/web/src/i18n/locales/ja.json
+++ b/web/src/i18n/locales/ja.json
@@ -3640,6 +3640,7 @@
"Pre-consumed": "事前消費",
"Pre-Consumed Quota": "事前消費クォータ",
"Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.": "設定は{{pref}}として保存されましたが、アクティブなサブスクリプションがありません。ウォレットが自動的に使用されます。",
+ "Preference saved as {{pref}}, but no active subscription. Requests will be rejected.": "設定は{{pref}}として保存されましたが、アクティブなサブスクリプションがありません。リクエストは拒否されます。",
"Preferences": "環境設定",
"Prefill Group Management": "プリフィルグループ管理",
"Prefill Groups": "プリフィルグループ",
@@ -5538,4 +5539,4 @@
"Zhipu V4": "Zhipu V 4",
"Zoom": "ズーム"
}
-}
+}
\ No newline at end of file
diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json
index 25752478332e..51e85754ec5a 100644
--- a/web/src/i18n/locales/ru.json
+++ b/web/src/i18n/locales/ru.json
@@ -3640,6 +3640,7 @@
"Pre-consumed": "Предоплата",
"Pre-Consumed Quota": "Предварительно потребленная квота",
"Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.": "Настройка сохранена как {{pref}}, но нет активной подписки. Кошелёк будет использоваться автоматически.",
+ "Preference saved as {{pref}}, but no active subscription. Requests will be rejected.": "Настройка сохранена как {{pref}}, но нет активной подписки. Запросы будут отклонены.",
"Preferences": "Настройки",
"Prefill Group Management": "Управление группами автозаполнения",
"Prefill Groups": "Группы автозаполнения",
@@ -5538,4 +5539,4 @@
"Zhipu V4": "Zhipu V4",
"Zoom": "Zoom"
}
-}
+}
\ No newline at end of file
diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json
index f55829019487..1a1ab235e659 100644
--- a/web/src/i18n/locales/vi.json
+++ b/web/src/i18n/locales/vi.json
@@ -3640,6 +3640,7 @@
"Pre-consumed": "Khấu trừ trước",
"Pre-Consumed Quota": "Hạn mức đã tiêu thụ trước",
"Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.": "Tùy chọn đã lưu là {{pref}}, nhưng không có gói đăng ký đang hoạt động. Ví sẽ được sử dụng tự động.",
+ "Preference saved as {{pref}}, but no active subscription. Requests will be rejected.": "Tùy chọn đã lưu là {{pref}}, nhưng không có gói đăng ký đang hoạt động. Các yêu cầu sẽ bị từ chối.",
"Preferences": "Tùy chọn",
"Prefill Group Management": "Quản lý Nhóm Điền sẵn",
"Prefill Groups": "Điền sẵn các nhóm",
@@ -5538,4 +5539,4 @@
"Zhipu V4": "Zhipu V4",
"Zoom": "Zoom"
}
-}
+}
\ No newline at end of file
diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json
index 94899c98cc74..cdb42a32eb78 100644
--- a/web/src/i18n/locales/zh-TW.json
+++ b/web/src/i18n/locales/zh-TW.json
@@ -3640,6 +3640,7 @@
"Pre-consumed": "預扣費",
"Pre-Consumed Quota": "預消耗配額",
"Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.": "已儲存偏好為{{pref}},目前無生效訂閱,將自動使用錢包",
+ "Preference saved as {{pref}}, but no active subscription. Requests will be rejected.": "已儲存偏好為{{pref}},目前無生效訂閱,請求將被拒絕",
"Preferences": "偏好設定",
"Prefill Group Management": "預填充分組管理",
"Prefill Groups": "預填充分組",
@@ -5538,4 +5539,4 @@
"Zhipu V4": "智譜 V4",
"Zoom": "縮放"
}
-}
+}
\ No newline at end of file
diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json
index 85278c6308cf..183e97661c9e 100644
--- a/web/src/i18n/locales/zh.json
+++ b/web/src/i18n/locales/zh.json
@@ -3640,6 +3640,7 @@
"Pre-consumed": "预扣费",
"Pre-Consumed Quota": "预消耗配额",
"Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.": "已保存偏好为{{pref}},当前无生效订阅,将自动使用钱包",
+ "Preference saved as {{pref}}, but no active subscription. Requests will be rejected.": "已保存偏好为{{pref}},当前无生效订阅,请求将被拒绝",
"Preferences": "偏好设置",
"Prefill Group Management": "预填充分组管理",
"Prefill Groups": "预填充分组",
@@ -5538,4 +5539,4 @@
"Zhipu V4": "智谱 V4",
"Zoom": "缩放"
}
-}
+}
\ No newline at end of file
From 1751f43ee07edc9eb0c56fd9b23586861b43df46 Mon Sep 17 00:00:00 2001
From: Xayinn <129403670+LinineTy@users.noreply.github.com>
Date: Sun, 30 Aug 2026 20:46:56 +0800
Subject: [PATCH 72/99] fix(sqlite): enable WAL + working busy timeout +
_txlock=immediate to stop concurrent write lockouts (#7030)
* fix(sqlite): enable WAL + working busy timeout + _txlock=immediate to stop concurrent write lockouts
---
common/database.go | 22 +++++++++++++++++++++-
1 file changed, 21 insertions(+), 1 deletion(-)
diff --git a/common/database.go b/common/database.go
index 30d341f37268..44e8753139fa 100644
--- a/common/database.go
+++ b/common/database.go
@@ -41,4 +41,24 @@ func UsingLogDatabase(databaseType DatabaseType) bool {
return logDatabaseType == databaseType
}
-var SQLitePath = "one-api.db?_busy_timeout=30000"
+// SQLitePath is the DSN for the default SQLite database. It uses WAL journal
+// mode so readers are never blocked by the single writer, plus a 30s busy
+// timeout for writers to queue.
+//
+// Two details are non-obvious and both are required for concurrent correctness:
+//
+// 1. The busy timeout must be passed as a `_pragma=busy_timeout(30000)` DSN
+// parameter. The pure-Go driver (modernc.org/sqlite, used through
+// github.com/glebarez/sqlite) silently ignores the plain `_busy_timeout=`
+// form, so without this the effective timeout stays at SQLite's 5s default
+// and concurrent writes surface as "database is locked" (see #6805).
+//
+// 2. `_txlock=immediate` (BEGIN IMMEDIATE) must be enabled. Without it, a
+// transaction that first SELECTs (establishing a read snapshot) and then
+// writes can hit SQLITE_BUSY_SNAPSHOT when another connection commits in
+// between; the busy handler does not cover that case, so the write fails
+// instantly no matter the timeout. BEGIN IMMEDIATE takes the write lock up
+// front, so writers serialize through the busy timeout instead of dying on
+// a stale snapshot. Autocommit SELECTs stay concurrent because WAL keeps
+// readers unlocked.
+var SQLitePath = "one-api.db?_pragma=busy_timeout(30000)&_pragma=journal_mode(WAL)&_txlock=immediate"
From 6eb6f35ed211b7459cae3b9f13286b9c93fc1bd6 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Sun, 30 Aug 2026 21:13:21 +0800
Subject: [PATCH 73/99] fix(model): return string from JSON column Valuers for
pg simple protocol
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
With PrepareStmt disabled, PostgreSQL queries run over pgx's simple
protocol, which encodes every []byte parameter as a bytea hex literal
('\x...'). driver.Valuer implementations returning []byte from
json.Marshal therefore fail json-column writes with SQLSTATE 22P02
(reported on the channels UPDATE path via ChannelInfo).
Reproduced against a live PostgreSQL 16: []byte Valuer into a json
column fails under simple protocol, string succeeds; []byte into a
text column silently stores the hex literal (no such path exists in
the repo today — audited all Valuers, json.RawMessage fields, and raw
SQL call sites).
- ChannelInfo, Properties, TaskPrivateData, JSONValue Value() now
return string; zero-value nil semantics unchanged. Task.Data
(bare json.RawMessage) is unaffected — database/sql's default
converter already passes it as expected.
- Their Scan() counterparts now accept both []byte and string via a
shared jsonScanBytes helper: SQLite returns string for these columns
once Value() emits string, and the old []byte-only assertions
silently zeroed the field (caught by the model test suite).
- Add regression tests locking both contracts: json-column Valuers
must return string (or nil for zero values), Scanners must accept
[]byte and string.
Verified end-to-end against PostgreSQL 16 with the real model types:
Channel create/update/read-back, Task json fields, PrefillGroup items.
---
model/channel.go | 11 +++--
model/json_column_test.go | 94 +++++++++++++++++++++++++++++++++++++++
model/main.go | 13 ++++++
model/prefill_group.go | 4 +-
model/task.go | 19 ++++++--
5 files changed, 133 insertions(+), 8 deletions(-)
create mode 100644 model/json_column_test.go
diff --git a/model/channel.go b/model/channel.go
index 397e94289b28..705c852b7a89 100644
--- a/model/channel.go
+++ b/model/channel.go
@@ -162,14 +162,19 @@ func ApplyChannelGroupFilter(query *gorm.DB, group string) *gorm.DB {
}
// Value implements driver.Valuer interface
+// 必须返回 string 而非 []byte:PG simple protocol 下 []byte 参数按 bytea
+// 编码,写 json 列会触发 SQLSTATE 22P02。
func (c ChannelInfo) Value() (driver.Value, error) {
- return common.Marshal(&c)
+ b, err := common.Marshal(&c)
+ if err != nil {
+ return nil, err
+ }
+ return string(b), nil
}
// Scan implements sql.Scanner interface
func (c *ChannelInfo) Scan(value interface{}) error {
- bytesValue, _ := value.([]byte)
- return common.Unmarshal(bytesValue, c)
+ return common.Unmarshal(jsonScanBytes(value), c)
}
func (channel *Channel) GetKeys() []string {
diff --git a/model/json_column_test.go b/model/json_column_test.go
new file mode 100644
index 000000000000..59272629d0e1
--- /dev/null
+++ b/model/json_column_test.go
@@ -0,0 +1,94 @@
+package model
+
+import (
+ "database/sql/driver"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// 保护契约:PostgreSQL 走 simple protocol(PrepareStmt 关闭)时,driver.Valuer
+// 返回 []byte 会被 pgx 按 bytea 十六进制字面量编码,写入 json 列触发
+// SQLSTATE 22P02。所有 json 列的 Value() 必须返回 string(或 nil)。
+func TestJSONColumnValuersReturnString(t *testing.T) {
+ testCases := []struct {
+ name string
+ valuer driver.Valuer
+ want string
+ }{
+ {
+ name: "ChannelInfo",
+ valuer: ChannelInfo{IsMultiKey: true, MultiKeySize: 2},
+ want: `{"is_multi_key":true,"multi_key_size":2,"multi_key_status_list":null,"multi_key_polling_index":0,"multi_key_mode":""}`,
+ },
+ {
+ name: "Properties",
+ valuer: Properties{Input: "hello"},
+ want: `{"input":"hello"}`,
+ },
+ {
+ name: "TaskPrivateData",
+ valuer: TaskPrivateData{Key: "k"},
+ want: `{"key":"k"}`,
+ },
+ {
+ name: "JSONValue",
+ valuer: JSONValue(`[{"k":"v"}]`),
+ want: `[{"k":"v"}]`,
+ },
+ }
+
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ value, err := testCase.valuer.Value()
+ require.NoError(t, err)
+ str, ok := value.(string)
+ require.True(t, ok, "Value() must return string, got %T", value)
+ assert.JSONEq(t, testCase.want, str)
+ })
+ }
+}
+
+// 空值仍返回 nil,保持列的 NULL 语义。
+func TestJSONColumnValuersZeroValueIsNil(t *testing.T) {
+ for name, valuer := range map[string]driver.Valuer{
+ "Properties": Properties{},
+ "TaskPrivateData": TaskPrivateData{},
+ "JSONValue": JSONValue(nil),
+ } {
+ t.Run(name, func(t *testing.T) {
+ value, err := valuer.Value()
+ require.NoError(t, err)
+ assert.Nil(t, value)
+ })
+ }
+}
+
+// 保护契约:json 列的 Scan 必须同时接受 []byte 与 string——不同驱动/协议
+// 模式返回类型不同,静默丢弃 string 会把已有数据清零。
+func TestJSONColumnScannersAcceptStringAndBytes(t *testing.T) {
+ toInput := func(kind string, payload string) interface{} {
+ if kind == "bytes" {
+ return []byte(payload)
+ }
+ return payload
+ }
+
+ for _, kind := range []string{"bytes", "string"} {
+ t.Run(kind, func(t *testing.T) {
+ var info ChannelInfo
+ require.NoError(t, info.Scan(toInput(kind, `{"is_multi_key":true,"multi_key_size":2}`)))
+ assert.True(t, info.IsMultiKey)
+ assert.Equal(t, 2, info.MultiKeySize)
+
+ var props Properties
+ require.NoError(t, props.Scan(toInput(kind, `{"input":"hello"}`)))
+ assert.Equal(t, "hello", props.Input)
+
+ var private TaskPrivateData
+ require.NoError(t, private.Scan(toInput(kind, `{"key":"k"}`)))
+ assert.Equal(t, "k", private.Key)
+ })
+ }
+}
diff --git a/model/main.go b/model/main.go
index 3c10c3d36b28..dd2920b0a31b 100644
--- a/model/main.go
+++ b/model/main.go
@@ -27,6 +27,19 @@ var commonFalseVal string
var logKeyCol string
var logGroupCol string
+// jsonScanBytes 归一化 json 列的驱动返回值:不同驱动/协议模式下同一列可能
+// 以 []byte 或 string 返回,静默丢弃 string 会导致字段被清零而不报错。
+func jsonScanBytes(value interface{}) []byte {
+ switch v := value.(type) {
+ case []byte:
+ return v
+ case string:
+ return []byte(v)
+ default:
+ return nil
+ }
+}
+
func initCol() {
// init common column names
if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) {
diff --git a/model/prefill_group.go b/model/prefill_group.go
index cc2e64da992e..0d3e3d1ef028 100644
--- a/model/prefill_group.go
+++ b/model/prefill_group.go
@@ -20,11 +20,13 @@ import (
type JSONValue json.RawMessage
// Value 实现 driver.Valuer 接口,用于数据库写入
+// 必须返回 string 而非 []byte:PG simple protocol 下 []byte 按 bytea 编码,
+// 写 json 列会触发 SQLSTATE 22P02。
func (j JSONValue) Value() (driver.Value, error) {
if j == nil {
return nil, nil
}
- return []byte(j), nil
+ return string(j), nil
}
// Scan 实现 sql.Scanner 接口,兼容不同驱动返回的类型
diff --git a/model/task.go b/model/task.go
index 5263c5481180..efbb6c817716 100644
--- a/model/task.go
+++ b/model/task.go
@@ -87,7 +87,7 @@ type Properties struct {
}
func (m *Properties) Scan(val interface{}) error {
- bytesValue, _ := val.([]byte)
+ bytesValue := jsonScanBytes(val)
if len(bytesValue) == 0 {
*m = Properties{}
return nil
@@ -99,7 +99,13 @@ func (m Properties) Value() (driver.Value, error) {
if m == (Properties{}) {
return nil, nil
}
- return common.Marshal(m)
+ // 必须返回 string 而非 []byte:PG simple protocol 下 []byte 按 bytea 编码,
+ // 写 json 列会触发 SQLSTATE 22P02。
+ b, err := common.Marshal(m)
+ if err != nil {
+ return nil, err
+ }
+ return string(b), nil
}
type TaskPrivateData struct {
@@ -180,7 +186,7 @@ func GenerateTaskID() string {
}
func (p *TaskPrivateData) Scan(val interface{}) error {
- bytesValue, _ := val.([]byte)
+ bytesValue := jsonScanBytes(val)
if len(bytesValue) == 0 {
return nil
}
@@ -191,7 +197,12 @@ func (p TaskPrivateData) Value() (driver.Value, error) {
if (p == TaskPrivateData{}) {
return nil, nil
}
- return common.Marshal(p)
+ // 同 Properties.Value:string 避免 PG simple protocol 的 bytea 编码。
+ b, err := common.Marshal(p)
+ if err != nil {
+ return nil, err
+ }
+ return string(b), nil
}
// SyncTaskQueryParams 用于包含所有搜索条件的结构体,可以根据需求添加更多字段
From b518d0033b670f5518b8a2f1cf8ea0142a9d1b8d Mon Sep 17 00:00:00 2001
From: txgo
Date: Sun, 30 Aug 2026 21:18:41 +0800
Subject: [PATCH 74/99] =?UTF-8?q?fix(relay):=20bound=20the=20wait=20for=20?=
=?UTF-8?q?upstream=20response=20headers=20(fixes=20unbounded=20heap=20gro?=
=?UTF-8?q?wth=20=E2=86=92=20OOM)=20(#6949)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(relay): bound the wait for upstream response headers (fixes unbounded heap growth)
The relay transport sets a dial timeout, a TLS handshake timeout and an expect-continue
timeout, but nothing bounds how long it waits for the upstream *response headers* after
the request has been written. An upstream that accepts the connection and then never
answers -- without sending FIN/RST, which is what happens when a NAT/firewall silently
drops the flow or the provider hangs -- parks the goroutine in
net/http.(*persistConn).roundTrip forever.
That goroutine keeps the whole request alive, which in practice means three copies of the
request body stay reachable for the lifetime of the process: the raw bytes from
io.ReadAll in CreateBodyStorageFromReader, the decoded messages held as json.RawMessage,
and the re-marshalled upstream body from common.Marshal. BodyStorageCleanup cannot help
here: it runs after c.Next() returns, and for these requests c.Next() never returns.
Measured on v1.0.0-rc.23 in production (see #6947 for the full evidence):
- 23 goroutines stuck in persistConn.roundTrip on a single 40h-old instance,
blocked between 353 and 1894 minutes (5.9h to 31.5h)
- 96.9% of the live heap, sampled after a forced GC, attributable to those three
body copies (HeapAlloc 892 MiB surviving three GC cycles; HeapObjects dropping
30x while bytes dropped only 25%)
- the live floor grows with uptime: 33.7 MiB at 0.1h, 89.2 at 13.8h, 510.0 at 40.1h,
955.2 at 146.8h, OOMKilled at 172.9h -- same image, same config, same load
Doubling the memory limit and adding GOMEMLIMIT only moved the OOM from 132h to 172.9h.
RELAY_TIMEOUT (http.Client.Timeout) cannot be used for this: it covers the whole response
read and would cut legitimate long streaming calls, which is why it defaults to 0.
ResponseHeaderTimeout only bounds the wait for the headers; streaming after they arrive is
unaffected.
The default is deliberately generous. Non-streaming upstreams usually send the response
headers only once generation has finished, so the value has to leave room for a long
completion. 1800s is 12x shorter than the shortest hang observed here while leaving
several times the headroom a normal non-streaming request needs; 0 restores the previous
unbounded behaviour.
The assignment goes next to the other transport.* lines rather than inside the else
branch: newRelayHTTPTransport() normally takes the http.DefaultTransport.Clone() path,
and DefaultTransport does not set ResponseHeaderTimeout either.
This repo already sets ResponseHeaderTimeout on its other outbound transports
(controller/model_sync.go, controller/ratio_sync.go); the relay path appears to have
been missed.
Refs #6947. Likely also the root cause of #6731, which reported the same symptom
(production OOM on /v1/responses after ~64h) but was closed for template reasons.
* review: clamp overflowing timeout values and switch the test to testify
Addresses the two CodeRabbit findings on this PR.
Overflow (common/init.go:113): a RELAY_RESPONSE_HEADER_TIMEOUT beyond ~9.2e9 seconds
overflows time.Duration and can wrap into a *tiny positive* timeout, which would cut
every relay request instead of only the stuck ones. The value is now clamped before the
conversion, with regression tests for both the negative and the overflowing input.
I did not add fail-on-startup validation for negative values, for two reasons: the
existing `if seconds > 0` guard already treats them as "disabled", and the neighbouring
env-driven timeouts in this file are less strict still -- RelayIdleConnTimeout is
converted with no guard at all. Failing startup on a bad value would be a behaviour
change out of step with the rest of the file; happy to add it if you'd prefer that
direction repo-wide.
Test style: switched to testify (require.Equal / require.Zero / require.Positive), which
is what every other test under service/ uses.
go build, go vet and go test ./common/... ./service/... pass.
(`go build ./...` fails on the `web/dist` embed both with and without this change -- the
frontend bundle is not checked in.)
---
.env.example | 4 ++++
README.md | 1 +
common/constants.go | 10 ++++++++++
common/init.go | 1 +
service/http_client.go | 23 +++++++++++++++++++++++
5 files changed, 39 insertions(+)
diff --git a/.env.example b/.env.example
index 2c25b62abbae..5cbd8076b23f 100644
--- a/.env.example
+++ b/.env.example
@@ -62,6 +62,10 @@
# RELAY_TIMEOUT=0
# Relay HTTP 客户端空闲连接超时时间,单位秒,默认跟随 Go 标准库,设置为0表示不限制
# RELAY_IDLE_CONN_TIMEOUT=90
+# 等待上游返回响应头的超时时间,单位秒,默认 1800,设置为 0 表示不限制。
+# 仅约束「等待响应头」这一段;响应头返回之后的流式传输不受影响。
+# 注意:非流式请求通常要等上游生成完毕才会返回响应头,因此该值需留足余量。
+# RELAY_RESPONSE_HEADER_TIMEOUT=1800
# 流模式无响应超时时间,单位秒,如果出现空补全可以尝试改为更大值
# STREAMING_TIMEOUT=300
diff --git a/README.md b/README.md
index 91778e659d3f..d05f8e58ca71 100644
--- a/README.md
+++ b/README.md
@@ -327,6 +327,7 @@ docker run --name new-api -d --restart always \
| `SQL_DSN` | Database connection string | - |
| `REDIS_CONN_STRING` | Redis connection string | - |
| `RELAY_IDLE_CONN_TIMEOUT` | Idle keep-alive timeout for relay HTTP clients, seconds. Defaults to Go standard library behavior; set `0` to disable | `90` |
+| `RELAY_RESPONSE_HEADER_TIMEOUT` | How long the relay waits for upstream **response headers**, seconds; set `0` to disable. Only bounds the header wait -- streaming after the headers arrive is unaffected. Note that non-streaming upstreams usually send headers only once generation finishes, so leave headroom | `1800` |
| `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` |
| `STREAM_SCANNER_MAX_BUFFER_MB` | Max per-line buffer (MB) for the stream scanner; increase when upstream sends huge image/base64 payloads | `64` |
| `MAX_REQUEST_BODY_MB` | Max request body size (MB, counted **after decompression**; prevents huge requests/zip bombs from exhausting memory). Exceeding it returns `413` | `32` |
diff --git a/common/constants.go b/common/constants.go
index 0b6e9ce82da5..2c7b90995094 100644
--- a/common/constants.go
+++ b/common/constants.go
@@ -163,6 +163,16 @@ var BatchUpdateInterval int
var RelayTimeout int // unit is second
var RelayIdleConnTimeout int // unit is second
+
+// RelayResponseHeaderTimeout limits how long the relay transport waits for the
+// upstream response headers after the request has been fully written.
+// 0 disables it (previous behaviour: wait forever).
+//
+// Note this is NOT the same as RelayTimeout (http.Client.Timeout), which covers
+// the whole response read and therefore breaks legitimate long streaming calls.
+// ResponseHeaderTimeout only bounds the wait for the response headers; once the
+// headers arrive, streaming is unaffected.
+var RelayResponseHeaderTimeout int // unit is second
var RelayMaxIdleConns int
var RelayMaxIdleConnsPerHost int
diff --git a/common/init.go b/common/init.go
index 5ca1a4ab0c94..a54075fad825 100644
--- a/common/init.go
+++ b/common/init.go
@@ -111,6 +111,7 @@ func InitEnv() {
BatchUpdateInterval = GetEnvOrDefault("BATCH_UPDATE_INTERVAL", 5)
RelayTimeout = GetEnvOrDefault("RELAY_TIMEOUT", 0)
RelayIdleConnTimeout = GetEnvOrDefault("RELAY_IDLE_CONN_TIMEOUT", 90)
+ RelayResponseHeaderTimeout = GetEnvOrDefault("RELAY_RESPONSE_HEADER_TIMEOUT", 1800)
RelayMaxIdleConns = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS", 500)
RelayMaxIdleConnsPerHost = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS_PER_HOST", 100)
diff --git a/service/http_client.go b/service/http_client.go
index f5c19eafedc8..19523fb13827 100644
--- a/service/http_client.go
+++ b/service/http_client.go
@@ -4,6 +4,7 @@ import (
"context"
"crypto/tls"
"fmt"
+ "math"
"net"
"net/http"
"net/url"
@@ -71,6 +72,10 @@ func ValidateSSRFProtectedFetchURL(urlStr string) error {
return validateURLWithCurrentFetchSetting(urlStr, true)
}
+// maxTimeoutSeconds is the largest number of seconds that still converts to a
+// time.Duration without overflowing (~292 years).
+const maxTimeoutSeconds = int(math.MaxInt64 / int64(time.Second))
+
func newRelayHTTPTransport() *http.Transport {
var transport *http.Transport
if defaultTransport, ok := http.DefaultTransport.(*http.Transport); ok && defaultTransport != nil {
@@ -91,6 +96,24 @@ func newRelayHTTPTransport() *http.Transport {
transport.MaxIdleConns = common.RelayMaxIdleConns
transport.MaxIdleConnsPerHost = common.RelayMaxIdleConnsPerHost
transport.IdleConnTimeout = time.Duration(common.RelayIdleConnTimeout) * time.Second
+ // Bound the wait for upstream response headers. Without it, an upstream that
+ // accepts the connection but never responds (and never sends FIN/RST) parks the
+ // goroutine forever, and every buffer that request owns -- the raw body read by
+ // io.ReadAll, the decoded messages, and the re-marshalled upstream body -- stays
+ // reachable for the lifetime of the process.
+ //
+ // This only covers the wait for the headers; streaming after the headers arrive
+ // is not affected. Set RELAY_RESPONSE_HEADER_TIMEOUT=0 to restore the old
+ // unbounded behaviour.
+ if seconds := common.RelayResponseHeaderTimeout; seconds > 0 {
+ // Clamp before converting: seconds beyond maxTimeoutSeconds overflow
+ // time.Duration and can wrap into a tiny positive timeout, which would cut
+ // every relay request instead of only the stuck ones.
+ if seconds > maxTimeoutSeconds {
+ seconds = maxTimeoutSeconds
+ }
+ transport.ResponseHeaderTimeout = time.Duration(seconds) * time.Second
+ }
transport.ForceAttemptHTTP2 = true
if common.TLSInsecureSkipVerify {
transport.TLSClientConfig = common.InsecureTLSConfig
From 74158715cde6d7b767ead23d9a2af64b7b58a588 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Sun, 30 Aug 2026 22:29:33 +0800
Subject: [PATCH 75/99] fix initialize database
---
go.mod | 6 +++---
go.sum | 14 +++++---------
2 files changed, 8 insertions(+), 12 deletions(-)
diff --git a/go.mod b/go.mod
index 420395003dbc..b0f997f2ee24 100644
--- a/go.mod
+++ b/go.mod
@@ -56,8 +56,8 @@ require (
golang.org/x/sys v0.45.0
golang.org/x/text v0.37.0
gopkg.in/yaml.v3 v3.0.1
- gorm.io/driver/mysql v1.4.3
- gorm.io/driver/postgres v1.5.2
+ gorm.io/driver/mysql v1.5.7
+ gorm.io/driver/postgres v1.5.9
gorm.io/gorm v1.25.12
)
@@ -95,7 +95,7 @@ require (
require (
github.com/DmitriyVTitov/size v1.5.0 // indirect
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
- github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/beorn7/perks v1.0.1 // indirect
diff --git a/go.sum b/go.sum
index d476af2e5169..c9c7229a0f72 100644
--- a/go.sum
+++ b/go.sum
@@ -1203,7 +1203,6 @@ github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
-github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
@@ -1461,7 +1460,6 @@ github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
-github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
@@ -3022,14 +3020,12 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/clickhouse v0.6.0 h1:nyhaeQ92qFEqf47B5N/vwPnnqV2DAuSHPC0QmlZrVZI=
gorm.io/driver/clickhouse v0.6.0/go.mod h1:UtkbKNA4ibWTCzVkuFY80hBsb82nTH335JUVUKvT9YY=
-gorm.io/driver/mysql v1.4.3 h1:/JhWJhO2v17d8hjApTltKNADm7K7YI2ogkR7avJUL3k=
-gorm.io/driver/mysql v1.4.3/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c=
-gorm.io/driver/postgres v1.5.2 h1:ytTDxxEv+MplXOfFe3Lzm7SjG09fcdb3Z/c056DTBx0=
-gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBpCgl8=
-gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
+gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
+gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
+gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
+gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/gorm v1.24.6/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
-gorm.io/gorm v1.25.2 h1:gs1o6Vsa+oVKG/a9ElL3XgyGfghFfkKA2SInQaCyMho=
-gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
+gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
From 69a41eeadc81adb08d04346512c76d62fd6203db Mon Sep 17 00:00:00 2001
From: Seefs <40468931+seefs001@users.noreply.github.com>
Date: Sun, 30 Aug 2026 22:51:50 +0800
Subject: [PATCH 76/99] fix(model): drop leftover prefill_groups unique
constraints before AutoMigrate (#7100)
---
model/main.go | 6 +++
model/prefill_group_migration.go | 81 ++++++++++++++++++++++++++++++++
2 files changed, 87 insertions(+)
create mode 100644 model/prefill_group_migration.go
diff --git a/model/main.go b/model/main.go
index dd2920b0a31b..197d98d1f7cd 100644
--- a/model/main.go
+++ b/model/main.go
@@ -321,6 +321,9 @@ func migrateDB() error {
if err := migrateTokenModelLimitsToText(); err != nil {
return err
}
+ if err := migratePrefillGroupUniqueIndex(DB); err != nil {
+ return err
+ }
err := DB.AutoMigrate(
&Channel{},
@@ -381,6 +384,9 @@ func migrateDB() error {
}
func migrateDBFast() error {
+ if err := migratePrefillGroupUniqueIndex(DB); err != nil {
+ return err
+ }
var wg sync.WaitGroup
diff --git a/model/prefill_group_migration.go b/model/prefill_group_migration.go
new file mode 100644
index 000000000000..1cff90659bb6
--- /dev/null
+++ b/model/prefill_group_migration.go
@@ -0,0 +1,81 @@
+package model
+
+import (
+ "fmt"
+
+ "github.com/QuantumNous/new-api/common"
+
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+// migratePrefillGroupUniqueIndex drops leftover single-column UNIQUE
+// constraints on prefill_groups.name before AutoMigrate.
+//
+// GORM 1.25.x MigrateColumnUnique treats a catalog unique column as the
+// `unique` tag and issues DROP CONSTRAINT uni__. Older
+// uniqueIndex migrations stored that uniqueness as idx__ or
+// PostgreSQL's __key, so the uni_* constraint does not exist
+// and AutoMigrate aborts with SQLSTATE 42704. Removing the catalog unique
+// constraint first lets AutoMigrate create uk_prefill_name (partial unique
+// index) instead.
+func migratePrefillGroupUniqueIndex(db *gorm.DB) error {
+ if db == nil || db.Dialector == nil || db.Dialector.Name() != "postgres" {
+ return nil
+ }
+ if !db.Migrator().HasTable(&PrefillGroup{}) {
+ return nil
+ }
+ stmt := &gorm.Statement{DB: db}
+ if err := stmt.Parse(&PrefillGroup{}); err != nil {
+ return err
+ }
+ return dropPrefillGroupLegacyNameUniques(db, stmt.Schema.Table)
+}
+
+func dropPrefillGroupLegacyNameUniques(db *gorm.DB, table string) error {
+ if db == nil || table == "" {
+ return nil
+ }
+ column := "name"
+ var constraintNames []string
+ if err := db.Raw(`
+SELECT tc.constraint_name
+FROM information_schema.table_constraints AS tc
+INNER JOIN information_schema.key_column_usage AS kcu
+ ON tc.constraint_catalog = kcu.constraint_catalog
+ AND tc.constraint_schema = kcu.constraint_schema
+ AND tc.constraint_name = kcu.constraint_name
+ AND tc.table_name = kcu.table_name
+WHERE tc.constraint_schema = current_schema()
+ AND tc.table_name = ?
+ AND tc.constraint_type = 'UNIQUE'
+GROUP BY tc.constraint_name
+HAVING COUNT(*) = 1 AND MIN(kcu.column_name) = ?`, table, column).Scan(&constraintNames).Error; err != nil {
+ return fmt.Errorf("list unique constraints on %s.%s: %w", table, column, err)
+ }
+ for _, name := range constraintNames {
+ if name == "" {
+ continue
+ }
+ if err := db.Exec("ALTER TABLE ? DROP CONSTRAINT IF EXISTS ?",
+ clause.Table{Name: table}, clause.Column{Name: name}).Error; err != nil {
+ return fmt.Errorf("drop unique constraint %s on %s: %w", name, table, err)
+ }
+ common.SysLog(fmt.Sprintf("dropped leftover unique constraint %s on %s.%s", name, table, column))
+ }
+
+ legacyIndexNames := []string{
+ db.NamingStrategy.IndexName(table, column),
+ db.NamingStrategy.UniqueName(table, column),
+ }
+ for _, name := range legacyIndexNames {
+ if name == "" || name == "uk_prefill_name" {
+ continue
+ }
+ if err := db.Exec("DROP INDEX IF EXISTS ?", clause.Column{Name: name}).Error; err != nil {
+ return fmt.Errorf("drop leftover unique index %s on %s: %w", name, table, err)
+ }
+ }
+ return nil
+}
From 2bf0820f4b89530acf14d389ba3e8229211933fa Mon Sep 17 00:00:00 2001
From: Calcium-Ion
Date: Sun, 30 Aug 2026 22:55:17 +0800
Subject: [PATCH 77/99] =?UTF-8?q?Revert=20"fix(model):=20drop=20leftover?=
=?UTF-8?q?=20prefill=5Fgroups=20unique=20constraints=20before=20Au?=
=?UTF-8?q?=E2=80=A6"=20(#7101)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This reverts commit 69a41eeadc81adb08d04346512c76d62fd6203db.
---
model/main.go | 6 ---
model/prefill_group_migration.go | 81 --------------------------------
2 files changed, 87 deletions(-)
delete mode 100644 model/prefill_group_migration.go
diff --git a/model/main.go b/model/main.go
index 197d98d1f7cd..dd2920b0a31b 100644
--- a/model/main.go
+++ b/model/main.go
@@ -321,9 +321,6 @@ func migrateDB() error {
if err := migrateTokenModelLimitsToText(); err != nil {
return err
}
- if err := migratePrefillGroupUniqueIndex(DB); err != nil {
- return err
- }
err := DB.AutoMigrate(
&Channel{},
@@ -384,9 +381,6 @@ func migrateDB() error {
}
func migrateDBFast() error {
- if err := migratePrefillGroupUniqueIndex(DB); err != nil {
- return err
- }
var wg sync.WaitGroup
diff --git a/model/prefill_group_migration.go b/model/prefill_group_migration.go
deleted file mode 100644
index 1cff90659bb6..000000000000
--- a/model/prefill_group_migration.go
+++ /dev/null
@@ -1,81 +0,0 @@
-package model
-
-import (
- "fmt"
-
- "github.com/QuantumNous/new-api/common"
-
- "gorm.io/gorm"
- "gorm.io/gorm/clause"
-)
-
-// migratePrefillGroupUniqueIndex drops leftover single-column UNIQUE
-// constraints on prefill_groups.name before AutoMigrate.
-//
-// GORM 1.25.x MigrateColumnUnique treats a catalog unique column as the
-// `unique` tag and issues DROP CONSTRAINT uni__. Older
-// uniqueIndex migrations stored that uniqueness as idx__ or
-// PostgreSQL's __key, so the uni_* constraint does not exist
-// and AutoMigrate aborts with SQLSTATE 42704. Removing the catalog unique
-// constraint first lets AutoMigrate create uk_prefill_name (partial unique
-// index) instead.
-func migratePrefillGroupUniqueIndex(db *gorm.DB) error {
- if db == nil || db.Dialector == nil || db.Dialector.Name() != "postgres" {
- return nil
- }
- if !db.Migrator().HasTable(&PrefillGroup{}) {
- return nil
- }
- stmt := &gorm.Statement{DB: db}
- if err := stmt.Parse(&PrefillGroup{}); err != nil {
- return err
- }
- return dropPrefillGroupLegacyNameUniques(db, stmt.Schema.Table)
-}
-
-func dropPrefillGroupLegacyNameUniques(db *gorm.DB, table string) error {
- if db == nil || table == "" {
- return nil
- }
- column := "name"
- var constraintNames []string
- if err := db.Raw(`
-SELECT tc.constraint_name
-FROM information_schema.table_constraints AS tc
-INNER JOIN information_schema.key_column_usage AS kcu
- ON tc.constraint_catalog = kcu.constraint_catalog
- AND tc.constraint_schema = kcu.constraint_schema
- AND tc.constraint_name = kcu.constraint_name
- AND tc.table_name = kcu.table_name
-WHERE tc.constraint_schema = current_schema()
- AND tc.table_name = ?
- AND tc.constraint_type = 'UNIQUE'
-GROUP BY tc.constraint_name
-HAVING COUNT(*) = 1 AND MIN(kcu.column_name) = ?`, table, column).Scan(&constraintNames).Error; err != nil {
- return fmt.Errorf("list unique constraints on %s.%s: %w", table, column, err)
- }
- for _, name := range constraintNames {
- if name == "" {
- continue
- }
- if err := db.Exec("ALTER TABLE ? DROP CONSTRAINT IF EXISTS ?",
- clause.Table{Name: table}, clause.Column{Name: name}).Error; err != nil {
- return fmt.Errorf("drop unique constraint %s on %s: %w", name, table, err)
- }
- common.SysLog(fmt.Sprintf("dropped leftover unique constraint %s on %s.%s", name, table, column))
- }
-
- legacyIndexNames := []string{
- db.NamingStrategy.IndexName(table, column),
- db.NamingStrategy.UniqueName(table, column),
- }
- for _, name := range legacyIndexNames {
- if name == "" || name == "uk_prefill_name" {
- continue
- }
- if err := db.Exec("DROP INDEX IF EXISTS ?", clause.Column{Name: name}).Error; err != nil {
- return fmt.Errorf("drop leftover unique index %s on %s: %w", name, table, err)
- }
- }
- return nil
-}
From 2b6f1dfefbe217fed31fc0726717cc7de6958e8e Mon Sep 17 00:00:00 2001
From: CaIon
Date: Sun, 30 Aug 2026 22:57:57 +0800
Subject: [PATCH 78/99] fix(model): drop leftover prefill_groups unique
constraints before AutoMigrate
---
AGENTS.md | 5 +
model/main.go | 86 +-------
model/prefill_group_migration.go | 214 ++++++++++++++++++
model/prefill_group_migration_test.go | 303 ++++++++++++++++++++++++++
4 files changed, 525 insertions(+), 83 deletions(-)
create mode 100644 model/prefill_group_migration.go
create mode 100644 model/prefill_group_migration_test.go
diff --git a/AGENTS.md b/AGENTS.md
index 0d6414739849..89bd9b101c5c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -81,6 +81,11 @@ Do NOT directly import or call `encoding/json` in business code. `json.RawMessag
**Database compatibility:** All database code MUST work with SQLite, MySQL >= 5.7.8, and PostgreSQL >= 9.6 simultaneously.
+- Any change that can affect database behavior MUST be verified before the work is considered complete. This includes ORM/database-driver dependency changes, connection/DSN/protocol or prepared-statement configuration, models and GORM tags, migrations and `AutoMigrate`, constraints and indexes, `Scanner`/`Valuer`/serializer behavior, raw SQL, transactions, and row locking.
+- Required database verification MUST exercise real SQLite, MySQL, and PostgreSQL instances. Unit tests, mocks, a successful build, code inspection, or testing only one dialect are not substitutes. Use at least one supported version of each engine; changes that depend on version-specific behavior must also cover the minimum supported version.
+- Treat GORM core and its database dialect/driver packages as a compatible version set. Any change to one of them requires checking upstream compatibility and running the complete three-database verification matrix; do not upgrade only the core package and infer that existing drivers remain compatible.
+- Schema or migration changes MUST be tested both on a fresh database and by upgrading a representative database created by the latest released version. Run startup/migration at least twice to prove idempotency, and verify that existing data, indexes, constraints, and uniqueness guarantees are preserved. Cover the separately configured log database when the affected path is shared with or used by it.
+- Record the exact database versions, commands, and results in the final handoff or pull request. If any required database verification cannot be run, report the blocker explicitly and do not claim the change is database-compatible or complete.
- Prefer GORM methods (`Create`, `Find`, `Where`, `Updates`, etc.) over raw SQL.
- Let GORM handle primary key generation; do not use `AUTO_INCREMENT` or `SERIAL` directly.
- Standard `SELECT ... FOR UPDATE` row locks built with GORM query methods in `model/` MUST use `lockForUpdate(tx)`. Do not use the legacy GORM v1 pattern `tx.Set("gorm:query_option", "FOR UPDATE")`, because GORM v2 silently ignores it and no lock is acquired. Do not duplicate `clause.Locking{Strength: "UPDATE"}` at call sites; the shared helper emits `FOR UPDATE` for MySQL/PostgreSQL and skips it for SQLite, where the syntax is unsupported. Dialect-specific locking with different semantics (for example, a MySQL next-key/gap lock) may use raw SQL only behind explicit database-type branches with valid fallbacks for every supported database.
diff --git a/model/main.go b/model/main.go
index dd2920b0a31b..fa61ea94f05e 100644
--- a/model/main.go
+++ b/model/main.go
@@ -315,6 +315,9 @@ func is64BitIntegerType(dbType common.DatabaseType, dataType string) bool {
}
func migrateDB() error {
+ if err := migratePrefillGroupUniqueness(DB); err != nil {
+ return err
+ }
// Migrate price_amount column from float/double to decimal for existing tables
migrateSubscriptionPlanPriceAmount()
// Migrate model_limits column from varchar to text for existing tables
@@ -380,89 +383,6 @@ func migrateDB() error {
return nil
}
-func migrateDBFast() error {
-
- var wg sync.WaitGroup
-
- migrations := []struct {
- model interface{}
- name string
- }{
- {&Channel{}, "Channel"},
- {&Token{}, "Token"},
- {&User{}, "User"},
- {&UserSession{}, "UserSession"},
- {&AuthFlow{}, "AuthFlow"},
- {&ExternalIdentityClaim{}, "ExternalIdentityClaim"},
- {&PasskeyCredential{}, "PasskeyCredential"},
- {&Option{}, "Option"},
- {&LoginEncryptionKey{}, "LoginEncryptionKey"},
- {&Redemption{}, "Redemption"},
- {&Ability{}, "Ability"},
- {&Log{}, "Log"},
- {&Midjourney{}, "Midjourney"},
- {&TopUp{}, "TopUp"},
- {&QuotaData{}, "QuotaData"},
- {&Task{}, "Task"},
- {&Model{}, "Model"},
- {&Vendor{}, "Vendor"},
- {&PrefillGroup{}, "PrefillGroup"},
- {&Setup{}, "Setup"},
- {&TwoFA{}, "TwoFA"},
- {&TwoFABackupCode{}, "TwoFABackupCode"},
- {&Checkin{}, "Checkin"},
- {&SubscriptionOrder{}, "SubscriptionOrder"},
- {&UserSubscription{}, "UserSubscription"},
- {&SubscriptionPreConsumeRecord{}, "SubscriptionPreConsumeRecord"},
- {&CustomOAuthProvider{}, "CustomOAuthProvider"},
- {&UserOAuthBinding{}, "UserOAuthBinding"},
- {&PerfMetric{}, "PerfMetric"},
- {&SystemInstance{}, "SystemInstance"},
- {&SystemTask{}, "SystemTask"},
- {&SystemTaskLock{}, "SystemTaskLock"},
- }
- // 动态计算migration数量,确保errChan缓冲区足够大
- errChan := make(chan error, len(migrations))
-
- for _, m := range migrations {
- wg.Add(1)
- go func(model interface{}, name string) {
- defer wg.Done()
- if err := DB.AutoMigrate(model); err != nil {
- errChan <- fmt.Errorf("failed to migrate %s: %v", name, err)
- }
- }(m.model, m.name)
- }
-
- // Wait for all migrations to complete
- wg.Wait()
- close(errChan)
-
- // Check for any errors
- for err := range errChan {
- if err != nil {
- return err
- }
- }
- if err := InitializeUserAuthVersions(); err != nil {
- return err
- }
- if err := InitializeExternalIdentityClaims(); err != nil {
- return err
- }
- if common.UsingMainDatabase(common.DatabaseTypeSQLite) {
- if err := ensureSubscriptionPlanTableSQLite(); err != nil {
- return err
- }
- } else {
- if err := DB.AutoMigrate(&SubscriptionPlan{}); err != nil {
- return err
- }
- }
- common.SysLog("database migrated")
- return nil
-}
-
func migrateLOGDB() error {
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
return migrateClickHouseLogDB()
diff --git a/model/prefill_group_migration.go b/model/prefill_group_migration.go
new file mode 100644
index 000000000000..72cdc03fcbb0
--- /dev/null
+++ b/model/prefill_group_migration.go
@@ -0,0 +1,214 @@
+package model
+
+import (
+ "fmt"
+
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+const prefillGroupNameIndex = "uk_prefill_name"
+const legacyPrefillGroupNameUnique = "idx_prefill_groups_name"
+
+type conflictingPrefillGroupUniqueness struct {
+ constraints []string
+ indexes []string
+}
+
+type prefillGroupNameIndexState struct {
+ exists bool
+ valid bool
+}
+
+func (conflicts conflictingPrefillGroupUniqueness) empty() bool {
+ return len(conflicts.constraints) == 0 && len(conflicts.indexes) == 0
+}
+
+func (conflicts conflictingPrefillGroupUniqueness) validateAutomaticMigrationScope() error {
+ unexpectedConstraints := make([]string, 0)
+ for _, name := range conflicts.constraints {
+ if name != legacyPrefillGroupNameUnique {
+ unexpectedConstraints = append(unexpectedConstraints, name)
+ }
+ }
+ unexpectedIndexes := make([]string, 0)
+ for _, name := range conflicts.indexes {
+ if name != legacyPrefillGroupNameUnique {
+ unexpectedIndexes = append(unexpectedIndexes, name)
+ }
+ }
+ if len(unexpectedConstraints) == 0 && len(unexpectedIndexes) == 0 {
+ return nil
+ }
+ return fmt.Errorf(
+ "prefill_groups.name has unsupported global unique constraints %q and indexes %q; only legacy object %q can be migrated automatically to partial uniqueness",
+ unexpectedConstraints,
+ unexpectedIndexes,
+ legacyPrefillGroupNameUnique,
+ )
+}
+
+func inspectConflictingPrefillGroupUniqueness(db *gorm.DB, tableName string) (conflictingPrefillGroupUniqueness, error) {
+ var conflicts conflictingPrefillGroupUniqueness
+ if err := db.Raw(`
+SELECT constraint_meta.conname
+FROM pg_catalog.pg_constraint AS constraint_meta
+WHERE constraint_meta.conrelid = to_regclass(?)
+ AND constraint_meta.contype = 'u'
+ AND cardinality(constraint_meta.conkey) = 1
+ AND EXISTS (
+ SELECT 1
+ FROM pg_catalog.pg_attribute AS attribute_meta
+ WHERE attribute_meta.attrelid = constraint_meta.conrelid
+ AND attribute_meta.attnum = constraint_meta.conkey[1]
+ AND attribute_meta.attname = ?
+ )
+ORDER BY constraint_meta.conname`, tableName, "name").Scan(&conflicts.constraints).Error; err != nil {
+ return conflicts, fmt.Errorf("inspect conflicting prefill group unique constraints: %w", err)
+ }
+
+ if err := db.Raw(`
+SELECT index_class.relname
+FROM pg_catalog.pg_index AS index_meta
+JOIN pg_catalog.pg_class AS index_class
+ ON index_class.oid = index_meta.indexrelid
+JOIN pg_catalog.pg_attribute AS attribute_meta
+ ON attribute_meta.attrelid = index_meta.indrelid
+ AND attribute_meta.attnum = index_meta.indkey[0]
+WHERE index_meta.indrelid = to_regclass(?)
+ AND index_meta.indisunique
+ AND NOT index_meta.indisprimary
+ AND index_meta.indpred IS NULL
+ AND index_meta.indexprs IS NULL
+ AND index_meta.indnatts = 1
+ AND attribute_meta.attname = ?
+ AND NOT EXISTS (
+ SELECT 1
+ FROM pg_catalog.pg_constraint AS constraint_meta
+ WHERE constraint_meta.conindid = index_meta.indexrelid
+ )
+ORDER BY index_class.relname`, tableName, "name").Scan(&conflicts.indexes).Error; err != nil {
+ return conflicts, fmt.Errorf("inspect conflicting prefill group unique indexes: %w", err)
+ }
+
+ return conflicts, nil
+}
+
+func inspectPrefillGroupNameIndex(db *gorm.DB, tableName string) (prefillGroupNameIndexState, error) {
+ var state struct {
+ Exists bool `gorm:"column:index_exists"`
+ Valid bool `gorm:"column:index_valid"`
+ }
+ if err := db.Raw(`
+SELECT count(*) > 0 AS index_exists,
+ COALESCE(bool_or(
+ index_meta.indisunique
+ AND index_meta.indisvalid
+ AND index_meta.indisready
+ AND NOT index_meta.indisprimary
+ AND index_meta.indexprs IS NULL
+ AND index_meta.indnatts = 1
+ AND attribute_meta.attname = ?
+ AND pg_get_expr(index_meta.indpred, index_meta.indrelid) = '(deleted_at IS NULL)'
+ ), false) AS index_valid
+FROM pg_catalog.pg_index AS index_meta
+JOIN pg_catalog.pg_class AS index_class
+ ON index_class.oid = index_meta.indexrelid
+LEFT JOIN pg_catalog.pg_attribute AS attribute_meta
+ ON attribute_meta.attrelid = index_meta.indrelid
+ AND attribute_meta.attnum = index_meta.indkey[0]
+WHERE index_meta.indrelid = to_regclass(?)
+ AND index_class.relname = ?`, "name", tableName, prefillGroupNameIndex).Scan(&state).Error; err != nil {
+ return prefillGroupNameIndexState{}, fmt.Errorf("inspect prefill group partial unique index: %w", err)
+ }
+ return prefillGroupNameIndexState{exists: state.Exists, valid: state.Valid}, nil
+}
+
+// migratePrefillGroupUniqueness replaces the known global PostgreSQL unique
+// object left by older GORM versions before AutoMigrate inspects the column.
+// Unknown conflicting objects are reported without being modified.
+func migratePrefillGroupUniqueness(db *gorm.DB) error {
+ if db == nil {
+ return fmt.Errorf("migrate prefill group uniqueness: database is nil")
+ }
+ if db.Dialector.Name() != "postgres" {
+ return nil
+ }
+
+ statement := &gorm.Statement{DB: db}
+ if err := statement.Parse(&PrefillGroup{}); err != nil {
+ return fmt.Errorf("parse prefill group schema: %w", err)
+ }
+ tableName := statement.Schema.Table
+ conflicts, err := inspectConflictingPrefillGroupUniqueness(db, tableName)
+ if err != nil {
+ return err
+ }
+ if conflicts.empty() {
+ return nil
+ }
+ if err := conflicts.validateAutomaticMigrationScope(); err != nil {
+ return err
+ }
+
+ return db.Transaction(func(tx *gorm.DB) error {
+ migrator := tx.Migrator()
+ if !migrator.HasTable(&PrefillGroup{}) {
+ return nil
+ }
+
+ if err := tx.Exec(
+ "LOCK TABLE ? IN ACCESS EXCLUSIVE MODE",
+ clause.Table{Name: tableName},
+ ).Error; err != nil {
+ return fmt.Errorf("lock prefill groups for uniqueness migration: %w", err)
+ }
+
+ conflicts, err := inspectConflictingPrefillGroupUniqueness(tx, tableName)
+ if err != nil {
+ return err
+ }
+ if conflicts.empty() {
+ return nil
+ }
+ if err := conflicts.validateAutomaticMigrationScope(); err != nil {
+ return err
+ }
+
+ if !migrator.HasColumn(&PrefillGroup{}, "DeletedAt") {
+ if err := migrator.AddColumn(&PrefillGroup{}, "DeletedAt"); err != nil {
+ return fmt.Errorf("add prefill groups deleted_at column: %w", err)
+ }
+ }
+
+ targetIndex, err := inspectPrefillGroupNameIndex(tx, tableName)
+ if err != nil {
+ return err
+ }
+ if !targetIndex.exists {
+ if err := migrator.CreateIndex(&PrefillGroup{}, prefillGroupNameIndex); err != nil {
+ return fmt.Errorf("create prefill group partial unique index: %w", err)
+ }
+ targetIndex, err = inspectPrefillGroupNameIndex(tx, tableName)
+ if err != nil {
+ return err
+ }
+ }
+ if !targetIndex.valid {
+ return fmt.Errorf("prefill group index %q has an unexpected definition", prefillGroupNameIndex)
+ }
+
+ for _, constraintName := range conflicts.constraints {
+ if err := migrator.DropConstraint(&PrefillGroup{}, constraintName); err != nil {
+ return fmt.Errorf("drop conflicting prefill group constraint %q: %w", constraintName, err)
+ }
+ }
+ for _, indexName := range conflicts.indexes {
+ if err := migrator.DropIndex(&PrefillGroup{}, indexName); err != nil {
+ return fmt.Errorf("drop conflicting prefill group index %q: %w", indexName, err)
+ }
+ }
+
+ return nil
+ })
+}
diff --git a/model/prefill_group_migration_test.go b/model/prefill_group_migration_test.go
new file mode 100644
index 000000000000..38ef90282fa0
--- /dev/null
+++ b/model/prefill_group_migration_test.go
@@ -0,0 +1,303 @@
+package model
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/driver/mysql"
+ "gorm.io/driver/postgres"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+func testPrefillGroupMigrationNonPostgreSQL(t *testing.T, db *gorm.DB) {
+ t.Helper()
+ tableName := fmt.Sprintf("prefill_group_migration_%d", time.Now().UnixNano())
+ t.Cleanup(func() { _ = db.Migrator().DropTable(tableName) })
+
+ tableDB := db.Table(tableName)
+ require.NoError(t, tableDB.AutoMigrate(&PrefillGroup{}))
+ require.NoError(t, tableDB.Create(&PrefillGroup{
+ Name: "preserved-name",
+ Type: "model",
+ Items: JSONValue(`["gpt-test"]`),
+ Description: "preserve me",
+ }).Error)
+
+ for range 2 {
+ require.NoError(t, migratePrefillGroupUniqueness(db))
+ require.NoError(t, tableDB.AutoMigrate(&PrefillGroup{}))
+ }
+
+ var preserved PrefillGroup
+ require.NoError(t, tableDB.Where("name = ?", "preserved-name").First(&preserved).Error)
+ assert.Equal(t, "preserve me", preserved.Description)
+ assert.True(t, tableDB.Migrator().HasIndex(&PrefillGroup{}, prefillGroupNameIndex))
+}
+
+func TestMigratePrefillGroupUniquenessSQLite(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ require.NoError(t, err)
+ testPrefillGroupMigrationNonPostgreSQL(t, db)
+}
+
+func TestMigratePrefillGroupUniquenessMySQL(t *testing.T) {
+ dsn := strings.TrimSpace(os.Getenv("TEST_MYSQL_DSN"))
+ if dsn == "" {
+ t.Skip("TEST_MYSQL_DSN is not configured")
+ }
+
+ db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
+ require.NoError(t, err)
+ sqlDB, err := db.DB()
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
+ testPrefillGroupMigrationNonPostgreSQL(t, db)
+}
+
+func TestMigratePrefillGroupUniquenessPostgreSQL(t *testing.T) {
+ dsn := strings.TrimSpace(os.Getenv("TEST_POSTGRES_DSN"))
+ if dsn == "" {
+ t.Skip("TEST_POSTGRES_DSN is not configured")
+ }
+
+ db, err := gorm.Open(postgres.New(postgres.Config{
+ DSN: dsn,
+ PreferSimpleProtocol: true,
+ }), &gorm.Config{})
+ require.NoError(t, err)
+ sqlDB, err := db.DB()
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
+
+ tests := []struct {
+ name string
+ prepareOld func(*testing.T, *gorm.DB)
+ blockedConstraints []string
+ blockedIndexes []string
+ preservedIndexes []string
+ }{
+ {name: "fresh"},
+ {
+ name: "legacy_constraint",
+ prepareOld: func(t *testing.T, tx *gorm.DB) {
+ t.Helper()
+ require.NoError(t, tx.Exec(
+ "ALTER TABLE ? ADD CONSTRAINT ? UNIQUE (?)",
+ clause.Table{Name: "prefill_groups"},
+ clause.Column{Name: legacyPrefillGroupNameUnique},
+ clause.Column{Name: "name"},
+ ).Error)
+ },
+ },
+ {
+ name: "legacy_standalone_index",
+ prepareOld: func(t *testing.T, tx *gorm.DB) {
+ t.Helper()
+ require.NoError(t, tx.Migrator().DropIndex(&PrefillGroup{}, prefillGroupNameIndex))
+ require.NoError(t, tx.Exec(
+ "CREATE UNIQUE INDEX ? ON ? (?)",
+ clause.Column{Name: legacyPrefillGroupNameUnique},
+ clause.Table{Name: "prefill_groups"},
+ clause.Column{Name: "name"},
+ ).Error)
+ },
+ },
+ {
+ name: "arbitrary_constraint_name",
+ prepareOld: func(t *testing.T, tx *gorm.DB) {
+ t.Helper()
+ for _, constraintName := range []string{
+ legacyPrefillGroupNameUnique,
+ "prefill_groups_name_key",
+ } {
+ require.NoError(t, tx.Exec(
+ "ALTER TABLE ? ADD CONSTRAINT ? UNIQUE (?)",
+ clause.Table{Name: "prefill_groups"},
+ clause.Column{Name: constraintName},
+ clause.Column{Name: "name"},
+ ).Error)
+ }
+ },
+ blockedConstraints: []string{legacyPrefillGroupNameUnique, "prefill_groups_name_key"},
+ },
+ {
+ name: "arbitrary_index_name",
+ prepareOld: func(t *testing.T, tx *gorm.DB) {
+ t.Helper()
+ for _, indexName := range []string{
+ legacyPrefillGroupNameUnique,
+ "prefill_groups_name_key",
+ } {
+ require.NoError(t, tx.Exec(
+ "CREATE UNIQUE INDEX ? ON ? (?)",
+ clause.Column{Name: indexName},
+ clause.Table{Name: "prefill_groups"},
+ clause.Column{Name: "name"},
+ ).Error)
+ }
+ },
+ blockedIndexes: []string{legacyPrefillGroupNameUnique, "prefill_groups_name_key"},
+ },
+ {
+ name: "non_conflicting_indexes_are_preserved",
+ prepareOld: func(t *testing.T, tx *gorm.DB) {
+ t.Helper()
+ require.NoError(t, tx.Exec(
+ "ALTER TABLE ? ADD CONSTRAINT ? UNIQUE (?)",
+ clause.Table{Name: "prefill_groups"},
+ clause.Column{Name: legacyPrefillGroupNameUnique},
+ clause.Column{Name: "name"},
+ ).Error)
+ require.NoError(t, tx.Exec(
+ "CREATE UNIQUE INDEX ? ON ? (?, ?)",
+ clause.Column{Name: "keep_prefill_name_deleted_at"},
+ clause.Table{Name: "prefill_groups"},
+ clause.Column{Name: "name"},
+ clause.Column{Name: "deleted_at"},
+ ).Error)
+ require.NoError(t, tx.Exec(
+ "CREATE UNIQUE INDEX ? ON ? (lower(?)) WHERE deleted_at IS NULL",
+ clause.Column{Name: "keep_prefill_lower_name"},
+ clause.Table{Name: "prefill_groups"},
+ clause.Column{Name: "name"},
+ ).Error)
+ require.NoError(t, tx.Exec(
+ "CREATE UNIQUE INDEX ? ON ? (?) WHERE deleted_at IS NOT NULL",
+ clause.Column{Name: "keep_prefill_deleted_name"},
+ clause.Table{Name: "prefill_groups"},
+ clause.Column{Name: "name"},
+ ).Error)
+ },
+ preservedIndexes: []string{
+ "keep_prefill_name_deleted_at",
+ "keep_prefill_lower_name",
+ "keep_prefill_deleted_name",
+ },
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ tx := db.Begin()
+ require.NoError(t, tx.Error)
+ t.Cleanup(func() { _ = tx.Rollback().Error })
+
+ schemaName := fmt.Sprintf("prefill_group_migration_%d", time.Now().UnixNano())
+ require.NoError(t, tx.Exec(
+ "CREATE SCHEMA ?",
+ clause.Table{Name: schemaName},
+ ).Error)
+ require.NoError(t, tx.Exec(
+ "SET LOCAL search_path TO ?",
+ clause.Table{Name: schemaName},
+ ).Error)
+
+ require.NoError(t, migratePrefillGroupUniqueness(tx))
+ require.NoError(t, tx.AutoMigrate(&PrefillGroup{}))
+ original := PrefillGroup{
+ Name: "shared-name",
+ Type: "model",
+ Items: JSONValue(`["gpt-test"]`),
+ Description: "preserve me",
+ }
+ require.NoError(t, tx.Create(&original).Error)
+ if test.prepareOld != nil {
+ test.prepareOld(t, tx)
+ }
+ if len(test.blockedConstraints) > 0 || len(test.blockedIndexes) > 0 {
+ err := migratePrefillGroupUniqueness(tx)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "prefill_groups_name_key")
+ for _, constraintName := range test.blockedConstraints {
+ assert.True(t, tx.Migrator().HasConstraint(&PrefillGroup{}, constraintName))
+ }
+ for _, indexName := range test.blockedIndexes {
+ assert.True(t, tx.Migrator().HasIndex(&PrefillGroup{}, indexName))
+ }
+ return
+ }
+
+ for range 2 {
+ require.NoError(t, migratePrefillGroupUniqueness(tx))
+ require.NoError(t, tx.AutoMigrate(&PrefillGroup{}))
+ }
+ for _, indexName := range test.preservedIndexes {
+ assert.True(t, tx.Migrator().HasIndex(&PrefillGroup{}, indexName))
+ }
+
+ var preserved PrefillGroup
+ require.NoError(t, tx.First(&preserved, original.Id).Error)
+ assert.Equal(t, original.Name, preserved.Name)
+ assert.Equal(t, original.Description, preserved.Description)
+
+ var globalConstraintCount int64
+ require.NoError(t, tx.Raw(`
+SELECT count(*)
+FROM pg_catalog.pg_constraint AS constraint_meta
+WHERE constraint_meta.conrelid = to_regclass('prefill_groups')
+ AND constraint_meta.contype = 'u'
+ AND cardinality(constraint_meta.conkey) = 1
+ AND EXISTS (
+ SELECT 1
+ FROM pg_catalog.pg_attribute AS attribute_meta
+ WHERE attribute_meta.attrelid = constraint_meta.conrelid
+ AND attribute_meta.attnum = constraint_meta.conkey[1]
+ AND attribute_meta.attname = 'name'
+ )`).Scan(&globalConstraintCount).Error)
+ assert.Zero(t, globalConstraintCount)
+
+ var globalIndexCount int64
+ require.NoError(t, tx.Raw(`
+SELECT count(*)
+FROM pg_catalog.pg_index AS index_meta
+JOIN pg_catalog.pg_attribute AS attribute_meta
+ ON attribute_meta.attrelid = index_meta.indrelid
+ AND attribute_meta.attnum = index_meta.indkey[0]
+WHERE index_meta.indrelid = to_regclass('prefill_groups')
+ AND index_meta.indisunique
+ AND NOT index_meta.indisprimary
+ AND index_meta.indpred IS NULL
+ AND index_meta.indexprs IS NULL
+ AND index_meta.indnatts = 1
+ AND attribute_meta.attname = 'name'`).Scan(&globalIndexCount).Error)
+ assert.Zero(t, globalIndexCount)
+
+ var targetIndexDefinition string
+ require.NoError(t, tx.Raw(`
+SELECT indexdef
+FROM pg_catalog.pg_indexes
+WHERE schemaname = current_schema()
+ AND tablename = 'prefill_groups'
+ AND indexname = ?`, prefillGroupNameIndex).Scan(&targetIndexDefinition).Error)
+ assert.Contains(t, strings.ToLower(targetIndexDefinition), "unique index")
+ assert.Contains(t, strings.ToLower(targetIndexDefinition), "where (deleted_at is null)")
+
+ duplicateError := tx.Transaction(func(duplicateTx *gorm.DB) error {
+ return duplicateTx.Create(&PrefillGroup{
+ Name: original.Name,
+ Type: "model",
+ Items: JSONValue(`[]`),
+ }).Error
+ })
+ require.Error(t, duplicateError)
+
+ require.NoError(t, tx.Delete(&original).Error)
+ require.NoError(t, tx.Create(&PrefillGroup{
+ Name: original.Name,
+ Type: "model",
+ Items: JSONValue(`[]`),
+ }).Error)
+
+ var totalRows int64
+ require.NoError(t, tx.Unscoped().Model(&PrefillGroup{}).Count(&totalRows).Error)
+ assert.EqualValues(t, 2, totalRows)
+ })
+ }
+}
From 8c8c4153d4b80d54352d21593de41aa9a6178f7e Mon Sep 17 00:00:00 2001
From: jimmyleocn <42566991+jimmyleocn@users.noreply.github.com>
Date: Mon, 31 Aug 2026 11:36:43 +0800
Subject: [PATCH 79/99] fix(log): preserve quota in usage statistics (#7108)
* fix(log): preserve quota in usage statistics
---
model/log.go | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/model/log.go b/model/log.go
index ea313589309a..7b908a6eecd6 100644
--- a/model/log.go
+++ b/model/log.go
@@ -678,10 +678,16 @@ func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelNa
common.SysError("failed to query log stat: " + err.Error())
return stat, errors.New("查询统计数据失败")
}
- if err := rpmTpmQuery.Scan(&stat).Error; err != nil {
+ var rateStat struct {
+ Rpm int
+ Tpm int
+ }
+ if err := rpmTpmQuery.Scan(&rateStat).Error; err != nil {
common.SysError("failed to query rpm/tpm stat: " + err.Error())
return stat, errors.New("查询统计数据失败")
}
+ stat.Rpm = rateStat.Rpm
+ stat.Tpm = rateStat.Tpm
return stat, nil
}
From 27ff6a8767e728f879d52770c273d4f73214a430 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Mon, 31 Aug 2026 11:51:38 +0800
Subject: [PATCH 80/99] fix(model): migrate legacy token key constraints
---
model/main.go | 3 +
model/token_migration.go | 217 +++++++++++++++++++++++++
model/token_migration_test.go | 295 ++++++++++++++++++++++++++++++++++
3 files changed, 515 insertions(+)
create mode 100644 model/token_migration.go
create mode 100644 model/token_migration_test.go
diff --git a/model/main.go b/model/main.go
index fa61ea94f05e..3a70622b21b3 100644
--- a/model/main.go
+++ b/model/main.go
@@ -315,6 +315,9 @@ func is64BitIntegerType(dbType common.DatabaseType, dataType string) bool {
}
func migrateDB() error {
+ if err := migrateTokenKeyUniqueness(DB); err != nil {
+ return err
+ }
if err := migratePrefillGroupUniqueness(DB); err != nil {
return err
}
diff --git a/model/token_migration.go b/model/token_migration.go
new file mode 100644
index 000000000000..c289652ef3d4
--- /dev/null
+++ b/model/token_migration.go
@@ -0,0 +1,217 @@
+package model
+
+import (
+ "fmt"
+ "strings"
+
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+const (
+ tokenKeyIndex = "idx_tokens_key"
+ postgresTokenKeyConstraint = "tokens_key_key"
+ gormTokenKeyConstraint = "uni_tokens_key"
+)
+
+type tokenKeyUniqueConstraint struct {
+ Name string `gorm:"column:constraint_name"`
+ Definition string `gorm:"column:constraint_definition"`
+ Deferrable bool `gorm:"column:is_deferrable"`
+ Validated bool `gorm:"column:is_validated"`
+}
+
+type tokenKeyIndexState struct {
+ exists bool
+ definitionValid bool
+ standaloneValid bool
+}
+
+func inspectTokenKeyUniqueConstraints(db *gorm.DB, tableName string) ([]tokenKeyUniqueConstraint, error) {
+ var constraints []tokenKeyUniqueConstraint
+ if err := db.Raw(`
+SELECT constraint_meta.conname AS constraint_name,
+ pg_get_constraintdef(constraint_meta.oid) AS constraint_definition,
+ constraint_meta.condeferrable AS is_deferrable,
+ constraint_meta.convalidated AS is_validated
+FROM pg_catalog.pg_constraint AS constraint_meta
+WHERE constraint_meta.conrelid = to_regclass(?)
+ AND constraint_meta.contype = 'u'
+ AND cardinality(constraint_meta.conkey) = 1
+ AND EXISTS (
+ SELECT 1
+ FROM pg_catalog.pg_attribute AS attribute_meta
+ WHERE attribute_meta.attrelid = constraint_meta.conrelid
+ AND attribute_meta.attnum = constraint_meta.conkey[1]
+ AND attribute_meta.attname = ?
+ )
+ORDER BY constraint_meta.conname`, tableName, "key").Scan(&constraints).Error; err != nil {
+ return nil, fmt.Errorf("inspect token key unique constraints: %w", err)
+ }
+ return constraints, nil
+}
+
+func validateTokenKeyUniqueConstraints(constraints []tokenKeyUniqueConstraint) error {
+ for _, constraint := range constraints {
+ switch constraint.Name {
+ case tokenKeyIndex, postgresTokenKeyConstraint, gormTokenKeyConstraint:
+ default:
+ return fmt.Errorf(
+ "tokens.key has unsupported unique constraint %q with definition %q",
+ constraint.Name,
+ constraint.Definition,
+ )
+ }
+ if constraint.Deferrable || !constraint.Validated || strings.Contains(strings.ToUpper(constraint.Definition), "NULLS NOT DISTINCT") {
+ return fmt.Errorf(
+ "tokens.key unique constraint %q has unsupported definition %q",
+ constraint.Name,
+ constraint.Definition,
+ )
+ }
+ }
+ return nil
+}
+
+func inspectTokenKeyIndex(db *gorm.DB, tableName string) (tokenKeyIndexState, error) {
+ var state struct {
+ Exists bool `gorm:"column:index_exists"`
+ DefinitionValid bool `gorm:"column:definition_valid"`
+ StandaloneValid bool `gorm:"column:standalone_valid"`
+ }
+ if err := db.Raw(`
+SELECT count(*) > 0 AS index_exists,
+ COALESCE(bool_or(
+ index_meta.indisunique
+ AND index_meta.indisvalid
+ AND index_meta.indisready
+ AND NOT index_meta.indisprimary
+ AND index_meta.indpred IS NULL
+ AND index_meta.indexprs IS NULL
+ AND index_meta.indnatts = 1
+ AND attribute_meta.attname = ?
+ ), false) AS definition_valid,
+ COALESCE(bool_or(
+ index_meta.indisunique
+ AND index_meta.indisvalid
+ AND index_meta.indisready
+ AND NOT index_meta.indisprimary
+ AND index_meta.indpred IS NULL
+ AND index_meta.indexprs IS NULL
+ AND index_meta.indnatts = 1
+ AND attribute_meta.attname = ?
+ AND NOT EXISTS (
+ SELECT 1
+ FROM pg_catalog.pg_constraint AS constraint_meta
+ WHERE constraint_meta.conindid = index_meta.indexrelid
+ )
+ ), false) AS standalone_valid
+FROM pg_catalog.pg_index AS index_meta
+JOIN pg_catalog.pg_class AS index_class
+ ON index_class.oid = index_meta.indexrelid
+LEFT JOIN pg_catalog.pg_attribute AS attribute_meta
+ ON attribute_meta.attrelid = index_meta.indrelid
+ AND attribute_meta.attnum = index_meta.indkey[0]
+WHERE index_meta.indrelid = to_regclass(?)
+ AND index_class.relname = ?`, "key", "key", tableName, tokenKeyIndex).Scan(&state).Error; err != nil {
+ return tokenKeyIndexState{}, fmt.Errorf("inspect token key unique index: %w", err)
+ }
+ return tokenKeyIndexState{
+ exists: state.Exists,
+ definitionValid: state.DefinitionValid,
+ standaloneValid: state.StandaloneValid,
+ }, nil
+}
+
+// migrateTokenKeyUniqueness converts known PostgreSQL UNIQUE constraints left
+// on tokens.key into the standalone uniqueIndex represented by the current
+// model. Unknown constraint names are reported without modifying the schema.
+func migrateTokenKeyUniqueness(db *gorm.DB) error {
+ if db == nil {
+ return fmt.Errorf("migrate token key uniqueness: database is nil")
+ }
+ if db.Dialector.Name() != "postgres" {
+ return nil
+ }
+
+ statement := &gorm.Statement{DB: db}
+ if err := statement.Parse(&Token{}); err != nil {
+ return fmt.Errorf("parse token schema: %w", err)
+ }
+ tableName := statement.Schema.Table
+ constraints, err := inspectTokenKeyUniqueConstraints(db, tableName)
+ if err != nil {
+ return err
+ }
+ if len(constraints) == 0 {
+ return nil
+ }
+ if err := validateTokenKeyUniqueConstraints(constraints); err != nil {
+ return err
+ }
+
+ return db.Transaction(func(tx *gorm.DB) error {
+ migrator := tx.Migrator()
+ if !migrator.HasTable(&Token{}) {
+ return nil
+ }
+
+ if err := tx.Exec(
+ "LOCK TABLE ? IN ACCESS EXCLUSIVE MODE",
+ clause.Table{Name: tableName},
+ ).Error; err != nil {
+ return fmt.Errorf("lock tokens for key uniqueness migration: %w", err)
+ }
+
+ constraints, err := inspectTokenKeyUniqueConstraints(tx, tableName)
+ if err != nil {
+ return err
+ }
+ if len(constraints) == 0 {
+ return nil
+ }
+ if err := validateTokenKeyUniqueConstraints(constraints); err != nil {
+ return err
+ }
+
+ targetIndex, err := inspectTokenKeyIndex(tx, tableName)
+ if err != nil {
+ return err
+ }
+ if targetIndex.exists && !targetIndex.definitionValid {
+ return fmt.Errorf("token key index %q has an unexpected definition", tokenKeyIndex)
+ }
+
+ for _, constraint := range constraints {
+ if err := migrator.DropConstraint(&Token{}, constraint.Name); err != nil {
+ return fmt.Errorf("drop token key unique constraint %q: %w", constraint.Name, err)
+ }
+ }
+
+ targetIndex, err = inspectTokenKeyIndex(tx, tableName)
+ if err != nil {
+ return err
+ }
+ if !targetIndex.exists {
+ if err := migrator.CreateIndex(&Token{}, tokenKeyIndex); err != nil {
+ return fmt.Errorf("create token key unique index: %w", err)
+ }
+ targetIndex, err = inspectTokenKeyIndex(tx, tableName)
+ if err != nil {
+ return err
+ }
+ }
+ if !targetIndex.standaloneValid {
+ return fmt.Errorf("token key index %q has an unexpected definition", tokenKeyIndex)
+ }
+
+ remainingConstraints, err := inspectTokenKeyUniqueConstraints(tx, tableName)
+ if err != nil {
+ return err
+ }
+ if len(remainingConstraints) != 0 {
+ return fmt.Errorf("tokens.key still has unique constraints after migration")
+ }
+ return nil
+ })
+}
diff --git a/model/token_migration_test.go b/model/token_migration_test.go
new file mode 100644
index 000000000000..f777dec8d0ca
--- /dev/null
+++ b/model/token_migration_test.go
@@ -0,0 +1,295 @@
+package model
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/driver/mysql"
+ "gorm.io/driver/postgres"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+func requireTokenConstraintExists(t *testing.T, db *gorm.DB, constraintName string) {
+ t.Helper()
+ var count int64
+ require.NoError(t, db.Raw(`
+SELECT count(*)
+FROM pg_catalog.pg_constraint
+WHERE conrelid = to_regclass(?)
+ AND conname = ?`, "tokens", constraintName).Scan(&count).Error)
+ require.EqualValues(t, 1, count)
+}
+
+func requireTokenIndexExists(t *testing.T, db *gorm.DB, indexName string) {
+ t.Helper()
+ var count int64
+ require.NoError(t, db.Raw(`
+SELECT count(*)
+FROM pg_catalog.pg_index AS index_meta
+JOIN pg_catalog.pg_class AS index_class
+ ON index_class.oid = index_meta.indexrelid
+WHERE index_meta.indrelid = to_regclass(?)
+ AND index_class.relname = ?`, "tokens", indexName).Scan(&count).Error)
+ require.EqualValues(t, 1, count)
+}
+
+func testTokenKeyMigrationNonPostgreSQL(t *testing.T, db *gorm.DB) {
+ t.Helper()
+ tableName := fmt.Sprintf("token_migration_%d", time.Now().UnixNano())
+ t.Cleanup(func() { _ = db.Migrator().DropTable(tableName) })
+
+ tableDB := db.Table(tableName)
+ require.NoError(t, tableDB.AutoMigrate(&Token{}))
+ require.NoError(t, tableDB.Create(&Token{UserId: 1, Key: "preserved-key"}).Error)
+
+ for range 2 {
+ require.NoError(t, migrateTokenKeyUniqueness(db))
+ require.NoError(t, tableDB.AutoMigrate(&Token{}))
+ }
+
+ var preserved Token
+ require.NoError(t, tableDB.Where(&Token{Key: "preserved-key"}).First(&preserved).Error)
+ assert.Equal(t, 1, preserved.UserId)
+ expectedIndex := db.NamingStrategy.IndexName(tableName, "key")
+ assert.True(t, db.Migrator().HasIndex(tableName, expectedIndex))
+}
+
+func TestMigrateTokenKeyUniquenessSQLite(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ require.NoError(t, err)
+ testTokenKeyMigrationNonPostgreSQL(t, db)
+}
+
+func TestMigrateTokenKeyUniquenessMySQL(t *testing.T) {
+ dsn := strings.TrimSpace(os.Getenv("TEST_MYSQL_DSN"))
+ if dsn == "" {
+ t.Skip("TEST_MYSQL_DSN is not configured")
+ }
+
+ db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
+ require.NoError(t, err)
+ sqlDB, err := db.DB()
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
+ testTokenKeyMigrationNonPostgreSQL(t, db)
+}
+
+func TestMigrateTokenKeyUniquenessPostgreSQL(t *testing.T) {
+ dsn := strings.TrimSpace(os.Getenv("TEST_POSTGRES_DSN"))
+ if dsn == "" {
+ t.Skip("TEST_POSTGRES_DSN is not configured")
+ }
+
+ db, err := gorm.Open(postgres.New(postgres.Config{
+ DSN: dsn,
+ PreferSimpleProtocol: true,
+ }), &gorm.Config{})
+ require.NoError(t, err)
+ sqlDB, err := db.DB()
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
+
+ tests := []struct {
+ name string
+ prepareOld func(*testing.T, *gorm.DB)
+ expectedError string
+ preservedConstraints []string
+ preservedIndexes []string
+ }{
+ {name: "fresh"},
+ {
+ name: "legacy_idx_constraint",
+ prepareOld: func(t *testing.T, tx *gorm.DB) {
+ t.Helper()
+ require.NoError(t, tx.Migrator().DropIndex(&Token{}, tokenKeyIndex))
+ require.NoError(t, tx.Exec(
+ "ALTER TABLE ? ADD CONSTRAINT ? UNIQUE (?)",
+ clause.Table{Name: "tokens"},
+ clause.Column{Name: tokenKeyIndex},
+ clause.Column{Name: "key"},
+ ).Error)
+ },
+ },
+ {
+ name: "gorm_generated_constraint",
+ prepareOld: func(t *testing.T, tx *gorm.DB) {
+ t.Helper()
+ require.NoError(t, tx.Exec(
+ "ALTER TABLE ? ADD CONSTRAINT ? UNIQUE (?)",
+ clause.Table{Name: "tokens"},
+ clause.Column{Name: gormTokenKeyConstraint},
+ clause.Column{Name: "key"},
+ ).Error)
+ },
+ },
+ {
+ name: "postgres_default_constraint_without_target_index",
+ prepareOld: func(t *testing.T, tx *gorm.DB) {
+ t.Helper()
+ require.NoError(t, tx.Migrator().DropIndex(&Token{}, tokenKeyIndex))
+ require.NoError(t, tx.Exec(
+ "ALTER TABLE ? ADD CONSTRAINT ? UNIQUE (?)",
+ clause.Table{Name: "tokens"},
+ clause.Column{Name: postgresTokenKeyConstraint},
+ clause.Column{Name: "key"},
+ ).Error)
+ },
+ },
+ {
+ name: "non_conflicting_uniqueness_is_preserved",
+ prepareOld: func(t *testing.T, tx *gorm.DB) {
+ t.Helper()
+ require.NoError(t, tx.Exec(
+ "ALTER TABLE ? ADD CONSTRAINT ? UNIQUE (?)",
+ clause.Table{Name: "tokens"},
+ clause.Column{Name: postgresTokenKeyConstraint},
+ clause.Column{Name: "key"},
+ ).Error)
+ require.NoError(t, tx.Exec(
+ "ALTER TABLE ? ADD CONSTRAINT ? UNIQUE (?, ?)",
+ clause.Table{Name: "tokens"},
+ clause.Column{Name: "keep_tokens_key_user_id"},
+ clause.Column{Name: "key"},
+ clause.Column{Name: "user_id"},
+ ).Error)
+ require.NoError(t, tx.Exec(
+ "CREATE UNIQUE INDEX ? ON ? (?) WHERE user_id > 0",
+ clause.Column{Name: "keep_tokens_partial_key"},
+ clause.Table{Name: "tokens"},
+ clause.Column{Name: "key"},
+ ).Error)
+ },
+ preservedConstraints: []string{"keep_tokens_key_user_id"},
+ preservedIndexes: []string{"keep_tokens_partial_key"},
+ },
+ {
+ name: "arbitrary_constraint_is_rejected",
+ prepareOld: func(t *testing.T, tx *gorm.DB) {
+ t.Helper()
+ require.NoError(t, tx.Exec(
+ "ALTER TABLE ? ADD CONSTRAINT ? UNIQUE (?)",
+ clause.Table{Name: "tokens"},
+ clause.Column{Name: "keep_tokens_key_unique"},
+ clause.Column{Name: "key"},
+ ).Error)
+ },
+ expectedError: "unsupported unique constraint",
+ preservedConstraints: []string{"keep_tokens_key_unique"},
+ },
+ {
+ name: "deferrable_constraint_is_rejected",
+ prepareOld: func(t *testing.T, tx *gorm.DB) {
+ t.Helper()
+ require.NoError(t, tx.Migrator().DropIndex(&Token{}, tokenKeyIndex))
+ require.NoError(t, tx.Exec(
+ "ALTER TABLE ? ADD CONSTRAINT ? UNIQUE (?) DEFERRABLE INITIALLY DEFERRED",
+ clause.Table{Name: "tokens"},
+ clause.Column{Name: postgresTokenKeyConstraint},
+ clause.Column{Name: "key"},
+ ).Error)
+ },
+ expectedError: "unsupported definition",
+ preservedConstraints: []string{postgresTokenKeyConstraint},
+ },
+ {
+ name: "invalid_target_index_is_rejected",
+ prepareOld: func(t *testing.T, tx *gorm.DB) {
+ t.Helper()
+ require.NoError(t, tx.Migrator().DropIndex(&Token{}, tokenKeyIndex))
+ require.NoError(t, tx.Exec(
+ "CREATE INDEX ? ON ? (?)",
+ clause.Column{Name: tokenKeyIndex},
+ clause.Table{Name: "tokens"},
+ clause.Column{Name: "key"},
+ ).Error)
+ require.NoError(t, tx.Exec(
+ "ALTER TABLE ? ADD CONSTRAINT ? UNIQUE (?)",
+ clause.Table{Name: "tokens"},
+ clause.Column{Name: postgresTokenKeyConstraint},
+ clause.Column{Name: "key"},
+ ).Error)
+ },
+ expectedError: "unexpected definition",
+ preservedConstraints: []string{postgresTokenKeyConstraint},
+ preservedIndexes: []string{tokenKeyIndex},
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ tx := db.Begin()
+ require.NoError(t, tx.Error)
+ t.Cleanup(func() { _ = tx.Rollback().Error })
+
+ schemaName := fmt.Sprintf("token_migration_%d", time.Now().UnixNano())
+ require.NoError(t, tx.Exec(
+ "CREATE SCHEMA ?",
+ clause.Table{Name: schemaName},
+ ).Error)
+ require.NoError(t, tx.Exec(
+ "SET LOCAL search_path TO ?",
+ clause.Table{Name: schemaName},
+ ).Error)
+
+ require.NoError(t, migrateTokenKeyUniqueness(tx))
+ require.NoError(t, tx.AutoMigrate(&Token{}))
+ original := Token{UserId: 1, Key: "preserved-key", Name: "preserve me"}
+ require.NoError(t, tx.Create(&original).Error)
+ if test.prepareOld != nil {
+ test.prepareOld(t, tx)
+ }
+
+ if test.expectedError != "" {
+ err := migrateTokenKeyUniqueness(tx)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), test.expectedError)
+ for _, constraintName := range test.preservedConstraints {
+ requireTokenConstraintExists(t, tx, constraintName)
+ }
+ for _, indexName := range test.preservedIndexes {
+ requireTokenIndexExists(t, tx, indexName)
+ }
+ return
+ }
+
+ for range 2 {
+ require.NoError(t, migrateTokenKeyUniqueness(tx))
+ require.NoError(t, tx.AutoMigrate(&Token{}))
+ }
+
+ var preserved Token
+ require.NoError(t, tx.First(&preserved, original.Id).Error)
+ assert.Equal(t, original.Key, preserved.Key)
+ assert.Equal(t, original.Name, preserved.Name)
+
+ constraints, err := inspectTokenKeyUniqueConstraints(tx, "tokens")
+ require.NoError(t, err)
+ assert.Empty(t, constraints)
+ targetIndex, err := inspectTokenKeyIndex(tx, "tokens")
+ require.NoError(t, err)
+ assert.True(t, targetIndex.standaloneValid)
+ for _, constraintName := range test.preservedConstraints {
+ requireTokenConstraintExists(t, tx, constraintName)
+ }
+ for _, indexName := range test.preservedIndexes {
+ requireTokenIndexExists(t, tx, indexName)
+ }
+
+ duplicateError := tx.Transaction(func(duplicateTx *gorm.DB) error {
+ return duplicateTx.Create(&Token{UserId: 2, Key: original.Key}).Error
+ })
+ require.Error(t, duplicateError)
+
+ var totalRows int64
+ require.NoError(t, tx.Model(&Token{}).Count(&totalRows).Error)
+ assert.EqualValues(t, 1, totalRows)
+ })
+ }
+}
From 67a0585d0f252dfca445c11b7600971b7eeb8eea Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=80=BA=E5=9D=82=E5=87=9B?=
<131595336+To3akaRin@users.noreply.github.com>
Date: Mon, 31 Aug 2026 16:37:46 +0800
Subject: [PATCH 81/99] fix(docs): correct Video API links across localized
READMEs (#7116)
---
README.en.md | 2 +-
README.fr.md | 2 +-
README.ja.md | 2 +-
README.md | 2 +-
README.zh_CN.md | 2 +-
README.zh_TW.md | 2 +-
6 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/README.en.md b/README.en.md
index 1204e1bc3dcc..cd0d551e1c01 100644
--- a/README.en.md
+++ b/README.en.md
@@ -281,7 +281,7 @@ docker run --name new-api -d --restart always \
- [Response Interface (Responses)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response)
- [Image Interface (Image)](https://docs.newapi.pro/en/docs/api/ai-model/images/openai/v1-images-generations--post)
- [Audio Interface (Audio)](https://docs.newapi.pro/en/docs/api/ai-model/audio/openai/create-transcription)
-- [Video Interface (Video)](https://docs.newapi.pro/en/docs/api/ai-model/videos/create-video-generation)
+- [Video Interface (Video)](https://docs.newapi.pro/en/docs/api/ai-model/videos/sora/createvideo)
- [Embedding Interface (Embeddings)](https://docs.newapi.pro/en/docs/api/ai-model/embeddings/create-embedding)
- [Rerank Interface (Rerank)](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank)
- [Realtime Conversation (Realtime)](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session)
diff --git a/README.fr.md b/README.fr.md
index 937d306754e2..7870076eb6e1 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -282,7 +282,7 @@ docker run --name new-api -d --restart always \
- [Interface de réponse (Responses)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/createresponse)
- [Interface d'image (Image)](https://docs.newapi.pro/en/docs/api/ai-model/images/openai/post-v1-images-generations)
- [Interface audio (Audio)](https://docs.newapi.pro/en/docs/api/ai-model/audio/openai/create-transcription)
-- [Interface vidéo (Video)](https://docs.newapi.pro/en/docs/api/ai-model/audio/openai/createspeech)
+- [Interface vidéo (Video)](https://docs.newapi.pro/en/docs/api/ai-model/videos/sora/createvideo)
- [Interface d'incorporation (Embeddings)](https://docs.newapi.pro/en/docs/api/ai-model/embeddings/createembedding)
- [Interface de rerank (Rerank)](https://docs.newapi.pro/en/docs/api/ai-model/rerank/creatererank)
- [Conversation en temps réel (Realtime)](https://docs.newapi.pro/en/docs/api/ai-model/realtime/createrealtimesession)
diff --git a/README.ja.md b/README.ja.md
index 6c615349c165..24f88a333f17 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -284,7 +284,7 @@ docker run --name new-api -d --restart always \
- [レスポンスインターフェース (Responses)](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/createresponse)
- [イメージインターフェース (Image)](https://docs.newapi.pro/ja/docs/api/ai-model/images/openai/post-v1-images-generations)
- [オーディオインターフェース (Audio)](https://docs.newapi.pro/ja/docs/api/ai-model/audio/openai/create-transcription)
-- [ビデオインターフェース (Video)](https://docs.newapi.pro/ja/docs/api/ai-model/audio/openai/createspeech)
+- [ビデオインターフェース (Video)](https://docs.newapi.pro/ja/docs/api/ai-model/videos/sora/createvideo)
- [エンベッドインターフェース (Embeddings)](https://docs.newapi.pro/ja/docs/api/ai-model/embeddings/createembedding)
- [再ランク付けインターフェース (Rerank)](https://docs.newapi.pro/ja/docs/api/ai-model/rerank/creatererank)
- [リアルタイム対話インターフェース (Realtime)](https://docs.newapi.pro/ja/docs/api/ai-model/realtime/createrealtimesession)
diff --git a/README.md b/README.md
index d05f8e58ca71..06924e547f45 100644
--- a/README.md
+++ b/README.md
@@ -282,7 +282,7 @@ docker run --name new-api -d --restart always \
- [Response Interface (Responses)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/createresponse)
- [Image Interface (Image)](https://docs.newapi.pro/en/docs/api/ai-model/images/openai/post-v1-images-generations)
- [Audio Interface (Audio)](https://docs.newapi.pro/en/docs/api/ai-model/audio/openai/create-transcription)
-- [Video Interface (Video)](https://docs.newapi.pro/en/docs/api/ai-model/audio/openai/createspeech)
+- [Video Interface (Video)](https://docs.newapi.pro/en/docs/api/ai-model/videos/sora/createvideo)
- [Embedding Interface (Embeddings)](https://docs.newapi.pro/en/docs/api/ai-model/embeddings/createembedding)
- [Rerank Interface (Rerank)](https://docs.newapi.pro/en/docs/api/ai-model/rerank/creatererank)
- [Realtime Conversation (Realtime)](https://docs.newapi.pro/en/docs/api/ai-model/realtime/createrealtimesession)
diff --git a/README.zh_CN.md b/README.zh_CN.md
index b3bcd08dc96e..9ab237584ac4 100644
--- a/README.zh_CN.md
+++ b/README.zh_CN.md
@@ -282,7 +282,7 @@ docker run --name new-api -d --restart always \
- [响应接口 (Responses)](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/createresponse)
- [图像接口 (Image)](https://docs.newapi.pro/zh/docs/api/ai-model/images/openai/post-v1-images-generations)
- [音频接口 (Audio)](https://docs.newapi.pro/zh/docs/api/ai-model/audio/openai/create-transcription)
-- [视频接口 (Video)](https://docs.newapi.pro/zh/docs/api/ai-model/audio/openai/createspeech)
+- [视频接口 (Video)](https://docs.newapi.pro/zh/docs/api/ai-model/videos/sora/createvideo)
- [嵌入接口 (Embeddings)](https://docs.newapi.pro/zh/docs/api/ai-model/embeddings/createembedding)
- [重排序接口 (Rerank)](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/creatererank)
- [实时对话 (Realtime)](https://docs.newapi.pro/zh/docs/api/ai-model/realtime/createrealtimesession)
diff --git a/README.zh_TW.md b/README.zh_TW.md
index ee9f952c3381..be7cf320a803 100644
--- a/README.zh_TW.md
+++ b/README.zh_TW.md
@@ -281,7 +281,7 @@ docker run --name new-api -d --restart always \
- [響應接口 (Responses)](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/createresponse)
- [圖像接口 (Image)](https://docs.newapi.pro/zh/docs/api/ai-model/images/openai/post-v1-images-generations)
- [音訊接口 (Audio)](https://docs.newapi.pro/zh/docs/api/ai-model/audio/openai/create-transcription)
-- [影片接口 (Video)](https://docs.newapi.pro/zh/docs/api/ai-model/audio/openai/createspeech)
+- [影片接口 (Video)](https://docs.newapi.pro/zh/docs/api/ai-model/videos/sora/createvideo)
- [嵌入接口 (Embeddings)](https://docs.newapi.pro/zh/docs/api/ai-model/embeddings/createembedding)
- [重排序接口 (Rerank)](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/creatererank)
- [即時對話 (Realtime)](https://docs.newapi.pro/zh/docs/api/ai-model/realtime/createrealtimesession)
From b7017c251badaacaab840646a959635d00665e2d Mon Sep 17 00:00:00 2001
From: Seefs <40468931+seefs001@users.noreply.github.com>
Date: Tue, 1 Sep 2026 20:54:17 +0800
Subject: [PATCH 82/99] fix(model): do not treat no-op system task state writes
as lock loss (#7135)
---
model/system_task.go | 18 +++++++++++++++++-
model/system_task_test.go | 31 +++++++++++++++++++++++++++++++
2 files changed, 48 insertions(+), 1 deletion(-)
diff --git a/model/system_task.go b/model/system_task.go
index c811409b487d..ffbe2dc1c9ae 100644
--- a/model/system_task.go
+++ b/model/system_task.go
@@ -322,7 +322,23 @@ func UpdateSystemTaskState(taskID string, lockedBy string, state any) error {
if result.Error != nil {
return result.Error
}
- if result.RowsAffected == 0 {
+ if result.RowsAffected > 0 {
+ return nil
+ }
+ // MySQL counts changed rows, not matched rows. A no-op persist of the same
+ // state in the same second therefore returns RowsAffected == 0 even while
+ // the lease is still held. Confirm the lock before treating this as loss.
+ // Reuse `now` from the UPDATE so a clock tick cannot reintroduce false
+ // lock-loss; a lease that expires during the write is caught by the next heartbeat.
+ var held int64
+ err = DB.Model(&SystemTask{}).
+ Where("task_id = ? AND status = ? AND locked_by = ?", taskID, SystemTaskStatusRunning, lockedBy).
+ Where("EXISTS (SELECT 1 FROM system_task_locks WHERE system_task_locks.task_id = system_tasks.task_id AND system_task_locks.locked_by = ? AND system_task_locks.locked_until >= ?)", lockedBy, now).
+ Count(&held).Error
+ if err != nil {
+ return err
+ }
+ if held == 0 {
return ErrSystemTaskLockLost
}
return nil
diff --git a/model/system_task_test.go b/model/system_task_test.go
index ac5678f74b1a..d0a4e5db6acc 100644
--- a/model/system_task_test.go
+++ b/model/system_task_test.go
@@ -350,3 +350,34 @@ func TestSystemTaskUpdatesRequireUnexpiredLock(t *testing.T) {
assert.Equal(t, SystemTaskStatusRunning, reloaded.Status)
assert.Empty(t, reloaded.State)
}
+
+func TestUpdateSystemTaskStateIdenticalPayloadDoesNotLoseLock(t *testing.T) {
+ // SQLite reports matched rows for unchanged UPDATEs, so this case passed
+ // even before the fix. The MySQL regression is covered by
+ // TestUpdateSystemTaskStateIdenticalPayloadDoesNotLoseLockConfiguredDatabases.
+ truncateTables(t)
+ runUpdateSystemTaskStateIdenticalPayloadKeepsLock(t, SystemTaskTypeLogCleanup)
+}
+
+func runUpdateSystemTaskStateIdenticalPayloadKeepsLock(t *testing.T, taskType string) {
+ t.Helper()
+ // Two persists in the same second so MySQL's unchanged-row UPDATE returns 0.
+
+ task, err := CreateSystemTask(taskType, nil, nil)
+ require.NoError(t, err)
+
+ runnerID := "runner-a"
+ _, claimed, err := ClaimSystemTask(task.ID, taskType, runnerID, common.GetTimestamp()+60)
+ require.NoError(t, err)
+ require.True(t, claimed)
+
+ state := testSystemTaskState{Total: 10, Processed: 10, Progress: 100, Remaining: 0}
+ require.NoError(t, UpdateSystemTaskState(task.TaskID, runnerID, state))
+ require.NoError(t, UpdateSystemTaskState(task.TaskID, runnerID, state), "identical state persist must not be treated as lock loss")
+
+ require.NoError(t, FinishSystemTask(task.TaskID, runnerID, SystemTaskStatusSucceeded, map[string]int64{"deleted_count": 10}, ""))
+ finished, err := GetSystemTaskByTaskID(task.TaskID)
+ require.NoError(t, err)
+ require.NotNil(t, finished)
+ assert.Equal(t, SystemTaskStatusSucceeded, finished.Status)
+}
From 0ed497f066a68613375124303ef54f220267b334 Mon Sep 17 00:00:00 2001
From: Calcium-Ion
Date: Tue, 1 Sep 2026 21:53:35 +0800
Subject: [PATCH 83/99] feat(relay): hosted-tool conversion fidelity, reasoning
normalization, and billing usage integrity (#7137)
* feat(relaykit): preserve hosted tools across conversions
- add protocol-neutral hosted-tool DTOs, conversion metadata, and loss policies
- bridge citations, grounding metadata, and hosted-tool stream lifecycles
- document the public conversion behavior and channel policy controls
* refactor(relaykit): normalize reasoning and thinking intent
- centralize provider-neutral reasoning intent, effort, and budget mappings
- parse model suffixes at the host entry boundary while preserving provider-owned tails
- keep adaptive Claude thinking and explicit zero-token compatibility consistent
* fix(billing): preserve authoritative usage across relay hops
- carry native BillingUsage sidecars through direct and streamed protocol bridges
- merge partial and terminal usage monotonically with safe fallback settlement
- retain cache metadata, penultimate usage, and per-call Gemini tool surcharges
* feat(relay): bridge Responses with Claude and Gemini protocols
- add direct request, response, and stream converters across supported relay formats
- expose Claude count_tokens and Chat-to-Responses compatibility endpoints
- carry conversion diagnostics through the host while retaining the curated public goldens
* fix(relay): wire relaykit conversions into host channels
- connect handlers, adaptors, and channel settings to the standalone conversion layer
- keep model mapping, pricing identity, retries, and provider-specific suffix behavior aligned
- ignore local audit artifacts and retain focused public regression coverage
---
.gitignore | 5 +-
controller/channel-test.go | 9 +-
controller/relay.go | 47 +-
controller/relay_count_tokens_test.go | 73 +
model/channel.go | 3 +
relay/channel/aws/adaptor.go | 4 +
relay/channel/aws/relay_aws_test.go | 8 +-
relay/channel/claude/adaptor.go | 33 +-
relay/channel/claude/adaptor_test.go | 90 ++
relay/channel/claude/relay-claude.go | 54 +-
relay/channel/claude/relay_claude_test.go | 33 +-
relay/channel/claude/relay_responses.go | 180 +++
relay/channel/gemini/adaptor.go | 26 +-
relay/channel/gemini/relay-gemini-native.go | 1 +
relay/channel/gemini/relay-gemini.go | 62 +-
relay/channel/gemini/relay_responses.go | 133 +-
relay/channel/newapi/adaptor.go | 4 +-
relay/channel/openai/adaptor.go | 274 +++-
relay/channel/openai/chat_via_responses.go | 73 +-
.../channel/openai/chat_via_responses_test.go | 45 +
relay/channel/openai/helper.go | 101 +-
relay/channel/openai/relay-openai.go | 76 +-
relay/channel/openai/relay_responses.go | 32 +-
relay/channel/openai/responses_via_chat.go | 51 +-
relay/channel/sub2api/adaptor_test.go | 39 +
relay/channel/vertex/adaptor.go | 38 +-
relay/channel/zhipu_4v/adaptor.go | 3 +-
relay/chat_completions_via_responses.go | 52 +-
relay/chat_completions_via_responses_test.go | 90 +-
relay/claude_handler.go | 83 +-
relay/common/conversion_diagnostics.go | 71 +
relay/common/override.go | 81 +-
relay/common/relay_info.go | 88 +-
relay/common/relay_info_test.go | 2 +
relay/common/tool_usage.go | 2 +-
relay/compatible_handler.go | 7 +-
relay/convert_request_error.go | 23 +
relay/convert_request_error_test.go | 72 +
relay/gemini_handler.go | 56 +-
relay/helper/price.go | 33 +-
relay/helper/price_test.go | 98 ++
relay/helper/reasoning_suffix.go | 145 ++
relay/helper/reasoning_suffix_test.go | 218 +++
relay/responses_handler.go | 5 +-
relaykit/README.md | 3 +
relaykit/dto/billing_usage.go | 258 +++-
relaykit/dto/channel_settings.go | 18 +
relaykit/dto/channel_settings_test.go | 12 +
relaykit/dto/claude.go | 36 +-
relaykit/dto/gemini.go | 52 +-
relaykit/dto/openai_request.go | 35 +-
relaykit/dto/openai_response.go | 171 ++-
relaykit/dto/reasoning_state.go | 14 +
relaykit/dto/usage_merge.go | 281 ++++
relaykit/dto/usage_merge_test.go | 67 +
relaykit/reasonmap/reasonmap.go | 5 +
.../claude_default_max_tokens_test.go | 32 +-
relaykit/relayconvert/convmeta/meta.go | 34 +
relaykit/relayconvert/convmeta/meta_test.go | 1 +
relaykit/relayconvert/convmeta/options.go | 38 +-
relaykit/relayconvert/golden_test.go | 89 +-
.../internal/claude_messages/citations.go | 58 +
.../stream_billing_usage_test.go | 66 +
.../claude_messages/to_oai_chat_req.go | 48 +-
.../claude_messages/to_oai_chat_resp.go | 188 ++-
.../to_oai_responses_hosted_stream.go | 194 +++
.../claude_messages/to_oai_responses_req.go | 326 ++++
.../internal/gemini_chat/grounding.go | 367 +++++
.../internal/gemini_chat/to_oai_chat_req.go | 140 +-
.../internal/gemini_chat/to_oai_chat_resp.go | 355 ++++-
.../to_oai_responses_hosted_stream.go | 71 +
.../internal/oai_chat/citations.go | 81 +
.../oai_chat/to_claude_messages_req.go | 169 +--
.../oai_chat/to_claude_messages_resp.go | 380 +++--
.../oai_chat/to_claude_messages_resp_test.go | 19 +
.../internal/oai_chat/to_gemini_chat_req.go | 109 +-
.../internal/oai_chat/to_gemini_chat_resp.go | 384 ++++-
.../internal/oai_chat/to_oai_responses_req.go | 17 +-
.../oai_chat/to_oai_responses_resp.go | 94 +-
.../oai_chat/to_oai_responses_resp_test.go | 21 +
.../oai_chat/to_oai_responses_stream_resp.go | 579 ++++++-
.../oai_responses/to_claude_messages_req.go | 67 +-
.../oai_responses/to_claude_messages_resp.go | 156 ++
.../to_claude_messages_stream_resp.go | 500 ++++++
.../to_claude_messages_stream_resp_test.go | 129 ++
.../oai_responses/to_gemini_chat_req.go | 49 +-
.../internal/oai_responses/to_oai_chat_req.go | 7 +-
.../oai_responses/to_oai_chat_resp.go | 171 ++-
.../oai_responses/to_oai_chat_resp_test.go | 102 +-
.../oai_responses/to_oai_chat_stream_resp.go | 310 +++-
.../internal/shared/claude/reasoning.go | 135 ++
.../internal/shared/claude/usage.go | 52 +
.../internal/shared/gemini/request.go | 214 ++-
.../relayconvert/internal/toolconv/decode.go | 960 ++++++++++++
.../relayconvert/internal/toolconv/encode.go | 1343 +++++++++++++++++
.../internal/toolconv/hosted_values.go | 171 +++
.../relayconvert/internal/toolconv/model.go | 118 ++
.../internal/toolconv/policy_test.go | 103 ++
.../internal/toolconv/response.go | 515 +++++++
.../internal/toolconv/response_artifacts.go | 816 ++++++++++
relaykit/relayconvert/reasoning/claude.go | 303 ++++
relaykit/relayconvert/reasoning/gemini.go | 374 +++++
relaykit/relayconvert/reasoning/intent.go | 609 ++++++++
.../relayconvert/reasoning/intent_test.go | 125 ++
relaykit/relayconvert/reasoning/suffix.go | 149 +-
.../relayconvert/reasoning/suffix_test.go | 140 ++
relaykit/relayconvert/request_compat.go | 45 +-
relaykit/relayconvert/request_registry.go | 85 +-
.../relayconvert/request_registry_test.go | 266 ++--
relaykit/relayconvert/response_compat.go | 69 +
relaykit/relayconvert/response_registry.go | 374 ++++-
.../relayconvert/response_registry_test.go | 48 +-
relaykit/relayconvert/terminal_stream_test.go | 27 +-
.../request/claude_to_gemini.golden.json | 94 --
.../request/claude_to_openai.golden.json | 67 -
.../claude_to_openai_responses.golden.json | 54 -
.../request/gemini_to_claude.golden.json | 61 -
.../request/gemini_to_openai.golden.json | 68 -
.../gemini_to_openai_responses.golden.json | 55 -
.../openai_responses_to_gemini.golden.json | 94 --
.../request/openai_to_gemini.golden.json | 107 --
.../response/claude_to_gemini.golden.json | 66 -
.../response/gemini_to_claude.golden.json | 35 +-
.../openai_responses_to_claude.golden.json | 4 +
.../openai_responses_to_gemini.golden.json | 62 -
.../openai_responses_to_openai.golden.json | 1 +
.../response/openai_to_claude.golden.json | 55 -
.../response/openai_to_gemini.golden.json | 62 -
.../openai_to_openai_responses.golden.json | 26 +-
.../stream/claude_to_gemini.golden.json | 66 +-
.../stream/gemini_to_claude.golden.json | 79 +-
.../relayconvert/text_converter_registry.go | 34 +-
.../text_converter_registry_test.go | 58 +-
.../relayconvert/tool_loss_policy_test.go | 91 ++
relaykit/types/conversion.go | 75 +
router/relay-router.go | 1 +
router/relay_router_test.go | 14 +
service/billing_session.go | 2 +-
service/billing_usage.go | 158 +-
service/log_info_generate.go | 11 +-
service/quota.go | 13 +-
service/request_converter.go | 18 +-
service/response_converter.go | 40 +
service/text_quota.go | 2 +-
service/text_quota_test.go | 79 +
service/token_counter.go | 6 +
setting/model_setting/global.go | 31 +
setting/ratio_setting/model_ratio.go | 13 +-
setting/reasoning/suffix.go | 20 +-
149 files changed, 14687 insertions(+), 3002 deletions(-)
create mode 100644 controller/relay_count_tokens_test.go
create mode 100644 relay/channel/claude/adaptor_test.go
create mode 100644 relay/channel/claude/relay_responses.go
create mode 100644 relay/common/conversion_diagnostics.go
create mode 100644 relay/convert_request_error.go
create mode 100644 relay/convert_request_error_test.go
create mode 100644 relay/helper/reasoning_suffix.go
create mode 100644 relay/helper/reasoning_suffix_test.go
create mode 100644 relaykit/dto/reasoning_state.go
create mode 100644 relaykit/dto/usage_merge.go
create mode 100644 relaykit/dto/usage_merge_test.go
create mode 100644 relaykit/relayconvert/internal/claude_messages/citations.go
create mode 100644 relaykit/relayconvert/internal/claude_messages/stream_billing_usage_test.go
create mode 100644 relaykit/relayconvert/internal/claude_messages/to_oai_responses_hosted_stream.go
create mode 100644 relaykit/relayconvert/internal/claude_messages/to_oai_responses_req.go
create mode 100644 relaykit/relayconvert/internal/gemini_chat/grounding.go
create mode 100644 relaykit/relayconvert/internal/gemini_chat/to_oai_responses_hosted_stream.go
create mode 100644 relaykit/relayconvert/internal/oai_chat/citations.go
create mode 100644 relaykit/relayconvert/internal/oai_responses/to_claude_messages_resp.go
create mode 100644 relaykit/relayconvert/internal/oai_responses/to_claude_messages_stream_resp.go
create mode 100644 relaykit/relayconvert/internal/oai_responses/to_claude_messages_stream_resp_test.go
create mode 100644 relaykit/relayconvert/internal/shared/claude/reasoning.go
create mode 100644 relaykit/relayconvert/internal/shared/claude/usage.go
create mode 100644 relaykit/relayconvert/internal/toolconv/decode.go
create mode 100644 relaykit/relayconvert/internal/toolconv/encode.go
create mode 100644 relaykit/relayconvert/internal/toolconv/hosted_values.go
create mode 100644 relaykit/relayconvert/internal/toolconv/model.go
create mode 100644 relaykit/relayconvert/internal/toolconv/policy_test.go
create mode 100644 relaykit/relayconvert/internal/toolconv/response.go
create mode 100644 relaykit/relayconvert/internal/toolconv/response_artifacts.go
create mode 100644 relaykit/relayconvert/reasoning/claude.go
create mode 100644 relaykit/relayconvert/reasoning/gemini.go
create mode 100644 relaykit/relayconvert/reasoning/intent.go
create mode 100644 relaykit/relayconvert/reasoning/intent_test.go
create mode 100644 relaykit/relayconvert/reasoning/suffix_test.go
delete mode 100644 relaykit/relayconvert/testdata/golden/request/claude_to_gemini.golden.json
delete mode 100644 relaykit/relayconvert/testdata/golden/request/claude_to_openai.golden.json
delete mode 100644 relaykit/relayconvert/testdata/golden/request/claude_to_openai_responses.golden.json
delete mode 100644 relaykit/relayconvert/testdata/golden/request/gemini_to_claude.golden.json
delete mode 100644 relaykit/relayconvert/testdata/golden/request/gemini_to_openai.golden.json
delete mode 100644 relaykit/relayconvert/testdata/golden/request/gemini_to_openai_responses.golden.json
delete mode 100644 relaykit/relayconvert/testdata/golden/request/openai_responses_to_gemini.golden.json
delete mode 100644 relaykit/relayconvert/testdata/golden/request/openai_to_gemini.golden.json
delete mode 100644 relaykit/relayconvert/testdata/golden/response/claude_to_gemini.golden.json
delete mode 100644 relaykit/relayconvert/testdata/golden/response/openai_responses_to_gemini.golden.json
delete mode 100644 relaykit/relayconvert/testdata/golden/response/openai_to_claude.golden.json
delete mode 100644 relaykit/relayconvert/testdata/golden/response/openai_to_gemini.golden.json
create mode 100644 relaykit/relayconvert/tool_loss_policy_test.go
create mode 100644 relaykit/types/conversion.go
create mode 100644 service/response_converter.go
diff --git a/.gitignore b/.gitignore
index dc328dd6c80c..ff2460948828 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
.idea
+.review
.vscode
.zed
.history
@@ -20,7 +21,7 @@ tiktoken_cache
.gocache
.gomodcache/
.cache
-plans
+.plans
.claude
.cursor
@@ -37,7 +38,7 @@ skills-lock.json
# Local-only live probes and scratch test workspaces.
.local-tests/
-service/relayconvert/chat_responses_live_local_test.go
+relaykit/relayconvert/chat_responses_live_local_test.go
service/openaicompat/chat_responses_live_local_test.go
go.work
go.work.sum
diff --git a/controller/channel-test.go b/controller/channel-test.go
index 895099a10627..fca5264bb978 100644
--- a/controller/channel-test.go
+++ b/controller/channel-test.go
@@ -259,6 +259,13 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
newAPIError: types.NewError(err, types.ErrorCodeChannelModelMappedError),
}
}
+ if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
+ return testResult{
+ context: c,
+ localErr: err,
+ newAPIError: types.NewErrorWithStatusCode(err, types.ErrorCodeConvertRequestFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()),
+ }
+ }
testModel = info.UpstreamModelName
// 更新请求中的模型名称
@@ -943,7 +950,7 @@ func testChannelForHealthCheck(ctx context.Context, channel *model.Channel, test
}
if allowDisable && isChannelEnabled && shouldBanChannel && channel.GetAutoBan() {
- processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
+ processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError, nil)
summary.Disabled++
}
diff --git a/controller/relay.go b/controller/relay.go
index a678888d9346..099b3fb70db1 100644
--- a/controller/relay.go
+++ b/controller/relay.go
@@ -238,7 +238,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
newAPIError = service.NormalizeViolationFeeError(newAPIError)
relayInfo.LastError = newAPIError
- processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
+ processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError, relayInfo)
if !shouldRetry(c, newAPIError, common.RetryTimes-retryParam.GetRetry()) {
break
@@ -257,6 +257,38 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
}
}
+// CountClaudeTokens implements Anthropic's token-counting utility endpoint.
+// It deliberately skips upstream generation and billing; callers use this
+// endpoint to size prompts before creating a Message.
+func CountClaudeTokens(c *gin.Context) {
+ request, err := helper.GetAndValidateClaudeRequest(c)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "type": "error",
+ "error": gin.H{
+ "type": "invalid_request_error",
+ "message": common.MessageWithRequestId(err.Error(), c.GetString(common.RequestIdKey)),
+ },
+ })
+ return
+ }
+
+ info := relaycommon.GenRelayInfoClaude(c, request)
+ inputTokens, err := service.CountRequestToken(c, request.GetTokenCountMeta(), info)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "type": "error",
+ "error": gin.H{
+ "type": "api_error",
+ "message": common.MessageWithRequestId(err.Error(), c.GetString(common.RequestIdKey)),
+ },
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"input_tokens": inputTokens})
+}
+
var upgrader = websocket.Upgrader{
Subprotocols: []string{"realtime"}, // WS 握手支持的协议,如果有使用 Sec-WebSocket-Protocol,则必须在此声明对应的 Protocol TODO add other protocol
CheckOrigin: func(r *http.Request) bool {
@@ -362,7 +394,7 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
return operation_setting.ShouldRetryByStatusCode(code)
}
-func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) {
+func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError, relayInfo *relaycommon.RelayInfo) {
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, common.LocalLogPreview(err.Error())))
// 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
@@ -392,6 +424,14 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
other["channel_type"] = c.GetInt("channel_type")
adminInfo := make(map[string]interface{})
adminInfo["use_channel"] = c.GetStringSlice("use_channel")
+ if relayInfo != nil {
+ if diagnostics := relayInfo.ConversionDiagnostics(); len(diagnostics) > 0 {
+ adminInfo["conversion_diagnostics"] = diagnostics
+ }
+ if relayInfo.ConversionDiagnosticsTruncated() {
+ adminInfo["conversion_diagnostics_truncated"] = true
+ }
+ }
isMultiKey := common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey)
if isMultiKey {
adminInfo["is_multi_key"] = true
@@ -655,7 +695,8 @@ func executeTaskSubmissionWith(
processChannelError(c,
*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey,
common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()),
- types.NewOpenAIError(taskErr.Error, types.ErrorCodeBadResponseStatusCode, taskErr.StatusCode))
+ types.NewOpenAIError(taskErr.Error, types.ErrorCodeBadResponseStatusCode, taskErr.StatusCode),
+ relayInfo)
}
willRetry := shouldRetryTaskRelay(c, channel.Id, taskErr, common.RetryTimes-retryParam.GetRetry())
diff --git a/controller/relay_count_tokens_test.go b/controller/relay_count_tokens_test.go
new file mode 100644
index 000000000000..2889bb76a78c
--- /dev/null
+++ b/controller/relay_count_tokens_test.go
@@ -0,0 +1,73 @@
+package controller
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCountClaudeTokensReturnsInputTokensWhenRelayCountingDisabled(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ originalCountToken := constant.CountToken
+ constant.CountToken = false
+ t.Cleanup(func() {
+ constant.CountToken = originalCountToken
+ })
+
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Request = httptest.NewRequest(
+ http.MethodPost,
+ "/v1/messages/count_tokens?beta=true",
+ strings.NewReader(`{
+ "model":"gemini-3.6-flash",
+ "messages":[{"role":"user","content":"count this prompt"}],
+ "tools":[{"name":"lookup","description":"Look up a value","input_schema":{"type":"object","properties":{"query":{"type":"string"}}}}]
+ }`),
+ )
+ ctx.Request.Header.Set("Content-Type", "application/json")
+ common.SetContextKey(ctx, constant.ContextKeyOriginalModel, "gemini-3.6-flash")
+
+ CountClaudeTokens(ctx)
+
+ require.Equal(t, http.StatusOK, recorder.Code)
+ var response struct {
+ InputTokens int `json:"input_tokens"`
+ }
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.Positive(t, response.InputTokens)
+}
+
+func TestCountClaudeTokensRejectsMissingMessages(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Request = httptest.NewRequest(
+ http.MethodPost,
+ "/v1/messages/count_tokens",
+ strings.NewReader(`{"model":"gemini-3.6-flash"}`),
+ )
+ ctx.Request.Header.Set("Content-Type", "application/json")
+
+ CountClaudeTokens(ctx)
+
+ require.Equal(t, http.StatusBadRequest, recorder.Code)
+ var response struct {
+ Type string `json:"type"`
+ Error struct {
+ Type string `json:"type"`
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.Equal(t, "error", response.Type)
+ assert.Equal(t, "invalid_request_error", response.Error.Type)
+ assert.Contains(t, response.Error.Message, "messages")
+}
diff --git a/model/channel.go b/model/channel.go
index 705c852b7a89..268f190e703c 100644
--- a/model/channel.go
+++ b/model/channel.go
@@ -989,6 +989,9 @@ func (channel *Channel) ValidateSettings() error {
return err
}
}
+ if err := channelOtherSettings.ValidateToolLossPolicy(); err != nil {
+ return err
+ }
if channel.Type == constant.ChannelTypeAdvancedCustom {
if channelOtherSettings.AdvancedCustom == nil {
return fmt.Errorf("advanced_custom is required")
diff --git a/relay/channel/aws/adaptor.go b/relay/channel/aws/adaptor.go
index 480aea3993f1..92519a1dee0f 100644
--- a/relay/channel/aws/adaptor.go
+++ b/relay/channel/aws/adaptor.go
@@ -39,6 +39,10 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
+ claudeAdaptor := claude.Adaptor{}
+ if _, err := claudeAdaptor.ConvertClaudeRequest(c, info, request); err != nil {
+ return nil, err
+ }
for i, message := range request.Messages {
updated := false
if !message.IsStringContent() {
diff --git a/relay/channel/aws/relay_aws_test.go b/relay/channel/aws/relay_aws_test.go
index 22d8373873ed..5f92566984ff 100644
--- a/relay/channel/aws/relay_aws_test.go
+++ b/relay/channel/aws/relay_aws_test.go
@@ -357,7 +357,7 @@ func TestAwsStreamHandlerUsesFinalUpstreamUsage(t *testing.T) {
assert.Contains(t, recorder.Body.String(), "[DONE]")
}
-func TestAwsStreamHandlerStopsAtClientCancellationAndKeepsPartialBillingUsage(t *testing.T) {
+func TestAwsStreamHandlerStopsAtClientCancellation(t *testing.T) {
originalRelayTimeout := common.RelayTimeout
common.RelayTimeout = 0
t.Cleanup(func() {
@@ -439,12 +439,6 @@ func TestAwsStreamHandlerStopsAtClientCancellationAndKeepsPartialBillingUsage(t
require.ErrorIs(t, upstreamContext.Err(), context.Canceled)
require.Nil(t, result.err)
require.NotNil(t, result.usage)
- require.NotNil(t, result.usage.BillingUsage)
- require.NotNil(t, result.usage.BillingUsage.ClaudeUsage)
- assert.Equal(t, dto.BillingUsageSourceClaudeMessages, result.usage.BillingUsage.Source)
- assert.Equal(t, dto.BillingUsageSemanticAnthropic, result.usage.BillingUsage.Semantic)
- assert.Equal(t, 100, result.usage.BillingUsage.ClaudeUsage.InputTokens)
- assert.Equal(t, 1, result.usage.BillingUsage.ClaudeUsage.OutputTokens)
assert.Equal(t, bodyLengthBeforeCancel, responseWriter.Body.Len())
assert.NotContains(t, responseWriter.Body.String(), "[DONE]")
diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go
index bbd711ff2c7d..8d3583f09a63 100644
--- a/relay/channel/claude/adaptor.go
+++ b/relay/channel/claude/adaptor.go
@@ -12,6 +12,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
+ "github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/gin-gonic/gin"
@@ -26,6 +27,22 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
+ if request.MaxTokens != nil && *request.MaxTokens == 0 {
+ request.MaxTokens = nil
+ }
+ if err := relayconvert.ApplyClaudeThinkingModel(request, info); err != nil {
+ return nil, err
+ }
+ if request.MaxTokens == nil {
+ defaultMaxTokens := uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(request.Model))
+ request.MaxTokens = &defaultMaxTokens
+ }
+ // ApplyClaudeThinkingModel no longer rewrites request.Model. Do not write
+ // a still-suffixed name back over the entry-normalized UpstreamModelName
+ // (AWS/Vertex look up getAwsModelID / claudeModelMap from that field).
+ if info.UpstreamModelName == "" {
+ info.UpstreamModelName = request.Model
+ }
return request, nil
}
@@ -96,7 +113,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if request == nil {
return nil, errors.New("request is nil")
}
- result, err := relayconvert.ConvertRequest(c, info, types.RelayFormatClaude, request)
+ result, err := service.ConvertRequest(c, info, types.RelayFormatClaude, request)
if err != nil {
return nil, err
}
@@ -113,8 +130,15 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
}
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
- // TODO implement me
- return nil, errors.New("not implemented")
+ result, err := service.ConvertRequest(c, info, types.RelayFormatClaude, &request)
+ if err != nil {
+ return nil, err
+ }
+ claudeRequest, ok := result.Value.(*dto.ClaudeRequest)
+ if !ok {
+ return nil, fmt.Errorf("expected Anthropic Messages request, got %T", result.Value)
+ }
+ return claudeRequest, nil
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
@@ -123,6 +147,9 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
info.FinalRequestRelayFormat = types.RelayFormatClaude
+ if info.RelayFormat == types.RelayFormatOpenAIResponses && info.IsStream {
+ return ClaudeResponsesStreamHandler(c, resp, info)
+ }
if info.IsStream {
return ClaudeStreamHandler(c, resp, info)
} else {
diff --git a/relay/channel/claude/adaptor_test.go b/relay/channel/claude/adaptor_test.go
new file mode 100644
index 000000000000..01c035638e56
--- /dev/null
+++ b/relay/channel/claude/adaptor_test.go
@@ -0,0 +1,90 @@
+package claude
+
+import (
+ "net/http/httptest"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/relay/helper"
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/setting/model_setting"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestConvertClaudeRequestTreatsZeroMaxTokensAsUnset(t *testing.T) {
+ zero := uint(0)
+ req := &dto.ClaudeRequest{
+ Model: "claude-sonnet-4-5",
+ MaxTokens: &zero,
+ Messages: []dto.ClaudeMessage{
+ {Role: "user", Content: "hello"},
+ },
+ }
+ info := &relaycommon.RelayInfo{
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: "claude-sonnet-4-5",
+ },
+ }
+
+ out, err := (&Adaptor{}).ConvertClaudeRequest(nil, info, req)
+ require.NoError(t, err)
+ converted, ok := out.(*dto.ClaudeRequest)
+ require.True(t, ok)
+ require.NotNil(t, converted.MaxTokens)
+ assert.Equal(t, uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(req.Model)), *converted.MaxTokens)
+}
+
+func TestConvertClaudeRequestZeroMaxTokensStillRaisesThinkingBudget(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+
+ zero := uint(0)
+ original := &dto.ClaudeRequest{
+ Model: "claude-3-7-sonnet-thinking",
+ MaxTokens: &zero,
+ Messages: []dto.ClaudeMessage{
+ {Role: "user", Content: "hello"},
+ },
+ }
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "claude-3-7-sonnet-thinking",
+ Request: original,
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: "claude-3-7-sonnet-thinking",
+ },
+ }
+ outbound, err := common.DeepCopy(original)
+ require.NoError(t, err)
+ require.NoError(t, helper.ModelMappedHelper(c, info, outbound))
+ require.NoError(t, helper.ApplyReasoningModelSuffix(info, outbound))
+
+ out, err := (&Adaptor{}).ConvertClaudeRequest(nil, info, outbound)
+ require.NoError(t, err)
+ converted, ok := out.(*dto.ClaudeRequest)
+ require.True(t, ok)
+ assert.Equal(t, "claude-3-7-sonnet", converted.Model)
+ require.NotNil(t, converted.Thinking)
+ require.NotNil(t, converted.MaxTokens)
+ assert.Greater(t, *converted.MaxTokens, uint(1024))
+}
+
+func TestConvertClaudeRequestDoesNotOverwriteTrimmedUpstreamModelName(t *testing.T) {
+ req := &dto.ClaudeRequest{
+ Model: "claude-3-7-sonnet-thinking",
+ Messages: []dto.ClaudeMessage{
+ {Role: "user", Content: "hello"},
+ },
+ }
+ info := &relaycommon.RelayInfo{
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: "claude-3-7-sonnet",
+ },
+ }
+
+ _, err := (&Adaptor{}).ConvertClaudeRequest(nil, info, req)
+ require.NoError(t, err)
+ assert.Equal(t, "claude-3-7-sonnet", info.UpstreamModelName)
+}
diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go
index 2f424b32abdd..511c7eb79a39 100644
--- a/relay/channel/claude/relay-claude.go
+++ b/relay/channel/claude/relay-claude.go
@@ -1,6 +1,7 @@
package claude
import (
+ "fmt"
"io"
"net/http"
"strings"
@@ -19,6 +20,8 @@ import (
"github.com/gin-gonic/gin"
)
+const claudeToChatStreamStateKey = "relaykit.claude_to_chat_stream_state"
+
func stopReasonClaude2OpenAI(reason string) string {
return relayconvert.StopReasonClaudeToOpenAI(reason)
}
@@ -117,7 +120,14 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
countClaudeStreamBillableTools(c, info, &claudeResponse)
helper.ClaudeChunkData(c, claudeResponse, data)
} else if info.RelayFormat == types.RelayFormatOpenAI {
- response := StreamResponseClaude2OpenAI(&claudeResponse)
+ state, err := claudeToChatStreamState(c)
+ if err != nil {
+ return types.NewError(err, types.ErrorCodeBadResponseBody)
+ }
+ response, err := state.ConvertChunk(&claudeResponse)
+ if err != nil {
+ return types.NewError(err, types.ErrorCodeBadResponseBody)
+ }
if !FormatClaudeResponseInfo(&claudeResponse, response, claudeInfo) {
return nil
@@ -125,6 +135,9 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
countClaudeStreamBillableTools(c, info, &claudeResponse)
+ if response == nil {
+ return nil
+ }
err = helper.ObjectData(c, response)
if err != nil {
logger.LogError(c, "send_stream_response_failed: "+err.Error())
@@ -133,6 +146,20 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
return nil
}
+func claudeToChatStreamState(c *gin.Context) (*relayconvert.ClaudeToChatStreamState, error) {
+ if value, ok := c.Get(claudeToChatStreamStateKey); ok {
+ state, ok := value.(*relayconvert.ClaudeToChatStreamState)
+ if !ok || state == nil {
+ return nil, fmt.Errorf("invalid Claude-to-Chat stream state %T", value)
+ }
+ return state, nil
+ }
+
+ state := relayconvert.NewClaudeToChatStreamState()
+ c.Set(claudeToChatStreamStateKey, state)
+ return state, nil
+}
+
func countClaudeStreamBillableTools(c *gin.Context, info *relaycommon.RelayInfo, claudeResponse *dto.ClaudeResponse) {
if claudeResponse == nil {
return
@@ -172,9 +199,7 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
if claudeInfo.Usage != nil {
claudeInfo.Usage.UsageSemantic = "anthropic"
}
- if claudeInfo.Usage != nil && claudeInfo.Usage.BillingUsage == nil {
- claudeInfo.Usage.BillingUsage = dto.NewClaudeMessagesBillingUsage(buildMessageDeltaPatchUsage(nil, claudeInfo))
- }
+ relayconvert.FinalizeClaudeStreamBillingUsage(claudeInfo)
if info.RelayFormat == types.RelayFormatClaude {
//
@@ -232,7 +257,10 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
claudeInfo.Usage.TotalTokens = claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens
claudeInfo.Usage.UsageSemantic = "anthropic"
- claudeInfo.Usage.BillingUsage = dto.NewClaudeMessagesBillingUsage(claudeResponse.Usage)
+ claudeInfo.Usage.BillingUsage = dto.CloneBillingUsage(claudeResponse.Usage.BillingUsage)
+ if claudeInfo.Usage.BillingUsage == nil {
+ claudeInfo.Usage.BillingUsage = dto.NewClaudeMessagesBillingUsage(claudeResponse.Usage)
+ }
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Usage.GetCacheCreation5mTokens()
@@ -247,6 +275,22 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody)
}
+ case types.RelayFormatOpenAIResponses:
+ convertResult, err := service.ConvertResponse(c, info, types.RelayFormatOpenAIResponses, &claudeResponse)
+ if err != nil {
+ return types.NewError(err, types.ErrorCodeBadResponseBody)
+ }
+ responsesResponse, ok := convertResult.Value.(*dto.OpenAIResponsesResponse)
+ if !ok {
+ return types.NewError(fmt.Errorf("expected OpenAI Responses response, got %T", convertResult.Value), types.ErrorCodeBadResponseBody)
+ }
+ if responseID := helper.GetResponseID(c); responseID != "" {
+ responsesResponse.ID = responseID
+ }
+ responseData, err = common.Marshal(responsesResponse)
+ if err != nil {
+ return types.NewError(err, types.ErrorCodeBadResponseBody)
+ }
case types.RelayFormatClaude:
responseData = data
}
diff --git a/relay/channel/claude/relay_claude_test.go b/relay/channel/claude/relay_claude_test.go
index 3975658fa416..703d78a37087 100644
--- a/relay/channel/claude/relay_claude_test.go
+++ b/relay/channel/claude/relay_claude_test.go
@@ -1,12 +1,16 @@
package claude
import (
+ "net/http/httptest"
"strings"
"testing"
+ "github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
+ "github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -323,8 +327,27 @@ func TestBuildOpenAIStyleUsageFromClaudeUsageDefaultsAggregateCacheCreationTo5m(
require.Equal(t, 0, openAIUsage.ClaudeCacheCreation1hTokens)
}
+func applyOpenAIChatReasoningThroughHandlerOrder(t *testing.T, original dto.GeneralOpenAIRequest) (*dto.GeneralOpenAIRequest, *relaycommon.RelayInfo) {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+
+ info := &relaycommon.RelayInfo{
+ OriginModelName: original.Model,
+ Request: &original,
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: original.Model,
+ },
+ }
+ outbound, err := common.DeepCopy(&original)
+ require.NoError(t, err)
+ require.NoError(t, helper.ModelMappedHelper(c, info, outbound))
+ require.NoError(t, helper.ApplyReasoningModelSuffix(info, outbound))
+ return outbound, info
+}
+
func TestOpenAIChatRequestToClaudeMessages_ClaudeOpus48HighUsesAdaptiveThinking(t *testing.T) {
- request := dto.GeneralOpenAIRequest{
+ original := dto.GeneralOpenAIRequest{
Model: "claude-opus-4-8-high",
Temperature: commonPointer(0.7),
TopP: commonPointer(0.9),
@@ -337,7 +360,8 @@ func TestOpenAIChatRequestToClaudeMessages_ClaudeOpus48HighUsesAdaptiveThinking(
},
}
- claudeRequest, err := relayconvert.OpenAIChatRequestToClaudeMessages(nil, &relaycommon.RelayInfo{}, request)
+ outbound, info := applyOpenAIChatReasoningThroughHandlerOrder(t, original)
+ claudeRequest, err := relayconvert.OpenAIChatRequestToClaudeMessages(nil, info, *outbound)
require.NoError(t, err)
require.Equal(t, "claude-opus-4-8", claudeRequest.Model)
require.NotNil(t, claudeRequest.Thinking)
@@ -350,7 +374,7 @@ func TestOpenAIChatRequestToClaudeMessages_ClaudeOpus48HighUsesAdaptiveThinking(
}
func TestOpenAIChatRequestToClaudeMessages_ClaudeOpus48ThinkingUsesAdaptiveHighEffort(t *testing.T) {
- request := dto.GeneralOpenAIRequest{
+ original := dto.GeneralOpenAIRequest{
Model: "claude-opus-4-8-thinking",
Temperature: commonPointer(0.7),
TopP: commonPointer(0.9),
@@ -363,7 +387,8 @@ func TestOpenAIChatRequestToClaudeMessages_ClaudeOpus48ThinkingUsesAdaptiveHighE
},
}
- claudeRequest, err := relayconvert.OpenAIChatRequestToClaudeMessages(nil, &relaycommon.RelayInfo{}, request)
+ outbound, info := applyOpenAIChatReasoningThroughHandlerOrder(t, original)
+ claudeRequest, err := relayconvert.OpenAIChatRequestToClaudeMessages(nil, info, *outbound)
require.NoError(t, err)
require.Equal(t, "claude-opus-4-8", claudeRequest.Model)
require.NotNil(t, claudeRequest.Thinking)
diff --git a/relay/channel/claude/relay_responses.go b/relay/channel/claude/relay_responses.go
new file mode 100644
index 000000000000..1e3697ae9d88
--- /dev/null
+++ b/relay/channel/claude/relay_responses.go
@@ -0,0 +1,180 @@
+package claude
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/logger"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/relay/helper"
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert"
+ "github.com/QuantumNous/new-api/relaykit/types"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/gin-gonic/gin"
+)
+
+func ClaudeResponsesStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) {
+ responseID := helper.GetResponseID(c)
+ created := common.GetTimestamp()
+ state, err := relayconvert.NewResponseStreamState(types.RelayFormatClaude, types.RelayFormatOpenAIResponses, relayconvert.ResponseStreamOptions{
+ ID: responseID,
+ Model: info.UpstreamModelName,
+ Created: created,
+ EmitSequenceNumber: true,
+ })
+ if err != nil {
+ return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+ }
+ hostedBridge := relayconvert.NewClaudeHostedStreamBridge()
+
+ claudeInfo := &ClaudeResponseInfo{
+ ResponseId: responseID,
+ Created: created,
+ Model: info.UpstreamModelName,
+ ResponseText: strings.Builder{},
+ Usage: &dto.Usage{},
+ }
+ var streamErr *types.NewAPIError
+ // streamFailed means a Responses-native terminal error was sent successfully.
+ // In that case the scanner stops without a transport error and the partial
+ // upstream usage remains billable.
+ streamFailed := false
+
+ sendResponsesEvent := func(eventType string, payload dto.ResponsesStreamResponse) bool {
+ payload.Type = eventType
+ data, err := common.Marshal(payload)
+ if err != nil {
+ streamErr = types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
+ return false
+ }
+ if err := helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: eventType}, string(data)); err != nil {
+ streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+ return false
+ }
+ return true
+ }
+ sendResult := func(result relayconvert.ResponseResult) bool {
+ event, ok := result.Value.(relayconvert.ChatToResponsesStreamEvent)
+ if !ok {
+ streamErr = types.NewOpenAIError(
+ fmt.Errorf("expected OpenAI Responses stream event, got %T", result.Value),
+ types.ErrorCodeBadResponse,
+ http.StatusInternalServerError,
+ )
+ return false
+ }
+ return sendResponsesEvent(event.Type, event.Payload)
+ }
+ failResponsesStream := func(err error) bool {
+ failureResults, handled := state.FailResponsesStream("server_error", err.Error(), "")
+ if !handled {
+ return false
+ }
+ for _, result := range failureResults {
+ if !sendResult(result) {
+ return true
+ }
+ }
+ streamFailed = true
+ return true
+ }
+
+ helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
+ var claudeResponse dto.ClaudeResponse
+ if err := common.UnmarshalJsonStr(data, &claudeResponse); err != nil {
+ logger.LogError(c, "failed to unmarshal Claude stream event: "+err.Error())
+ if failResponsesStream(err) {
+ // A nil streamErr here is intentional: the protocol-level failure
+ // event was delivered, so only the scanner needs to stop.
+ sr.Stop(streamErr)
+ return
+ }
+ streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
+ sr.Stop(streamErr)
+ return
+ }
+ if claudeError := claudeResponse.GetClaudeError(); claudeError != nil && claudeError.Type != "" {
+ if failResponsesStream(fmt.Errorf("%s", claudeError.Message)) {
+ sr.Stop(streamErr)
+ return
+ }
+ streamErr = types.WithClaudeError(*claudeError, http.StatusInternalServerError)
+ sr.Stop(streamErr)
+ return
+ }
+
+ if claudeResponse.StopReason != "" {
+ maybeMarkClaudeRefusal(c, claudeResponse.StopReason)
+ }
+ if claudeResponse.Delta != nil && claudeResponse.Delta.StopReason != nil {
+ maybeMarkClaudeRefusal(c, *claudeResponse.Delta.StopReason)
+ }
+ if claudeResponse.Type == "message_start" && claudeResponse.Message != nil {
+ info.UpstreamModelName = claudeResponse.Message.Model
+ }
+ FormatClaudeResponseInfo(&claudeResponse, nil, claudeInfo)
+ countClaudeStreamBillableTools(c, info, &claudeResponse)
+ hostedEvents, consumed, err := hostedBridge.Convert(&claudeResponse, state)
+ if err != nil {
+ if failResponsesStream(err) {
+ sr.Stop(streamErr)
+ return
+ }
+ streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+ sr.Stop(streamErr)
+ return
+ }
+ for _, event := range hostedEvents {
+ if !sendResponsesEvent(event.Type, event.Payload) {
+ sr.Stop(streamErr)
+ return
+ }
+ }
+ if consumed {
+ return
+ }
+
+ results, err := service.ConvertStreamResponseChunk(c, info, state, &claudeResponse)
+ if err != nil {
+ if failResponsesStream(err) {
+ sr.Stop(streamErr)
+ return
+ }
+ streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+ sr.Stop(streamErr)
+ return
+ }
+ for _, result := range results {
+ if !sendResult(result) {
+ sr.Stop(streamErr)
+ return
+ }
+ }
+ })
+ if streamErr != nil {
+ return nil, streamErr
+ }
+ if streamFailed {
+ return claudeInfo.Usage, nil
+ }
+
+ HandleStreamFinalResponse(c, info, claudeInfo)
+ openAIUsage := buildOpenAIStyleUsageFromClaudeUsage(claudeInfo.Usage)
+ state.SetUsage(&openAIUsage)
+ finalResults, err := service.FinalizeStreamResponse(c, info, state)
+ if err != nil {
+ if failResponsesStream(err) {
+ return claudeInfo.Usage, streamErr
+ }
+ return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+ }
+ for _, result := range finalResults {
+ if !sendResult(result) {
+ return nil, streamErr
+ }
+ }
+ return claudeInfo.Usage, nil
+}
diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go
index c96ecca6fb7f..073e501526e1 100644
--- a/relay/channel/gemini/adaptor.go
+++ b/relay/channel/gemini/adaptor.go
@@ -13,8 +13,8 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
+ "github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
- "github.com/QuantumNous/new-api/setting/reasoning"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
@@ -24,6 +24,9 @@ type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
+ if err := relayconvert.ApplyGeminiThinkingConfigChecked(request, info); err != nil {
+ return nil, err
+ }
if len(request.Contents) > 0 {
for i, content := range request.Contents {
if i == 0 {
@@ -44,7 +47,7 @@ func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayIn
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
- result, err := relayconvert.ConvertRequest(c, info, types.RelayFormatGemini, req)
+ result, err := service.ConvertRequest(c, info, types.RelayFormatGemini, req)
if err != nil {
return nil, err
}
@@ -132,21 +135,6 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
- if model_setting.GetGeminiSettings().ThinkingAdapterEnabled &&
- !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
- // 新增逻辑:处理 -thinking- 格式
- if strings.Contains(info.UpstreamModelName, "-thinking-") {
- parts := strings.Split(info.UpstreamModelName, "-thinking-")
- info.UpstreamModelName = parts[0]
- } else if strings.HasSuffix(info.UpstreamModelName, "-thinking") { // 旧的适配
- info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
- } else if strings.HasSuffix(info.UpstreamModelName, "-nothinking") {
- info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking")
- } else if baseModel, level, ok := reasoning.TrimEffortSuffix(info.UpstreamModelName); ok && level != "" {
- info.UpstreamModelName = baseModel
- }
- }
-
version := model_setting.GetGeminiVersionSetting(info.UpstreamModelName)
if strings.HasPrefix(info.UpstreamModelName, "imagen") {
@@ -183,7 +171,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if request == nil {
return nil, errors.New("request is nil")
}
- result, err := relayconvert.ConvertRequest(c, info, types.RelayFormatGemini, request)
+ result, err := service.ConvertRequest(c, info, types.RelayFormatGemini, request)
if err != nil {
return nil, err
}
@@ -239,7 +227,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
}
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
- result, err := relayconvert.ConvertRequest(c, info, types.RelayFormatGemini, &request)
+ result, err := service.ConvertRequest(c, info, types.RelayFormatGemini, &request)
if err != nil {
return nil, err
}
diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go
index 74742d666b25..c6c913383bdf 100644
--- a/relay/channel/gemini/relay-gemini-native.go
+++ b/relay/channel/gemini/relay-gemini-native.go
@@ -34,6 +34,7 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
+ countGeminiBillableFunctionCalls(info, &geminiResponse)
if len(geminiResponse.Candidates) == 0 && geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
common.SetContextKey(c, constant.ContextKeyAdminRejectReason, fmt.Sprintf("gemini_block_reason=%s", *geminiResponse.PromptFeedback.BlockReason))
diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go
index 84acea73c585..e437f1277bd1 100644
--- a/relay/channel/gemini/relay-gemini.go
+++ b/relay/channel/gemini/relay-gemini.go
@@ -55,10 +55,13 @@ func patchGeminiZeroCompletionUsage(c *gin.Context, info *relaycommon.RelayInfo,
usage.CompletionTokens = imageCount * 1400
}
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
- // Overwrite the metadata-derived billing usage: effectiveBillingUsage prefers
- // BillingUsage during settlement, so keeping the prompt-only metadata there
- // would still bill zero completion tokens.
- usage.BillingUsage = dto.NewEstimatedGeminiChatBillingUsage(usage)
+ // Settlement prefers BillingUsage, so fill the missing completion in the
+ // original upstream dialect without discarding cache or modality details.
+ if usage.BillingUsage != nil {
+ usage.BillingUsage = dto.CloneBillingUsageWithEstimatedCompletion(usage.BillingUsage, usage.CompletionTokens)
+ } else {
+ usage.BillingUsage = dto.NewEstimatedGeminiChatBillingUsage(usage)
+ }
}
func geminiResponseUsageText(response *dto.GeminiChatResponse) string {
@@ -88,6 +91,23 @@ func markGeminiGoogleSearchCall(c *gin.Context, response *dto.GeminiChatResponse
}
}
+func countGeminiBillableFunctionCalls(info *relaycommon.RelayInfo, response *dto.GeminiChatResponse) {
+ if info == nil || response == nil {
+ return
+ }
+ for _, candidate := range response.Candidates {
+ for _, part := range candidate.Content.Parts {
+ if part.FunctionCall == nil {
+ continue
+ }
+ if part.FunctionCall.WillContinue != nil && *part.FunctionCall.WillContinue {
+ continue
+ }
+ info.CountBillableToolCall(dto.BuildInCallFunctionCall, part.FunctionCall.FunctionName)
+ }
+ }
+}
+
func buildUsageFromGeminiResponse(c *gin.Context, info *relaycommon.RelayInfo, response *dto.GeminiChatResponse) dto.Usage {
metadata := response.GetUsageMetadata()
if dto.HasGeminiUsageMetadataTokens(metadata) {
@@ -148,12 +168,15 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
var usage = &dto.Usage{}
var imageCount int
var hasBillableUsageMetadata bool
+ var streamErr error
+ var accumulatedUsageMetadata *dto.GeminiUsageMetadata
responseText := strings.Builder{}
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
var geminiResponse dto.GeminiChatResponse
if err := common.UnmarshalJsonStr(data, &geminiResponse); err != nil {
- sr.Stop(fmt.Errorf("unmarshal: %w", err))
+ streamErr = fmt.Errorf("unmarshal Gemini stream response: %w", err)
+ sr.Stop(streamErr)
return
}
@@ -162,6 +185,7 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
}
markGeminiGoogleSearchCall(c, &geminiResponse)
+ countGeminiBillableFunctionCalls(info, &geminiResponse)
// 统计图片数量
for _, candidate := range geminiResponse.Candidates {
@@ -177,13 +201,19 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
// 更新使用量统计
if metadata := geminiResponse.GetUsageMetadata(); dto.HasGeminiUsageMetadataTokens(metadata) {
- mappedUsage := buildUsageFromGeminiMetadata(metadata, info.GetEstimatePromptTokens())
+ accumulatedUsageMetadata = dto.MergeGeminiUsageMetadataNonZero(accumulatedUsageMetadata, metadata)
+ mappedUsage := buildUsageFromGeminiMetadata(accumulatedUsageMetadata, info.GetEstimatePromptTokens())
*usage = mappedUsage
hasBillableUsageMetadata = true
}
if !callback(data, &geminiResponse) {
- sr.Stop(fmt.Errorf("gemini callback stopped"))
+ if isGeminiDownstreamStop(c, info) {
+ sr.Stop(nil)
+ return
+ }
+ streamErr = errors.New("Gemini stream callback stopped")
+ sr.Stop(streamErr)
}
})
@@ -203,9 +233,24 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
patchGeminiZeroCompletionUsage(c, info, usage, responseText.String(), imageCount)
}
+ if streamErr != nil {
+ return usage, types.NewOpenAIError(streamErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
+ }
+ if info.StreamStatus != nil && !info.StreamStatus.IsNormalEnd() {
+ logger.LogWarn(c, fmt.Sprintf("Gemini stream ended unexpectedly: %s", info.StreamStatus.Summary()))
+ }
+
return usage, nil
}
+func isGeminiDownstreamStop(c *gin.Context, info *relaycommon.RelayInfo) bool {
+ if c != nil && c.Request != nil && c.Request.Context().Err() != nil {
+ return true
+ }
+ return info != nil && info.StreamStatus != nil &&
+ info.StreamStatus.EndReason == relaycommon.StreamEndReasonClientGone
+}
+
func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
id := helper.GetResponseID(c)
createAt := common.GetTimestamp()
@@ -323,6 +368,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
markGeminiGoogleSearchCall(c, &geminiResponse)
+ countGeminiBillableFunctionCalls(info, &geminiResponse)
if len(geminiResponse.Candidates) == 0 {
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
@@ -371,7 +417,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
case types.RelayFormatClaude:
- convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatClaude, fullTextResponse)
+ convertResult, err := service.ConvertResponse(c, info, types.RelayFormatClaude, fullTextResponse)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
diff --git a/relay/channel/gemini/relay_responses.go b/relay/channel/gemini/relay_responses.go
index 7b3d746b27a1..924fd04d043b 100644
--- a/relay/channel/gemini/relay_responses.go
+++ b/relay/channel/gemini/relay_responses.go
@@ -32,6 +32,7 @@ func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
markGeminiGoogleSearchCall(c, &geminiResponse)
+ countGeminiBillableFunctionCalls(info, &geminiResponse)
if len(geminiResponse.Candidates) == 0 {
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
@@ -50,15 +51,9 @@ func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
)
}
- chatResp := responseGeminiChat2OpenAI(c, &geminiResponse)
- chatResp.Model = info.UpstreamModelName
- if responseID := helper.GetResponseID(c); responseID != "" {
- chatResp.Id = responseID
- }
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
- chatResp.Usage = usage
- convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatOpenAIResponses, chatResp)
+ convertResult, err := service.ConvertResponse(c, info, types.RelayFormatOpenAIResponses, &geminiResponse)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
@@ -66,10 +61,11 @@ func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
if !ok {
return nil, types.NewOpenAIError(fmt.Errorf("expected OpenAI responses response, got %T", convertResult.Value), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
- responsesUsage := convertResult.Usage
- if responsesUsage == nil || responsesUsage.TotalTokens == 0 {
- responsesResp.Usage = relayconvert.UsageFromChatUsage(&usage)
+ if responseID := helper.GetResponseID(c); responseID != "" {
+ responsesResp.ID = responseID
}
+ responsesResp.Model = info.UpstreamModelName
+ responsesResp.Usage = relayconvert.UsageFromChatUsage(&usage)
responseBody, err = common.Marshal(responsesResp)
if err != nil {
@@ -82,17 +78,16 @@ func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
responseID := helper.GetResponseID(c)
created := common.GetTimestamp()
- state, err := relayconvert.NewResponseStreamState(types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses, relayconvert.ResponseStreamOptions{
- ID: responseID,
- Model: info.UpstreamModelName,
- Created: created,
+ state, err := relayconvert.NewResponseStreamState(types.RelayFormatGemini, types.RelayFormatOpenAIResponses, relayconvert.ResponseStreamOptions{
+ ID: responseID,
+ Model: info.UpstreamModelName,
+ Created: created,
+ EmitSequenceNumber: true,
})
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
- finishReason := constant.FinishReasonStop
- toolCallIndexByChoice := make(map[int]map[string]int)
- nextToolCallIndexByChoice := make(map[int]int)
+ hostedBridge := relayconvert.NewGeminiHostedStreamBridge()
var streamErr *types.NewAPIError
sendEvent := func(event relayconvert.ChatToResponsesStreamEvent) bool {
@@ -101,12 +96,37 @@ func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, r
streamErr = types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
return false
}
- helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: event.Type}, string(data))
+ if err := helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: event.Type}, string(data)); err != nil {
+ if info.StreamStatus != nil {
+ info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, err)
+ }
+ return false
+ }
+ return true
+ }
+ failResponsesStream := func(err error) bool {
+ failureResults, handled := state.FailResponsesStream("server_error", err.Error(), "")
+ if !handled {
+ return false
+ }
+ for _, result := range failureResults {
+ event, ok := result.Value.(relayconvert.ChatToResponsesStreamEvent)
+ if !ok {
+ streamErr = types.NewOpenAIError(fmt.Errorf("expected OAI responses stream event, got %T", result.Value), types.ErrorCodeBadResponse, http.StatusInternalServerError)
+ return true
+ }
+ if !sendEvent(event) {
+ return true
+ }
+ }
return true
}
- sendChunk := func(chunk *dto.ChatCompletionsStreamResponse) bool {
- results, err := relayconvert.ConvertStreamResponseChunk(c, info, state, chunk)
+ sendChunk := func(chunk *dto.GeminiChatResponse) bool {
+ results, err := service.ConvertStreamResponseChunk(c, info, state, chunk)
if err != nil {
+ if failResponsesStream(err) {
+ return false
+ }
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
return false
}
@@ -123,58 +143,46 @@ func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, r
return true
}
- usage, streamAPIError := geminiStreamHandler(c, info, resp, func(data string, geminiResponse *dto.GeminiChatResponse) bool {
- response, isStop := streamResponseGeminiChat2OpenAI(geminiResponse)
- response.Id = responseID
- response.Created = created
- response.Model = info.UpstreamModelName
-
- if response.IsToolCall() {
- finishReason = constant.FinishReasonToolCalls
- }
- for choiceIdx := range response.Choices {
- choiceKey := response.Choices[choiceIdx].Index
- for toolIdx := range response.Choices[choiceIdx].Delta.ToolCalls {
- tool := &response.Choices[choiceIdx].Delta.ToolCalls[toolIdx]
- if tool.ID == "" {
- continue
- }
- indexByID := toolCallIndexByChoice[choiceKey]
- if indexByID == nil {
- indexByID = make(map[string]int)
- toolCallIndexByChoice[choiceKey] = indexByID
- }
- if idx, ok := indexByID[tool.ID]; ok {
- tool.SetIndex(idx)
- continue
- }
- idx := nextToolCallIndexByChoice[choiceKey]
- nextToolCallIndexByChoice[choiceKey] = idx + 1
- indexByID[tool.ID] = idx
- tool.SetIndex(idx)
- }
- }
-
- if !sendChunk(response) {
- return false
- }
- if isStop {
- return sendChunk(helper.GenerateStopResponse(responseID, created, info.UpstreamModelName, finishReason))
- }
- return true
+ usage, streamAPIError := geminiStreamHandler(c, info, resp, func(_ string, geminiResponse *dto.GeminiChatResponse) bool {
+ hostedBridge.Observe(geminiResponse)
+ return sendChunk(geminiResponse)
})
if streamAPIError != nil {
+ if failResponsesStream(streamAPIError) && streamErr == nil {
+ return usage, nil
+ }
return usage, streamAPIError
}
+ if info.StreamStatus != nil && !info.StreamStatus.IsNormalEnd() {
+ if info.StreamStatus.EndReason != relaycommon.StreamEndReasonClientGone {
+ failResponsesStream(fmt.Errorf("gemini stream ended unexpectedly: %s", info.StreamStatus.Summary()))
+ }
+ return usage, nil
+ }
if streamErr != nil {
return nil, streamErr
}
+ hostedEvents, err := hostedBridge.Finalize(state)
+ if err != nil {
+ return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+ }
+ for _, event := range hostedEvents {
+ if !sendEvent(event) {
+ if streamErr != nil {
+ return usage, streamErr
+ }
+ return usage, nil
+ }
+ }
if usage != nil {
state.SetUsage(usage)
}
- finalResults, err := relayconvert.FinalizeStreamResponse(c, info, state)
+ finalResults, err := service.FinalizeStreamResponse(c, info, state)
if err != nil {
+ if failResponsesStream(err) {
+ return usage, streamErr
+ }
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
for _, result := range finalResults {
@@ -183,7 +191,10 @@ func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, r
return nil, types.NewOpenAIError(fmt.Errorf("expected OAI responses stream event, got %T", result.Value), types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
if !sendEvent(event) {
- return nil, streamErr
+ if streamErr != nil {
+ return usage, streamErr
+ }
+ return usage, nil
}
}
return usage, nil
diff --git a/relay/channel/newapi/adaptor.go b/relay/channel/newapi/adaptor.go
index 979d5c27691d..63c3ccfac271 100644
--- a/relay/channel/newapi/adaptor.go
+++ b/relay/channel/newapi/adaptor.go
@@ -75,14 +75,14 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
if request == nil {
return nil, errors.New("request is nil")
}
- return request, nil
+ return a.claudeAdaptor.ConvertClaudeRequest(c, info, request)
}
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
if request == nil {
return nil, errors.New("request is nil")
}
- return request, nil
+ return a.geminiAdaptor.ConvertGeminiRequest(c, info, request)
}
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go
index 64ae3102b3c2..dd64149b20da 100644
--- a/relay/channel/openai/adaptor.go
+++ b/relay/channel/openai/adaptor.go
@@ -19,14 +19,15 @@ import (
"github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/ai360"
"github.com/QuantumNous/new-api/relay/channel/lingyiwanwu"
+ "github.com/QuantumNous/new-api/relay/channel/openrouter"
"github.com/QuantumNous/new-api/relaykit/dto"
//"github.com/QuantumNous/new-api/relay/channel/minimax"
- "github.com/QuantumNous/new-api/relay/channel/openrouter"
"github.com/QuantumNous/new-api/relay/channel/xinference"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/common_handler"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
+ kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
@@ -249,80 +250,122 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
request.StreamOptions = nil
}
if info.ChannelType == constant.ChannelTypeOpenRouter {
+ initialIntent, err := kitreasoning.FromOpenAIChat(request)
+ if err != nil {
+ return nil, kitreasoning.AsClientError(err)
+ }
+ if request.THINKING != nil && strings.HasPrefix(info.UpstreamModelName, "anthropic") {
+ var thinking dto.Thinking
+ if err := common.Unmarshal(request.THINKING, &thinking); err != nil {
+ return nil, fmt.Errorf("error Unmarshal thinking: %w", err)
+ }
+ legacyIntent, err := kitreasoning.FromClaude(&dto.ClaudeRequest{Thinking: &thinking})
+ if err != nil {
+ return nil, kitreasoning.AsClientError(err)
+ }
+ initialIntent, err = kitreasoning.MergeExplicit(initialIntent, legacyIntent, request.Model)
+ if err != nil {
+ return nil, kitreasoning.AsClientError(err)
+ }
+ request.THINKING = nil
+ }
if len(request.Usage) == 0 {
request.Usage = json.RawMessage(`{"include":true}`)
}
// 适配 OpenRouter 的 thinking 后缀
- if !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) &&
- strings.HasSuffix(info.UpstreamModelName, "-thinking") {
- info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
- request.Model = info.UpstreamModelName
- if len(request.Reasoning) == 0 {
- reasoning := map[string]any{
- "enabled": true,
- }
- if request.ReasoningEffort != "" && request.ReasoningEffort != "none" {
- reasoning["effort"] = request.ReasoningEffort
- }
- marshal, err := common.Marshal(reasoning)
- if err != nil {
- return nil, fmt.Errorf("error marshalling reasoning: %w", err)
- }
- request.Reasoning = marshal
+ preserveSuffix := model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) || model_setting.ShouldPreserveThinkingSuffix(info.UpstreamModelName)
+ mergeEffortSuffix := func(modelName string) error {
+ rawEffort, _ := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(modelName)
+ if rawEffort == "" {
+ return nil
}
- // 清空多余的ReasoningEffort
- request.ReasoningEffort = ""
- } else {
- if len(request.Reasoning) == 0 {
- // 适配 OpenAI 的 ReasoningEffort 格式
- if request.ReasoningEffort != "" {
- reasoning := map[string]any{
- "enabled": true,
- }
- if request.ReasoningEffort != "none" {
- reasoning["effort"] = request.ReasoningEffort
- marshal, err := common.Marshal(reasoning)
- if err != nil {
- return nil, fmt.Errorf("error marshalling reasoning: %w", err)
- }
- request.Reasoning = marshal
- }
+ effort, err := kitreasoning.ParseEffort(rawEffort)
+ if err != nil {
+ return err
+ }
+ mode := kitreasoning.ModeEnabled
+ if effort == kitreasoning.EffortNone {
+ mode = kitreasoning.ModeDisabled
+ }
+ initialIntent, err = kitreasoning.MergeExplicitAndSuffix(initialIntent, kitreasoning.Intent{Mode: mode, Effort: effort, Source: kitreasoning.SourceSuffix}, modelName)
+ return err
+ }
+ if !preserveSuffix {
+ if err := mergeEffortSuffix(info.UpstreamModelName); err != nil {
+ return nil, kitreasoning.AsClientError(err)
+ }
+ if _, baseModel := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.UpstreamModelName); baseModel != info.UpstreamModelName {
+ info.UpstreamModelName = baseModel
+ request.Model = baseModel
+ }
+ if info.OriginModelName != info.UpstreamModelName {
+ if err := mergeEffortSuffix(info.OriginModelName); err != nil {
+ return nil, kitreasoning.AsClientError(err)
}
}
- request.ReasoningEffort = ""
}
-
- // https://docs.anthropic.com/en/api/openai-sdk#extended-thinking-support
- // 没有做排除3.5Haiku等,要出问题再加吧,最佳兼容性(不是
- if request.THINKING != nil && strings.HasPrefix(info.UpstreamModelName, "anthropic") {
- var thinking dto.Thinking // Claude标准Thinking格式
- if err := json.Unmarshal(request.THINKING, &thinking); err != nil {
- return nil, fmt.Errorf("error Unmarshal thinking: %w", err)
+ if !preserveSuffix && strings.HasSuffix(info.UpstreamModelName, "-thinking") {
+ initialIntent, err = kitreasoning.MergeExplicitAndSuffix(
+ initialIntent,
+ kitreasoning.Intent{Mode: kitreasoning.ModeEnabled},
+ info.UpstreamModelName,
+ )
+ if err != nil {
+ return nil, kitreasoning.AsClientError(err)
}
-
- // 只有当 thinking.Type 是 "enabled" 时才处理
- if thinking.Type == "enabled" {
- // 检查 BudgetTokens 是否为 nil
- if thinking.BudgetTokens == nil {
- return nil, fmt.Errorf("BudgetTokens is nil when thinking is enabled")
+ info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
+ request.Model = info.UpstreamModelName
+ }
+ if !preserveSuffix && info.OriginModelName != info.UpstreamModelName && strings.HasSuffix(info.OriginModelName, "-thinking") {
+ initialIntent, err = kitreasoning.MergeExplicitAndSuffix(
+ initialIntent,
+ kitreasoning.Intent{Mode: kitreasoning.ModeEnabled},
+ info.OriginModelName,
+ )
+ if err != nil {
+ return nil, kitreasoning.AsClientError(err)
+ }
+ }
+ if !initialIntent.IsEmpty() {
+ reasoningConfig := make(map[string]any)
+ if len(request.Reasoning) > 0 {
+ if err := common.Unmarshal(request.Reasoning, &reasoningConfig); err != nil {
+ return nil, fmt.Errorf("error unmarshalling reasoning: %w", err)
}
-
- reasoning := openrouter.RequestReasoning{
- Enabled: true,
- MaxTokens: *thinking.BudgetTokens,
+ if reasoningConfig == nil {
+ reasoningConfig = make(map[string]any)
}
-
- marshal, err := common.Marshal(reasoning)
- if err != nil {
- return nil, fmt.Errorf("error marshalling reasoning: %w", err)
+ }
+ disabled := initialIntent.Mode == kitreasoning.ModeDisabled || initialIntent.Effort == kitreasoning.EffortNone
+ if initialIntent.HasStrength() {
+ reasoningConfig["enabled"] = !disabled
+ if disabled {
+ delete(reasoningConfig, "effort")
+ delete(reasoningConfig, "max_tokens")
}
-
- request.Reasoning = marshal
}
-
- // 清空 THINKING
- request.THINKING = nil
+ if !disabled && initialIntent.BudgetTokens != nil {
+ reasoningConfig["max_tokens"] = *initialIntent.BudgetTokens
+ delete(reasoningConfig, "effort")
+ } else if !disabled && initialIntent.Effort != "" && initialIntent.Effort != kitreasoning.EffortNone {
+ reasoningConfig["effort"] = string(initialIntent.Effort)
+ delete(reasoningConfig, "max_tokens")
+ }
+ if initialIntent.IncludeThoughts != nil {
+ reasoningConfig["exclude"] = !*initialIntent.IncludeThoughts
+ }
+ marshal, err := common.Marshal(reasoningConfig)
+ if err != nil {
+ return nil, fmt.Errorf("error marshalling reasoning: %w", err)
+ }
+ request.Reasoning = marshal
+ }
+ request.ReasoningEffort = ""
+ effectiveEffort := kitreasoning.EffectiveEffort(initialIntent)
+ if initialIntent.BudgetTokens != nil {
+ effectiveEffort = kitreasoning.EffortFromBudget(*initialIntent.BudgetTokens)
}
+ info.SetReasoningEffort(string(effectiveEffort))
}
isOModel := dto.IsOpenAIReasoningOModel(info.UpstreamModelName)
@@ -344,16 +387,6 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
request.LogProbs = nil
}
- // 转换模型推理力度后缀
- effort, originModel := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.UpstreamModelName)
- if effort != "" {
- request.ReasoningEffort = effort
- info.UpstreamModelName = originModel
- request.Model = originModel
- }
-
- info.SetReasoningEffort(request.ReasoningEffort)
-
// o系列模型developer适配(o1-mini除外)
if !strings.HasPrefix(info.UpstreamModelName, "o1-mini") && !strings.HasPrefix(info.UpstreamModelName, "o1-preview") {
//修改第一个Message的内容,将system改为developer
@@ -363,6 +396,53 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
}
}
+ if info.ChannelType != constant.ChannelTypeOpenRouter {
+ preserveSuffix := model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) || model_setting.ShouldPreserveThinkingSuffix(info.UpstreamModelName)
+ effort, baseModel := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.UpstreamModelName)
+ if preserveSuffix {
+ effort = ""
+ }
+ currentIntent, err := kitreasoning.FromOpenAIChat(request)
+ if err != nil {
+ return nil, kitreasoning.AsClientError(err)
+ }
+ mergeSuffix := func(modelName, rawEffort string) error {
+ if rawEffort == "" {
+ return nil
+ }
+ suffixEffort, err := kitreasoning.ParseEffort(rawEffort)
+ if err != nil {
+ return err
+ }
+ mode := kitreasoning.ModeEnabled
+ if suffixEffort == kitreasoning.EffortNone {
+ mode = kitreasoning.ModeDisabled
+ }
+ currentIntent, err = kitreasoning.MergeExplicitAndSuffix(currentIntent, kitreasoning.Intent{Mode: mode, Effort: suffixEffort, Source: kitreasoning.SourceSuffix}, modelName)
+ return err
+ }
+ if err := mergeSuffix(info.UpstreamModelName, effort); err != nil {
+ return nil, kitreasoning.AsClientError(err)
+ }
+ if !preserveSuffix && info.OriginModelName != info.UpstreamModelName {
+ originEffort, _ := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.OriginModelName)
+ if err := mergeSuffix(info.OriginModelName, originEffort); err != nil {
+ return nil, kitreasoning.AsClientError(err)
+ }
+ }
+ if effort != "" {
+ info.UpstreamModelName = baseModel
+ request.Model = baseModel
+ }
+ if canonicalEffort := kitreasoning.OpenAIEffort(kitreasoning.EffectiveEffort(currentIntent)); canonicalEffort != "" {
+ request.ReasoningEffort = string(canonicalEffort)
+ info.SetReasoningEffort(string(canonicalEffort))
+ }
+ if info.ChannelType == constant.ChannelTypeOpenAI || info.ChannelType == constant.ChannelTypeAzure {
+ request.Reasoning = nil
+ }
+ }
+
return request, nil
}
@@ -604,18 +684,52 @@ func detectImageMimeType(filename string) string {
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
// 转换模型推理力度后缀
effort, originModel := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(request.Model)
- if effort != "" {
- if request.Reasoning == nil {
- request.Reasoning = &dto.Reasoning{
- Effort: effort,
- }
- } else {
- request.Reasoning.Effort = effort
+ preserveSuffix := model_setting.ShouldPreserveThinkingSuffix(request.Model) || (info != nil && model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName))
+ if preserveSuffix {
+ effort = ""
+ }
+ currentIntent, err := kitreasoning.FromOpenAIResponses(&request)
+ if err != nil {
+ return nil, kitreasoning.AsClientError(err)
+ }
+ mergeSuffix := func(modelName, rawEffort string) error {
+ if rawEffort == "" {
+ return nil
+ }
+ suffixEffort, err := kitreasoning.ParseEffort(rawEffort)
+ if err != nil {
+ return err
}
+ mode := kitreasoning.ModeEnabled
+ if suffixEffort == kitreasoning.EffortNone {
+ mode = kitreasoning.ModeDisabled
+ }
+ currentIntent, err = kitreasoning.MergeExplicitAndSuffix(currentIntent, kitreasoning.Intent{Mode: mode, Effort: suffixEffort, Source: kitreasoning.SourceSuffix}, modelName)
+ return err
+ }
+ if err := mergeSuffix(request.Model, effort); err != nil {
+ return nil, kitreasoning.AsClientError(err)
+ }
+ if !preserveSuffix && info != nil && info.OriginModelName != request.Model {
+ originEffort, _ := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.OriginModelName)
+ if err := mergeSuffix(info.OriginModelName, originEffort); err != nil {
+ return nil, kitreasoning.AsClientError(err)
+ }
+ }
+ if effort != "" {
request.Model = originModel
+ if info != nil {
+ info.UpstreamModelName = originModel
+ }
}
- if info != nil && request.Reasoning != nil && request.Reasoning.Effort != "" {
- info.SetReasoningEffort(request.Reasoning.Effort)
+ if canonicalEffort := kitreasoning.OpenAIEffort(kitreasoning.EffectiveEffort(currentIntent)); canonicalEffort != "" {
+ if request.Reasoning == nil {
+ request.Reasoning = &dto.Reasoning{}
+ }
+ request.Reasoning.Effort = string(canonicalEffort)
+ if info != nil {
+ info.SetReasoningEffort(string(canonicalEffort))
+ }
}
return request, nil
}
diff --git a/relay/channel/openai/chat_via_responses.go b/relay/channel/openai/chat_via_responses.go
index 25caeb5854bd..5a45585dcc59 100644
--- a/relay/channel/openai/chat_via_responses.go
+++ b/relay/channel/openai/chat_via_responses.go
@@ -41,33 +41,10 @@ func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
}
- chatResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatOpenAI, &responsesResp)
+ responseValue, usage, err := convertResponsesResponseForClient(c, info, &responsesResp)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
- chatResp, ok := chatResult.Value.(*dto.OpenAITextResponse)
- if !ok {
- return nil, types.NewOpenAIError(fmt.Errorf("expected OpenAI chat response, got %T", chatResult.Value), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
- }
- if chatID := helper.GetResponseID(c); chatID != "" {
- chatResp.Id = chatID
- }
- usage := chatResult.Usage
-
- if usage == nil || usage.TotalTokens == 0 {
- text := service.ExtractOutputTextFromResponses(&responsesResp)
- usage = service.ResponseText2Usage(c, text, info.UpstreamModelName, info.GetEstimatePromptTokens())
- chatResp.Usage = *usage
- }
-
- responseValue := any(chatResp)
- if info.RelayFormat != types.RelayFormatOpenAI {
- targetResult, err := relayconvert.ConvertResponse(c, info, info.RelayFormat, chatResp)
- if err != nil {
- return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
- }
- responseValue = targetResult.Value
- }
responseBody, err := common.Marshal(responseValue)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
@@ -150,39 +127,39 @@ func OaiResponsesToChatBufferedStreamHandler(c *gin.Context, info *relaycommon.R
}
accumulator.SupplementResponseOutput(finalResponse)
- chatResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatOpenAI, finalResponse)
+ responseValue, usage, err := convertResponsesResponseForClient(c, info, finalResponse)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
- chatResp, ok := chatResult.Value.(*dto.OpenAITextResponse)
- if !ok {
- return nil, types.NewOpenAIError(fmt.Errorf("expected OpenAI chat response, got %T", chatResult.Value), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
+ responseBody, err := common.Marshal(responseValue)
+ if err != nil {
+ return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
}
- if chatID := helper.GetResponseID(c); chatID != "" {
- chatResp.Id = chatID
+
+ service.IOCopyBytesGracefully(c, resp, responseBody)
+ return usage, nil
+}
+
+func convertResponsesResponseForClient(c *gin.Context, info *relaycommon.RelayInfo, response *dto.OpenAIResponsesResponse) (any, *dto.Usage, error) {
+ if responseID := helper.GetResponseID(c); responseID != "" {
+ response.ID = responseID
}
- usage := chatResult.Usage
+
+ usage := relayconvert.UsageFromResponsesUsage(response.Usage)
if usage == nil || usage.TotalTokens == 0 {
- text := service.ExtractOutputTextFromResponses(finalResponse)
+ text := service.ExtractOutputTextFromResponses(response)
usage = service.ResponseText2Usage(c, text, info.UpstreamModelName, info.GetEstimatePromptTokens())
- chatResp.Usage = *usage
+ response.Usage = relayconvert.UsageFromChatUsage(usage)
}
- responseValue := any(chatResp)
- if info.RelayFormat != types.RelayFormatOpenAI {
- targetResult, err := relayconvert.ConvertResponse(c, info, info.RelayFormat, chatResp)
- if err != nil {
- return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
- }
- responseValue = targetResult.Value
- }
- responseBody, err := common.Marshal(responseValue)
+ result, err := service.ConvertResponse(c, info, info.RelayFormat, response)
if err != nil {
- return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
+ return nil, nil, err
}
-
- service.IOCopyBytesGracefully(c, resp, responseBody)
- return usage, nil
+ if result.Usage != nil && result.Usage.TotalTokens != 0 {
+ usage = result.Usage
+ }
+ return result.Value, usage, nil
}
func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
@@ -293,7 +270,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return
}
- results, err := relayconvert.ConvertStreamResponseChunk(c, info, state, &streamResp)
+ results, err := service.ConvertStreamResponseChunk(c, info, state, &streamResp)
if err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
sr.Stop(streamErr)
@@ -320,7 +297,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil {
info.ClaudeConvertInfo.Usage = usage
}
- finalResults, err := relayconvert.FinalizeStreamResponse(c, info, state)
+ finalResults, err := service.FinalizeStreamResponse(c, info, state)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
diff --git a/relay/channel/openai/chat_via_responses_test.go b/relay/channel/openai/chat_via_responses_test.go
index df83b1d616b9..27ee71c9aba7 100644
--- a/relay/channel/openai/chat_via_responses_test.go
+++ b/relay/channel/openai/chat_via_responses_test.go
@@ -10,6 +10,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
@@ -171,6 +172,50 @@ func TestOaiResponsesToChatBufferedStreamHandlerReturnsJSONFromSSE(t *testing.T)
require.Contains(t, got, `"finish_reason":"tool_calls"`)
}
+func TestOaiResponsesToChatBufferedStreamHandlerPreservesInterleavedClaudeContent(t *testing.T) {
+ oldMode := gin.Mode()
+ gin.SetMode(gin.TestMode)
+ t.Cleanup(func() { gin.SetMode(oldMode) })
+
+ body := strings.Join([]string{
+ `data: {"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_1","summary":[]}}`,
+ `data: {"type":"response.reasoning_summary_text.delta","output_index":0,"item_id":"rs_1","delta":"**Planning file inspection**"}`,
+ `data: {"type":"response.output_item.added","output_index":1,"item":{"type":"message","id":"msg_1","role":"assistant","content":[]}}`,
+ `data: {"type":"response.output_text.delta","output_index":1,"item_id":"msg_1","delta":"I’ll inspect the starter repository."}`,
+ `data: {"type":"response.output_item.added","output_index":2,"item":{"type":"reasoning","id":"rs_2","summary":[]}}`,
+ `data: {"type":"response.reasoning_summary_text.delta","output_index":2,"item_id":"rs_2","delta":"**Clarifying environment task requirements**"}`,
+ `data: {"type":"response.output_item.added","output_index":3,"item":{"type":"message","id":"msg_2","role":"assistant","content":[]}}`,
+ `data: {"type":"response.output_text.delta","output_index":3,"item_id":"msg_2","delta":"What would you like me to build?"}`,
+ `data: {"type":"response.done","response":{"id":"resp_1","model":"gpt-test","status":"completed","usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}`,
+ `data: [DONE]`,
+ ``,
+ }, "\n")
+
+ c, recorder, resp, info := newResponsesChatTestContext(t, body, false)
+ info.RelayFormat = types.RelayFormatClaude
+
+ usage, apiErr := OaiResponsesToChatBufferedStreamHandler(c, info, resp)
+ require.Nil(t, apiErr)
+ require.NotNil(t, usage)
+ assert.Equal(t, 3, usage.TotalTokens)
+
+ var claudeResponse dto.ClaudeResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &claudeResponse))
+ require.Len(t, claudeResponse.Content, 4)
+ assert.Equal(t, []string{"thinking", "text", "thinking", "text"}, []string{
+ claudeResponse.Content[0].Type,
+ claudeResponse.Content[1].Type,
+ claudeResponse.Content[2].Type,
+ claudeResponse.Content[3].Type,
+ })
+ require.NotNil(t, claudeResponse.Content[0].Thinking)
+ require.NotNil(t, claudeResponse.Content[2].Thinking)
+ assert.Equal(t, "**Planning file inspection**", *claudeResponse.Content[0].Thinking)
+ assert.Equal(t, "I’ll inspect the starter repository.", claudeResponse.Content[1].GetText())
+ assert.Equal(t, "**Clarifying environment task requirements**", *claudeResponse.Content[2].Thinking)
+ assert.Equal(t, "What would you like me to build?", claudeResponse.Content[3].GetText())
+}
+
func TestOaiChatToResponsesStreamHandlerConvertsSSEOrderAndUsage(t *testing.T) {
oldMode := gin.Mode()
gin.SetMode(gin.TestMode)
diff --git a/relay/channel/openai/helper.go b/relay/channel/openai/helper.go
index 666235ff5633..3999ba560fae 100644
--- a/relay/channel/openai/helper.go
+++ b/relay/channel/openai/helper.go
@@ -19,16 +19,20 @@ import (
"github.com/gin-gonic/gin"
)
+const chatToGeminiStreamStateKey = "relaykit.chat_to_gemini_stream_state"
+
// 辅助函数
func HandleStreamFormat(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error {
- info.SendResponseCount++
-
switch info.RelayFormat {
case types.RelayFormatOpenAI:
+ info.SendResponseCount++
return sendStreamData(c, info, data, forceFormat, thinkToContent)
case types.RelayFormatClaude:
+ info.SendResponseCount++
return handleClaudeFormat(c, data, info)
case types.RelayFormatGemini:
+ // The stateful relaykit path owns its chunk counter so multi-hop and
+ // direct conversions observe the same stream state semantics.
return handleGeminiFormat(c, data, info)
}
return nil
@@ -41,9 +45,9 @@ func handleClaudeFormat(c *gin.Context, data string, info *relaycommon.RelayInfo
}
if streamResponse.Usage != nil {
- info.ClaudeConvertInfo.Usage = streamResponse.Usage
+ info.EnsureClaudeConvertInfo().Usage = streamResponse.Usage
}
- result, err := relayconvert.ConvertStreamResponse(c, info, types.RelayFormatClaude, &streamResponse)
+ result, err := service.ConvertStreamResponse(c, info, types.RelayFormatClaude, &streamResponse)
if err != nil {
return err
}
@@ -64,29 +68,55 @@ func handleGeminiFormat(c *gin.Context, data string, info *relaycommon.RelayInfo
return err
}
- result, err := relayconvert.ConvertStreamResponse(c, info, types.RelayFormatGemini, &streamResponse)
+ state, err := chatToGeminiStreamState(c, &streamResponse)
if err != nil {
return err
}
- geminiResponse, ok := result.Value.(*dto.GeminiChatResponse)
- if !ok {
- return fmt.Errorf("expected Gemini stream response, got %T", result.Value)
+ results, err := service.ConvertStreamResponseChunk(c, info, state, &streamResponse)
+ if err != nil {
+ return err
}
+ return sendGeminiStreamResults(c, results)
+}
- // 如果返回 nil,表示没有实际内容,跳过发送
- if geminiResponse == nil {
- return nil
+func chatToGeminiStreamState(c *gin.Context, streamResponse *dto.ChatCompletionsStreamResponse) (*relayconvert.ResponseStreamState, error) {
+ if value, ok := c.Get(chatToGeminiStreamStateKey); ok {
+ state, ok := value.(*relayconvert.ResponseStreamState)
+ if !ok || state == nil {
+ return nil, fmt.Errorf("invalid Chat-to-Gemini stream state %T", value)
+ }
+ return state, nil
}
- geminiResponseStr, err := common.Marshal(geminiResponse)
+ state, err := relayconvert.NewResponseStreamState(types.RelayFormatOpenAI, types.RelayFormatGemini, relayconvert.ResponseStreamOptions{
+ ID: streamResponse.Id,
+ Model: streamResponse.Model,
+ Created: streamResponse.Created,
+ })
if err != nil {
- logger.LogError(c, "failed to marshal gemini response: "+err.Error())
- return err
+ return nil, err
}
+ c.Set(chatToGeminiStreamStateKey, state)
+ return state, nil
+}
- // send gemini format response
- c.Render(-1, common.CustomEvent{Data: "data: " + string(geminiResponseStr)})
- _ = helper.FlushWriter(c)
+func sendGeminiStreamResults(c *gin.Context, results []relayconvert.ResponseResult) error {
+ for _, result := range results {
+ geminiResponse, ok := result.Value.(*dto.GeminiChatResponse)
+ if !ok {
+ return fmt.Errorf("expected Gemini stream response, got %T", result.Value)
+ }
+ if geminiResponse == nil {
+ continue
+ }
+ data, err := common.Marshal(geminiResponse)
+ if err != nil {
+ logger.LogError(c, "failed to marshal gemini response: "+err.Error())
+ return err
+ }
+ c.Render(-1, common.CustomEvent{Data: "data: " + string(data)})
+ _ = helper.FlushWriter(c)
+ }
return nil
}
@@ -148,7 +178,7 @@ func handleLastResponse(lastStreamData string, responseId *string, createAt *int
if service.ValidUsage(lastStreamResponse.Usage) {
*containStreamUsage = true
- *usage = lastStreamResponse.Usage
+ *usage = dto.MergeUsageNonZero(*usage, lastStreamResponse.Usage)
if !info.ShouldIncludeUsage {
*shouldSendLastResp = lo.SomeBy(lastStreamResponse.Choices, func(choice dto.ChatCompletionsStreamResponseChoice) bool {
return choice.Delta.GetContentString() != "" || choice.Delta.GetReasoningContent() != ""
@@ -181,7 +211,7 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
info.ClaudeConvertInfo.Usage = usage
- result, err := relayconvert.ConvertStreamResponse(c, info, types.RelayFormatClaude, &streamResponse)
+ result, err := service.ConvertStreamResponse(c, info, types.RelayFormatClaude, &streamResponse)
if err != nil {
common.SysLog("error converting Claude stream response: " + err.Error())
return
@@ -203,36 +233,31 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
return
}
- // 这里处理的是 openai 最后一个流响应,其 delta 为空,有 finish_reason 字段
- // 因此相比较于 google 官方的流响应,由 openai 转换而来会多一个 parts 为空,finishReason 为 STOP 的响应
- // 而包含最后一段文本输出的响应(倒数第二个)的 finishReason 为 null
- // 暂不知是否有程序会不兼容。
-
- result, err := relayconvert.ConvertStreamResponse(c, info, types.RelayFormatGemini, &streamResponse)
+ state, err := chatToGeminiStreamState(c, &streamResponse)
if err != nil {
- common.SysLog("error converting Gemini stream response: " + err.Error())
+ common.SysLog("error creating Gemini stream state: " + err.Error())
return
}
- geminiResponse, ok := result.Value.(*dto.GeminiChatResponse)
- if !ok {
- common.SysLog(fmt.Sprintf("expected Gemini stream response, got %T", result.Value))
+ state.SetUsage(usage)
+
+ results, err := service.ConvertStreamResponseChunk(c, info, state, &streamResponse)
+ if err != nil {
+ common.SysLog("error converting final Gemini stream response: " + err.Error())
return
}
-
- // openai 流响应开头的空数据
- if geminiResponse == nil {
+ if err := sendGeminiStreamResults(c, results); err != nil {
+ common.SysLog("error sending final Gemini stream response: " + err.Error())
return
}
- geminiResponseStr, err := common.Marshal(geminiResponse)
+ results, err = service.FinalizeStreamResponse(c, info, state)
if err != nil {
- common.SysLog("error marshalling gemini response: " + err.Error())
+ common.SysLog("error finalizing Gemini stream response: " + err.Error())
return
}
-
- // 发送最终的 Gemini 响应
- c.Render(-1, common.CustomEvent{Data: "data: " + string(geminiResponseStr)})
- _ = helper.FlushWriter(c)
+ if err := sendGeminiStreamResults(c, results); err != nil {
+ common.SysLog("error sending finalized Gemini stream response: " + err.Error())
+ }
}
}
diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go
index 9a0619eb27f5..285e342f824a 100644
--- a/relay/channel/openai/relay-openai.go
+++ b/relay/channel/openai/relay-openai.go
@@ -13,7 +13,6 @@ import (
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
- "github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
@@ -118,13 +117,10 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
var toolCount int
var usage = &dto.Usage{}
var lastStreamData string
- var secondLastStreamData string // 存储倒数第二个stream data,用于音频模型
+ var secondLastStreamData string // 保留倒数第二个stream data;部分兼容网关把完整usage放在倒数第二个事件
seenStreamToolCalls := make(map[string]struct{})
var streamFunctionCallNames []string
- // 检查是否为音频模型
- isAudioModel := strings.Contains(strings.ToLower(model), "audio")
-
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
if lastStreamData != "" {
if err := HandleStreamFormat(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent); err != nil {
@@ -133,8 +129,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
}
}
if len(data) > 0 {
- // 对音频模型,保存倒数第二个stream data
- if isAudioModel && lastStreamData != "" {
+ if lastStreamData != "" {
secondLastStreamData = lastStreamData
}
@@ -147,31 +142,36 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
}
})
- // 对音频模型,从倒数第二个stream data中提取usage信息
- if isAudioModel && secondLastStreamData != "" {
+ // 处理最后的响应
+ shouldSendLastResp := true
+ if err := handleLastResponse(lastStreamData, &responseId, &createAt, &systemFingerprint, &model, &usage,
+ &containStreamUsage, info, &shouldSendLastResp); err != nil {
+ logger.LogError(c, fmt.Sprintf("error handling last response: %s, lastStreamData: [%s]", err.Error(), lastStreamData))
+ }
+
+ // 部分兼容网关把完整的累计usage附在倒数第二个事件上,随后发送一个空的最后事件。
+ // 仅当最后一个事件没有有效usage时,回退到倒数第二个事件的完整快照。
+ usageFrame := lastStreamData
+ if !containStreamUsage && secondLastStreamData != "" {
var streamResp struct {
Usage *dto.Usage `json:"usage"`
}
err := common.Unmarshal([]byte(secondLastStreamData), &streamResp)
- if err == nil && streamResp.Usage != nil && service.ValidUsage(streamResp.Usage) {
- usage = streamResp.Usage
+ if err == nil && streamResp.Usage != nil &&
+ streamResp.Usage.PromptTokens > 0 &&
+ (streamResp.Usage.CompletionTokens > 0 || streamResp.Usage.TotalTokens > 0) {
+ usage = dto.MergeUsageNonZero(usage, streamResp.Usage)
containStreamUsage = true
+ usageFrame = secondLastStreamData
if common.DebugEnabled {
- logger.LogDebug(c, "Audio model usage extracted from second last SSE: PromptTokens=%d, CompletionTokens=%d, TotalTokens=%d, InputTokens=%d, OutputTokens=%d",
+ logger.LogDebug(c, "usage extracted from second last SSE: PromptTokens=%d, CompletionTokens=%d, TotalTokens=%d, InputTokens=%d, OutputTokens=%d",
usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens,
usage.InputTokens, usage.OutputTokens)
}
}
}
- // 处理最后的响应
- shouldSendLastResp := true
- if err := handleLastResponse(lastStreamData, &responseId, &createAt, &systemFingerprint, &model, &usage,
- &containStreamUsage, info, &shouldSendLastResp); err != nil {
- logger.LogError(c, fmt.Sprintf("error handling last response: %s, lastStreamData: [%s]", err.Error(), lastStreamData))
- }
-
if info.RelayFormat == types.RelayFormatOpenAI {
if shouldSendLastResp {
_ = sendStreamData(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent)
@@ -183,7 +183,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
usage.CompletionTokens += toolCount * 7
}
- applyUsagePostProcessing(info, usage, common.StringToByteSlice(lastStreamData))
+ applyUsagePostProcessing(info, usage, common.StringToByteSlice(usageFrame))
for _, name := range streamFunctionCallNames {
info.CountBillableToolCall(dto.BuildInCallFunctionCall, name)
@@ -201,7 +201,7 @@ func collectStreamFunctionCallNames(data string, seen map[string]struct{}, names
}
for _, choice := range streamResponse.Choices {
for i, tc := range choice.Delta.ToolCalls {
- name := tc.Function.Name
+ name := strings.TrimSpace(tc.Function.Name)
if name == "" {
continue
}
@@ -209,11 +209,30 @@ func collectStreamFunctionCallNames(data string, seen map[string]struct{}, names
if tc.Index != nil {
toolIdx = *tc.Index
}
- key := fmt.Sprintf("%d-%d", choice.Index, toolIdx)
- if _, ok := seen[key]; ok {
- continue
+ fallbackKey := fmt.Sprintf("index\x00%d\x00%d\x00%s", choice.Index, toolIdx, name)
+ activeKey := fmt.Sprintf("active\x00%d\x00%d\x00%s", choice.Index, toolIdx, name)
+ callID := strings.TrimSpace(tc.ID)
+ if callID != "" {
+ idKey := fmt.Sprintf("id\x00%d\x00%s", choice.Index, callID)
+ if _, ok := seen[idKey]; ok {
+ continue
+ }
+ seen[idKey] = struct{}{}
+ seen[activeKey] = struct{}{}
+ if _, delayedID := seen[fallbackKey]; delayedID {
+ delete(seen, fallbackKey)
+ continue
+ }
+ } else {
+ if _, ok := seen[fallbackKey]; ok {
+ continue
+ }
+ if _, ok := seen[activeKey]; ok {
+ continue
+ }
+ seen[fallbackKey] = struct{}{}
+ seen[activeKey] = struct{}{}
}
- seen[key] = struct{}{}
*names = append(*names, name)
}
}
@@ -280,11 +299,12 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
completionTokens += ctkm
}
}
- simpleResponse.Usage = dto.Usage{
+ fallbackUsage := &dto.Usage{
PromptTokens: info.GetEstimatePromptTokens(),
CompletionTokens: completionTokens,
TotalTokens: info.GetEstimatePromptTokens() + completionTokens,
}
+ simpleResponse.Usage = *fallbackUsage
usageModified = true
}
@@ -310,7 +330,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
break
}
case types.RelayFormatClaude:
- convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatClaude, &simpleResponse)
+ convertResult, err := service.ConvertResponse(c, info, types.RelayFormatClaude, &simpleResponse)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
@@ -320,7 +340,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
}
responseBody = claudeRespStr
case types.RelayFormatGemini:
- convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatGemini, &simpleResponse)
+ convertResult, err := service.ConvertResponse(c, info, types.RelayFormatGemini, &simpleResponse)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go
index ceca1af3b381..93b2599ded6f 100644
--- a/relay/channel/openai/relay_responses.go
+++ b/relay/channel/openai/relay_responses.go
@@ -11,6 +11,7 @@ import (
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
@@ -38,16 +39,7 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
service.IOCopyBytesGracefully(c, resp, responseBody)
// compute usage
- usage := dto.Usage{}
- if responsesResponse.Usage != nil {
- usage.PromptTokens = responsesResponse.Usage.InputTokens
- usage.CompletionTokens = responsesResponse.Usage.OutputTokens
- usage.TotalTokens = responsesResponse.Usage.TotalTokens
- if responsesResponse.Usage.InputTokensDetails != nil {
- usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens
- usage.PromptTokensDetails.CacheWriteTokens = responsesResponse.Usage.InputTokensDetails.CacheWriteTokens
- }
- }
+ usage := relayconvert.NormalizeResponsesUsage(responsesResponse.Usage)
// Count actual tool invocations from Output (not tool declarations).
for _, output := range responsesResponse.Output {
switch output.Type {
@@ -69,7 +61,7 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
}
imageCounter.Commit(info)
- return &usage, nil
+ return usage, nil
}
func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
@@ -99,19 +91,8 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
case "response.completed", "response.done":
if streamResponse.Response != nil {
if streamResponse.Response.Usage != nil {
- if streamResponse.Response.Usage.InputTokens != 0 {
- usage.PromptTokens = streamResponse.Response.Usage.InputTokens
- }
- if streamResponse.Response.Usage.OutputTokens != 0 {
- usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
- }
- if streamResponse.Response.Usage.TotalTokens != 0 {
- usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
- }
- if streamResponse.Response.Usage.InputTokensDetails != nil {
- usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
- usage.PromptTokensDetails.CacheWriteTokens = streamResponse.Response.Usage.InputTokensDetails.CacheWriteTokens
- }
+ incomingUsage := relayconvert.NormalizeResponsesUsage(streamResponse.Response.Usage)
+ usage = dto.MergeUsageNonZero(usage, incomingUsage)
}
if !imageCommitted {
if relaycommon.IsNonBillableResponsesStatus(streamResponse.Response.Status) {
@@ -173,6 +154,9 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
}
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
+ if usage.BillingUsage != nil {
+ usage.BillingUsage = dto.CloneBillingUsageWithEstimatedCompletion(usage.BillingUsage, usage.CompletionTokens)
+ }
return usage, nil
}
diff --git a/relay/channel/openai/responses_via_chat.go b/relay/channel/openai/responses_via_chat.go
index 53b9d33cbc0e..648770832852 100644
--- a/relay/channel/openai/responses_via_chat.go
+++ b/relay/channel/openai/responses_via_chat.go
@@ -38,7 +38,7 @@ func OaiChatToResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
if responseID := helper.GetResponseID(c); responseID != "" {
chatResp.Id = responseID
}
- convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatOpenAIResponses, &chatResp)
+ convertResult, err := service.ConvertResponse(c, info, types.RelayFormatOpenAIResponses, &chatResp)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
@@ -70,8 +70,9 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
responseID := helper.GetResponseID(c)
state, err := relayconvert.NewResponseStreamState(types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses, relayconvert.ResponseStreamOptions{
- ID: responseID,
- Model: info.UpstreamModelName,
+ ID: responseID,
+ Model: info.UpstreamModelName,
+ EmitSequenceNumber: true,
})
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
@@ -84,7 +85,27 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
streamErr = types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
return false
}
- helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: event.Type}, string(data))
+ if err := helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: event.Type}, string(data)); err != nil {
+ streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+ return false
+ }
+ return true
+ }
+ failResponsesStream := func(err error) bool {
+ failureResults, handled := state.FailResponsesStream("server_error", err.Error(), "")
+ if !handled {
+ return false
+ }
+ for _, result := range failureResults {
+ event, ok := result.Value.(relayconvert.ChatToResponsesStreamEvent)
+ if !ok {
+ streamErr = types.NewOpenAIError(fmt.Errorf("expected OAI responses stream event, got %T", result.Value), types.ErrorCodeBadResponse, http.StatusInternalServerError)
+ return true
+ }
+ if !sendEvent(event) {
+ return true
+ }
+ }
return true
}
@@ -97,6 +118,10 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
var errorResp dto.OpenAITextResponse
if err := common.UnmarshalJsonStr(data, &errorResp); err == nil {
if oaiError := errorResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
+ if failResponsesStream(fmt.Errorf("%s", oaiError.Message)) {
+ sr.Stop(streamErr)
+ return
+ }
streamErr = types.WithOpenAIError(*oaiError, resp.StatusCode)
sr.Stop(streamErr)
return
@@ -106,12 +131,21 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
var chunk dto.ChatCompletionsStreamResponse
if err := common.UnmarshalJsonStr(data, &chunk); err != nil {
logger.LogError(c, "failed to unmarshal chat stream response: "+err.Error())
- sr.Error(err)
+ if failResponsesStream(err) {
+ sr.Stop(streamErr)
+ return
+ }
+ streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
+ sr.Stop(streamErr)
return
}
- results, err := relayconvert.ConvertStreamResponseChunk(c, info, state, &chunk)
+ results, err := service.ConvertStreamResponseChunk(c, info, state, &chunk)
if err != nil {
+ if failResponsesStream(err) {
+ sr.Stop(streamErr)
+ return
+ }
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
sr.Stop(streamErr)
return
@@ -140,8 +174,11 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
state.SetUsage(usage)
}
- finalResults, err := relayconvert.FinalizeStreamResponse(c, info, state)
+ finalResults, err := service.FinalizeStreamResponse(c, info, state)
if err != nil {
+ if failResponsesStream(err) {
+ return usage, streamErr
+ }
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
for _, result := range finalResults {
diff --git a/relay/channel/sub2api/adaptor_test.go b/relay/channel/sub2api/adaptor_test.go
index 4ce3912a7bb9..7c1f1c33f00b 100644
--- a/relay/channel/sub2api/adaptor_test.go
+++ b/relay/channel/sub2api/adaptor_test.go
@@ -1,11 +1,13 @@
package sub2api
import (
+ "encoding/json"
"testing"
"github.com/QuantumNous/new-api/constant"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
+ "github.com/QuantumNous/new-api/relaykit/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -44,3 +46,40 @@ func TestAdaptorInheritsNewAPIResponsesCompactSupport(t *testing.T) {
assert.Equal(t, "sub2api", adaptor.GetChannelName())
assert.Empty(t, adaptor.GetModelList())
}
+
+func TestConvertClaudeRequestPreservesAdaptiveThinkingForCompatibleModel(t *testing.T) {
+ adaptor := &Adaptor{}
+ maxTokens := uint(8192)
+ temperature := 0.2
+ topP := 0.99
+ request := &dto.ClaudeRequest{
+ Model: "gpt-5.6-sol",
+ MaxTokens: &maxTokens,
+ Temperature: &temperature,
+ TopP: &topP,
+ Thinking: &dto.Thinking{Type: "adaptive", Display: "summarized"},
+ OutputConfig: json.RawMessage(`{"effort":"xhigh","provider_option":true}`),
+ Messages: []dto.ClaudeMessage{
+ {Role: "user", Content: "hello"},
+ },
+ }
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "gpt-5.6-sol",
+ ChannelMeta: &relaycommon.ChannelMeta{
+ ChannelType: constant.ChannelTypeSub2API,
+ },
+ }
+
+ converted, err := adaptor.ConvertClaudeRequest(nil, info, request)
+
+ require.NoError(t, err)
+ assert.Same(t, request, converted)
+ require.NotNil(t, request.Thinking)
+ assert.Equal(t, "adaptive", request.Thinking.Type)
+ assert.Equal(t, "summarized", request.Thinking.Display)
+ assert.JSONEq(t, `{"effort":"xhigh","provider_option":true}`, string(request.OutputConfig))
+ assert.Same(t, &temperature, request.Temperature)
+ assert.Same(t, &topP, request.TopP)
+ assert.Equal(t, "xhigh", info.ReasoningEffort)
+ assert.Equal(t, "gpt-5.6-sol", info.UpstreamModelName)
+}
diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go
index c60d75d29f21..5e306318bb95 100644
--- a/relay/channel/vertex/adaptor.go
+++ b/relay/channel/vertex/adaptor.go
@@ -18,7 +18,6 @@ import (
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
- "github.com/QuantumNous/new-api/setting/reasoning"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
@@ -56,15 +55,16 @@ type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
- // Vertex AI does not support functionResponse.id; keep it stripped here for consistency.
+ // Vertex AI's generateContent schema does not expose the Gemini API's
+ // function-call identity fields. Strip both sides at this provider boundary.
if model_setting.GetGeminiSettings().RemoveFunctionResponseIdEnabled {
- removeFunctionResponseID(request)
+ removeFunctionCallIDs(request)
}
geminiAdaptor := gemini.Adaptor{}
return geminiAdaptor.ConvertGeminiRequest(c, info, request)
}
-func removeFunctionResponseID(request *dto.GeminiChatRequest) {
+func removeFunctionCallIDs(request *dto.GeminiChatRequest) {
if request == nil {
return
}
@@ -76,10 +76,10 @@ func removeFunctionResponseID(request *dto.GeminiChatRequest) {
}
for j := range request.Contents[i].Parts {
part := &request.Contents[i].Parts[j]
- if part.FunctionResponse == nil {
- continue
+ if part.FunctionCall != nil {
+ part.FunctionCall.ID = ""
}
- if len(part.FunctionResponse.ID) > 0 {
+ if part.FunctionResponse != nil && len(part.FunctionResponse.ID) > 0 {
part.FunctionResponse.ID = nil
}
}
@@ -88,12 +88,16 @@ func removeFunctionResponseID(request *dto.GeminiChatRequest) {
if len(request.Requests) > 0 {
for i := range request.Requests {
- removeFunctionResponseID(&request.Requests[i])
+ removeFunctionCallIDs(&request.Requests[i])
}
}
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
+ claudeAdaptor := claude.Adaptor{}
+ if _, err := claudeAdaptor.ConvertClaudeRequest(c, info, request); err != nil {
+ return nil, err
+ }
if v, ok := claudeModelMap[info.UpstreamModelName]; ok {
c.Set("request_model", v)
} else {
@@ -170,21 +174,6 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
suffix := ""
if a.RequestMode == RequestModeGemini {
- if model_setting.GetGeminiSettings().ThinkingAdapterEnabled &&
- !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
- // 新增逻辑:处理 -thinking- 格式
- if strings.Contains(info.UpstreamModelName, "-thinking-") {
- parts := strings.Split(info.UpstreamModelName, "-thinking-")
- info.UpstreamModelName = parts[0]
- } else if strings.HasSuffix(info.UpstreamModelName, "-thinking") { // 旧的适配
- info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
- } else if strings.HasSuffix(info.UpstreamModelName, "-nothinking") {
- info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking")
- } else if baseModel, level, ok := reasoning.TrimEffortSuffix(info.UpstreamModelName); ok && level != "" {
- info.UpstreamModelName = baseModel
- }
- }
-
if info.IsStream {
suffix = "streamGenerateContent?alt=sse"
} else {
@@ -310,6 +299,9 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if !ok {
return nil, fmt.Errorf("expected Gemini generateContent request, got %T", result.Value)
}
+ if model_setting.GetGeminiSettings().RemoveFunctionResponseIdEnabled {
+ removeFunctionCallIDs(geminiRequest)
+ }
c.Set("request_model", request.Model)
return geminiRequest, nil
} else if a.RequestMode == RequestModeOpenSource {
diff --git a/relay/channel/zhipu_4v/adaptor.go b/relay/channel/zhipu_4v/adaptor.go
index 9153d39f35c6..d27232e2d155 100644
--- a/relay/channel/zhipu_4v/adaptor.go
+++ b/relay/channel/zhipu_4v/adaptor.go
@@ -28,7 +28,8 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
- return req, nil
+ claudeAdaptor := claude.Adaptor{}
+ return claudeAdaptor.ConvertClaudeRequest(c, info, req)
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
diff --git a/relay/chat_completions_via_responses.go b/relay/chat_completions_via_responses.go
index b8a6fc875872..202c04137c76 100644
--- a/relay/chat_completions_via_responses.go
+++ b/relay/chat_completions_via_responses.go
@@ -70,30 +70,35 @@ func applySystemPromptIfNeeded(c *gin.Context, info *relaycommon.RelayInfo, requ
}
}
-func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.Adaptor, request *dto.GeneralOpenAIRequest) (*dto.Usage, *types.NewAPIError) {
- chatJSON, err := common.Marshal(request)
- if err != nil {
- return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
- }
-
- chatJSON, err = relaycommon.RemoveDisabledFields(chatJSON, info.ChannelOtherSettings, info.ChannelSetting.PassThroughBodyEnabled)
- if err != nil {
- return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
- }
+func textRequestViaResponses(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.Adaptor, request any) (*dto.Usage, *types.NewAPIError) {
+ paramOverrideApplied := false
+ if chatRequest, ok := request.(*dto.GeneralOpenAIRequest); ok {
+ chatJSON, err := common.Marshal(chatRequest)
+ if err != nil {
+ return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
+ }
- if len(info.ParamOverride) > 0 {
- chatJSON, err = relaycommon.ApplyParamOverrideWithRelayInfo(chatJSON, info)
+ chatJSON, err = relaycommon.RemoveDisabledFields(chatJSON, info.ChannelOtherSettings, info.ChannelSetting.PassThroughBodyEnabled)
if err != nil {
- return nil, newAPIErrorFromParamOverride(err)
+ return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
+ }
+
+ if len(info.ParamOverride) > 0 {
+ chatJSON, err = relaycommon.ApplyParamOverrideWithRelayInfo(chatJSON, info)
+ if err != nil {
+ return nil, newAPIErrorFromParamOverride(err)
+ }
+ paramOverrideApplied = true
}
- }
- var overriddenChatReq dto.GeneralOpenAIRequest
- if err := common.Unmarshal(chatJSON, &overriddenChatReq); err != nil {
- return nil, types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
+ var overriddenChatReq dto.GeneralOpenAIRequest
+ if err := common.Unmarshal(chatJSON, &overriddenChatReq); err != nil {
+ return nil, types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
+ }
+ request = &overriddenChatReq
}
- result, err := service.ConvertRequestVia(c, info, &overriddenChatReq, types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses)
+ result, err := service.ConvertRequest(c, info, types.RelayFormatOpenAIResponses, request)
if err != nil {
return nil, types.NewErrorWithStatusCode(err, types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
}
@@ -101,7 +106,10 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad
if !ok {
return nil, types.NewError(fmt.Errorf("expected OpenAI responses request, got %T", result.Value), types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
+ return relayResponsesRequest(c, info, adaptor, responsesReq, paramOverrideApplied)
+}
+func relayResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.Adaptor, responsesReq *dto.OpenAIResponsesRequest, paramOverrideApplied bool) (*dto.Usage, *types.NewAPIError) {
savedRelayMode := info.RelayMode
savedRequestURLPath := info.RequestURLPath
defer func() {
@@ -114,7 +122,7 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad
convertedRequest, err := adaptor.ConvertOpenAIResponsesRequest(c, info, *responsesReq)
if err != nil {
- return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
+ return nil, newConvertRequestFailedError(c, info, err)
}
relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
@@ -127,6 +135,12 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad
if err != nil {
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
+ if !paramOverrideApplied && len(info.ParamOverride) > 0 {
+ jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info)
+ if err != nil {
+ return nil, newAPIErrorFromParamOverride(err)
+ }
+ }
body, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
if err != nil {
diff --git a/relay/chat_completions_via_responses_test.go b/relay/chat_completions_via_responses_test.go
index 185878744436..5b1067ee2efa 100644
--- a/relay/chat_completions_via_responses_test.go
+++ b/relay/chat_completions_via_responses_test.go
@@ -1,11 +1,21 @@
package relay
import (
+ "io"
"math"
+ "net/http"
+ "net/http/httptest"
"testing"
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ openaichannel "github.com/QuantumNous/new-api/relay/channel/openai"
relaycommon "github.com/QuantumNous/new-api/relay/common"
- "github.com/QuantumNous/new-api/types"
+ relayconstant "github.com/QuantumNous/new-api/relay/constant"
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ relaytypes "github.com/QuantumNous/new-api/relaykit/types"
+ hosttypes "github.com/QuantumNous/new-api/types"
+ "github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -31,7 +41,7 @@ func TestIsResponsesEventStreamContentType(t *testing.T) {
func TestRecalcQuotaFromRatiosIgnoresInvalidMultipliers(t *testing.T) {
info := &relaycommon.RelayInfo{
- PriceData: types.PriceData{
+ PriceData: hosttypes.PriceData{
Quota: 100,
},
}
@@ -52,7 +62,7 @@ func TestRecalcQuotaFromRatiosIgnoresInvalidMultipliers(t *testing.T) {
func TestRecalcQuotaFromRatiosRejectsAllInvalidAdjustedRatios(t *testing.T) {
info := &relaycommon.RelayInfo{
- PriceData: types.PriceData{
+ PriceData: hosttypes.PriceData{
Quota: 100,
},
}
@@ -69,3 +79,77 @@ func TestRecalcQuotaFromRatiosRejectsAllInvalidAdjustedRatios(t *testing.T) {
assert.Equal(t, 0, quota)
assert.True(t, info.PriceData.HasOtherRatio("duration"))
}
+
+func TestTextRequestViaResponsesConvertsClaudeDirectly(t *testing.T) {
+ type capturedRequest struct {
+ path string
+ body []byte
+ }
+ captured := make(chan capturedRequest, 1)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ captured <- capturedRequest{path: r.URL.Path, body: body}
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{
+ "id":"resp_1",
+ "object":"response",
+ "status":"completed",
+ "model":"gpt-5.6-sol",
+ "output":[{"type":"message","id":"msg_1","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],
+ "usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}
+ }`))
+ }))
+ defer server.Close()
+
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ info := &relaycommon.RelayInfo{
+ RelayMode: relayconstant.RelayModeChatCompletions,
+ RelayFormat: relaytypes.RelayFormatClaude,
+ OriginModelName: "gpt-5.6-sol",
+ RequestConversionChain: []relaytypes.RelayFormat{relaytypes.RelayFormatClaude},
+ ChannelMeta: &relaycommon.ChannelMeta{
+ ChannelType: constant.ChannelTypeOpenAI,
+ ChannelBaseUrl: server.URL,
+ ApiKey: "test-key",
+ UpstreamModelName: "gpt-5.6-sol",
+ },
+ }
+ adaptor := &openaichannel.Adaptor{}
+ adaptor.Init(info)
+ request := &dto.ClaudeRequest{
+ Model: "gpt-5.6-sol",
+ Thinking: &dto.Thinking{Type: "adaptive", Display: "summarized"},
+ Messages: []dto.ClaudeMessage{{Role: "user", Content: "hello"}},
+ }
+
+ usage, apiErr := textRequestViaResponses(c, info, adaptor, request)
+
+ require.Nil(t, apiErr)
+ require.NotNil(t, usage)
+ assert.Equal(t, 5, usage.TotalTokens)
+ assert.Equal(t, []relaytypes.RelayFormat{relaytypes.RelayFormatClaude, relaytypes.RelayFormatOpenAIResponses}, info.RequestConversionChain)
+
+ upstream := <-captured
+ assert.Equal(t, "/v1/responses", upstream.path)
+ var upstreamBody map[string]any
+ require.NoError(t, common.Unmarshal(upstream.body, &upstreamBody))
+ assert.NotContains(t, upstreamBody, "messages")
+ reasoning, ok := upstreamBody["reasoning"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "high", reasoning["effort"])
+ assert.Equal(t, "detailed", reasoning["summary"])
+
+ var response dto.ClaudeResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.Len(t, response.Content, 1)
+ assert.Equal(t, "ok", response.Content[0].GetText())
+}
diff --git a/relay/claude_handler.go b/relay/claude_handler.go
index ff7854469d7f..1dfbc3b65780 100644
--- a/relay/claude_handler.go
+++ b/relay/claude_handler.go
@@ -1,7 +1,6 @@
package relay
import (
- "encoding/json"
"fmt"
"io"
"net/http"
@@ -16,7 +15,6 @@ import (
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
- "github.com/QuantumNous/new-api/setting/reasoning"
"github.com/gin-gonic/gin"
)
@@ -40,6 +38,9 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
+ if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
+ return newConvertRequestFailedError(c, info, err)
+ }
adaptor := GetAdaptor(info.ApiType)
if adaptor == nil {
@@ -47,71 +48,6 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
}
adaptor.Init(info)
- if request.MaxTokens == nil || *request.MaxTokens == 0 {
- defaultMaxTokens := uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(request.Model))
- request.MaxTokens = &defaultMaxTokens
- }
-
- if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(request.Model); ok && effortLevel != "" &&
- (strings.HasPrefix(request.Model, "claude-opus-4-6") ||
- strings.HasPrefix(request.Model, "claude-opus-4-7") ||
- strings.HasPrefix(request.Model, "claude-opus-4-8")) {
- request.Model = baseModel
- request.Thinking = &dto.Thinking{
- Type: "adaptive",
- }
- request.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
- if strings.HasPrefix(request.Model, "claude-opus-4-7") ||
- strings.HasPrefix(request.Model, "claude-opus-4-8") {
- // Opus 4.7/4.8 reject non-default temperature/top_p/top_k with 400
- // and defaults display to "omitted"; restore the 4.6 visible summary.
- request.Thinking.Display = "summarized"
- request.Temperature = nil
- request.TopP = nil
- request.TopK = nil
- } else {
- request.Temperature = common.GetPointer[float64](1.0)
- }
- info.UpstreamModelName = request.Model
- } else if model_setting.GetClaudeSettings().ThinkingAdapterEnabled &&
- strings.HasSuffix(request.Model, "-thinking") {
- if request.Thinking == nil {
- baseModel := strings.TrimSuffix(request.Model, "-thinking")
- if strings.HasPrefix(baseModel, "claude-opus-4-7") ||
- strings.HasPrefix(baseModel, "claude-opus-4-8") {
- // Opus 4.7/4.8 reject thinking.type="enabled"; use adaptive at high effort.
- request.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
- request.OutputConfig = json.RawMessage(`{"effort":"high"}`)
- request.Temperature = nil
- request.TopP = nil
- request.TopK = nil
- } else {
- // 因为BudgetTokens 必须大于1024
- if request.MaxTokens == nil || *request.MaxTokens < 1280 {
- request.MaxTokens = common.GetPointer[uint](1280)
- }
-
- // BudgetTokens 为 max_tokens 的 80%
- request.Thinking = &dto.Thinking{
- Type: "enabled",
- BudgetTokens: common.GetPointer[int](int(float64(*request.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)),
- }
- // TODO: 临时处理
- // https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
- request.Temperature = common.GetPointer[float64](1.0)
- }
- }
- if !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
- request.Model = strings.TrimSuffix(request.Model, "-thinking")
- }
- 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 {
request.SetStringSystem(info.ChannelSetting.SystemPrompt)
@@ -140,16 +76,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
if !model_setting.GetGlobalSettings().PassThroughRequestEnabled &&
!info.ChannelSetting.PassThroughBodyEnabled &&
service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.ChannelType, info.OriginModelName) {
- result, convErr := service.ConvertRequest(c, info, types.RelayFormatOpenAI, request)
- if convErr != nil {
- return types.NewError(convErr, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
- }
- openAIRequest, ok := result.Value.(*dto.GeneralOpenAIRequest)
- if !ok {
- return types.NewError(fmt.Errorf("expected OpenAI chat completions request, got %T", result.Value), types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
- }
-
- usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, openAIRequest)
+ usage, newApiErr := textRequestViaResponses(c, info, adaptor, request)
if newApiErr != nil {
return newApiErr
}
@@ -168,7 +95,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
} else {
convertedRequest, err := adaptor.ConvertClaudeRequest(c, info, request)
if err != nil {
- return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
+ return newConvertRequestFailedError(c, info, err)
}
relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
jsonData, err := common.Marshal(convertedRequest)
diff --git a/relay/common/conversion_diagnostics.go b/relay/common/conversion_diagnostics.go
new file mode 100644
index 000000000000..bc1e1cd24e11
--- /dev/null
+++ b/relay/common/conversion_diagnostics.go
@@ -0,0 +1,71 @@
+package common
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/QuantumNous/new-api/logger"
+ "github.com/QuantumNous/new-api/relaykit/types"
+ "github.com/gin-gonic/gin"
+)
+
+const maxConversionDiagnostics = 32
+
+type conversionDiagnosticKey struct {
+ code string
+ path string
+ severity types.ConversionDiagnosticSeverity
+ from types.RelayFormat
+ to types.RelayFormat
+}
+
+// RecordConversionDiagnostics retains conversion losses for the consume log
+// and emits one request-correlated warning per distinct diagnostic. The cap
+// prevents malformed streams from growing request state without bound.
+func (info *RelayInfo) RecordConversionDiagnostics(ctx context.Context, diagnostics []types.ConversionDiagnostic) {
+ if info == nil || len(diagnostics) == 0 {
+ return
+ }
+ if ginCtx, ok := ctx.(*gin.Context); ok && ginCtx == nil {
+ ctx = nil
+ }
+ if info.conversionDiagnosticKeys == nil {
+ info.conversionDiagnosticKeys = make(map[conversionDiagnosticKey]struct{})
+ }
+ for _, diagnostic := range diagnostics {
+ key := conversionDiagnosticKey{
+ code: diagnostic.Code,
+ path: diagnostic.Path,
+ severity: diagnostic.Severity,
+ from: diagnostic.From,
+ to: diagnostic.To,
+ }
+ if _, exists := info.conversionDiagnosticKeys[key]; exists {
+ continue
+ }
+ if len(info.conversionDiagnostics) >= maxConversionDiagnostics {
+ if !info.conversionDiagnosticsTruncated {
+ info.conversionDiagnosticsTruncated = true
+ logger.LogWarn(ctx, fmt.Sprintf("conversion diagnostics truncated after %d distinct entries", maxConversionDiagnostics))
+ }
+ continue
+ }
+ info.conversionDiagnosticKeys[key] = struct{}{}
+ info.conversionDiagnostics = append(info.conversionDiagnostics, diagnostic)
+ logger.LogWarn(ctx, fmt.Sprintf(
+ "conversion diagnostic: code=%q severity=%q from=%q to=%q path=%q message=%q",
+ diagnostic.Code, diagnostic.Severity, diagnostic.From, diagnostic.To, diagnostic.Path, diagnostic.Message,
+ ))
+ }
+}
+
+func (info *RelayInfo) ConversionDiagnostics() []types.ConversionDiagnostic {
+ if info == nil || len(info.conversionDiagnostics) == 0 {
+ return nil
+ }
+ return append([]types.ConversionDiagnostic(nil), info.conversionDiagnostics...)
+}
+
+func (info *RelayInfo) ConversionDiagnosticsTruncated() bool {
+ return info != nil && info.conversionDiagnosticsTruncated
+}
diff --git a/relay/common/override.go b/relay/common/override.go
index b1a7d17744fa..477af24677dc 100644
--- a/relay/common/override.go
+++ b/relay/common/override.go
@@ -10,6 +10,7 @@ import (
"strings"
"github.com/QuantumNous/new-api/common"
+ kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/samber/lo"
"github.com/tidwall/gjson"
@@ -224,22 +225,73 @@ func syncReasoningEffortAfterParamOverride(info *RelayInfo, before, after []byte
}
func extractReasoningEffortFromJSON(format types.RelayFormat, data []byte) (string, bool) {
- var paths []string
switch format {
case types.RelayFormatOpenAI:
- paths = []string{"reasoning_effort", "reasoning.effort"}
+ if effort, exists := firstStringValue(data, "reasoning_effort"); exists && effort != "" {
+ return effort, true
+ }
+ if enabled := gjson.GetBytes(data, "reasoning.enabled"); enabled.Exists() {
+ if enabled.Type != gjson.True && enabled.Type != gjson.False {
+ return "", true
+ }
+ if !enabled.Bool() {
+ return string(kitreasoning.EffortNone), true
+ }
+ if effort, exists := firstStringValue(data, "reasoning.effort"); exists && effort != "" {
+ return effort, true
+ }
+ if budget := gjson.GetBytes(data, "reasoning.max_tokens"); budget.Exists() {
+ return reasoningEffortFromBudgetValue(budget)
+ }
+ return string(kitreasoning.EffortHigh), true
+ }
+ if effort, exists := firstStringValue(data, "reasoning.effort"); exists && effort != "" {
+ return effort, true
+ }
+ if budget := gjson.GetBytes(data, "reasoning.max_tokens"); budget.Exists() {
+ return reasoningEffortFromBudgetValue(budget)
+ }
+ return "", false
case types.RelayFormatOpenAIResponses:
- paths = []string{"reasoning.effort"}
+ return firstStringValue(data, "reasoning.effort")
case types.RelayFormatClaude:
- paths = []string{"output_config.effort"}
+ if effort, exists := firstStringValue(data, "output_config.effort"); exists && effort != "" {
+ return effort, true
+ }
+ thinkingType, hasThinkingType := firstStringValue(data, "thinking.type")
+ if thinkingType == "disabled" {
+ return string(kitreasoning.EffortNone), true
+ }
+ if budget := gjson.GetBytes(data, "thinking.budget_tokens"); budget.Exists() {
+ return reasoningEffortFromBudgetValue(budget)
+ }
+ if thinkingType == "enabled" || thinkingType == "adaptive" {
+ return string(kitreasoning.EffortHigh), true
+ }
+ return "", hasThinkingType
case types.RelayFormatGemini:
- paths = []string{
+ level, hasLevel := firstStringValue(data,
"generationConfig.thinkingConfig.thinkingLevel",
"generation_config.thinking_config.thinking_level",
+ )
+ if level != "" {
+ return level, true
+ }
+ for _, path := range []string{
+ "generationConfig.thinkingConfig.thinkingBudget",
+ "generation_config.thinking_config.thinking_budget",
+ } {
+ if budget := gjson.GetBytes(data, path); budget.Exists() {
+ return reasoningEffortFromBudgetValue(budget)
+ }
}
+ return "", hasLevel
default:
return "", false
}
+}
+
+func firstStringValue(data []byte, paths ...string) (string, bool) {
for _, path := range paths {
value := gjson.GetBytes(data, path)
if !value.Exists() {
@@ -253,6 +305,25 @@ func extractReasoningEffortFromJSON(format types.RelayFormat, data []byte) (stri
return "", false
}
+func reasoningEffortFromBudgetValue(value gjson.Result) (string, bool) {
+ if value.Type != gjson.Number {
+ return "", true
+ }
+ budget := value.Float()
+ switch {
+ case budget == 0:
+ return string(kitreasoning.EffortNone), true
+ case budget < 0:
+ return string(kitreasoning.EffortHigh), true
+ case budget <= 1024:
+ return string(kitreasoning.EffortLow), true
+ case budget <= 8192:
+ return string(kitreasoning.EffortMedium), true
+ default:
+ return string(kitreasoning.EffortHigh), true
+ }
+}
+
func shouldEnableParamOverrideAudit(paramOverride map[string]interface{}) bool {
if common.DebugEnabled {
return true
diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go
index 6154cfc790f3..727476b1ece4 100644
--- a/relay/common/relay_info.go
+++ b/relay/common/relay_info.go
@@ -14,6 +14,7 @@ import (
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
+ kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/model_setting"
hosttypes "github.com/QuantumNous/new-api/types"
@@ -98,25 +99,34 @@ type RelayInfo struct {
UsePrice bool
RelayMode int
OriginModelName string
- RequestURLPath string
- RequestHeaders map[string]string
- ShouldIncludeUsage bool
- DisablePing bool // 是否禁止向下游发送自定义 Ping
- ClientWs *websocket.Conn
- TargetWs *websocket.Conn
- InputAudioFormat string
- OutputAudioFormat string
- RealtimeTools []dto.RealTimeTool
- IsFirstRequest bool
- AudioUsage bool
- ReasoningEffort string
- UserSetting dto.UserSetting
- UserEmail string
- UserQuota int
- RelayFormat types.RelayFormat
- SendResponseCount int
- ReceivedResponseCount int
- FinalPreConsumedQuota int // 最终预消耗的配额
+
+ // BillingModelName is the pricing identity for this request. It is kept
+ // separate from OriginModelName and UpstreamModelName so virtual pricing
+ // aliases never participate in channel selection or upstream routing.
+ BillingModelName string
+
+ RequestURLPath string
+ RequestHeaders map[string]string
+ ShouldIncludeUsage bool
+ DisablePing bool // 是否禁止向下游发送自定义 Ping
+ ClientWs *websocket.Conn
+ TargetWs *websocket.Conn
+ InputAudioFormat string
+ OutputAudioFormat string
+ RealtimeTools []dto.RealTimeTool
+ IsFirstRequest bool
+ AudioUsage bool
+ ReasoningEffort string
+ // ReasoningConversion is the suffix-derived reasoning intent attached
+ // after model mapping. Converters read it via ReasoningState().
+ ReasoningConversion *dto.ReasoningConversionState
+ UserSetting dto.UserSetting
+ UserEmail string
+ UserQuota int
+ RelayFormat types.RelayFormat
+ SendResponseCount int
+ ReceivedResponseCount int
+ FinalPreConsumedQuota int // 最终预消耗的配额
// ForcePreConsume 为 true 时禁用 BillingSession 的信任额度旁路,
// 强制预扣全额。用于异步任务(视频/音乐生成等),因为请求返回后任务仍在运行,
// 必须在提交前锁定全额。
@@ -176,6 +186,10 @@ type RelayInfo struct {
// convOptions caches the converter settings snapshot (see ConvOptions).
convOptions *convmeta.Options
+ conversionDiagnostics []types.ConversionDiagnostic
+ conversionDiagnosticKeys map[conversionDiagnosticKey]struct{}
+ conversionDiagnosticsTruncated bool
+
ThinkingContentInfo
TokenCountMeta
*ClaudeConvertInfo
@@ -186,6 +200,9 @@ type RelayInfo struct {
}
func (info *RelayInfo) InitChannelMeta(c *gin.Context) {
+ info.FinalRequestRelayFormat = ""
+ info.RequestConversionChain = nil
+ info.InitRequestConversionChain()
channelType := common.GetContextKeyInt(c, constant.ContextKeyChannelType)
paramOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelParamOverride)
headerOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelHeaderOverride)
@@ -236,8 +253,10 @@ func (info *RelayInfo) InitChannelMeta(c *gin.Context) {
info.convOptions = nil
if model_setting.GetGlobalSettings().PassThroughRequestEnabled || channelMeta.ChannelSetting.PassThroughBodyEnabled {
info.ReasoningEffort = ""
+ info.ReasoningConversion = nil
} else {
info.ReasoningEffort = reasoningEffortFromRequest(info.Request)
+ info.ReasoningConversion = nil
}
// reset some fields based on channel meta
@@ -261,6 +280,9 @@ func (info *RelayInfo) ToString() string {
fmt.Fprintf(b, "IsPlayground: %t, ", info.IsPlayground)
fmt.Fprintf(b, "RequestURLPath: %q, ", info.RequestURLPath)
fmt.Fprintf(b, "OriginModelName: %q, ", info.OriginModelName)
+ if info.BillingModelName != "" && info.BillingModelName != info.OriginModelName {
+ fmt.Fprintf(b, "BillingModelName: %q, ", info.BillingModelName)
+ }
fmt.Fprintf(b, "EstimatePromptTokens: %d, ", info.estimatePromptTokens)
fmt.Fprintf(b, "ShouldIncludeUsage: %t, ", info.ShouldIncludeUsage)
fmt.Fprintf(b, "DisablePing: %t, ", info.DisablePing)
@@ -464,7 +486,10 @@ func reasoningEffortFromRequest(request dto.Request) string {
}
case *dto.GeminiChatRequest:
if req != nil && req.GenerationConfig.ThinkingConfig != nil {
- effort = req.GenerationConfig.ThinkingConfig.ThinkingLevel
+ intent, err := kitreasoning.FromGemini(req)
+ if err == nil {
+ effort = string(kitreasoning.EffectiveEffort(intent))
+ }
}
}
return strings.TrimSpace(effort)
@@ -739,6 +764,18 @@ func (info *RelayInfo) GetOriginModelName() string {
return info.OriginModelName
}
+// GetBillingModelName returns the effective pricing identity without changing
+// either the client-visible model or the model sent to the selected channel.
+func (info *RelayInfo) GetBillingModelName() string {
+ if info == nil {
+ return ""
+ }
+ if info.BillingModelName != "" {
+ return info.BillingModelName
+ }
+ return info.OriginModelName
+}
+
func (info *RelayInfo) GetUpstreamModelName() string {
if info == nil || info.ChannelMeta == nil {
return ""
@@ -780,6 +817,13 @@ func (info *RelayInfo) SetReasoningEffort(effort string) {
info.ReasoningEffort = strings.TrimSpace(effort)
}
+func (info *RelayInfo) ReasoningState() *dto.ReasoningConversionState {
+ if info == nil {
+ return nil
+ }
+ return info.ReasoningConversion
+}
+
func (info *RelayInfo) EnsureClaudeConvertInfo() *convmeta.ClaudeConvertInfo {
if info == nil {
return &convmeta.ClaudeConvertInfo{
@@ -832,8 +876,12 @@ func (info *RelayInfo) ConvOptions() *convmeta.Options {
},
OpenRouterDialect: info != nil && info.GetChannelType() == constant.ChannelTypeOpenRouter,
PreserveThinkingSuffix: model_setting.ShouldPreserveThinkingSuffix,
+ PreserveEffortTail: model_setting.ShouldPreserveEffortTail,
}
if info != nil {
+ if info.ChannelMeta != nil {
+ options.ToolLossPolicy = types.ConversionLossPolicy(info.ChannelOtherSettings.ToolLossPolicy)
+ }
info.convOptions = options
}
return options
diff --git a/relay/common/relay_info_test.go b/relay/common/relay_info_test.go
index 42a0f8567bfe..5142c5414b51 100644
--- a/relay/common/relay_info_test.go
+++ b/relay/common/relay_info_test.go
@@ -56,6 +56,7 @@ func TestRelayInfoMetaTypedNilReceiver(t *testing.T) {
assert.Zero(t, meta.GetChannelType())
assert.False(t, meta.GetIsStream())
assert.Empty(t, meta.GetReasoningEffort())
+ assert.Nil(t, meta.ReasoningState())
assert.Zero(t, meta.GetEstimatePromptTokens())
assert.Zero(t, meta.GetSendResponseCount())
@@ -81,6 +82,7 @@ func TestRelayInfoMetaTypedNilReceiver(t *testing.T) {
assert.NotNil(t, firstOptions.Gemini.SupportsImagine)
assert.NotNil(t, firstOptions.Gemini.SafetySetting)
assert.NotNil(t, firstOptions.PreserveThinkingSuffix)
+ assert.NotNil(t, firstOptions.PreserveEffortTail)
}
func TestGenRelayInfoCapturesRequestReasoningEffort(t *testing.T) {
diff --git a/relay/common/tool_usage.go b/relay/common/tool_usage.go
index e9bed6250fdd..499953fa2941 100644
--- a/relay/common/tool_usage.go
+++ b/relay/common/tool_usage.go
@@ -46,7 +46,7 @@ func (info *RelayInfo) CountBillableToolCall(itemType string, functionName strin
if _, reserved := reservedBillableToolNames[functionName]; reserved {
return
}
- if operation_setting.GetToolPriceForModel(functionName, info.OriginModelName) <= 0 {
+ if operation_setting.GetToolPriceForModel(functionName, info.GetBillingModelName()) <= 0 {
return
}
info.incrementBillableToolCall(functionName)
diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go
index 8edb3362b6e0..ba816c462481 100644
--- a/relay/compatible_handler.go
+++ b/relay/compatible_handler.go
@@ -43,6 +43,9 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
+ if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
+ return newConvertRequestFailedError(c, info, err)
+ }
includeUsage := true
// 判断用户是否需要返回使用情况
@@ -76,7 +79,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
!info.ChannelSetting.PassThroughBodyEnabled &&
service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.ChannelType, info.OriginModelName) {
applySystemPromptIfNeeded(c, info, request)
- usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, request)
+ usage, newApiErr := textRequestViaResponses(c, info, adaptor, request)
if newApiErr != nil {
return newApiErr
}
@@ -108,7 +111,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
} else {
convertedRequest, err := adaptor.ConvertOpenAIRequest(c, info, request)
if err != nil {
- return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
+ return newConvertRequestFailedError(c, info, err)
}
relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
diff --git a/relay/convert_request_error.go b/relay/convert_request_error.go
new file mode 100644
index 000000000000..31468d90c30c
--- /dev/null
+++ b/relay/convert_request_error.go
@@ -0,0 +1,23 @@
+package relay
+
+import (
+ "errors"
+ "net/http"
+
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
+ "github.com/QuantumNous/new-api/relaykit/types"
+ "github.com/gin-gonic/gin"
+)
+
+func newConvertRequestFailedError(c *gin.Context, info *relaycommon.RelayInfo, err error) *types.NewAPIError {
+ var loss *types.ConversionLossError
+ if errors.As(err, &loss) {
+ info.RecordConversionDiagnostics(c, loss.Diagnostics)
+ return types.NewErrorWithStatusCode(err, types.ErrorCodeConvertRequestFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
+ }
+ if kitreasoning.IsClientError(err) {
+ return types.NewErrorWithStatusCode(err, types.ErrorCodeConvertRequestFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
+ }
+ return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
+}
diff --git a/relay/convert_request_error_test.go b/relay/convert_request_error_test.go
new file mode 100644
index 000000000000..c3caba2cf952
--- /dev/null
+++ b/relay/convert_request_error_test.go
@@ -0,0 +1,72 @@
+package relay
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/relaykit/types"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestOptInSafeToolLossRejectedAsBadRequestWithAdminDiagnostics(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
+
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "gpt-4o",
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: "gpt-4o",
+ ChannelOtherSettings: dto.ChannelOtherSettings{
+ ToolLossPolicy: string(types.ConversionLossPolicySafe),
+ },
+ },
+ }
+
+ tools, err := common.Marshal([]map[string]any{{"codeExecution": map[string]any{}}})
+ require.NoError(t, err)
+ req := &dto.GeminiChatRequest{
+ Contents: []dto.GeminiChatContent{
+ {Role: "user", Parts: []dto.GeminiPart{{Text: "run this"}}},
+ },
+ Tools: tools,
+ }
+
+ result, convErr := service.ConvertRequest(c, info, types.RelayFormatOpenAI, req)
+ require.Error(t, convErr)
+ var loss *types.ConversionLossError
+ require.ErrorAs(t, convErr, &loss)
+ require.NotEmpty(t, loss.Diagnostics)
+ require.NotNil(t, result)
+
+ apiErr := newConvertRequestFailedError(c, info, convErr)
+ require.NotNil(t, apiErr)
+ assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode)
+ assert.Equal(t, types.ErrorCodeConvertRequestFailed, apiErr.GetErrorCode())
+ assert.True(t, types.IsSkipRetryError(apiErr))
+
+ diagnostics := info.ConversionDiagnostics()
+ require.NotEmpty(t, diagnostics)
+ assert.True(t, hasHostDiagnosticCode(diagnostics, "unsupported_hosted_tool"))
+
+ other := service.GenerateTextOtherInfo(c, info, 1, 1, 1, 0, 0, 0, 1)
+ adminInfo, ok := other["admin_info"].(map[string]interface{})
+ require.True(t, ok)
+ require.Contains(t, adminInfo, "conversion_diagnostics")
+}
+
+func hasHostDiagnosticCode(diagnostics []types.ConversionDiagnostic, code string) bool {
+ for _, diagnostic := range diagnostics {
+ if diagnostic.Code == code {
+ return true
+ }
+ }
+ return false
+}
diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go
index 57010d87c380..ffa6e996beaf 100644
--- a/relay/gemini_handler.go
+++ b/relay/gemini_handler.go
@@ -12,7 +12,6 @@ import (
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
- "github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
@@ -20,37 +19,6 @@ import (
"github.com/gin-gonic/gin"
)
-func isNoThinkingRequest(req *dto.GeminiChatRequest) bool {
- if req.GenerationConfig.ThinkingConfig != nil && req.GenerationConfig.ThinkingConfig.ThinkingBudget != nil {
- configBudget := req.GenerationConfig.ThinkingConfig.ThinkingBudget
- if configBudget != nil && *configBudget == 0 {
- // 如果思考预算为 0,则认为是非思考请求
- return true
- }
- }
- return false
-}
-
-func trimModelThinking(modelName string) string {
- // 去除模型名称中的 -nothinking 后缀
- if strings.HasSuffix(modelName, "-nothinking") {
- return strings.TrimSuffix(modelName, "-nothinking")
- }
- // 去除模型名称中的 -thinking 后缀
- if strings.HasSuffix(modelName, "-thinking") {
- return strings.TrimSuffix(modelName, "-thinking")
- }
-
- // 去除模型名称中的 -thinking-number
- if strings.Contains(modelName, "-thinking-") {
- parts := strings.Split(modelName, "-thinking-")
- if len(parts) > 1 {
- return parts[0] + "-thinking"
- }
- }
- return modelName
-}
-
func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) {
info.InitChannelMeta(c)
@@ -69,23 +37,8 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
-
- if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
- if isNoThinkingRequest(request) {
- // check is thinking
- if !strings.Contains(info.OriginModelName, "-nothinking") {
- // try to get no thinking model price
- noThinkingModelName := info.OriginModelName + "-nothinking"
- containPrice := helper.HasModelBillingConfig(noThinkingModelName)
- if containPrice {
- info.OriginModelName = noThinkingModelName
- info.UpstreamModelName = noThinkingModelName
- }
- }
- }
- if request.GenerationConfig.ThinkingConfig == nil {
- relayconvert.ApplyGeminiThinkingConfig(request, info)
- }
+ if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
+ return newConvertRequestFailedError(c, info, err)
}
adaptor := GetAdaptor(info.ApiType)
@@ -146,7 +99,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
// 使用 ConvertGeminiRequest 转换请求格式
convertedRequest, err := adaptor.ConvertGeminiRequest(c, info, request)
if err != nil {
- return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
+ return newConvertRequestFailedError(c, info, err)
}
relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
jsonData, err := common.Marshal(convertedRequest)
@@ -245,6 +198,9 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPI
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
+ if err = helper.ApplyReasoningModelSuffix(info, req); err != nil {
+ return newConvertRequestFailedError(c, info, err)
+ }
req.SetModelName("models/" + info.UpstreamModelName)
diff --git a/relay/helper/price.go b/relay/helper/price.go
index b9ae819bf57f..1db88d816f12 100644
--- a/relay/helper/price.go
+++ b/relay/helper/price.go
@@ -71,13 +71,14 @@ func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) hostty
}
func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, meta *types.TokenCountMeta) (hosttypes.PriceData, error) {
- modelPrice, usePrice := ratio_setting.GetModelPrice(info.OriginModelName, false)
+ billingModelName := info.GetBillingModelName()
+ modelPrice, usePrice := ratio_setting.GetModelPrice(billingModelName, false)
groupRatioInfo := HandleGroupRatio(c, info)
// Check if this model uses tiered_expr billing
- if billing_setting.GetBillingMode(info.OriginModelName) == billing_setting.BillingModeTieredExpr {
- return modelPriceHelperTiered(c, info, promptTokens, meta, groupRatioInfo)
+ if billing_setting.GetBillingMode(billingModelName) == billing_setting.BillingModeTieredExpr {
+ return modelPriceHelperTiered(c, info, billingModelName, promptTokens, meta, groupRatioInfo)
}
var preConsumedQuota int
@@ -98,7 +99,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
}
var success bool
var matchName string
- modelRatio, success, matchName = ratio_setting.GetModelRatio(info.OriginModelName)
+ modelRatio, success, matchName = ratio_setting.GetModelRatio(billingModelName)
if !success {
acceptUnsetRatio := false
if info.UserSetting.AcceptUnsetRatioModel {
@@ -108,15 +109,15 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
return hosttypes.PriceData{}, modelPriceNotConfiguredError(matchName, info.UserId)
}
}
- completionRatio = ratio_setting.GetCompletionRatio(info.OriginModelName)
- cacheRatio, _ = ratio_setting.GetCacheRatio(info.OriginModelName)
- cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(info.OriginModelName)
+ completionRatio = ratio_setting.GetCompletionRatio(billingModelName)
+ cacheRatio, _ = ratio_setting.GetCacheRatio(billingModelName)
+ cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(billingModelName)
cacheCreationRatio5m = cacheCreationRatio
// 固定1h和5min缓存写入价格的比例
cacheCreationRatio1h = cacheCreationRatio * claudeCacheCreation1hMultiplier
- imageRatio, _ = ratio_setting.GetImageRatio(info.OriginModelName)
- audioRatio = ratio_setting.GetAudioRatio(info.OriginModelName)
- audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName)
+ imageRatio, _ = ratio_setting.GetImageRatio(billingModelName)
+ audioRatio = ratio_setting.GetAudioRatio(billingModelName)
+ audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(billingModelName)
ratio := modelRatio * groupRatioInfo.GroupRatio
quota, err := common.QuotaFromFloatStrict(float64(preConsumedTokens) * ratio)
if err != nil {
@@ -266,10 +267,10 @@ func HasModelBillingConfig(modelName string) bool {
return ok && strings.TrimSpace(expr) != ""
}
-func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, meta *types.TokenCountMeta, groupRatioInfo hosttypes.GroupRatioInfo) (hosttypes.PriceData, error) {
- exprStr, ok := billing_setting.GetBillingExpr(info.OriginModelName)
+func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, billingModelName string, promptTokens int, meta *types.TokenCountMeta, groupRatioInfo hosttypes.GroupRatioInfo) (hosttypes.PriceData, error) {
+ exprStr, ok := billing_setting.GetBillingExpr(billingModelName)
if !ok {
- return hosttypes.PriceData{}, fmt.Errorf("model %s is configured as tiered_expr but has no billing expression", info.OriginModelName)
+ return hosttypes.PriceData{}, fmt.Errorf("model %s is configured as tiered_expr but has no billing expression", billingModelName)
}
estimatedCompletionTokens := meta.MaxTokens
@@ -288,7 +289,7 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptT
Len: float64(promptTokens),
}, requestInput)
if err != nil {
- return hosttypes.PriceData{}, fmt.Errorf("model %s tiered expr run failed: %w", info.OriginModelName, err)
+ return hosttypes.PriceData{}, fmt.Errorf("model %s tiered expr run failed: %w", billingModelName, err)
}
// Expression coefficients are $/1M tokens prices; convert to quota the same way per-call billing does.
@@ -309,7 +310,7 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptT
exprHash := billingexpr.ExprHashString(exprStr)
snapshot := &billingexpr.BillingSnapshot{
BillingMode: billing_setting.BillingModeTieredExpr,
- ModelName: info.OriginModelName,
+ ModelName: billingModelName,
ExprString: exprStr,
ExprHash: exprHash,
GroupRatio: groupRatioInfo.GroupRatio,
@@ -330,7 +331,7 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptT
QuotaToPreConsume: preConsumedQuota,
}
- logger.LogDebug(c, "model_price_helper_tiered result: model=%s preConsume=%d quotaBeforeGroup=%.2f groupRatio=%.2f tier=%s", info.OriginModelName, preConsumedQuota, quotaBeforeGroup, groupRatioInfo.GroupRatio, trace.MatchedTier)
+ logger.LogDebug(c, "model_price_helper_tiered result: model=%s preConsume=%d quotaBeforeGroup=%.2f groupRatio=%.2f tier=%s", billingModelName, preConsumedQuota, quotaBeforeGroup, groupRatioInfo.GroupRatio, trace.MatchedTier)
info.PriceData = priceData
return priceData, nil
diff --git a/relay/helper/price_test.go b/relay/helper/price_test.go
index ca38b54829ad..75f825f016fd 100644
--- a/relay/helper/price_test.go
+++ b/relay/helper/price_test.go
@@ -8,11 +8,15 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/config"
+ "github.com/QuantumNous/new-api/setting/model_setting"
+ "github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -272,3 +276,97 @@ func TestModelPriceHelperRequestBillingRatiosOnlyApplyToFixedPrice(t *testing.T)
require.Equal(t, common.QuotaClampOverflow, clamp.Kind)
require.Nil(t, info.Billing)
}
+
+// Pricing at controller/relay.go runs before ApplyReasoningModelSuffix.
+// Identity is GetBillingModelName() → OriginModelName (the suffixed client
+// name), matching main's info.OriginModelName lookup. Wildcard entries such
+// as gemini-2.5-flash-thinking-* depend on that unstripped origin form.
+func TestModelPriceHelperUsesSuffixedOriginLikeMain(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ savedRatios := ratio_setting.ModelRatio2JSONString()
+ t.Cleanup(func() {
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios))
+ })
+ ratios := ratio_setting.GetModelRatioCopy()
+ ratios["gemini-2.5-flash"] = 0.15
+ ratios["gemini-2.5-flash-thinking-*"] = 0.075
+ ratioJSON, err := common.Marshal(ratios)
+ require.NoError(t, err)
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
+
+ oldSelfUse := operation_setting.SelfUseModeEnabled
+ operation_setting.SelfUseModeEnabled = true
+ t.Cleanup(func() { operation_setting.SelfUseModeEnabled = oldSelfUse })
+
+ ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+ ctx.Set("group", "default")
+
+ suffixed := &relaycommon.RelayInfo{
+ OriginModelName: "gemini-2.5-flash-thinking-8192",
+ UserGroup: "default",
+ UsingGroup: "default",
+ }
+ suffixedPrice, err := ModelPriceHelper(ctx, suffixed, 1000, &types.TokenCountMeta{})
+ require.NoError(t, err)
+ assert.Empty(t, suffixed.BillingModelName)
+ assert.Equal(t, "gemini-2.5-flash-thinking-8192", suffixed.GetBillingModelName())
+ assert.Equal(t, 0.075, suffixedPrice.ModelRatio)
+
+ base := &relaycommon.RelayInfo{
+ OriginModelName: "gemini-2.5-flash",
+ UserGroup: "default",
+ UsingGroup: "default",
+ }
+ basePrice, err := ModelPriceHelper(ctx, base, 1000, &types.TokenCountMeta{})
+ require.NoError(t, err)
+ assert.Empty(t, base.BillingModelName)
+ assert.Equal(t, "gemini-2.5-flash", base.GetBillingModelName())
+ assert.Equal(t, 0.15, basePrice.ModelRatio)
+}
+
+func TestModelPriceHelperNativeGeminiNoThinkingDoesNotAliasBillingModel(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ savedRatios := ratio_setting.ModelRatio2JSONString()
+ t.Cleanup(func() {
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios))
+ })
+ ratios := ratio_setting.GetModelRatioCopy()
+ ratios["gemini-3-pro"] = 1.25
+ ratioJSON, err := common.Marshal(ratios)
+ require.NoError(t, err)
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
+
+ oldSelfUse := operation_setting.SelfUseModeEnabled
+ operation_setting.SelfUseModeEnabled = true
+ t.Cleanup(func() { operation_setting.SelfUseModeEnabled = oldSelfUse })
+
+ geminiSettings := model_setting.GetGeminiSettings()
+ oldThinking := geminiSettings.ThinkingAdapterEnabled
+ geminiSettings.ThinkingAdapterEnabled = true
+ t.Cleanup(func() { geminiSettings.ThinkingAdapterEnabled = oldThinking })
+
+ budget := 0
+ ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+ ctx.Set("group", "default")
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "gemini-3-pro",
+ UserGroup: "default",
+ UsingGroup: "default",
+ Request: &dto.GeminiChatRequest{
+ GenerationConfig: dto.GeminiChatGenerationConfig{
+ ThinkingConfig: &dto.GeminiThinkingConfig{
+ ThinkingBudget: &budget,
+ },
+ },
+ },
+ }
+
+ priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
+ require.NoError(t, err)
+ assert.Empty(t, info.BillingModelName)
+ assert.Equal(t, "gemini-3-pro", info.GetBillingModelName())
+ assert.Equal(t, 1.25, priceData.ModelRatio)
+ assert.NotEqual(t, 37.5, priceData.ModelRatio)
+}
diff --git a/relay/helper/reasoning_suffix.go b/relay/helper/reasoning_suffix.go
new file mode 100644
index 000000000000..b5c1ca4329ad
--- /dev/null
+++ b/relay/helper/reasoning_suffix.go
@@ -0,0 +1,145 @@
+package helper
+
+import (
+ "strings"
+
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
+ "github.com/QuantumNous/new-api/setting/model_setting"
+)
+
+// ApplyReasoningModelSuffix parses host-private reasoning suffixes from the
+// origin and mapped model names, attaches the resulting intent to RelayInfo,
+// and normalizes UpstreamModelName to the unsuffixed base. Optional outbound
+// requests are the DeepCopy the handler will send upstream; they must be
+// synced here because info.Request is the original, not that copy. Conflict
+// between an explicit request field and a suffix is a client error.
+func ApplyReasoningModelSuffix(info *relaycommon.RelayInfo, outbound ...dto.Request) error {
+ if info == nil {
+ return nil
+ }
+ passThrough := model_setting.GetGlobalSettings().PassThroughRequestEnabled
+ if info.ChannelMeta != nil && info.ChannelSetting.PassThroughBodyEnabled {
+ passThrough = true
+ }
+ if passThrough {
+ return nil
+ }
+
+ opts := info.ConvOptions()
+ origin := info.GetOriginModelName()
+ upstream := ""
+ if info.ChannelMeta != nil {
+ upstream = info.UpstreamModelName
+ }
+ if opts.ShouldPreserveThinkingSuffix(origin) || opts.ShouldPreserveThinkingSuffix(upstream) {
+ return nil
+ }
+
+ originBase, originIntent, originFound, err := parseHostModelSuffix(origin, opts)
+ if err != nil {
+ return reasoning.AsClientError(err)
+ }
+ upstreamBase, upstreamIntent, upstreamFound, err := parseHostModelSuffix(upstream, opts)
+ if err != nil {
+ return reasoning.AsClientError(err)
+ }
+
+ suffix := originIntent
+ if originFound && upstreamFound {
+ suffix, err = reasoning.MergeExplicitAndSuffix(originIntent, upstreamIntent, origin)
+ if err != nil {
+ return reasoning.AsClientError(err)
+ }
+ } else if upstreamFound {
+ suffix = upstreamIntent
+ }
+
+ explicit, err := explicitIntentFromRequest(info.Request)
+ if err != nil {
+ return reasoning.AsClientError(err)
+ }
+ conflictModel := upstream
+ if conflictModel == "" {
+ conflictModel = origin
+ }
+ if _, err = reasoning.MergeExplicitAndSuffix(explicit, suffix, conflictModel); err != nil {
+ return reasoning.AsClientError(err)
+ }
+
+ if !suffix.IsEmpty() {
+ info.ReasoningConversion = reasoning.StateFromIntent(suffix)
+ }
+
+ if upstreamFound && info.ChannelMeta != nil {
+ info.UpstreamModelName = upstreamBase
+ } else if !info.IsModelMapped && originFound && info.ChannelMeta != nil {
+ info.UpstreamModelName = originBase
+ }
+ // Handlers DeepCopy before this helper; info.Request is the original.
+ // Sync every outbound copy the caller is about to send upstream.
+ for _, outbound := range outbound {
+ if outbound != nil {
+ outbound.SetModelName(info.UpstreamModelName)
+ }
+ }
+ if info.Request != nil {
+ info.Request.SetModelName(info.UpstreamModelName)
+ }
+ return nil
+}
+
+func parseHostModelSuffix(name string, opts *convmeta.Options) (string, reasoning.Intent, bool, error) {
+ if name == "" {
+ return name, reasoning.Intent{}, false, nil
+ }
+ if strings.HasPrefix(name, "claude-") {
+ return reasoning.ParseClaudeModelSuffix(name, opts.Claude.ThinkingAdapterEnabled)
+ }
+ if strings.HasPrefix(name, "gemini-") {
+ if !opts.Gemini.ThinkingAdapterEnabled {
+ return name, reasoning.Intent{}, false, nil
+ }
+ return reasoning.ParseGeminiModelSuffix(name, true)
+ }
+ // deepseek-v4 effort tails are consumed by ParseDeepSeekV4ThinkingSuffix
+ // in the DeepSeek adaptor; stripping them here drops THINKING+effort.
+ if strings.HasPrefix(name, "deepseek-v4-") {
+ return name, reasoning.Intent{}, false, nil
+ }
+ effort, base := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(name, opts.PreserveEffortTail)
+ if effort != "" {
+ parsed, err := reasoning.ParseEffort(effort)
+ if err != nil {
+ return name, reasoning.Intent{}, false, err
+ }
+ mode := reasoning.ModeEnabled
+ if parsed == reasoning.EffortNone {
+ mode = reasoning.ModeDisabled
+ }
+ return base, reasoning.Intent{Mode: mode, Effort: parsed, Source: reasoning.SourceSuffix}, true, nil
+ }
+ // Generic -thinking trim is OpenRouter-only. Volcengine/DeepSeek adaptors
+ // read the suffix off UpstreamModelName themselves.
+ if opts != nil && opts.OpenRouterDialect && strings.HasSuffix(name, "-thinking") {
+ return strings.TrimSuffix(name, "-thinking"), reasoning.Intent{Mode: reasoning.ModeEnabled, Source: reasoning.SourceSuffix}, true, nil
+ }
+ return name, reasoning.Intent{}, false, nil
+}
+
+func explicitIntentFromRequest(req dto.Request) (reasoning.Intent, error) {
+ switch r := req.(type) {
+ case *dto.ClaudeRequest:
+ return reasoning.FromClaude(r)
+ case *dto.GeminiChatRequest:
+ return reasoning.FromGemini(r)
+ case *dto.GeneralOpenAIRequest:
+ return reasoning.FromOpenAIChat(r)
+ case *dto.OpenAIResponsesRequest:
+ return reasoning.FromOpenAIResponses(r)
+ default:
+ return reasoning.Intent{}, nil
+ }
+}
diff --git a/relay/helper/reasoning_suffix_test.go b/relay/helper/reasoning_suffix_test.go
new file mode 100644
index 000000000000..e07b8873b096
--- /dev/null
+++ b/relay/helper/reasoning_suffix_test.go
@@ -0,0 +1,218 @@
+package helper
+
+import (
+ "net/http/httptest"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/setting/model_setting"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestApplyReasoningModelSuffixTrimsUpstreamAndAttachesState(t *testing.T) {
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "claude-3-7-sonnet-thinking",
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: "claude-3-7-sonnet-thinking",
+ },
+ }
+
+ require.NoError(t, ApplyReasoningModelSuffix(info))
+ assert.Equal(t, "claude-3-7-sonnet", info.UpstreamModelName)
+ require.NotNil(t, info.ReasoningConversion)
+ assert.Equal(t, "enabled", info.ReasoningConversion.Mode)
+}
+
+func TestApplyReasoningModelSuffixRetryKeepsEquivalentState(t *testing.T) {
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "claude-opus-4-8-high",
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: "claude-opus-4-8-high",
+ },
+ }
+
+ require.NoError(t, ApplyReasoningModelSuffix(info))
+ require.NotNil(t, info.ReasoningConversion)
+ firstMode := info.ReasoningConversion.Mode
+ firstEffort := info.ReasoningConversion.Effort
+
+ info.UpstreamModelName = info.OriginModelName
+ require.NoError(t, ApplyReasoningModelSuffix(info))
+ require.NotNil(t, info.ReasoningConversion)
+ assert.Equal(t, firstMode, info.ReasoningConversion.Mode)
+ assert.Equal(t, firstEffort, info.ReasoningConversion.Effort)
+ assert.Equal(t, "claude-opus-4-8", info.UpstreamModelName)
+}
+
+func TestApplyReasoningModelSuffixRetryClearsStateWhenNewChannelHasNoSuffix(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ req := &dto.ClaudeRequest{Model: "claude-3-7-sonnet"}
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "claude-3-7-sonnet",
+ Request: req,
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: "claude-3-7-sonnet-thinking",
+ IsModelMapped: true,
+ },
+ }
+ require.NoError(t, ApplyReasoningModelSuffix(info))
+ require.NotNil(t, info.ReasoningState())
+
+ ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+ ctx.Request = httptest.NewRequest("POST", "/v1/messages", nil)
+ common.SetContextKey(ctx, constant.ContextKeyOriginalModel, "claude-3-7-sonnet")
+ common.SetContextKey(ctx, constant.ContextKeyChannelType, constant.ChannelTypeAnthropic)
+ info.InitChannelMeta(ctx)
+ assert.Nil(t, info.ReasoningState())
+
+ require.NoError(t, ApplyReasoningModelSuffix(info))
+ assert.Nil(t, info.ReasoningState())
+}
+
+func TestApplyReasoningModelSuffixPassThroughDoesNotTrim(t *testing.T) {
+ settings := model_setting.GetGlobalSettings()
+ original := settings.PassThroughRequestEnabled
+ t.Cleanup(func() { settings.PassThroughRequestEnabled = original })
+ settings.PassThroughRequestEnabled = true
+
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "claude-3-7-sonnet-thinking",
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: "claude-3-7-sonnet-thinking",
+ },
+ }
+
+ require.NoError(t, ApplyReasoningModelSuffix(info))
+ assert.Equal(t, "claude-3-7-sonnet-thinking", info.UpstreamModelName)
+ assert.Nil(t, info.ReasoningConversion)
+}
+
+func TestApplyReasoningModelSuffixBlacklistDoesNotTrim(t *testing.T) {
+ settings := model_setting.GetGlobalSettings()
+ original := append([]string(nil), settings.ThinkingModelBlacklist...)
+ t.Cleanup(func() { settings.ThinkingModelBlacklist = original })
+ settings.ThinkingModelBlacklist = append(settings.ThinkingModelBlacklist, "claude-3-7-sonnet-thinking")
+
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "claude-3-7-sonnet-thinking",
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: "claude-3-7-sonnet-thinking",
+ },
+ }
+
+ require.NoError(t, ApplyReasoningModelSuffix(info))
+ assert.Equal(t, "claude-3-7-sonnet-thinking", info.UpstreamModelName)
+ assert.Nil(t, info.ReasoningConversion)
+}
+
+func TestApplyReasoningModelSuffixRejectsExplicitSuffixConflict(t *testing.T) {
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "claude-3-7-sonnet-thinking",
+ Request: &dto.ClaudeRequest{
+ Model: "claude-3-7-sonnet-thinking",
+ Thinking: &dto.Thinking{Type: "disabled"},
+ },
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: "claude-3-7-sonnet-thinking",
+ },
+ }
+
+ err := ApplyReasoningModelSuffix(info)
+ require.Error(t, err)
+}
+
+func TestApplyReasoningModelSuffixGeminiNoThinkingWhenAdapterEnabled(t *testing.T) {
+ settings := model_setting.GetGeminiSettings()
+ original := settings.ThinkingAdapterEnabled
+ t.Cleanup(func() { settings.ThinkingAdapterEnabled = original })
+ settings.ThinkingAdapterEnabled = true
+
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "gemini-2.5-flash-nothinking",
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: "gemini-2.5-flash-nothinking",
+ },
+ }
+
+ require.NoError(t, ApplyReasoningModelSuffix(info))
+ assert.Equal(t, "gemini-2.5-flash", info.UpstreamModelName)
+ require.NotNil(t, info.ReasoningConversion)
+ assert.Equal(t, "disabled", info.ReasoningConversion.Mode)
+ assert.Equal(t, "none", info.ReasoningConversion.Effort)
+}
+
+func TestApplyReasoningModelSuffixPreservesEffortTailModelID(t *testing.T) {
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "qwen-max",
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: "qwen-max",
+ },
+ }
+
+ require.NoError(t, ApplyReasoningModelSuffix(info))
+ assert.Equal(t, "qwen-max", info.UpstreamModelName)
+ assert.Nil(t, info.ReasoningConversion)
+}
+
+func TestApplyReasoningModelSuffixLeavesDeepSeekV4SuffixForAdaptor(t *testing.T) {
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "deepseek-v4-chat-max",
+ ChannelMeta: &relaycommon.ChannelMeta{
+ ChannelType: constant.ChannelTypeDeepSeek,
+ UpstreamModelName: "deepseek-v4-chat-max",
+ },
+ }
+
+ require.NoError(t, ApplyReasoningModelSuffix(info))
+ assert.Equal(t, "deepseek-v4-chat-max", info.UpstreamModelName)
+ assert.Nil(t, info.ReasoningConversion)
+}
+
+func TestApplyReasoningModelSuffixLeavesVolcengineDeepSeekThinkingForAdaptor(t *testing.T) {
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "deepseek-r1-thinking",
+ ChannelMeta: &relaycommon.ChannelMeta{
+ ChannelType: constant.ChannelTypeVolcEngine,
+ UpstreamModelName: "deepseek-r1-thinking",
+ },
+ }
+
+ require.NoError(t, ApplyReasoningModelSuffix(info))
+ assert.Equal(t, "deepseek-r1-thinking", info.UpstreamModelName)
+ assert.Nil(t, info.ReasoningConversion)
+}
+
+func TestApplyReasoningModelSuffixStillParsesOpenAIEffortTail(t *testing.T) {
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "gpt-5.1-high",
+ ChannelMeta: &relaycommon.ChannelMeta{
+ ChannelType: constant.ChannelTypeOpenAI,
+ UpstreamModelName: "gpt-5.1-high",
+ },
+ }
+
+ require.NoError(t, ApplyReasoningModelSuffix(info))
+ assert.Equal(t, "gpt-5.1", info.UpstreamModelName)
+ require.NotNil(t, info.ReasoningConversion)
+ assert.Equal(t, "enabled", info.ReasoningConversion.Mode)
+ assert.Equal(t, "high", info.ReasoningConversion.Effort)
+}
+
+func TestApplyReasoningModelSuffixTrimsOpenRouterThinkingOnly(t *testing.T) {
+ openRouter := &relaycommon.RelayInfo{
+ OriginModelName: "some-model-thinking",
+ ChannelMeta: &relaycommon.ChannelMeta{
+ ChannelType: constant.ChannelTypeOpenRouter,
+ UpstreamModelName: "some-model-thinking",
+ },
+ }
+ require.NoError(t, ApplyReasoningModelSuffix(openRouter))
+ assert.Equal(t, "some-model", openRouter.UpstreamModelName)
+ require.NotNil(t, openRouter.ReasoningConversion)
+ assert.Equal(t, "enabled", openRouter.ReasoningConversion.Mode)
+}
diff --git a/relay/responses_handler.go b/relay/responses_handler.go
index 4321c7a4b641..13eeeb8f000c 100644
--- a/relay/responses_handler.go
+++ b/relay/responses_handler.go
@@ -70,6 +70,9 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
+ if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
+ return newConvertRequestFailedError(c, info, err)
+ }
adaptor := GetAdaptor(info.ApiType)
if adaptor == nil {
@@ -86,7 +89,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
} else {
convertedRequest, err := adaptor.ConvertOpenAIResponsesRequest(c, info, *request)
if err != nil {
- return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
+ return newConvertRequestFailedError(c, info, err)
}
relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
jsonData, err := common.Marshal(convertedRequest)
diff --git a/relaykit/README.md b/relaykit/README.md
index 4a26376b5b2b..cd534c8c36cf 100644
--- a/relaykit/README.md
+++ b/relaykit/README.md
@@ -199,6 +199,9 @@ meta := &convmeta.Values{
- OpenAI Chat 或 OpenAI Responses 转 Claude 时,Claude 请求必须具有 `max_tokens`。源请求未提供时,需要配置 `Claude.DefaultMaxTokens`,否则转换会返回错误。
- RelayKit 不负责选择渠道或映射模型名。调用转换前,应将请求中的 `Model` 设置为目标上游使用的模型名。
- 自定义 `convmeta.Meta` 的指针实现必须保证所有方法对 nil receiver 安全,完整约束见 `convmeta.Meta` 的接口注释。
+- 工具损耗策略默认是 `allow`:跨协议转换会成功,损耗以诊断形式返回。`safe` / `strict` 只在请求阶段 opt-in 拒绝;响应和流式转换无论策略如何都不会因损耗失败。
+- `ThinkingAdapterEnabled` 只控制是否把已解析的推理意图渲染到 Claude / Gemini 请求上。`-thinking` / `-nothinking` / effort 尾缀等命名约定不再由转换器自动解释。
+- 若你的入口仍使用这些模型名后缀,请在调用转换前自行调用 `relayconvert/reasoning` 的 `Parse*` 帮助函数,把结果写成 `dto.ReasoningConversionState`,并通过 `convmeta.Meta.ReasoningState()`(`convmeta.Values.ReasoningConversion`)传入。同时把发给上游的模型名裁成无后缀基础名。
## 多模态内容
diff --git a/relaykit/dto/billing_usage.go b/relaykit/dto/billing_usage.go
index 075bce41e6bb..ac3535e8bfb6 100644
--- a/relaykit/dto/billing_usage.go
+++ b/relaykit/dto/billing_usage.go
@@ -1,5 +1,7 @@
package dto
+import "strings"
+
const (
BillingUsageSourceClaudeMessages = "claude_messages"
BillingUsageSourceGeminiChat = "gemini_chat"
@@ -100,7 +102,15 @@ func HasOpenAIUsageTokens(usage *Usage) bool {
usage.CompletionTokenDetails.AudioTokens != 0 {
return true
}
- return usage.InputTokensDetails != nil
+ if usage.InputTokensDetails == nil {
+ return false
+ }
+ return usage.InputTokensDetails.CachedTokens != 0 ||
+ usage.InputTokensDetails.CachedCreationTokens != 0 ||
+ usage.InputTokensDetails.CacheWriteTokens != 0 ||
+ usage.InputTokensDetails.TextTokens != 0 ||
+ usage.InputTokensDetails.ImageTokens != 0 ||
+ usage.InputTokensDetails.AudioTokens != 0
}
func NewGeminiChatBillingUsage(metadata *GeminiUsageMetadata) *BillingUsage {
@@ -111,15 +121,92 @@ func NewEstimatedGeminiChatBillingUsage(usage *Usage) *BillingUsage {
if usage == nil {
return nil
}
+ reasoningTokens := usage.CompletionTokenDetails.ReasoningTokens
+ candidateTokens := usage.CompletionTokens - reasoningTokens
+ if candidateTokens < 0 {
+ candidateTokens = 0
+ }
totalTokens := usage.TotalTokens
if totalTokens == 0 {
totalTokens = usage.PromptTokens + usage.CompletionTokens
}
- return newGeminiChatBillingUsage(&GeminiUsageMetadata{
- PromptTokenCount: usage.PromptTokens,
- CandidatesTokenCount: usage.CompletionTokens,
- TotalTokenCount: totalTokens,
- }, true)
+ metadata := &GeminiUsageMetadata{
+ PromptTokenCount: usage.PromptTokens,
+ CandidatesTokenCount: candidateTokens,
+ TotalTokenCount: totalTokens,
+ ThoughtsTokenCount: reasoningTokens,
+ CachedContentTokenCount: usage.PromptTokensDetails.CachedTokens,
+ }
+ for _, detail := range []GeminiPromptTokensDetails{
+ {Modality: "TEXT", TokenCount: usage.PromptTokensDetails.TextTokens},
+ {Modality: "IMAGE", TokenCount: usage.PromptTokensDetails.ImageTokens},
+ {Modality: "AUDIO", TokenCount: usage.PromptTokensDetails.AudioTokens},
+ } {
+ if detail.TokenCount != 0 {
+ metadata.PromptTokensDetails = append(metadata.PromptTokensDetails, detail)
+ }
+ }
+ for _, detail := range []GeminiPromptTokensDetails{
+ {Modality: "TEXT", TokenCount: usage.CompletionTokenDetails.TextTokens},
+ {Modality: "IMAGE", TokenCount: usage.CompletionTokenDetails.ImageTokens},
+ {Modality: "AUDIO", TokenCount: usage.CompletionTokenDetails.AudioTokens},
+ } {
+ if detail.TokenCount != 0 {
+ metadata.CandidatesTokensDetails = append(metadata.CandidatesTokensDetails, detail)
+ }
+ }
+ return newGeminiChatBillingUsage(metadata, true)
+}
+
+// CloneBillingUsageWithEstimatedCompletion preserves the original upstream
+// billing dialect and fills a missing completion count without rebuilding the
+// payload from a converted, potentially lossy Usage value.
+func CloneBillingUsageWithEstimatedCompletion(usage *BillingUsage, completionTokens int) *BillingUsage {
+ clone := CloneBillingUsage(usage)
+ if clone == nil || completionTokens <= 0 {
+ return clone
+ }
+
+ updated := false
+ switch {
+ case clone.OpenAIUsage != nil:
+ openAIUsage := clone.OpenAIUsage
+ if openAIUsage.CompletionTokens == 0 && openAIUsage.OutputTokens == 0 {
+ openAIUsage.CompletionTokens = completionTokens
+ openAIUsage.OutputTokens = completionTokens
+ inputTokens := openAIUsage.PromptTokens
+ if inputTokens == 0 {
+ inputTokens = openAIUsage.InputTokens
+ }
+ if totalTokens := inputTokens + completionTokens; openAIUsage.TotalTokens < totalTokens {
+ openAIUsage.TotalTokens = totalTokens
+ }
+ updated = true
+ }
+ case clone.ClaudeUsage != nil:
+ if clone.ClaudeUsage.OutputTokens == 0 {
+ clone.ClaudeUsage.OutputTokens = completionTokens
+ updated = true
+ }
+ case clone.GeminiUsageMetadata != nil:
+ metadata := clone.GeminiUsageMetadata
+ if metadata.CandidatesTokenCount == 0 {
+ candidateTokens := completionTokens - metadata.ThoughtsTokenCount
+ if candidateTokens < 0 {
+ candidateTokens = 0
+ }
+ metadata.CandidatesTokenCount = candidateTokens
+ totalTokens := metadata.PromptTokenCount + metadata.ToolUsePromptTokenCount + metadata.CandidatesTokenCount + metadata.ThoughtsTokenCount
+ if metadata.TotalTokenCount < totalTokens {
+ metadata.TotalTokenCount = totalTokens
+ }
+ updated = true
+ }
+ }
+ if updated {
+ clone.Estimated = true
+ }
+ return clone
}
func newGeminiChatBillingUsage(metadata *GeminiUsageMetadata, estimated bool) *BillingUsage {
@@ -149,6 +236,165 @@ func CloneBillingUsage(usage *BillingUsage) *BillingUsage {
return &clone
}
+// CanonicalUsage decodes the original provider usage carried across relay
+// hops into the shared accounting shape. The BillingUsage snapshot remains the
+// source of truth and is cloned onto the returned value for further relays.
+func (usage *BillingUsage) CanonicalUsage() (*Usage, bool) {
+ if usage == nil {
+ return nil, false
+ }
+ source := strings.TrimSpace(usage.Source)
+ semantic := strings.TrimSpace(usage.Semantic)
+
+ // A structurally recognized but all-zero payload must not become the
+ // settlement source of truth; rejecting it lets settlement fall back to a
+ // non-zero top-level usage.
+ if HasOpenAIUsageTokens(usage.OpenAIUsage) &&
+ (strings.EqualFold(source, BillingUsageSourceOAIChat) ||
+ strings.EqualFold(source, BillingUsageSourceOAIResponses) ||
+ strings.EqualFold(semantic, BillingUsageSemanticOpenAI)) {
+ return usage.canonicalOpenAIUsage(), true
+ }
+ if HasClaudeUsageTokens(usage.ClaudeUsage) &&
+ (strings.EqualFold(source, BillingUsageSourceClaudeMessages) ||
+ strings.EqualFold(semantic, BillingUsageSemanticAnthropic)) {
+ return usage.canonicalClaudeUsage(), true
+ }
+ if HasGeminiUsageMetadataTokens(usage.GeminiUsageMetadata) &&
+ (strings.EqualFold(source, BillingUsageSourceGeminiChat) ||
+ strings.EqualFold(semantic, BillingUsageSemanticGemini)) {
+ return usage.canonicalGeminiUsage(), true
+ }
+ return nil, false
+}
+
+func (usage *BillingUsage) canonicalOpenAIUsage() *Usage {
+ canonical := cloneOpenAIUsage(usage.OpenAIUsage)
+ if inputDetails := canonical.InputTokensDetails; inputDetails != nil {
+ if canonical.PromptTokensDetails.CachedTokens == 0 && inputDetails.CachedTokens > 0 {
+ canonical.PromptTokensDetails.CachedTokens = inputDetails.CachedTokens
+ }
+ if canonical.PromptTokensDetails.CachedCreationTokens == 0 && inputDetails.CachedCreationTokens > 0 {
+ canonical.PromptTokensDetails.CachedCreationTokens = inputDetails.CachedCreationTokens
+ }
+ if canonical.PromptTokensDetails.CacheWriteTokens == 0 && inputDetails.CacheWriteTokens > 0 {
+ canonical.PromptTokensDetails.CacheWriteTokens = inputDetails.CacheWriteTokens
+ }
+ if canonical.PromptTokensDetails.TextTokens == 0 && inputDetails.TextTokens > 0 {
+ canonical.PromptTokensDetails.TextTokens = inputDetails.TextTokens
+ }
+ if canonical.PromptTokensDetails.ImageTokens == 0 && inputDetails.ImageTokens > 0 {
+ canonical.PromptTokensDetails.ImageTokens = inputDetails.ImageTokens
+ }
+ if canonical.PromptTokensDetails.AudioTokens == 0 && inputDetails.AudioTokens > 0 {
+ canonical.PromptTokensDetails.AudioTokens = inputDetails.AudioTokens
+ }
+ }
+ if canonical.PromptTokensDetails.CachedTokens == 0 && canonical.PromptCacheHitTokens > 0 {
+ canonical.PromptTokensDetails.CachedTokens = canonical.PromptCacheHitTokens
+ }
+ if canonical.PromptTokens == 0 && canonical.InputTokens > 0 {
+ canonical.PromptTokens = canonical.InputTokens
+ }
+ if canonical.CompletionTokens == 0 && canonical.OutputTokens > 0 {
+ canonical.CompletionTokens = canonical.OutputTokens
+ }
+ if canonical.InputTokens == 0 && canonical.PromptTokens > 0 {
+ canonical.InputTokens = canonical.PromptTokens
+ }
+ if canonical.OutputTokens == 0 && canonical.CompletionTokens > 0 {
+ canonical.OutputTokens = canonical.CompletionTokens
+ }
+ if canonical.TotalTokens == 0 {
+ canonical.TotalTokens = canonical.PromptTokens + canonical.CompletionTokens
+ }
+ canonical.UsageSemantic = BillingUsageSemanticOpenAI
+ canonical.UsageSource = usage.Source
+ canonical.BillingUsage = CloneBillingUsage(usage)
+ return canonical
+}
+
+func (usage *BillingUsage) canonicalClaudeUsage() *Usage {
+ claudeUsage := usage.ClaudeUsage
+ cacheCreation5m := claudeUsage.GetCacheCreation5mTokens()
+ if cacheCreation5m == 0 {
+ cacheCreation5m = claudeUsage.ClaudeCacheCreation5mTokens
+ }
+ cacheCreation1h := claudeUsage.GetCacheCreation1hTokens()
+ if cacheCreation1h == 0 {
+ cacheCreation1h = claudeUsage.ClaudeCacheCreation1hTokens
+ }
+
+ canonical := &Usage{
+ PromptTokens: claudeUsage.InputTokens,
+ CompletionTokens: claudeUsage.OutputTokens,
+ TotalTokens: claudeUsage.InputTokens + claudeUsage.OutputTokens,
+ InputTokens: claudeUsage.InputTokens + claudeUsage.CacheReadInputTokens + claudeUsage.CacheCreationInputTokens,
+ OutputTokens: claudeUsage.OutputTokens,
+ UsageSemantic: BillingUsageSemanticAnthropic,
+ UsageSource: BillingUsageSourceClaudeMessages,
+ BillingUsage: CloneBillingUsage(usage),
+ ClaudeCacheCreation5mTokens: cacheCreation5m,
+ ClaudeCacheCreation1hTokens: cacheCreation1h,
+ }
+ canonical.PromptTokensDetails.CachedTokens = claudeUsage.CacheReadInputTokens
+ canonical.PromptTokensDetails.CachedCreationTokens = claudeUsage.CacheCreationInputTokens
+ return canonical
+}
+
+func (usage *BillingUsage) canonicalGeminiUsage() *Usage {
+ metadata := usage.GeminiUsageMetadata
+ promptTokens := metadata.PromptTokenCount + metadata.ToolUsePromptTokenCount
+ canonical := &Usage{
+ PromptTokens: promptTokens,
+ CompletionTokens: metadata.CandidatesTokenCount + metadata.ThoughtsTokenCount,
+ TotalTokens: metadata.TotalTokenCount,
+ UsageSemantic: BillingUsageSemanticGemini,
+ UsageSource: BillingUsageSourceGeminiChat,
+ BillingUsage: CloneBillingUsage(usage),
+ }
+ canonical.CompletionTokenDetails.ReasoningTokens = metadata.ThoughtsTokenCount
+ canonical.PromptTokensDetails.CachedTokens = metadata.CachedContentTokenCount
+
+ for _, detail := range metadata.PromptTokensDetails {
+ addGeminiInputTokenDetail(&canonical.PromptTokensDetails, detail)
+ }
+ for _, detail := range metadata.ToolUsePromptTokensDetails {
+ addGeminiInputTokenDetail(&canonical.PromptTokensDetails, detail)
+ }
+ for _, detail := range metadata.CandidatesTokensDetails {
+ switch detail.Modality {
+ case "IMAGE":
+ canonical.CompletionTokenDetails.ImageTokens += detail.TokenCount
+ case "AUDIO":
+ canonical.CompletionTokenDetails.AudioTokens += detail.TokenCount
+ case "TEXT":
+ canonical.CompletionTokenDetails.TextTokens += detail.TokenCount
+ }
+ }
+
+ if canonical.TotalTokens == 0 {
+ canonical.TotalTokens = canonical.PromptTokens + canonical.CompletionTokens
+ } else if canonical.CompletionTokens <= 0 {
+ canonical.CompletionTokens = canonical.TotalTokens - canonical.PromptTokens
+ }
+ if canonical.PromptTokens > 0 && canonical.PromptTokensDetails.TextTokens == 0 && canonical.PromptTokensDetails.AudioTokens == 0 {
+ canonical.PromptTokensDetails.TextTokens = canonical.PromptTokens
+ }
+ return canonical
+}
+
+func addGeminiInputTokenDetail(details *InputTokenDetails, detail GeminiPromptTokensDetails) {
+ switch detail.Modality {
+ case "AUDIO":
+ details.AudioTokens += detail.TokenCount
+ case "IMAGE":
+ details.ImageTokens += detail.TokenCount
+ case "TEXT":
+ details.TextTokens += detail.TokenCount
+ }
+}
+
func cloneOpenAIUsage(usage *Usage) *Usage {
if usage == nil {
return nil
diff --git a/relaykit/dto/channel_settings.go b/relaykit/dto/channel_settings.go
index 51f7062a805b..b23d52218e0a 100644
--- a/relaykit/dto/channel_settings.go
+++ b/relaykit/dto/channel_settings.go
@@ -86,6 +86,10 @@ type ChannelOtherSettings struct {
UpstreamModelUpdateLastRemovedModels []string `json:"upstream_model_update_last_removed_models,omitempty"` // 上次检测到的可删除模型
UpstreamModelUpdateIgnoredModels []string `json:"upstream_model_update_ignored_models,omitempty"` // 手动忽略的模型
AdvancedCustom *AdvancedCustomConfig `json:"advanced_custom,omitempty"`
+ // ToolLossPolicy is a channel-level opt-in for request-phase conversion
+ // rejection. Empty follows the default allow policy. Accepted values:
+ // "", "allow", "safe", "strict".
+ ToolLossPolicy string `json:"tool_loss_policy,omitempty"`
}
func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {
@@ -95,6 +99,20 @@ func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {
return *s.OpenRouterEnterprise
}
+// ValidateToolLossPolicy validates the channel-level request-phase tool-loss
+// policy. Empty keeps the default allow policy.
+func (s *ChannelOtherSettings) ValidateToolLossPolicy() error {
+ if s == nil {
+ return nil
+ }
+ switch strings.TrimSpace(s.ToolLossPolicy) {
+ case "", string(types.ConversionLossPolicyAllow), string(types.ConversionLossPolicySafe), string(types.ConversionLossPolicyStrict):
+ return nil
+ default:
+ return fmt.Errorf("invalid tool_loss_policy: %s", s.ToolLossPolicy)
+ }
+}
+
const (
advancedCustomConverterNone = "none"
advancedCustomConverterClaudeMessagesToOpenAIChat = "anthropic_messages_to_openai_chat_completions"
diff --git a/relaykit/dto/channel_settings_test.go b/relaykit/dto/channel_settings_test.go
index e84988731bf8..0f970a11083b 100644
--- a/relaykit/dto/channel_settings_test.go
+++ b/relaykit/dto/channel_settings_test.go
@@ -642,3 +642,15 @@ func TestChannelSettingsValidateHTTPTransport(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "http2_connection_shards")
}
+
+func TestChannelOtherSettingsValidateToolLossPolicy(t *testing.T) {
+ require.NoError(t, (*ChannelOtherSettings)(nil).ValidateToolLossPolicy())
+ require.NoError(t, (&ChannelOtherSettings{}).ValidateToolLossPolicy())
+ require.NoError(t, (&ChannelOtherSettings{ToolLossPolicy: "allow"}).ValidateToolLossPolicy())
+ require.NoError(t, (&ChannelOtherSettings{ToolLossPolicy: "safe"}).ValidateToolLossPolicy())
+ require.NoError(t, (&ChannelOtherSettings{ToolLossPolicy: "strict"}).ValidateToolLossPolicy())
+
+ err := (&ChannelOtherSettings{ToolLossPolicy: "drop"}).ValidateToolLossPolicy()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "tool_loss_policy")
+}
diff --git a/relaykit/dto/claude.go b/relaykit/dto/claude.go
index a3a41e75019b..46a2f87eae05 100644
--- a/relaykit/dto/claude.go
+++ b/relaykit/dto/claude.go
@@ -24,10 +24,24 @@ type ClaudeMediaMessage struct {
PartialJson *string `json:"partial_json,omitempty"`
Role string `json:"role,omitempty"`
Thinking *string `json:"thinking,omitempty"`
+ Data string `json:"data,omitempty"`
Signature string `json:"signature,omitempty"`
Delta string `json:"delta,omitempty"`
CacheControl json.RawMessage `json:"cache_control,omitempty"`
- // tool_calls
+
+ // Text blocks and citations_delta events.
+ Citations json.RawMessage `json:"citations,omitempty"`
+ Citation json.RawMessage `json:"citation,omitempty"`
+
+ // Server-tool and tool-result blocks.
+ Caller json.RawMessage `json:"caller,omitempty"`
+ ServerName string `json:"server_name,omitempty"`
+ IsError *bool `json:"is_error,omitempty"`
+ // ErrorCode is a relaykit compatibility extension. Claude places provider
+ // error codes inside nested tool-result error content.
+ ErrorCode string `json:"error_code,omitempty"`
+
+ // Tool-use and tool-result blocks.
Id string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input any `json:"input,omitempty"`
@@ -173,6 +187,7 @@ type Tool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema map[string]interface{} `json:"input_schema"`
+ Strict *bool `json:"strict,omitempty"`
}
type InputSchema struct {
@@ -182,10 +197,14 @@ type InputSchema struct {
}
type ClaudeWebSearchTool struct {
- Type string `json:"type"`
- Name string `json:"name"`
- MaxUses int `json:"max_uses,omitempty"`
- UserLocation *ClaudeWebSearchUserLocation `json:"user_location,omitempty"`
+ Type string `json:"type"`
+ Name string `json:"name"`
+ MaxUses int `json:"max_uses,omitempty"`
+ AllowedDomains []string `json:"allowed_domains,omitempty"`
+ BlockedDomains []string `json:"blocked_domains,omitempty"`
+ AllowedCallers []string `json:"allowed_callers,omitempty"`
+ ResponseInclusion string `json:"response_inclusion,omitempty"`
+ UserLocation *ClaudeWebSearchUserLocation `json:"user_location,omitempty"`
}
type ClaudeWebSearchUserLocation struct {
@@ -413,7 +432,7 @@ func (c *ClaudeRequest) GetTools() []any {
func (c *ClaudeRequest) GetEfforts() string {
var OutputConfig OutputConfigForEffort
- if err := json.Unmarshal(c.OutputConfig, &OutputConfig); err == nil {
+ if err := kitutil.Unmarshal(c.OutputConfig, &OutputConfig); err == nil {
effort := OutputConfig.Effort
return effort
}
@@ -596,5 +615,8 @@ func (u *ClaudeUsage) GetCacheCreationTotalTokens() int {
}
type ClaudeServerToolUse struct {
- WebSearchRequests int `json:"web_search_requests"`
+ WebSearchRequests int `json:"web_search_requests,omitempty"`
+ WebFetchRequests int `json:"web_fetch_requests,omitempty"`
+ CodeExecutionRequests int `json:"code_execution_requests,omitempty"`
+ ToolSearchRequests int `json:"tool_search_requests,omitempty"`
}
diff --git a/relaykit/dto/gemini.go b/relaykit/dto/gemini.go
index 033fcc0f8b3d..3a68a0203ae8 100644
--- a/relaykit/dto/gemini.go
+++ b/relaykit/dto/gemini.go
@@ -48,8 +48,9 @@ type ToolConfig struct {
}
type FunctionCallingConfig struct {
- Mode FunctionCallingConfigMode `json:"mode,omitempty"`
- AllowedFunctionNames []string `json:"allowedFunctionNames,omitempty"`
+ Mode FunctionCallingConfigMode `json:"mode,omitempty"`
+ AllowedFunctionNames []string `json:"allowedFunctionNames,omitempty"`
+ StreamFunctionCallArguments *bool `json:"streamFunctionCallArguments,omitempty"`
}
type FunctionCallingConfigMode string
@@ -161,8 +162,8 @@ func (r *GeminiChatRequest) SetTools(tools []GeminiChatTool) {
}
type GeminiThinkingConfig struct {
- IncludeThoughts bool `json:"includeThoughts,omitempty"`
- ThinkingBudget *int `json:"thinkingBudget,omitempty"`
+ IncludeThoughts *bool `json:"includeThoughts,omitempty"`
+ ThinkingBudget *int `json:"thinkingBudget,omitempty"`
// TODO Conflict with thinkingbudget.
ThinkingLevel string `json:"thinkingLevel,omitempty"`
}
@@ -184,7 +185,7 @@ func (c *GeminiThinkingConfig) UnmarshalJSON(data []byte) error {
*c = GeminiThinkingConfig(aux.Alias)
if aux.IncludeThoughtsSnake != nil {
- c.IncludeThoughts = *aux.IncludeThoughtsSnake
+ c.IncludeThoughts = aux.IncludeThoughtsSnake
}
if aux.ThinkingBudgetSnake != nil {
@@ -239,8 +240,21 @@ func (g *GeminiInlineData) UnmarshalJSON(data []byte) error {
}
type FunctionCall struct {
- FunctionName string `json:"name"`
- Arguments any `json:"args"`
+ // ID is optional in the Gemini protocol and identifies the matching function response.
+ ID string `json:"id,omitempty"`
+ FunctionName string `json:"name"`
+ Arguments any `json:"args"`
+ PartialArgs []GeminiPartialArg `json:"partialArgs,omitempty"`
+ WillContinue *bool `json:"willContinue,omitempty"`
+}
+
+type GeminiPartialArg struct {
+ JSONPath string `json:"jsonPath"`
+ NumberValue *float64 `json:"numberValue,omitempty"`
+ StringValue *string `json:"stringValue,omitempty"`
+ BoolValue *bool `json:"boolValue,omitempty"`
+ NullValue json.RawMessage `json:"nullValue,omitempty"`
+ WillContinue *bool `json:"willContinue,omitempty"`
}
type GeminiFunctionResponse struct {
@@ -320,11 +334,16 @@ type GeminiChatSafetySettings struct {
}
type GeminiChatTool struct {
- GoogleSearch any `json:"googleSearch,omitempty"`
- GoogleSearchRetrieval any `json:"googleSearchRetrieval,omitempty"`
- CodeExecution any `json:"codeExecution,omitempty"`
- FunctionDeclarations any `json:"functionDeclarations,omitempty"`
- URLContext any `json:"urlContext,omitempty"`
+ GoogleSearch any `json:"googleSearch,omitempty"`
+ GoogleSearchRetrieval any `json:"googleSearchRetrieval,omitempty"`
+ GoogleMaps json.RawMessage `json:"googleMaps,omitempty"`
+ EnterpriseWebSearch json.RawMessage `json:"enterpriseWebSearch,omitempty"`
+ CodeExecution any `json:"codeExecution,omitempty"`
+ FunctionDeclarations any `json:"functionDeclarations,omitempty"`
+ URLContext any `json:"urlContext,omitempty"`
+ FileSearch json.RawMessage `json:"fileSearch,omitempty"`
+ ComputerUse json.RawMessage `json:"computerUse,omitempty"`
+ Retrieval json.RawMessage `json:"retrieval,omitempty"`
}
type GeminiChatGenerationConfig struct {
@@ -447,7 +466,14 @@ type GeminiChatCandidate struct {
}
type GeminiGroundingMetadata struct {
- WebSearchQueries []string `json:"webSearchQueries,omitempty"`
+ WebSearchQueries []string `json:"webSearchQueries,omitempty"`
+ RetrievalQueries []string `json:"retrievalQueries,omitempty"`
+ GroundingChunks json.RawMessage `json:"groundingChunks,omitempty"`
+ GroundingSupports json.RawMessage `json:"groundingSupports,omitempty"`
+ SearchEntryPoint json.RawMessage `json:"searchEntryPoint,omitempty"`
+ RetrievalMetadata json.RawMessage `json:"retrievalMetadata,omitempty"`
+ SourceFlaggingUris json.RawMessage `json:"sourceFlaggingUris,omitempty"`
+ GoogleMapsWidgetContextToken string `json:"googleMapsWidgetContextToken,omitempty"`
}
type GeminiChatSafetyRating struct {
diff --git a/relaykit/dto/openai_request.go b/relaykit/dto/openai_request.go
index d54ac0d15bde..1d89361e90a9 100644
--- a/relaykit/dto/openai_request.go
+++ b/relaykit/dto/openai_request.go
@@ -81,7 +81,7 @@ type GeneralOpenAIRequest struct {
ExtraBody json.RawMessage `json:"extra_body,omitempty"`
//xai
SearchParameters json.RawMessage `json:"search_parameters,omitempty"`
- // claude
+ // OpenAI Chat web search.
WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
// OpenRouter Params
Usage json.RawMessage `json:"usage,omitempty"`
@@ -108,6 +108,9 @@ type GeneralOpenAIRequest struct {
ReasoningSplit json.RawMessage `json:"reasoning_split,omitempty"`
// vLLM
ThinkingTokenBudget json.RawMessage `json:"thinking_token_budget,omitempty"`
+
+ // Internal conversion state; never serialized to an upstream protocol.
+ ReasoningConversion *ReasoningConversionState `json:"-"`
}
func (r GeneralOpenAIRequest) MarshalJSON() ([]byte, error) {
@@ -266,6 +269,7 @@ type FunctionRequest struct {
Name string `json:"name"`
Parameters any `json:"parameters,omitempty"`
Arguments string `json:"arguments,omitempty"`
+ Strict *bool `json:"strict,omitempty"`
}
type StreamOptions struct {
@@ -311,7 +315,10 @@ type Message struct {
Reasoning *string `json:"reasoning,omitempty"`
ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
ToolCallId string `json:"tool_call_id,omitempty"`
- parsedContent []MediaContent
+ // Annotations is an official Chat response field. Keeping it on the shared
+ // message type also preserves annotations when clients replay assistant output.
+ Annotations json.RawMessage `json:"annotations,omitempty"`
+ parsedContent []MediaContent
//parsedStringContent *string
}
@@ -485,14 +492,14 @@ func (m *Message) ParseToolCalls() []ToolCallRequest {
return nil
}
var toolCalls []ToolCallRequest
- if err := json.Unmarshal(m.ToolCalls, &toolCalls); err == nil {
+ if err := kitutil.Unmarshal(m.ToolCalls, &toolCalls); err == nil {
return toolCalls
}
return toolCalls
}
func (m *Message) SetToolCalls(toolCalls any) {
- toolCallsJson, _ := json.Marshal(toolCalls)
+ toolCallsJson, _ := kitutil.Marshal(toolCalls)
m.ToolCalls = toolCallsJson
}
@@ -562,6 +569,11 @@ func (m *Message) ParseContent() []MediaContent {
return contentList
}
+ if content, ok := m.Content.([]MediaContent); ok {
+ m.parsedContent = content
+ return content
+ }
+
// 尝试解析为数组
//var arrayContent []map[string]interface{}
@@ -682,7 +694,7 @@ func (m *Message) ParseContent() []MediaContent {
}
var stringContent string
- if err := json.Unmarshal(m.Content, &stringContent); err == nil {
+ if err := kitutil.Unmarshal(m.Content, &stringContent); err == nil {
m.parsedStringContent = &stringContent
return stringContent
}
@@ -707,14 +719,14 @@ func (m *Message) SetNullContent() {
}
func (m *Message) SetStringContent(content string) {
- jsonContent, _ := json.Marshal(content)
+ jsonContent, _ := kitutil.Marshal(content)
m.Content = jsonContent
m.parsedStringContent = &content
m.parsedContent = nil
}
func (m *Message) SetMediaContent(content []MediaContent) {
- jsonContent, _ := json.Marshal(content)
+ jsonContent, _ := kitutil.Marshal(content)
m.Content = jsonContent
m.parsedContent = nil
m.parsedStringContent = nil
@@ -725,7 +737,7 @@ func (m *Message) IsStringContent() bool {
return true
}
var stringContent string
- if err := json.Unmarshal(m.Content, &stringContent); err == nil {
+ if err := kitutil.Unmarshal(m.Content, &stringContent); err == nil {
m.parsedStringContent = &stringContent
return true
}
@@ -741,7 +753,7 @@ func (m *Message) ParseContent() []MediaContent {
// 先尝试解析为字符串
var stringContent string
- if err := json.Unmarshal(m.Content, &stringContent); err == nil {
+ if err := kitutil.Unmarshal(m.Content, &stringContent); err == nil {
contentList = []MediaContent{{
Type: ContentTypeText,
Text: stringContent,
@@ -752,7 +764,7 @@ func (m *Message) ParseContent() []MediaContent {
// 尝试解析为数组
var arrayContent []map[string]interface{}
- if err := json.Unmarshal(m.Content, &arrayContent); err == nil {
+ if err := kitutil.Unmarshal(m.Content, &arrayContent); err == nil {
for _, contentItem := range arrayContent {
contentType, ok := contentItem["type"].(string)
if !ok {
@@ -907,6 +919,9 @@ type OpenAIResponsesRequest struct {
ThinkingBudget json.RawMessage `json:"thinking_budget,omitempty"`
// perplexity
Preset json.RawMessage `json:"preset,omitempty"`
+
+ // Internal conversion state; never serialized to an upstream protocol.
+ ReasoningConversion *ReasoningConversionState `json:"-"`
}
func (r OpenAIResponsesRequest) MarshalJSON() ([]byte, error) {
diff --git a/relaykit/dto/openai_response.go b/relaykit/dto/openai_response.go
index 945d0a869904..6ab5c6fcf062 100644
--- a/relaykit/dto/openai_response.go
+++ b/relaykit/dto/openai_response.go
@@ -3,6 +3,7 @@ package dto
import (
"encoding/json"
"fmt"
+ "strings"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
@@ -91,6 +92,10 @@ type ChatCompletionsStreamResponseChoiceDelta struct {
Reasoning *string `json:"reasoning,omitempty"`
Role string `json:"role,omitempty"`
ToolCalls []ToolCallResponse `json:"tool_calls,omitempty"`
+ // Annotations is an OpenAI-compatible streaming extension supported by
+ // providers such as OpenRouter. Relaykit uses it to preserve streaming URL
+ // citations, including Claude round-trip metadata.
+ Annotations json.RawMessage `json:"annotations,omitempty"`
}
func (c *ChatCompletionsStreamResponseChoiceDelta) SetContentString(s string) {
@@ -325,17 +330,143 @@ type IncompleteDetails struct {
}
type ResponsesOutput struct {
- Type string `json:"type"`
- ID string `json:"id"`
- Status string `json:"status"`
- Role string `json:"role"`
- Content []ResponsesOutputContent `json:"content"`
- Quality string `json:"quality"`
- Size string `json:"size"`
- Result string `json:"result,omitempty"`
- CallId string `json:"call_id,omitempty"`
- Name string `json:"name,omitempty"`
- Arguments json.RawMessage `json:"arguments,omitempty"`
+ Type string `json:"type"`
+ ID string `json:"id"`
+ Status string `json:"status"`
+ Role string `json:"role"`
+ Content []ResponsesOutputContent `json:"content"`
+ Summary []ResponsesReasoningSummaryPart `json:"summary,omitempty"`
+ Quality string `json:"quality"`
+ Size string `json:"size"`
+ Result string `json:"result,omitempty"`
+ CallId string `json:"call_id,omitempty"`
+ Name string `json:"name,omitempty"`
+ Arguments json.RawMessage `json:"arguments,omitempty"`
+ Action json.RawMessage `json:"action,omitempty"`
+ Queries json.RawMessage `json:"queries,omitempty"`
+ Results json.RawMessage `json:"results,omitempty"`
+ Sources json.RawMessage `json:"sources,omitempty"`
+ Code json.RawMessage `json:"code,omitempty"`
+ Outputs json.RawMessage `json:"outputs,omitempty"`
+ ContainerID string `json:"container_id,omitempty"`
+ PendingSafetyChecks json.RawMessage `json:"pending_safety_checks,omitempty"`
+ Caller json.RawMessage `json:"caller,omitempty"`
+ ServerLabel string `json:"server_label,omitempty"`
+ Output json.RawMessage `json:"output,omitempty"`
+ ItemError json.RawMessage `json:"error,omitempty"`
+ ApprovalRequestID string `json:"approval_request_id,omitempty"`
+ MCPTools json.RawMessage `json:"tools,omitempty"`
+}
+
+// MarshalJSON keeps hosted-tool variants within their protocol-specific
+// schemas. ResponsesOutput also represents messages, images, and function
+// calls, whose fields must not leak into web_search_call or mcp_call items.
+func (r ResponsesOutput) MarshalJSON() ([]byte, error) {
+ switch r.Type {
+ case "web_search_call":
+ return kitutil.Marshal(struct {
+ Type string `json:"type"`
+ ID string `json:"id"`
+ Status string `json:"status,omitempty"`
+ Action json.RawMessage `json:"action,omitempty"`
+ }{Type: r.Type, ID: r.ID, Status: r.Status, Action: r.Action})
+ case "mcp_call":
+ return kitutil.Marshal(struct {
+ Type string `json:"type"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ ServerLabel string `json:"server_label"`
+ Arguments json.RawMessage `json:"arguments"`
+ Status string `json:"status,omitempty"`
+ Output json.RawMessage `json:"output,omitempty"`
+ Error json.RawMessage `json:"error,omitempty"`
+ ApprovalRequestID string `json:"approval_request_id,omitempty"`
+ }{
+ Type: r.Type,
+ ID: r.ID,
+ Name: r.Name,
+ ServerLabel: r.ServerLabel,
+ Arguments: r.Arguments,
+ Status: r.Status,
+ Output: r.Output,
+ Error: r.ItemError,
+ ApprovalRequestID: r.ApprovalRequestID,
+ })
+ default:
+ type responsesOutputAlias ResponsesOutput
+ return kitutil.Marshal(responsesOutputAlias(r))
+ }
+}
+
+// NormalizeResponsesWebSearchAction validates and canonicalizes the current
+// Responses web_search_call action union. Claude emits {"query": ...}; the
+// Responses representation additionally requires a discriminator.
+func NormalizeResponsesWebSearchAction(raw json.RawMessage) (json.RawMessage, error) {
+ var action struct {
+ Type string `json:"type"`
+ Query string `json:"query"`
+ Queries []string `json:"queries"`
+ Sources json.RawMessage `json:"sources"`
+ URL string `json:"url"`
+ Pattern string `json:"pattern"`
+ }
+ if err := kitutil.Unmarshal(raw, &action); err != nil {
+ return nil, fmt.Errorf("decode Responses web-search action: %w", err)
+ }
+ action.Type = strings.TrimSpace(action.Type)
+ action.Query = strings.TrimSpace(action.Query)
+ action.URL = strings.TrimSpace(action.URL)
+ action.Pattern = strings.TrimSpace(action.Pattern)
+ for index := range action.Queries {
+ action.Queries[index] = strings.TrimSpace(action.Queries[index])
+ if action.Queries[index] == "" {
+ return nil, fmt.Errorf("Responses web-search action queries[%d] must not be empty", index)
+ }
+ }
+ if action.Type == "" && (action.Query != "" || len(action.Queries) > 0) {
+ action.Type = "search"
+ }
+
+ var canonical any
+ switch action.Type {
+ case "search":
+ if action.Query == "" && len(action.Queries) == 0 {
+ return nil, fmt.Errorf("Responses web-search action %q requires query or queries", action.Type)
+ }
+ if len(action.Sources) > 0 && kitutil.GetJsonType(action.Sources) != "array" && kitutil.GetJsonType(action.Sources) != "null" {
+ return nil, fmt.Errorf("Responses web-search action sources must be an array")
+ }
+ canonical = struct {
+ Type string `json:"type"`
+ Query string `json:"query,omitempty"`
+ Queries []string `json:"queries,omitempty"`
+ Sources json.RawMessage `json:"sources,omitempty"`
+ }{Type: action.Type, Query: action.Query, Queries: action.Queries, Sources: action.Sources}
+ case "open_page":
+ if action.URL == "" {
+ return nil, fmt.Errorf("Responses web-search action %q requires url", action.Type)
+ }
+ canonical = struct {
+ Type string `json:"type"`
+ URL string `json:"url"`
+ }{Type: action.Type, URL: action.URL}
+ case "find", "find_in_page":
+ if action.URL == "" || action.Pattern == "" {
+ return nil, fmt.Errorf("Responses web-search action %q requires url and pattern", action.Type)
+ }
+ canonical = struct {
+ Type string `json:"type"`
+ URL string `json:"url"`
+ Pattern string `json:"pattern"`
+ }{Type: "find_in_page", URL: action.URL, Pattern: action.Pattern}
+ default:
+ return nil, fmt.Errorf("unsupported Responses web-search action type %q", action.Type)
+ }
+ encoded, err := kitutil.Marshal(canonical)
+ if err != nil {
+ return nil, fmt.Errorf("encode Responses web-search action: %w", err)
+ }
+ return encoded, nil
}
// ArgumentsString returns function call arguments in the string form expected by Chat Completions.
@@ -384,10 +515,20 @@ const (
// ResponsesStreamResponse 用于处理 /v1/responses 流式响应
type ResponsesStreamResponse struct {
- Type string `json:"type"`
- Response *OpenAIResponsesResponse `json:"response,omitempty"`
- Delta string `json:"delta,omitempty"`
- Item *ResponsesOutput `json:"item,omitempty"`
+ Type string `json:"type"`
+ Response *OpenAIResponsesResponse `json:"response,omitempty"`
+ Code string `json:"code,omitempty"`
+ Message string `json:"message,omitempty"`
+ Param string `json:"param,omitempty"`
+ Delta string `json:"delta,omitempty"`
+ Arguments *string `json:"arguments,omitempty"`
+ Name string `json:"name,omitempty"`
+ Text *string `json:"text,omitempty"`
+ Item *ResponsesOutput `json:"item,omitempty"`
+ SequenceNumber *int `json:"sequence_number,omitempty"`
+ Annotation json.RawMessage `json:"annotation,omitempty"`
+ AnnotationIndex *int `json:"annotation_index,omitempty"`
+ Obfuscation string `json:"obfuscation,omitempty"`
// - response.function_call_arguments.delta
// - response.function_call_arguments.done
OutputIndex *int `json:"output_index,omitempty"`
diff --git a/relaykit/dto/reasoning_state.go b/relaykit/dto/reasoning_state.go
new file mode 100644
index 000000000000..8a588caa777d
--- /dev/null
+++ b/relaykit/dto/reasoning_state.go
@@ -0,0 +1,14 @@
+package dto
+
+// ReasoningConversionState carries provider-native reasoning controls between
+// in-process conversion steps. It is not part of any provider wire protocol;
+// request fields that reference it must use json:"-".
+//
+// Converters that rebuild an OpenAI request must copy this state so exact
+// budgets and explicit include-thoughts choices survive multi-step routes.
+type ReasoningConversionState struct {
+ Mode string
+ Effort string
+ BudgetTokens *int
+ IncludeThoughts *bool
+}
diff --git a/relaykit/dto/usage_merge.go b/relaykit/dto/usage_merge.go
new file mode 100644
index 000000000000..39383b1b7794
--- /dev/null
+++ b/relaykit/dto/usage_merge.go
@@ -0,0 +1,281 @@
+package dto
+
+import (
+ "reflect"
+ "strings"
+)
+
+// MergeUsageNonZero overlays usage snapshots: a later non-zero field
+// overwrites the current value, while a later zero value never erases an
+// earlier positive count. Compatible BillingUsage snapshots follow the same
+// rule within their provider-native payload.
+func MergeUsageNonZero(current *Usage, incoming *Usage) *Usage {
+ if current == nil {
+ current = &Usage{}
+ }
+ if incoming == nil {
+ return current
+ }
+
+ if incoming.PromptTokens > 0 {
+ current.PromptTokens = incoming.PromptTokens
+ }
+ if incoming.CompletionTokens > 0 {
+ current.CompletionTokens = incoming.CompletionTokens
+ }
+ if incoming.TotalTokens > 0 {
+ current.TotalTokens = incoming.TotalTokens
+ }
+ if incoming.PromptCacheHitTokens > 0 {
+ current.PromptCacheHitTokens = incoming.PromptCacheHitTokens
+ }
+ if incoming.InputTokens > 0 {
+ current.InputTokens = incoming.InputTokens
+ }
+ if incoming.OutputTokens > 0 {
+ current.OutputTokens = incoming.OutputTokens
+ }
+ if incoming.ClaudeCacheCreation5mTokens > 0 {
+ current.ClaudeCacheCreation5mTokens = incoming.ClaudeCacheCreation5mTokens
+ }
+ if incoming.ClaudeCacheCreation1hTokens > 0 {
+ current.ClaudeCacheCreation1hTokens = incoming.ClaudeCacheCreation1hTokens
+ }
+
+ mergeInputTokenDetails(¤t.PromptTokensDetails, incoming.PromptTokensDetails)
+ if incoming.InputTokensDetails != nil {
+ details := *incoming.InputTokensDetails
+ if details.CachedTokens > 0 ||
+ details.CachedCreationTokens > 0 ||
+ details.CacheWriteTokens > 0 ||
+ details.TextTokens > 0 ||
+ details.AudioTokens > 0 ||
+ details.ImageTokens > 0 {
+ if current.InputTokensDetails == nil {
+ current.InputTokensDetails = &InputTokenDetails{}
+ }
+ mergeInputTokenDetails(current.InputTokensDetails, details)
+ }
+ }
+
+ if incoming.CompletionTokenDetails.TextTokens > 0 {
+ current.CompletionTokenDetails.TextTokens = incoming.CompletionTokenDetails.TextTokens
+ }
+ if incoming.CompletionTokenDetails.AudioTokens > 0 {
+ current.CompletionTokenDetails.AudioTokens = incoming.CompletionTokenDetails.AudioTokens
+ }
+ if incoming.CompletionTokenDetails.ImageTokens > 0 {
+ current.CompletionTokenDetails.ImageTokens = incoming.CompletionTokenDetails.ImageTokens
+ }
+ if incoming.CompletionTokenDetails.ReasoningTokens > 0 {
+ current.CompletionTokenDetails.ReasoningTokens = incoming.CompletionTokenDetails.ReasoningTokens
+ }
+
+ if incoming.UsageSemantic != "" {
+ current.UsageSemantic = incoming.UsageSemantic
+ }
+ if incoming.UsageSource != "" {
+ current.UsageSource = incoming.UsageSource
+ }
+ if incoming.BillingUsage != nil {
+ current.BillingUsage = MergeBillingUsageNonZero(current.BillingUsage, incoming.BillingUsage)
+ }
+ if incoming.Cost != nil && !reflect.ValueOf(incoming.Cost).IsZero() {
+ current.Cost = incoming.Cost
+ }
+ if total := current.PromptTokens + current.CompletionTokens; total > current.TotalTokens {
+ current.TotalTokens = total
+ }
+ if total := current.InputTokens + current.OutputTokens; total > current.TotalTokens {
+ current.TotalTokens = total
+ }
+
+ return current
+}
+
+// MergeBillingUsageNonZero preserves non-zero provider-native fields across
+// partial stream snapshots. A snapshot from a different billing dialect
+// remains authoritative and replaces the previous payload.
+func MergeBillingUsageNonZero(current *BillingUsage, incoming *BillingUsage) *BillingUsage {
+ if incoming == nil {
+ return CloneBillingUsage(current)
+ }
+ if current == nil || !sameBillingUsageDialect(current, incoming) {
+ return CloneBillingUsage(incoming)
+ }
+
+ merged := CloneBillingUsage(current)
+ if incoming.Source != "" {
+ merged.Source = incoming.Source
+ }
+ if incoming.Semantic != "" {
+ merged.Semantic = incoming.Semantic
+ }
+ merged.Estimated = current.Estimated || incoming.Estimated
+
+ switch {
+ case current.OpenAIUsage != nil && incoming.OpenAIUsage != nil:
+ merged.OpenAIUsage = MergeUsageNonZero(
+ cloneOpenAIUsage(current.OpenAIUsage),
+ cloneOpenAIUsage(incoming.OpenAIUsage),
+ )
+ case current.ClaudeUsage != nil && incoming.ClaudeUsage != nil:
+ merged.ClaudeUsage = mergeClaudeUsageNonZero(current.ClaudeUsage, incoming.ClaudeUsage)
+ case current.GeminiUsageMetadata != nil && incoming.GeminiUsageMetadata != nil:
+ merged.GeminiUsageMetadata = MergeGeminiUsageMetadataNonZero(current.GeminiUsageMetadata, incoming.GeminiUsageMetadata)
+ }
+
+ return merged
+}
+
+func sameBillingUsageDialect(current *BillingUsage, incoming *BillingUsage) bool {
+ if current.Source != "" && incoming.Source != "" && !strings.EqualFold(current.Source, incoming.Source) {
+ return false
+ }
+ if current.Semantic != "" && incoming.Semantic != "" && !strings.EqualFold(current.Semantic, incoming.Semantic) {
+ return false
+ }
+ return current.OpenAIUsage != nil && incoming.OpenAIUsage != nil ||
+ current.ClaudeUsage != nil && incoming.ClaudeUsage != nil ||
+ current.GeminiUsageMetadata != nil && incoming.GeminiUsageMetadata != nil
+}
+
+func mergeClaudeUsageNonZero(current *ClaudeUsage, incoming *ClaudeUsage) *ClaudeUsage {
+ merged := cloneClaudeUsage(current)
+ if merged == nil {
+ merged = &ClaudeUsage{}
+ }
+ if incoming == nil {
+ return merged
+ }
+ if incoming.InputTokens > 0 {
+ merged.InputTokens = incoming.InputTokens
+ }
+ if incoming.CacheCreationInputTokens > 0 {
+ merged.CacheCreationInputTokens = incoming.CacheCreationInputTokens
+ }
+ if incoming.CacheReadInputTokens > 0 {
+ merged.CacheReadInputTokens = incoming.CacheReadInputTokens
+ }
+ if incoming.OutputTokens > 0 {
+ merged.OutputTokens = incoming.OutputTokens
+ }
+ if incoming.ClaudeCacheCreation5mTokens > 0 {
+ merged.ClaudeCacheCreation5mTokens = incoming.ClaudeCacheCreation5mTokens
+ }
+ if incoming.ClaudeCacheCreation1hTokens > 0 {
+ merged.ClaudeCacheCreation1hTokens = incoming.ClaudeCacheCreation1hTokens
+ }
+ if incoming.CacheCreation != nil {
+ cacheCreation := *incoming.CacheCreation
+ merged.CacheCreation = &cacheCreation
+ }
+ if incoming.ServerToolUse != nil {
+ if merged.ServerToolUse == nil {
+ merged.ServerToolUse = &ClaudeServerToolUse{}
+ }
+ if incoming.ServerToolUse.WebSearchRequests > 0 {
+ merged.ServerToolUse.WebSearchRequests = incoming.ServerToolUse.WebSearchRequests
+ }
+ if incoming.ServerToolUse.WebFetchRequests > 0 {
+ merged.ServerToolUse.WebFetchRequests = incoming.ServerToolUse.WebFetchRequests
+ }
+ if incoming.ServerToolUse.CodeExecutionRequests > 0 {
+ merged.ServerToolUse.CodeExecutionRequests = incoming.ServerToolUse.CodeExecutionRequests
+ }
+ if incoming.ServerToolUse.ToolSearchRequests > 0 {
+ merged.ServerToolUse.ToolSearchRequests = incoming.ServerToolUse.ToolSearchRequests
+ }
+ }
+ return merged
+}
+
+// MergeGeminiUsageMetadataNonZero overlays Gemini's cumulative usage
+// snapshots: a later non-zero field overwrites the current value without
+// dropping fields omitted by a later chunk.
+func MergeGeminiUsageMetadataNonZero(current *GeminiUsageMetadata, incoming *GeminiUsageMetadata) *GeminiUsageMetadata {
+ if current == nil && incoming == nil {
+ return nil
+ }
+ if current == nil {
+ metadata := cloneGeminiUsageMetadata(*incoming)
+ metadata.BillingUsage = CloneBillingUsage(incoming.BillingUsage)
+ return &metadata
+ }
+
+ merged := cloneGeminiUsageMetadata(*current)
+ merged.BillingUsage = CloneBillingUsage(current.BillingUsage)
+ if incoming == nil {
+ return &merged
+ }
+ if incoming.PromptTokenCount > 0 {
+ merged.PromptTokenCount = incoming.PromptTokenCount
+ }
+ if incoming.ToolUsePromptTokenCount > 0 {
+ merged.ToolUsePromptTokenCount = incoming.ToolUsePromptTokenCount
+ }
+ if incoming.CandidatesTokenCount > 0 {
+ merged.CandidatesTokenCount = incoming.CandidatesTokenCount
+ merged.ThoughtsTokenCount = incoming.ThoughtsTokenCount
+ } else if incoming.ThoughtsTokenCount > 0 {
+ merged.ThoughtsTokenCount = incoming.ThoughtsTokenCount
+ }
+ if incoming.TotalTokenCount > 0 {
+ merged.TotalTokenCount = incoming.TotalTokenCount
+ }
+ if incoming.CachedContentTokenCount > 0 {
+ merged.CachedContentTokenCount = incoming.CachedContentTokenCount
+ }
+ merged.PromptTokensDetails = mergeGeminiTokenDetails(merged.PromptTokensDetails, incoming.PromptTokensDetails)
+ merged.ToolUsePromptTokensDetails = mergeGeminiTokenDetails(merged.ToolUsePromptTokensDetails, incoming.ToolUsePromptTokensDetails)
+ merged.CandidatesTokensDetails = mergeGeminiTokenDetails(merged.CandidatesTokensDetails, incoming.CandidatesTokensDetails)
+ if incoming.BillingUsage != nil {
+ merged.BillingUsage = MergeBillingUsageNonZero(merged.BillingUsage, incoming.BillingUsage)
+ }
+ if total := merged.PromptTokenCount + merged.ToolUsePromptTokenCount + merged.CandidatesTokenCount + merged.ThoughtsTokenCount; total > merged.TotalTokenCount {
+ merged.TotalTokenCount = total
+ }
+ return &merged
+}
+
+func mergeGeminiTokenDetails(current []GeminiPromptTokensDetails, incoming []GeminiPromptTokensDetails) []GeminiPromptTokensDetails {
+ merged := append([]GeminiPromptTokensDetails{}, current...)
+ indexes := make(map[string]int, len(merged))
+ for index, detail := range merged {
+ indexes[strings.ToUpper(strings.TrimSpace(detail.Modality))] = index
+ }
+ for _, detail := range incoming {
+ if detail.TokenCount <= 0 {
+ continue
+ }
+ key := strings.ToUpper(strings.TrimSpace(detail.Modality))
+ if index, ok := indexes[key]; ok {
+ merged[index] = detail
+ continue
+ }
+ indexes[key] = len(merged)
+ merged = append(merged, detail)
+ }
+ return merged
+}
+
+func mergeInputTokenDetails(current *InputTokenDetails, incoming InputTokenDetails) {
+ if incoming.CachedTokens > 0 {
+ current.CachedTokens = incoming.CachedTokens
+ }
+ if incoming.CachedCreationTokens > 0 {
+ current.CachedCreationTokens = incoming.CachedCreationTokens
+ }
+ if incoming.CacheWriteTokens > 0 {
+ current.CacheWriteTokens = incoming.CacheWriteTokens
+ }
+ if incoming.TextTokens > 0 {
+ current.TextTokens = incoming.TextTokens
+ }
+ if incoming.AudioTokens > 0 {
+ current.AudioTokens = incoming.AudioTokens
+ }
+ if incoming.ImageTokens > 0 {
+ current.ImageTokens = incoming.ImageTokens
+ }
+}
diff --git a/relaykit/dto/usage_merge_test.go b/relaykit/dto/usage_merge_test.go
new file mode 100644
index 000000000000..088ff02405d8
--- /dev/null
+++ b/relaykit/dto/usage_merge_test.go
@@ -0,0 +1,67 @@
+package dto
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMergeClaudeUsageCacheCreationReplacesWholeObject(t *testing.T) {
+ t.Parallel()
+
+ merged := mergeClaudeUsageNonZero(
+ &ClaudeUsage{
+ CacheCreation: &ClaudeCacheCreationUsage{Ephemeral1hInputTokens: 1000},
+ },
+ &ClaudeUsage{
+ CacheCreation: &ClaudeCacheCreationUsage{
+ Ephemeral5mInputTokens: 1000,
+ Ephemeral1hInputTokens: 0,
+ },
+ },
+ )
+
+ require.NotNil(t, merged.CacheCreation)
+ assert.Equal(t, 1000, merged.CacheCreation.Ephemeral5mInputTokens)
+ assert.Equal(t, 0, merged.CacheCreation.Ephemeral1hInputTokens)
+}
+
+func TestMergeGeminiUsageMetadataCandidatesAndThoughtsReplacedAsPair(t *testing.T) {
+ t.Parallel()
+
+ merged := MergeGeminiUsageMetadataNonZero(
+ &GeminiUsageMetadata{
+ PromptTokenCount: 10,
+ ThoughtsTokenCount: 100,
+ },
+ &GeminiUsageMetadata{
+ PromptTokenCount: 10,
+ CandidatesTokenCount: 150,
+ ThoughtsTokenCount: 0,
+ TotalTokenCount: 160,
+ },
+ )
+ require.NotNil(t, merged)
+ assert.Equal(t, 150, merged.CandidatesTokenCount)
+ assert.Equal(t, 0, merged.ThoughtsTokenCount)
+
+ billing := NewGeminiChatBillingUsage(merged)
+ usage, ok := billing.CanonicalUsage()
+ require.True(t, ok)
+ assert.Equal(t, 150, usage.CompletionTokens)
+}
+
+func TestMergeUsageNonZeroKeepsPositiveValuesAndTakesMaxTotal(t *testing.T) {
+ t.Parallel()
+
+ merged := MergeUsageNonZero(
+ &Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15},
+ &Usage{PromptTokens: 0, CompletionTokens: 0, TotalTokens: 20},
+ )
+
+ require.NotNil(t, merged)
+ assert.Equal(t, 10, merged.PromptTokens)
+ assert.Equal(t, 5, merged.CompletionTokens)
+ assert.Equal(t, 20, merged.TotalTokens)
+}
diff --git a/relaykit/reasonmap/reasonmap.go b/relaykit/reasonmap/reasonmap.go
index 8c6f66c61808..04d5d2a3e23a 100644
--- a/relaykit/reasonmap/reasonmap.go
+++ b/relaykit/reasonmap/reasonmap.go
@@ -16,6 +16,11 @@ func ClaudeStopReasonToOpenAIFinishReason(stopReason string) string {
return "length"
case "tool_use":
return "tool_calls"
+ case "pause_turn":
+ // Responses has no pause_turn finish reason. Treat the provider's
+ // resumable server-side loop as an incomplete response instead of a
+ // successful stop; the hosted output items preserve continuation state.
+ return "length"
case "refusal":
return types.FinishReasonContentFilter
default:
diff --git a/relaykit/relayconvert/claude_default_max_tokens_test.go b/relaykit/relayconvert/claude_default_max_tokens_test.go
index d97c17b0e808..8cd61c8b9653 100644
--- a/relaykit/relayconvert/claude_default_max_tokens_test.go
+++ b/relaykit/relayconvert/claude_default_max_tokens_test.go
@@ -8,6 +8,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -80,6 +81,21 @@ func TestClaudeDefaultMaxTokensPresence(t *testing.T) {
require.NotNil(t, got.MaxTokens)
assert.Equal(t, clientMaxTokens, *got.MaxTokens)
})
+
+ t.Run("client zero same as absent, hook fills", func(t *testing.T) {
+ clientMaxTokens := uint(0)
+ got, err := converter.convert(t, claudeDefaultsMeta(func(string) int { return 512 }), &clientMaxTokens)
+ require.NoError(t, err)
+ require.NotNil(t, got.MaxTokens)
+ assert.Equal(t, uint(512), *got.MaxTokens)
+ })
+
+ t.Run("client zero same as absent, no hook fails", func(t *testing.T) {
+ clientMaxTokens := uint(0)
+ got, err := converter.convert(t, &convmeta.Values{}, &clientMaxTokens)
+ require.ErrorIs(t, err, sharedclaude.ErrMissingMaxTokens)
+ assert.Nil(t, got)
+ })
})
}
}
@@ -88,12 +104,18 @@ func TestClaudeDefaultMaxTokensPresence(t *testing.T) {
// "-thinking" request without max_tokens must keep converting even when no
// DefaultMaxTokens hook is configured.
func TestClaudeThinkingAdapterSatisfiesMaxTokensWithoutCallback(t *testing.T) {
- meta := &convmeta.Values{Options: &convmeta.Options{
- Claude: convmeta.ClaudeOptions{
- ThinkingAdapterEnabled: true,
- ThinkingAdapterBudgetTokensPercentage: 0.8,
+ _, intent, found, err := reasoning.ParseClaudeModelSuffix("claude-test-thinking", true)
+ require.NoError(t, err)
+ require.True(t, found)
+ meta := &convmeta.Values{
+ ReasoningConversion: reasoning.StateFromIntent(intent),
+ Options: &convmeta.Options{
+ Claude: convmeta.ClaudeOptions{
+ ThinkingAdapterEnabled: true,
+ ThinkingAdapterBudgetTokensPercentage: 0.8,
+ },
},
- }}
+ }
got, err := OpenAIChatRequestToClaudeMessages(context.Background(), meta, dto.GeneralOpenAIRequest{
Model: "claude-test-thinking",
Messages: []dto.Message{
diff --git a/relaykit/relayconvert/convmeta/meta.go b/relaykit/relayconvert/convmeta/meta.go
index 68848af9715a..2362e2b71fdd 100644
--- a/relaykit/relayconvert/convmeta/meta.go
+++ b/relaykit/relayconvert/convmeta/meta.go
@@ -28,6 +28,10 @@ type Meta interface {
// SetReasoningEffort records the effort level a converter derived from a
// model-name suffix so downstream billing/logging can see it.
SetReasoningEffort(effort string)
+ // ReasoningState returns the suffix-derived reasoning intent attached at
+ // the host entry layer. Standalone callers that do not set it receive nil;
+ // converters then use only explicit request fields.
+ ReasoningState() *dto.ReasoningConversionState
GetEstimatePromptTokens() int
// EnsureClaudeConvertInfo lazily creates and returns the mutable
@@ -60,6 +64,20 @@ type ClaudeConvertInfo struct {
ToolCallBaseIndex int
ToolCallMaxIndexOffset int
+ ToolCalls []*ClaudeStreamToolCall
+ ToolCallByIndex map[int]*ClaudeStreamToolCall
+ ToolCallByID map[string]*ClaudeStreamToolCall
+}
+
+// ClaudeStreamToolCall tracks one OpenAI tool_calls entry while it is encoded
+// as a Claude tool_use content block. Chat tool indexes and Claude content
+// block indexes are separate domains, so the mapping must remain explicit.
+type ClaudeStreamToolCall struct {
+ BlockIndex int
+ ID string
+ Name string
+ PendingArguments string
+ Started bool
}
const (
@@ -79,6 +97,7 @@ type Values struct {
ChannelType int
IsStream bool
ReasoningEffort string
+ ReasoningConversion *dto.ReasoningConversionState
EstimatePromptTokens int
ClaudeConvertInfo *ClaudeConvertInfo
@@ -139,6 +158,13 @@ func (v *Values) SetReasoningEffort(effort string) {
}
}
+func (v *Values) ReasoningState() *dto.ReasoningConversionState {
+ if v == nil {
+ return nil
+ }
+ return v.ReasoningConversion
+}
+
func (v *Values) GetEstimatePromptTokens() int {
if v == nil {
return 0
@@ -213,3 +239,11 @@ func OptionsOf(m Meta) *Options {
}
return m.ConvOptions()
}
+
+// ReasoningStateOf is a nil-safe reader for Meta.ReasoningState.
+func ReasoningStateOf(m Meta) *dto.ReasoningConversionState {
+ if m == nil {
+ return nil
+ }
+ return m.ReasoningState()
+}
diff --git a/relaykit/relayconvert/convmeta/meta_test.go b/relaykit/relayconvert/convmeta/meta_test.go
index 0b055f3fd341..fbd5b654d567 100644
--- a/relaykit/relayconvert/convmeta/meta_test.go
+++ b/relaykit/relayconvert/convmeta/meta_test.go
@@ -19,6 +19,7 @@ func TestValuesTypedNilMetaIsSafe(t *testing.T) {
assert.Zero(t, meta.GetChannelType())
assert.False(t, meta.GetIsStream())
assert.Empty(t, meta.GetReasoningEffort())
+ assert.Nil(t, meta.ReasoningState())
assert.Zero(t, meta.GetEstimatePromptTokens())
assert.Zero(t, meta.GetSendResponseCount())
diff --git a/relaykit/relayconvert/convmeta/options.go b/relaykit/relayconvert/convmeta/options.go
index af8cfeb664dc..efafb2ed4673 100644
--- a/relaykit/relayconvert/convmeta/options.go
+++ b/relaykit/relayconvert/convmeta/options.go
@@ -1,5 +1,7 @@
package convmeta
+import "github.com/QuantumNous/new-api/relaykit/types"
+
// Options is the per-request snapshot of host configuration that converters
// consult. The host fills it from its settings system when constructing the
// Meta (see relaycommon.RelayInfo.ConvOptions); relaykit users fill it
@@ -8,6 +10,13 @@ type Options struct {
Claude ClaudeOptions
Gemini GeminiOptions
+ // ToolLossPolicy controls whether a cross-protocol conversion may omit or
+ // approximate built-in-tool semantics. The zero value uses the allow
+ // policy: conversion succeeds and every loss is returned as a diagnostic.
+ // safe/strict rejection is request-phase opt-in only; response and stream
+ // conversion never reject regardless of this field.
+ ToolLossPolicy types.ConversionLossPolicy
+
// OpenRouterDialect marks the upstream as OpenRouter's OpenAI-compatible
// surface, which accepts extra fields (reasoning config, cache_control on
// system parts) that converters emit only for that dialect. The host sets
@@ -18,11 +27,16 @@ type Options struct {
// suffix must be kept on the outgoing model name (host blacklist lookup).
// Nil means "never preserve".
PreserveThinkingSuffix func(modelName string) bool
+
+ // PreserveEffortTail reports real model IDs whose names already end in an
+ // effort-like token (for example qwen-max). Nil means "never preserve".
+ PreserveEffortTail func(modelName string) bool
}
type ClaudeOptions struct {
- // ThinkingAdapterEnabled turns "-thinking"-suffixed OpenAI model names
- // into Claude extended-thinking requests.
+ // ThinkingAdapterEnabled controls whether suffix-derived reasoning intent
+ // is rendered onto Claude thinking / output_config. Suffix parsing itself
+ // is the host entry layer's job (standalone users call Parse* themselves).
ThinkingAdapterEnabled bool
// ThinkingAdapterBudgetTokensPercentage sizes thinking budget_tokens as a
// fraction of max_tokens when the adapter fires.
@@ -36,11 +50,16 @@ type ClaudeOptions struct {
// standalone relaykit users must supply one or guarantee max_tokens on
// every request.
DefaultMaxTokens func(modelName string) int
+ // WebSearchToolVersion selects the Claude hosted web-search tool version
+ // emitted by cross-protocol conversion. Empty keeps the compatibility
+ // baseline web_search_20250305.
+ WebSearchToolVersion string
}
type GeminiOptions struct {
- // ThinkingAdapterEnabled maps -thinking/-nothinking/effort suffixes to
- // Gemini thinkingConfig.
+ // ThinkingAdapterEnabled controls whether suffix-derived reasoning intent
+ // is rendered onto Gemini thinkingConfig. Suffix parsing itself is the
+ // host entry layer's job (standalone users call Parse* themselves).
ThinkingAdapterEnabled bool
// ThinkingAdapterBudgetTokensPercentage sizes thinkingBudget as a fraction
// of maxOutputTokens when the adapter fires.
@@ -77,3 +96,14 @@ func (o *GeminiOptions) SafetySettingFor(category string) string {
func (o *Options) ShouldPreserveThinkingSuffix(modelName string) bool {
return o != nil && o.PreserveThinkingSuffix != nil && o.PreserveThinkingSuffix(modelName)
}
+
+func (o *Options) ShouldPreserveEffortTail(modelName string) bool {
+ return o != nil && o.PreserveEffortTail != nil && o.PreserveEffortTail(modelName)
+}
+
+func (o *Options) EffectiveToolLossPolicy() types.ConversionLossPolicy {
+ if o == nil || o.ToolLossPolicy == "" {
+ return types.ConversionLossPolicyAllow
+ }
+ return o.ToolLossPolicy
+}
diff --git a/relaykit/relayconvert/golden_test.go b/relaykit/relayconvert/golden_test.go
index 112ba268ff6f..ef3a0d4224bc 100644
--- a/relaykit/relayconvert/golden_test.go
+++ b/relaykit/relayconvert/golden_test.go
@@ -1,8 +1,7 @@
package relayconvert
-// golden_test.go pins the byte-level output of every registered (from, to)
-// conversion route so the relaykit extraction refactor can prove behavior is
-// unchanged at each phase. Run with -update to regenerate testdata/golden.
+// golden_test.go pins the byte-level output of selected public conversion
+// routes. Run with -update to regenerate testdata/golden.
//
// Volatile values (generated UUID-based ids, unix timestamps) are normalized
// before comparison so the snapshots are deterministic.
@@ -69,10 +68,30 @@ func checkGolden(t *testing.T, name string, got []byte) {
return
}
want, err := os.ReadFile(path)
- require.NoError(t, err, "golden file missing, run: go test ./service/relayconvert -run TestGolden -update")
+ require.NoError(t, err, "golden file missing, run: cd relaykit && GOWORK=off go test ./relayconvert -run TestGolden -update")
require.Equal(t, string(want), string(got), "conversion output drifted from golden snapshot %s", path)
}
+func checkStreamEventsGolden(t *testing.T, name string, events []any) {
+ t.Helper()
+ got := marshalGolden(t, map[string]any{"events": events})
+ path := filepath.Join(goldenDir, name+".golden.json")
+ if *updateGolden {
+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
+ require.NoError(t, os.WriteFile(path, got, 0o644))
+ return
+ }
+
+ wantData, err := os.ReadFile(path)
+ require.NoError(t, err, "golden file missing, run: cd relaykit && GOWORK=off go test ./relayconvert -run TestGolden -update")
+ var wantSnapshot map[string]json.RawMessage
+ require.NoError(t, json.Unmarshal(wantData, &wantSnapshot))
+ wantEvents, ok := wantSnapshot["events"]
+ require.True(t, ok, "stream golden snapshot %s has no events", path)
+ want := marshalGolden(t, map[string]json.RawMessage{"events": wantEvents})
+ require.Equal(t, string(want), string(got), "conversion events drifted from golden snapshot %s", path)
+}
+
// goldenInfo mirrors the host's default converter options (new-api's
// model_setting defaults at the time the snapshots were recorded) so the
// golden files stay comparable across the extraction.
@@ -120,42 +139,6 @@ func fixtureRequests() map[types.RelayFormat]any {
"tool_choice": "auto"
}`, openai)
- claude := &dto.ClaudeRequest{}
- mustUnmarshalFixture(`{
- "model": "claude-test",
- "max_tokens": 1024,
- "stream": true,
- "system": "You are a helpful assistant.",
- "messages": [
- {"role": "user", "content": [
- {"type": "text", "text": "What is in this image?"},
- {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "aGVsbG8="}}
- ]},
- {"role": "assistant", "content": [
- {"type": "thinking", "thinking": "Let me look.", "signature": "sig"},
- {"type": "tool_use", "id": "toolu_abc", "name": "get_weather", "input": {"city": "Paris"}}
- ]},
- {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_abc", "content": "15 degrees"}]}
- ],
- "tools": [{"name": "get_weather", "description": "Get weather by city", "input_schema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}],
- "thinking": {"type": "enabled", "budget_tokens": 512}
- }`, claude)
-
- gemini := &dto.GeminiChatRequest{}
- mustUnmarshalFixture(`{
- "contents": [
- {"role": "user", "parts": [
- {"text": "What is in this image?"},
- {"inlineData": {"mimeType": "image/png", "data": "aGVsbG8="}}
- ]},
- {"role": "model", "parts": [{"functionCall": {"name": "get_weather", "args": {"city": "Paris"}}}]},
- {"role": "user", "parts": [{"functionResponse": {"name": "get_weather", "response": {"result": "15 degrees"}}}]}
- ],
- "systemInstruction": {"parts": [{"text": "You are a helpful assistant."}]},
- "tools": [{"functionDeclarations": [{"name": "get_weather", "description": "Get weather by city", "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}]}],
- "generationConfig": {"maxOutputTokens": 1024, "temperature": 0.7}
- }`, gemini)
-
responses := &dto.OpenAIResponsesRequest{}
mustUnmarshalFixture(`{
"model": "gpt-test",
@@ -175,8 +158,6 @@ func fixtureRequests() map[types.RelayFormat]any {
return map[types.RelayFormat]any{
types.RelayFormatOpenAI: openai,
- types.RelayFormatClaude: claude,
- types.RelayFormatGemini: gemini,
types.RelayFormatOpenAIResponses: responses,
}
}
@@ -308,8 +289,17 @@ func allFormats() []types.RelayFormat {
func TestGoldenRequestConversionMatrix(t *testing.T) {
requests := fixtureRequests()
- for _, from := range allFormats() {
- for _, to := range allFormats() {
+ fromFormats := []types.RelayFormat{
+ types.RelayFormatOpenAI,
+ types.RelayFormatOpenAIResponses,
+ }
+ toFormats := []types.RelayFormat{
+ types.RelayFormatOpenAI,
+ types.RelayFormatClaude,
+ types.RelayFormatOpenAIResponses,
+ }
+ for _, from := range fromFormats {
+ for _, to := range toFormats {
if from == to {
continue
}
@@ -330,7 +320,8 @@ func TestGoldenResponseConversionMatrix(t *testing.T) {
responses := fixtureResponses()
for _, from := range allFormats() {
for _, to := range allFormats() {
- if from == to {
+ if from == to || to == types.RelayFormatGemini ||
+ (from == types.RelayFormatOpenAI && to == types.RelayFormatClaude) {
continue
}
name := fmt.Sprintf("response/%s_to_%s", from, to)
@@ -373,11 +364,9 @@ func TestGoldenStreamConversionMatrix(t *testing.T) {
outputs = append(outputs, r.Value)
}
- snapshot := map[string]any{
- "events": outputs,
- "usage": state.Usage(),
- }
- checkGolden(t, name, marshalGolden(t, snapshot))
+ // Billing usage has private, cross-module acceptance coverage. Keep
+ // the public golden focused on client-visible stream events.
+ checkStreamEventsGolden(t, name, outputs)
})
}
}
diff --git a/relaykit/relayconvert/internal/claude_messages/citations.go b/relaykit/relayconvert/internal/claude_messages/citations.go
new file mode 100644
index 000000000000..cd97eb3029cb
--- /dev/null
+++ b/relaykit/relayconvert/internal/claude_messages/citations.go
@@ -0,0 +1,58 @@
+package claudemessages
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+ "unicode/utf8"
+
+ kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+)
+
+func claudeCitationsToChat(raw json.RawMessage, text string, textOffset int) ([]any, error) {
+ if len(raw) == 0 {
+ return nil, nil
+ }
+ var citations []map[string]any
+ if err := kitutil.Unmarshal(raw, &citations); err != nil {
+ return nil, fmt.Errorf("invalid Claude citations: %w", err)
+ }
+ annotations := make([]any, 0, len(citations))
+ for _, citation := range citations {
+ url := strings.TrimSpace(kitutil.Interface2String(citation["url"]))
+ if url == "" {
+ continue
+ }
+ converted := map[string]any{
+ "url": url,
+ "title": strings.TrimSpace(kitutil.Interface2String(citation["title"])),
+ }
+ citedText := kitutil.Interface2String(citation["cited_text"])
+ if citedText != "" {
+ converted["cited_text"] = citedText
+ if index := strings.Index(text, citedText); index >= 0 {
+ startIndex := textOffset + utf8.RuneCountInString(text[:index])
+ converted["start_index"] = startIndex
+ converted["end_index"] = startIndex + utf8.RuneCountInString(citedText)
+ }
+ }
+ if encryptedIndex := kitutil.Interface2String(citation["encrypted_index"]); encryptedIndex != "" {
+ converted["encrypted_index"] = encryptedIndex
+ }
+ if converted["title"] == "" {
+ delete(converted, "title")
+ }
+ annotations = append(annotations, map[string]any{
+ "type": "url_citation",
+ "url_citation": converted,
+ })
+ }
+ return annotations, nil
+}
+
+func marshalChatAnnotations(annotations []any) (json.RawMessage, error) {
+ if len(annotations) == 0 {
+ return nil, nil
+ }
+ return kitutil.Marshal(annotations)
+}
diff --git a/relaykit/relayconvert/internal/claude_messages/stream_billing_usage_test.go b/relaykit/relayconvert/internal/claude_messages/stream_billing_usage_test.go
new file mode 100644
index 000000000000..971b07ea03bd
--- /dev/null
+++ b/relaykit/relayconvert/internal/claude_messages/stream_billing_usage_test.go
@@ -0,0 +1,66 @@
+package claudemessages
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMessageStartZeroOutputSidecarRemainsRefreshable(t *testing.T) {
+ t.Parallel()
+
+ info := &ClaudeResponseInfo{Usage: &dto.Usage{}}
+ ok := FormatClaudeResponseInfo(&dto.ClaudeResponse{
+ Type: "message_start",
+ Message: &dto.ClaudeMediaMessage{
+ Id: "msg_1",
+ Model: "claude-test",
+ Usage: &dto.ClaudeUsage{
+ InputTokens: 10,
+ OutputTokens: 0,
+ BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{
+ InputTokens: 10,
+ OutputTokens: 0,
+ }),
+ },
+ },
+ }, nil, info)
+ require.True(t, ok)
+
+ ok = FormatClaudeResponseInfo(&dto.ClaudeResponse{
+ Type: "message_delta",
+ Usage: &dto.ClaudeUsage{
+ OutputTokens: 42,
+ },
+ }, nil, info)
+ require.True(t, ok)
+ require.NotNil(t, info.Usage.BillingUsage)
+ require.NotNil(t, info.Usage.BillingUsage.ClaudeUsage)
+ assert.Equal(t, 42, info.Usage.BillingUsage.ClaudeUsage.OutputTokens)
+}
+
+func TestTerminalSidecarRemainsAuthoritativeAgainstFinalize(t *testing.T) {
+ t.Parallel()
+
+ info := &ClaudeResponseInfo{Usage: &dto.Usage{}}
+ ok := FormatClaudeResponseInfo(&dto.ClaudeResponse{
+ Type: "message_delta",
+ Usage: &dto.ClaudeUsage{
+ InputTokens: 10,
+ OutputTokens: 7,
+ BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{
+ InputTokens: 10,
+ OutputTokens: 7,
+ }),
+ },
+ }, nil, info)
+ require.True(t, ok)
+ require.NotNil(t, info.Usage.BillingUsage)
+ require.NotNil(t, info.Usage.BillingUsage.ClaudeUsage)
+
+ info.Usage.CompletionTokens = 99
+ FinalizeClaudeStreamBillingUsage(info)
+ assert.Equal(t, 7, info.Usage.BillingUsage.ClaudeUsage.OutputTokens)
+}
diff --git a/relaykit/relayconvert/internal/claude_messages/to_oai_chat_req.go b/relaykit/relayconvert/internal/claude_messages/to_oai_chat_req.go
index 56e6663e1a03..fa1a3c1d801e 100644
--- a/relaykit/relayconvert/internal/claude_messages/to_oai_chat_req.go
+++ b/relaykit/relayconvert/internal/claude_messages/to_oai_chat_req.go
@@ -7,6 +7,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
const (
@@ -16,7 +17,7 @@ const (
)
type openRouterRequestReasoning struct {
- Enabled bool `json:"enabled"`
+ Enabled *bool `json:"enabled,omitempty"`
Effort string `json:"effort,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Exclude bool `json:"exclude,omitempty"`
@@ -39,6 +40,10 @@ func ClaudeMessagesRequestToOpenAIChat(claudeRequest dto.ClaudeRequest, info con
if claudeRequest.Stream != nil {
openAIRequest.Stream = kitutil.GetPointer(*claudeRequest.Stream)
}
+ reasoningIntent, effectiveEffort, err := claudeRequestReasoningIntent(&claudeRequest, info)
+ if err != nil {
+ return nil, reasoning.AsClientError(err)
+ }
isOpenRouter := convmeta.OptionsOf(info).OpenRouterDialect
if isOpenRouter {
@@ -46,17 +51,21 @@ func ClaudeMessagesRequestToOpenAIChat(claudeRequest dto.ClaudeRequest, info con
effortBytes, _ := kitutil.Marshal(effort)
openAIRequest.Verbosity = effortBytes
}
- if claudeRequest.Thinking != nil {
+ if !reasoningIntent.IsEmpty() {
var reasoningConfig openRouterRequestReasoning
- if claudeRequest.Thinking.Type == "enabled" {
- reasoningConfig = openRouterRequestReasoning{
- Enabled: true,
- MaxTokens: claudeRequest.Thinking.GetBudgetTokens(),
- }
- } else if claudeRequest.Thinking.Type == "adaptive" {
+ disabled := reasoningIntent.Mode == reasoning.ModeDisabled || reasoningIntent.Effort == reasoning.EffortNone
+ enabled := !disabled
+ reasoningConfig.Enabled = &enabled
+ if enabled && reasoningIntent.BudgetTokens != nil && reasoningIntent.Mode != reasoning.ModeAdaptive {
reasoningConfig = openRouterRequestReasoning{
- Enabled: true,
+ Enabled: &enabled,
+ MaxTokens: *reasoningIntent.BudgetTokens,
}
+ } else if enabled {
+ reasoningConfig.Effort = string(reasoning.EffectiveEffort(reasoningIntent))
+ }
+ if reasoningIntent.IncludeThoughts != nil {
+ reasoningConfig.Exclude = !*reasoningIntent.IncludeThoughts
}
reasoningJSON, err := kitutil.Marshal(reasoningConfig)
if err != nil {
@@ -64,13 +73,24 @@ func ClaudeMessagesRequestToOpenAIChat(claudeRequest dto.ClaudeRequest, info con
}
openAIRequest.Reasoning = reasoningJSON
}
- } else if info != nil {
- thinkingSuffix := "-thinking"
- if strings.HasSuffix(info.GetOriginModelName(), thinkingSuffix) &&
- !strings.HasSuffix(openAIRequest.Model, thinkingSuffix) {
- openAIRequest.Model = openAIRequest.Model + thinkingSuffix
+ } else {
+ if err := reasoning.ApplyToOpenAIChat(&openAIRequest, reasoningIntent); err != nil {
+ return nil, reasoning.AsClientError(err)
+ }
+ if info != nil {
+ // Keep the outgoing -thinking suffix so a cascaded downstream
+ // new-api can recover reasoning intent from the model name. This
+ // is an emission-side policy, not converter-side suffix parsing.
+ thinkingSuffix := "-thinking"
+ if strings.HasSuffix(info.GetOriginModelName(), thinkingSuffix) &&
+ !strings.HasSuffix(openAIRequest.Model, thinkingSuffix) {
+ openAIRequest.Model = openAIRequest.Model + thinkingSuffix
+ }
}
}
+ if info != nil && effectiveEffort != "" {
+ info.SetReasoningEffort(string(effectiveEffort))
+ }
if len(claudeRequest.StopSequences) == 1 {
openAIRequest.Stop = claudeRequest.StopSequences[0]
diff --git a/relaykit/relayconvert/internal/claude_messages/to_oai_chat_resp.go b/relaykit/relayconvert/internal/claude_messages/to_oai_chat_resp.go
index 55762335a3af..1e9a5c4a7ed2 100644
--- a/relaykit/relayconvert/internal/claude_messages/to_oai_chat_resp.go
+++ b/relaykit/relayconvert/internal/claude_messages/to_oai_chat_resp.go
@@ -1,8 +1,10 @@
package claudemessages
import (
+ "encoding/json"
"fmt"
"strings"
+ "unicode/utf8"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/reasonmap"
@@ -19,6 +21,10 @@ type ClaudeResponseInfo struct {
ResponseText strings.Builder
Usage *dto.Usage
Done bool
+
+ // Only snapshots synthesized from partial display usage may be refreshed by
+ // later display deltas. Serialized BillingUsage always remains authoritative.
+ billingUsageSynthesized bool
}
func StopReasonClaudeToOpenAI(reason string) string {
@@ -47,6 +53,10 @@ func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCo
if claudeResponse.ContentBlock != nil {
if claudeResponse.ContentBlock.Type == "text" && claudeResponse.ContentBlock.Text != nil {
choice.Delta.SetContentString(*claudeResponse.ContentBlock.Text)
+ annotations, err := claudeCitationsToChat(claudeResponse.ContentBlock.Citations, *claudeResponse.ContentBlock.Text, 0)
+ if err == nil {
+ choice.Delta.Annotations, _ = marshalChatAnnotations(annotations)
+ }
}
if claudeResponse.ContentBlock.Type == "tool_use" {
tools = append(tools, dto.ToolCallResponse{
@@ -79,6 +89,14 @@ func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCo
choice.Delta.ReasoningContent = &signatureContent
case "thinking_delta":
choice.Delta.ReasoningContent = claudeResponse.Delta.Thinking
+ case "citations_delta":
+ if len(claudeResponse.Delta.Citation) > 0 {
+ raw, _ := kitutil.Marshal([]json.RawMessage{claudeResponse.Delta.Citation})
+ annotations, err := claudeCitationsToChat(raw, "", 0)
+ if err == nil {
+ choice.Delta.Annotations, _ = marshalChatAnnotations(annotations)
+ }
+ }
}
}
} else if claudeResponse.Type == "message_delta" {
@@ -102,6 +120,101 @@ func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCo
return &response
}
+// ClaudeToChatStreamState translates Anthropic content block indexes into the
+// independent, dense index space used by Chat Completions tool_calls. Text and
+// thinking blocks therefore do not create holes in the downstream tool array.
+type ClaudeToChatStreamState struct {
+ toolIndexByContentBlock map[int]int
+ blockTypeByContentBlock map[int]string
+ nextToolIndex int
+}
+
+func NewClaudeToChatStreamState() *ClaudeToChatStreamState {
+ return &ClaudeToChatStreamState{
+ toolIndexByContentBlock: make(map[int]int),
+ blockTypeByContentBlock: make(map[int]string),
+ }
+}
+
+func (s *ClaudeToChatStreamState) ConvertChunk(claudeResponse *dto.ClaudeResponse) (*dto.ChatCompletionsStreamResponse, error) {
+ if s == nil {
+ return nil, fmt.Errorf("Claude-to-Chat stream state is required")
+ }
+ if claudeResponse == nil {
+ return nil, nil
+ }
+ if s.toolIndexByContentBlock == nil {
+ s.toolIndexByContentBlock = make(map[int]int)
+ }
+ if s.blockTypeByContentBlock == nil {
+ s.blockTypeByContentBlock = make(map[int]string)
+ }
+
+ converted := *claudeResponse
+ switch claudeResponse.Type {
+ case "content_block_start":
+ if claudeResponse.ContentBlock == nil {
+ break
+ }
+ blockType := strings.TrimSpace(claudeResponse.ContentBlock.Type)
+ if blockType == "" {
+ break
+ }
+ if claudeResponse.Index == nil {
+ return nil, fmt.Errorf("Claude content block stream start is missing index")
+ }
+ contentBlockIndex := *claudeResponse.Index
+ s.blockTypeByContentBlock[contentBlockIndex] = blockType
+ if blockType != "tool_use" {
+ if isClaudeHostedToolStreamBlock(blockType) {
+ return nil, nil
+ }
+ break
+ }
+ toolIndex, exists := s.toolIndexByContentBlock[contentBlockIndex]
+ if !exists {
+ toolIndex = s.nextToolIndex
+ s.nextToolIndex++
+ s.toolIndexByContentBlock[contentBlockIndex] = toolIndex
+ }
+ converted.Index = kitutil.GetPointer(toolIndex)
+ case "content_block_delta":
+ if claudeResponse.Delta == nil || claudeResponse.Delta.Type != "input_json_delta" {
+ break
+ }
+ if claudeResponse.Index == nil {
+ return nil, fmt.Errorf("Claude tool-use stream delta is missing content block index")
+ }
+ if claudeResponse.Delta.PartialJson == nil {
+ return nil, fmt.Errorf("Claude tool-use stream delta is missing partial JSON")
+ }
+ contentBlockIndex := *claudeResponse.Index
+ toolIndex, exists := s.toolIndexByContentBlock[contentBlockIndex]
+ if !exists {
+ if isClaudeHostedToolStreamBlock(s.blockTypeByContentBlock[contentBlockIndex]) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("Claude tool-use stream delta references unknown content block index %d", contentBlockIndex)
+ }
+ converted.Index = kitutil.GetPointer(toolIndex)
+ case "content_block_stop":
+ if claudeResponse.Index != nil {
+ delete(s.blockTypeByContentBlock, *claudeResponse.Index)
+ }
+ }
+
+ return StreamResponseClaude2OpenAI(&converted), nil
+}
+
+func isClaudeHostedToolStreamBlock(blockType string) bool {
+ switch blockType {
+ case "server_tool_use", "mcp_tool_use", "web_search_tool_result", "mcp_tool_result", "code_execution_tool_result", "web_fetch_tool_result":
+ return true
+ default:
+ return false
+ }
+}
+
func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextResponse {
choices := make([]dto.OpenAITextResponseChoice, 0)
fullTextResponse := dto.OpenAITextResponse{
@@ -109,16 +222,17 @@ func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextRe
Object: "chat.completion",
Created: kitutil.GetTimestamp(),
}
- var responseText string
+ var responseText strings.Builder
+ responseTextOffset := 0
var responseThinking string
if len(claudeResponse.Content) > 0 {
- responseText = claudeResponse.Content[0].GetText()
if claudeResponse.Content[0].Thinking != nil {
responseThinking = *claudeResponse.Content[0].Thinking
}
}
tools := make([]dto.ToolCallResponse, 0)
thinkingContent := ""
+ annotations := make([]any, 0)
fullTextResponse.Id = claudeResponse.Id
for _, message := range claudeResponse.Content {
@@ -138,7 +252,14 @@ func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextRe
thinkingContent = *message.Thinking
}
case "text":
- responseText = message.GetText()
+ text := message.GetText()
+ offset := responseTextOffset
+ responseText.WriteString(text)
+ responseTextOffset += utf8.RuneCountInString(text)
+ converted, err := claudeCitationsToChat(message.Citations, text, offset)
+ if err == nil {
+ annotations = append(annotations, converted...)
+ }
}
}
choice := dto.OpenAITextResponseChoice{
@@ -148,7 +269,10 @@ func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextRe
},
FinishReason: StopReasonClaudeToOpenAI(claudeResponse.StopReason),
}
- choice.SetStringContent(responseText)
+ choice.SetStringContent(responseText.String())
+ if encodedAnnotations, err := marshalChatAnnotations(annotations); err == nil && len(encodedAnnotations) > 0 {
+ choice.Message.Annotations = encodedAnnotations
+ }
if len(responseThinking) > 0 {
choice.ReasoningContent = &responseThinking
}
@@ -295,6 +419,45 @@ func claudeBillingUsageFromSemanticUsage(usage *dto.Usage) *dto.BillingUsage {
return dto.NewClaudeMessagesBillingUsage(claudeUsage)
}
+func updateClaudeStreamBillingUsage(claudeUsage *dto.ClaudeUsage, claudeInfo *ClaudeResponseInfo, terminal bool) {
+ if claudeUsage == nil || claudeInfo == nil || claudeInfo.Usage == nil {
+ return
+ }
+ if billingUsage := dto.CloneBillingUsage(claudeUsage.BillingUsage); billingUsage != nil {
+ claudeInfo.Usage.BillingUsage = billingUsage
+ if terminal || claudeUsage.OutputTokens > 0 {
+ claudeInfo.billingUsageSynthesized = false
+ return
+ }
+ claudeInfo.billingUsageSynthesized = true
+ return
+ }
+ if claudeInfo.Usage.BillingUsage != nil && !claudeInfo.billingUsageSynthesized {
+ return
+ }
+ claudeInfo.Usage.BillingUsage = claudeBillingUsageFromSemanticUsage(claudeInfo.Usage)
+ claudeInfo.billingUsageSynthesized = claudeInfo.Usage.BillingUsage != nil
+}
+
+// FinalizeClaudeStreamBillingUsage refreshes only a locally synthesized
+// snapshot after the host has applied its missing-usage fallback. A snapshot
+// received on the wire remains authoritative and is never rewritten.
+func FinalizeClaudeStreamBillingUsage(claudeInfo *ClaudeResponseInfo) {
+ if claudeInfo == nil || claudeInfo.Usage == nil {
+ return
+ }
+ if claudeInfo.Usage.BillingUsage != nil && !claudeInfo.billingUsageSynthesized {
+ return
+ }
+
+ billingUsage := claudeBillingUsageFromSemanticUsage(claudeInfo.Usage)
+ if billingUsage != nil && !claudeInfo.Done {
+ billingUsage.Estimated = true
+ }
+ claudeInfo.Usage.BillingUsage = billingUsage
+ claudeInfo.billingUsageSynthesized = billingUsage != nil
+}
+
func PatchClaudeMessageDeltaUsageData(data string, usage *dto.ClaudeUsage) string {
if data == "" || usage == nil {
return data
@@ -343,14 +506,15 @@ func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *d
}
if claudeResponse.Message != nil && claudeResponse.Message.Usage != nil {
- claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens
+ messageUsage := claudeResponse.Message.Usage
+ claudeInfo.Usage.PromptTokens = messageUsage.InputTokens
claudeInfo.Usage.UsageSemantic = "anthropic"
- claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens
- claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens
- claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Message.Usage.GetCacheCreation5mTokens()
- claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Message.Usage.GetCacheCreation1hTokens()
- claudeInfo.Usage.CompletionTokens = claudeResponse.Message.Usage.OutputTokens
- claudeInfo.Usage.BillingUsage = claudeBillingUsageFromSemanticUsage(claudeInfo.Usage)
+ claudeInfo.Usage.PromptTokensDetails.CachedTokens = messageUsage.CacheReadInputTokens
+ claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = messageUsage.CacheCreationInputTokens
+ claudeInfo.Usage.ClaudeCacheCreation5mTokens = messageUsage.GetCacheCreation5mTokens()
+ claudeInfo.Usage.ClaudeCacheCreation1hTokens = messageUsage.GetCacheCreation1hTokens()
+ claudeInfo.Usage.CompletionTokens = messageUsage.OutputTokens
+ updateClaudeStreamBillingUsage(messageUsage, claudeInfo, false)
}
} else if claudeResponse.Type == "content_block_delta" {
if claudeResponse.Delta != nil {
@@ -383,7 +547,7 @@ func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *d
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
}
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
- claudeInfo.Usage.BillingUsage = claudeBillingUsageFromSemanticUsage(claudeInfo.Usage)
+ updateClaudeStreamBillingUsage(claudeResponse.Usage, claudeInfo, true)
}
claudeInfo.Done = true
diff --git a/relaykit/relayconvert/internal/claude_messages/to_oai_responses_hosted_stream.go b/relaykit/relayconvert/internal/claude_messages/to_oai_responses_hosted_stream.go
new file mode 100644
index 000000000000..e074e8b61a96
--- /dev/null
+++ b/relaykit/relayconvert/internal/claude_messages/to_oai_responses_hosted_stream.go
@@ -0,0 +1,194 @@
+package claudemessages
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ oaichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_chat"
+ kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+)
+
+// ClaudeHostedStreamBridge keeps Anthropic server-executed tool blocks out of
+// the Chat Completions pivot. Anthropic streams a hosted call's input after the
+// content_block_start event, so the bridge owns those input_json_delta events
+// until content_block_stop and only then starts the Responses output item.
+type ClaudeHostedStreamBridge struct {
+ pending map[int]*claudeHostedStreamCall
+}
+
+type claudeHostedStreamCall struct {
+ blockType string
+ id string
+ name string
+ serverName string
+ caller []byte
+ startInput []byte
+ input strings.Builder
+}
+
+func NewClaudeHostedStreamBridge() *ClaudeHostedStreamBridge {
+ return &ClaudeHostedStreamBridge{pending: make(map[int]*claudeHostedStreamCall)}
+}
+
+// Convert consumes provider-hosted stream frames and reports whether the frame
+// must be skipped by the ordinary Claude-to-Chat converter.
+func (b *ClaudeHostedStreamBridge) Convert(response *dto.ClaudeResponse, state *oaichat.ChatToResponsesStreamState) ([]oaichat.ChatToResponsesStreamEvent, bool, error) {
+ if response == nil || state == nil {
+ return nil, false, nil
+ }
+ if b == nil {
+ return nil, false, fmt.Errorf("Claude hosted stream bridge is required")
+ }
+ if b.pending == nil {
+ b.pending = make(map[int]*claudeHostedStreamCall)
+ }
+ index := response.GetIndex()
+
+ switch response.Type {
+ case "content_block_start":
+ if response.ContentBlock == nil {
+ return nil, false, nil
+ }
+ block := response.ContentBlock
+ blockType := strings.TrimSpace(block.Type)
+ switch blockType {
+ case "server_tool_use", "mcp_tool_use":
+ if _, exists := b.pending[index]; exists {
+ return nil, true, fmt.Errorf("duplicate Claude hosted-tool content block index %d", index)
+ }
+ if blockType == "mcp_tool_use" && (strings.TrimSpace(block.Name) == "" || strings.TrimSpace(block.ServerName) == "") {
+ return nil, true, fmt.Errorf("Claude MCP tool use must include name and server_name")
+ }
+ if _, err := claudeHostedCallOutputType(blockType, block.Name); err != nil {
+ return nil, true, err
+ }
+ pending := &claudeHostedStreamCall{
+ blockType: blockType,
+ id: block.Id,
+ name: block.Name,
+ serverName: block.ServerName,
+ caller: append([]byte(nil), block.Caller...),
+ }
+ // Non-stream-shaped gateways occasionally include the complete input
+ // on the start frame. Preserve it as a fallback, while streamed deltas
+ // replace the placeholder at block completion.
+ if block.Input != nil {
+ input, err := kitutil.Marshal(block.Input)
+ if err != nil {
+ return nil, true, fmt.Errorf("marshal Claude hosted-tool input: %w", err)
+ }
+ if string(input) != "{}" && string(input) != "null" {
+ pending.startInput = input
+ }
+ }
+ b.pending[index] = pending
+ return nil, true, nil
+ case "web_search_tool_result", "mcp_tool_result":
+ outputType, err := claudeHostedResultOutputType(blockType)
+ if err != nil {
+ return nil, true, err
+ }
+ var result []byte
+ if outputType != "web_search_call" {
+ result, err = kitutil.Marshal(block.Content)
+ if err != nil {
+ return nil, true, fmt.Errorf("marshal Claude hosted-tool result: %w", err)
+ }
+ }
+ events, err := state.CompleteHostedTool(oaichat.HostedToolStreamResult{
+ Type: outputType,
+ ID: block.ToolUseId,
+ Result: result,
+ ErrorCode: claudeHostedResultErrorCode(block.Content, block.ErrorCode),
+ IsError: block.IsError != nil && *block.IsError,
+ })
+ return events, true, err
+ default:
+ return nil, false, nil
+ }
+ case "content_block_delta":
+ pending := b.pending[index]
+ if pending == nil {
+ return nil, false, nil
+ }
+ if response.Delta != nil && response.Delta.Type == "input_json_delta" && response.Delta.PartialJson != nil {
+ pending.input.WriteString(*response.Delta.PartialJson)
+ }
+ return nil, true, nil
+ case "content_block_stop":
+ pending := b.pending[index]
+ if pending == nil {
+ return nil, false, nil
+ }
+ delete(b.pending, index)
+ action := []byte(pending.input.String())
+ if len(action) == 0 {
+ action = pending.startInput
+ }
+ if len(action) == 0 {
+ action = []byte("{}")
+ }
+ outputType, err := claudeHostedCallOutputType(pending.blockType, pending.name)
+ if err != nil {
+ return nil, true, err
+ }
+ events, err := state.StartHostedTool(oaichat.HostedToolStreamStart{
+ Type: outputType,
+ ID: pending.id,
+ Name: pending.name,
+ Action: action,
+ Caller: pending.caller,
+ ServerLabel: pending.serverName,
+ })
+ return events, true, err
+ default:
+ return nil, false, nil
+ }
+}
+
+func claudeHostedCallOutputType(blockType string, name string) (string, error) {
+ if blockType == "mcp_tool_use" {
+ return "mcp_call", nil
+ }
+ switch strings.TrimSpace(name) {
+ case "web_search":
+ return "web_search_call", nil
+ case "code_execution":
+ return "", fmt.Errorf("Claude code_execution has no valid OpenAI Responses mapping without a container_id")
+ case "web_fetch":
+ return "", fmt.Errorf("Claude web_fetch has no valid OpenAI Responses hosted-tool mapping")
+ default:
+ return "", fmt.Errorf("unknown Claude server tool %q cannot be represented as an OpenAI Responses hosted tool", name)
+ }
+}
+
+func claudeHostedResultOutputType(blockType string) (string, error) {
+ switch blockType {
+ case "web_search_tool_result":
+ return "web_search_call", nil
+ case "mcp_tool_result":
+ return "mcp_call", nil
+ case "code_execution_tool_result":
+ return "", fmt.Errorf("Claude code_execution result has no valid OpenAI Responses mapping without a container_id")
+ case "web_fetch_tool_result":
+ return "", fmt.Errorf("Claude web_fetch result has no valid OpenAI Responses hosted-tool mapping")
+ default:
+ return "", fmt.Errorf("unknown Claude hosted-tool result %q", blockType)
+ }
+}
+
+func claudeHostedResultErrorCode(content any, fallback string) string {
+ if strings.TrimSpace(fallback) != "" {
+ return strings.TrimSpace(fallback)
+ }
+ value, ok := content.(map[string]any)
+ if !ok {
+ return ""
+ }
+ contentType := strings.TrimSpace(kitutil.Interface2String(value["type"]))
+ if !strings.HasSuffix(contentType, "_error") {
+ return ""
+ }
+ return strings.TrimSpace(kitutil.Interface2String(value["error_code"]))
+}
diff --git a/relaykit/relayconvert/internal/claude_messages/to_oai_responses_req.go b/relaykit/relayconvert/internal/claude_messages/to_oai_responses_req.go
new file mode 100644
index 000000000000..75ae96f36f03
--- /dev/null
+++ b/relaykit/relayconvert/internal/claude_messages/to_oai_responses_req.go
@@ -0,0 +1,326 @@
+package claudemessages
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
+ kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
+)
+
+func ClaudeMessagesRequestToOpenAIResponses(claudeRequest dto.ClaudeRequest, info convmeta.Meta) (*dto.OpenAIResponsesRequest, error) {
+ if strings.TrimSpace(claudeRequest.Model) == "" {
+ return nil, errors.New("model is required")
+ }
+
+ input, err := claudeMessagesToResponsesInput(claudeRequest.Messages)
+ if err != nil {
+ return nil, err
+ }
+ instructions, err := claudeSystemToResponsesInstructions(&claudeRequest)
+ if err != nil {
+ return nil, err
+ }
+ tools, err := claudeToolsToResponsesTools(claudeRequest.Tools)
+ if err != nil {
+ return nil, err
+ }
+ toolChoice, parallelToolCalls, err := claudeToolChoiceToResponses(claudeRequest.ToolChoice)
+ if err != nil {
+ return nil, err
+ }
+
+ // Claude context_management is an object containing protocol-specific edit
+ // strategies. Responses expects an array of compaction entries, so copying
+ // the raw Claude value would produce an invalid upstream request.
+ responsesRequest := &dto.OpenAIResponsesRequest{
+ Model: claudeRequest.Model,
+ Input: input,
+ Instructions: instructions,
+ Metadata: append(json.RawMessage(nil), claudeRequest.Metadata...),
+ ServiceTier: claudeRequest.ServiceTier,
+ Stream: claudeRequest.Stream,
+ Temperature: claudeRequest.Temperature,
+ Tools: tools,
+ ToolChoice: toolChoice,
+ ParallelToolCalls: parallelToolCalls,
+ TopP: claudeRequest.TopP,
+ }
+ if info != nil && !convmeta.OptionsOf(info).OpenRouterDialect {
+ // Keep the outgoing -thinking suffix so a cascaded downstream new-api
+ // can recover reasoning intent from the model name. This is an
+ // emission-side policy, not converter-side suffix parsing.
+ thinkingSuffix := "-thinking"
+ if strings.HasSuffix(info.GetOriginModelName(), thinkingSuffix) && !strings.HasSuffix(responsesRequest.Model, thinkingSuffix) {
+ responsesRequest.Model += thinkingSuffix
+ }
+ }
+ if claudeRequest.MaxTokens != nil {
+ maxOutputTokens := *claudeRequest.MaxTokens
+ responsesRequest.MaxOutputTokens = &maxOutputTokens
+ } else if claudeRequest.MaxTokensToSample != nil {
+ maxOutputTokens := *claudeRequest.MaxTokensToSample
+ responsesRequest.MaxOutputTokens = &maxOutputTokens
+ }
+
+ reasoningIntent, effectiveEffort, err := claudeRequestReasoningIntent(&claudeRequest, info)
+ if err != nil {
+ return nil, reasoning.AsClientError(err)
+ }
+ if err := reasoning.ApplyToOpenAIResponses(responsesRequest, reasoningIntent); err != nil {
+ return nil, reasoning.AsClientError(err)
+ }
+ if info != nil && effectiveEffort != "" {
+ info.SetReasoningEffort(string(effectiveEffort))
+ }
+
+ return responsesRequest, nil
+}
+
+func claudeRequestReasoningIntent(claudeRequest *dto.ClaudeRequest, info convmeta.Meta) (reasoning.Intent, reasoning.Effort, error) {
+ reasoningIntent, err := reasoning.FromClaude(claudeRequest)
+ if err != nil {
+ return reasoning.Intent{}, "", err
+ }
+ sourceModel := claudeRequest.Model
+ if info != nil && info.GetOriginModelName() != "" {
+ sourceModel = info.GetOriginModelName()
+ }
+ if suffix := reasoning.IntentFromState(convmeta.ReasoningStateOf(info)); !suffix.IsEmpty() {
+ reasoningIntent, err = reasoning.MergeExplicitAndSuffix(reasoningIntent, suffix, sourceModel)
+ if err != nil {
+ return reasoning.Intent{}, "", err
+ }
+ }
+ reasoningIntent = reasoning.ResolveClaudeDefault(sourceModel, reasoningIntent)
+ return reasoningIntent, reasoning.EffectiveEffort(reasoningIntent), nil
+}
+
+func claudeSystemToResponsesInstructions(request *dto.ClaudeRequest) (json.RawMessage, error) {
+ if request == nil || request.System == nil {
+ return nil, nil
+ }
+ if request.IsStringSystem() {
+ return kitutil.Marshal(request.GetStringSystem())
+ }
+
+ var instructions strings.Builder
+ systemBlocks, err := kitutil.Any2Type[[]dto.ClaudeMediaMessage](request.System)
+ if err != nil {
+ return nil, fmt.Errorf("invalid Claude system content: %w", err)
+ }
+ for _, block := range systemBlocks {
+ if block.Type == "text" || block.Type == "input_text" || block.Type == "" {
+ instructions.WriteString(block.GetText())
+ }
+ }
+ if instructions.Len() == 0 {
+ return nil, nil
+ }
+ return kitutil.Marshal(instructions.String())
+}
+
+func claudeMessagesToResponsesInput(messages []dto.ClaudeMessage) (json.RawMessage, error) {
+ input := make([]map[string]any, 0, len(messages))
+ for messageIndex := range messages {
+ message := messages[messageIndex]
+ role := strings.TrimSpace(message.Role)
+ if role == "" {
+ continue
+ }
+ if message.IsStringContent() {
+ input = append(input, map[string]any{
+ "role": role,
+ "content": message.GetStringContent(),
+ })
+ continue
+ }
+
+ blocks, err := message.ParseContent()
+ if err != nil {
+ return nil, fmt.Errorf("messages[%d].content: %w", messageIndex, err)
+ }
+ contentParts := make([]map[string]any, 0, len(blocks))
+ flushContent := func() {
+ if len(contentParts) == 0 {
+ return
+ }
+ input = append(input, map[string]any{
+ "role": role,
+ "content": contentParts,
+ })
+ contentParts = nil
+ }
+
+ for blockIndex := range blocks {
+ block := blocks[blockIndex]
+ switch block.Type {
+ case "text", "input_text":
+ partType := "input_text"
+ if role == "assistant" {
+ partType = "output_text"
+ }
+ contentParts = append(contentParts, map[string]any{
+ "type": partType,
+ "text": block.GetText(),
+ })
+ case "image":
+ if source := claudeSourceURL(block.Source); source != "" {
+ contentParts = append(contentParts, map[string]any{
+ "type": "input_image",
+ "image_url": source,
+ })
+ }
+ case "document":
+ if source := claudeSourceURL(block.Source); source != "" {
+ contentParts = append(contentParts, map[string]any{
+ "type": "input_file",
+ "file_data": source,
+ })
+ }
+ case "tool_use":
+ flushContent()
+ arguments, err := kitutil.Marshal(block.Input)
+ if err != nil {
+ return nil, fmt.Errorf("messages[%d].content[%d].input: %w", messageIndex, blockIndex, err)
+ }
+ if block.Input == nil {
+ arguments = []byte("{}")
+ }
+ input = append(input, map[string]any{
+ "type": "function_call",
+ "call_id": block.Id,
+ "name": block.Name,
+ "arguments": string(arguments),
+ })
+ case "tool_result":
+ flushContent()
+ output, err := claudeToolResultToResponsesOutput(block.Content)
+ if err != nil {
+ return nil, fmt.Errorf("messages[%d].content[%d].content: %w", messageIndex, blockIndex, err)
+ }
+ input = append(input, map[string]any{
+ "type": "function_call_output",
+ "call_id": block.ToolUseId,
+ "output": output,
+ })
+ }
+ }
+ flushContent()
+ }
+ return kitutil.Marshal(input)
+}
+
+func claudeToolsToResponsesTools(value any) (json.RawMessage, error) {
+ if value == nil {
+ return nil, nil
+ }
+ tools, err := kitutil.Any2Type[[]dto.Tool](value)
+ if err != nil {
+ return nil, fmt.Errorf("invalid Claude tools: %w", err)
+ }
+ converted := make([]map[string]any, 0, len(tools))
+ for _, tool := range tools {
+ function := map[string]any{
+ "type": "function",
+ "name": tool.Name,
+ "description": tool.Description,
+ "parameters": tool.InputSchema,
+ }
+ if tool.Strict != nil {
+ function["strict"] = *tool.Strict
+ }
+ converted = append(converted, function)
+ }
+ return kitutil.Marshal(converted)
+}
+
+func claudeToolChoiceToResponses(value any) (json.RawMessage, json.RawMessage, error) {
+ if value == nil {
+ return nil, nil, nil
+ }
+ choice, err := kitutil.Any2Type[dto.ClaudeToolChoice](value)
+ if err != nil {
+ return nil, nil, fmt.Errorf("invalid Claude tool_choice: %w", err)
+ }
+
+ var converted any
+ switch choice.Type {
+ case "", "auto":
+ converted = "auto"
+ case "any":
+ converted = "required"
+ case "none":
+ converted = "none"
+ case "tool":
+ converted = map[string]any{"type": "function", "name": choice.Name}
+ default:
+ return nil, nil, fmt.Errorf("unsupported Claude tool_choice type %q", choice.Type)
+ }
+ toolChoice, err := kitutil.Marshal(converted)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ var parallelToolCalls json.RawMessage
+ if choice.DisableParallelToolUse && choice.Type != "none" {
+ parallelToolCalls, err = kitutil.Marshal(false)
+ if err != nil {
+ return nil, nil, err
+ }
+ }
+ return toolChoice, parallelToolCalls, nil
+}
+
+func claudeToolResultToResponsesOutput(content any) (any, error) {
+ if content == nil {
+ return "", nil
+ }
+ if text, ok := content.(string); ok {
+ return text, nil
+ }
+ blocks, err := kitutil.Any2Type[[]dto.ClaudeMediaMessage](content)
+ if err != nil {
+ return content, nil
+ }
+ parts := make([]map[string]any, 0, len(blocks))
+ for _, block := range blocks {
+ switch block.Type {
+ case "text", "input_text":
+ parts = append(parts, map[string]any{"type": "input_text", "text": block.GetText()})
+ case "image":
+ if source := claudeSourceURL(block.Source); source != "" {
+ parts = append(parts, map[string]any{"type": "input_image", "image_url": source})
+ }
+ case "document":
+ if source := claudeSourceURL(block.Source); source != "" {
+ parts = append(parts, map[string]any{"type": "input_file", "file_data": source})
+ }
+ }
+ }
+ if len(parts) == 0 {
+ return content, nil
+ }
+ return parts, nil
+}
+
+func claudeSourceURL(source *dto.ClaudeMessageSource) string {
+ if source == nil {
+ return ""
+ }
+ if strings.TrimSpace(source.Url) != "" {
+ return source.Url
+ }
+ data := kitutil.Interface2String(source.Data)
+ if data == "" {
+ return ""
+ }
+ if strings.HasPrefix(data, "data:") {
+ return data
+ }
+ return fmt.Sprintf("data:%s;base64,%s", source.MediaType, data)
+}
diff --git a/relaykit/relayconvert/internal/gemini_chat/grounding.go b/relaykit/relayconvert/internal/gemini_chat/grounding.go
new file mode 100644
index 000000000000..10658d773b28
--- /dev/null
+++ b/relaykit/relayconvert/internal/gemini_chat/grounding.go
@@ -0,0 +1,367 @@
+package geminichat
+
+import (
+ "fmt"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+)
+
+type geminiGroundingChunk struct {
+ Web *geminiGroundingSource `json:"web,omitempty"`
+ RetrievedContext *geminiGroundingSource `json:"retrievedContext,omitempty"`
+}
+
+type geminiGroundingSource struct {
+ URI string `json:"uri,omitempty"`
+ Title string `json:"title,omitempty"`
+}
+
+type geminiGroundingSupport struct {
+ Segment struct {
+ PartIndex *int `json:"partIndex,omitempty"`
+ StartIndex int `json:"startIndex,omitempty"`
+ EndIndex int `json:"endIndex,omitempty"`
+ Text string `json:"text,omitempty"`
+ } `json:"segment"`
+ GroundingChunkIndices []int `json:"groundingChunkIndices"`
+}
+
+type renderedGeminiPart struct {
+ text string
+ startByte int
+}
+
+type streamedGeminiPartSpan struct {
+ partStartByte int
+ partEndByte int
+ renderedStartByte int
+}
+
+type streamedGeminiPart struct {
+ text strings.Builder
+ spans []streamedGeminiPartSpan
+}
+
+// geminiGroundingStreamCandidate retains the protocol state needed to resolve
+// grounding metadata emitted after the text it describes. Gemini's streaming
+// contract makes grounding chunk indexes cumulative across response chunks and
+// keeps segment offsets relative to the accumulated candidate part.
+type geminiGroundingStreamCandidate struct {
+ rendered strings.Builder
+ parts map[int]*streamedGeminiPart
+ chunks []geminiGroundingChunk
+}
+
+// GroundingWebSearchQueries returns the distinct hosted-search queries that
+// Gemini reports for a response. The provider may repeat metadata across
+// candidates or stream chunks, so callers can safely accumulate this result
+// without manufacturing duplicate Responses tool calls.
+func GroundingWebSearchQueries(response *dto.GeminiChatResponse) []string {
+ if response == nil {
+ return nil
+ }
+ queries := make([]string, 0)
+ seen := make(map[string]struct{})
+ for candidateIndex := range response.Candidates {
+ metadata := response.Candidates[candidateIndex].GroundingMetadata
+ if metadata == nil {
+ continue
+ }
+ for _, query := range metadata.WebSearchQueries {
+ query = strings.TrimSpace(query)
+ if query == "" {
+ continue
+ }
+ if _, exists := seen[query]; exists {
+ continue
+ }
+ seen[query] = struct{}{}
+ queries = append(queries, query)
+ }
+ }
+ return queries
+}
+
+func groundingAnnotationsToChat(metadata *dto.GeminiGroundingMetadata, content dto.GeminiChatContent, rendered string) []byte {
+ if metadata == nil || len(metadata.GroundingChunks) == 0 || len(metadata.GroundingSupports) == 0 {
+ return nil
+ }
+ var chunks []geminiGroundingChunk
+ if err := kitutil.Unmarshal(metadata.GroundingChunks, &chunks); err != nil {
+ return nil
+ }
+ var supports []geminiGroundingSupport
+ if err := kitutil.Unmarshal(metadata.GroundingSupports, &supports); err != nil {
+ return nil
+ }
+
+ parts := locateRenderedGeminiParts(content, rendered)
+ textPartCount := 0
+ soleTextPart := -1
+ for index := range parts {
+ if parts[index].startByte < 0 {
+ continue
+ }
+ textPartCount++
+ soleTextPart = index
+ }
+
+ annotations := make([]any, 0)
+ seen := make(map[string]struct{})
+ for _, support := range supports {
+ partIndex := soleTextPart
+ if support.Segment.PartIndex != nil {
+ partIndex = *support.Segment.PartIndex
+ } else if textPartCount != 1 {
+ continue
+ }
+ if partIndex < 0 || partIndex >= len(parts) || parts[partIndex].startByte < 0 {
+ continue
+ }
+ part := parts[partIndex]
+ start, end, ok := groundingRuneRange(rendered, part, support.Segment.StartIndex, support.Segment.EndIndex)
+ if !ok {
+ continue
+ }
+ if support.Segment.Text != "" && part.text[support.Segment.StartIndex:support.Segment.EndIndex] != support.Segment.Text {
+ continue
+ }
+ annotations = appendGroundingAnnotations(annotations, chunks, support, start, end, "", seen)
+ }
+ return marshalGroundingAnnotations(annotations)
+}
+
+func newGeminiGroundingStreamCandidate() *geminiGroundingStreamCandidate {
+ return &geminiGroundingStreamCandidate{parts: make(map[int]*streamedGeminiPart)}
+}
+
+func (s *geminiGroundingStreamCandidate) appendContent(content dto.GeminiChatContent, rendered string) {
+ if s == nil {
+ return
+ }
+ if s.parts == nil {
+ s.parts = make(map[int]*streamedGeminiPart)
+ }
+
+ renderedParts := locateRenderedGeminiParts(content, rendered)
+ renderedBase := s.rendered.Len()
+ for index := range content.Parts {
+ partContent := content.Parts[index]
+ text := partContent.Text
+ if text == "" || partContent.Thought {
+ continue
+ }
+ part := s.parts[index]
+ if part == nil {
+ part = &streamedGeminiPart{}
+ s.parts[index] = part
+ }
+ partStart := part.text.Len()
+ part.text.WriteString(text)
+
+ // A standalone newline is intentionally omitted by the existing Gemini
+ // renderer. Keep it in the source part so later byte offsets stay correct,
+ // but do not claim that it has a corresponding rendered span.
+ if text == "\n" || index >= len(renderedParts) || renderedParts[index].startByte < 0 {
+ continue
+ }
+ renderedStart := renderedBase + renderedParts[index].startByte
+ part.spans = append(part.spans, streamedGeminiPartSpan{
+ partStartByte: partStart,
+ partEndByte: partStart + len(text),
+ renderedStartByte: renderedStart,
+ })
+ }
+ s.rendered.WriteString(rendered)
+}
+
+func (s *geminiGroundingStreamCandidate) appendGroundingChunks(metadata *dto.GeminiGroundingMetadata) {
+ if s == nil || metadata == nil || len(metadata.GroundingChunks) == 0 {
+ return
+ }
+ var chunks []geminiGroundingChunk
+ if err := kitutil.Unmarshal(metadata.GroundingChunks, &chunks); err != nil {
+ return
+ }
+ s.chunks = append(s.chunks, chunks...)
+}
+
+func (s *geminiGroundingStreamCandidate) groundingAnnotations(
+ metadata *dto.GeminiGroundingMetadata,
+ candidateIndex int64,
+ seen map[string]struct{},
+) []byte {
+ if s == nil || metadata == nil {
+ return nil
+ }
+ s.appendGroundingChunks(metadata)
+ if len(s.chunks) == 0 || len(metadata.GroundingSupports) == 0 {
+ return nil
+ }
+ var supports []geminiGroundingSupport
+ if err := kitutil.Unmarshal(metadata.GroundingSupports, &supports); err != nil {
+ return nil
+ }
+
+ annotations := make([]any, 0)
+ keyPrefix := fmt.Sprintf("%d:", candidateIndex)
+ for _, support := range supports {
+ partIndex, ok := s.groundingPartIndex(support)
+ if !ok {
+ continue
+ }
+ start, end, ok := s.groundingRuneRange(partIndex, support.Segment.StartIndex, support.Segment.EndIndex)
+ if !ok {
+ continue
+ }
+ part := s.parts[partIndex]
+ if support.Segment.Text != "" && part.text.String()[support.Segment.StartIndex:support.Segment.EndIndex] != support.Segment.Text {
+ continue
+ }
+ annotations = appendGroundingAnnotations(annotations, s.chunks, support, start, end, keyPrefix, seen)
+ }
+ return marshalGroundingAnnotations(annotations)
+}
+
+func (s *geminiGroundingStreamCandidate) groundingPartIndex(support geminiGroundingSupport) (int, bool) {
+ if support.Segment.PartIndex != nil {
+ partIndex := *support.Segment.PartIndex
+ part := s.parts[partIndex]
+ return partIndex, part != nil && len(part.spans) > 0
+ }
+ solePartIndex := -1
+ for partIndex, part := range s.parts {
+ if part == nil || len(part.spans) == 0 {
+ continue
+ }
+ if solePartIndex >= 0 {
+ return 0, false
+ }
+ solePartIndex = partIndex
+ }
+ return solePartIndex, solePartIndex >= 0
+}
+
+func (s *geminiGroundingStreamCandidate) groundingRuneRange(partIndex int, startByte int, endByte int) (int, int, bool) {
+ if s == nil {
+ return 0, 0, false
+ }
+ part := s.parts[partIndex]
+ if part == nil {
+ return 0, 0, false
+ }
+ partText := part.text.String()
+ if startByte < 0 || endByte <= startByte || endByte > len(partText) {
+ return 0, 0, false
+ }
+ if !utf8.ValidString(partText[:startByte]) || !utf8.ValidString(partText[:endByte]) {
+ return 0, 0, false
+ }
+
+ renderedStart, renderedEnd := -1, -1
+ for _, span := range part.spans {
+ if renderedStart < 0 && startByte >= span.partStartByte && startByte < span.partEndByte {
+ renderedStart = span.renderedStartByte + startByte - span.partStartByte
+ }
+ if endByte > span.partStartByte && endByte <= span.partEndByte {
+ renderedEnd = span.renderedStartByte + endByte - span.partStartByte
+ }
+ }
+ if renderedStart < 0 || renderedEnd <= renderedStart {
+ return 0, 0, false
+ }
+ rendered := s.rendered.String()
+ if renderedEnd > len(rendered) || rendered[renderedStart:renderedEnd] != partText[startByte:endByte] {
+ return 0, 0, false
+ }
+ if !utf8.ValidString(rendered[:renderedStart]) || !utf8.ValidString(rendered[:renderedEnd]) {
+ return 0, 0, false
+ }
+ return utf8.RuneCountInString(rendered[:renderedStart]), utf8.RuneCountInString(rendered[:renderedEnd]), true
+}
+
+func appendGroundingAnnotations(
+ annotations []any,
+ chunks []geminiGroundingChunk,
+ support geminiGroundingSupport,
+ start int,
+ end int,
+ keyPrefix string,
+ seen map[string]struct{},
+) []any {
+ for _, chunkIndex := range support.GroundingChunkIndices {
+ if chunkIndex < 0 || chunkIndex >= len(chunks) {
+ continue
+ }
+ source := chunks[chunkIndex].Web
+ if source == nil {
+ source = chunks[chunkIndex].RetrievedContext
+ }
+ if source == nil || source.URI == "" {
+ continue
+ }
+ key := fmt.Sprintf("%s%d:%d:%s", keyPrefix, start, end, source.URI)
+ if _, exists := seen[key]; exists {
+ continue
+ }
+ seen[key] = struct{}{}
+ annotations = append(annotations, map[string]any{
+ "type": "url_citation",
+ "url_citation": map[string]any{
+ "start_index": start,
+ "end_index": end,
+ "url": source.URI,
+ "title": source.Title,
+ },
+ })
+ }
+ return annotations
+}
+
+func marshalGroundingAnnotations(annotations []any) []byte {
+ if len(annotations) == 0 {
+ return nil
+ }
+ encoded, err := kitutil.Marshal(annotations)
+ if err != nil {
+ return nil
+ }
+ return encoded
+}
+
+func locateRenderedGeminiParts(content dto.GeminiChatContent, rendered string) []renderedGeminiPart {
+ parts := make([]renderedGeminiPart, len(content.Parts))
+ cursor := 0
+ for index := range content.Parts {
+ part := content.Parts[index]
+ text := part.Text
+ parts[index] = renderedGeminiPart{text: text, startByte: -1}
+ if text == "" || part.Thought || cursor > len(rendered) {
+ continue
+ }
+ relative := strings.Index(rendered[cursor:], text)
+ if relative < 0 {
+ continue
+ }
+ start := cursor + relative
+ parts[index].startByte = start
+ cursor = start + len(text)
+ }
+ return parts
+}
+
+func groundingRuneRange(rendered string, part renderedGeminiPart, startByte int, endByte int) (int, int, bool) {
+ if startByte < 0 || endByte <= startByte || endByte > len(part.text) {
+ return 0, 0, false
+ }
+ if !utf8.ValidString(part.text[:startByte]) || !utf8.ValidString(part.text[:endByte]) {
+ return 0, 0, false
+ }
+ partStartRunes := utf8.RuneCountInString(rendered[:part.startByte])
+ start := partStartRunes + utf8.RuneCountInString(part.text[:startByte])
+ end := partStartRunes + utf8.RuneCountInString(part.text[:endByte])
+ return start, end, true
+}
diff --git a/relaykit/relayconvert/internal/gemini_chat/to_oai_chat_req.go b/relaykit/relayconvert/internal/gemini_chat/to_oai_chat_req.go
index b614bb8d4b6a..04b6b74e3350 100644
--- a/relaykit/relayconvert/internal/gemini_chat/to_oai_chat_req.go
+++ b/relaykit/relayconvert/internal/gemini_chat/to_oai_chat_req.go
@@ -8,6 +8,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/jsonutil"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatRequest, info convmeta.Meta) (*dto.GeneralOpenAIRequest, error) {
@@ -21,7 +22,41 @@ func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatReque
Model: modelName,
Stream: kitutil.GetPointer(isStream),
}
+ reasoningIntent, err := reasoning.FromGemini(geminiRequest)
+ if err != nil {
+ return nil, reasoning.AsClientError(err)
+ }
+ sourceModelName := modelName
+ if info != nil && info.GetOriginModelName() != "" {
+ sourceModelName = info.GetOriginModelName()
+ }
+ baseSourceModel := sourceModelName
+ opts := convmeta.OptionsOf(info)
+ preserveSuffix := opts.ShouldPreserveThinkingSuffix(sourceModelName)
+ if !preserveSuffix {
+ if suffix := reasoning.IntentFromState(convmeta.ReasoningStateOf(info)); !suffix.IsEmpty() {
+ reasoningIntent, err = reasoning.MergeExplicitAndSuffix(reasoningIntent, suffix, sourceModelName)
+ if err != nil {
+ return nil, reasoning.AsClientError(err)
+ }
+ }
+ }
+ if baseSourceModel != "" && geminiRequest.GenerationConfig.ThinkingConfig != nil {
+ _, err = reasoning.ValidateGeminiThinkingConfig(baseSourceModel, geminiRequest.GenerationConfig.ThinkingConfig)
+ if err != nil {
+ return nil, reasoning.AsClientError(err)
+ }
+ }
+ reasoningIntent = reasoning.ResolveGeminiDefault(baseSourceModel, reasoningIntent)
+ effectiveEffort := reasoning.EffectiveEffort(reasoningIntent)
+ if err := reasoning.ApplyToOpenAIChat(openaiRequest, reasoningIntent); err != nil {
+ return nil, reasoning.AsClientError(err)
+ }
+ if effectiveEffort != "" && info != nil {
+ info.SetReasoningEffort(string(effectiveEffort))
+ }
+ callHistory := newGeminiFunctionCallHistory(geminiRequest.Contents)
var messages []dto.Message
for _, content := range geminiRequest.Contents {
message := dto.Message{
@@ -30,8 +65,13 @@ func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatReque
var mediaContents []dto.MediaContent
var toolCalls []dto.ToolCallRequest
+ var reasoningTexts []string
for _, part := range content.Parts {
if part.Text != "" {
+ if part.Thought {
+ reasoningTexts = append(reasoningTexts, part.Text)
+ continue
+ }
mediaContent := dto.MediaContent{
Type: "text",
Text: part.Text,
@@ -59,7 +99,7 @@ func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatReque
mediaContents = append(mediaContents, mediaContent)
} else if part.FunctionCall != nil {
toolCall := dto.ToolCallRequest{
- ID: fmt.Sprintf("call_%d", len(toolCalls)+1),
+ ID: callHistory.add(part.FunctionCall),
Type: "function",
Function: dto.FunctionRequest{
Name: part.FunctionCall.FunctionName,
@@ -70,7 +110,7 @@ func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatReque
} else if part.FunctionResponse != nil {
toolMessage := dto.Message{
Role: "tool",
- ToolCallId: fmt.Sprintf("call_%d", len(toolCalls)),
+ ToolCallId: callHistory.match(part.FunctionResponse),
}
toolMessage.SetStringContent(jsonutil.ToJSONString(part.FunctionResponse.Response))
messages = append(messages, toolMessage)
@@ -84,8 +124,12 @@ func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatReque
} else if len(mediaContents) > 0 {
message.SetMediaContent(mediaContents)
}
+ if len(reasoningTexts) > 0 {
+ reasoningContent := strings.Join(reasoningTexts, "\n")
+ message.ReasoningContent = &reasoningContent
+ }
- if len(message.ParseContent()) > 0 || len(message.ToolCalls) > 0 {
+ if len(message.ParseContent()) > 0 || len(message.ToolCalls) > 0 || message.ReasoningContent != nil {
messages = append(messages, message)
}
}
@@ -95,19 +139,19 @@ func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatReque
if geminiRequest.GenerationConfig.Temperature != nil {
openaiRequest.Temperature = geminiRequest.GenerationConfig.Temperature
}
- if geminiRequest.GenerationConfig.TopP != nil && *geminiRequest.GenerationConfig.TopP > 0 {
+ if geminiRequest.GenerationConfig.TopP != nil {
openaiRequest.TopP = kitutil.GetPointer(*geminiRequest.GenerationConfig.TopP)
}
- if geminiRequest.GenerationConfig.TopK != nil && *geminiRequest.GenerationConfig.TopK > 0 {
+ if geminiRequest.GenerationConfig.TopK != nil {
openaiRequest.TopK = kitutil.GetPointer(int(*geminiRequest.GenerationConfig.TopK))
}
- if geminiRequest.GenerationConfig.MaxOutputTokens != nil && *geminiRequest.GenerationConfig.MaxOutputTokens > 0 {
+ if geminiRequest.GenerationConfig.MaxOutputTokens != nil {
openaiRequest.MaxTokens = kitutil.GetPointer(*geminiRequest.GenerationConfig.MaxOutputTokens)
}
if len(geminiRequest.GenerationConfig.StopSequences) > 0 {
openaiRequest.Stop = geminiRequest.GenerationConfig.StopSequences[:min(len(geminiRequest.GenerationConfig.StopSequences), 4)]
}
- if geminiRequest.GenerationConfig.CandidateCount != nil && *geminiRequest.GenerationConfig.CandidateCount > 0 {
+ if geminiRequest.GenerationConfig.CandidateCount != nil {
openaiRequest.N = kitutil.GetPointer(*geminiRequest.GenerationConfig.CandidateCount)
}
@@ -150,6 +194,88 @@ func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatReque
return openaiRequest, nil
}
+type geminiPendingFunctionCall struct {
+ id string
+ name string
+}
+
+// geminiFunctionCallHistory keeps legacy Gemini histories without call IDs
+// correlated across content boundaries. Named matching permits results for
+// different parallel functions to arrive out of order; same-name calls use
+// their original call order because old payloads contain no stronger identity.
+type geminiFunctionCallHistory struct {
+ reservedIDs map[string]struct{}
+ pending []geminiPendingFunctionCall
+ nextID int
+}
+
+func newGeminiFunctionCallHistory(contents []dto.GeminiChatContent) *geminiFunctionCallHistory {
+ history := &geminiFunctionCallHistory{
+ reservedIDs: make(map[string]struct{}),
+ nextID: 1,
+ }
+ for _, content := range contents {
+ for _, part := range content.Parts {
+ if part.FunctionCall != nil && part.FunctionCall.ID != "" {
+ history.reservedIDs[part.FunctionCall.ID] = struct{}{}
+ }
+ if part.FunctionResponse != nil {
+ if id := kitutil.JsonRawMessageToString(part.FunctionResponse.ID); id != "" {
+ history.reservedIDs[id] = struct{}{}
+ }
+ }
+ }
+ }
+ return history
+}
+
+func (h *geminiFunctionCallHistory) add(call *dto.FunctionCall) string {
+ id := call.ID
+ if id == "" {
+ id = h.newFallbackID()
+ }
+ h.pending = append(h.pending, geminiPendingFunctionCall{id: id, name: call.FunctionName})
+ return id
+}
+
+func (h *geminiFunctionCallHistory) match(response *dto.GeminiFunctionResponse) string {
+ if id := kitutil.JsonRawMessageToString(response.ID); id != "" {
+ h.removePendingByID(id)
+ return id
+ }
+
+ for i, call := range h.pending {
+ if response.Name != "" && call.name != response.Name {
+ continue
+ }
+ h.pending = append(h.pending[:i], h.pending[i+1:]...)
+ return call.id
+ }
+ return h.newFallbackID()
+}
+
+func (h *geminiFunctionCallHistory) removePendingByID(id string) {
+ for i, call := range h.pending {
+ if call.id != id {
+ continue
+ }
+ h.pending = append(h.pending[:i], h.pending[i+1:]...)
+ return
+ }
+}
+
+func (h *geminiFunctionCallHistory) newFallbackID() string {
+ for {
+ id := fmt.Sprintf("call_%d", h.nextID)
+ h.nextID++
+ if _, exists := h.reservedIDs[id]; exists {
+ continue
+ }
+ h.reservedIDs[id] = struct{}{}
+ return id
+ }
+}
+
func convertGeminiRoleToOpenAI(geminiRole string) string {
switch geminiRole {
case "user":
diff --git a/relaykit/relayconvert/internal/gemini_chat/to_oai_chat_resp.go b/relaykit/relayconvert/internal/gemini_chat/to_oai_chat_resp.go
index 81f8078d355d..c74af6d577cb 100644
--- a/relaykit/relayconvert/internal/gemini_chat/to_oai_chat_resp.go
+++ b/relaykit/relayconvert/internal/gemini_chat/to_oai_chat_resp.go
@@ -2,6 +2,8 @@ package geminichat
import (
"fmt"
+ "sort"
+ "strconv"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
@@ -176,6 +178,7 @@ func ResponseGeminiChat2OpenAI(id string, created int64, response *dto.GeminiCha
if isToolCall {
choice.FinishReason = types.FinishReasonToolCalls
}
+ choice.Message.Annotations = groundingAnnotationsToChat(candidate.GroundingMetadata, candidate.Content, choice.Message.StringContent())
fullTextResponse.Choices = append(fullTextResponse.Choices, choice)
}
@@ -272,6 +275,7 @@ func StreamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*d
if isTools {
choice.FinishReason = &types.FinishReasonToolCalls
}
+ choice.Delta.Annotations = groundingAnnotationsToChat(candidate.GroundingMetadata, candidate.Content, content.String())
choices = append(choices, choice)
}
@@ -288,8 +292,29 @@ type GeminiToChatStreamState struct {
sawToolCall bool
finishEmitted bool
latestUsage *dto.Usage
+ // Gemini generateContent streams complete function calls. Keep their
+ // occurrence indexes monotonic because chunk-local indexes restart at zero.
+ nextToolIndexByCandidate map[int64]int
+ toolIndexByCandidateID map[int64]map[string]int
+ partialToolByCandidate map[int64]*geminiPartialToolCall
+ groundingByCandidate map[int64]*geminiGroundingStreamCandidate
+ sentGroundingAnnotations map[string]struct{}
}
+type geminiPartialToolCall struct {
+ id string
+ name string
+ arguments map[string]interface{}
+}
+
+type geminiPartialArgPathSegment struct {
+ member string
+ index int
+ isIndex bool
+}
+
+const maxGeminiPartialArgArrayIndex = 4095
+
func NewGeminiToChatStreamState(id string, created int64) *GeminiToChatStreamState {
id = strings.TrimSpace(id)
if id == "" {
@@ -298,12 +323,40 @@ func NewGeminiToChatStreamState(id string, created int64) *GeminiToChatStreamSta
if created == 0 {
created = kitutil.GetTimestamp()
}
- return &GeminiToChatStreamState{id: id, created: created}
+ return &GeminiToChatStreamState{
+ id: id,
+ created: created,
+ nextToolIndexByCandidate: make(map[int64]int),
+ toolIndexByCandidateID: make(map[int64]map[string]int),
+ partialToolByCandidate: make(map[int64]*geminiPartialToolCall),
+ groundingByCandidate: make(map[int64]*geminiGroundingStreamCandidate),
+ sentGroundingAnnotations: make(map[string]struct{}),
+ }
}
-func (s *GeminiToChatStreamState) ConvertChunk(geminiResponse *dto.GeminiChatResponse, model string, usage *dto.Usage) []*dto.ChatCompletionsStreamResponse {
+func (s *GeminiToChatStreamState) ConvertChunk(geminiResponse *dto.GeminiChatResponse, model string, usage *dto.Usage) ([]*dto.ChatCompletionsStreamResponse, error) {
if s == nil || geminiResponse == nil {
- return nil
+ return nil, nil
+ }
+ if s.groundingByCandidate == nil {
+ s.groundingByCandidate = make(map[int64]*geminiGroundingStreamCandidate)
+ }
+ if s.sentGroundingAnnotations == nil {
+ s.sentGroundingAnnotations = make(map[string]struct{})
+ }
+ if s.nextToolIndexByCandidate == nil {
+ s.nextToolIndexByCandidate = make(map[int64]int)
+ }
+ if s.toolIndexByCandidateID == nil {
+ s.toolIndexByCandidateID = make(map[int64]map[string]int)
+ }
+ if s.partialToolByCandidate == nil {
+ s.partialToolByCandidate = make(map[int64]*geminiPartialToolCall)
+ }
+ var err error
+ geminiResponse, err = s.preparePartialFunctionCalls(geminiResponse)
+ if err != nil {
+ return nil, err
}
hasNonStopFinish := false
for _, candidate := range geminiResponse.Candidates {
@@ -314,12 +367,47 @@ func (s *GeminiToChatStreamState) ConvertChunk(geminiResponse *dto.GeminiChatRes
}
response, isStop := StreamResponseGeminiChat2OpenAI(geminiResponse)
if response == nil {
- return nil
+ return nil, nil
}
response.Id = s.id
response.Created = s.created
response.Model = model
response.Usage = usage
+ for index := range geminiResponse.Candidates {
+ if index >= len(response.Choices) {
+ break
+ }
+ candidate := &geminiResponse.Candidates[index]
+ choice := &response.Choices[index]
+ for toolIndex := range choice.Delta.ToolCalls {
+ callID := strings.TrimSpace(choice.Delta.ToolCalls[toolIndex].ID)
+ indexesByID := s.toolIndexByCandidateID[candidate.Index]
+ if indexesByID == nil {
+ indexesByID = make(map[string]int)
+ s.toolIndexByCandidateID[candidate.Index] = indexesByID
+ }
+ stableIndex, exists := indexesByID[callID]
+ if callID == "" || !exists {
+ stableIndex = s.nextToolIndexByCandidate[candidate.Index]
+ s.nextToolIndexByCandidate[candidate.Index] = stableIndex + 1
+ if callID != "" {
+ indexesByID[callID] = stableIndex
+ }
+ }
+ choice.Delta.ToolCalls[toolIndex].SetIndex(stableIndex)
+ }
+ grounding := s.groundingByCandidate[candidate.Index]
+ if grounding == nil {
+ grounding = newGeminiGroundingStreamCandidate()
+ s.groundingByCandidate[candidate.Index] = grounding
+ }
+ grounding.appendContent(candidate.Content, response.Choices[index].Delta.GetContentString())
+ response.Choices[index].Delta.Annotations = grounding.groundingAnnotations(
+ candidate.GroundingMetadata,
+ candidate.Index,
+ s.sentGroundingAnnotations,
+ )
+ }
if response.IsToolCall() {
s.sawToolCall = true
@@ -345,14 +433,29 @@ func (s *GeminiToChatStreamState) ConvertChunk(geminiResponse *dto.GeminiChatRes
if isStop && !s.finishEmitted {
responses = append(responses, s.terminalChunk(model))
}
- return responses
+ return responses, nil
}
-func (s *GeminiToChatStreamState) Finalize(model string) []*dto.ChatCompletionsStreamResponse {
- if s == nil || s.finishEmitted {
- return nil
+func (s *GeminiToChatStreamState) Finalize(model string) ([]*dto.ChatCompletionsStreamResponse, error) {
+ if s == nil {
+ return nil, nil
+ }
+ if len(s.partialToolByCandidate) > 0 {
+ candidateIndexes := make([]int64, 0, len(s.partialToolByCandidate))
+ for candidateIndex := range s.partialToolByCandidate {
+ candidateIndexes = append(candidateIndexes, candidateIndex)
+ }
+ sort.Slice(candidateIndexes, func(i, j int) bool {
+ return candidateIndexes[i] < candidateIndexes[j]
+ })
+ candidateIndex := candidateIndexes[0]
+ partial := s.partialToolByCandidate[candidateIndex]
+ return nil, fmt.Errorf("Gemini stream ended with an incomplete function call for candidate %d (id %q, name %q)", candidateIndex, partial.id, partial.name)
+ }
+ if s.finishEmitted {
+ return nil, nil
}
- return []*dto.ChatCompletionsStreamResponse{s.terminalChunk(model)}
+ return []*dto.ChatCompletionsStreamResponse{s.terminalChunk(model)}, nil
}
func (s *GeminiToChatStreamState) Usage() *dto.Usage {
@@ -362,6 +465,234 @@ func (s *GeminiToChatStreamState) Usage() *dto.Usage {
return s.latestUsage
}
+func (s *GeminiToChatStreamState) preparePartialFunctionCalls(response *dto.GeminiChatResponse) (*dto.GeminiChatResponse, error) {
+ prepared := *response
+ prepared.Candidates = append([]dto.GeminiChatCandidate(nil), response.Candidates...)
+ for candidateIndex := range prepared.Candidates {
+ candidate := &prepared.Candidates[candidateIndex]
+ parts := make([]dto.GeminiPart, 0, len(candidate.Content.Parts))
+ for _, part := range candidate.Content.Parts {
+ call := part.FunctionCall
+ if call == nil || (s.partialToolByCandidate[candidate.Index] == nil && call.WillContinue == nil && len(call.PartialArgs) == 0) {
+ parts = append(parts, part)
+ continue
+ }
+ completed, ready, err := s.appendPartialFunctionCall(candidate.Index, call)
+ if err != nil {
+ return nil, fmt.Errorf("reconstruct Gemini streamed function arguments: %w", err)
+ }
+ if ready {
+ part.FunctionCall = completed
+ parts = append(parts, part)
+ }
+ }
+ candidate.Content.Parts = parts
+ }
+ return &prepared, nil
+}
+
+func (s *GeminiToChatStreamState) appendPartialFunctionCall(candidateIndex int64, call *dto.FunctionCall) (*dto.FunctionCall, bool, error) {
+ current := s.partialToolByCandidate[candidateIndex]
+ if current == nil {
+ current = &geminiPartialToolCall{arguments: make(map[string]interface{})}
+ s.partialToolByCandidate[candidateIndex] = current
+ }
+ if id := strings.TrimSpace(call.ID); id != "" {
+ if current.id != "" && current.id != id {
+ return nil, false, fmt.Errorf("candidate %d function call changed id from %q to %q", candidateIndex, current.id, id)
+ }
+ current.id = id
+ }
+ if name := strings.TrimSpace(call.FunctionName); name != "" {
+ if current.name != "" && current.name != name {
+ return nil, false, fmt.Errorf("candidate %d function call changed name from %q to %q", candidateIndex, current.name, name)
+ }
+ current.name = name
+ }
+ for _, partial := range call.PartialArgs {
+ path, err := parseGeminiPartialArgPath(partial.JSONPath)
+ if err != nil {
+ return nil, false, err
+ }
+ value, present := geminiPartialArgValue(partial)
+ if !present {
+ continue
+ }
+ updated, err := setGeminiPartialArgValue(current.arguments, path, value, partial.StringValue != nil)
+ if err != nil {
+ return nil, false, fmt.Errorf("set partial argument %q: %w", partial.JSONPath, err)
+ }
+ arguments, ok := updated.(map[string]interface{})
+ if !ok {
+ return nil, false, fmt.Errorf("partial argument path %q replaced the arguments object", partial.JSONPath)
+ }
+ current.arguments = arguments
+ }
+ if call.WillContinue != nil && *call.WillContinue {
+ return nil, false, nil
+ }
+ if current.name == "" {
+ return nil, false, fmt.Errorf("candidate %d completed a partial function call without a name", candidateIndex)
+ }
+ completed := &dto.FunctionCall{ID: current.id, FunctionName: current.name, Arguments: current.arguments}
+ delete(s.partialToolByCandidate, candidateIndex)
+ return completed, true, nil
+}
+
+func parseGeminiPartialArgPath(jsonPath string) ([]geminiPartialArgPathSegment, error) {
+ path := strings.TrimSpace(jsonPath)
+ if path == "" || path[0] != '$' {
+ return nil, fmt.Errorf("unsupported Gemini partial argument path %q", jsonPath)
+ }
+ segments := make([]geminiPartialArgPathSegment, 0)
+ for offset := 1; offset < len(path); {
+ switch path[offset] {
+ case '.':
+ offset++
+ start := offset
+ for offset < len(path) && path[offset] != '.' && path[offset] != '[' {
+ offset++
+ }
+ if start == offset {
+ return nil, fmt.Errorf("empty member in Gemini partial argument path %q", jsonPath)
+ }
+ member := path[start:offset]
+ if strings.ContainsAny(member, "]*?") {
+ return nil, fmt.Errorf("unsupported member %q in Gemini partial argument path", member)
+ }
+ segments = append(segments, geminiPartialArgPathSegment{member: member})
+ case '[':
+ offset++
+ if offset >= len(path) {
+ return nil, fmt.Errorf("unterminated selector in Gemini partial argument path %q", jsonPath)
+ }
+ if path[offset] == '\'' || path[offset] == '"' {
+ member, next, err := parseGeminiPartialArgMember(path, offset)
+ if err != nil {
+ return nil, fmt.Errorf("invalid Gemini partial argument path %q: %w", jsonPath, err)
+ }
+ offset = next
+ if offset >= len(path) || path[offset] != ']' {
+ return nil, fmt.Errorf("unterminated member selector in Gemini partial argument path %q", jsonPath)
+ }
+ offset++
+ segments = append(segments, geminiPartialArgPathSegment{member: member})
+ continue
+ }
+ start := offset
+ for offset < len(path) && path[offset] >= '0' && path[offset] <= '9' {
+ offset++
+ }
+ if start == offset || offset >= len(path) || path[offset] != ']' {
+ return nil, fmt.Errorf("unsupported selector in Gemini partial argument path %q", jsonPath)
+ }
+ index, err := strconv.Atoi(path[start:offset])
+ if err != nil {
+ return nil, fmt.Errorf("invalid array index in Gemini partial argument path %q: %w", jsonPath, err)
+ }
+ if index > maxGeminiPartialArgArrayIndex {
+ return nil, fmt.Errorf("array index %d exceeds Gemini partial argument materialization limit %d", index, maxGeminiPartialArgArrayIndex)
+ }
+ offset++
+ segments = append(segments, geminiPartialArgPathSegment{index: index, isIndex: true})
+ default:
+ return nil, fmt.Errorf("unsupported selector at offset %d in Gemini partial argument path %q", offset, jsonPath)
+ }
+ }
+ if len(segments) == 0 {
+ return nil, fmt.Errorf("Gemini partial argument path %q targets the arguments root", jsonPath)
+ }
+ return segments, nil
+}
+
+func geminiPartialArgValue(partial dto.GeminiPartialArg) (any, bool) {
+ switch {
+ case partial.StringValue != nil:
+ return *partial.StringValue, true
+ case partial.NumberValue != nil:
+ return *partial.NumberValue, true
+ case partial.BoolValue != nil:
+ return *partial.BoolValue, true
+ case partial.NullValue != nil:
+ return nil, true
+ default:
+ return nil, false
+ }
+}
+
+func parseGeminiPartialArgMember(path string, offset int) (string, int, error) {
+ quote := path[offset]
+ start := offset
+ offset++
+ for offset < len(path) {
+ if path[offset] == '\\' {
+ offset += 2
+ continue
+ }
+ if path[offset] == quote {
+ raw := path[start : offset+1]
+ if quote == '\'' {
+ raw = `"` + strings.ReplaceAll(strings.ReplaceAll(raw[1:len(raw)-1], `"`, `\"`), `\'`, `'`) + `"`
+ }
+ var member string
+ if err := kitutil.Unmarshal([]byte(raw), &member); err != nil {
+ return "", 0, err
+ }
+ return member, offset + 1, nil
+ }
+ offset++
+ }
+ return "", 0, fmt.Errorf("unterminated quoted member")
+}
+
+func setGeminiPartialArgValue(current any, path []geminiPartialArgPathSegment, value any, appendString bool) (any, error) {
+ if len(path) == 0 {
+ if appendString {
+ if existing, ok := current.(string); ok {
+ return existing + value.(string), nil
+ }
+ }
+ return value, nil
+ }
+ segment := path[0]
+ if segment.isIndex {
+ var array []interface{}
+ switch typed := current.(type) {
+ case nil:
+ array = make([]interface{}, segment.index+1)
+ case []interface{}:
+ array = typed
+ if len(array) <= segment.index {
+ array = append(array, make([]interface{}, segment.index-len(array)+1)...)
+ }
+ default:
+ return nil, fmt.Errorf("array index %d traverses %T", segment.index, current)
+ }
+ updated, err := setGeminiPartialArgValue(array[segment.index], path[1:], value, appendString)
+ if err != nil {
+ return nil, err
+ }
+ array[segment.index] = updated
+ return array, nil
+ }
+
+ var object map[string]interface{}
+ switch typed := current.(type) {
+ case nil:
+ object = make(map[string]interface{})
+ case map[string]interface{}:
+ object = typed
+ default:
+ return nil, fmt.Errorf("member %q traverses %T", segment.member, current)
+ }
+ updated, err := setGeminiPartialArgValue(object[segment.member], path[1:], value, appendString)
+ if err != nil {
+ return nil, err
+ }
+ object[segment.member] = updated
+ return object, nil
+}
+
func (s *GeminiToChatStreamState) terminalChunk(model string) *dto.ChatCompletionsStreamResponse {
finishReason := types.FinishReasonStop
if s.sawToolCall {
@@ -388,8 +719,12 @@ func geminiResponseToolCall(item *dto.GeminiPart) *dto.ToolCallResponse {
if err != nil {
return nil
}
+ callID := strings.TrimSpace(item.FunctionCall.ID)
+ if callID == "" {
+ callID = fmt.Sprintf("call_%s", kitutil.GetUUID())
+ }
return &dto.ToolCallResponse{
- ID: fmt.Sprintf("call_%s", kitutil.GetUUID()),
+ ID: callID,
Type: "function",
Function: dto.FunctionResponse{
Arguments: string(argsBytes),
diff --git a/relaykit/relayconvert/internal/gemini_chat/to_oai_responses_hosted_stream.go b/relaykit/relayconvert/internal/gemini_chat/to_oai_responses_hosted_stream.go
new file mode 100644
index 000000000000..473b9766c2e0
--- /dev/null
+++ b/relaykit/relayconvert/internal/gemini_chat/to_oai_responses_hosted_stream.go
@@ -0,0 +1,71 @@
+package geminichat
+
+import (
+ "fmt"
+
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ oaichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_chat"
+ kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+)
+
+// GeminiHostedStreamBridge accumulates grounding metadata until the provider
+// stream ends. Gemini commonly reports the search queries after emitting the
+// answer text; delaying the synthetic Responses item keeps one complete,
+// canonical action instead of emitting partial or duplicate tool calls.
+type GeminiHostedStreamBridge struct {
+ queries []string
+ seen map[string]struct{}
+}
+
+func NewGeminiHostedStreamBridge() *GeminiHostedStreamBridge {
+ return &GeminiHostedStreamBridge{seen: make(map[string]struct{})}
+}
+
+func (b *GeminiHostedStreamBridge) Observe(response *dto.GeminiChatResponse) {
+ if b == nil {
+ return
+ }
+ if b.seen == nil {
+ b.seen = make(map[string]struct{})
+ }
+ for _, query := range GroundingWebSearchQueries(response) {
+ if _, exists := b.seen[query]; exists {
+ continue
+ }
+ b.seen[query] = struct{}{}
+ b.queries = append(b.queries, query)
+ }
+}
+
+func (b *GeminiHostedStreamBridge) Finalize(state *oaichat.ChatToResponsesStreamState) ([]oaichat.ChatToResponsesStreamEvent, error) {
+ if b == nil || len(b.queries) == 0 {
+ return nil, nil
+ }
+ if state == nil {
+ return nil, fmt.Errorf("Chat-to-Responses stream state is required")
+ }
+ action, err := kitutil.Marshal(map[string]any{
+ "type": "search",
+ "queries": append([]string(nil), b.queries...),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("marshal Gemini web-search action: %w", err)
+ }
+ callID := fmt.Sprintf("ws_%s", kitutil.GetUUID())
+ events, err := state.StartHostedTool(oaichat.HostedToolStreamStart{
+ Type: "web_search_call",
+ ID: callID,
+ Action: action,
+ })
+ if err != nil {
+ return nil, err
+ }
+ completed, err := state.CompleteHostedTool(oaichat.HostedToolStreamResult{
+ Type: "web_search_call",
+ ID: callID,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return append(events, completed...), nil
+}
diff --git a/relaykit/relayconvert/internal/oai_chat/citations.go b/relaykit/relayconvert/internal/oai_chat/citations.go
new file mode 100644
index 000000000000..a2e056bbbd4b
--- /dev/null
+++ b/relaykit/relayconvert/internal/oai_chat/citations.go
@@ -0,0 +1,81 @@
+package oaichat
+
+import (
+ "encoding/json"
+ "strings"
+ "unicode/utf8"
+
+ kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+)
+
+func chatAnnotationsToClaude(raw json.RawMessage, text string) []json.RawMessage {
+ if len(raw) == 0 {
+ return nil
+ }
+ var annotations []map[string]any
+ if err := kitutil.Unmarshal(raw, &annotations); err != nil {
+ return nil
+ }
+ citations := make([]json.RawMessage, 0, len(annotations))
+ for _, annotation := range annotations {
+ if strings.TrimSpace(kitutil.Interface2String(annotation["type"])) != "url_citation" {
+ continue
+ }
+ citation, ok := annotation["url_citation"].(map[string]any)
+ if !ok {
+ citation = annotation
+ }
+ url := strings.TrimSpace(kitutil.Interface2String(citation["url"]))
+ if url == "" {
+ continue
+ }
+ converted := map[string]any{
+ "type": "web_search_result_location",
+ "url": url,
+ "title": strings.TrimSpace(kitutil.Interface2String(citation["title"])),
+ }
+ if citedText := kitutil.Interface2String(citation["cited_text"]); citedText != "" {
+ converted["cited_text"] = citedText
+ } else if citedText := citedTextFromAnnotation(text, citation); citedText != "" {
+ converted["cited_text"] = citedText
+ }
+ if encryptedIndex := kitutil.Interface2String(citation["encrypted_index"]); encryptedIndex != "" {
+ converted["encrypted_index"] = encryptedIndex
+ }
+ if converted["title"] == "" {
+ delete(converted, "title")
+ }
+ encoded, err := kitutil.Marshal(converted)
+ if err == nil {
+ citations = append(citations, encoded)
+ }
+ }
+ return citations
+}
+
+func citedTextFromAnnotation(text string, citation map[string]any) string {
+ start, startOK := annotationIndex(citation["start_index"])
+ end, endOK := annotationIndex(citation["end_index"])
+ if !startOK || !endOK || start < 0 || end <= start {
+ return ""
+ }
+ if end > utf8.RuneCountInString(text) {
+ return ""
+ }
+ runes := []rune(text)
+ return string(runes[start:end])
+}
+
+func annotationIndex(value any) (int, bool) {
+ switch number := value.(type) {
+ case float64:
+ return int(number), number >= 0 && number == float64(int(number))
+ case int:
+ return number, number >= 0
+ case json.Number:
+ parsed, err := number.Int64()
+ return int(parsed), err == nil && parsed >= 0
+ default:
+ return 0, false
+ }
+}
diff --git a/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req.go b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req.go
index ee4722f571fd..f071bc0eba1f 100644
--- a/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req.go
+++ b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req.go
@@ -1,7 +1,6 @@
package oaichat
import (
- "encoding/json"
"fmt"
"strings"
@@ -14,19 +13,6 @@ import (
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
-const (
- webSearchMaxUsesLow = 1
- webSearchMaxUsesMedium = 5
- webSearchMaxUsesHigh = 10
-)
-
-type openRouterRequestReasoning struct {
- Enabled bool `json:"enabled"`
- Effort string `json:"effort,omitempty"`
- MaxTokens int `json:"max_tokens,omitempty"`
- Exclude bool `json:"exclude,omitempty"`
-}
-
func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
opts := convmeta.OptionsOf(info)
claudeTools := make([]any, 0, len(textRequest.Tools))
@@ -74,15 +60,6 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
webSearchTool.UserLocation = anthropicUserLocation
}
- switch textRequest.WebSearchOptions.SearchContextSize {
- case "low":
- webSearchTool.MaxUses = webSearchMaxUsesLow
- case "medium":
- webSearchTool.MaxUses = webSearchMaxUsesMedium
- case "high":
- webSearchTool.MaxUses = webSearchMaxUsesHigh
- }
-
claudeTools = append(claudeTools, &webSearchTool)
}
@@ -94,8 +71,10 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
if len(claudeTools) > 0 {
claudeRequest.Tools = claudeTools
}
- if maxTokens := textRequest.GetMaxTokens(); maxTokens > 0 {
- claudeRequest.MaxTokens = kitutil.GetPointer(maxTokens)
+ if textRequest.MaxCompletionTokens != nil && *textRequest.MaxCompletionTokens > 0 {
+ claudeRequest.MaxTokens = kitutil.GetPointer(*textRequest.MaxCompletionTokens)
+ } else if textRequest.MaxTokens != nil && *textRequest.MaxTokens > 0 {
+ claudeRequest.MaxTokens = kitutil.GetPointer(*textRequest.MaxTokens)
}
if textRequest.TopP != nil {
claudeRequest.TopP = kitutil.GetPointer(*textRequest.TopP)
@@ -114,92 +93,17 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
}
}
- if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens == 0 {
- if defaultMaxTokens, configured := opts.Claude.DefaultMaxTokensFor(textRequest.Model); configured {
- value := uint(defaultMaxTokens)
- claudeRequest.MaxTokens = &value
- }
- }
-
- if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(textRequest.Model); ok && effortLevel != "" &&
- (strings.HasPrefix(textRequest.Model, "claude-opus-4-6") ||
- strings.HasPrefix(textRequest.Model, "claude-opus-4-7") ||
- strings.HasPrefix(textRequest.Model, "claude-opus-4-8")) {
- claudeRequest.Model = baseModel
- claudeRequest.Thinking = &dto.Thinking{
- Type: "adaptive",
- }
- claudeRequest.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
- if strings.HasPrefix(baseModel, "claude-opus-4-7") ||
- strings.HasPrefix(baseModel, "claude-opus-4-8") {
- claudeRequest.Thinking.Display = "summarized"
- claudeRequest.Temperature = nil
- claudeRequest.TopP = nil
- claudeRequest.TopK = nil
- } else {
- claudeRequest.TopP = nil
- claudeRequest.Temperature = kitutil.GetPointer[float64](1.0)
- }
- } else if opts.Claude.ThinkingAdapterEnabled &&
- strings.HasSuffix(textRequest.Model, "-thinking") {
-
- trimmedModel := strings.TrimSuffix(textRequest.Model, "-thinking")
- if strings.HasPrefix(trimmedModel, "claude-opus-4-7") ||
- strings.HasPrefix(trimmedModel, "claude-opus-4-8") {
- claudeRequest.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
- claudeRequest.OutputConfig = json.RawMessage(`{"effort":"high"}`)
- claudeRequest.Temperature = nil
- claudeRequest.TopP = nil
- claudeRequest.TopK = nil
- } else {
- if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens < 1280 {
- claudeRequest.MaxTokens = kitutil.GetPointer[uint](1280)
- }
-
- claudeRequest.Thinking = &dto.Thinking{
- Type: "enabled",
- BudgetTokens: kitutil.GetPointer[int](int(float64(*claudeRequest.MaxTokens) * opts.Claude.ThinkingAdapterBudgetTokensPercentage)),
- }
- claudeRequest.TopP = nil
- claudeRequest.Temperature = kitutil.GetPointer[float64](1.0)
- }
- if !opts.ShouldPreserveThinkingSuffix(textRequest.Model) {
- claudeRequest.Model = trimmedModel
- }
+ sourceReasoning, err := reasoning.FromOpenAIChat(&textRequest)
+ if err != nil {
+ return nil, reasoning.AsClientError(err)
}
-
- if textRequest.ReasoningEffort != "" {
- switch textRequest.ReasoningEffort {
- case "low":
- claudeRequest.Thinking = &dto.Thinking{
- Type: "enabled",
- BudgetTokens: kitutil.GetPointer[int](1280),
- }
- case "medium":
- claudeRequest.Thinking = &dto.Thinking{
- Type: "enabled",
- BudgetTokens: kitutil.GetPointer[int](2048),
- }
- case "high":
- claudeRequest.Thinking = &dto.Thinking{
- Type: "enabled",
- BudgetTokens: kitutil.GetPointer[int](4096),
- }
- }
+ if err := sharedclaude.ApplyReasoning(&claudeRequest, info, sourceReasoning); err != nil {
+ return nil, reasoning.AsClientError(err)
}
-
- if textRequest.Reasoning != nil {
- var reasoningConfig openRouterRequestReasoning
- if err := kitutil.Unmarshal(textRequest.Reasoning, &reasoningConfig); err != nil {
- return nil, err
- }
-
- budgetTokens := reasoningConfig.MaxTokens
- if budgetTokens > 0 {
- claudeRequest.Thinking = &dto.Thinking{
- Type: "enabled",
- BudgetTokens: &budgetTokens,
- }
+ if claudeRequest.MaxTokens == nil {
+ if defaultMaxTokens, configured := opts.Claude.DefaultMaxTokensFor(claudeRequest.Model); configured {
+ value := uint(defaultMaxTokens)
+ claudeRequest.MaxTokens = &value
}
}
@@ -220,9 +124,25 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
lastMessage := dto.Message{
Role: "tool",
}
- for i, message := range textRequest.Messages {
- if message.Role == "" {
- textRequest.Messages[i].Role = "user"
+ for _, message := range textRequest.Messages {
+ switch message.Role {
+ case "":
+ message.Role = "user"
+ case "developer":
+ message.Role = "system"
+ case "function":
+ if message.ToolCallId != "" {
+ message.Role = "tool"
+ } else {
+ message.Role = "user"
+ }
+ case "tool":
+ if message.ToolCallId == "" {
+ message.Role = "user"
+ }
+ case "system", "user", "assistant":
+ default:
+ message.Role = "user"
}
fmtMessage := dto.Message{
Role: message.Role,
@@ -236,7 +156,7 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
}
if lastMessage.Role == message.Role && lastMessage.Role != "tool" {
if lastMessage.IsStringContent() && message.IsStringContent() {
- fmtMessage.SetStringContent(strings.Trim(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()), "\""))
+ fmtMessage.SetStringContent(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()))
formatMessages = formatMessages[:len(formatMessages)-1]
}
}
@@ -250,6 +170,15 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
claudeMessages := make([]dto.ClaudeMessage, 0)
isFirstMessage := true
var systemMessages []dto.ClaudeMediaMessage
+ placeholderUserMessage := dto.ClaudeMessage{
+ Role: "user",
+ Content: []dto.ClaudeMediaMessage{
+ {
+ Type: "text",
+ Text: kitutil.GetPointer[string]("..."),
+ },
+ },
+ }
for _, message := range formatMessages {
if message.Role == "system" {
@@ -276,16 +205,7 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
if isFirstMessage {
isFirstMessage = false
if message.Role != "user" {
- claudeMessage := dto.ClaudeMessage{
- Role: "user",
- Content: []dto.ClaudeMediaMessage{
- {
- Type: "text",
- Text: kitutil.GetPointer[string]("..."),
- },
- },
- }
- claudeMessages = append(claudeMessages, claudeMessage)
+ claudeMessages = append(claudeMessages, placeholderUserMessage)
}
}
@@ -384,6 +304,9 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
}
claudeMessages = append(claudeMessages, claudeMessage)
}
+ if len(claudeMessages) == 0 && len(systemMessages) > 0 {
+ claudeMessages = append(claudeMessages, placeholderUserMessage)
+ }
if len(systemMessages) > 0 {
claudeRequest.System = systemMessages
diff --git a/relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go
index 554e70a9d5e9..78fb875bc37d 100644
--- a/relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go
+++ b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go
@@ -1,13 +1,14 @@
package oaichat
import (
+ "fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/reasonmap"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
+ sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
- "github.com/samber/lo"
)
func generateStopBlock(index int) *dto.ClaudeResponse {
@@ -25,9 +26,18 @@ func stopOpenBlocks(state *convmeta.ClaudeConvertInfo) []*dto.ClaudeResponse {
case convmeta.LastMessageTypeText, convmeta.LastMessageTypeThinking:
return []*dto.ClaudeResponse{generateStopBlock(state.Index)}
case convmeta.LastMessageTypeTools:
- responses := make([]*dto.ClaudeResponse, 0, state.ToolCallMaxIndexOffset+1)
- for offset := 0; offset <= state.ToolCallMaxIndexOffset; offset++ {
- responses = append(responses, generateStopBlock(state.ToolCallBaseIndex+offset))
+ if len(state.ToolCalls) == 0 {
+ responses := make([]*dto.ClaudeResponse, 0, state.ToolCallMaxIndexOffset+1)
+ for offset := 0; offset <= state.ToolCallMaxIndexOffset; offset++ {
+ responses = append(responses, generateStopBlock(state.ToolCallBaseIndex+offset))
+ }
+ return responses
+ }
+ responses := make([]*dto.ClaudeResponse, 0, len(state.ToolCalls))
+ for _, tool := range state.ToolCalls {
+ if tool != nil && tool.Started {
+ responses = append(responses, generateStopBlock(tool.BlockIndex))
+ }
}
return responses
default:
@@ -35,59 +45,52 @@ func stopOpenBlocks(state *convmeta.ClaudeConvertInfo) []*dto.ClaudeResponse {
}
}
-func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
- if oaiUsage == nil {
+func startPendingToolBlocks(state *convmeta.ClaudeConvertInfo) []*dto.ClaudeResponse {
+ if state == nil || state.LastMessagesType != convmeta.LastMessageTypeTools {
return nil
}
- if billingUsage := dto.CloneBillingUsage(oaiUsage.BillingUsage); billingUsage != nil && billingUsage.ClaudeUsage != nil {
- if billingUsage.Source == dto.BillingUsageSourceClaudeMessages || billingUsage.Semantic == dto.BillingUsageSemanticAnthropic {
- return billingUsage.ClaudeUsage
+ responses := make([]*dto.ClaudeResponse, 0)
+ for _, tool := range state.ToolCalls {
+ if tool == nil || tool.Started || tool.Name == "" {
+ continue
}
- }
- billingUsage := dto.NewOpenAIChatBillingUsage(oaiUsage)
- if existingBillingUsage := dto.CloneBillingUsage(oaiUsage.BillingUsage); existingBillingUsage != nil && existingBillingUsage.OpenAIUsage != nil {
- if existingBillingUsage.Source == dto.BillingUsageSourceOAIChat ||
- existingBillingUsage.Source == dto.BillingUsageSourceOAIResponses ||
- existingBillingUsage.Semantic == dto.BillingUsageSemanticOpenAI {
- billingUsage = existingBillingUsage
- }
- }
- cacheCreation5m, cacheCreation1h := NormalizeCacheCreationSplit(
- oaiUsage.PromptTokensDetails.CachedCreationTokens,
- oaiUsage.ClaudeCacheCreation5mTokens,
- oaiUsage.ClaudeCacheCreation1hTokens,
- )
- cacheCreationTokens := oaiUsage.PromptTokensDetails.CacheCreationTokensTotal()
- inputTokens := oaiUsage.PromptTokens
- if oaiUsage.PromptTokensDetails.CacheWriteTokens > 0 {
- // OpenAI native cache-write usage counts cached and cache-write tokens
- // inside prompt_tokens, while Claude semantics reports input_tokens
- // excluding both. Both counts are unadjusted prefixes and may overlap,
- // so clamp a negative remainder at zero.
- inputTokens = oaiUsage.PromptTokens - oaiUsage.PromptTokensDetails.CachedTokens - cacheCreationTokens
- if inputTokens < 0 {
- inputTokens = 0
+ if tool.ID == "" {
+ tool.ID = fmt.Sprintf("toolu_%s", kitutil.GetUUID())
}
- }
- usage := &dto.ClaudeUsage{
- InputTokens: inputTokens,
- OutputTokens: oaiUsage.CompletionTokens,
- CacheCreationInputTokens: cacheCreationTokens,
- CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens,
- BillingUsage: billingUsage,
- }
- if cacheCreation5m > 0 || cacheCreation1h > 0 {
- usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
- Ephemeral5mInputTokens: cacheCreation5m,
- Ephemeral1hInputTokens: cacheCreation1h,
+ idx := tool.BlockIndex
+ responses = append(responses, &dto.ClaudeResponse{
+ Index: &idx,
+ Type: "content_block_start",
+ ContentBlock: &dto.ClaudeMediaMessage{
+ Id: tool.ID,
+ Type: "tool_use",
+ Name: tool.Name,
+ Input: map[string]interface{}{},
+ },
+ })
+ tool.Started = true
+ if tool.PendingArguments != "" {
+ arguments := tool.PendingArguments
+ responses = append(responses, &dto.ClaudeResponse{
+ Index: &idx,
+ Type: "content_block_delta",
+ Delta: &dto.ClaudeMediaMessage{
+ Type: "input_json_delta",
+ PartialJson: &arguments,
+ },
+ })
+ tool.PendingArguments = ""
}
}
- return usage
+ return responses
+}
+
+func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
+ return sharedclaude.UsageFromOpenAI(oaiUsage)
}
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
- remainder := lo.Max([]int{totalTokens - tokens5m - tokens1h, 0})
- return tokens5m + remainder, tokens1h
+ return sharedclaude.NormalizeCacheCreationSplit(totalTokens, tokens5m, tokens1h)
}
func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info convmeta.Meta) []*dto.ClaudeResponse {
@@ -108,6 +111,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
// For tools, OpenAI tool_calls can stream multiple parallel tool_use blocks (indexed from 0),
// so we may have multiple open blocks and must stop each one explicitly.
appendStopOpenBlocks := func() {
+ claudeResponses = append(claudeResponses, startPendingToolBlocks(state)...)
claudeResponses = append(claudeResponses, stopOpenBlocks(state)...)
}
// stopOpenBlocksAndAdvance closes the currently open block(s) and advances the content block index
@@ -122,14 +126,47 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
appendStopOpenBlocks()
switch state.LastMessagesType {
case convmeta.LastMessageTypeTools:
- state.Index = state.ToolCallBaseIndex + state.ToolCallMaxIndexOffset + 1
+ state.Index = state.ToolCallBaseIndex + len(state.ToolCalls)
state.ToolCallBaseIndex = 0
state.ToolCallMaxIndexOffset = 0
+ state.ToolCalls = nil
+ state.ToolCallByIndex = nil
+ state.ToolCallByID = nil
default:
state.Index++
}
state.LastMessagesType = convmeta.LastMessageTypeNone
}
+ appendCitationDeltas := func(raw []byte) {
+ citations := chatAnnotationsToClaude(raw, "")
+ if len(citations) == 0 {
+ return
+ }
+ if state.LastMessagesType != convmeta.LastMessageTypeText {
+ stopOpenBlocksAndAdvance()
+ idx := state.Index
+ claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
+ Index: &idx,
+ Type: "content_block_start",
+ ContentBlock: &dto.ClaudeMediaMessage{
+ Type: "text",
+ Text: kitutil.GetPointer[string](""),
+ },
+ })
+ state.LastMessagesType = convmeta.LastMessageTypeText
+ }
+ for _, citation := range citations {
+ idx := state.Index
+ claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
+ Index: &idx,
+ Type: "content_block_delta",
+ Delta: &dto.ClaudeMediaMessage{
+ Type: "citations_delta",
+ Citation: citation,
+ },
+ })
+ }
+ }
if info.GetSendResponseCount() == 1 {
msg := &dto.ClaudeMediaMessage{
Id: openAIResponse.Id,
@@ -146,128 +183,6 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
Type: "message_start",
Message: msg,
})
- //claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
- // Type: "ping",
- //})
- if openAIResponse.IsToolCall() {
- state.LastMessagesType = convmeta.LastMessageTypeTools
- state.ToolCallBaseIndex = 0
- state.ToolCallMaxIndexOffset = 0
- var toolCall dto.ToolCallResponse
- if len(openAIResponse.Choices) > 0 && len(openAIResponse.Choices[0].Delta.ToolCalls) > 0 {
- toolCall = openAIResponse.Choices[0].Delta.ToolCalls[0]
- } else {
- first := openAIResponse.GetFirstToolCall()
- if first != nil {
- toolCall = *first
- } else {
- toolCall = dto.ToolCallResponse{}
- }
- }
- resp := &dto.ClaudeResponse{
- Type: "content_block_start",
- ContentBlock: &dto.ClaudeMediaMessage{
- Id: toolCall.ID,
- Type: "tool_use",
- Name: toolCall.Function.Name,
- Input: map[string]interface{}{},
- },
- }
- resp.SetIndex(0)
- claudeResponses = append(claudeResponses, resp)
- // 首块包含工具 delta,则追加 input_json_delta
- if toolCall.Function.Arguments != "" {
- idx := 0
- claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
- Index: &idx,
- Type: "content_block_delta",
- Delta: &dto.ClaudeMediaMessage{
- Type: "input_json_delta",
- PartialJson: &toolCall.Function.Arguments,
- },
- })
- }
- } else {
-
- }
- // 判断首个响应是否存在内容(非标准的 OpenAI 响应)
- if len(openAIResponse.Choices) > 0 {
- reasoning := openAIResponse.Choices[0].Delta.GetReasoningContent()
- content := openAIResponse.Choices[0].Delta.GetContentString()
-
- if reasoning != "" {
- if state.LastMessagesType != convmeta.LastMessageTypeThinking {
- stopOpenBlocksAndAdvance()
- }
- idx := state.Index
- claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
- Index: &idx,
- Type: "content_block_start",
- ContentBlock: &dto.ClaudeMediaMessage{
- Type: "thinking",
- Thinking: kitutil.GetPointer[string](""),
- },
- })
- idx2 := idx
- claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
- Index: &idx2,
- Type: "content_block_delta",
- Delta: &dto.ClaudeMediaMessage{
- Type: "thinking_delta",
- Thinking: &reasoning,
- },
- })
- state.LastMessagesType = convmeta.LastMessageTypeThinking
- } else if content != "" {
- if state.LastMessagesType != convmeta.LastMessageTypeText {
- stopOpenBlocksAndAdvance()
- }
- idx := state.Index
- claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
- Index: &idx,
- Type: "content_block_start",
- ContentBlock: &dto.ClaudeMediaMessage{
- Type: "text",
- Text: kitutil.GetPointer[string](""),
- },
- })
- idx2 := idx
- claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
- Index: &idx2,
- Type: "content_block_delta",
- Delta: &dto.ClaudeMediaMessage{
- Type: "text_delta",
- Text: kitutil.GetPointer[string](content),
- },
- })
- state.LastMessagesType = convmeta.LastMessageTypeText
- }
- }
-
- // A first chunk can carry finish_reason before usage; defer terminal events until usage arrives.
- if len(openAIResponse.Choices) > 0 && openAIResponse.Choices[0].FinishReason != nil && *openAIResponse.Choices[0].FinishReason != "" {
- state.FinishReason = *openAIResponse.Choices[0].FinishReason
- oaiUsage := openAIResponse.Usage
- if oaiUsage == nil {
- oaiUsage = state.Usage
- }
- if oaiUsage == nil {
- return claudeResponses
- }
- appendStopOpenBlocks()
- claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
- Type: "message_delta",
- Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
- Delta: &dto.ClaudeMediaMessage{
- StopReason: kitutil.GetPointer[string](stopReasonOpenAI2Claude(state.FinishReason)),
- },
- })
- claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
- Type: "message_stop",
- })
- state.Done = true
- }
- return claudeResponses
}
if len(openAIResponse.Choices) == 0 {
@@ -300,13 +215,6 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
doneChunk := chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != ""
if doneChunk {
state.FinishReason = *chosenChoice.FinishReason
- oaiUsage := openAIResponse.Usage
- if oaiUsage == nil {
- oaiUsage = state.Usage
- // Some upstreams emit finish_reason first, then send a final usage-only chunk.
- // Defer closing until usage is available so the final message_delta carries it.
- return claudeResponses
- }
}
var claudeResponse dto.ClaudeResponse
@@ -318,38 +226,80 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
stopOpenBlocksAndAdvance()
state.ToolCallBaseIndex = state.Index
state.ToolCallMaxIndexOffset = 0
+ state.ToolCalls = nil
+ state.ToolCallByIndex = make(map[int]*convmeta.ClaudeStreamToolCall)
+ state.ToolCallByID = make(map[string]*convmeta.ClaudeStreamToolCall)
}
state.LastMessagesType = convmeta.LastMessageTypeTools
- base := state.ToolCallBaseIndex
- maxOffset := state.ToolCallMaxIndexOffset
-
+ if state.ToolCallByIndex == nil {
+ state.ToolCallByIndex = make(map[int]*convmeta.ClaudeStreamToolCall)
+ }
+ if state.ToolCallByID == nil {
+ state.ToolCallByID = make(map[string]*convmeta.ClaudeStreamToolCall)
+ }
for i, toolCall := range toolCalls {
- offset := 0
+ toolIndex := i
if toolCall.Index != nil {
- offset = *toolCall.Index
- } else {
- offset = i
+ toolIndex = *toolCall.Index
}
- if offset > maxOffset {
- maxOffset = offset
+ incomingID := strings.TrimSpace(toolCall.ID)
+ var tool *convmeta.ClaudeStreamToolCall
+ if incomingID != "" {
+ tool = state.ToolCallByID[incomingID]
+ }
+ if tool == nil {
+ tool = state.ToolCallByIndex[toolIndex]
+ }
+ if tool != nil && incomingID != "" && tool.ID != "" && tool.ID != incomingID {
+ tool = nil
+ }
+ if tool == nil {
+ tool = &convmeta.ClaudeStreamToolCall{
+ BlockIndex: state.ToolCallBaseIndex + len(state.ToolCalls),
+ }
+ state.ToolCalls = append(state.ToolCalls, tool)
+ }
+ state.ToolCallByIndex[toolIndex] = tool
+ if tool.ID == "" && incomingID != "" {
+ tool.ID = incomingID
+ state.ToolCallByID[incomingID] = tool
+ }
+ if tool.Name == "" && strings.TrimSpace(toolCall.Function.Name) != "" {
+ tool.Name = strings.TrimSpace(toolCall.Function.Name)
+ }
+ if !tool.Started {
+ tool.PendingArguments += toolCall.Function.Arguments
}
- blockIndex := base + offset
- idx := blockIndex
- if toolCall.Function.Name != "" {
+ idx := tool.BlockIndex
+ if !tool.Started && tool.ID != "" && tool.Name != "" {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
- Id: toolCall.ID,
+ Id: tool.ID,
Type: "tool_use",
- Name: toolCall.Function.Name,
+ Name: tool.Name,
Input: map[string]interface{}{},
},
})
+ tool.Started = true
+ if tool.PendingArguments != "" {
+ arguments := tool.PendingArguments
+ claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
+ Index: &idx,
+ Type: "content_block_delta",
+ Delta: &dto.ClaudeMediaMessage{
+ Type: "input_json_delta",
+ PartialJson: &arguments,
+ },
+ })
+ tool.PendingArguments = ""
+ }
+ continue
}
- if len(toolCall.Function.Arguments) > 0 {
+ if tool.Started && toolCall.Function.Arguments != "" {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
@@ -360,8 +310,10 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
})
}
}
- state.ToolCallMaxIndexOffset = maxOffset
- state.Index = base + maxOffset
+ state.ToolCallMaxIndexOffset = len(state.ToolCalls) - 1
+ if len(state.ToolCalls) > 0 {
+ state.Index = state.ToolCallBaseIndex + len(state.ToolCalls) - 1
+ }
} else {
reasoning := chosenChoice.Delta.GetReasoningContent()
textContent := chosenChoice.Delta.GetContentString()
@@ -412,22 +364,27 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
if !isEmpty && claudeResponse.Delta != nil {
claudeResponses = append(claudeResponses, &claudeResponse)
}
+ appendCitationDeltas(chosenChoice.Delta.Annotations)
if doneChunk || state.Done {
- appendStopOpenBlocks()
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = state.Usage
}
- if oaiUsage != nil {
- claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
- Type: "message_delta",
- Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
- Delta: &dto.ClaudeMediaMessage{
- StopReason: kitutil.GetPointer[string](stopReasonOpenAI2Claude(state.FinishReason)),
- },
- })
+ if oaiUsage == nil {
+ // Some upstreams emit finish_reason first, then send a final usage-only chunk.
+ // Keep content blocks open until usage is available so the terminal message_delta
+ // can carry both usage and the final stop reason.
+ return claudeResponses
}
+ appendStopOpenBlocks()
+ claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
+ Type: "message_delta",
+ Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
+ Delta: &dto.ClaudeMediaMessage{
+ StopReason: kitutil.GetPointer[string](stopReasonOpenAI2Claude(state.FinishReason)),
+ },
+ })
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_stop",
})
@@ -452,7 +409,8 @@ func FinalizeStreamResponseOpenAI2Claude(info convmeta.Meta) []*dto.ClaudeRespon
if stopReason == "" {
stopReason = "end_turn"
}
- responses := stopOpenBlocks(state)
+ responses := startPendingToolBlocks(state)
+ responses = append(responses, stopOpenBlocks(state)...)
responses = append(responses,
&dto.ClaudeResponse{
Type: "message_delta",
@@ -478,12 +436,22 @@ func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info convmeta
}
for _, choice := range openAIResponse.Choices {
stopReason = stopReasonOpenAI2Claude(choice.FinishReason)
+ reasoningContent := choice.Message.GetReasoningContent()
textContent := choice.Message.StringContent()
toolCalls := choice.Message.ParseToolCalls()
- if textContent != "" || len(toolCalls) == 0 {
+ if reasoningContent != "" {
+ claudeContent := dto.ClaudeMediaMessage{Type: "thinking"}
+ claudeContent.Thinking = kitutil.GetPointer(reasoningContent)
+ contents = append(contents, claudeContent)
+ }
+ if textContent != "" || (reasoningContent == "" && len(toolCalls) == 0) {
claudeContent := dto.ClaudeMediaMessage{}
claudeContent.Type = "text"
claudeContent.SetText(textContent)
+ citations := chatAnnotationsToClaude(choice.Message.Annotations, textContent)
+ if len(citations) > 0 {
+ claudeContent.Citations, _ = kitutil.Marshal(citations)
+ }
contents = append(contents, claudeContent)
}
for _, toolUse := range toolCalls {
diff --git a/relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go
index 1ccdd00b2562..976e72591911 100644
--- a/relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go
+++ b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go
@@ -79,6 +79,25 @@ func TestResponseOpenAI2ClaudeUsageCarriesOpenAIBillingUsage(t *testing.T) {
assert.Nil(t, resp.Usage.BillingUsage.OpenAIUsage.BillingUsage)
}
+func TestResponseOpenAI2ClaudePreservesReasoningBeforeText(t *testing.T) {
+ message := dto.Message{Role: "assistant", Content: "final answer"}
+ message.ReasoningContent = ptr("considering the request")
+ resp := ResponseOpenAI2Claude(&dto.OpenAITextResponse{
+ Id: "chatcmpl_1",
+ Model: "gpt-test",
+ Choices: []dto.OpenAITextResponseChoice{
+ {Message: message, FinishReason: "stop"},
+ },
+ }, nil)
+
+ require.Len(t, resp.Content, 2)
+ assert.Equal(t, "thinking", resp.Content[0].Type)
+ require.NotNil(t, resp.Content[0].Thinking)
+ assert.Equal(t, "considering the request", *resp.Content[0].Thinking)
+ assert.Equal(t, "text", resp.Content[1].Type)
+ assert.Equal(t, "final answer", resp.Content[1].GetText())
+}
+
func TestBuildClaudeUsageFromOpenAICacheWriteUsage(t *testing.T) {
usage := buildClaudeUsageFromOpenAIUsage(&dto.Usage{
PromptTokens: 3619,
diff --git a/relaykit/relayconvert/internal/oai_chat/to_gemini_chat_req.go b/relaykit/relayconvert/internal/oai_chat/to_gemini_chat_req.go
index 2ebb9348d4c7..3828b5fa03b8 100644
--- a/relaykit/relayconvert/internal/oai_chat/to_gemini_chat_req.go
+++ b/relaykit/relayconvert/internal/oai_chat/to_gemini_chat_req.go
@@ -3,6 +3,7 @@ package oaichat
import (
"errors"
"fmt"
+ "math"
"strings"
"context"
@@ -11,6 +12,7 @@ import (
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
sharedgemini "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/gemini"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto.GeneralOpenAIRequest, info convmeta.Meta) (*dto.GeminiChatRequest, error) {
@@ -22,13 +24,15 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
},
}
- if textRequest.TopP != nil && *textRequest.TopP > 0 {
+ if textRequest.TopP != nil {
geminiRequest.GenerationConfig.TopP = kitutil.GetPointer(*textRequest.TopP)
}
- if maxTokens := textRequest.GetMaxTokens(); maxTokens > 0 {
- geminiRequest.GenerationConfig.MaxOutputTokens = kitutil.GetPointer(maxTokens)
+ if textRequest.MaxCompletionTokens != nil {
+ geminiRequest.GenerationConfig.MaxOutputTokens = kitutil.GetPointer(*textRequest.MaxCompletionTokens)
+ } else if textRequest.MaxTokens != nil {
+ geminiRequest.GenerationConfig.MaxOutputTokens = kitutil.GetPointer(*textRequest.MaxTokens)
}
- if textRequest.Seed != nil && *textRequest.Seed != 0 {
+ if textRequest.Seed != nil {
geminiRequest.GenerationConfig.Seed = kitutil.GetPointer(int64(*textRequest.Seed))
}
@@ -50,7 +54,6 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
geminiRequest.GenerationConfig.StopSequences = stopSequences
}
- adaptorWithExtraBody := false
if len(textRequest.ExtraBody) > 0 {
var extraBody map[string]interface{}
if err := kitutil.Unmarshal(textRequest.ExtraBody, &extraBody); err != nil {
@@ -58,61 +61,47 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
}
if googleBody, ok := extraBody["google"].(map[string]interface{}); ok {
- if !strings.HasSuffix(upstreamModelName, "-nothinking") {
- adaptorWithExtraBody = true
- if _, hasErrorParam := googleBody["thinkingConfig"]; hasErrorParam {
- return nil, errors.New("extra_body.google.thinkingConfig is not supported, use extra_body.google.thinking_config instead")
- }
+ if _, hasErrorParam := googleBody["thinkingConfig"]; hasErrorParam {
+ return nil, errors.New("extra_body.google.thinkingConfig is not supported, use extra_body.google.thinking_config instead")
+ }
- if thinkingConfig, ok := googleBody["thinking_config"].(map[string]interface{}); ok {
- if _, hasErrorParam := thinkingConfig["thinkingBudget"]; hasErrorParam {
- return nil, errors.New("extra_body.google.thinking_config.thinkingBudget is not supported, use extra_body.google.thinking_config.thinking_budget instead")
- }
- var hasThinkingConfig bool
- var tempThinkingConfig dto.GeminiThinkingConfig
-
- if thinkingBudget, exists := thinkingConfig["thinking_budget"]; exists {
- switch v := thinkingBudget.(type) {
- case float64:
- budgetInt := int(v)
- tempThinkingConfig.ThinkingBudget = kitutil.GetPointer(budgetInt)
- tempThinkingConfig.IncludeThoughts = budgetInt > 0
- hasThinkingConfig = true
- default:
- return nil, errors.New("extra_body.google.thinking_config.thinking_budget must be an integer")
- }
+ if thinkingConfig, ok := googleBody["thinking_config"].(map[string]interface{}); ok {
+ if _, hasErrorParam := thinkingConfig["thinkingBudget"]; hasErrorParam {
+ return nil, errors.New("extra_body.google.thinking_config.thinkingBudget is not supported, use extra_body.google.thinking_config.thinking_budget instead")
+ }
+ var hasThinkingConfig bool
+ var tempThinkingConfig dto.GeminiThinkingConfig
+
+ if thinkingBudget, exists := thinkingConfig["thinking_budget"]; exists {
+ v, ok := thinkingBudget.(float64)
+ maxInt := int(^uint(0) >> 1)
+ if !ok || math.IsNaN(v) || math.IsInf(v, 0) || math.Trunc(v) != v || v > float64(maxInt) || v < float64(-maxInt-1) {
+ return nil, errors.New("extra_body.google.thinking_config.thinking_budget must be an integer")
}
+ budgetInt := int(v)
+ tempThinkingConfig.ThinkingBudget = kitutil.GetPointer(budgetInt)
+ hasThinkingConfig = true
+ }
- if includeThoughts, exists := thinkingConfig["include_thoughts"]; exists {
- if v, ok := includeThoughts.(bool); ok {
- tempThinkingConfig.IncludeThoughts = v
- hasThinkingConfig = true
- } else {
- return nil, errors.New("extra_body.google.thinking_config.include_thoughts must be a boolean")
- }
+ if includeThoughts, exists := thinkingConfig["include_thoughts"]; exists {
+ if v, ok := includeThoughts.(bool); ok {
+ tempThinkingConfig.IncludeThoughts = kitutil.GetPointer(v)
+ hasThinkingConfig = true
+ } else {
+ return nil, errors.New("extra_body.google.thinking_config.include_thoughts must be a boolean")
}
- if thinkingLevel, exists := thinkingConfig["thinking_level"]; exists {
- if v, ok := thinkingLevel.(string); ok {
- tempThinkingConfig.ThinkingLevel = v
- hasThinkingConfig = true
- } else {
- return nil, errors.New("extra_body.google.thinking_config.thinking_level must be a string")
- }
+ }
+ if thinkingLevel, exists := thinkingConfig["thinking_level"]; exists {
+ if v, ok := thinkingLevel.(string); ok {
+ tempThinkingConfig.ThinkingLevel = v
+ hasThinkingConfig = true
+ } else {
+ return nil, errors.New("extra_body.google.thinking_config.thinking_level must be a string")
}
+ }
- if hasThinkingConfig {
- if geminiRequest.GenerationConfig.ThinkingConfig == nil {
- geminiRequest.GenerationConfig.ThinkingConfig = &tempThinkingConfig
- } else {
- if tempThinkingConfig.ThinkingBudget != nil {
- geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = tempThinkingConfig.ThinkingBudget
- }
- geminiRequest.GenerationConfig.ThinkingConfig.IncludeThoughts = tempThinkingConfig.IncludeThoughts
- if tempThinkingConfig.ThinkingLevel != "" {
- geminiRequest.GenerationConfig.ThinkingConfig.ThinkingLevel = tempThinkingConfig.ThinkingLevel
- }
- }
- }
+ if hasThinkingConfig {
+ geminiRequest.GenerationConfig.ThinkingConfig = &tempThinkingConfig
}
}
@@ -147,8 +136,8 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
}
}
- if !adaptorWithExtraBody {
- sharedgemini.ApplyThinkingConfig(&geminiRequest, info, textRequest)
+ if err := sharedgemini.ApplyThinkingConfig(&geminiRequest, info, textRequest); err != nil {
+ return nil, reasoning.AsClientError(err)
}
var safetySettings []dto.GeminiChatSafetySettings
@@ -270,6 +259,13 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
Name: name,
Response: contentMap,
}
+ if message.ToolCallId != "" {
+ id, err := kitutil.Marshal(message.ToolCallId)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal function response ID: %w", err)
+ }
+ functionResp.ID = id
+ }
*parts = append(*parts, dto.GeminiPart{
FunctionResponse: functionResp,
@@ -293,6 +289,7 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
}
toolCall := dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
+ ID: call.ID,
FunctionName: call.Function.Name,
Arguments: args,
},
diff --git a/relaykit/relayconvert/internal/oai_chat/to_gemini_chat_resp.go b/relaykit/relayconvert/internal/oai_chat/to_gemini_chat_resp.go
index 9c54d3c0aec2..4671b2dc5027 100644
--- a/relaykit/relayconvert/internal/oai_chat/to_gemini_chat_resp.go
+++ b/relaykit/relayconvert/internal/oai_chat/to_gemini_chat_resp.go
@@ -1,11 +1,53 @@
package oaichat
import (
+ "fmt"
+ "sort"
+ "strings"
+
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
+type ChatToGeminiStreamState struct {
+ toolsByChoice map[int][]*chatToGeminiStreamTool
+ toolByIndex map[chatToGeminiStreamToolKey]*chatToGeminiStreamTool
+ toolByID map[chatToGeminiStreamToolIDKey]*chatToGeminiStreamTool
+ finishedChoices map[int]bool
+ seenChoices map[int]bool
+ usage *dto.Usage
+ usageEmitted bool
+ finalized bool
+}
+
+type chatToGeminiStreamToolKey struct {
+ ChoiceIndex int
+ ToolIndex int
+}
+
+type chatToGeminiStreamToolIDKey struct {
+ ChoiceIndex int
+ ID string
+}
+
+type chatToGeminiStreamTool struct {
+ ID string
+ Name string
+ Arguments strings.Builder
+ Emitted bool
+}
+
+func NewChatToGeminiStreamState() *ChatToGeminiStreamState {
+ return &ChatToGeminiStreamState{
+ toolsByChoice: make(map[int][]*chatToGeminiStreamTool),
+ toolByIndex: make(map[chatToGeminiStreamToolKey]*chatToGeminiStreamTool),
+ toolByID: make(map[chatToGeminiStreamToolIDKey]*chatToGeminiStreamTool),
+ finishedChoices: make(map[int]bool),
+ seenChoices: make(map[int]bool),
+ }
+}
+
// ResponseOpenAI2Gemini 将 OpenAI 响应转换为 Gemini 格式
func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info convmeta.Meta) *dto.GeminiChatResponse {
totalTokens := openAIResponse.TotalTokens
@@ -64,19 +106,11 @@ func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info convmeta
toolCalls := choice.Message.ParseToolCalls()
for _, toolCall := range toolCalls {
- var args map[string]interface{}
- if toolCall.Function.Arguments != "" {
- if err := kitutil.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
- args = map[string]interface{}{"arguments": toolCall.Function.Arguments}
- }
- } else {
- args = make(map[string]interface{})
- }
-
part := dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
+ ID: toolCall.ID,
FunctionName: toolCall.Function.Name,
- Arguments: args,
+ Arguments: geminiFunctionArguments(toolCall.Function.Arguments),
},
}
content.Parts = append(content.Parts, part)
@@ -165,20 +199,11 @@ func StreamResponseOpenAI2Gemini(openAIResponse *dto.ChatCompletionsStreamRespon
// 处理工具调用
if choice.Delta.ToolCalls != nil {
for _, toolCall := range choice.Delta.ToolCalls {
- // 解析参数
- var args map[string]interface{}
- if toolCall.Function.Arguments != "" {
- if err := kitutil.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
- args = map[string]interface{}{"arguments": toolCall.Function.Arguments}
- }
- } else {
- args = make(map[string]interface{})
- }
-
part := dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
+ ID: toolCall.ID,
FunctionName: toolCall.Function.Name,
- Arguments: args,
+ Arguments: geminiFunctionArguments(toolCall.Function.Arguments),
},
}
content.Parts = append(content.Parts, part)
@@ -201,6 +226,306 @@ func StreamResponseOpenAI2Gemini(openAIResponse *dto.ChatCompletionsStreamRespon
return geminiResponse
}
+// ConvertChunk accumulates OpenAI tool-call deltas until their choice ends.
+// Gemini functionCall parts are atomic, so emitting each OpenAI arguments
+// fragment as a separate part would create duplicate calls with invalid input.
+func (s *ChatToGeminiStreamState) ConvertChunk(openAIResponse *dto.ChatCompletionsStreamResponse, info convmeta.Meta) ([]*dto.GeminiChatResponse, error) {
+ if openAIResponse == nil {
+ return nil, nil
+ }
+ if s == nil {
+ return nil, fmt.Errorf("OpenAI chat to Gemini stream state is required")
+ }
+ if s.finalized {
+ return nil, fmt.Errorf("OpenAI chat to Gemini stream received data after finalization")
+ }
+ if s.toolsByChoice == nil {
+ s.toolsByChoice = make(map[int][]*chatToGeminiStreamTool)
+ }
+ if s.toolByIndex == nil {
+ s.toolByIndex = make(map[chatToGeminiStreamToolKey]*chatToGeminiStreamTool)
+ }
+ if s.toolByID == nil {
+ s.toolByID = make(map[chatToGeminiStreamToolIDKey]*chatToGeminiStreamTool)
+ }
+ if s.finishedChoices == nil {
+ s.finishedChoices = make(map[int]bool)
+ }
+ if s.seenChoices == nil {
+ s.seenChoices = make(map[int]bool)
+ }
+ if openAIResponse.Usage != nil {
+ s.usage = UsageFromChatUsage(openAIResponse.Usage)
+ }
+
+ candidates := make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices))
+ for _, choice := range openAIResponse.Choices {
+ s.seenChoices[choice.Index] = true
+ hasText := choice.Delta.GetContentString() != ""
+ hasToolDelta := len(choice.Delta.ToolCalls) > 0
+ hasFinish := choice.FinishReason != nil && strings.TrimSpace(*choice.FinishReason) != ""
+ if s.finishedChoices[choice.Index] {
+ if hasText || hasToolDelta {
+ return nil, fmt.Errorf("OpenAI chat choice %d received data after completion", choice.Index)
+ }
+ continue
+ }
+
+ for position, toolCall := range choice.Delta.ToolCalls {
+ if toolCall.Index == nil {
+ toolCall.SetIndex(position)
+ }
+ if err := s.appendToolCallDelta(choice.Index, toolCall); err != nil {
+ return nil, err
+ }
+ }
+
+ candidate := dto.GeminiChatCandidate{
+ Index: int64(choice.Index),
+ SafetyRatings: []dto.GeminiChatSafetyRating{},
+ Content: dto.GeminiChatContent{
+ Role: "model",
+ Parts: make([]dto.GeminiPart, 0),
+ },
+ }
+ if hasText {
+ candidate.Content.Parts = append(candidate.Content.Parts, dto.GeminiPart{Text: choice.Delta.GetContentString()})
+ }
+ if hasFinish {
+ parts, err := s.finishChoice(choice.Index)
+ if err != nil {
+ return nil, err
+ }
+ candidate.Content.Parts = append(candidate.Content.Parts, parts...)
+ finishReason := geminiFinishReason(*choice.FinishReason)
+ candidate.FinishReason = &finishReason
+ s.finishedChoices[choice.Index] = true
+ }
+ if len(candidate.Content.Parts) > 0 || candidate.FinishReason != nil {
+ candidates = append(candidates, candidate)
+ }
+ }
+
+ if len(candidates) == 0 {
+ if openAIResponse.Usage != nil && len(s.finishedChoices) > 0 {
+ s.usageEmitted = true
+ return []*dto.GeminiChatResponse{newGeminiStreamResponse(nil, s.usage, info)}, nil
+ }
+ return nil, nil
+ }
+ if openAIResponse.Usage != nil {
+ s.usageEmitted = true
+ }
+ return []*dto.GeminiChatResponse{newGeminiStreamResponse(candidates, openAIResponse.Usage, info)}, nil
+}
+
+// Finalize emits any calls left pending when an upstream closes without a
+// finish-reason chunk. Calling Finalize more than once is safe.
+func (s *ChatToGeminiStreamState) Finalize(info convmeta.Meta) ([]*dto.GeminiChatResponse, error) {
+ if s == nil || s.finalized {
+ return nil, nil
+ }
+
+ choiceIndexes := make(map[int]struct{})
+ for choiceIndex, tools := range s.toolsByChoice {
+ for _, tool := range tools {
+ if !tool.Emitted {
+ choiceIndexes[choiceIndex] = struct{}{}
+ break
+ }
+ }
+ }
+ for choiceIndex := range s.seenChoices {
+ if !s.finishedChoices[choiceIndex] {
+ choiceIndexes[choiceIndex] = struct{}{}
+ }
+ }
+ orderedChoices := make([]int, 0, len(choiceIndexes))
+ for choiceIndex := range choiceIndexes {
+ orderedChoices = append(orderedChoices, choiceIndex)
+ }
+ sort.Ints(orderedChoices)
+
+ candidates := make([]dto.GeminiChatCandidate, 0, len(orderedChoices))
+ for _, choiceIndex := range orderedChoices {
+ parts, err := s.finishChoice(choiceIndex)
+ if err != nil {
+ return nil, err
+ }
+ finishReason := "STOP"
+ candidates = append(candidates, dto.GeminiChatCandidate{
+ Index: int64(choiceIndex),
+ FinishReason: &finishReason,
+ SafetyRatings: []dto.GeminiChatSafetyRating{},
+ Content: dto.GeminiChatContent{
+ Role: "model",
+ Parts: parts,
+ },
+ })
+ }
+ if len(candidates) == 0 {
+ s.finalized = true
+ if s.usage == nil || s.usageEmitted {
+ return nil, nil
+ }
+ s.usageEmitted = true
+ return []*dto.GeminiChatResponse{newGeminiStreamResponse(nil, s.usage, info)}, nil
+ }
+ s.finalized = true
+ s.usageEmitted = s.usage != nil
+ return []*dto.GeminiChatResponse{newGeminiStreamResponse(candidates, s.usage, info)}, nil
+}
+
+func (s *ChatToGeminiStreamState) Usage() *dto.Usage {
+ if s == nil || s.usage == nil {
+ return nil
+ }
+ return UsageFromChatUsage(s.usage)
+}
+
+func (s *ChatToGeminiStreamState) SetUsage(usage *dto.Usage) {
+ if s == nil || usage == nil {
+ return
+ }
+ s.usage = UsageFromChatUsage(usage)
+}
+
+func (s *ChatToGeminiStreamState) StreamUsage() *dto.Usage {
+ return s.Usage()
+}
+
+func (s *ChatToGeminiStreamState) SetStreamUsage(usage *dto.Usage) {
+ s.SetUsage(usage)
+}
+
+func (s *ChatToGeminiStreamState) appendToolCallDelta(choiceIndex int, toolCall dto.ToolCallResponse) error {
+ toolIndex := 0
+ if toolCall.Index != nil {
+ toolIndex = *toolCall.Index
+ }
+ if toolIndex < 0 {
+ return fmt.Errorf("OpenAI chat choice %d has negative tool-call index %d", choiceIndex, toolIndex)
+ }
+ key := chatToGeminiStreamToolKey{ChoiceIndex: choiceIndex, ToolIndex: toolIndex}
+ incomingID := strings.TrimSpace(toolCall.ID)
+ var tool *chatToGeminiStreamTool
+ if incomingID != "" {
+ tool = s.toolByID[chatToGeminiStreamToolIDKey{ChoiceIndex: choiceIndex, ID: incomingID}]
+ }
+ if tool == nil {
+ tool = s.toolByIndex[key]
+ }
+ if tool != nil && incomingID != "" && tool.ID != "" && tool.ID != incomingID {
+ tool = nil
+ }
+ if tool == nil {
+ tool = &chatToGeminiStreamTool{}
+ s.toolsByChoice[choiceIndex] = append(s.toolsByChoice[choiceIndex], tool)
+ }
+ s.toolByIndex[key] = tool
+ // Compatibility gateways may reset a source index for the next occurrence.
+ // Once identity changes, keep the new occurrence active for later metadata-free deltas.
+ if tool.Emitted {
+ return fmt.Errorf("OpenAI chat choice %d tool-call index %d received data after completion", choiceIndex, toolIndex)
+ }
+
+ if incomingID != "" {
+ if tool.ID != "" && tool.ID != incomingID {
+ return fmt.Errorf("OpenAI chat choice %d tool-call index %d changed id from %q to %q", choiceIndex, toolIndex, tool.ID, incomingID)
+ }
+ tool.ID = incomingID
+ s.toolByID[chatToGeminiStreamToolIDKey{ChoiceIndex: choiceIndex, ID: incomingID}] = tool
+ }
+ incomingName := strings.TrimSpace(toolCall.Function.Name)
+ if incomingName != "" {
+ if tool.Name != "" && tool.Name != incomingName {
+ return fmt.Errorf("OpenAI chat choice %d tool-call index %d changed name from %q to %q", choiceIndex, toolIndex, tool.Name, incomingName)
+ }
+ tool.Name = incomingName
+ }
+ tool.Arguments.WriteString(toolCall.Function.Arguments)
+ return nil
+}
+
+func (s *ChatToGeminiStreamState) finishChoice(choiceIndex int) ([]dto.GeminiPart, error) {
+ tools := s.toolsByChoice[choiceIndex]
+ pending := make([]*chatToGeminiStreamTool, 0, len(tools))
+ for _, tool := range tools {
+ if !tool.Emitted {
+ pending = append(pending, tool)
+ }
+ }
+
+ parts := make([]dto.GeminiPart, 0, len(pending))
+ for _, tool := range pending {
+ if tool.Name == "" {
+ return nil, fmt.Errorf("OpenAI chat choice %d has a tool call without a function name", choiceIndex)
+ }
+ parts = append(parts, dto.GeminiPart{FunctionCall: &dto.FunctionCall{
+ ID: tool.ID,
+ FunctionName: tool.Name,
+ Arguments: geminiFunctionArguments(tool.Arguments.String()),
+ }})
+ }
+ for _, tool := range pending {
+ tool.Emitted = true
+ }
+ return parts, nil
+}
+
+func newGeminiStreamResponse(candidates []dto.GeminiChatCandidate, usage *dto.Usage, info convmeta.Meta) *dto.GeminiChatResponse {
+ if candidates == nil {
+ candidates = make([]dto.GeminiChatCandidate, 0)
+ }
+ estimatePromptTokens := 0
+ if info != nil {
+ estimatePromptTokens = info.GetEstimatePromptTokens()
+ }
+ response := &dto.GeminiChatResponse{
+ Candidates: candidates,
+ HasUsageMetadata: true,
+ UsageMetadata: dto.GeminiUsageMetadata{
+ PromptTokenCount: estimatePromptTokens,
+ TotalTokenCount: estimatePromptTokens,
+ },
+ }
+ if usage == nil {
+ return response
+ }
+ response.UsageMetadata.PromptTokenCount = usage.PromptTokens
+ response.UsageMetadata.CandidatesTokenCount = usage.CompletionTokens
+ response.UsageMetadata.TotalTokenCount = usage.TotalTokens
+ response.UsageMetadata.BillingUsage = openAIBillingUsageFromUsage(usage)
+ if metadata, ok := geminiBillingMetadataFromOpenAIUsage(usage); ok {
+ response.UsageMetadata = metadata
+ }
+ return response
+}
+
+func geminiFunctionArguments(raw string) map[string]interface{} {
+ if strings.TrimSpace(raw) == "" || strings.TrimSpace(raw) == "null" {
+ return map[string]interface{}{}
+ }
+ var args map[string]interface{}
+ if err := kitutil.Unmarshal([]byte(raw), &args); err == nil && args != nil {
+ return args
+ }
+ // Preserve historically accepted malformed/non-object input without
+ // emitting a non-object Gemini args value.
+ return map[string]interface{}{"arguments": raw}
+}
+
+func geminiFinishReason(finishReason string) string {
+ switch strings.TrimSpace(finishReason) {
+ case "length":
+ return "MAX_TOKENS"
+ case "content_filter":
+ return "SAFETY"
+ default:
+ return "STOP"
+ }
+}
+
func geminiBillingMetadataFromOpenAIUsage(usage *dto.Usage) (dto.GeminiUsageMetadata, bool) {
if usage == nil || usage.BillingUsage == nil || usage.BillingUsage.GeminiUsageMetadata == nil {
return dto.GeminiUsageMetadata{}, false
@@ -212,19 +537,22 @@ func geminiBillingMetadataFromOpenAIUsage(usage *dto.Usage) (dto.GeminiUsageMeta
if billingUsage == nil || billingUsage.GeminiUsageMetadata == nil {
return dto.GeminiUsageMetadata{}, false
}
- return *billingUsage.GeminiUsageMetadata, true
+ metadata := *billingUsage.GeminiUsageMetadata
+ // Restore the sidecar marker on the restored native payload so the next
+ // hop keeps settling on the original dialect (including Estimated).
+ metadata.BillingUsage = dto.CloneBillingUsage(usage.BillingUsage)
+ return metadata, true
}
func openAIBillingUsageFromUsage(usage *dto.Usage) *dto.BillingUsage {
if usage == nil {
return nil
}
- if existingBillingUsage := dto.CloneBillingUsage(usage.BillingUsage); existingBillingUsage != nil && existingBillingUsage.OpenAIUsage != nil {
- if existingBillingUsage.Source == dto.BillingUsageSourceOAIChat ||
- existingBillingUsage.Source == dto.BillingUsageSourceOAIResponses ||
- existingBillingUsage.Semantic == dto.BillingUsageSemanticOpenAI {
- return existingBillingUsage
- }
+ // An existing sidecar snapshots the original provider usage; carry it
+ // across this bridge unchanged regardless of its dialect. Only synthesize
+ // an OpenAI snapshot when no sidecar exists yet.
+ if existingBillingUsage := dto.CloneBillingUsage(usage.BillingUsage); existingBillingUsage != nil {
+ return existingBillingUsage
}
return dto.NewOpenAIChatBillingUsage(usage)
}
diff --git a/relaykit/relayconvert/internal/oai_chat/to_oai_responses_req.go b/relaykit/relayconvert/internal/oai_chat/to_oai_responses_req.go
index 104dfe8baab8..6bb03a1d5e11 100644
--- a/relaykit/relayconvert/internal/oai_chat/to_oai_responses_req.go
+++ b/relaykit/relayconvert/internal/oai_chat/to_oai_responses_req.go
@@ -8,6 +8,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/samber/lo"
)
@@ -358,9 +359,8 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d
textRaw := convertChatResponseFormatToResponsesText(req.ResponseFormat)
maxOutputTokens := lo.FromPtrOr(req.MaxTokens, uint(0))
- maxCompletionTokens := lo.FromPtrOr(req.MaxCompletionTokens, uint(0))
- if maxCompletionTokens > maxOutputTokens {
- maxOutputTokens = maxCompletionTokens
+ if req.MaxCompletionTokens != nil {
+ maxOutputTokens = *req.MaxCompletionTokens
}
// OpenAI Responses API rejects max_output_tokens < 16 when explicitly provided.
//if maxOutputTokens > 0 && maxOutputTokens < 16 {
@@ -412,11 +412,12 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d
out.MaxOutputTokens = lo.ToPtr(maxOutputTokens)
}
- if req.ReasoningEffort != "" {
- out.Reasoning = &dto.Reasoning{
- Effort: req.ReasoningEffort,
- Summary: "detailed",
- }
+ reasoningIntent, err := reasoning.FromOpenAIChat(req)
+ if err != nil {
+ return nil, reasoning.AsClientError(err)
+ }
+ if err := reasoning.ApplyToOpenAIResponses(out, reasoningIntent); err != nil {
+ return nil, reasoning.AsClientError(err)
}
return out, nil
diff --git a/relaykit/relayconvert/internal/oai_chat/to_oai_responses_resp.go b/relaykit/relayconvert/internal/oai_chat/to_oai_responses_resp.go
index e732819d453f..5fc35dd3ae30 100644
--- a/relaykit/relayconvert/internal/oai_chat/to_oai_responses_resp.go
+++ b/relaykit/relayconvert/internal/oai_chat/to_oai_responses_resp.go
@@ -14,21 +14,22 @@ const (
chatFinishReasonLength = "length"
chatFinishReasonContentFilter = "content_filter"
- responsesEventCreated = "response.created"
- responsesEventCompleted = "response.completed"
- responsesEventIncomplete = "response.incomplete"
- responsesEventOutputTextDelta = "response.output_text.delta"
- responsesEventOutputItemAdded = "response.output_item.added"
- responsesEventOutputItemDone = "response.output_item.done"
- responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
- responsesEventFunctionArgsDone = "response.function_call_arguments.done"
- responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta"
- responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done"
- responsesOutputTypeFunctionCall = "function_call"
- responsesOutputTypeMessage = "message"
- responsesOutputTypeReasoning = "reasoning"
- responsesIncompleteReasonContentFilter = "content_filter"
- responsesIncompleteReasonMaxTokens = "max_output_tokens"
+ responsesEventCreated = "response.created"
+ responsesEventCompleted = "response.completed"
+ responsesEventIncomplete = "response.incomplete"
+ responsesEventOutputTextDelta = "response.output_text.delta"
+ responsesEventOutputTextAnnotationAdded = "response.output_text.annotation.added"
+ responsesEventOutputItemAdded = "response.output_item.added"
+ responsesEventOutputItemDone = "response.output_item.done"
+ responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
+ responsesEventFunctionArgsDone = "response.function_call_arguments.done"
+ responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta"
+ responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done"
+ responsesOutputTypeFunctionCall = "function_call"
+ responsesOutputTypeMessage = "message"
+ responsesOutputTypeReasoning = "reasoning"
+ responsesIncompleteReasonContentFilter = "content_filter"
+ responsesIncompleteReasonMaxTokens = "max_output_tokens"
)
func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id string) (*dto.OpenAIResponsesResponse, *dto.Usage, error) {
@@ -57,30 +58,34 @@ func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id
out.IncompleteDetails = details
}
- if text := choice.Message.StringContent(); text != "" {
+ if reasoning := choice.Message.GetReasoningContent(); reasoning != "" {
out.Output = append(out.Output, dto.ResponsesOutput{
- Type: responsesOutputTypeMessage,
- ID: fmt.Sprintf("%s_msg_0", id),
+ Type: responsesOutputTypeReasoning,
+ ID: fmt.Sprintf("%s_reasoning_0", id),
Status: responseOutputStatus(out),
- Role: "assistant",
- Content: []dto.ResponsesOutputContent{
+ Summary: []dto.ResponsesReasoningSummaryPart{
{
- Type: "output_text",
- Text: text,
- Annotations: []interface{}{},
+ Type: "summary_text",
+ Text: reasoning,
},
},
})
}
- if reasoning := choice.Message.GetReasoningContent(); reasoning != "" {
+ if text := choice.Message.StringContent(); text != "" {
+ annotations, err := chatAnnotationsToResponses(choice.Message.Annotations)
+ if err != nil {
+ return nil, nil, err
+ }
out.Output = append(out.Output, dto.ResponsesOutput{
- Type: responsesOutputTypeReasoning,
- ID: fmt.Sprintf("%s_reasoning_0", id),
+ Type: responsesOutputTypeMessage,
+ ID: fmt.Sprintf("%s_msg_0", id),
Status: responseOutputStatus(out),
+ Role: "assistant",
Content: []dto.ResponsesOutputContent{
{
- Type: "summary_text",
- Text: reasoning,
+ Type: "output_text",
+ Text: text,
+ Annotations: annotations,
},
},
})
@@ -97,6 +102,35 @@ func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id
return out, usage, nil
}
+func chatAnnotationsToResponses(raw []byte) ([]interface{}, error) {
+ if len(raw) == 0 {
+ return []interface{}{}, nil
+ }
+ var annotations []map[string]any
+ if err := kitutil.Unmarshal(raw, &annotations); err != nil {
+ return nil, fmt.Errorf("invalid Chat annotations: %w", err)
+ }
+ converted := make([]interface{}, 0, len(annotations))
+ for _, annotation := range annotations {
+ if strings.TrimSpace(kitutil.Interface2String(annotation["type"])) != "url_citation" {
+ converted = append(converted, annotation)
+ continue
+ }
+ citation, ok := annotation["url_citation"].(map[string]any)
+ if !ok {
+ converted = append(converted, annotation)
+ continue
+ }
+ flattened := make(map[string]any, len(citation)+1)
+ flattened["type"] = "url_citation"
+ for key, value := range citation {
+ flattened[key] = value
+ }
+ converted = append(converted, flattened)
+ }
+ return converted, nil
+}
+
func ResponsesStatusFromChatFinishReason(finishReason string) (string, *dto.IncompleteDetails) {
switch strings.TrimSpace(finishReason) {
case chatFinishReasonLength:
@@ -230,3 +264,7 @@ func responsesStreamEvent(eventType string, payload dto.ResponsesStreamResponse)
func intPtr(v int) *int {
return &v
}
+
+func stringPtr(v string) *string {
+ return &v
+}
diff --git a/relaykit/relayconvert/internal/oai_chat/to_oai_responses_resp_test.go b/relaykit/relayconvert/internal/oai_chat/to_oai_responses_resp_test.go
index a6d752c981b0..34b6a5d9d908 100644
--- a/relaykit/relayconvert/internal/oai_chat/to_oai_responses_resp_test.go
+++ b/relaykit/relayconvert/internal/oai_chat/to_oai_responses_resp_test.go
@@ -41,6 +41,27 @@ func TestChatCompletionsResponseToResponsesPreservesTextToolCallsAndUsage(t *tes
assert.Equal(t, `"{\"q\":\"x\"}"`, string(resp.Output[1].Arguments))
}
+func TestChatCompletionsResponseToResponsesEmitsReasoningSummaryBeforeText(t *testing.T) {
+ message := dto.Message{Role: "assistant", Content: "final answer"}
+ message.ReasoningContent = lo.ToPtr("thinking summary")
+ resp, _, err := ChatCompletionsResponseToResponsesResponse(&dto.OpenAITextResponse{
+ Id: "chatcmpl_1",
+ Model: "gpt-test",
+ Choices: []dto.OpenAITextResponseChoice{
+ {Message: message, FinishReason: "stop"},
+ },
+ }, "resp_1")
+ require.NoError(t, err)
+
+ require.Len(t, resp.Output, 2)
+ assert.Equal(t, responsesOutputTypeReasoning, resp.Output[0].Type)
+ require.Len(t, resp.Output[0].Summary, 1)
+ assert.Equal(t, "thinking summary", resp.Output[0].Summary[0].Text)
+ assert.Empty(t, resp.Output[0].Content)
+ assert.Equal(t, responsesOutputTypeMessage, resp.Output[1].Type)
+ assert.Equal(t, "final answer", resp.Output[1].Content[0].Text)
+}
+
func TestChatCompletionsResponseToResponsesMapsIncompleteFinishReasons(t *testing.T) {
tests := []struct {
name string
diff --git a/relaykit/relayconvert/internal/oai_chat/to_oai_responses_stream_resp.go b/relaykit/relayconvert/internal/oai_chat/to_oai_responses_stream_resp.go
index 301afc76d9fe..da9777359435 100644
--- a/relaykit/relayconvert/internal/oai_chat/to_oai_responses_stream_resp.go
+++ b/relaykit/relayconvert/internal/oai_chat/to_oai_responses_stream_resp.go
@@ -1,12 +1,14 @@
package oaichat
import (
+ "encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/QuantumNous/new-api/relaykit/dto"
+ kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
type ChatToResponsesStreamEvent struct {
@@ -20,27 +22,35 @@ type ChatToResponsesStreamState struct {
Created int64
Usage *dto.Usage
- status string
- incompleteDetails *dto.IncompleteDetails
- sentCreated bool
- textOutputIndex int
- textStarted bool
- textDone bool
- reasoningIndex int
- reasoningStarted bool
- reasoningDone bool
- finalized bool
- nextOutputIndex int
- toolsByIndex map[int]*chatToResponsesStreamTool
- outputOrder []chatToResponsesOutputRef
- text strings.Builder
- reasoning strings.Builder
+ // EmitSequenceNumber enables the required sequence_number field for current
+ // Responses API SSE consumers while preserving the legacy relaykit default.
+ EmitSequenceNumber bool
+
+ status string
+ incompleteDetails *dto.IncompleteDetails
+ sentCreated bool
+ textOutputIndex int
+ textStarted bool
+ textDone bool
+ reasoningIndex int
+ reasoningStarted bool
+ reasoningDone bool
+ finalized bool
+ nextSequenceNumber int
+ nextOutputIndex int
+ toolsByIndex map[int]*chatToResponsesStreamTool
+ hostedByID map[string]*chatToResponsesHostedTool
+ outputOrder []chatToResponsesOutputRef
+ text strings.Builder
+ annotations []interface{}
+ reasoning strings.Builder
}
type chatToResponsesStreamTool struct {
ChatIndex int
OutputIndex int
- ID string
+ ItemID string
+ CallID string
Name string
Arguments strings.Builder
Done bool
@@ -49,6 +59,34 @@ type chatToResponsesStreamTool struct {
type chatToResponsesOutputRef struct {
Kind string
ToolIndex int
+ HostedID string
+}
+
+// HostedToolStreamStart describes a provider-hosted tool call that is already
+// being executed upstream. It is intentionally separate from function calls:
+// hosted calls have their own Responses lifecycle and result fields.
+type HostedToolStreamStart struct {
+ Type string
+ ID string
+ Name string
+ Action []byte
+ Caller []byte
+ ServerLabel string
+}
+
+// HostedToolStreamResult completes a previously started hosted tool call.
+type HostedToolStreamResult struct {
+ Type string
+ ID string
+ Result []byte
+ ErrorCode string
+ IsError bool
+}
+
+type chatToResponsesHostedTool struct {
+ OutputIndex int
+ Output dto.ResponsesOutput
+ Done bool
}
func NewChatToResponsesStreamState(id string, model string) *ChatToResponsesStreamState {
@@ -61,7 +99,211 @@ func NewChatToResponsesStreamState(id string, model string) *ChatToResponsesStre
textOutputIndex: -1,
reasoningIndex: -1,
toolsByIndex: make(map[int]*chatToResponsesStreamTool),
+ hostedByID: make(map[string]*chatToResponsesHostedTool),
+ }
+}
+
+func (s *ChatToResponsesStreamState) StreamUsage() *dto.Usage {
+ if s == nil {
+ return nil
+ }
+ return s.Usage
+}
+
+func (s *ChatToResponsesStreamState) SetStreamUsage(usage *dto.Usage) {
+ if s != nil && usage != nil {
+ s.Usage = UsageFromChatUsage(usage)
+ }
+}
+
+func (s *ChatToResponsesStreamState) StartHostedTool(start HostedToolStreamStart) ([]ChatToResponsesStreamEvent, error) {
+ if s == nil {
+ return nil, fmt.Errorf("Chat-to-Responses stream state is required")
+ }
+ start.ID = strings.TrimSpace(start.ID)
+ if start.ID == "" {
+ return nil, fmt.Errorf("hosted-tool stream call is missing an id")
+ }
+ if _, exists := s.hostedByID[start.ID]; exists {
+ return nil, fmt.Errorf("duplicate hosted-tool stream call id %q", start.ID)
+ }
+ if hostedEventPrefix(start.Type) == "" {
+ return nil, fmt.Errorf("unsupported Responses hosted-tool output type %q", start.Type)
+ }
+ caller := strings.TrimSpace(string(start.Caller))
+ if caller != "" && caller != "null" {
+ return nil, fmt.Errorf("Responses %s cannot preserve Claude hosted-tool caller provenance", start.Type)
+ }
+
+ tool := &chatToResponsesHostedTool{
+ Output: dto.ResponsesOutput{
+ Type: start.Type,
+ ID: start.ID,
+ Status: "in_progress",
+ },
+ }
+ switch start.Type {
+ case "web_search_call":
+ action, err := dto.NormalizeResponsesWebSearchAction(start.Action)
+ if err != nil {
+ return nil, err
+ }
+ tool.Output.Action = action
+ case "code_interpreter_call":
+ return nil, fmt.Errorf("cannot map provider code execution to Responses code_interpreter_call without a container_id")
+ case "mcp_call":
+ if strings.TrimSpace(start.Name) == "" || strings.TrimSpace(start.ServerLabel) == "" {
+ return nil, fmt.Errorf("Responses MCP call requires name and server_label")
+ }
+ arguments, err := hostedJSONString(start.Action)
+ if err != nil {
+ return nil, fmt.Errorf("encode Responses MCP arguments: %w", err)
+ }
+ tool.Output.Name = start.Name
+ tool.Output.ServerLabel = start.ServerLabel
+ tool.Output.Arguments = arguments
+ }
+ outputIndex := s.nextHostedIndex(start.ID)
+ tool.OutputIndex = outputIndex
+ s.hostedByID[start.ID] = tool
+
+ events := s.ensureCreated()
+ addedItem := cloneHostedOutput(&tool.Output)
+ if start.Type == "mcp_call" {
+ addedItem.Arguments = json.RawMessage(`""`)
+ }
+ events = append(events,
+ s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
+ OutputIndex: intPtr(outputIndex),
+ ItemID: start.ID,
+ Item: addedItem,
+ }),
+ s.event(hostedEventPrefix(start.Type)+".in_progress", dto.ResponsesStreamResponse{
+ OutputIndex: intPtr(outputIndex),
+ ItemID: start.ID,
+ }),
+ )
+ if start.Type == "web_search_call" {
+ events = append(events, s.event(hostedEventPrefix(start.Type)+".searching", dto.ResponsesStreamResponse{
+ OutputIndex: intPtr(outputIndex),
+ ItemID: start.ID,
+ }))
+ }
+ if start.Type == "mcp_call" {
+ arguments := dto.ResponsesArgumentsString(tool.Output.Arguments)
+ events = append(events,
+ s.event("response.mcp_call_arguments.delta", dto.ResponsesStreamResponse{
+ OutputIndex: intPtr(outputIndex),
+ ItemID: start.ID,
+ Delta: arguments,
+ }),
+ s.event("response.mcp_call_arguments.done", dto.ResponsesStreamResponse{
+ OutputIndex: intPtr(outputIndex),
+ ItemID: start.ID,
+ Arguments: kitutil.GetPointer(arguments),
+ }),
+ )
+ }
+ return events, nil
+}
+
+func (s *ChatToResponsesStreamState) CompleteHostedTool(result HostedToolStreamResult) ([]ChatToResponsesStreamEvent, error) {
+ if s == nil {
+ return nil, fmt.Errorf("Chat-to-Responses stream state is required")
+ }
+ result.ID = strings.TrimSpace(result.ID)
+ tool := s.hostedByID[result.ID]
+ if tool == nil {
+ return nil, fmt.Errorf("hosted-tool result references unknown call %q", result.ID)
+ }
+ if tool.Done {
+ return nil, fmt.Errorf("duplicate hosted-tool result for call %q", result.ID)
+ }
+ if result.Type != "" && result.Type != tool.Output.Type {
+ return nil, fmt.Errorf("hosted-tool result type %q does not match call type %q", result.Type, tool.Output.Type)
+ }
+
+ failed := result.IsError || strings.TrimSpace(result.ErrorCode) != ""
+ tool.Output.Status = "completed"
+ switch tool.Output.Type {
+ case "web_search_call":
+ // Responses exposes only the action and lifecycle status on a
+ // web_search_call. Claude's opaque result payload cannot be emitted
+ // as a top-level `results` field.
+ case "code_interpreter_call":
+ return nil, fmt.Errorf("Responses code_interpreter_call is not supported without a container_id")
+ case "mcp_call":
+ output, err := hostedResultString(result.Result)
+ if err != nil {
+ return nil, fmt.Errorf("encode Responses MCP output: %w", err)
+ }
+ tool.Output.Output = output
+ }
+ if failed {
+ tool.Output.Status = "failed"
+ errorValue := result.ErrorCode
+ if errorValue == "" {
+ errorValue = "hosted tool execution failed"
+ }
+ if tool.Output.Type == "mcp_call" {
+ encoded, err := kitutil.Marshal(errorValue)
+ if err != nil {
+ return nil, fmt.Errorf("marshal hosted-tool error: %w", err)
+ }
+ tool.Output.ItemError = encoded
+ tool.Output.Output = nil
+ }
+ }
+ tool.Done = true
+
+ events := make([]ChatToResponsesStreamEvent, 0, 2)
+ if eventType := hostedTerminalEvent(tool.Output.Type, failed); eventType != "" {
+ events = append(events, s.event(eventType, dto.ResponsesStreamResponse{
+ OutputIndex: intPtr(tool.OutputIndex),
+ ItemID: result.ID,
+ }))
+ }
+ events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
+ OutputIndex: intPtr(tool.OutputIndex),
+ ItemID: result.ID,
+ Item: cloneHostedOutput(&tool.Output),
+ }))
+ return events, nil
+}
+
+// Fail emits a terminal Responses error using the same event allocator as the
+// rest of the stream, so callers never have to append a JSON HTTP error to an
+// already-started SSE response.
+func (s *ChatToResponsesStreamState) Fail(code string, message string, param string) []ChatToResponsesStreamEvent {
+ if s == nil || s.finalized {
+ return nil
+ }
+ code = strings.TrimSpace(code)
+ if code == "" {
+ code = "server_error"
+ }
+ message = strings.TrimSpace(message)
+ if message == "" {
+ message = "upstream response stream failed"
+ }
+ s.status = "failed"
+ events := s.ensureCreated()
+ events = append(events, s.doneDeltaEvents()...)
+ s.finalized = true
+ events = append(events, s.event("error", dto.ResponsesStreamResponse{
+ Code: code,
+ Message: message,
+ Param: param,
+ }))
+ response := s.finalResponse()
+ response.Error = map[string]any{
+ "code": code,
+ "message": message,
}
+ events = append(events, s.event("response.failed", dto.ResponsesStreamResponse{
+ Response: response,
+ }))
+ return events
}
func ChatCompletionsStreamChunkToResponsesEvents(chunk *dto.ChatCompletionsStreamResponse, state *ChatToResponsesStreamState) ([]ChatToResponsesStreamEvent, error) {
@@ -81,14 +323,7 @@ func ChatCompletionsStreamChunkToResponsesEvents(chunk *dto.ChatCompletionsStrea
state.Usage = UsageFromChatUsage(chunk.Usage)
}
- events := make([]ChatToResponsesStreamEvent, 0)
- if !state.sentCreated {
- state.sentCreated = true
- events = append(events, responsesStreamEvent(responsesEventCreated, dto.ResponsesStreamResponse{
- Type: responsesEventCreated,
- Response: state.createdResponse(),
- }))
- }
+ events := state.ensureCreated()
for _, choice := range chunk.Choices {
if choice.Delta.GetReasoningContent() != "" {
events = append(events, state.appendReasoningDelta(choice.Delta.GetReasoningContent())...)
@@ -96,6 +331,13 @@ func ChatCompletionsStreamChunkToResponsesEvents(chunk *dto.ChatCompletionsStrea
if choice.Delta.GetContentString() != "" {
events = append(events, state.appendTextDelta(choice.Delta.GetContentString())...)
}
+ if len(choice.Delta.Annotations) > 0 {
+ annotationEvents, err := state.appendAnnotationDelta(choice.Delta.Annotations)
+ if err != nil {
+ return nil, err
+ }
+ events = append(events, annotationEvents...)
+ }
for _, toolCall := range choice.Delta.ToolCalls {
toolEvents, err := state.appendToolCallDelta(toolCall)
if err != nil {
@@ -111,6 +353,17 @@ func ChatCompletionsStreamChunkToResponsesEvents(chunk *dto.ChatCompletionsStrea
return events, nil
}
+func (s *ChatToResponsesStreamState) ensureCreated() []ChatToResponsesStreamEvent {
+ if s.sentCreated {
+ return nil
+ }
+ s.sentCreated = true
+ return []ChatToResponsesStreamEvent{s.event(responsesEventCreated, dto.ResponsesStreamResponse{
+ Type: responsesEventCreated,
+ Response: s.createdResponse(),
+ })}
+}
+
func FinalizeChatCompletionsStreamToResponses(state *ChatToResponsesStreamState) []ChatToResponsesStreamEvent {
if state == nil || state.finalized {
return nil
@@ -122,7 +375,7 @@ func FinalizeChatCompletionsStreamToResponses(state *ChatToResponsesStreamState)
if state.status == "incomplete" {
eventType = responsesEventIncomplete
}
- events = append(events, responsesStreamEvent(eventType, dto.ResponsesStreamResponse{
+ events = append(events, state.event(eventType, dto.ResponsesStreamResponse{
Type: eventType,
Response: resp,
}))
@@ -137,11 +390,23 @@ func (s *ChatToResponsesStreamState) UsageText() string {
}
func (s *ChatToResponsesStreamState) appendTextDelta(delta string) []ChatToResponsesStreamEvent {
- events := make([]ChatToResponsesStreamEvent, 0, 2)
+ events := s.startText()
+ s.text.WriteString(delta)
+ events = append(events, s.event(responsesEventOutputTextDelta, dto.ResponsesStreamResponse{
+ Type: responsesEventOutputTextDelta,
+ OutputIndex: intPtr(s.textOutputIndex),
+ ContentIndex: intPtr(0),
+ Delta: delta,
+ ItemID: s.messageID(),
+ }))
+ return events
+}
+
+func (s *ChatToResponsesStreamState) startText() []ChatToResponsesStreamEvent {
if !s.textStarted {
s.textStarted = true
s.textOutputIndex = s.nextIndex("message", -1)
- events = append(events, responsesStreamEvent(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
+ return []ChatToResponsesStreamEvent{s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: intPtr(s.textOutputIndex),
Item: &dto.ResponsesOutput{
@@ -151,17 +416,34 @@ func (s *ChatToResponsesStreamState) appendTextDelta(delta string) []ChatToRespo
Role: "assistant",
Content: []dto.ResponsesOutputContent{},
},
+ })}
+ }
+ return nil
+}
+
+func (s *ChatToResponsesStreamState) appendAnnotationDelta(raw []byte) ([]ChatToResponsesStreamEvent, error) {
+ annotations, err := chatAnnotationsToResponses(raw)
+ if err != nil {
+ return nil, err
+ }
+ events := s.startText()
+ for _, annotation := range annotations {
+ annotationJSON, err := kitutil.Marshal(annotation)
+ if err != nil {
+ return nil, fmt.Errorf("marshal Responses annotation: %w", err)
+ }
+ annotationIndex := len(s.annotations)
+ s.annotations = append(s.annotations, annotation)
+ events = append(events, s.event(responsesEventOutputTextAnnotationAdded, dto.ResponsesStreamResponse{
+ Type: responsesEventOutputTextAnnotationAdded,
+ OutputIndex: intPtr(s.textOutputIndex),
+ ContentIndex: intPtr(0),
+ AnnotationIndex: intPtr(annotationIndex),
+ Annotation: annotationJSON,
+ ItemID: s.messageID(),
}))
}
- s.text.WriteString(delta)
- events = append(events, responsesStreamEvent(responsesEventOutputTextDelta, dto.ResponsesStreamResponse{
- Type: responsesEventOutputTextDelta,
- OutputIndex: intPtr(s.textOutputIndex),
- ContentIndex: intPtr(0),
- Delta: delta,
- ItemID: s.messageID(),
- }))
- return events
+ return events, nil
}
func (s *ChatToResponsesStreamState) appendReasoningDelta(delta string) []ChatToResponsesStreamEvent {
@@ -169,19 +451,19 @@ func (s *ChatToResponsesStreamState) appendReasoningDelta(delta string) []ChatTo
if !s.reasoningStarted {
s.reasoningStarted = true
s.reasoningIndex = s.nextIndex("reasoning", -1)
- events = append(events, responsesStreamEvent(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
+ events = append(events, s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: intPtr(s.reasoningIndex),
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeReasoning,
ID: s.reasoningID(),
Status: "in_progress",
- Content: []dto.ResponsesOutputContent{},
+ Summary: []dto.ResponsesReasoningSummaryPart{},
},
}))
}
s.reasoning.WriteString(delta)
- events = append(events, responsesStreamEvent(responsesEventReasoningSummaryDelta, dto.ResponsesStreamResponse{
+ events = append(events, s.event(responsesEventReasoningSummaryDelta, dto.ResponsesStreamResponse{
Type: responsesEventReasoningSummaryDelta,
OutputIndex: intPtr(s.reasoningIndex),
SummaryIndex: intPtr(0),
@@ -196,45 +478,57 @@ func (s *ChatToResponsesStreamState) appendToolCallDelta(toolCall dto.ToolCallRe
if toolCall.Index != nil {
chatIndex = *toolCall.Index
}
+ incomingID := strings.TrimSpace(toolCall.ID)
tool := s.toolsByIndex[chatIndex]
events := make([]ChatToResponsesStreamEvent, 0, 2)
if tool == nil {
tool = &chatToResponsesStreamTool{
ChatIndex: chatIndex,
OutputIndex: s.nextIndex("tool", chatIndex),
- ID: strings.TrimSpace(toolCall.ID),
+ CallID: incomingID,
Name: strings.TrimSpace(toolCall.Function.Name),
}
- if tool.ID == "" {
- tool.ID = fmt.Sprintf("%s_call_%d", s.ID, chatIndex)
+ tool.ItemID = incomingID
+ if tool.ItemID == "" {
+ tool.ItemID = fmt.Sprintf("%s_call_%d", s.ID, chatIndex)
}
s.toolsByIndex[chatIndex] = tool
- events = append(events, responsesStreamEvent(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
+ events = append(events, s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: intPtr(tool.OutputIndex),
- ItemID: tool.ID,
+ ItemID: tool.ItemID,
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
- ID: tool.ID,
+ ID: tool.ItemID,
Status: "in_progress",
- CallId: tool.ID,
+ CallId: tool.callID(),
Name: tool.Name,
Arguments: []byte(`""`),
},
}))
}
- if strings.TrimSpace(toolCall.ID) != "" {
- tool.ID = strings.TrimSpace(toolCall.ID)
+ if tool.Done {
+ return nil, fmt.Errorf("tool-call stream index %d received data after completion", chatIndex)
+ }
+ if incomingID != "" {
+ if tool.CallID != "" && tool.CallID != incomingID {
+ return nil, fmt.Errorf("tool-call stream index %d changed id from %q to %q", chatIndex, tool.CallID, incomingID)
+ }
+ tool.CallID = incomingID
}
- if strings.TrimSpace(toolCall.Function.Name) != "" {
- tool.Name = strings.TrimSpace(toolCall.Function.Name)
+ incomingName := strings.TrimSpace(toolCall.Function.Name)
+ if incomingName != "" {
+ if tool.Name != "" && tool.Name != incomingName {
+ return nil, fmt.Errorf("tool-call stream index %d changed name from %q to %q", chatIndex, tool.Name, incomingName)
+ }
+ tool.Name = incomingName
}
if toolCall.Function.Arguments != "" {
tool.Arguments.WriteString(toolCall.Function.Arguments)
- events = append(events, responsesStreamEvent(responsesEventFunctionArgsDelta, dto.ResponsesStreamResponse{
+ events = append(events, s.event(responsesEventFunctionArgsDelta, dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDelta,
OutputIndex: intPtr(tool.OutputIndex),
- ItemID: tool.ID,
+ ItemID: tool.ItemID,
Delta: toolCall.Function.Arguments,
}))
}
@@ -246,13 +540,17 @@ func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEv
status := s.outputStatus()
if s.textStarted && !s.textDone {
s.textDone = true
- events = append(events, responsesStreamEvent("response.output_text.done", dto.ResponsesStreamResponse{
+ textDone := dto.ResponsesStreamResponse{
Type: "response.output_text.done",
OutputIndex: intPtr(s.textOutputIndex),
ContentIndex: intPtr(0),
ItemID: s.messageID(),
- }))
- events = append(events, responsesStreamEvent(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
+ }
+ if s.EmitSequenceNumber {
+ textDone.Text = kitutil.GetPointer(s.text.String())
+ }
+ events = append(events, s.event("response.output_text.done", textDone))
+ events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemDone,
OutputIndex: intPtr(s.textOutputIndex),
Item: s.messageOutput(status),
@@ -260,7 +558,7 @@ func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEv
}
if s.reasoningStarted && !s.reasoningDone {
s.reasoningDone = true
- events = append(events, responsesStreamEvent(responsesEventReasoningSummaryDone, dto.ResponsesStreamResponse{
+ reasoningDone := dto.ResponsesStreamResponse{
Type: responsesEventReasoningSummaryDone,
OutputIndex: intPtr(s.reasoningIndex),
SummaryIndex: intPtr(0),
@@ -269,8 +567,13 @@ func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEv
Type: "summary_text",
Text: s.reasoning.String(),
},
- }))
- events = append(events, responsesStreamEvent(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
+ }
+ if s.EmitSequenceNumber {
+ reasoningDone.Text = kitutil.GetPointer(s.reasoning.String())
+ reasoningDone.Part = nil
+ }
+ events = append(events, s.event(responsesEventReasoningSummaryDone, reasoningDone))
+ events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemDone,
OutputIndex: intPtr(s.reasoningIndex),
Item: s.reasoningOutput(status),
@@ -281,17 +584,55 @@ func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEv
continue
}
tool.Done = true
- events = append(events, responsesStreamEvent(responsesEventFunctionArgsDone, dto.ResponsesStreamResponse{
+ argumentsDone := dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDone,
OutputIndex: intPtr(tool.OutputIndex),
- ItemID: tool.ID,
- }))
- events = append(events, responsesStreamEvent(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
+ ItemID: tool.ItemID,
+ }
+ if s.EmitSequenceNumber {
+ argumentsDone.Arguments = kitutil.GetPointer(tool.Arguments.String())
+ argumentsDone.Name = tool.Name
+ }
+ events = append(events, s.event(responsesEventFunctionArgsDone, argumentsDone))
+ events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemDone,
OutputIndex: intPtr(tool.OutputIndex),
Item: s.toolOutput(tool, status),
}))
}
+ for _, ref := range s.outputOrder {
+ if ref.Kind != "hosted" {
+ continue
+ }
+ tool := s.hostedByID[ref.HostedID]
+ if tool == nil || tool.Done {
+ continue
+ }
+ if s.status != "failed" {
+ s.status = "incomplete"
+ }
+ tool.Done = true
+ tool.Output.Status = "incomplete"
+ if s.status == "failed" {
+ tool.Output.Status = "failed"
+ errorValue, err := kitutil.Marshal("provider stream failed before hosted-tool result")
+ if err == nil && tool.Output.Type == "mcp_call" {
+ tool.Output.ItemError = errorValue
+ tool.Output.Output = nil
+ }
+ if eventType := hostedTerminalEvent(tool.Output.Type, true); eventType != "" {
+ events = append(events, s.event(eventType, dto.ResponsesStreamResponse{
+ OutputIndex: intPtr(tool.OutputIndex),
+ ItemID: tool.Output.ID,
+ }))
+ }
+ }
+ events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
+ OutputIndex: intPtr(tool.OutputIndex),
+ ItemID: tool.Output.ID,
+ Item: cloneHostedOutput(&tool.Output),
+ }))
+ }
return events
}
@@ -315,6 +656,10 @@ func (s *ChatToResponsesStreamState) finalResponse() *dto.OpenAIResponsesRespons
if tool := s.toolsByIndex[ref.ToolIndex]; tool != nil {
output = append(output, *s.toolOutput(tool, status))
}
+ case "hosted":
+ if tool := s.hostedByID[ref.HostedID]; tool != nil {
+ output = append(output, *cloneHostedOutput(&tool.Output))
+ }
}
}
return &dto.OpenAIResponsesResponse{
@@ -347,6 +692,13 @@ func (s *ChatToResponsesStreamState) nextIndex(kind string, toolIndex int) int {
return index
}
+func (s *ChatToResponsesStreamState) nextHostedIndex(id string) int {
+ index := s.nextOutputIndex
+ s.nextOutputIndex++
+ s.outputOrder = append(s.outputOrder, chatToResponsesOutputRef{Kind: "hosted", HostedID: id})
+ return index
+}
+
func (s *ChatToResponsesStreamState) sortedTools() []*chatToResponsesStreamTool {
indexes := make([]int, 0, len(s.toolsByIndex))
for index := range s.toolsByIndex {
@@ -361,7 +713,7 @@ func (s *ChatToResponsesStreamState) sortedTools() []*chatToResponsesStreamTool
}
func (s *ChatToResponsesStreamState) outputStatus() string {
- if s.status == "incomplete" {
+ if s.status == "incomplete" || s.status == "failed" {
return "incomplete"
}
return "completed"
@@ -376,6 +728,10 @@ func (s *ChatToResponsesStreamState) reasoningID() string {
}
func (s *ChatToResponsesStreamState) messageOutput(status string) *dto.ResponsesOutput {
+ annotations := s.annotations
+ if annotations == nil {
+ annotations = []interface{}{}
+ }
return &dto.ResponsesOutput{
Type: responsesOutputTypeMessage,
ID: s.messageID(),
@@ -385,7 +741,7 @@ func (s *ChatToResponsesStreamState) messageOutput(status string) *dto.Responses
{
Type: "output_text",
Text: s.text.String(),
- Annotations: []interface{}{},
+ Annotations: annotations,
},
},
}
@@ -396,7 +752,7 @@ func (s *ChatToResponsesStreamState) reasoningOutput(status string) *dto.Respons
Type: responsesOutputTypeReasoning,
ID: s.reasoningID(),
Status: status,
- Content: []dto.ResponsesOutputContent{
+ Summary: []dto.ResponsesReasoningSummaryPart{
{
Type: "summary_text",
Text: s.reasoning.String(),
@@ -408,10 +764,99 @@ func (s *ChatToResponsesStreamState) reasoningOutput(status string) *dto.Respons
func (s *ChatToResponsesStreamState) toolOutput(tool *chatToResponsesStreamTool, status string) *dto.ResponsesOutput {
return &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
- ID: tool.ID,
+ ID: tool.ItemID,
Status: status,
- CallId: tool.ID,
+ CallId: tool.callID(),
Name: tool.Name,
Arguments: chatArgumentsRawMessage(tool.Arguments.String()),
}
}
+
+func (t *chatToResponsesStreamTool) callID() string {
+ if t == nil {
+ return ""
+ }
+ if t.CallID == "" {
+ return t.ItemID
+ }
+ return t.CallID
+}
+
+func hostedEventPrefix(outputType string) string {
+ switch outputType {
+ case "web_search_call":
+ return "response.web_search_call"
+ case "mcp_call":
+ return "response.mcp_call"
+ default:
+ return ""
+ }
+}
+
+func hostedTerminalEvent(outputType string, failed bool) string {
+ prefix := hostedEventPrefix(outputType)
+ if prefix == "" {
+ return ""
+ }
+ if !failed {
+ return prefix + ".completed"
+ }
+ // OpenAI currently defines a dedicated failed lifecycle event for MCP.
+ // Web search and code interpreter surface failure on output_item.done.
+ if outputType == "mcp_call" {
+ return prefix + ".failed"
+ }
+ return ""
+}
+
+func hostedJSONString(value []byte) (json.RawMessage, error) {
+ if len(value) == 0 {
+ return json.RawMessage(`""`), nil
+ }
+ if !json.Valid(value) {
+ return nil, fmt.Errorf("invalid JSON payload")
+ }
+ encoded, err := kitutil.Marshal(string(value))
+ if err != nil {
+ return nil, err
+ }
+ return encoded, nil
+}
+
+func hostedResultString(value []byte) (json.RawMessage, error) {
+ if len(value) == 0 {
+ return json.RawMessage(`""`), nil
+ }
+ if !json.Valid(value) {
+ return nil, fmt.Errorf("invalid JSON payload")
+ }
+ if kitutil.GetJsonType(value) == "string" {
+ return append(json.RawMessage(nil), value...), nil
+ }
+ return hostedJSONString(value)
+}
+
+func cloneHostedOutput(output *dto.ResponsesOutput) *dto.ResponsesOutput {
+ if output == nil {
+ return nil
+ }
+ clone := *output
+ clone.Action = append([]byte(nil), output.Action...)
+ clone.Arguments = append([]byte(nil), output.Arguments...)
+ clone.Code = append([]byte(nil), output.Code...)
+ clone.Results = append([]byte(nil), output.Results...)
+ clone.Outputs = append([]byte(nil), output.Outputs...)
+ clone.Output = append([]byte(nil), output.Output...)
+ clone.ItemError = append([]byte(nil), output.ItemError...)
+ clone.Caller = append([]byte(nil), output.Caller...)
+ return &clone
+}
+
+func (s *ChatToResponsesStreamState) event(eventType string, payload dto.ResponsesStreamResponse) ChatToResponsesStreamEvent {
+ if s.EmitSequenceNumber {
+ sequenceNumber := s.nextSequenceNumber
+ s.nextSequenceNumber++
+ payload.SequenceNumber = &sequenceNumber
+ }
+ return responsesStreamEvent(eventType, payload)
+}
diff --git a/relaykit/relayconvert/internal/oai_responses/to_claude_messages_req.go b/relaykit/relayconvert/internal/oai_responses/to_claude_messages_req.go
index 3695449f0ab3..a9116a0f2199 100644
--- a/relaykit/relayconvert/internal/oai_responses/to_claude_messages_req.go
+++ b/relaykit/relayconvert/internal/oai_responses/to_claude_messages_req.go
@@ -1,15 +1,16 @@
package oairesponses
import (
+ "context"
"fmt"
"strings"
- "context"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
func convertOpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Meta, request any) (any, error) {
@@ -40,13 +41,6 @@ func OpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Met
if req.MaxOutputTokens != nil && *req.MaxOutputTokens > 0 {
claudeRequest.MaxTokens = kitutil.GetPointer(*req.MaxOutputTokens)
}
- if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens == 0 {
- if defaultMaxTokens, configured := convmeta.OptionsOf(info).Claude.DefaultMaxTokensFor(req.Model); configured {
- value := uint(defaultMaxTokens)
- claudeRequest.MaxTokens = &value
- }
- }
-
functions, err := RequestFunctionDeclarations(req.Tools)
if err != nil {
return nil, err
@@ -62,7 +56,19 @@ func OpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Met
if toolChoice != nil || RawJSONPresent(req.ParallelToolCalls) {
claudeRequest.ToolChoice = sharedclaude.MapOpenAIToolChoice(toolChoice, ParallelToolCalls(req.ParallelToolCalls))
}
- applyResponsesReasoningToClaude(req, claudeRequest)
+ sourceReasoning, err := reasoning.FromOpenAIResponses(req)
+ if err != nil {
+ return nil, reasoning.AsClientError(err)
+ }
+ if err := sharedclaude.ApplyReasoning(claudeRequest, info, sourceReasoning); err != nil {
+ return nil, reasoning.AsClientError(err)
+ }
+ if claudeRequest.MaxTokens == nil {
+ if defaultMaxTokens, configured := convmeta.OptionsOf(info).Claude.DefaultMaxTokensFor(claudeRequest.Model); configured {
+ value := uint(defaultMaxTokens)
+ claudeRequest.MaxTokens = &value
+ }
+ }
systemMessages := make([]dto.ClaudeMediaMessage, 0)
if RawJSONPresent(req.Instructions) {
@@ -92,13 +98,21 @@ func OpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Met
case ResponsesInputTypeFunctionCallOutput, ResponsesInputTypeCustomToolOutput:
claudeRequest.Messages = appendClaudeToolResult(claudeRequest.Messages, responsesFunctionOutputItemToClaudeToolResult(item))
default:
- role := responsesClaudeRole(item)
+ sourceRole := strings.TrimSpace(kitutil.Interface2String(item["role"]))
+ role := responsesClaudeRole(sourceRole)
parts, err := responsesInputContentToClaudeMediaMessages(c, item["content"])
if err != nil {
return nil, err
}
+ if sourceRole == "" && len(parts) == 0 {
+ continue
+ }
if role == "system" {
- systemMessages = append(systemMessages, parts...)
+ for _, part := range parts {
+ if part.Type == "text" {
+ systemMessages = append(systemMessages, part)
+ }
+ }
continue
}
if len(parts) == 0 {
@@ -119,7 +133,9 @@ func OpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Met
if len(systemMessages) > 0 {
claudeRequest.System = systemMessages
}
- claudeRequest.Messages = ensureClaudeMessagesStartWithUser(claudeRequest.Messages)
+ if len(claudeRequest.Messages) > 0 || len(systemMessages) > 0 {
+ claudeRequest.Messages = ensureClaudeMessagesStartWithUser(claudeRequest.Messages)
+ }
// Checked last so every injection path has had its chance to satisfy the
// required field.
if claudeRequest.MaxTokens == nil {
@@ -140,27 +156,6 @@ func responsesFunctionDeclarationsToClaudeTools(functions []dto.FunctionRequest)
return tools
}
-func applyResponsesReasoningToClaude(req *dto.OpenAIResponsesRequest, claudeRequest *dto.ClaudeRequest) {
- effort := ReasoningEffort(req)
- switch effort {
- case "low":
- claudeRequest.Thinking = &dto.Thinking{
- Type: "enabled",
- BudgetTokens: kitutil.GetPointer(1280),
- }
- case "medium":
- claudeRequest.Thinking = &dto.Thinking{
- Type: "enabled",
- BudgetTokens: kitutil.GetPointer(2048),
- }
- case "high":
- claudeRequest.Thinking = &dto.Thinking{
- Type: "enabled",
- BudgetTokens: kitutil.GetPointer(4096),
- }
- }
-}
-
func responsesInputContentToClaudeMediaMessages(c context.Context, content any) ([]dto.ClaudeMediaMessage, error) {
contentParts, err := ContentParts(content)
if err != nil {
@@ -280,8 +275,8 @@ func claudeMessageContentParts(content any) []dto.ClaudeMediaMessage {
}
}
-func responsesClaudeRole(item map[string]any) string {
- switch strings.TrimSpace(kitutil.Interface2String(item["role"])) {
+func responsesClaudeRole(role string) string {
+ switch role {
case "assistant":
return "assistant"
case "system", "developer":
@@ -292,7 +287,7 @@ func responsesClaudeRole(item map[string]any) string {
}
func ensureClaudeMessagesStartWithUser(messages []dto.ClaudeMessage) []dto.ClaudeMessage {
- if len(messages) == 0 || messages[0].Role == "user" {
+ if len(messages) > 0 && messages[0].Role == "user" {
return messages
}
return append([]dto.ClaudeMessage{
diff --git a/relaykit/relayconvert/internal/oai_responses/to_claude_messages_resp.go b/relaykit/relayconvert/internal/oai_responses/to_claude_messages_resp.go
new file mode 100644
index 000000000000..51482ab1880b
--- /dev/null
+++ b/relaykit/relayconvert/internal/oai_responses/to_claude_messages_resp.go
@@ -0,0 +1,156 @@
+package oairesponses
+
+import (
+ "encoding/json"
+ "errors"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/relaykit/reasonmap"
+ sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
+ kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+)
+
+func ResponsesResponseToClaudeMessagesResponse(resp *dto.OpenAIResponsesResponse) (*dto.ClaudeResponse, *dto.Usage, error) {
+ if resp == nil {
+ return nil, nil, errors.New("response is nil")
+ }
+
+ usage := UsageFromResponsesUsage(resp.Usage)
+ claudeResponse := &dto.ClaudeResponse{
+ Id: resp.ID,
+ Type: "message",
+ Role: "assistant",
+ Model: resp.Model,
+ Usage: sharedclaude.UsageFromOpenAI(usage),
+ }
+ sawToolCall := false
+ for index := range resp.Output {
+ output := resp.Output[index]
+ if output.Type == responsesOutputTypeMessage && output.Role != "" && output.Role != "assistant" {
+ continue
+ }
+ switch output.Type {
+ case responsesOutputTypeReasoning:
+ if thinking := reasoningOutputText(&output); thinking != "" {
+ claudeResponse.Content = append(claudeResponse.Content, dto.ClaudeMediaMessage{
+ Type: "thinking",
+ Thinking: kitutil.GetPointer(thinking),
+ })
+ }
+ case responsesOutputTypeMessage:
+ for _, content := range output.Content {
+ if content.Type != "output_text" {
+ continue
+ }
+ block := dto.ClaudeMediaMessage{Type: "text", Text: kitutil.GetPointer(content.Text)}
+ if citations := responsesAnnotationsToClaude(content.Annotations, content.Text); len(citations) > 0 {
+ block.Citations, _ = kitutil.Marshal(citations)
+ }
+ claudeResponse.Content = append(claudeResponse.Content, block)
+ }
+ case responsesOutputTypeFunctionCall, responsesOutputTypeCustomToolCall:
+ sawToolCall = true
+ callID := strings.TrimSpace(output.CallId)
+ if callID == "" {
+ callID = strings.TrimSpace(output.ID)
+ }
+ claudeResponse.Content = append(claudeResponse.Content, dto.ClaudeMediaMessage{
+ Type: "tool_use",
+ Id: callID,
+ Name: output.Name,
+ Input: responsesArgumentsToClaudeInput(output.ArgumentsString()),
+ })
+ }
+ }
+ if len(claudeResponse.Content) == 0 {
+ claudeResponse.Content = []dto.ClaudeMediaMessage{{Type: "text", Text: kitutil.GetPointer("")}}
+ }
+ claudeResponse.StopReason = responsesClaudeStopReason(resp, sawToolCall)
+ return claudeResponse, usage, nil
+}
+
+func responsesArgumentsToClaudeInput(arguments string) map[string]any {
+ input := make(map[string]any)
+ if strings.TrimSpace(arguments) == "" {
+ return input
+ }
+ if err := kitutil.Unmarshal([]byte(arguments), &input); err == nil && input != nil {
+ return input
+ }
+ return map[string]any{"input": arguments}
+}
+
+func responsesClaudeStopReason(resp *dto.OpenAIResponsesResponse, sawToolCall bool) string {
+ if finishReason, ok := ResponsesFinishReasonFromStatus(resp); ok {
+ return reasonmap.OpenAIFinishReasonToClaudeStopReason(finishReason)
+ }
+ if sawToolCall {
+ return "tool_use"
+ }
+ return "end_turn"
+}
+
+func responsesAnnotationsToClaude(annotations []interface{}, text string) []json.RawMessage {
+ citations := make([]json.RawMessage, 0, len(annotations))
+ for _, rawAnnotation := range annotations {
+ annotation, err := kitutil.Any2Type[map[string]any](rawAnnotation)
+ if err != nil || strings.TrimSpace(kitutil.Interface2String(annotation["type"])) != "url_citation" {
+ continue
+ }
+ citation := annotation
+ if nested, ok := annotation["url_citation"].(map[string]any); ok {
+ citation = nested
+ }
+ url := strings.TrimSpace(kitutil.Interface2String(citation["url"]))
+ if url == "" {
+ continue
+ }
+ converted := map[string]any{
+ "type": "web_search_result_location",
+ "url": url,
+ "title": strings.TrimSpace(kitutil.Interface2String(citation["title"])),
+ }
+ if citedText := kitutil.Interface2String(citation["cited_text"]); citedText != "" {
+ converted["cited_text"] = citedText
+ } else if citedText := responsesCitedText(text, citation); citedText != "" {
+ converted["cited_text"] = citedText
+ }
+ if encryptedIndex := kitutil.Interface2String(citation["encrypted_index"]); encryptedIndex != "" {
+ converted["encrypted_index"] = encryptedIndex
+ }
+ if converted["title"] == "" {
+ delete(converted, "title")
+ }
+ encoded, err := kitutil.Marshal(converted)
+ if err == nil {
+ citations = append(citations, encoded)
+ }
+ }
+ return citations
+}
+
+func responsesCitedText(text string, citation map[string]any) string {
+ start, startOK := responsesAnnotationIndex(citation["start_index"])
+ end, endOK := responsesAnnotationIndex(citation["end_index"])
+ if !startOK || !endOK || start < 0 || end <= start || end > utf8.RuneCountInString(text) {
+ return ""
+ }
+ runes := []rune(text)
+ return string(runes[start:end])
+}
+
+func responsesAnnotationIndex(value any) (int, bool) {
+ switch number := value.(type) {
+ case float64:
+ return int(number), number >= 0 && number == float64(int(number))
+ case int:
+ return number, number >= 0
+ case json.Number:
+ parsed, err := number.Int64()
+ return int(parsed), err == nil && parsed >= 0
+ default:
+ return 0, false
+ }
+}
diff --git a/relaykit/relayconvert/internal/oai_responses/to_claude_messages_stream_resp.go b/relaykit/relayconvert/internal/oai_responses/to_claude_messages_stream_resp.go
new file mode 100644
index 000000000000..7721bd80bee1
--- /dev/null
+++ b/relaykit/relayconvert/internal/oai_responses/to_claude_messages_stream_resp.go
@@ -0,0 +1,500 @@
+package oairesponses
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
+ kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+)
+
+const responsesEventOutputTextDone = "response.output_text.done"
+
+type ResponsesToClaudeStreamState struct {
+ ID string
+ Model string
+ Usage *dto.Usage
+
+ sentMessageStart bool
+ done bool
+ sawToolCall bool
+ nextBlockIndex int
+ blocks []*responsesClaudeStreamBlock
+ byOutputIndex map[int]*responsesClaudeStreamBlock
+ byItemID map[string]*responsesClaudeStreamBlock
+ lastByKind map[string]*responsesClaudeStreamBlock
+ usageText strings.Builder
+}
+
+type responsesClaudeStreamBlock struct {
+ Index int
+ Kind string
+ ItemID string
+ CallID string
+ Name string
+ Started bool
+ Stopped bool
+ Value strings.Builder
+ SentBytes int
+ AnnotationCount int
+ NeedsReasoningBreak bool
+}
+
+func NewResponsesToClaudeStreamState(id string, model string) *ResponsesToClaudeStreamState {
+ return &ResponsesToClaudeStreamState{
+ ID: strings.TrimSpace(id),
+ Model: strings.TrimSpace(model),
+ byOutputIndex: make(map[int]*responsesClaudeStreamBlock),
+ byItemID: make(map[string]*responsesClaudeStreamBlock),
+ lastByKind: make(map[string]*responsesClaudeStreamBlock),
+ }
+}
+
+func (s *ResponsesToClaudeStreamState) UsageText() string {
+ if s == nil {
+ return ""
+ }
+ return s.usageText.String()
+}
+
+func (s *ResponsesToClaudeStreamState) Done() bool {
+ return s != nil && s.done
+}
+
+func (s *ResponsesToClaudeStreamState) SetUsage(usage *dto.Usage) {
+ if s != nil && usage != nil {
+ s.Usage = usage
+ }
+}
+
+func (s *ResponsesToClaudeStreamState) StreamUsage() *dto.Usage {
+ if s == nil {
+ return nil
+ }
+ return s.Usage
+}
+
+func (s *ResponsesToClaudeStreamState) SetStreamUsage(usage *dto.Usage) {
+ s.SetUsage(usage)
+}
+
+func (s *ResponsesToClaudeStreamState) ConvertChunk(event *dto.ResponsesStreamResponse, estimatedInputTokens int) ([]*dto.ClaudeResponse, *dto.Usage, error) {
+ if s == nil {
+ return nil, nil, nil
+ }
+ if event == nil || s.done {
+ return nil, s.Usage, nil
+ }
+
+ s.applyResponseMetadata(event.Response)
+ switch event.Type {
+ case responsesEventCreated:
+ return s.ensureMessageStart(estimatedInputTokens), s.Usage, nil
+ case responsesEventReasoningSummaryDelta, responsesEventReasoningTextDelta:
+ block, err := s.ensureBlock(event, "thinking")
+ if err != nil {
+ return nil, s.Usage, err
+ }
+ delta := event.Delta
+ if block.NeedsReasoningBreak && delta != "" {
+ delta = separatedResponsesDelta(delta)
+ block.NeedsReasoningBreak = false
+ }
+ return s.appendDelta(block, delta, estimatedInputTokens), s.Usage, nil
+ case responsesEventReasoningSummaryDone, responsesEventReasoningTextDone:
+ block, err := s.ensureBlock(event, "thinking")
+ if err != nil {
+ return nil, s.Usage, err
+ }
+ var responses []*dto.ClaudeResponse
+ if event.Text != nil {
+ responses = append(responses, s.mergeFinalValue(block, *event.Text, estimatedInputTokens)...)
+ }
+ if block.Value.Len() > 0 {
+ block.NeedsReasoningBreak = true
+ }
+ return responses, s.Usage, nil
+ case responsesEventOutputTextDelta:
+ block, err := s.ensureBlock(event, "text")
+ if err != nil {
+ return nil, s.Usage, err
+ }
+ return s.appendDelta(block, event.Delta, estimatedInputTokens), s.Usage, nil
+ case responsesEventOutputTextDone:
+ block, err := s.ensureBlock(event, "text")
+ if err != nil {
+ return nil, s.Usage, err
+ }
+ if event.Text == nil {
+ return nil, s.Usage, nil
+ }
+ return s.mergeFinalValue(block, *event.Text, estimatedInputTokens), s.Usage, nil
+ case responsesEventOutputTextAnnotationAdded:
+ block, err := s.ensureBlock(event, "text")
+ if err != nil {
+ return nil, s.Usage, err
+ }
+ var annotation any
+ if err := kitutil.Unmarshal(event.Annotation, &annotation); err != nil {
+ return nil, s.Usage, fmt.Errorf("invalid Responses stream annotation: %w", err)
+ }
+ return s.appendAnnotations(block, []any{annotation}, estimatedInputTokens, false), s.Usage, nil
+ case responsesEventOutputItemAdded, responsesEventOutputItemDone:
+ responses, err := s.applyOutputItem(event, estimatedInputTokens, event.Type == responsesEventOutputItemDone)
+ return responses, s.Usage, err
+ case responsesEventFunctionArgsDelta, responsesEventCustomToolInputDelta:
+ block, err := s.ensureBlock(event, "tool_use")
+ if err != nil {
+ return nil, s.Usage, err
+ }
+ return s.appendDelta(block, event.Delta, estimatedInputTokens), s.Usage, nil
+ case responsesEventFunctionArgsDone, responsesEventCustomToolInputDone:
+ block, err := s.ensureBlock(event, "tool_use")
+ if err != nil {
+ return nil, s.Usage, err
+ }
+ if event.Arguments == nil {
+ return nil, s.Usage, nil
+ }
+ return s.mergeFinalValue(block, *event.Arguments, estimatedInputTokens), s.Usage, nil
+ case responsesEventCompleted, responsesEventDone, responsesEventIncomplete:
+ responses, err := s.finish(event.Response, estimatedInputTokens)
+ return responses, s.Usage, err
+ case responsesEventFailed, responsesEventError:
+ message := strings.TrimSpace(event.Message)
+ if message == "" {
+ message = event.Type
+ }
+ return nil, s.Usage, fmt.Errorf("responses stream error: %s", message)
+ default:
+ return nil, s.Usage, nil
+ }
+}
+
+func (s *ResponsesToClaudeStreamState) Finalize(estimatedInputTokens int) ([]*dto.ClaudeResponse, error) {
+ if s == nil || s.done {
+ return nil, nil
+ }
+ return s.finish(nil, estimatedInputTokens)
+}
+
+func (s *ResponsesToClaudeStreamState) applyResponseMetadata(response *dto.OpenAIResponsesResponse) {
+ if s == nil || response == nil {
+ return
+ }
+ if response.ID != "" {
+ s.ID = response.ID
+ }
+ if response.Model != "" {
+ s.Model = response.Model
+ }
+ if response.Usage != nil {
+ s.Usage = dto.MergeUsageNonZero(s.Usage, UsageFromResponsesUsage(response.Usage))
+ }
+}
+
+func (s *ResponsesToClaudeStreamState) ensureMessageStart(estimatedInputTokens int) []*dto.ClaudeResponse {
+ if s.sentMessageStart {
+ return nil
+ }
+ s.sentMessageStart = true
+ inputTokens := estimatedInputTokens
+ if s.Usage != nil {
+ if usage := sharedclaude.UsageFromOpenAI(s.Usage); usage != nil {
+ inputTokens = usage.InputTokens
+ }
+ }
+ message := &dto.ClaudeMediaMessage{
+ Id: s.ID,
+ Type: "message",
+ Role: "assistant",
+ Model: s.Model,
+ Usage: &dto.ClaudeUsage{InputTokens: inputTokens},
+ }
+ message.SetContent(make([]any, 0))
+ return []*dto.ClaudeResponse{{Type: "message_start", Message: message}}
+}
+
+func (s *ResponsesToClaudeStreamState) ensureBlock(event *dto.ResponsesStreamResponse, kind string) (*responsesClaudeStreamBlock, error) {
+ block := s.findBlock(event)
+ if block == nil {
+ if last := s.lastByKind[kind]; last != nil && !last.Stopped && event.OutputIndex == nil && responseStreamEventItemID(event) == "" {
+ block = last
+ }
+ }
+ if block == nil {
+ block = &responsesClaudeStreamBlock{Index: s.nextBlockIndex, Kind: kind}
+ s.nextBlockIndex++
+ s.blocks = append(s.blocks, block)
+ }
+ if block.Kind == "" {
+ block.Kind = kind
+ }
+ if block.Kind != kind {
+ return nil, fmt.Errorf("Responses output item changed from %s to %s", block.Kind, kind)
+ }
+ s.applyBlockMetadata(block, event)
+ s.lastByKind[kind] = block
+ return block, nil
+}
+
+func (s *ResponsesToClaudeStreamState) findBlock(event *dto.ResponsesStreamResponse) *responsesClaudeStreamBlock {
+ if event == nil {
+ return nil
+ }
+ if event.OutputIndex != nil {
+ if block := s.byOutputIndex[*event.OutputIndex]; block != nil {
+ return block
+ }
+ }
+ if itemID := responseStreamEventItemID(event); itemID != "" {
+ return s.byItemID[itemID]
+ }
+ return nil
+}
+
+func (s *ResponsesToClaudeStreamState) applyBlockMetadata(block *responsesClaudeStreamBlock, event *dto.ResponsesStreamResponse) {
+ if block == nil || event == nil {
+ return
+ }
+ if event.OutputIndex != nil {
+ s.byOutputIndex[*event.OutputIndex] = block
+ }
+ if itemID := responseStreamEventItemID(event); itemID != "" {
+ block.ItemID = itemID
+ s.byItemID[itemID] = block
+ }
+ if event.Item == nil {
+ return
+ }
+ if callID := strings.TrimSpace(event.Item.CallId); callID != "" {
+ block.CallID = callID
+ } else if block.CallID == "" {
+ block.CallID = strings.TrimSpace(event.Item.ID)
+ }
+ if name := strings.TrimSpace(event.Item.Name); name != "" {
+ block.Name = name
+ }
+}
+
+func (s *ResponsesToClaudeStreamState) startBlock(block *responsesClaudeStreamBlock, estimatedInputTokens int) []*dto.ClaudeResponse {
+ if block == nil || block.Started || block.Stopped {
+ return nil
+ }
+ var content dto.ClaudeMediaMessage
+ switch block.Kind {
+ case "text":
+ content = dto.ClaudeMediaMessage{Type: "text", Text: kitutil.GetPointer("")}
+ case "thinking":
+ content = dto.ClaudeMediaMessage{Type: "thinking", Thinking: kitutil.GetPointer("")}
+ case "tool_use":
+ if block.Name == "" {
+ return nil
+ }
+ callID := block.CallID
+ if callID == "" {
+ callID = block.ItemID
+ }
+ content = dto.ClaudeMediaMessage{Type: "tool_use", Id: callID, Name: block.Name, Input: map[string]any{}}
+ s.sawToolCall = true
+ default:
+ return nil
+ }
+ block.Started = true
+ responses := s.ensureMessageStart(estimatedInputTokens)
+ index := block.Index
+ responses = append(responses, &dto.ClaudeResponse{Type: "content_block_start", Index: &index, ContentBlock: &content})
+ return responses
+}
+
+func (s *ResponsesToClaudeStreamState) appendDelta(block *responsesClaudeStreamBlock, delta string, estimatedInputTokens int) []*dto.ClaudeResponse {
+ if block == nil || block.Stopped || delta == "" {
+ return nil
+ }
+ block.Value.WriteString(delta)
+ return s.flushBlock(block, estimatedInputTokens)
+}
+
+func (s *ResponsesToClaudeStreamState) mergeFinalValue(block *responsesClaudeStreamBlock, finalValue string, estimatedInputTokens int) []*dto.ClaudeResponse {
+ if block == nil || block.Stopped {
+ return nil
+ }
+ current := block.Value.String()
+ if current == "" {
+ block.Value.WriteString(finalValue)
+ } else if strings.HasPrefix(finalValue, current) {
+ block.Value.WriteString(finalValue[len(current):])
+ }
+ return s.flushBlock(block, estimatedInputTokens)
+}
+
+func (s *ResponsesToClaudeStreamState) flushBlock(block *responsesClaudeStreamBlock, estimatedInputTokens int) []*dto.ClaudeResponse {
+ if block == nil || block.Stopped {
+ return nil
+ }
+ responses := s.startBlock(block, estimatedInputTokens)
+ if !block.Started {
+ return responses
+ }
+ value := block.Value.String()
+ if block.SentBytes >= len(value) {
+ return responses
+ }
+ delta := value[block.SentBytes:]
+ block.SentBytes = len(value)
+ s.usageText.WriteString(delta)
+ index := block.Index
+ media := &dto.ClaudeMediaMessage{}
+ switch block.Kind {
+ case "text":
+ media.Type = "text_delta"
+ media.Text = &delta
+ case "thinking":
+ media.Type = "thinking_delta"
+ media.Thinking = &delta
+ case "tool_use":
+ media.Type = "input_json_delta"
+ media.PartialJson = &delta
+ }
+ responses = append(responses, &dto.ClaudeResponse{Type: "content_block_delta", Index: &index, Delta: media})
+ return responses
+}
+
+func (s *ResponsesToClaudeStreamState) stopBlock(block *responsesClaudeStreamBlock, estimatedInputTokens int) []*dto.ClaudeResponse {
+ if block == nil || block.Stopped {
+ return nil
+ }
+ responses := s.flushBlock(block, estimatedInputTokens)
+ responses = append(responses, s.startBlock(block, estimatedInputTokens)...)
+ if !block.Started {
+ return responses
+ }
+ block.Stopped = true
+ index := block.Index
+ return append(responses, &dto.ClaudeResponse{Type: "content_block_stop", Index: &index})
+}
+
+func (s *ResponsesToClaudeStreamState) applyOutputItem(event *dto.ResponsesStreamResponse, estimatedInputTokens int, stop bool) ([]*dto.ClaudeResponse, error) {
+ if event == nil || event.Item == nil {
+ return nil, nil
+ }
+ item := event.Item
+ var kind string
+ switch item.Type {
+ case responsesOutputTypeReasoning:
+ kind = "thinking"
+ case responsesOutputTypeMessage:
+ if item.Role != "" && item.Role != "assistant" {
+ return nil, nil
+ }
+ kind = "text"
+ case responsesOutputTypeFunctionCall, responsesOutputTypeCustomToolCall:
+ kind = "tool_use"
+ default:
+ return nil, nil
+ }
+ block, err := s.ensureBlock(event, kind)
+ if err != nil {
+ return nil, err
+ }
+ var responses []*dto.ClaudeResponse
+ switch kind {
+ case "thinking":
+ responses = append(responses, s.mergeFinalValue(block, reasoningOutputText(item), estimatedInputTokens)...)
+ case "text":
+ var text strings.Builder
+ var annotations []any
+ for _, content := range item.Content {
+ if content.Type != "output_text" {
+ continue
+ }
+ text.WriteString(content.Text)
+ annotations = append(annotations, content.Annotations...)
+ }
+ responses = append(responses, s.mergeFinalValue(block, text.String(), estimatedInputTokens)...)
+ responses = append(responses, s.appendAnnotations(block, annotations, estimatedInputTokens, true)...)
+ case "tool_use":
+ responses = append(responses, s.mergeFinalValue(block, item.ArgumentsString(), estimatedInputTokens)...)
+ }
+ if stop {
+ responses = append(responses, s.stopBlock(block, estimatedInputTokens)...)
+ }
+ return responses, nil
+}
+
+func (s *ResponsesToClaudeStreamState) appendAnnotations(block *responsesClaudeStreamBlock, annotations []any, estimatedInputTokens int, snapshot bool) []*dto.ClaudeResponse {
+ if block == nil || block.Kind != "text" || block.Stopped || len(annotations) == 0 {
+ return nil
+ }
+ remaining := annotations
+ if snapshot {
+ if len(annotations) <= block.AnnotationCount {
+ return nil
+ }
+ remaining = annotations[block.AnnotationCount:]
+ block.AnnotationCount = len(annotations)
+ } else {
+ block.AnnotationCount += len(annotations)
+ }
+ citations := responsesAnnotationsToClaude(remaining, block.Value.String())
+ if len(citations) == 0 {
+ return nil
+ }
+ responses := s.startBlock(block, estimatedInputTokens)
+ index := block.Index
+ for _, citation := range citations {
+ responses = append(responses, &dto.ClaudeResponse{
+ Type: "content_block_delta",
+ Index: &index,
+ Delta: &dto.ClaudeMediaMessage{Type: "citations_delta", Citation: citation},
+ })
+ }
+ return responses
+}
+
+func (s *ResponsesToClaudeStreamState) finish(response *dto.OpenAIResponsesResponse, estimatedInputTokens int) ([]*dto.ClaudeResponse, error) {
+ if s.done {
+ return nil, nil
+ }
+ s.applyResponseMetadata(response)
+ responses := make([]*dto.ClaudeResponse, 0)
+ if response != nil {
+ for outputIndex := range response.Output {
+ index := outputIndex
+ item := response.Output[outputIndex]
+ event := &dto.ResponsesStreamResponse{OutputIndex: &index, ItemID: item.ID, Item: &item}
+ itemResponses, err := s.applyOutputItem(event, estimatedInputTokens, true)
+ if err != nil {
+ return nil, err
+ }
+ responses = append(responses, itemResponses...)
+ }
+ }
+ for _, block := range s.blocks {
+ responses = append(responses, s.stopBlock(block, estimatedInputTokens)...)
+ }
+ responses = append(responses, s.ensureMessageStart(estimatedInputTokens)...)
+ stopReason := responsesClaudeStopReason(response, s.sawToolCall)
+ usage := sharedclaude.UsageFromOpenAI(s.Usage)
+ responses = append(responses,
+ &dto.ClaudeResponse{
+ Type: "message_delta",
+ Usage: usage,
+ Delta: &dto.ClaudeMediaMessage{StopReason: &stopReason},
+ },
+ &dto.ClaudeResponse{Type: "message_stop"},
+ )
+ s.done = true
+ return responses, nil
+}
+
+func separatedResponsesDelta(delta string) string {
+ if strings.HasPrefix(delta, "\n\n") {
+ return delta
+ }
+ if strings.HasPrefix(delta, "\n") {
+ return "\n" + delta
+ }
+ return "\n\n" + delta
+}
diff --git a/relaykit/relayconvert/internal/oai_responses/to_claude_messages_stream_resp_test.go b/relaykit/relayconvert/internal/oai_responses/to_claude_messages_stream_resp_test.go
new file mode 100644
index 000000000000..b20c9860692f
--- /dev/null
+++ b/relaykit/relayconvert/internal/oai_responses/to_claude_messages_stream_resp_test.go
@@ -0,0 +1,129 @@
+package oairesponses
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestResponsesToClaudeStreamDoesNotRepeatBlocksFromDoneAndCompletedEvents(t *testing.T) {
+ state := NewResponsesToClaudeStreamState("", "")
+ arguments := `{"q":"x"}`
+ argumentRaw, err := kitutil.Marshal(arguments)
+ require.NoError(t, err)
+ statusRaw, err := kitutil.Marshal("completed")
+ require.NoError(t, err)
+
+ reasoningItem := dto.ResponsesOutput{
+ Type: responsesOutputTypeReasoning,
+ ID: "rs_1",
+ Summary: []dto.ResponsesReasoningSummaryPart{{Type: "summary_text", Text: "plan"}},
+ }
+ messageItem := dto.ResponsesOutput{
+ Type: responsesOutputTypeMessage,
+ ID: "msg_1",
+ Role: "assistant",
+ Content: []dto.ResponsesOutputContent{{Type: "output_text", Text: "hello"}},
+ }
+ toolItem := dto.ResponsesOutput{
+ Type: responsesOutputTypeFunctionCall,
+ ID: "fc_1",
+ CallId: "call_1",
+ Name: "lookup",
+ Arguments: argumentRaw,
+ }
+
+ events := []*dto.ResponsesStreamResponse{
+ {Type: responsesEventCreated, Response: &dto.OpenAIResponsesResponse{ID: "resp_1", Model: "gpt-test"}},
+ {Type: responsesEventOutputItemAdded, OutputIndex: kitutil.GetPointer(0), ItemID: reasoningItem.ID, Item: &dto.ResponsesOutput{Type: reasoningItem.Type, ID: reasoningItem.ID}},
+ {Type: responsesEventReasoningSummaryDelta, OutputIndex: kitutil.GetPointer(0), ItemID: reasoningItem.ID, Delta: "plan"},
+ {Type: responsesEventReasoningSummaryDone, OutputIndex: kitutil.GetPointer(0), ItemID: reasoningItem.ID, Text: kitutil.GetPointer("plan")},
+ {Type: responsesEventOutputItemDone, OutputIndex: kitutil.GetPointer(0), ItemID: reasoningItem.ID, Item: &reasoningItem},
+ {Type: responsesEventOutputItemAdded, OutputIndex: kitutil.GetPointer(1), ItemID: messageItem.ID, Item: &dto.ResponsesOutput{Type: messageItem.Type, ID: messageItem.ID, Role: "assistant"}},
+ {Type: responsesEventOutputTextDelta, OutputIndex: kitutil.GetPointer(1), ItemID: messageItem.ID, Delta: "hello"},
+ {Type: responsesEventOutputTextDone, OutputIndex: kitutil.GetPointer(1), ItemID: messageItem.ID, Text: kitutil.GetPointer("hello")},
+ {Type: responsesEventOutputItemDone, OutputIndex: kitutil.GetPointer(1), ItemID: messageItem.ID, Item: &messageItem},
+ {Type: responsesEventOutputItemAdded, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Item: &dto.ResponsesOutput{Type: toolItem.Type, ID: toolItem.ID, CallId: toolItem.CallId, Name: toolItem.Name}},
+ {Type: responsesEventFunctionArgsDelta, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Delta: `{"q":`},
+ {Type: responsesEventFunctionArgsDelta, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Delta: `"x"}`},
+ {Type: responsesEventFunctionArgsDone, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Arguments: &arguments},
+ {Type: responsesEventOutputItemDone, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Item: &toolItem},
+ {
+ Type: responsesEventCompleted,
+ Response: &dto.OpenAIResponsesResponse{
+ ID: "resp_1",
+ Model: "gpt-test",
+ Status: statusRaw,
+ Output: []dto.ResponsesOutput{reasoningItem, messageItem, toolItem},
+ Usage: &dto.Usage{InputTokens: 11, OutputTokens: 7, TotalTokens: 18},
+ },
+ },
+ }
+
+ var output []*dto.ClaudeResponse
+ for _, event := range events {
+ converted, _, err := state.ConvertChunk(event, 9)
+ require.NoError(t, err)
+ output = append(output, converted...)
+ }
+
+ starts := responsesOfType(output, "content_block_start")
+ stops := responsesOfType(output, "content_block_stop")
+ require.Len(t, responsesOfType(output, "message_start"), 1)
+ require.Len(t, starts, 3)
+ require.Len(t, stops, 3)
+ require.Len(t, responsesOfType(output, "message_delta"), 1)
+ require.Len(t, responsesOfType(output, "message_stop"), 1)
+ assert.Equal(t, []int{0, 1, 2}, []int{starts[0].GetIndex(), starts[1].GetIndex(), starts[2].GetIndex()})
+ assert.Equal(t, []string{"thinking", "text", "tool_use"}, []string{starts[0].ContentBlock.Type, starts[1].ContentBlock.Type, starts[2].ContentBlock.Type})
+ assert.Equal(t, "plan", joinedClaudeDeltas(output, "thinking_delta"))
+ assert.Equal(t, "hello", joinedClaudeDeltas(output, "text_delta"))
+ assert.Equal(t, arguments, joinedClaudeDeltas(output, "input_json_delta"))
+ messageDelta := responsesOfType(output, "message_delta")[0]
+ require.NotNil(t, messageDelta.Delta.StopReason)
+ assert.Equal(t, "tool_use", *messageDelta.Delta.StopReason)
+
+ finalized, err := state.Finalize(9)
+ require.NoError(t, err)
+ assert.Empty(t, finalized)
+ repeated, _, err := state.ConvertChunk(events[len(events)-1], 9)
+ require.NoError(t, err)
+ assert.Empty(t, repeated)
+}
+
+func responsesOfType(responses []*dto.ClaudeResponse, responseType string) []*dto.ClaudeResponse {
+ filtered := make([]*dto.ClaudeResponse, 0)
+ for _, response := range responses {
+ if response != nil && response.Type == responseType {
+ filtered = append(filtered, response)
+ }
+ }
+ return filtered
+}
+
+func joinedClaudeDeltas(responses []*dto.ClaudeResponse, deltaType string) string {
+ result := ""
+ for _, response := range responses {
+ if response == nil || response.Type != "content_block_delta" || response.Delta == nil || response.Delta.Type != deltaType {
+ continue
+ }
+ switch deltaType {
+ case "thinking_delta":
+ if response.Delta.Thinking != nil {
+ result += *response.Delta.Thinking
+ }
+ case "text_delta":
+ if response.Delta.Text != nil {
+ result += *response.Delta.Text
+ }
+ case "input_json_delta":
+ if response.Delta.PartialJson != nil {
+ result += *response.Delta.PartialJson
+ }
+ }
+ }
+ return result
+}
diff --git a/relaykit/relayconvert/internal/oai_responses/to_gemini_chat_req.go b/relaykit/relayconvert/internal/oai_responses/to_gemini_chat_req.go
index 18ddceedba36..41fc03fc0151 100644
--- a/relaykit/relayconvert/internal/oai_responses/to_gemini_chat_req.go
+++ b/relaykit/relayconvert/internal/oai_responses/to_gemini_chat_req.go
@@ -10,6 +10,7 @@ import (
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
sharedgemini "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/gemini"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
func convertOpenAIResponsesRequestToGeminiChat(c context.Context, info convmeta.Meta, request any) (any, error) {
@@ -42,10 +43,10 @@ func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIRespon
Temperature: req.Temperature,
},
}
- if req.TopP != nil && *req.TopP > 0 {
+ if req.TopP != nil {
geminiRequest.GenerationConfig.TopP = kitutil.GetPointer(*req.TopP)
}
- if req.MaxOutputTokens != nil && *req.MaxOutputTokens > 0 {
+ if req.MaxOutputTokens != nil {
geminiRequest.GenerationConfig.MaxOutputTokens = kitutil.GetPointer(*req.MaxOutputTokens)
}
@@ -59,11 +60,19 @@ func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIRespon
if err := applyResponsesTextToGemini(req.Text, geminiRequest); err != nil {
return nil, err
}
- sharedgemini.ApplyThinkingConfig(geminiRequest, info, dto.GeneralOpenAIRequest{
- Model: req.Model,
- MaxCompletionTokens: req.MaxOutputTokens,
- ReasoningEffort: ReasoningEffort(req),
- })
+ reasoningIntent, err := reasoning.FromOpenAIResponses(req)
+ if err != nil {
+ return nil, reasoning.AsClientError(err)
+ }
+ var reasoningPivot dto.GeneralOpenAIRequest
+ if err := reasoning.ApplyToOpenAIChat(&reasoningPivot, reasoningIntent); err != nil {
+ return nil, reasoning.AsClientError(err)
+ }
+ reasoningPivot.Model = req.Model
+ reasoningPivot.MaxCompletionTokens = req.MaxOutputTokens
+ if err := sharedgemini.ApplyThinkingConfig(geminiRequest, info, reasoningPivot); err != nil {
+ return nil, reasoning.AsClientError(err)
+ }
var safetySettings []dto.GeminiChatSafetySettings
for _, category := range sharedgemini.SafetySettingCategories {
@@ -137,7 +146,10 @@ func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIRespon
}
appendGeminiContentPart(geminiRequest, "model", part)
case ResponsesInputTypeFunctionCallOutput:
- part := responsesFunctionOutputItemToGeminiPart(item, callNames)
+ part, err := responsesFunctionOutputItemToGeminiPart(item, callNames)
+ if err != nil {
+ return nil, err
+ }
appendGeminiContentPart(geminiRequest, "user", part)
default:
role := responsesGeminiRole(item)
@@ -252,24 +264,33 @@ func responsesFunctionCallItemToGeminiPart(item map[string]any) (dto.GeminiPart,
callID := CallID(item)
return dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
+ ID: callID,
FunctionName: name,
Arguments: ObjectValue(item["arguments"], "arguments"),
},
}, callID, nil
}
-func responsesFunctionOutputItemToGeminiPart(item map[string]any, callNames map[string]string) dto.GeminiPart {
+func responsesFunctionOutputItemToGeminiPart(item map[string]any, callNames map[string]string) (dto.GeminiPart, error) {
callID := CallID(item)
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
if name == "" {
name = callNames[callID]
}
- return dto.GeminiPart{
- FunctionResponse: &dto.GeminiFunctionResponse{
- Name: name,
- Response: GeminiResponseMap(item["output"]),
- },
+ response := &dto.GeminiFunctionResponse{
+ Name: name,
+ Response: GeminiResponseMap(item["output"]),
}
+ if callID != "" {
+ id, err := kitutil.Marshal(callID)
+ if err != nil {
+ return dto.GeminiPart{}, fmt.Errorf("failed to marshal function response ID: %w", err)
+ }
+ response.ID = id
+ }
+ return dto.GeminiPart{
+ FunctionResponse: response,
+ }, nil
}
func appendGeminiContentPart(req *dto.GeminiChatRequest, role string, part dto.GeminiPart) {
diff --git a/relaykit/relayconvert/internal/oai_responses/to_oai_chat_req.go b/relaykit/relayconvert/internal/oai_responses/to_oai_chat_req.go
index 6966095ecc38..21a2de7d4f5d 100644
--- a/relaykit/relayconvert/internal/oai_responses/to_oai_chat_req.go
+++ b/relaykit/relayconvert/internal/oai_responses/to_oai_chat_req.go
@@ -8,6 +8,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
const (
@@ -85,8 +86,10 @@ func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (
return nil, fmt.Errorf("invalid presence_penalty: %w", err)
}
- if req.Reasoning != nil {
- out.ReasoningEffort = req.Reasoning.Effort
+ if reasoningIntent, err := reasoning.FromOpenAIResponses(req); err != nil {
+ return nil, reasoning.AsClientError(err)
+ } else if err := reasoning.ApplyToOpenAIChat(out, reasoningIntent); err != nil {
+ return nil, reasoning.AsClientError(err)
}
if req.ServiceTier != "" {
out.ServiceTier, _ = kitutil.Marshal(req.ServiceTier)
diff --git a/relaykit/relayconvert/internal/oai_responses/to_oai_chat_resp.go b/relaykit/relayconvert/internal/oai_responses/to_oai_chat_resp.go
index b97f9116d787..630252487f61 100644
--- a/relaykit/relayconvert/internal/oai_responses/to_oai_chat_resp.go
+++ b/relaykit/relayconvert/internal/oai_responses/to_oai_chat_resp.go
@@ -10,29 +10,30 @@ import (
)
const (
- responsesEventCreated = "response.created"
- responsesEventCompleted = "response.completed"
- responsesEventDone = "response.done"
- responsesEventIncomplete = "response.incomplete"
- responsesEventFailed = "response.failed"
- responsesEventError = "response.error"
- responsesEventOutputTextDelta = "response.output_text.delta"
- responsesEventOutputItemAdded = "response.output_item.added"
- responsesEventOutputItemDone = "response.output_item.done"
- responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
- responsesEventFunctionArgsDone = "response.function_call_arguments.done"
- responsesEventCustomToolInputDelta = "response.custom_tool_call_input.delta"
- responsesEventCustomToolInputDone = "response.custom_tool_call_input.done"
- responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta"
- responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done"
- responsesEventReasoningTextDelta = "response.reasoning_text.delta"
- responsesEventReasoningTextDone = "response.reasoning_text.done"
- responsesOutputTypeFunctionCall = "function_call"
- responsesOutputTypeCustomToolCall = "custom_tool_call"
- responsesOutputTypeMessage = "message"
- responsesOutputTypeReasoning = "reasoning"
- responsesIncompleteReasonContentFilter = "content_filter"
- responsesIncompleteReasonMaxTokens = "max_output_tokens"
+ responsesEventCreated = "response.created"
+ responsesEventCompleted = "response.completed"
+ responsesEventDone = "response.done"
+ responsesEventIncomplete = "response.incomplete"
+ responsesEventFailed = "response.failed"
+ responsesEventError = "response.error"
+ responsesEventOutputTextDelta = "response.output_text.delta"
+ responsesEventOutputTextAnnotationAdded = "response.output_text.annotation.added"
+ responsesEventOutputItemAdded = "response.output_item.added"
+ responsesEventOutputItemDone = "response.output_item.done"
+ responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
+ responsesEventFunctionArgsDone = "response.function_call_arguments.done"
+ responsesEventCustomToolInputDelta = "response.custom_tool_call_input.delta"
+ responsesEventCustomToolInputDone = "response.custom_tool_call_input.done"
+ responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta"
+ responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done"
+ responsesEventReasoningTextDelta = "response.reasoning_text.delta"
+ responsesEventReasoningTextDone = "response.reasoning_text.done"
+ responsesOutputTypeFunctionCall = "function_call"
+ responsesOutputTypeCustomToolCall = "custom_tool_call"
+ responsesOutputTypeMessage = "message"
+ responsesOutputTypeReasoning = "reasoning"
+ responsesIncompleteReasonContentFilter = "content_filter"
+ responsesIncompleteReasonMaxTokens = "max_output_tokens"
)
func ResponsesFinishReasonFromStatus(resp *dto.OpenAIResponsesResponse) (string, bool) {
@@ -103,6 +104,11 @@ func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesRespons
Role: "assistant",
Content: text,
}
+ if annotations, err := responsesAnnotationsToChat(resp); err != nil {
+ return nil, nil, err
+ } else if len(annotations) > 0 {
+ msg.Annotations = annotations
+ }
if reasoning != "" {
msg.ReasoningContent = &reasoning
}
@@ -128,7 +134,65 @@ func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesRespons
return out, usage, nil
}
+func responsesAnnotationsToChat(resp *dto.OpenAIResponsesResponse) ([]byte, error) {
+ annotations := make([]any, 0)
+ for _, output := range resp.Output {
+ if output.Type != responsesOutputTypeMessage {
+ continue
+ }
+ for _, content := range output.Content {
+ for _, annotation := range content.Annotations {
+ converted, err := responseAnnotationToChat(annotation)
+ if err != nil {
+ return nil, err
+ }
+ annotations = append(annotations, converted)
+ }
+ }
+ }
+ if len(annotations) == 0 {
+ return nil, nil
+ }
+ return kitutil.Marshal(annotations)
+}
+
+func responseAnnotationToChat(annotation any) (map[string]any, error) {
+ value, ok := annotation.(map[string]any)
+ if !ok {
+ converted, err := kitutil.Any2Type[map[string]any](annotation)
+ if err != nil {
+ return nil, fmt.Errorf("invalid Responses annotation: %w", err)
+ }
+ value = converted
+ }
+ if strings.TrimSpace(kitutil.Interface2String(value["type"])) != "url_citation" {
+ return value, nil
+ }
+ citation := make(map[string]any, len(value)-1)
+ for key, item := range value {
+ if key != "type" {
+ citation[key] = item
+ }
+ }
+ return map[string]any{
+ "type": "url_citation",
+ "url_citation": citation,
+ }, nil
+}
+
func UsageFromResponsesUsage(src *dto.Usage) *dto.Usage {
+ return usageFromResponsesUsage(src, true)
+}
+
+// NormalizeResponsesUsage maps Responses usage into the shared accounting
+// shape without creating a BillingUsage snapshot. Native Responses handlers
+// use it so passthrough traffic preserves an existing snapshot but does not
+// introduce a conversion sidecar solely for local settlement.
+func NormalizeResponsesUsage(src *dto.Usage) *dto.Usage {
+ return usageFromResponsesUsage(src, false)
+}
+
+func usageFromResponsesUsage(src *dto.Usage, createBillingSnapshot bool) *dto.Usage {
usage := &dto.Usage{}
if src == nil {
return usage
@@ -136,7 +200,7 @@ func UsageFromResponsesUsage(src *dto.Usage) *dto.Usage {
usage.UsageSemantic = src.UsageSemantic
usage.UsageSource = src.UsageSource
usage.BillingUsage = dto.CloneBillingUsage(src.BillingUsage)
- if usage.BillingUsage == nil {
+ if usage.BillingUsage == nil && createBillingSnapshot {
usage.BillingUsage = dto.NewOpenAIResponsesBillingUsage(src)
}
usage.Cost = src.Cost
@@ -190,21 +254,25 @@ func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
if out.Role != "" && out.Role != "assistant" {
continue
}
+ var outputText strings.Builder
for _, c := range out.Content {
if c.Type == "output_text" && c.Text != "" {
- sb.WriteString(c.Text)
+ outputText.WriteString(c.Text)
}
}
+ appendSeparatedText(&sb, outputText.String())
}
if sb.Len() > 0 {
return sb.String()
}
for _, out := range resp.Output {
+ var outputText strings.Builder
for _, c := range out.Content {
if c.Text != "" {
- sb.WriteString(c.Text)
+ outputText.WriteString(c.Text)
}
}
+ appendSeparatedText(&sb, outputText.String())
}
return sb.String()
}
@@ -219,15 +287,56 @@ func ExtractReasoningTextFromResponses(resp *dto.OpenAIResponsesResponse) string
if out.Type != responsesOutputTypeReasoning {
continue
}
- for _, c := range out.Content {
- if c.Text != "" {
- sb.WriteString(c.Text)
- }
- }
+ appendSeparatedText(&sb, reasoningOutputText(&out))
}
return sb.String()
}
+func reasoningOutputText(output *dto.ResponsesOutput) string {
+ if output == nil {
+ return ""
+ }
+ var text strings.Builder
+ hasContentText := false
+ for _, part := range output.Content {
+ if part.Text != "" {
+ hasContentText = true
+ break
+ }
+ }
+ if hasContentText {
+ for _, part := range output.Content {
+ appendSeparatedText(&text, part.Text)
+ }
+ return text.String()
+ }
+ for _, part := range output.Summary {
+ appendSeparatedText(&text, part.Text)
+ }
+ return text.String()
+}
+
+func appendSeparatedText(builder *strings.Builder, text string) {
+ if builder == nil || text == "" {
+ return
+ }
+ if builder.Len() > 0 {
+ current := builder.String()
+ trailingNewlines := 0
+ for index := len(current) - 1; index >= 0 && trailingNewlines < 2 && current[index] == '\n'; index-- {
+ trailingNewlines++
+ }
+ leadingNewlines := 0
+ for leadingNewlines < len(text) && leadingNewlines < 2 && text[leadingNewlines] == '\n' {
+ leadingNewlines++
+ }
+ for missing := 2 - trailingNewlines - leadingNewlines; missing > 0; missing-- {
+ builder.WriteByte('\n')
+ }
+ }
+ builder.WriteString(text)
+}
+
func responseStatusString(resp *dto.OpenAIResponsesResponse) string {
if resp == nil || len(resp.Status) == 0 {
return ""
diff --git a/relaykit/relayconvert/internal/oai_responses/to_oai_chat_resp_test.go b/relaykit/relayconvert/internal/oai_responses/to_oai_chat_resp_test.go
index 49efa07d163e..100289bb6476 100644
--- a/relaykit/relayconvert/internal/oai_responses/to_oai_chat_resp_test.go
+++ b/relaykit/relayconvert/internal/oai_responses/to_oai_chat_resp_test.go
@@ -56,9 +56,9 @@ func TestResponsesResponseToChatCompletionsPreservesReasoningSummary(t *testing.
Output: []dto.ResponsesOutput{
{
Type: responsesOutputTypeReasoning,
- Content: []dto.ResponsesOutputContent{
+ Summary: []dto.ResponsesReasoningSummaryPart{
{Type: "summary_text", Text: "first summary"},
- {Type: "summary_text", Text: "\n\nsecond summary"},
+ {Type: "summary_text", Text: "second summary"},
},
},
{
@@ -77,6 +77,20 @@ func TestResponsesResponseToChatCompletionsPreservesReasoningSummary(t *testing.
assert.Equal(t, "final", chat.Choices[0].Message.StringContent())
}
+func TestResponsesResponseToChatCompletionsSeparatesInterleavedOutputItems(t *testing.T) {
+ resp := &dto.OpenAIResponsesResponse{
+ ID: "resp_1",
+ Model: "gpt-test",
+ Status: []byte(`"completed"`),
+ Output: interleavedReasoningAndTextOutput(),
+ }
+
+ chat, _, err := ResponsesResponseToChatCompletionsResponse(resp, "chatcmpl_1")
+ require.NoError(t, err)
+ assert.Equal(t, "**Planning file inspection**\n\n**Clarifying environment task requirements**", chat.Choices[0].Message.GetReasoningContent())
+ assert.Equal(t, "I’ll inspect the starter repository.\n\nWhat would you like me to build?", chat.Choices[0].Message.StringContent())
+}
+
func TestResponsesFinishReasonFromIncompleteStatus(t *testing.T) {
tests := []struct {
name string
@@ -433,6 +447,90 @@ func TestResponsesBufferedAccumulatorDoesNotDuplicatePendingArgsWithOutputIndexA
assert.Empty(t, acc.pendingByItemID)
}
+func TestResponsesBufferedAccumulatorPreservesInterleavedReasoningAndTextItems(t *testing.T) {
+ acc := NewResponsesBufferedAccumulator()
+ events := []dto.ResponsesStreamResponse{
+ bufferedOutputItemAdded(0, "rs_1", responsesOutputTypeReasoning),
+ {Type: responsesEventReasoningSummaryDelta, OutputIndex: intPointer(0), ItemID: "rs_1", Delta: "**Planning file inspection**"},
+ bufferedOutputItemAdded(1, "msg_1", responsesOutputTypeMessage),
+ {Type: responsesEventOutputTextDelta, OutputIndex: intPointer(1), ItemID: "msg_1", Delta: "I’ll inspect the starter repository."},
+ bufferedOutputItemAdded(2, "rs_2", responsesOutputTypeReasoning),
+ {Type: responsesEventReasoningSummaryDelta, OutputIndex: intPointer(2), ItemID: "rs_2", Delta: "**Clarifying environment task requirements**"},
+ bufferedOutputItemAdded(3, "msg_2", responsesOutputTypeMessage),
+ {Type: responsesEventOutputTextDelta, OutputIndex: intPointer(3), ItemID: "msg_2", Delta: "What would you like me to build?"},
+ }
+ for index := range events {
+ acc.ProcessEvent(&events[index])
+ }
+
+ output := acc.BuildOutput()
+ require.Len(t, output, 4)
+ assert.Equal(t, []string{
+ responsesOutputTypeReasoning,
+ responsesOutputTypeMessage,
+ responsesOutputTypeReasoning,
+ responsesOutputTypeMessage,
+ }, []string{output[0].Type, output[1].Type, output[2].Type, output[3].Type})
+ assert.Equal(t, "**Planning file inspection**", output[0].Summary[0].Text)
+ assert.Equal(t, "I’ll inspect the starter repository.", output[1].Content[0].Text)
+ assert.Equal(t, "**Clarifying environment task requirements**", output[2].Summary[0].Text)
+ assert.Equal(t, "What would you like me to build?", output[3].Content[0].Text)
+}
+
+func TestResponsesStreamTerminalOutputPreservesInterleavedReasoningAndTextItems(t *testing.T) {
+ state := newTestResponsesStreamState()
+ chunks, err := ResponsesStreamEventToChatChunks(&dto.ResponsesStreamResponse{
+ Type: responsesEventCompleted,
+ Response: &dto.OpenAIResponsesResponse{
+ Status: []byte(`"completed"`),
+ Output: interleavedReasoningAndTextOutput(),
+ },
+ }, state)
+ require.NoError(t, err)
+
+ var deltas []string
+ for _, chunk := range chunks {
+ if len(chunk.Choices) == 0 {
+ continue
+ }
+ delta := chunk.Choices[0].Delta
+ if delta.ReasoningContent != nil {
+ deltas = append(deltas, "thinking:"+*delta.ReasoningContent)
+ }
+ if delta.Content != nil && *delta.Content != "" {
+ deltas = append(deltas, "text:"+*delta.Content)
+ }
+ }
+ assert.Equal(t, []string{
+ "thinking:**Planning file inspection**",
+ "text:I’ll inspect the starter repository.",
+ "thinking:**Clarifying environment task requirements**",
+ "text:What would you like me to build?",
+ }, deltas)
+}
+
+func bufferedOutputItemAdded(outputIndex int, itemID string, itemType string) dto.ResponsesStreamResponse {
+ return dto.ResponsesStreamResponse{
+ Type: responsesEventOutputItemAdded,
+ OutputIndex: &outputIndex,
+ ItemID: itemID,
+ Item: &dto.ResponsesOutput{ID: itemID, Type: itemType},
+ }
+}
+
+func interleavedReasoningAndTextOutput() []dto.ResponsesOutput {
+ return []dto.ResponsesOutput{
+ {Type: responsesOutputTypeReasoning, Summary: []dto.ResponsesReasoningSummaryPart{{Type: "summary_text", Text: "**Planning file inspection**"}}},
+ {Type: responsesOutputTypeMessage, Role: "assistant", Content: []dto.ResponsesOutputContent{{Type: "output_text", Text: "I’ll inspect the starter repository."}}},
+ {Type: responsesOutputTypeReasoning, Summary: []dto.ResponsesReasoningSummaryPart{{Type: "summary_text", Text: "**Clarifying environment task requirements**"}}},
+ {Type: responsesOutputTypeMessage, Role: "assistant", Content: []dto.ResponsesOutputContent{{Type: "output_text", Text: "What would you like me to build?"}}},
+ }
+}
+
+func intPointer(value int) *int {
+ return &value
+}
+
func newTestResponsesStreamState() *ResponsesToChatStreamState {
state := NewResponsesToChatStreamState("gpt-test", false)
state.ID = "chatcmpl_test"
diff --git a/relaykit/relayconvert/internal/oai_responses/to_oai_chat_stream_resp.go b/relaykit/relayconvert/internal/oai_responses/to_oai_chat_stream_resp.go
index 6026e3899eeb..645b169fbb06 100644
--- a/relaykit/relayconvert/internal/oai_responses/to_oai_chat_stream_resp.go
+++ b/relaykit/relayconvert/internal/oai_responses/to_oai_chat_stream_resp.go
@@ -21,6 +21,7 @@ type ResponsesToChatStreamState struct {
sentStart bool
finalized bool
hasSentText bool
+ sentAnnotationCount int
sawToolCall bool
hasSentReasoning bool
needsReasoningSummaryBreak bool
@@ -61,6 +62,19 @@ func NewResponsesToChatStreamState(model string, includeUsage bool) *ResponsesTo
}
}
+func (s *ResponsesToChatStreamState) StreamUsage() *dto.Usage {
+ if s == nil {
+ return nil
+ }
+ return s.Usage
+}
+
+func (s *ResponsesToChatStreamState) SetStreamUsage(usage *dto.Usage) {
+ if s != nil && usage != nil {
+ s.Usage = usage
+ }
+}
+
func (s *ResponsesToChatStreamState) UsageText() string {
if s == nil {
return ""
@@ -86,6 +100,8 @@ func ResponsesStreamEventToChatChunks(event *dto.ResponsesStreamResponse, state
return nil, nil
case responsesEventOutputTextDelta:
return state.textDelta(event.Delta), nil
+ case responsesEventOutputTextAnnotationAdded:
+ return state.annotationRawDelta(event.Annotation)
case responsesEventOutputItemAdded, responsesEventOutputItemDone:
if event.Item == nil || !isResponsesToolOutputType(event.Item.Type) {
return nil, nil
@@ -101,7 +117,10 @@ func ResponsesStreamEventToChatChunks(event *dto.ResponsesStreamResponse, state
response = ensureIncompleteResponse(response)
}
state.applyResponseMetadata(response)
- chunks := state.terminalOutputChunks(response)
+ chunks, err := state.terminalOutputChunks(response)
+ if err != nil {
+ return nil, err
+ }
chunks = append(chunks, state.finalize(response)...)
return chunks, nil
case responsesEventFailed, responsesEventError:
@@ -132,7 +151,7 @@ func (s *ResponsesToChatStreamState) applyResponseMetadata(response *dto.OpenAIR
s.Created = int64(response.CreatedAt)
}
if response.Usage != nil {
- s.Usage = UsageFromResponsesUsage(response.Usage)
+ s.Usage = dto.MergeUsageNonZero(s.Usage, UsageFromResponsesUsage(response.Usage))
}
}
@@ -160,16 +179,19 @@ func (s *ResponsesToChatStreamState) textDelta(delta string) []dto.ChatCompletio
return chunks
}
-func (s *ResponsesToChatStreamState) terminalOutputChunks(response *dto.OpenAIResponsesResponse) []dto.ChatCompletionsStreamResponse {
+func (s *ResponsesToChatStreamState) terminalOutputChunks(response *dto.OpenAIResponsesResponse) ([]dto.ChatCompletionsStreamResponse, error) {
if s == nil || response == nil || len(response.Output) == 0 {
- return nil
+ return nil, nil
}
var chunks []dto.ChatCompletionsStreamResponse
+ hadSentText := s.hasSentText
+ hadSentReasoning := s.hasSentReasoning
+ annotationOffset := 0
for i := range response.Output {
out := &response.Output[i]
switch {
- case out.Type == responsesOutputTypeMessage && !s.hasSentText:
+ case out.Type == responsesOutputTypeMessage && !hadSentText:
var text strings.Builder
for _, c := range out.Content {
if c.Type == "output_text" && c.Text != "" {
@@ -177,19 +199,88 @@ func (s *ResponsesToChatStreamState) terminalOutputChunks(response *dto.OpenAIRe
}
}
chunks = append(chunks, s.textDelta(text.String())...)
- case out.Type == responsesOutputTypeReasoning && !s.hasSentReasoning:
- var reasoning strings.Builder
- for _, c := range out.Content {
- if c.Text != "" {
- reasoning.WriteString(c.Text)
- }
+ annotationChunks, err := s.remainingAnnotationChunks(out, annotationOffset)
+ if err != nil {
+ return nil, err
+ }
+ chunks = append(chunks, annotationChunks...)
+ annotationOffset += responsesOutputAnnotationCount(out)
+ case out.Type == responsesOutputTypeMessage:
+ annotationChunks, err := s.remainingAnnotationChunks(out, annotationOffset)
+ if err != nil {
+ return nil, err
}
- chunks = append(chunks, s.reasoningDelta(reasoning.String())...)
+ chunks = append(chunks, annotationChunks...)
+ annotationOffset += responsesOutputAnnotationCount(out)
+ case out.Type == responsesOutputTypeReasoning && !hadSentReasoning:
+ chunks = append(chunks, s.reasoningDelta(reasoningOutputText(out))...)
case isResponsesToolOutputType(out.Type):
chunks = append(chunks, s.toolItem(&dto.ResponsesStreamResponse{Item: out})...)
}
}
- return chunks
+ return chunks, nil
+}
+
+func (s *ResponsesToChatStreamState) annotationRawDelta(raw []byte) ([]dto.ChatCompletionsStreamResponse, error) {
+ if len(raw) == 0 {
+ return nil, nil
+ }
+ var annotation map[string]any
+ if err := kitutil.Unmarshal(raw, &annotation); err != nil {
+ return nil, fmt.Errorf("invalid Responses stream annotation: %w", err)
+ }
+ return s.annotationDelta(annotation)
+}
+
+func (s *ResponsesToChatStreamState) annotationDelta(annotation any) ([]dto.ChatCompletionsStreamResponse, error) {
+ converted, err := responseAnnotationToChat(annotation)
+ if err != nil {
+ return nil, err
+ }
+ raw, err := kitutil.Marshal([]any{converted})
+ if err != nil {
+ return nil, fmt.Errorf("marshal Chat annotation: %w", err)
+ }
+ s.sentAnnotationCount++
+ chunks := s.ensureStart()
+ chunks = append(chunks, s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{
+ Annotations: raw,
+ }, nil))
+ return chunks, nil
+}
+
+func (s *ResponsesToChatStreamState) remainingAnnotationChunks(output *dto.ResponsesOutput, offset int) ([]dto.ChatCompletionsStreamResponse, error) {
+ if output == nil {
+ return nil, nil
+ }
+ annotations := make([]any, 0)
+ for _, content := range output.Content {
+ annotations = append(annotations, content.Annotations...)
+ }
+ start := s.sentAnnotationCount - offset
+ if start < 0 {
+ start = 0
+ }
+ if start >= len(annotations) {
+ return nil, nil
+ }
+ var chunks []dto.ChatCompletionsStreamResponse
+ for _, annotation := range annotations[start:] {
+ converted, err := s.annotationDelta(annotation)
+ if err != nil {
+ return nil, err
+ }
+ chunks = append(chunks, converted...)
+ }
+ return chunks, nil
+}
+
+func responsesOutputAnnotationCount(output *dto.ResponsesOutput) int {
+ count := 0
+ for _, content := range output.Content {
+ count += len(content.Annotations)
+ }
+ return count
}
func (s *ResponsesToChatStreamState) reasoningDelta(delta string) []dto.ChatCompletionsStreamResponse {
@@ -554,8 +645,10 @@ func (s *ResponsesToChatStreamState) keyForEvent(event *dto.ResponsesStreamRespo
}
type ResponsesBufferedAccumulator struct {
- text strings.Builder
- reasoning strings.Builder
+ items []*responsesBufferedItem
+ outputIndexToItemIdx map[int]int
+ itemIDToItemIdx map[string]int
+ lastUnindexedItemIdx int
tools []*responsesBufferedTool
outputIndexToToolIdx map[int]int
itemIDToToolIdx map[string]int
@@ -563,6 +656,15 @@ type ResponsesBufferedAccumulator struct {
pendingByItemID map[string]string
}
+type responsesBufferedItem struct {
+ Type string
+ ID string
+ Text strings.Builder
+ Annotations []interface{}
+ ToolIndex int
+ NeedsReasoningBreak bool
+}
+
type responsesBufferedTool struct {
CallID string
ItemID string
@@ -572,6 +674,9 @@ type responsesBufferedTool struct {
func NewResponsesBufferedAccumulator() *ResponsesBufferedAccumulator {
return &ResponsesBufferedAccumulator{
+ outputIndexToItemIdx: make(map[int]int),
+ itemIDToItemIdx: make(map[string]int),
+ lastUnindexedItemIdx: -1,
outputIndexToToolIdx: make(map[int]int),
itemIDToToolIdx: make(map[string]int),
pendingByOutputIndex: make(map[int]string),
@@ -585,11 +690,55 @@ func (a *ResponsesBufferedAccumulator) ProcessEvent(event *dto.ResponsesStreamRe
}
switch event.Type {
case responsesEventOutputTextDelta:
- a.text.WriteString(event.Delta)
+ item := a.ensureItem(event, responsesOutputTypeMessage)
+ item.Text.WriteString(event.Delta)
+ case responsesEventOutputTextAnnotationAdded:
+ item := a.ensureItem(event, responsesOutputTypeMessage)
+ var annotation interface{}
+ if err := kitutil.Unmarshal(event.Annotation, &annotation); err == nil && annotation != nil {
+ item.Annotations = append(item.Annotations, annotation)
+ }
case responsesEventReasoningSummaryDelta, responsesEventReasoningTextDelta:
- a.reasoning.WriteString(event.Delta)
+ item := a.ensureItem(event, responsesOutputTypeReasoning)
+ if item.NeedsReasoningBreak {
+ appendSeparatedText(&item.Text, event.Delta)
+ item.NeedsReasoningBreak = false
+ } else {
+ item.Text.WriteString(event.Delta)
+ }
+ case responsesEventReasoningSummaryDone, responsesEventReasoningTextDone:
+ item := a.ensureItem(event, responsesOutputTypeReasoning)
+ if item.Text.Len() == 0 && event.Text != nil {
+ item.Text.WriteString(*event.Text)
+ }
+ if item.Text.Len() > 0 {
+ item.NeedsReasoningBreak = true
+ }
case responsesEventOutputItemAdded, responsesEventOutputItemDone:
- if event.Item != nil && isResponsesToolOutputType(event.Item.Type) {
+ if event.Item == nil {
+ return
+ }
+ switch {
+ case event.Item.Type == responsesOutputTypeReasoning:
+ item := a.ensureItem(event, event.Item.Type)
+ if item.Text.Len() == 0 {
+ item.Text.WriteString(reasoningOutputText(event.Item))
+ }
+ case event.Item.Type == responsesOutputTypeMessage:
+ item := a.ensureItem(event, event.Item.Type)
+ seedText := item.Text.Len() == 0
+ seedAnnotations := len(item.Annotations) == 0
+ for _, content := range event.Item.Content {
+ if content.Type == "output_text" {
+ if seedText {
+ item.Text.WriteString(content.Text)
+ }
+ if seedAnnotations {
+ item.Annotations = append(item.Annotations, content.Annotations...)
+ }
+ }
+ }
+ case isResponsesToolOutputType(event.Item.Type):
tool := a.ensureTool(event)
if args := event.Item.ArgumentsString(); args != "" {
tool.Arguments.Reset()
@@ -620,50 +769,119 @@ func (a *ResponsesBufferedAccumulator) BuildOutput() []dto.ResponsesOutput {
if a == nil {
return nil
}
- out := make([]dto.ResponsesOutput, 0, 2+len(a.tools))
- if a.reasoning.Len() > 0 {
- out = append(out, dto.ResponsesOutput{
- Type: responsesOutputTypeReasoning,
- Content: []dto.ResponsesOutputContent{
- {Type: "summary_text", Text: a.reasoning.String()},
- },
- })
- }
- if a.text.Len() > 0 {
- out = append(out, dto.ResponsesOutput{
- Type: responsesOutputTypeMessage,
- Role: "assistant",
- Content: []dto.ResponsesOutputContent{
- {Type: "output_text", Text: a.text.String()},
- },
- })
- }
- for _, tool := range a.tools {
- if tool == nil {
+ out := make([]dto.ResponsesOutput, 0, len(a.items))
+ for _, item := range a.items {
+ if item == nil {
continue
}
- argsRaw, _ := kitutil.Marshal(tool.Arguments.String())
- out = append(out, dto.ResponsesOutput{
- Type: responsesOutputTypeFunctionCall,
- ID: tool.ItemID,
- CallId: tool.CallID,
- Name: tool.Name,
- Arguments: argsRaw,
- })
+ switch item.Type {
+ case responsesOutputTypeReasoning:
+ if item.Text.Len() == 0 {
+ continue
+ }
+ out = append(out, dto.ResponsesOutput{
+ Type: item.Type,
+ ID: item.ID,
+ Summary: []dto.ResponsesReasoningSummaryPart{
+ {Type: "summary_text", Text: item.Text.String()},
+ },
+ })
+ case responsesOutputTypeMessage:
+ if item.Text.Len() == 0 {
+ continue
+ }
+ out = append(out, dto.ResponsesOutput{
+ Type: item.Type,
+ ID: item.ID,
+ Role: "assistant",
+ Content: []dto.ResponsesOutputContent{
+ {Type: "output_text", Text: item.Text.String(), Annotations: item.Annotations},
+ },
+ })
+ case responsesOutputTypeFunctionCall, responsesOutputTypeCustomToolCall:
+ if item.ToolIndex < 0 || item.ToolIndex >= len(a.tools) || a.tools[item.ToolIndex] == nil {
+ continue
+ }
+ tool := a.tools[item.ToolIndex]
+ argsRaw, _ := kitutil.Marshal(tool.Arguments.String())
+ out = append(out, dto.ResponsesOutput{
+ Type: item.Type,
+ ID: tool.ItemID,
+ CallId: tool.CallID,
+ Name: tool.Name,
+ Arguments: argsRaw,
+ })
+ }
}
return out
}
+func (a *ResponsesBufferedAccumulator) ensureItem(event *dto.ResponsesStreamResponse, itemType string) *responsesBufferedItem {
+ if idx, ok := a.findItemIndex(event); ok {
+ item := a.items[idx]
+ if item.Type == "" {
+ item.Type = itemType
+ }
+ a.applyItemMetadata(idx, item, event)
+ return item
+ }
+ if event != nil && event.OutputIndex == nil && responseStreamEventItemID(event) == "" && a.lastUnindexedItemIdx >= 0 {
+ item := a.items[a.lastUnindexedItemIdx]
+ if item != nil && item.Type == itemType {
+ return item
+ }
+ }
+ item := &responsesBufferedItem{Type: itemType, ToolIndex: -1}
+ idx := len(a.items)
+ a.items = append(a.items, item)
+ a.lastUnindexedItemIdx = idx
+ a.applyItemMetadata(idx, item, event)
+ return item
+}
+
+func (a *ResponsesBufferedAccumulator) applyItemMetadata(idx int, item *responsesBufferedItem, event *dto.ResponsesStreamResponse) {
+ if item == nil || event == nil {
+ return
+ }
+ if event.OutputIndex != nil {
+ a.outputIndexToItemIdx[*event.OutputIndex] = idx
+ }
+ if itemID := responseStreamEventItemID(event); itemID != "" {
+ item.ID = itemID
+ a.itemIDToItemIdx[itemID] = idx
+ }
+}
+
+func (a *ResponsesBufferedAccumulator) findItemIndex(event *dto.ResponsesStreamResponse) (int, bool) {
+ if event == nil {
+ return 0, false
+ }
+ if event.OutputIndex != nil {
+ if idx, ok := a.outputIndexToItemIdx[*event.OutputIndex]; ok {
+ return idx, true
+ }
+ }
+ if itemID := responseStreamEventItemID(event); itemID != "" {
+ idx, ok := a.itemIDToItemIdx[itemID]
+ return idx, ok
+ }
+ return 0, false
+}
+
func (a *ResponsesBufferedAccumulator) ensureTool(event *dto.ResponsesStreamResponse) *responsesBufferedTool {
if idx, ok := a.findToolIndex(event); ok {
tool := a.tools[idx]
a.applyToolMetadata(tool, event)
+ item := a.ensureItem(event, event.Item.Type)
+ item.ToolIndex = idx
return tool
}
tool := &responsesBufferedTool{}
a.applyToolMetadata(tool, event)
idx := len(a.tools)
a.tools = append(a.tools, tool)
+ item := a.ensureItem(event, event.Item.Type)
+ item.ToolIndex = idx
if event.OutputIndex != nil {
a.outputIndexToToolIdx[*event.OutputIndex] = idx
if pending := a.pendingByOutputIndex[*event.OutputIndex]; pending != "" {
diff --git a/relaykit/relayconvert/internal/shared/claude/reasoning.go b/relaykit/relayconvert/internal/shared/claude/reasoning.go
new file mode 100644
index 000000000000..054fda404c0f
--- /dev/null
+++ b/relaykit/relayconvert/internal/shared/claude/reasoning.go
@@ -0,0 +1,135 @@
+package claude
+
+import (
+ "fmt"
+ "math"
+
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
+ kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
+)
+
+func ApplyReasoning(req *dto.ClaudeRequest, info convmeta.Meta, source reasoning.Intent) error {
+ if req == nil {
+ return nil
+ }
+
+ native, err := reasoning.FromClaude(req)
+ if err != nil {
+ return err
+ }
+ explicit, err := reasoning.MergeExplicit(native, source, req.Model)
+ if err != nil {
+ return err
+ }
+
+ opts := convmeta.OptionsOf(info)
+ baseModel := req.Model
+ capabilityModel := baseModel
+ suffix := reasoning.IntentFromState(convmeta.ReasoningStateOf(info))
+ preserveSuffix := opts.ShouldPreserveThinkingSuffix(req.Model)
+ if info != nil && opts.ShouldPreserveThinkingSuffix(info.GetOriginModelName()) {
+ preserveSuffix = true
+ }
+ if preserveSuffix {
+ suffix = reasoning.Intent{}
+ }
+ if info != nil && !reasoning.IsKnownClaudeModel(capabilityModel) && reasoning.IsKnownClaudeModel(info.GetOriginModelName()) {
+ capabilityModel = info.GetOriginModelName()
+ }
+ intent, err := reasoning.MergeExplicitAndSuffix(explicit, suffix, req.Model)
+ if err != nil {
+ return err
+ }
+ knownClaudeModel := reasoning.IsKnownClaudeModel(capabilityModel)
+ if source.IsEmpty() && suffix.IsEmpty() && !knownClaudeModel {
+ // A native Messages request can target a non-Anthropic model through a
+ // Claude-compatible proxy. Its capability vocabulary belongs to that
+ // upstream, so preserve validated native controls instead of applying
+ // Anthropic model rules to an unknown model name.
+ if info != nil {
+ if effort := reasoning.EffectiveEffort(intent); effort != "" {
+ info.SetReasoningEffort(string(effort))
+ }
+ }
+ return nil
+ }
+ if !knownClaudeModel && intent.Mode == reasoning.ModeAdaptive {
+ // Cross-protocol pivots cannot safely assume that an unknown
+ // Claude-compatible model implements Anthropic's adaptive mode. Render
+ // the broadly supported manual form while retaining the requested
+ // strength. Native Claude requests took the passthrough path above.
+ intent.Mode = reasoning.ModeEnabled
+ if intent.Effort == "" {
+ intent.Effort = reasoning.EffortHigh
+ }
+ }
+ if req.MaxTokens == nil && intent.HasStrength() {
+ // Adapter-provided defaults may be raised to accommodate an exact
+ // cross-protocol budget. Explicit client max_tokens values are never
+ // expanded and remain subject to the renderer's strict validation.
+ minimum := uint(1280)
+ if configuredDefault, configured := opts.Claude.DefaultMaxTokensFor(capabilityModel); configured && configuredDefault > 0 {
+ minimum = uint(configuredDefault)
+ }
+ if reasoning.ClaudeUsesManualThinking(capabilityModel, intent) && *intent.BudgetTokens >= 0 {
+ if *intent.BudgetTokens == math.MaxInt {
+ return fmt.Errorf("thinking budget is too large to derive max_tokens")
+ }
+ required := uint(*intent.BudgetTokens) + 1
+ const maxDerivedTokens = uint(math.MaxInt32 / 2)
+ if required > maxDerivedTokens {
+ return fmt.Errorf("thinking budget %d exceeds the supported conversion limit", *intent.BudgetTokens)
+ }
+ if minimum < required {
+ minimum = required
+ }
+ }
+ req.MaxTokens = &minimum
+ }
+
+ rendered, err := reasoning.RenderClaude(capabilityModel, intent, req.MaxTokens, opts.Claude.ThinkingAdapterBudgetTokensPercentage)
+ if err != nil {
+ return err
+ }
+ req.Model = baseModel
+ if rendered.Thinking != nil {
+ req.Thinking = rendered.Thinking
+ }
+ if rendered.OutputEffort != "" {
+ outputConfig := make(map[string]any)
+ if len(req.OutputConfig) > 0 {
+ if kitutil.GetJsonType(req.OutputConfig) != "object" {
+ return fmt.Errorf("Claude output_config must be a JSON object")
+ }
+ if err := kitutil.Unmarshal(req.OutputConfig, &outputConfig); err != nil {
+ return fmt.Errorf("invalid Claude output_config: %w", err)
+ }
+ if outputConfig == nil {
+ outputConfig = make(map[string]any)
+ }
+ }
+ outputConfig["effort"] = string(rendered.OutputEffort)
+ encoded, err := kitutil.Marshal(outputConfig)
+ if err != nil {
+ return fmt.Errorf("failed to marshal Claude output_config: %w", err)
+ }
+ req.OutputConfig = encoded
+ }
+ if rendered.ClearSampling {
+ req.Temperature = nil
+ req.TopP = nil
+ req.TopK = nil
+ } else if rendered.ConstrainThinkingSampling {
+ req.Temperature = nil
+ req.TopK = nil
+ if req.TopP != nil && (*req.TopP < 0.95 || *req.TopP > 1) {
+ req.TopP = nil
+ }
+ }
+ if info != nil && rendered.EffectiveEffort != "" {
+ info.SetReasoningEffort(string(rendered.EffectiveEffort))
+ }
+ return nil
+}
diff --git a/relaykit/relayconvert/internal/shared/claude/usage.go b/relaykit/relayconvert/internal/shared/claude/usage.go
new file mode 100644
index 000000000000..25fe72bb6f7e
--- /dev/null
+++ b/relaykit/relayconvert/internal/shared/claude/usage.go
@@ -0,0 +1,52 @@
+package claude
+
+import "github.com/QuantumNous/new-api/relaykit/dto"
+
+func UsageFromOpenAI(usage *dto.Usage) *dto.ClaudeUsage {
+ if usage == nil {
+ return nil
+ }
+ // An existing sidecar snapshots the original provider usage; carry it
+ // across this bridge unchanged regardless of its dialect. Only synthesize
+ // an OpenAI snapshot when no sidecar exists yet.
+ existingBillingUsage := dto.CloneBillingUsage(usage.BillingUsage)
+ if existingBillingUsage != nil && existingBillingUsage.ClaudeUsage != nil &&
+ (existingBillingUsage.Source == dto.BillingUsageSourceClaudeMessages || existingBillingUsage.Semantic == dto.BillingUsageSemanticAnthropic) {
+ result := existingBillingUsage.ClaudeUsage
+ result.BillingUsage = dto.CloneBillingUsage(usage.BillingUsage)
+ return result
+ }
+ billingUsage := existingBillingUsage
+ if billingUsage == nil {
+ billingUsage = dto.NewOpenAIChatBillingUsage(usage)
+ }
+ cacheCreation5m, cacheCreation1h := NormalizeCacheCreationSplit(
+ usage.PromptTokensDetails.CachedCreationTokens,
+ usage.ClaudeCacheCreation5mTokens,
+ usage.ClaudeCacheCreation1hTokens,
+ )
+ cacheCreationTokens := usage.PromptTokensDetails.CacheCreationTokensTotal()
+ inputTokens := usage.PromptTokens
+ if usage.UsageSemantic != dto.BillingUsageSemanticAnthropic {
+ // OpenAI-style prompt/input totals include cache reads and writes, while
+ // Claude reports both separately from input_tokens.
+ inputTokens = usage.PromptTokens - usage.PromptTokensDetails.CachedTokens - cacheCreationTokens
+ if inputTokens < 0 {
+ inputTokens = 0
+ }
+ }
+ result := &dto.ClaudeUsage{
+ InputTokens: inputTokens,
+ OutputTokens: usage.CompletionTokens,
+ CacheCreationInputTokens: cacheCreationTokens,
+ CacheReadInputTokens: usage.PromptTokensDetails.CachedTokens,
+ BillingUsage: billingUsage,
+ }
+ if cacheCreation5m > 0 || cacheCreation1h > 0 {
+ result.CacheCreation = &dto.ClaudeCacheCreationUsage{
+ Ephemeral5mInputTokens: cacheCreation5m,
+ Ephemeral1hInputTokens: cacheCreation1h,
+ }
+ }
+ return result
+}
diff --git a/relaykit/relayconvert/internal/shared/gemini/request.go b/relaykit/relayconvert/internal/shared/gemini/request.go
index ac4ce3a694f4..8845bb242bd5 100644
--- a/relaykit/relayconvert/internal/shared/gemini/request.go
+++ b/relaykit/relayconvert/internal/shared/gemini/request.go
@@ -1,12 +1,12 @@
package gemini
import (
+ "fmt"
"strconv"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
- kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
@@ -41,14 +41,6 @@ var SafetySettingCategories = []string{
const ThoughtSignatureBypassValue = "context_engineering_is_the_way_to_go"
-const (
- pro25MinBudget = 128
- pro25MaxBudget = 32768
- flash25MaxBudget = 24576
- flash25LiteMinBudget = 512
- flash25LiteMaxBudget = 24576
-)
-
func ShouldAttachThoughtSignature(opts *convmeta.Options) bool {
return opts != nil && opts.Gemini.FunctionCallThoughtSignatureEnabled
}
@@ -81,70 +73,109 @@ func AttachFirstTextThoughtSignature(opts *convmeta.Options, parts []dto.GeminiP
return false
}
-func ApplyThinkingConfig(geminiRequest *dto.GeminiChatRequest, info convmeta.Meta, oaiRequest ...dto.GeneralOpenAIRequest) {
+func ApplyThinkingConfig(geminiRequest *dto.GeminiChatRequest, info convmeta.Meta, oaiRequest ...dto.GeneralOpenAIRequest) error {
opts := convmeta.OptionsOf(info)
- if geminiRequest == nil || info == nil || !opts.Gemini.ThinkingAdapterEnabled {
- return
+ if geminiRequest == nil {
+ return nil
}
modelName := convmeta.UpstreamModelName(info)
- isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") &&
- !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") &&
- !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25")
+ var source reasoning.Intent
+ if len(oaiRequest) > 0 {
+ if modelName == "" {
+ modelName = oaiRequest[0].Model
+ }
+ var err error
+ source, err = reasoning.FromOpenAIChat(&oaiRequest[0])
+ if err != nil {
+ return err
+ }
+ }
- if strings.Contains(modelName, "-thinking-") {
- parts := strings.SplitN(modelName, "-thinking-", 2)
- if len(parts) == 2 && parts[1] != "" {
- if budgetTokens, err := strconv.Atoi(parts[1]); err == nil {
- clampedBudget := clampThinkingBudget(modelName, budgetTokens)
- geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
- ThinkingBudget: kitutil.GetPointer(clampedBudget),
- IncludeThoughts: true,
- }
- }
+ baseModel := modelName
+ suffix := reasoning.IntentFromState(convmeta.ReasoningStateOf(info))
+ preserveSuffix := opts.ShouldPreserveThinkingSuffix(modelName)
+ if info != nil && opts.ShouldPreserveThinkingSuffix(info.GetOriginModelName()) {
+ preserveSuffix = true
+ }
+ if preserveSuffix {
+ suffix = reasoning.Intent{}
+ }
+ native, err := reasoning.FromGemini(geminiRequest)
+ if err != nil {
+ return err
+ }
+ source = reasoning.ResolveGeminiEnabledDefault(baseModel, source, geminiRequest.GenerationConfig.MaxOutputTokens)
+ if native.HasStrength() && source.HasStrength() {
+ equivalent, compareErr := reasoning.EquivalentGeminiStrength(baseModel, native, source)
+ if compareErr != nil {
+ return compareErr
+ }
+ if !equivalent {
+ nativeEffort := reasoning.EffectiveEffort(native)
+ sourceEffort := reasoning.EffectiveEffort(source)
+ return fmt.Errorf("%w for model %q: Gemini thinking_config effort %q differs from standard effort %q", reasoning.ErrEffortConflict, modelName, nativeEffort, sourceEffort)
}
- } else if strings.HasSuffix(modelName, "-thinking") {
- unsupportedModels := []string{
- "gemini-2.5-pro-preview-05-06",
- "gemini-2.5-pro-preview-03-25",
+ // Native Gemini configuration is the lossless representation. Once the
+ // two controls are equivalent, retain only portable visibility metadata
+ // from the standard representation.
+ if native.IncludeThoughts == nil {
+ native.IncludeThoughts = source.IncludeThoughts
}
- isUnsupported := false
- for _, unsupportedModel := range unsupportedModels {
- if strings.HasPrefix(modelName, unsupportedModel) {
- isUnsupported = true
- break
+ source = reasoning.Intent{}
+ }
+ explicit, err := reasoning.MergeExplicit(native, source, modelName)
+ if err != nil {
+ return err
+ }
+ if explicit.HasStrength() && suffix.HasStrength() {
+ equivalent, compareErr := reasoning.EquivalentGeminiStrength(baseModel, explicit, suffix)
+ if compareErr != nil {
+ return compareErr
+ }
+ if equivalent {
+ if explicit.IncludeThoughts == nil {
+ explicit.IncludeThoughts = suffix.IncludeThoughts
}
+ suffix = reasoning.Intent{}
}
+ }
+ requested, err := reasoning.MergeExplicitAndSuffix(explicit, suffix, modelName)
+ if err != nil {
+ return err
+ }
+ requested = reasoning.ResolveGeminiEnabledDefault(baseModel, requested, geminiRequest.GenerationConfig.MaxOutputTokens)
- if isUnsupported {
- geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
- IncludeThoughts: true,
- }
- } else {
- geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
- IncludeThoughts: true,
- }
- if geminiRequest.GenerationConfig.MaxOutputTokens != nil && *geminiRequest.GenerationConfig.MaxOutputTokens > 0 {
- budgetTokens := opts.Gemini.ThinkingAdapterBudgetTokensPercentage * float64(*geminiRequest.GenerationConfig.MaxOutputTokens)
- clampedBudget := clampThinkingBudget(modelName, int(budgetTokens))
- geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = kitutil.GetPointer(clampedBudget)
- } else if len(oaiRequest) > 0 {
- geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = kitutil.GetPointer(clampThinkingBudgetByEffort(modelName, oaiRequest[0].ReasoningEffort))
- }
+ if native.HasStrength() && !suffix.HasStrength() {
+ if explicit.IncludeThoughts != nil {
+ geminiRequest.GenerationConfig.ThinkingConfig.IncludeThoughts = explicit.IncludeThoughts
}
- } else if strings.HasSuffix(modelName, "-nothinking") {
- if !isNew25Pro {
- geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
- ThinkingBudget: kitutil.GetPointer(0),
- }
+ effort, err := reasoning.ValidateGeminiThinkingConfig(baseModel, geminiRequest.GenerationConfig.ThinkingConfig)
+ if err != nil {
+ return err
}
- } else if _, level, ok := reasoning.TrimEffortSuffix(modelName); ok && level != "" {
- geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
- IncludeThoughts: true,
- ThinkingLevel: level,
+ if info != nil && effort != "" {
+ info.SetReasoningEffort(string(effort))
}
- info.SetReasoningEffort(level)
+ return nil
+ }
+ if requested.IsEmpty() {
+ return nil
}
+ rendered, err := reasoning.RenderGemini(
+ baseModel,
+ requested,
+ geminiRequest.GenerationConfig.MaxOutputTokens,
+ opts.Gemini.ThinkingAdapterBudgetTokensPercentage,
+ )
+ if err != nil {
+ return err
+ }
+ geminiRequest.GenerationConfig.ThinkingConfig = rendered.Config
+ if info != nil && rendered.EffectiveEffort != "" {
+ info.SetReasoningEffort(string(rendered.EffectiveEffort))
+ }
+ return nil
}
func ParseStopSequences(stop any) []string {
@@ -200,68 +231,3 @@ func SupportedMimeTypesList() []string {
}
return keys
}
-
-func isNew25ProModel(modelName string) bool {
- return strings.HasPrefix(modelName, "gemini-2.5-pro") &&
- !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") &&
- !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25")
-}
-
-func is25FlashLiteModel(modelName string) bool {
- return strings.HasPrefix(modelName, "gemini-2.5-flash-lite")
-}
-
-func clampThinkingBudget(modelName string, budget int) int {
- isNew25Pro := isNew25ProModel(modelName)
- is25FlashLite := is25FlashLiteModel(modelName)
-
- if is25FlashLite {
- if budget < flash25LiteMinBudget {
- return flash25LiteMinBudget
- }
- if budget > flash25LiteMaxBudget {
- return flash25LiteMaxBudget
- }
- } else if isNew25Pro {
- if budget < pro25MinBudget {
- return pro25MinBudget
- }
- if budget > pro25MaxBudget {
- return pro25MaxBudget
- }
- } else {
- if budget < 0 {
- return 0
- }
- if budget > flash25MaxBudget {
- return flash25MaxBudget
- }
- }
- return budget
-}
-
-func clampThinkingBudgetByEffort(modelName string, effort string) int {
- isNew25Pro := isNew25ProModel(modelName)
- is25FlashLite := is25FlashLiteModel(modelName)
-
- maxBudget := 0
- if is25FlashLite {
- maxBudget = flash25LiteMaxBudget
- }
- if isNew25Pro {
- maxBudget = pro25MaxBudget
- } else {
- maxBudget = flash25MaxBudget
- }
- switch effort {
- case "high":
- maxBudget = maxBudget * 80 / 100
- case "medium":
- maxBudget = maxBudget * 50 / 100
- case "low":
- maxBudget = maxBudget * 20 / 100
- case "minimal":
- maxBudget = maxBudget * 5 / 100
- }
- return clampThinkingBudget(modelName, maxBudget)
-}
diff --git a/relaykit/relayconvert/internal/toolconv/decode.go b/relaykit/relayconvert/internal/toolconv/decode.go
new file mode 100644
index 000000000000..748fed041d02
--- /dev/null
+++ b/relaykit/relayconvert/internal/toolconv/decode.go
@@ -0,0 +1,960 @@
+package toolconv
+
+import (
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+ "github.com/QuantumNous/new-api/relaykit/types"
+)
+
+const maxClaudeWebSearchUses = 1000
+
+func ExtractRequest(format types.RelayFormat, request any) (any, Set, error) {
+ switch format {
+ case types.RelayFormatOpenAI:
+ return extractOpenAIChatRequest(request)
+ case types.RelayFormatOpenAIResponses:
+ return extractOpenAIResponsesRequest(request)
+ case types.RelayFormatClaude:
+ return extractClaudeRequest(request)
+ case types.RelayFormatGemini:
+ return extractGeminiRequest(request)
+ default:
+ return request, Set{Source: format}, nil
+ }
+}
+
+func extractOpenAIChatRequest(request any) (any, Set, error) {
+ source, ok := request.(*dto.GeneralOpenAIRequest)
+ if !ok {
+ value, valueOK := request.(dto.GeneralOpenAIRequest)
+ if !valueOK {
+ return nil, Set{}, fmt.Errorf("expected OpenAI chat completions request, got %T", request)
+ }
+ source = &value
+ }
+
+ set := Set{Source: types.RelayFormatOpenAI}
+ set.ParallelAllowed = source.ParallelTooCalls
+ if len(source.Functions) > 0 {
+ var functions []dto.FunctionRequest
+ if err := kitutil.Unmarshal(source.Functions, &functions); err != nil {
+ return nil, Set{}, fmt.Errorf("invalid legacy functions: %w", err)
+ }
+ for _, function := range functions {
+ function := function
+ set.Definitions = append(set.Definitions, Definition{
+ Kind: KindFunction,
+ Execution: ExecutionClient,
+ Function: &Function{Name: function.Name, Description: function.Description, Parameters: function.Parameters, Strict: function.Strict},
+ })
+ }
+ }
+ for index, tool := range source.Tools {
+ if tool.Type == "function" || tool.Type == "" {
+ set.Definitions = append(set.Definitions, Definition{
+ Kind: KindFunction,
+ Execution: ExecutionClient,
+ Function: &Function{
+ Name: tool.Function.Name,
+ Description: tool.Function.Description,
+ Parameters: tool.Function.Parameters,
+ Strict: tool.Function.Strict,
+ },
+ })
+ continue
+ }
+ if len(tool.Custom) == 0 {
+ return nil, Set{}, fmt.Errorf("tools[%d] has unsupported type %q without a native payload", index, tool.Type)
+ }
+ definition, err := decodeOpenAIResponsesDefinition(tool.Custom)
+ if err != nil {
+ return nil, Set{}, fmt.Errorf("tools[%d]: %w", index, err)
+ }
+ set.Definitions = append(set.Definitions, definition)
+ }
+
+ if source.WebSearchOptions != nil {
+ webSearch := &WebSearch{
+ SearchContextSize: source.WebSearchOptions.SearchContextSize,
+ }
+ location, err := decodeOpenAIChatLocation(source.WebSearchOptions.UserLocation)
+ if err != nil {
+ return nil, Set{}, err
+ }
+ webSearch.Location = location
+ set.Definitions = append(set.Definitions, Definition{
+ Kind: KindWebSearch,
+ Execution: ExecutionServer,
+ NativeType: "web_search_options",
+ WebSearch: webSearch,
+ })
+ }
+
+ if choice, err := decodeOpenAIChatChoice(source.ToolChoice); err != nil {
+ return nil, Set{}, err
+ } else if choice != nil {
+ set.Choice = choice
+ }
+ if len(source.FunctionCall) > 0 {
+ legacyChoice, err := decodeLegacyOpenAIFunctionChoice(source.FunctionCall)
+ if err != nil {
+ return nil, Set{}, err
+ }
+ if set.Choice != nil && legacyChoice != nil {
+ return nil, Set{}, fmt.Errorf("tool_choice and legacy function_call cannot both be converted")
+ }
+ set.Choice = legacyChoice
+ }
+
+ clone := *source
+ clone.Tools = nil
+ clone.ToolChoice = nil
+ clone.WebSearchOptions = nil
+ clone.Functions = nil
+ clone.FunctionCall = nil
+ clone.ParallelTooCalls = nil
+ return &clone, set, nil
+}
+
+func extractOpenAIResponsesRequest(request any) (any, Set, error) {
+ source, ok := request.(*dto.OpenAIResponsesRequest)
+ if !ok {
+ value, valueOK := request.(dto.OpenAIResponsesRequest)
+ if !valueOK {
+ return nil, Set{}, fmt.Errorf("expected OpenAI Responses request, got %T", request)
+ }
+ source = &value
+ }
+
+ set := Set{Source: types.RelayFormatOpenAIResponses}
+ set.ParallelAllowed = rawBoolPointer(source.ParallelToolCalls)
+ if len(source.Tools) > 0 {
+ var rawTools []json.RawMessage
+ if err := kitutil.Unmarshal(source.Tools, &rawTools); err != nil {
+ return nil, Set{}, fmt.Errorf("invalid Responses tools: %w", err)
+ }
+ for index, rawTool := range rawTools {
+ definition, err := decodeOpenAIResponsesDefinition(rawTool)
+ if err != nil {
+ return nil, Set{}, fmt.Errorf("tools[%d]: %w", index, err)
+ }
+ set.Definitions = append(set.Definitions, definition)
+ }
+ }
+ choice, err := decodeOpenAIResponsesChoice(source.ToolChoice)
+ if err != nil {
+ return nil, Set{}, err
+ }
+ set.Choice = choice
+
+ clone := *source
+ clone.Tools = nil
+ clone.ToolChoice = nil
+ clone.ParallelToolCalls = nil
+ sanitizedInput, history, err := extractOpenAIResponsesHostedHistory(source.Input)
+ if err != nil {
+ return nil, Set{}, err
+ }
+ clone.Input = sanitizedInput
+ set.History = history
+ return &clone, set, nil
+}
+
+func extractClaudeRequest(request any) (any, Set, error) {
+ source, ok := request.(*dto.ClaudeRequest)
+ if !ok {
+ value, valueOK := request.(dto.ClaudeRequest)
+ if !valueOK {
+ return nil, Set{}, fmt.Errorf("expected Claude Messages request, got %T", request)
+ }
+ source = &value
+ }
+
+ set := Set{Source: types.RelayFormatClaude}
+ if source.ToolChoice != nil {
+ rawChoice, rawErr := rawJSON(source.ToolChoice)
+ if rawErr == nil {
+ var choiceMap map[string]any
+ if kitutil.Unmarshal(rawChoice, &choiceMap) == nil {
+ if disabled, ok := choiceMap["disable_parallel_tool_use"].(bool); ok {
+ allowed := !disabled
+ set.ParallelAllowed = &allowed
+ }
+ }
+ }
+ }
+ if source.Tools != nil {
+ rawTools, err := rawJSON(source.Tools)
+ if err != nil {
+ return nil, Set{}, fmt.Errorf("invalid Claude tools: %w", err)
+ }
+ var tools []json.RawMessage
+ if err := kitutil.Unmarshal(rawTools, &tools); err != nil {
+ return nil, Set{}, fmt.Errorf("invalid Claude tools: %w", err)
+ }
+ for index, rawTool := range tools {
+ definition, err := decodeClaudeDefinition(rawTool)
+ if err != nil {
+ return nil, Set{}, fmt.Errorf("tools[%d]: %w", index, err)
+ }
+ set.Definitions = append(set.Definitions, definition)
+ }
+ }
+ choice, err := decodeClaudeChoice(source.ToolChoice, set.Definitions)
+ if err != nil {
+ return nil, Set{}, err
+ }
+ set.Choice = choice
+
+ clone := *source
+ clone.Tools = nil
+ clone.ToolChoice = nil
+ clone.Messages, set.History, err = extractClaudeHostedHistory(source.Messages)
+ if err != nil {
+ return nil, Set{}, err
+ }
+ return &clone, set, nil
+}
+
+func extractGeminiRequest(request any) (any, Set, error) {
+ source, ok := request.(*dto.GeminiChatRequest)
+ if !ok {
+ value, valueOK := request.(dto.GeminiChatRequest)
+ if !valueOK {
+ return nil, Set{}, fmt.Errorf("expected Gemini generateContent request, got %T", request)
+ }
+ source = &value
+ }
+
+ set := Set{Source: types.RelayFormatGemini}
+ if source.ToolConfig != nil {
+ set.NativeToolConfig, _ = rawJSON(source.ToolConfig)
+ }
+ if len(source.Tools) > 0 {
+ var tools []json.RawMessage
+ if err := kitutil.Unmarshal(source.Tools, &tools); err != nil {
+ return nil, Set{}, fmt.Errorf("invalid Gemini tools: %w", err)
+ }
+ for index, rawTool := range tools {
+ definitions, err := decodeGeminiDefinitions(rawTool)
+ if err != nil {
+ return nil, Set{}, fmt.Errorf("tools[%d]: %w", index, err)
+ }
+ for definitionIndex := range definitions {
+ definitions[definitionIndex].Group = index
+ }
+ set.Definitions = append(set.Definitions, definitions...)
+ }
+ }
+ set.Choice = decodeGeminiChoice(source.ToolConfig)
+
+ clone := *source
+ clone.Tools = nil
+ clone.ToolConfig = nil
+ return &clone, set, nil
+}
+
+func decodeOpenAIResponsesDefinition(raw json.RawMessage) (Definition, error) {
+ var tool map[string]any
+ if err := kitutil.Unmarshal(raw, &tool); err != nil {
+ return Definition{}, err
+ }
+ toolType := strings.TrimSpace(kitutil.Interface2String(tool["type"]))
+ if toolType == "function" {
+ return Definition{
+ Kind: KindFunction,
+ Execution: ExecutionClient,
+ Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
+ Raw: cloneRaw(raw),
+ Function: &Function{
+ Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
+ Description: kitutil.Interface2String(tool["description"]),
+ Parameters: tool["parameters"],
+ Strict: boolPointer(tool, "strict"),
+ },
+ }, nil
+ }
+ if isOpenAIResponsesWebSearchType(toolType) {
+ webSearch := &WebSearch{
+ SearchContextSize: strings.TrimSpace(kitutil.Interface2String(tool["search_context_size"])),
+ ExternalWebAccess: boolPointer(tool, "external_web_access"),
+ }
+ if value, exists := tool["return_token_budget"]; exists {
+ encoded, err := rawJSON(value)
+ if err != nil {
+ return Definition{}, err
+ }
+ webSearch.ReturnTokenBudget = encoded
+ }
+ if filters, ok := tool["filters"].(map[string]any); ok {
+ webSearch.AllowedDomains = stringSlice(filters["allowed_domains"])
+ }
+ if location, ok := tool["user_location"].(map[string]any); ok {
+ webSearch.Location = locationFromMap(location)
+ }
+ return Definition{
+ Kind: KindWebSearch,
+ Execution: ExecutionServer,
+ NativeType: toolType,
+ WebSearch: webSearch,
+ Raw: cloneRaw(raw),
+ }, nil
+ }
+ return Definition{
+ Kind: kindFromNativeType(toolType),
+ Execution: executionFromNativeType(toolType),
+ NativeType: toolType,
+ Raw: cloneRaw(raw),
+ }, nil
+}
+
+func decodeClaudeDefinition(raw json.RawMessage) (Definition, error) {
+ var tool map[string]any
+ if err := kitutil.Unmarshal(raw, &tool); err != nil {
+ return Definition{}, err
+ }
+ toolType := strings.TrimSpace(kitutil.Interface2String(tool["type"]))
+ if strings.HasPrefix(toolType, "web_search") {
+ if !isVersionedClaudeWebSearchType(toolType) {
+ return Definition{}, fmt.Errorf("invalid Claude web-search tool version %q", toolType)
+ }
+ if !isKnownClaudeWebSearchType(toolType) {
+ return Definition{
+ Kind: KindNative,
+ Execution: ExecutionServer,
+ NativeType: toolType,
+ Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
+ Raw: cloneRaw(raw),
+ }, nil
+ }
+ toolName := strings.TrimSpace(kitutil.Interface2String(tool["name"]))
+ if toolName != "web_search" {
+ return Definition{}, fmt.Errorf("Claude web-search tool name must be %q", "web_search")
+ }
+ webSearch := &WebSearch{
+ AllowedDomains: stringSlice(tool["allowed_domains"]),
+ BlockedDomains: stringSlice(tool["blocked_domains"]),
+ AllowedCallers: stringSlice(tool["allowed_callers"]),
+ ResponseInclusion: strings.TrimSpace(kitutil.Interface2String(tool["response_inclusion"])),
+ }
+ if _, exists := tool["max_uses"]; exists {
+ var fields struct {
+ MaxUses *int `json:"max_uses"`
+ }
+ if err := kitutil.Unmarshal(raw, &fields); err != nil || fields.MaxUses == nil {
+ return Definition{}, fmt.Errorf("max_uses must be a JSON integer")
+ }
+ if *fields.MaxUses <= 0 || *fields.MaxUses > maxClaudeWebSearchUses {
+ return Definition{}, fmt.Errorf("max_uses must be between 1 and %d", maxClaudeWebSearchUses)
+ }
+ webSearch.MaxUses = fields.MaxUses
+ }
+ if len(webSearch.AllowedDomains) > 0 && len(webSearch.BlockedDomains) > 0 {
+ return Definition{}, fmt.Errorf("allowed_domains and blocked_domains are mutually exclusive")
+ }
+ if webSearch.ResponseInclusion != "" && !claudeWebSearchSupportsResponseInclusion(toolType) {
+ return Definition{}, fmt.Errorf("response_inclusion requires Claude web_search_20260318")
+ }
+ if location, ok := tool["user_location"].(map[string]any); ok {
+ webSearch.Location = locationFromMap(location)
+ }
+ return Definition{
+ Kind: KindWebSearch,
+ Execution: ExecutionServer,
+ NativeType: toolType,
+ Name: toolName,
+ WebSearch: webSearch,
+ Raw: cloneRaw(raw),
+ }, nil
+ }
+ if toolType == "" {
+ return Definition{
+ Kind: KindFunction,
+ Execution: ExecutionClient,
+ Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
+ Raw: cloneRaw(raw),
+ Function: &Function{
+ Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
+ Description: kitutil.Interface2String(tool["description"]),
+ Parameters: tool["input_schema"],
+ Strict: boolPointer(tool, "strict"),
+ },
+ }, nil
+ }
+ return Definition{
+ Kind: kindFromNativeType(toolType),
+ Execution: executionFromNativeType(toolType),
+ NativeType: toolType,
+ Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
+ Raw: cloneRaw(raw),
+ }, nil
+}
+
+func decodeGeminiDefinitions(raw json.RawMessage) ([]Definition, error) {
+ var tool map[string]any
+ if err := kitutil.Unmarshal(raw, &tool); err != nil {
+ return nil, err
+ }
+ definitions := make([]Definition, 0)
+ if functions, ok := tool["functionDeclarations"].([]any); ok {
+ for _, value := range functions {
+ function, ok := value.(map[string]any)
+ if !ok {
+ continue
+ }
+ parameters := function["parameters"]
+ parametersJSONSchema, hasParametersJSONSchema := function["parametersJsonSchema"]
+ if parameters != nil && hasParametersJSONSchema && parametersJSONSchema != nil {
+ return nil, fmt.Errorf("function %q declares both parameters and parametersJsonSchema", strings.TrimSpace(kitutil.Interface2String(function["name"])))
+ }
+ if parameters == nil && hasParametersJSONSchema {
+ parameters = parametersJSONSchema
+ }
+ functionRaw, err := rawJSON(map[string]any{"functionDeclarations": []any{value}})
+ if err != nil {
+ return nil, err
+ }
+ definitions = append(definitions, Definition{
+ Kind: KindFunction,
+ Execution: ExecutionClient,
+ Name: strings.TrimSpace(kitutil.Interface2String(function["name"])),
+ Raw: functionRaw,
+ Function: &Function{
+ Name: strings.TrimSpace(kitutil.Interface2String(function["name"])),
+ Description: kitutil.Interface2String(function["description"]),
+ Parameters: parameters,
+ },
+ })
+ }
+ }
+ for key := range tool {
+ var kind Kind
+ var nativeType string
+ switch key {
+ case "functionDeclarations":
+ continue
+ case "googleSearch":
+ kind, nativeType = KindWebSearch, "googleSearch"
+ case "googleSearchRetrieval":
+ kind, nativeType = KindWebSearch, "googleSearchRetrieval"
+ case "enterpriseWebSearch":
+ kind, nativeType = KindWebSearch, "enterpriseWebSearch"
+ case "googleMaps":
+ kind, nativeType = KindNative, "googleMaps"
+ case "codeExecution":
+ kind, nativeType = KindCodeExecution, "codeExecution"
+ case "urlContext":
+ kind, nativeType = KindURLContext, "urlContext"
+ case "fileSearch":
+ kind, nativeType = KindFileSearch, "fileSearch"
+ case "computerUse":
+ kind, nativeType = KindComputerUse, "computerUse"
+ case "retrieval":
+ kind, nativeType = KindFileSearch, "retrieval"
+ default:
+ kind, nativeType = KindNative, key
+ }
+ keyRaw, err := rawJSON(map[string]any{key: tool[key]})
+ if err != nil {
+ return nil, err
+ }
+ definition := Definition{
+ Kind: kind,
+ Execution: ExecutionServer,
+ NativeType: nativeType,
+ Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
+ Raw: keyRaw,
+ }
+ if kind == KindWebSearch {
+ definition.WebSearch = &WebSearch{}
+ }
+ definitions = append(definitions, definition)
+ }
+ return definitions, nil
+}
+
+func decodeLegacyOpenAIFunctionChoice(raw json.RawMessage) (*Choice, error) {
+ if len(raw) == 0 {
+ return nil, nil
+ }
+ if kitutil.GetJsonType(raw) == "string" {
+ var value string
+ if err := kitutil.Unmarshal(raw, &value); err != nil {
+ return nil, err
+ }
+ return choiceFromString(value), nil
+ }
+ var value map[string]any
+ if err := kitutil.Unmarshal(raw, &value); err != nil {
+ return nil, fmt.Errorf("invalid legacy function_call: %w", err)
+ }
+ name := strings.TrimSpace(kitutil.Interface2String(value["name"]))
+ if name == "" {
+ return nil, fmt.Errorf("legacy function_call requires name")
+ }
+ return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: name}, nil
+}
+
+func rawBoolPointer(raw json.RawMessage) *bool {
+ if len(raw) == 0 || kitutil.GetJsonType(raw) != "boolean" {
+ return nil
+ }
+ var value bool
+ if kitutil.Unmarshal(raw, &value) != nil {
+ return nil
+ }
+ return &value
+}
+
+func decodeOpenAIChatLocation(raw json.RawMessage) (*ApproximateLocation, error) {
+ if len(raw) == 0 {
+ return nil, nil
+ }
+ var wrapper map[string]any
+ if err := kitutil.Unmarshal(raw, &wrapper); err != nil {
+ return nil, fmt.Errorf("invalid web_search_options.user_location: %w", err)
+ }
+ location, ok := wrapper["approximate"].(map[string]any)
+ if !ok {
+ return nil, nil
+ }
+ return locationFromMap(location), nil
+}
+
+func decodeOpenAIChatChoice(value any) (*Choice, error) {
+ if value == nil {
+ return nil, nil
+ }
+ if text, ok := value.(string); ok {
+ return choiceFromString(text), nil
+ }
+ raw, err := rawJSON(value)
+ if err != nil {
+ return nil, fmt.Errorf("invalid Chat tool_choice: %w", err)
+ }
+ var choice map[string]any
+ if err := kitutil.Unmarshal(raw, &choice); err != nil {
+ return nil, fmt.Errorf("invalid Chat tool_choice: %w", err)
+ }
+ if strings.TrimSpace(kitutil.Interface2String(choice["type"])) != "function" {
+ return &Choice{Mode: ChoiceOpaque, Raw: cloneRaw(raw)}, nil
+ }
+ function, _ := choice["function"].(map[string]any)
+ name := strings.TrimSpace(kitutil.Interface2String(function["name"]))
+ if name == "" {
+ return nil, fmt.Errorf("Chat function tool_choice requires function.name")
+ }
+ return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: name}, nil
+}
+
+func decodeOpenAIResponsesChoice(raw json.RawMessage) (*Choice, error) {
+ if len(raw) == 0 {
+ return nil, nil
+ }
+ if kitutil.GetJsonType(raw) == "string" {
+ var text string
+ if err := kitutil.Unmarshal(raw, &text); err != nil {
+ return nil, err
+ }
+ return choiceFromString(text), nil
+ }
+ var value map[string]any
+ if err := kitutil.Unmarshal(raw, &value); err != nil {
+ return nil, fmt.Errorf("invalid Responses tool_choice: %w", err)
+ }
+ toolType := strings.TrimSpace(kitutil.Interface2String(value["type"]))
+ if toolType == "function" {
+ name := strings.TrimSpace(kitutil.Interface2String(value["name"]))
+ if name == "" {
+ return nil, fmt.Errorf("Responses function tool_choice requires name")
+ }
+ return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: name}, nil
+ }
+ if isOpenAIResponsesWebSearchType(toolType) {
+ return &Choice{Mode: ChoiceNamed, Kind: KindWebSearch, Name: "web_search", NativeType: toolType, Raw: cloneRaw(raw)}, nil
+ }
+ return &Choice{Mode: ChoiceOpaque, Kind: kindFromNativeType(toolType), NativeType: toolType, Raw: cloneRaw(raw)}, nil
+}
+
+func decodeClaudeChoice(value any, definitions []Definition) (*Choice, error) {
+ if value == nil {
+ return nil, nil
+ }
+ raw, err := rawJSON(value)
+ if err != nil {
+ return nil, fmt.Errorf("invalid Claude tool_choice: %w", err)
+ }
+ var choice map[string]any
+ if err := kitutil.Unmarshal(raw, &choice); err != nil {
+ return nil, fmt.Errorf("invalid Claude tool_choice: %w", err)
+ }
+ choiceType := strings.TrimSpace(kitutil.Interface2String(choice["type"]))
+ var decoded *Choice
+ switch choiceType {
+ case "auto":
+ decoded = &Choice{Mode: ChoiceAuto}
+ case "none":
+ decoded = &Choice{Mode: ChoiceNone}
+ case "any":
+ decoded = &Choice{Mode: ChoiceRequired}
+ case "tool":
+ name := strings.TrimSpace(kitutil.Interface2String(choice["name"]))
+ kind := KindNative
+ matches := 0
+ for _, definition := range definitions {
+ definitionName := definition.Name
+ if definition.Kind == KindFunction && definition.Function != nil {
+ definitionName = definition.Function.Name
+ }
+ if definitionName != name {
+ continue
+ }
+ matches++
+ kind = definition.Kind
+ }
+ if matches > 1 {
+ return nil, fmt.Errorf("Claude tool_choice name %q is ambiguous across %d definitions", name, matches)
+ }
+ decoded = &Choice{Mode: ChoiceNamed, Kind: kind, Name: name}
+ default:
+ decoded = &Choice{Mode: ChoiceOpaque}
+ }
+ if disabled, ok := choice["disable_parallel_tool_use"].(bool); ok {
+ decoded.DisableParallelToolUse = &disabled
+ }
+ decoded.Raw = cloneRaw(raw)
+ return decoded, nil
+}
+
+func decodeGeminiChoice(config *dto.ToolConfig) *Choice {
+ if config == nil || config.FunctionCallingConfig == nil {
+ return nil
+ }
+ functionConfig := config.FunctionCallingConfig
+ switch strings.ToUpper(strings.TrimSpace(string(functionConfig.Mode))) {
+ case "NONE":
+ return &Choice{Mode: ChoiceNone}
+ case "ANY":
+ if len(functionConfig.AllowedFunctionNames) == 1 {
+ return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: functionConfig.AllowedFunctionNames[0]}
+ }
+ return &Choice{
+ Mode: ChoiceRequired,
+ Kind: KindFunction,
+ AllowedNames: append([]string(nil), functionConfig.AllowedFunctionNames...),
+ }
+ case "", "AUTO":
+ return &Choice{Mode: ChoiceAuto}
+ default:
+ raw, _ := rawJSON(functionConfig)
+ return &Choice{Mode: ChoiceOpaque, Raw: raw}
+ }
+}
+
+func choiceFromString(value string) *Choice {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case "none":
+ return &Choice{Mode: ChoiceNone}
+ case "required", "any":
+ return &Choice{Mode: ChoiceRequired}
+ case "auto":
+ return &Choice{Mode: ChoiceAuto}
+ default:
+ raw, _ := rawJSON(value)
+ return &Choice{Mode: ChoiceOpaque, Raw: raw}
+ }
+}
+
+func isOpenAIResponsesWebSearchType(toolType string) bool {
+ switch toolType {
+ case "web_search", "web_search_2025_08_26", "web_search_preview", "web_search_preview_2025_03_11":
+ return true
+ default:
+ return false
+ }
+}
+
+func claudeWebSearchSupportsResponseInclusion(toolType string) bool {
+ return toolType == "web_search_20260318"
+}
+
+func isKnownClaudeWebSearchType(toolType string) bool {
+ switch toolType {
+ case "web_search_20250305", "web_search_20260209", "web_search_20260318":
+ return true
+ default:
+ return false
+ }
+}
+
+func isVersionedClaudeWebSearchType(toolType string) bool {
+ const prefix = "web_search_"
+ version := strings.TrimPrefix(toolType, prefix)
+ if !strings.HasPrefix(toolType, prefix) || len(version) != 8 {
+ return false
+ }
+ _, err := strconv.ParseUint(version, 10, 32)
+ return err == nil
+}
+
+func locationFromMap(value map[string]any) *ApproximateLocation {
+ if len(value) == 0 {
+ return nil
+ }
+ location := &ApproximateLocation{
+ City: strings.TrimSpace(kitutil.Interface2String(value["city"])),
+ Region: strings.TrimSpace(kitutil.Interface2String(value["region"])),
+ Country: strings.TrimSpace(kitutil.Interface2String(value["country"])),
+ Timezone: strings.TrimSpace(kitutil.Interface2String(value["timezone"])),
+ }
+ if location.City == "" && location.Region == "" && location.Country == "" && location.Timezone == "" {
+ return nil
+ }
+ return location
+}
+
+func boolPointer(value map[string]any, key string) *bool {
+ raw, exists := value[key]
+ if !exists {
+ return nil
+ }
+ parsed, ok := raw.(bool)
+ if !ok {
+ return nil
+ }
+ return &parsed
+}
+
+func stringSlice(value any) []string {
+ items, ok := value.([]any)
+ if !ok {
+ if strings, stringsOK := value.([]string); stringsOK {
+ return append([]string(nil), strings...)
+ }
+ return nil
+ }
+ result := make([]string, 0, len(items))
+ for _, item := range items {
+ if text, ok := item.(string); ok && strings.TrimSpace(text) != "" {
+ result = append(result, text)
+ }
+ }
+ return result
+}
+
+func rawJSON(value any) (json.RawMessage, error) {
+ switch raw := value.(type) {
+ case json.RawMessage:
+ return cloneRaw(raw), nil
+ case []byte:
+ return cloneRaw(raw), nil
+ default:
+ encoded, err := kitutil.Marshal(value)
+ return json.RawMessage(encoded), err
+ }
+}
+
+func cloneRaw(raw []byte) json.RawMessage {
+ return append(json.RawMessage(nil), raw...)
+}
+
+func kindFromNativeType(toolType string) Kind {
+ switch {
+ case toolType == "file_search":
+ return KindFileSearch
+ case strings.HasPrefix(toolType, "web_fetch"):
+ return KindWebFetch
+ case toolType == "code_interpreter", strings.HasPrefix(toolType, "code_execution"):
+ return KindCodeExecution
+ case strings.Contains(toolType, "computer"):
+ return KindComputerUse
+ case toolType == "url_context":
+ return KindURLContext
+ case toolType == "mcp", toolType == "mcp_toolset":
+ return KindMCP
+ case toolType == "image_generation":
+ return KindImage
+ default:
+ return KindNative
+ }
+}
+
+func executionFromNativeType(toolType string) Execution {
+ if strings.HasPrefix(toolType, "computer_") || strings.HasPrefix(toolType, "bash_") || strings.HasPrefix(toolType, "text_editor_") || strings.HasPrefix(toolType, "memory_") {
+ return ExecutionClient
+ }
+ return ExecutionServer
+}
+
+func extractOpenAIResponsesHostedHistory(input json.RawMessage) (json.RawMessage, []HostedHistoryItem, error) {
+ if len(input) == 0 || kitutil.GetJsonType(input) != "array" {
+ return input, nil, nil
+ }
+ var rawItems []json.RawMessage
+ if err := kitutil.Unmarshal(input, &rawItems); err != nil {
+ return nil, nil, fmt.Errorf("invalid Responses input: %w", err)
+ }
+ filtered := make([]json.RawMessage, 0, len(rawItems))
+ var history []HostedHistoryItem
+ for index, rawItem := range rawItems {
+ var item map[string]any
+ if err := kitutil.Unmarshal(rawItem, &item); err != nil {
+ return nil, nil, fmt.Errorf("input[%d]: %w", index, err)
+ }
+ itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
+ if !isResponsesHostedHistoryType(itemType) {
+ filtered = append(filtered, rawItem)
+ continue
+ }
+ status := strings.TrimSpace(kitutil.Interface2String(item["status"]))
+ action := rawMapValue(item, "action")
+ results := firstRawMapValue(item, "results", "sources", "output")
+ if itemType == "mcp_call" {
+ action = rawMapValue(item, "arguments")
+ output := rawMapValue(item, "output")
+ itemError := rawMapValue(item, "error")
+ results = output
+ if rawJSONPresent(itemError) {
+ results = itemError
+ status = "failed"
+ }
+ }
+ history = append(history, HostedHistoryItem{
+ Kind: hostedKindFromResponsesType(itemType),
+ NativeType: itemType,
+ Role: strings.TrimSpace(kitutil.Interface2String(item["role"])),
+ MessageIndex: index,
+ Sequence: index,
+ ID: strings.TrimSpace(kitutil.Interface2String(item["id"])),
+ CallID: strings.TrimSpace(kitutil.Interface2String(item["call_id"])),
+ Name: strings.TrimSpace(kitutil.Interface2String(item["name"])),
+ ServerName: strings.TrimSpace(kitutil.Interface2String(item["server_label"])),
+ Status: status,
+ Action: action,
+ Results: results,
+ Caller: rawMapValue(item, "caller"),
+ Raw: cloneRaw(rawItem),
+ })
+ }
+ if len(history) == 0 {
+ return input, nil, nil
+ }
+ encoded, err := kitutil.Marshal(filtered)
+ if err != nil {
+ return nil, nil, err
+ }
+ return encoded, history, nil
+}
+
+func isResponsesHostedHistoryType(itemType string) bool {
+ switch strings.TrimSpace(itemType) {
+ case "web_search_call", "file_search_call", "code_interpreter_call", "computer_call", "computer_call_output", "image_generation_call", "local_shell_call", "local_shell_call_output", "apply_patch_call", "apply_patch_call_output", "mcp_call", "mcp_list_tools", "mcp_approval_request", "mcp_approval_response":
+ return true
+ default:
+ return false
+ }
+}
+
+func extractClaudeHostedHistory(messages []dto.ClaudeMessage) ([]dto.ClaudeMessage, []HostedHistoryItem, error) {
+ clonedMessages := make([]dto.ClaudeMessage, 0, len(messages))
+ var history []HostedHistoryItem
+ for messageIndex := range messages {
+ message := messages[messageIndex]
+ if message.IsStringContent() {
+ clonedMessages = append(clonedMessages, message)
+ continue
+ }
+ rawContent, err := rawJSON(message.Content)
+ if err != nil {
+ return nil, nil, fmt.Errorf("messages[%d].content: %w", messageIndex, err)
+ }
+ var blocks []json.RawMessage
+ if err := kitutil.Unmarshal(rawContent, &blocks); err != nil {
+ return nil, nil, fmt.Errorf("messages[%d].content: %w", messageIndex, err)
+ }
+ filtered := make([]any, 0, len(blocks))
+ historyStart := len(history)
+ for blockIndex, rawBlock := range blocks {
+ var block map[string]any
+ if err := kitutil.Unmarshal(rawBlock, &block); err != nil {
+ return nil, nil, fmt.Errorf("messages[%d].content[%d]: %w", messageIndex, blockIndex, err)
+ }
+ blockType := strings.TrimSpace(kitutil.Interface2String(block["type"]))
+ if blockType != "server_tool_use" && blockType != "mcp_tool_use" && !isClaudeHostedToolBlock(blockType) {
+ filtered = append(filtered, block)
+ continue
+ }
+ name := strings.TrimSpace(kitutil.Interface2String(block["name"]))
+ kind := hostedKindFromClaudeCall(blockType, name)
+ if strings.HasSuffix(blockType, "_tool_result") {
+ kind = hostedKindFromClaudeResult(blockType)
+ }
+ results := rawMapValue(block, "content")
+ status := "in_progress"
+ if strings.HasSuffix(blockType, "_tool_result") {
+ status = "completed"
+ isError, _ := block["is_error"].(bool)
+ failed, _ := claudeHostedResultFailure(
+ blockType,
+ results,
+ &isError,
+ strings.TrimSpace(kitutil.Interface2String(block["error_code"])),
+ )
+ if failed {
+ status = "failed"
+ }
+ }
+ history = append(history, HostedHistoryItem{
+ Kind: kind,
+ NativeType: blockType,
+ Role: message.Role,
+ MessageIndex: messageIndex,
+ BlockIndex: blockIndex,
+ Sequence: len(history),
+ ID: strings.TrimSpace(kitutil.Interface2String(block["id"])),
+ CallID: strings.TrimSpace(kitutil.Interface2String(block["tool_use_id"])),
+ Name: name,
+ ServerName: strings.TrimSpace(kitutil.Interface2String(block["server_name"])),
+ Status: status,
+ Action: rawMapValue(block, "input"),
+ Results: results,
+ Caller: rawMapValue(block, "caller"),
+ Raw: cloneRaw(rawBlock),
+ })
+ }
+ if len(filtered) > 0 {
+ for index := historyStart; index < len(history); index++ {
+ history[index].MessageHasRegular = true
+ }
+ message.Content = filtered
+ clonedMessages = append(clonedMessages, message)
+ }
+ }
+ return clonedMessages, history, nil
+}
+
+func rawMapValue(value map[string]any, key string) json.RawMessage {
+ item, exists := value[key]
+ if !exists {
+ return nil
+ }
+ encoded, err := kitutil.Marshal(item)
+ if err != nil {
+ return nil
+ }
+ return encoded
+}
+
+func firstRawMapValue(value map[string]any, keys ...string) json.RawMessage {
+ for _, key := range keys {
+ if raw := rawMapValue(value, key); len(raw) > 0 {
+ return raw
+ }
+ }
+ return nil
+}
diff --git a/relaykit/relayconvert/internal/toolconv/encode.go b/relaykit/relayconvert/internal/toolconv/encode.go
new file mode 100644
index 000000000000..224901f478f3
--- /dev/null
+++ b/relaykit/relayconvert/internal/toolconv/encode.go
@@ -0,0 +1,1343 @@
+package toolconv
+
+import (
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
+ sharedgemini "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/gemini"
+ kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+ "github.com/QuantumNous/new-api/relaykit/types"
+)
+
+func AttachRequest(format types.RelayFormat, request any, set Set, options *convmeta.Options) (any, []types.ConversionDiagnostic, error) {
+ if set.Empty() {
+ return request, nil, nil
+ }
+ var (
+ value any
+ diagnostics []types.ConversionDiagnostic
+ err error
+ )
+ switch format {
+ case types.RelayFormatOpenAI:
+ value, diagnostics, err = attachOpenAIChatRequest(request, set)
+ case types.RelayFormatOpenAIResponses:
+ value, diagnostics, err = attachOpenAIResponsesRequest(request, set)
+ case types.RelayFormatClaude:
+ value, diagnostics, err = attachClaudeRequest(request, set, options)
+ case types.RelayFormatGemini:
+ value, diagnostics, err = attachGeminiRequest(request, set)
+ default:
+ value = request
+ }
+ if err != nil {
+ return nil, diagnostics, err
+ }
+ for index := range diagnostics {
+ diagnostics[index].From = set.Source
+ diagnostics[index].To = format
+ }
+ if err := types.RejectConversionLoss(options.EffectiveToolLossPolicy(), diagnostics); err != nil {
+ return nil, diagnostics, err
+ }
+ return value, diagnostics, nil
+}
+
+func attachOpenAIChatRequest(request any, set Set) (any, []types.ConversionDiagnostic, error) {
+ target, ok := request.(*dto.GeneralOpenAIRequest)
+ if !ok || target == nil {
+ return nil, nil, fmt.Errorf("expected OpenAI chat completions request, got %T", request)
+ }
+ var diagnostics []types.ConversionDiagnostic
+ for index, definition := range set.Definitions {
+ switch definition.Kind {
+ case KindFunction:
+ if definition.Function == nil {
+ continue
+ }
+ target.Tools = append(target.Tools, dto.ToolCallRequest{
+ Type: "function",
+ Function: dto.FunctionRequest{
+ Name: definition.Function.Name,
+ Description: definition.Function.Description,
+ Parameters: definition.Function.Parameters,
+ Strict: definition.Function.Strict,
+ },
+ })
+ case KindWebSearch:
+ if set.Source == types.RelayFormatGemini {
+ diagnostics = append(diagnostics, geminiNativeWebSearchDiagnostics(index, definition, types.RelayFormatOpenAI)...)
+ }
+ if target.WebSearchOptions != nil {
+ return nil, diagnostics, fmt.Errorf("tools[%d]: multiple hosted web-search definitions cannot be represented by Chat Completions", index)
+ }
+ options := &dto.WebSearchOptions{}
+ if definition.WebSearch != nil {
+ options.SearchContextSize = definition.WebSearch.SearchContextSize
+ if definition.WebSearch.Location != nil {
+ location := map[string]any{
+ "type": "approximate",
+ "approximate": locationMap(definition.WebSearch.Location),
+ }
+ options.UserLocation, _ = kitutil.Marshal(location)
+ }
+ diagnostics = append(diagnostics, openAIChatWebSearchDiagnostics(index, definition.WebSearch)...)
+ }
+ target.WebSearchOptions = options
+ default:
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("tools[%d]", index),
+ "unsupported_hosted_tool",
+ fmt.Sprintf("OpenAI Chat Completions cannot represent hosted tool %q", definition.NativeType),
+ ))
+ }
+ }
+
+ normalizedChoice, allowedChoiceDiagnostics := narrowAllowedFunctionChoice(set.Choice, types.RelayFormatOpenAI)
+ choice, choiceDiagnostics := encodeOpenAIChatChoice(normalizedChoice)
+ target.ToolChoice = choice
+ target.ParallelTooCalls = set.ParallelAllowed
+ diagnostics = append(diagnostics, allowedChoiceDiagnostics...)
+ diagnostics = append(diagnostics, choiceDiagnostics...)
+ diagnostics = append(diagnostics, unsupportedHostedHistoryDiagnostics(types.RelayFormatOpenAI, set.History)...)
+ return target, diagnostics, nil
+}
+
+func attachOpenAIResponsesRequest(request any, set Set) (any, []types.ConversionDiagnostic, error) {
+ target, ok := request.(*dto.OpenAIResponsesRequest)
+ if !ok || target == nil {
+ return nil, nil, fmt.Errorf("expected OpenAI Responses request, got %T", request)
+ }
+ tools := make([]any, 0, len(set.Definitions))
+ var diagnostics []types.ConversionDiagnostic
+ for index, definition := range set.Definitions {
+ switch definition.Kind {
+ case KindFunction:
+ if definition.Function == nil {
+ continue
+ }
+ tool := map[string]any{
+ "type": "function",
+ "name": definition.Function.Name,
+ "description": definition.Function.Description,
+ "parameters": definition.Function.Parameters,
+ }
+ if definition.Function.Strict != nil {
+ tool["strict"] = *definition.Function.Strict
+ }
+ deleteEmptyStrings(tool)
+ tools = append(tools, tool)
+ case KindWebSearch:
+ if set.Source == types.RelayFormatGemini {
+ diagnostics = append(diagnostics, geminiNativeWebSearchDiagnostics(index, definition, types.RelayFormatOpenAIResponses)...)
+ }
+ webSearch := definition.WebSearch
+ toolType := "web_search"
+ if set.Source == types.RelayFormatOpenAIResponses && definition.NativeType != "" {
+ toolType = definition.NativeType
+ }
+ tool := map[string]any{"type": toolType}
+ if webSearch != nil {
+ if webSearch.SearchContextSize != "" {
+ tool["search_context_size"] = webSearch.SearchContextSize
+ }
+ if webSearch.Location != nil {
+ location := locationMap(webSearch.Location)
+ location["type"] = "approximate"
+ tool["user_location"] = location
+ }
+ if len(webSearch.AllowedDomains) > 0 {
+ tool["filters"] = map[string]any{"allowed_domains": webSearch.AllowedDomains}
+ }
+ if webSearch.ExternalWebAccess != nil {
+ tool["external_web_access"] = *webSearch.ExternalWebAccess
+ }
+ if len(webSearch.ReturnTokenBudget) > 0 {
+ var budget any
+ if err := kitutil.Unmarshal(webSearch.ReturnTokenBudget, &budget); err != nil {
+ return nil, diagnostics, fmt.Errorf("tools[%d].return_token_budget: %w", index, err)
+ }
+ tool["return_token_budget"] = budget
+ }
+ diagnostics = append(diagnostics, openAIResponsesWebSearchDiagnostics(index, webSearch)...)
+ }
+ tools = append(tools, tool)
+ default:
+ if set.Source == types.RelayFormatOpenAIResponses && len(definition.Raw) > 0 {
+ var tool any
+ if err := kitutil.Unmarshal(definition.Raw, &tool); err != nil {
+ return nil, diagnostics, err
+ }
+ tools = append(tools, tool)
+ continue
+ }
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("tools[%d]", index),
+ "unsupported_hosted_tool",
+ fmt.Sprintf("OpenAI Responses has no verified mapping for hosted tool %q", definition.NativeType),
+ ))
+ }
+ }
+ if len(tools) > 0 {
+ encoded, err := kitutil.Marshal(tools)
+ if err != nil {
+ return nil, diagnostics, err
+ }
+ target.Tools = encoded
+ }
+ normalizedChoice, allowedChoiceDiagnostics := narrowAllowedFunctionChoice(set.Choice, types.RelayFormatOpenAIResponses)
+ choice, choiceDiagnostics, err := encodeOpenAIResponsesChoice(normalizedChoice, set.Source)
+ if err != nil {
+ return nil, diagnostics, err
+ }
+ target.ToolChoice = choice
+ if set.ParallelAllowed != nil {
+ target.ParallelToolCalls, _ = kitutil.Marshal(*set.ParallelAllowed)
+ }
+ diagnostics = append(diagnostics, allowedChoiceDiagnostics...)
+ diagnostics = append(diagnostics, choiceDiagnostics...)
+ historyDiagnostics, err := appendHostedHistoryToOpenAIResponses(target, set)
+ if err != nil {
+ return nil, diagnostics, err
+ }
+ diagnostics = append(diagnostics, historyDiagnostics...)
+ return target, diagnostics, nil
+}
+
+func attachClaudeRequest(request any, set Set, options *convmeta.Options) (any, []types.ConversionDiagnostic, error) {
+ target, ok := request.(*dto.ClaudeRequest)
+ if !ok || target == nil {
+ return nil, nil, fmt.Errorf("expected Claude Messages request, got %T", request)
+ }
+ tools := make([]any, 0, len(set.Definitions))
+ var diagnostics []types.ConversionDiagnostic
+ for index, definition := range set.Definitions {
+ switch definition.Kind {
+ case KindFunction:
+ if definition.Function == nil {
+ continue
+ }
+ inputSchema, err := functionParametersMap(definition.Function.Parameters)
+ if err != nil {
+ return nil, diagnostics, fmt.Errorf("tools[%d].input_schema: %w", index, err)
+ }
+ tools = append(tools, &dto.Tool{
+ Name: definition.Function.Name,
+ Description: definition.Function.Description,
+ InputSchema: inputSchema,
+ Strict: definition.Function.Strict,
+ })
+ case KindWebSearch:
+ if set.Source == types.RelayFormatGemini {
+ diagnostics = append(diagnostics, geminiNativeWebSearchDiagnostics(index, definition, types.RelayFormatClaude)...)
+ }
+ toolType := "web_search_20250305"
+ if set.Source == types.RelayFormatClaude && isKnownClaudeWebSearchType(definition.NativeType) {
+ toolType = definition.NativeType
+ } else if options != nil && options.Claude.WebSearchToolVersion != "" {
+ if !isKnownClaudeWebSearchType(options.Claude.WebSearchToolVersion) {
+ return nil, diagnostics, fmt.Errorf("unsupported Claude web-search tool version %q", options.Claude.WebSearchToolVersion)
+ }
+ toolType = options.Claude.WebSearchToolVersion
+ }
+ webSearch := definition.WebSearch
+ if webSearch != nil && len(webSearch.AllowedDomains) > 0 && len(webSearch.BlockedDomains) > 0 {
+ return nil, diagnostics, fmt.Errorf("tools[%d]: allowed_domains and blocked_domains are mutually exclusive", index)
+ }
+ if webSearch != nil && webSearch.ResponseInclusion != "" && !claudeWebSearchSupportsResponseInclusion(toolType) {
+ return nil, diagnostics, fmt.Errorf("tools[%d].response_inclusion requires Claude web_search_20260318", index)
+ }
+ tool := map[string]any{"type": toolType, "name": "web_search"}
+ if webSearch != nil {
+ if webSearch.Location != nil {
+ location := locationMap(webSearch.Location)
+ location["type"] = "approximate"
+ tool["user_location"] = location
+ }
+ if len(webSearch.AllowedDomains) > 0 {
+ tool["allowed_domains"] = webSearch.AllowedDomains
+ }
+ if len(webSearch.BlockedDomains) > 0 {
+ tool["blocked_domains"] = webSearch.BlockedDomains
+ }
+ if webSearch.MaxUses != nil {
+ tool["max_uses"] = *webSearch.MaxUses
+ }
+ if len(webSearch.AllowedCallers) > 0 {
+ tool["allowed_callers"] = webSearch.AllowedCallers
+ }
+ if webSearch.ResponseInclusion != "" {
+ tool["response_inclusion"] = webSearch.ResponseInclusion
+ }
+ diagnostics = append(diagnostics, claudeWebSearchDiagnostics(index, webSearch)...)
+ }
+ tools = append(tools, tool)
+ default:
+ if set.Source == types.RelayFormatClaude && len(definition.Raw) > 0 {
+ var tool any
+ if err := kitutil.Unmarshal(definition.Raw, &tool); err != nil {
+ return nil, diagnostics, err
+ }
+ tools = append(tools, tool)
+ continue
+ }
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("tools[%d]", index),
+ "unsupported_hosted_tool",
+ fmt.Sprintf("Claude Messages has no verified mapping for hosted tool %q", definition.NativeType),
+ ))
+ }
+ }
+ if len(tools) > 0 {
+ target.Tools = tools
+ }
+ normalizedChoice, allowedChoiceDiagnostics := narrowAllowedFunctionChoice(set.Choice, types.RelayFormatClaude)
+ choice, choiceDiagnostics := encodeClaudeChoice(normalizedChoice, set.ParallelAllowed, set.Source)
+ target.ToolChoice = choice
+ diagnostics = append(diagnostics, allowedChoiceDiagnostics...)
+ diagnostics = append(diagnostics, choiceDiagnostics...)
+ historyDiagnostics, err := appendHostedHistoryToClaude(target, set)
+ if err != nil {
+ return nil, diagnostics, err
+ }
+ diagnostics = append(diagnostics, historyDiagnostics...)
+ return target, diagnostics, nil
+}
+
+func attachGeminiRequest(request any, set Set) (any, []types.ConversionDiagnostic, error) {
+ target, ok := request.(*dto.GeminiChatRequest)
+ if !ok || target == nil {
+ return nil, nil, fmt.Errorf("expected Gemini generateContent request, got %T", request)
+ }
+ if set.Source == types.RelayFormatGemini {
+ tools, err := rebuildGeminiToolGroups(set.Definitions)
+ if err != nil {
+ return nil, nil, err
+ }
+ if len(tools) > 0 {
+ target.Tools, err = kitutil.Marshal(tools)
+ if err != nil {
+ return nil, nil, err
+ }
+ }
+ if len(set.NativeToolConfig) > 0 {
+ var config dto.ToolConfig
+ if err := kitutil.Unmarshal(set.NativeToolConfig, &config); err != nil {
+ return nil, nil, fmt.Errorf("toolConfig: %w", err)
+ }
+ target.ToolConfig = &config
+ }
+ return target, unsupportedHostedHistoryDiagnostics(types.RelayFormatGemini, set.History), nil
+ }
+ var (
+ functions []map[string]any
+ tools []map[string]any
+ diagnostics []types.ConversionDiagnostic
+ )
+ for index, definition := range set.Definitions {
+ switch definition.Kind {
+ case KindFunction:
+ if definition.Function == nil {
+ continue
+ }
+ parameters := definition.Function.Parameters
+ if parameters != nil {
+ cloned, err := kitutil.Any2Type[any](parameters)
+ if err != nil {
+ return nil, diagnostics, fmt.Errorf("tools[%d].parameters: %w", index, err)
+ }
+ if params, ok := cloned.(map[string]any); ok {
+ if properties, exists := params["properties"].(map[string]any); exists && len(properties) == 0 {
+ cloned = nil
+ }
+ }
+ parameters = sharedgemini.CleanFunctionParameters(cloned)
+ }
+ function := map[string]any{
+ "name": definition.Function.Name,
+ "description": definition.Function.Description,
+ "parameters": parameters,
+ }
+ deleteEmptyStrings(function)
+ functions = append(functions, function)
+ if definition.Function.Strict != nil {
+ diagnostics = append(diagnostics, presentationLoss(fmt.Sprintf("tools[%d].strict", index), "unsupported_function_strict", "Gemini does not expose OpenAI function strictness"))
+ }
+ case KindWebSearch:
+ if set.Source == types.RelayFormatGemini && len(definition.Raw) > 0 {
+ var tool map[string]any
+ if err := kitutil.Unmarshal(definition.Raw, &tool); err != nil {
+ return nil, diagnostics, err
+ }
+ tools = append(tools, tool)
+ } else {
+ tools = append(tools, map[string]any{"googleSearch": map[string]any{}})
+ }
+ if definition.WebSearch != nil {
+ diagnostics = append(diagnostics, geminiWebSearchDiagnostics(index, definition.WebSearch)...)
+ }
+ case KindCodeExecution:
+ if set.Source == types.RelayFormatGemini {
+ tools = append(tools, map[string]any{"codeExecution": map[string]any{}})
+ continue
+ }
+ diagnostics = append(diagnostics, semanticLoss(fmt.Sprintf("tools[%d]", index), "unverified_tool_mapping", "code execution semantics differ across providers"))
+ case KindURLContext:
+ if set.Source == types.RelayFormatGemini {
+ tools = append(tools, map[string]any{"urlContext": map[string]any{}})
+ continue
+ }
+ diagnostics = append(diagnostics, semanticLoss(fmt.Sprintf("tools[%d]", index), "unverified_tool_mapping", "URL context has no verified mapping from the source protocol"))
+ default:
+ if set.Source == types.RelayFormatGemini && len(definition.Raw) > 0 {
+ var tool map[string]any
+ if err := kitutil.Unmarshal(definition.Raw, &tool); err != nil {
+ return nil, diagnostics, err
+ }
+ tools = append(tools, tool)
+ continue
+ }
+ // The established Responses-to-Gemini compatibility path removes
+ // free-form/unknown tools together with custom call history in
+ // PrepareOpenAIResponsesRequest. Keep that explicit downgrade as a
+ // diagnostic; other opaque tools may be server-executed and remain a
+ // semantic loss under the default Safe policy.
+ if set.Source == types.RelayFormatOpenAIResponses && (definition.NativeType == "custom" || definition.NativeType == "unknown") {
+ diagnostics = append(diagnostics, presentationLoss(
+ fmt.Sprintf("tools[%d]", index),
+ "custom_tool_omitted",
+ "Gemini cannot represent this OpenAI free-form or unknown tool; its preprocessed call history and definition were omitted",
+ ))
+ continue
+ }
+ if set.Source == types.RelayFormatOpenAIResponses && definition.Kind == KindNative {
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("tools[%d]", index),
+ "unsupported_opaque_tool",
+ fmt.Sprintf("Gemini cannot represent OpenAI opaque tool %q; the definition was omitted", definition.NativeType),
+ ))
+ continue
+ }
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("tools[%d]", index),
+ "unsupported_hosted_tool",
+ fmt.Sprintf("Gemini generateContent has no verified mapping for hosted tool %q", definition.NativeType),
+ ))
+ }
+ }
+ if len(functions) > 0 {
+ tools = append(tools, map[string]any{"functionDeclarations": functions})
+ }
+ if len(tools) > 0 {
+ encoded, err := kitutil.Marshal(tools)
+ if err != nil {
+ return nil, diagnostics, err
+ }
+ target.Tools = encoded
+ }
+ config, choiceDiagnostics := encodeGeminiChoice(set.Choice)
+ target.ToolConfig = config
+ diagnostics = append(diagnostics, choiceDiagnostics...)
+ if set.ParallelAllowed != nil && !*set.ParallelAllowed {
+ diagnostics = append(diagnostics, semanticLoss(
+ "parallel_tool_calls",
+ "unsupported_parallel_tool_control",
+ "Gemini generateContent does not expose a request field equivalent to parallel_tool_calls",
+ ))
+ }
+ diagnostics = append(diagnostics, unsupportedHostedHistoryDiagnostics(types.RelayFormatGemini, set.History)...)
+ return target, diagnostics, nil
+}
+
+func rebuildGeminiToolGroups(definitions []Definition) ([]map[string]any, error) {
+ groups := make(map[int]map[string]any)
+ indexes := make([]int, 0)
+ for index, definition := range definitions {
+ if len(definition.Raw) == 0 {
+ return nil, fmt.Errorf("tools[%d]: missing native Gemini tool payload", index)
+ }
+ var fragment map[string]any
+ if err := kitutil.Unmarshal(definition.Raw, &fragment); err != nil {
+ return nil, fmt.Errorf("tools[%d]: %w", index, err)
+ }
+ group, exists := groups[definition.Group]
+ if !exists {
+ group = make(map[string]any)
+ groups[definition.Group] = group
+ indexes = append(indexes, definition.Group)
+ }
+ for key, value := range fragment {
+ if key == "functionDeclarations" {
+ existing, _ := group[key].([]any)
+ incoming, ok := value.([]any)
+ if !ok {
+ return nil, fmt.Errorf("tools[%d].functionDeclarations must be an array", index)
+ }
+ group[key] = append(existing, incoming...)
+ continue
+ }
+ group[key] = value
+ }
+ }
+ sort.Ints(indexes)
+ tools := make([]map[string]any, 0, len(indexes))
+ for _, index := range indexes {
+ tools = append(tools, groups[index])
+ }
+ return tools, nil
+}
+
+func appendHostedHistoryToOpenAIResponses(target *dto.OpenAIResponsesRequest, set Set) ([]types.ConversionDiagnostic, error) {
+ if len(set.History) == 0 {
+ return nil, nil
+ }
+ input, err := responsesInputItems(target.Input)
+ if err != nil {
+ return nil, err
+ }
+ itemsBySourceIndex := make(map[int][]map[string]any)
+ mixedBySourceIndex := make(map[int]bool)
+ convertedByID := make(map[string]map[string]any)
+ var diagnostics []types.ConversionDiagnostic
+ for index, history := range set.History {
+ if history.MessageHasRegular {
+ mixedBySourceIndex[history.MessageIndex] = true
+ }
+ var item map[string]any
+ if set.Source == types.RelayFormatOpenAIResponses && len(history.Raw) > 0 {
+ if err := kitutil.Unmarshal(history.Raw, &item); err != nil {
+ return nil, fmt.Errorf("hosted_history[%d]: %w", index, err)
+ }
+ } else {
+ outputType := responsesTypeFromHostedKind(history.Kind)
+ if outputType == "" {
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("hosted_history[%d]", index),
+ "hosted_tool_history_unsupported",
+ fmt.Sprintf("%s cannot preserve hosted-tool continuation item %q", types.RelayFormatOpenAIResponses, history.NativeType),
+ ))
+ continue
+ }
+ id := history.ID
+ if id == "" {
+ id = history.CallID
+ }
+ if strings.HasSuffix(history.NativeType, "_tool_result") || history.NativeType == "mcp_tool_result" {
+ if call, exists := convertedByID[history.CallID]; exists {
+ failed := history.Status == "failed"
+ if failed {
+ call["status"] = "failed"
+ } else {
+ call["status"] = "completed"
+ }
+ if history.Kind == KindMCP {
+ if len(history.Results) > 0 {
+ var (
+ encoded json.RawMessage
+ normalized bool
+ err error
+ )
+ if failed {
+ encoded, normalized, err = responsesMCPErrorFromClaudeContent(history.Results, "")
+ } else {
+ encoded, normalized, err = responsesMCPStringFromClaudeContent(history.Results)
+ }
+ if err != nil {
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("hosted_history[%d].content", index),
+ "mcp_result_unrepresentable",
+ err.Error(),
+ ))
+ continue
+ }
+ var value string
+ if err := kitutil.Unmarshal(encoded, &value); err != nil {
+ return nil, fmt.Errorf("hosted_history[%d].content: %w", index, err)
+ }
+ if failed {
+ call["error"] = value
+ } else {
+ call["output"] = value
+ }
+ if normalized {
+ diagnostics = append(diagnostics, presentationLoss(
+ fmt.Sprintf("hosted_history[%d].content", index),
+ "mcp_text_result_normalized",
+ "Claude's single MCP text block was normalized to a Responses output string",
+ ))
+ }
+ }
+ } else if history.Kind == KindWebSearch && len(history.Results) > 0 {
+ diagnostics = append(diagnostics, presentationLoss(
+ fmt.Sprintf("hosted_history[%d].content", index),
+ "web_search_result_omitted",
+ "Claude web-search result content has no field on a Responses web_search_call",
+ ))
+ }
+ continue
+ }
+ }
+ item = map[string]any{
+ "type": outputType,
+ "id": id,
+ "status": history.Status,
+ }
+ if item["status"] == "" {
+ item["status"] = "in_progress"
+ }
+ switch history.Kind {
+ case KindWebSearch:
+ action, err := dto.NormalizeResponsesWebSearchAction(history.Action)
+ if err != nil {
+ return nil, fmt.Errorf("hosted_history[%d].action: %w", index, err)
+ }
+ var actionValue any
+ if err := kitutil.Unmarshal(action, &actionValue); err != nil {
+ return nil, fmt.Errorf("hosted_history[%d].action: %w", index, err)
+ }
+ item["action"] = actionValue
+ case KindMCP:
+ if strings.TrimSpace(history.Name) == "" || strings.TrimSpace(history.ServerName) == "" {
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("hosted_history[%d]", index),
+ "mcp_identity_missing",
+ "Claude MCP history requires both name and server_name for Responses mapping",
+ ))
+ continue
+ }
+ item["name"] = history.Name
+ item["server_label"] = history.ServerName
+ if len(history.Action) > 0 {
+ arguments, err := responsesMCPArgumentsFromClaude(history.Action)
+ if err != nil {
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("hosted_history[%d].input", index),
+ "mcp_arguments_unrepresentable",
+ err.Error(),
+ ))
+ continue
+ }
+ var value string
+ if err := kitutil.Unmarshal(arguments, &value); err != nil {
+ return nil, fmt.Errorf("hosted_history[%d].input: %w", index, err)
+ }
+ item["arguments"] = value
+ } else {
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("hosted_history[%d].input", index),
+ "mcp_arguments_missing",
+ "Claude MCP history has no input object",
+ ))
+ continue
+ }
+ if len(history.Results) > 0 {
+ failed := history.Status == "failed"
+ var encoded json.RawMessage
+ var normalized bool
+ if failed {
+ encoded, normalized, err = responsesMCPErrorFromClaudeContent(history.Results, "")
+ } else {
+ encoded, normalized, err = responsesMCPStringFromClaudeContent(history.Results)
+ }
+ if err != nil {
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("hosted_history[%d].content", index),
+ "mcp_result_unrepresentable",
+ err.Error(),
+ ))
+ continue
+ }
+ var value string
+ if err := kitutil.Unmarshal(encoded, &value); err != nil {
+ return nil, fmt.Errorf("hosted_history[%d].content: %w", index, err)
+ }
+ if failed {
+ item["error"] = value
+ item["status"] = "failed"
+ } else {
+ item["output"] = value
+ item["status"] = "completed"
+ }
+ if normalized {
+ diagnostics = append(diagnostics, presentationLoss(
+ fmt.Sprintf("hosted_history[%d].content", index),
+ "mcp_text_result_normalized",
+ "Claude's single MCP text block was normalized to a Responses output string",
+ ))
+ }
+ }
+ if len(history.Caller) > 0 {
+ var caller any
+ if err := kitutil.Unmarshal(history.Caller, &caller); err != nil {
+ return nil, fmt.Errorf("hosted_history[%d].caller: %w", index, err)
+ }
+ item["caller"] = caller
+ }
+ }
+ if id != "" {
+ convertedByID[id] = item
+ }
+ }
+ itemsBySourceIndex[history.MessageIndex] = append(itemsBySourceIndex[history.MessageIndex], item)
+ }
+ if len(itemsBySourceIndex) == 0 {
+ if len(diagnostics) > 0 {
+ return diagnostics, nil
+ }
+ return unsupportedHostedHistoryDiagnostics(types.RelayFormatOpenAIResponses, set.History), nil
+ }
+ merged := make([]map[string]any, 0, len(input)+len(set.History))
+ inputIndex := 0
+ maxSourceIndex := 0
+ for _, history := range set.History {
+ if history.MessageIndex > maxSourceIndex {
+ maxSourceIndex = history.MessageIndex
+ }
+ }
+ for sourceIndex := 0; sourceIndex <= maxSourceIndex || inputIndex < len(input); sourceIndex++ {
+ if hostedItems := itemsBySourceIndex[sourceIndex]; len(hostedItems) > 0 {
+ if !mixedBySourceIndex[sourceIndex] {
+ merged = append(merged, hostedItems...)
+ continue
+ }
+ merged = append(merged, hostedItems...)
+ if inputIndex < len(input) {
+ merged = append(merged, input[inputIndex])
+ inputIndex++
+ }
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("hosted_history[%d]", sourceIndex),
+ "hosted_tool_order_unrepresentable",
+ "hosted and ordinary blocks share one source message, but Responses represents hosted calls as separate input items",
+ ))
+ continue
+ }
+ if inputIndex < len(input) {
+ merged = append(merged, input[inputIndex])
+ inputIndex++
+ }
+ }
+ encoded, err := kitutil.Marshal(merged)
+ if err != nil {
+ return nil, err
+ }
+ target.Input = encoded
+ diagnostics = append(diagnostics, presentationLoss(
+ "input",
+ "hosted_tool_history_approximated",
+ "hosted-tool continuation state is preserved, but provider-specific item fields may differ",
+ ))
+ return diagnostics, nil
+}
+
+func appendHostedHistoryToClaude(target *dto.ClaudeRequest, set Set) ([]types.ConversionDiagnostic, error) {
+ if len(set.History) == 0 {
+ return nil, nil
+ }
+ blocksBySourceIndex := make(map[int][]any)
+ mixedBySourceIndex := make(map[int]bool)
+ var diagnostics []types.ConversionDiagnostic
+ for index, history := range set.History {
+ if history.MessageHasRegular {
+ mixedBySourceIndex[history.MessageIndex] = true
+ }
+ var blocks []any
+ if set.Source == types.RelayFormatClaude && len(history.Raw) > 0 {
+ var block any
+ if err := kitutil.Unmarshal(history.Raw, &block); err != nil {
+ return nil, fmt.Errorf("hosted_history[%d]: %w", index, err)
+ }
+ blocksBySourceIndex[history.MessageIndex] = append(blocksBySourceIndex[history.MessageIndex], block)
+ continue
+ }
+ if set.Source == types.RelayFormatOpenAIResponses && history.Kind == KindWebSearch {
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("hosted_history[%d]", index),
+ "web_search_continuation_unrepresentable",
+ "Responses web-search history cannot reconstruct Claude's encrypted web_search_tool_result continuation state",
+ ))
+ continue
+ }
+ if history.Kind == KindMCP {
+ if history.Name == "" || history.ServerName == "" {
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("hosted_history[%d]", index),
+ "mcp_identity_missing",
+ "Responses MCP history requires both name and server_label for Claude mapping",
+ ))
+ continue
+ }
+ id := history.ID
+ if id == "" {
+ id = history.CallID
+ }
+ if id == "" {
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("hosted_history[%d].id", index),
+ "hosted_tool_id_missing",
+ "Responses MCP history has no id for pairing the call with its result",
+ ))
+ continue
+ }
+ if history.Status != "" && history.Status != "in_progress" && history.Status != "completed" && history.Status != "failed" {
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("hosted_history[%d].status", index),
+ "hosted_tool_status_unrepresentable",
+ fmt.Sprintf("Claude cannot preserve Responses MCP status %q", history.Status),
+ ))
+ }
+ mcpResult := history.Results
+ mcpFailed := history.Status == "failed"
+ if len(history.Raw) > 0 {
+ var rawFields struct {
+ ApprovalRequestID string `json:"approval_request_id"`
+ Output json.RawMessage `json:"output"`
+ Error json.RawMessage `json:"error"`
+ }
+ if err := kitutil.Unmarshal(history.Raw, &rawFields); err != nil {
+ return nil, fmt.Errorf("hosted_history[%d]: %w", index, err)
+ }
+ if strings.TrimSpace(rawFields.ApprovalRequestID) != "" {
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("hosted_history[%d].approval_request_id", index),
+ "mcp_approval_state_unrepresentable",
+ "Claude MCP history cannot preserve a Responses approval_request_id",
+ ))
+ }
+ if rawJSONPresent(rawFields.Output) && rawJSONPresent(rawFields.Error) {
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("hosted_history[%d]", index),
+ "mcp_result_ambiguous",
+ "Responses MCP history contains both output and error",
+ ))
+ }
+ if rawJSONPresent(rawFields.Error) {
+ mcpResult = rawFields.Error
+ mcpFailed = true
+ } else if rawJSONPresent(rawFields.Output) {
+ mcpResult = rawFields.Output
+ }
+ }
+ input, inputErr := claudeMCPInputFromResponses(history.Action)
+ if inputErr != nil {
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("hosted_history[%d].arguments", index),
+ "mcp_arguments_unrepresentable",
+ inputErr.Error(),
+ ))
+ continue
+ }
+ call := map[string]any{
+ "type": "mcp_tool_use",
+ "id": id,
+ "name": history.Name,
+ "server_name": history.ServerName,
+ "input": input,
+ }
+ if len(history.Caller) > 0 {
+ var caller any
+ if err := kitutil.Unmarshal(history.Caller, &caller); err != nil {
+ return nil, fmt.Errorf("hosted_history[%d].caller: %w", index, err)
+ }
+ call["caller"] = caller
+ }
+ blocks = append(blocks, call)
+ if rawJSONPresent(mcpResult) {
+ content, resultErr := claudeMCPContentFromResponsesString(mcpResult)
+ if resultErr != nil {
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("hosted_history[%d].output", index),
+ "mcp_result_unrepresentable",
+ resultErr.Error(),
+ ))
+ continue
+ }
+ result := map[string]any{"type": "mcp_tool_result", "tool_use_id": id, "content": content}
+ if mcpFailed {
+ result["is_error"] = true
+ }
+ blocks = append(blocks, result)
+ } else if history.Status == "completed" || mcpFailed {
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("hosted_history[%d]", index),
+ "mcp_result_missing",
+ fmt.Sprintf("Responses MCP history has status %q but no output or error", history.Status),
+ ))
+ }
+ blocksBySourceIndex[history.MessageIndex] = append(blocksBySourceIndex[history.MessageIndex], blocks...)
+ continue
+ }
+ name := claudeNameFromHostedKind(history.Kind)
+ if name == "" {
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("hosted_history[%d]", index),
+ "hosted_tool_history_unsupported",
+ fmt.Sprintf("%s cannot preserve hosted-tool continuation item %q", types.RelayFormatClaude, history.NativeType),
+ ))
+ continue
+ }
+ id := history.ID
+ if id == "" {
+ id = history.CallID
+ }
+ var input any = map[string]any{}
+ if history.Kind == KindWebSearch {
+ webInput, inputErr := claudeWebSearchInputFromResponses(history.Action)
+ if inputErr != nil {
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("hosted_history[%d].action", index),
+ "web_search_action_unrepresentable",
+ inputErr.Error(),
+ ))
+ continue
+ }
+ input = webInput
+ } else if len(history.Action) > 0 {
+ if err := kitutil.Unmarshal(history.Action, &input); err != nil {
+ return nil, fmt.Errorf("hosted_history[%d].action: %w", index, err)
+ }
+ }
+ call := map[string]any{
+ "type": "server_tool_use",
+ "id": id,
+ "name": name,
+ "input": input,
+ }
+ if len(history.Caller) > 0 {
+ var caller any
+ if err := kitutil.Unmarshal(history.Caller, &caller); err != nil {
+ return nil, fmt.Errorf("hosted_history[%d].caller: %w", index, err)
+ }
+ call["caller"] = caller
+ }
+ blocks = append(blocks, call)
+ if len(history.Results) > 0 && !(set.Source == types.RelayFormatOpenAIResponses && history.Kind == KindWebSearch) {
+ var content any
+ if err := kitutil.Unmarshal(history.Results, &content); err != nil {
+ return nil, fmt.Errorf("hosted_history[%d].results: %w", index, err)
+ }
+ blocks = append(blocks, map[string]any{
+ "type": claudeResultTypeFromHostedKind(history.Kind),
+ "tool_use_id": id,
+ "content": content,
+ })
+ } else if len(history.Results) > 0 && history.Kind == KindWebSearch {
+ diagnostics = append(diagnostics, presentationLoss(
+ fmt.Sprintf("hosted_history[%d].results", index),
+ "web_search_result_omitted",
+ "Responses web-search source metadata cannot reconstruct Claude's encrypted web_search_tool_result",
+ ))
+ }
+ blocksBySourceIndex[history.MessageIndex] = append(blocksBySourceIndex[history.MessageIndex], blocks...)
+ }
+ if len(blocksBySourceIndex) == 0 {
+ if len(diagnostics) > 0 {
+ return diagnostics, nil
+ }
+ return unsupportedHostedHistoryDiagnostics(types.RelayFormatClaude, set.History), nil
+ }
+ messages := make([]dto.ClaudeMessage, 0, len(target.Messages)+len(blocksBySourceIndex))
+ messageIndex := 0
+ maxSourceIndex := 0
+ for _, history := range set.History {
+ if history.MessageIndex > maxSourceIndex {
+ maxSourceIndex = history.MessageIndex
+ }
+ }
+ for sourceIndex := 0; sourceIndex <= maxSourceIndex || messageIndex < len(target.Messages); sourceIndex++ {
+ if blocks := blocksBySourceIndex[sourceIndex]; len(blocks) > 0 {
+ role := "assistant"
+ for _, history := range set.History {
+ if history.MessageIndex == sourceIndex && history.Role != "" {
+ role = history.Role
+ break
+ }
+ }
+ if !mixedBySourceIndex[sourceIndex] {
+ messages = append(messages, dto.ClaudeMessage{Role: role, Content: blocks})
+ continue
+ }
+ messages = append(messages, dto.ClaudeMessage{Role: role, Content: blocks})
+ if messageIndex < len(target.Messages) {
+ messages = append(messages, target.Messages[messageIndex])
+ messageIndex++
+ }
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("hosted_history[%d]", sourceIndex),
+ "hosted_tool_order_unrepresentable",
+ "hosted and ordinary content cannot be merged after the intermediate converter coalesced source blocks",
+ ))
+ continue
+ }
+ if messageIndex < len(target.Messages) {
+ messages = append(messages, target.Messages[messageIndex])
+ messageIndex++
+ }
+ }
+ target.Messages = messages
+ diagnostics = append(diagnostics, presentationLoss(
+ "messages",
+ "hosted_tool_history_approximated",
+ "hosted-tool continuation state is preserved, but provider-specific item fields may differ",
+ ))
+ return diagnostics, nil
+}
+
+func responsesInputItems(raw json.RawMessage) ([]map[string]any, error) {
+ if len(raw) == 0 {
+ return nil, nil
+ }
+ switch kitutil.GetJsonType(raw) {
+ case "array":
+ var input []map[string]any
+ if err := kitutil.Unmarshal(raw, &input); err != nil {
+ return nil, fmt.Errorf("invalid Responses input: %w", err)
+ }
+ return input, nil
+ case "string":
+ var text string
+ if err := kitutil.Unmarshal(raw, &text); err != nil {
+ return nil, fmt.Errorf("invalid Responses input: %w", err)
+ }
+ return []map[string]any{{"role": "user", "content": text}}, nil
+ default:
+ return nil, fmt.Errorf("cannot append hosted-tool history to Responses input type %q", kitutil.GetJsonType(raw))
+ }
+}
+
+func unsupportedHostedHistoryDiagnostics(format types.RelayFormat, history []HostedHistoryItem) []types.ConversionDiagnostic {
+ if len(history) == 0 {
+ return nil
+ }
+ diagnostics := make([]types.ConversionDiagnostic, 0, len(history))
+ for index, item := range history {
+ diagnostics = append(diagnostics, semanticLoss(
+ fmt.Sprintf("hosted_history[%d]", index),
+ "hosted_tool_history_unsupported",
+ fmt.Sprintf("%s cannot preserve hosted-tool continuation item %q", format, item.NativeType),
+ ))
+ }
+ return diagnostics
+}
+
+func openAIChatWebSearchDiagnostics(index int, search *WebSearch) []types.ConversionDiagnostic {
+ if search == nil {
+ return nil
+ }
+ path := fmt.Sprintf("tools[%d]", index)
+ var diagnostics []types.ConversionDiagnostic
+ if len(search.AllowedDomains) > 0 || len(search.BlockedDomains) > 0 {
+ diagnostics = append(diagnostics, semanticLoss(path+".domains", "unsupported_domain_filter", "Chat Completions web_search_options cannot preserve domain access constraints"))
+ }
+ if search.MaxUses != nil {
+ diagnostics = append(diagnostics, semanticLoss(path+".max_uses", "unsupported_search_limit", "Chat Completions cannot preserve Claude max_uses"))
+ }
+ if len(search.AllowedCallers) > 0 || search.ExternalWebAccess != nil {
+ diagnostics = append(diagnostics, semanticLoss(path, "unsupported_search_controls", "Chat Completions cannot preserve caller or external-access constraints"))
+ }
+ if search.ResponseInclusion != "" || len(search.ReturnTokenBudget) > 0 {
+ diagnostics = append(diagnostics, presentationLoss(path, "unsupported_search_tuning", "Chat Completions cannot preserve response-inclusion or return-token tuning"))
+ }
+ return diagnostics
+}
+
+func openAIResponsesWebSearchDiagnostics(index int, search *WebSearch) []types.ConversionDiagnostic {
+ if search == nil {
+ return nil
+ }
+ path := fmt.Sprintf("tools[%d]", index)
+ var diagnostics []types.ConversionDiagnostic
+ if search.MaxUses != nil {
+ diagnostics = append(diagnostics, semanticLoss(path+".max_uses", "unsupported_search_limit", "OpenAI Responses cannot preserve Claude max_uses"))
+ }
+ if len(search.BlockedDomains) > 0 {
+ diagnostics = append(diagnostics, semanticLoss(path+".blocked_domains", "unsupported_blocked_domains", "OpenAI Responses web search supports allow filters but not Claude blocked_domains"))
+ }
+ if len(search.AllowedCallers) > 0 {
+ diagnostics = append(diagnostics, semanticLoss(path, "unsupported_search_controls", "OpenAI Responses cannot preserve Claude caller constraints"))
+ }
+ if search.ResponseInclusion != "" {
+ diagnostics = append(diagnostics, presentationLoss(path+".response_inclusion", "unsupported_search_tuning", "OpenAI Responses cannot preserve Claude response-inclusion tuning"))
+ }
+ return diagnostics
+}
+
+func claudeWebSearchDiagnostics(index int, search *WebSearch) []types.ConversionDiagnostic {
+ if search == nil {
+ return nil
+ }
+ path := fmt.Sprintf("tools[%d]", index)
+ var diagnostics []types.ConversionDiagnostic
+ if search.SearchContextSize != "" {
+ diagnostics = append(diagnostics, presentationLoss(path+".search_context_size", "unsupported_search_context_size", "Claude has no equivalent for OpenAI search_context_size; max_uses is deliberately not inferred"))
+ }
+ if search.ExternalWebAccess != nil {
+ diagnostics = append(diagnostics, semanticLoss(path+".external_web_access", "unsupported_search_controls", "Claude cannot preserve OpenAI external-web access constraints"))
+ }
+ if len(search.ReturnTokenBudget) > 0 {
+ diagnostics = append(diagnostics, presentationLoss(path+".return_token_budget", "unsupported_search_tuning", "Claude cannot preserve OpenAI return-token tuning"))
+ }
+ return diagnostics
+}
+
+func geminiWebSearchDiagnostics(index int, search *WebSearch) []types.ConversionDiagnostic {
+ if search == nil {
+ return nil
+ }
+ path := fmt.Sprintf("tools[%d]", index)
+ if search.Location == nil && len(search.AllowedDomains) == 0 && len(search.BlockedDomains) == 0 && search.SearchContextSize == "" && search.MaxUses == nil && len(search.AllowedCallers) == 0 && search.ResponseInclusion == "" && search.ExternalWebAccess == nil && len(search.ReturnTokenBudget) == 0 {
+ return nil
+ }
+ var diagnostics []types.ConversionDiagnostic
+ if len(search.AllowedDomains) > 0 || len(search.BlockedDomains) > 0 || search.ExternalWebAccess != nil || search.MaxUses != nil || len(search.AllowedCallers) > 0 {
+ diagnostics = append(diagnostics, semanticLoss(path, "unsupported_search_constraints", "Gemini Google Search cannot preserve source web-search access or execution constraints"))
+ }
+ if search.Location != nil || search.SearchContextSize != "" || search.ResponseInclusion != "" || len(search.ReturnTokenBudget) > 0 {
+ diagnostics = append(diagnostics, presentationLoss(path, "unsupported_search_tuning", "Gemini Google Search cannot preserve source web-search location or result tuning"))
+ }
+ return diagnostics
+}
+
+func geminiNativeWebSearchDiagnostics(index int, definition Definition, target types.RelayFormat) []types.ConversionDiagnostic {
+ path := fmt.Sprintf("tools[%d]", index)
+ switch definition.NativeType {
+ case "googleSearch":
+ if !geminiNativeToolHasConfiguration(definition) {
+ return nil
+ }
+ return []types.ConversionDiagnostic{semanticLoss(
+ path+".googleSearch",
+ "unsupported_native_search_config",
+ fmt.Sprintf("%s cannot preserve Gemini googleSearch configuration", target),
+ )}
+ case "googleSearchRetrieval":
+ return []types.ConversionDiagnostic{semanticLoss(
+ path+".googleSearchRetrieval",
+ "legacy_search_semantics_unrepresentable",
+ fmt.Sprintf("%s cannot preserve Gemini legacy dynamic-retrieval semantics", target),
+ )}
+ case "enterpriseWebSearch":
+ return []types.ConversionDiagnostic{semanticLoss(
+ path+".enterpriseWebSearch",
+ "enterprise_search_semantics_unrepresentable",
+ fmt.Sprintf("%s cannot replace Gemini enterprise search with public web search without changing its data source", target),
+ )}
+ default:
+ return []types.ConversionDiagnostic{semanticLoss(
+ path,
+ "unverified_search_mapping",
+ fmt.Sprintf("%s has no verified mapping for Gemini search tool %q", target, definition.NativeType),
+ )}
+ }
+}
+
+func geminiNativeToolHasConfiguration(definition Definition) bool {
+ if len(definition.Raw) == 0 {
+ return false
+ }
+ var wrapper map[string]json.RawMessage
+ if kitutil.Unmarshal(definition.Raw, &wrapper) != nil {
+ return true
+ }
+ payload := wrapper[definition.NativeType]
+ if !rawJSONPresent(payload) {
+ return false
+ }
+ if kitutil.GetJsonType(payload) != "object" {
+ return true
+ }
+ var fields map[string]json.RawMessage
+ return kitutil.Unmarshal(payload, &fields) != nil || len(fields) > 0
+}
+
+func narrowAllowedFunctionChoice(choice *Choice, target types.RelayFormat) (*Choice, []types.ConversionDiagnostic) {
+ if choice == nil || len(choice.AllowedNames) == 0 {
+ return choice, nil
+ }
+ normalized := *choice
+ normalized.AllowedNames = nil
+ if len(choice.AllowedNames) == 1 {
+ normalized.Mode = ChoiceNamed
+ normalized.Kind = KindFunction
+ normalized.Name = choice.AllowedNames[0]
+ return &normalized, nil
+ }
+ return &normalized, []types.ConversionDiagnostic{semanticLoss(
+ "tool_choice.allowed_function_names",
+ "allowed_function_subset_unrepresentable",
+ fmt.Sprintf("%s cannot restrict a required tool call to Gemini's %d-name function subset", target, len(choice.AllowedNames)),
+ )}
+}
+
+func encodeOpenAIChatChoice(choice *Choice) (any, []types.ConversionDiagnostic) {
+ if choice == nil {
+ return nil, nil
+ }
+ switch choice.Mode {
+ case ChoiceAuto:
+ return "auto", nil
+ case ChoiceNone:
+ return "none", nil
+ case ChoiceRequired:
+ return "required", nil
+ case ChoiceNamed:
+ if choice.Kind == KindFunction {
+ return map[string]any{"type": "function", "function": map[string]any{"name": choice.Name}}, nil
+ }
+ if choice.Kind == KindWebSearch {
+ return nil, []types.ConversionDiagnostic{presentationLoss("tool_choice", "implicit_search_choice", "Chat Completions expresses hosted search through web_search_options instead of tool_choice")}
+ }
+ }
+ return nil, []types.ConversionDiagnostic{semanticLoss("tool_choice", "unsupported_tool_choice", "Chat Completions cannot represent the source hosted tool choice")}
+}
+
+func encodeOpenAIResponsesChoice(choice *Choice, source types.RelayFormat) (json.RawMessage, []types.ConversionDiagnostic, error) {
+ if choice == nil {
+ return nil, nil, nil
+ }
+ var value any
+ switch choice.Mode {
+ case ChoiceAuto, ChoiceNone, ChoiceRequired:
+ value = string(choice.Mode)
+ case ChoiceNamed:
+ if len(choice.Raw) > 0 && choice.NativeType != "" {
+ return append(json.RawMessage(nil), choice.Raw...), nil, nil
+ }
+ switch choice.Kind {
+ case KindFunction:
+ value = map[string]any{"type": "function", "name": choice.Name}
+ case KindWebSearch:
+ value = map[string]any{"type": "web_search"}
+ default:
+ return nil, []types.ConversionDiagnostic{semanticLoss("tool_choice", "unsupported_tool_choice", "OpenAI Responses has no verified hosted tool-choice mapping")}, nil
+ }
+ case ChoiceOpaque:
+ if source == types.RelayFormatOpenAIResponses && len(choice.Raw) > 0 {
+ return append(json.RawMessage(nil), choice.Raw...), nil, nil
+ }
+ return nil, []types.ConversionDiagnostic{semanticLoss("tool_choice", "unsupported_tool_choice", "OpenAI Responses cannot reconstruct the source complex tool choice")}, nil
+ }
+ encoded, err := kitutil.Marshal(value)
+ return encoded, nil, err
+}
+
+func encodeClaudeChoice(choice *Choice, parallelAllowed *bool, source types.RelayFormat) (any, []types.ConversionDiagnostic) {
+ if choice == nil && parallelAllowed == nil {
+ return nil, nil
+ }
+ if source == types.RelayFormatClaude && choice != nil && len(choice.Raw) > 0 {
+ var value any
+ if err := kitutil.Unmarshal(choice.Raw, &value); err != nil {
+ return nil, []types.ConversionDiagnostic{semanticLoss("tool_choice", "invalid_native_tool_choice", "Claude tool_choice could not be restored from its native payload")}
+ }
+ return value, nil
+ }
+ value := map[string]any{}
+ if choice != nil {
+ switch choice.Mode {
+ case ChoiceAuto:
+ value["type"] = "auto"
+ case ChoiceNone:
+ value["type"] = "none"
+ case ChoiceRequired:
+ value["type"] = "any"
+ case ChoiceNamed:
+ if choice.Kind != KindFunction && choice.Kind != KindWebSearch {
+ return nil, []types.ConversionDiagnostic{semanticLoss("tool_choice", "unsupported_tool_choice", "Claude has no verified hosted tool-choice mapping")}
+ }
+ value["type"] = "tool"
+ value["name"] = choice.Name
+ case ChoiceOpaque:
+ return nil, []types.ConversionDiagnostic{semanticLoss("tool_choice", "unsupported_tool_choice", "Claude cannot represent the source complex tool-choice policy")}
+ }
+ }
+ if value["type"] == nil && parallelAllowed != nil {
+ value["type"] = "auto"
+ }
+ if parallelAllowed != nil && value["type"] != "none" {
+ value["disable_parallel_tool_use"] = !*parallelAllowed
+ } else if choice != nil && choice.DisableParallelToolUse != nil && value["type"] != "none" {
+ value["disable_parallel_tool_use"] = *choice.DisableParallelToolUse
+ }
+ return value, nil
+}
+
+func encodeGeminiChoice(choice *Choice) (*dto.ToolConfig, []types.ConversionDiagnostic) {
+ if choice == nil {
+ return nil, nil
+ }
+ config := &dto.ToolConfig{FunctionCallingConfig: &dto.FunctionCallingConfig{}}
+ if len(choice.AllowedNames) > 0 {
+ config.FunctionCallingConfig.Mode = "ANY"
+ config.FunctionCallingConfig.AllowedFunctionNames = append([]string(nil), choice.AllowedNames...)
+ return config, nil
+ }
+ switch choice.Mode {
+ case ChoiceAuto:
+ config.FunctionCallingConfig.Mode = "AUTO"
+ case ChoiceNone:
+ config.FunctionCallingConfig.Mode = "NONE"
+ case ChoiceRequired:
+ config.FunctionCallingConfig.Mode = "ANY"
+ case ChoiceNamed:
+ if choice.Kind != KindFunction {
+ return nil, []types.ConversionDiagnostic{semanticLoss("tool_choice", "unsupported_tool_choice", "Gemini generateContent does not expose an equivalent hosted-tool choice")}
+ }
+ config.FunctionCallingConfig.Mode = "ANY"
+ config.FunctionCallingConfig.AllowedFunctionNames = []string{choice.Name}
+ case ChoiceOpaque:
+ return nil, []types.ConversionDiagnostic{semanticLoss("tool_choice", "unsupported_tool_choice", "Gemini cannot represent the source complex tool-choice policy")}
+ }
+ return config, nil
+}
+
+func semanticLoss(path string, code string, message string) types.ConversionDiagnostic {
+ return types.ConversionDiagnostic{Code: code, Path: path, Message: message, Severity: types.ConversionDiagnosticError}
+}
+
+func presentationLoss(path string, code string, message string) types.ConversionDiagnostic {
+ return types.ConversionDiagnostic{Code: code, Path: path, Message: message, Severity: types.ConversionDiagnosticWarning}
+}
+
+func locationMap(location *ApproximateLocation) map[string]any {
+ value := map[string]any{
+ "city": location.City,
+ "region": location.Region,
+ "country": location.Country,
+ "timezone": location.Timezone,
+ }
+ deleteEmptyStrings(value)
+ return value
+}
+
+func deleteEmptyStrings(value map[string]any) {
+ for key, item := range value {
+ if text, ok := item.(string); ok && text == "" {
+ delete(value, key)
+ }
+ }
+}
+
+func functionParametersMap(parameters any) (map[string]interface{}, error) {
+ if parameters == nil {
+ return map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{},
+ }, nil
+ }
+ converted, err := kitutil.Any2Type[map[string]interface{}](parameters)
+ if err != nil {
+ return nil, err
+ }
+ if converted["type"] == nil {
+ converted["type"] = "object"
+ }
+ if converted["properties"] == nil {
+ converted["properties"] = map[string]interface{}{}
+ }
+ return converted, nil
+}
diff --git a/relaykit/relayconvert/internal/toolconv/hosted_values.go b/relaykit/relayconvert/internal/toolconv/hosted_values.go
new file mode 100644
index 000000000000..46723eef3b1d
--- /dev/null
+++ b/relaykit/relayconvert/internal/toolconv/hosted_values.go
@@ -0,0 +1,171 @@
+package toolconv
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+)
+
+// claudeWebSearchInputFromResponses narrows the richer Responses action union
+// to the only operation exposed by Claude's web-search server tool: one query.
+func claudeWebSearchInputFromResponses(raw json.RawMessage) (map[string]any, error) {
+ canonical, err := dto.NormalizeResponsesWebSearchAction(raw)
+ if err != nil {
+ return nil, err
+ }
+ var action struct {
+ Type string `json:"type"`
+ Query string `json:"query"`
+ Queries []string `json:"queries"`
+ }
+ if err := kitutil.Unmarshal(canonical, &action); err != nil {
+ return nil, fmt.Errorf("decode normalized Responses web-search action: %w", err)
+ }
+ if action.Type != "search" {
+ return nil, fmt.Errorf("Responses web-search action %q has no Claude equivalent", action.Type)
+ }
+
+ queries := make([]string, 0, len(action.Queries)+1)
+ for _, query := range action.Queries {
+ query = strings.TrimSpace(query)
+ if query != "" {
+ queries = append(queries, query)
+ }
+ }
+ deprecatedQuery := strings.TrimSpace(action.Query)
+ if len(queries) == 0 && deprecatedQuery != "" {
+ queries = append(queries, deprecatedQuery)
+ } else if deprecatedQuery != "" && (len(queries) != 1 || queries[0] != deprecatedQuery) {
+ return nil, fmt.Errorf("Responses web-search action contains conflicting query and queries fields")
+ }
+ if len(queries) != 1 {
+ return nil, fmt.Errorf("Claude web search requires exactly one query, got %d", len(queries))
+ }
+ return map[string]any{"query": queries[0]}, nil
+}
+
+// responsesMCPArgumentsFromClaude converts Claude's JSON-object input into the
+// JSON string required by a Responses mcp_call.arguments field.
+func responsesMCPArgumentsFromClaude(raw json.RawMessage) (json.RawMessage, error) {
+ trimmed := strings.TrimSpace(string(raw))
+ if trimmed == "" || kitutil.GetJsonType(raw) != "object" {
+ return nil, fmt.Errorf("Claude MCP input must be a JSON object")
+ }
+ var object map[string]json.RawMessage
+ if err := kitutil.Unmarshal(raw, &object); err != nil {
+ return nil, fmt.Errorf("decode Claude MCP input: %w", err)
+ }
+ encoded, err := kitutil.Marshal(trimmed)
+ if err != nil {
+ return nil, fmt.Errorf("encode Responses MCP arguments: %w", err)
+ }
+ return encoded, nil
+}
+
+// claudeMCPInputFromResponses decodes the outer Responses JSON string and
+// validates that its contents satisfy Claude's JSON-object input contract.
+func claudeMCPInputFromResponses(raw json.RawMessage) (any, error) {
+ var encoded string
+ if len(raw) == 0 || kitutil.GetJsonType(raw) != "string" {
+ return nil, fmt.Errorf("Responses MCP arguments must be a JSON string")
+ }
+ if err := kitutil.Unmarshal(raw, &encoded); err != nil {
+ return nil, fmt.Errorf("decode Responses MCP arguments string: %w", err)
+ }
+ encoded = strings.TrimSpace(encoded)
+ if encoded == "" || kitutil.GetJsonType(json.RawMessage(encoded)) != "object" {
+ return nil, fmt.Errorf("Responses MCP arguments must contain a JSON object")
+ }
+ var input map[string]any
+ if err := kitutil.Unmarshal([]byte(encoded), &input); err != nil {
+ return nil, fmt.Errorf("decode Responses MCP arguments object: %w", err)
+ }
+ return input, nil
+}
+
+// responsesMCPStringFromClaudeContent maps the Claude result shapes that can
+// be represented without changing their meaning. A single text block is the
+// structured form of a plain MCP text result; other block arrays can contain
+// media/resources that a Responses string cannot faithfully preserve.
+func responsesMCPStringFromClaudeContent(raw json.RawMessage) (json.RawMessage, bool, error) {
+ switch kitutil.GetJsonType(raw) {
+ case "string":
+ var value string
+ if err := kitutil.Unmarshal(raw, &value); err != nil {
+ return nil, false, fmt.Errorf("decode Claude MCP result string: %w", err)
+ }
+ return append(json.RawMessage(nil), raw...), false, nil
+ case "array":
+ var blocks []map[string]json.RawMessage
+ if err := kitutil.Unmarshal(raw, &blocks); err != nil {
+ return nil, false, fmt.Errorf("decode Claude MCP result blocks: %w", err)
+ }
+ if len(blocks) == 0 {
+ encoded, err := kitutil.Marshal("")
+ return encoded, true, err
+ }
+ if len(blocks) != 1 {
+ return nil, false, fmt.Errorf("Responses MCP output cannot preserve %d Claude content blocks", len(blocks))
+ }
+ var blockType string
+ if err := kitutil.Unmarshal(blocks[0]["type"], &blockType); err != nil || blockType != "text" {
+ return nil, false, fmt.Errorf("Responses MCP output can only preserve a Claude text result block")
+ }
+ var text string
+ if err := kitutil.Unmarshal(blocks[0]["text"], &text); err != nil {
+ return nil, false, fmt.Errorf("decode Claude MCP text result: %w", err)
+ }
+ encoded, err := kitutil.Marshal(text)
+ if err != nil {
+ return nil, false, fmt.Errorf("encode Responses MCP output: %w", err)
+ }
+ return encoded, true, nil
+ default:
+ return nil, false, fmt.Errorf("Responses MCP output cannot preserve Claude result type %q", kitutil.GetJsonType(raw))
+ }
+}
+
+func claudeMCPContentFromResponsesString(raw json.RawMessage) (string, error) {
+ if len(raw) == 0 || kitutil.GetJsonType(raw) != "string" {
+ return "", fmt.Errorf("Responses MCP output/error must be a JSON string")
+ }
+ var content string
+ if err := kitutil.Unmarshal(raw, &content); err != nil {
+ return "", fmt.Errorf("decode Responses MCP output/error string: %w", err)
+ }
+ return content, nil
+}
+
+func claudeHostedResultFailure(blockType string, content json.RawMessage, explicitError *bool, explicitCode string) (bool, string) {
+ if explicitError != nil && *explicitError {
+ return true, strings.TrimSpace(explicitCode)
+ }
+ if strings.TrimSpace(explicitCode) != "" {
+ return true, strings.TrimSpace(explicitCode)
+ }
+ if !strings.HasSuffix(strings.TrimSpace(blockType), "_tool_result") || kitutil.GetJsonType(content) != "object" {
+ return false, ""
+ }
+ var resultError struct {
+ Type string `json:"type"`
+ ErrorCode string `json:"error_code"`
+ }
+ if kitutil.Unmarshal(content, &resultError) != nil {
+ return false, ""
+ }
+ if !strings.HasSuffix(strings.TrimSpace(resultError.Type), "_error") && strings.TrimSpace(resultError.ErrorCode) == "" {
+ return false, ""
+ }
+ return true, strings.TrimSpace(resultError.ErrorCode)
+}
+
+func responsesMCPErrorFromClaudeContent(raw json.RawMessage, errorCode string) (json.RawMessage, bool, error) {
+ if errorCode = strings.TrimSpace(errorCode); errorCode != "" {
+ encoded, err := kitutil.Marshal(errorCode)
+ return encoded, false, err
+ }
+ return responsesMCPStringFromClaudeContent(raw)
+}
diff --git a/relaykit/relayconvert/internal/toolconv/model.go b/relaykit/relayconvert/internal/toolconv/model.go
new file mode 100644
index 000000000000..21e4d8ec1303
--- /dev/null
+++ b/relaykit/relayconvert/internal/toolconv/model.go
@@ -0,0 +1,118 @@
+package toolconv
+
+import (
+ "encoding/json"
+
+ "github.com/QuantumNous/new-api/relaykit/types"
+)
+
+type Kind string
+
+const (
+ KindFunction Kind = "function"
+ KindWebSearch Kind = "web_search"
+ KindFileSearch Kind = "file_search"
+ KindWebFetch Kind = "web_fetch"
+ KindCodeExecution Kind = "code_execution"
+ KindComputerUse Kind = "computer_use"
+ KindURLContext Kind = "url_context"
+ KindMCP Kind = "mcp"
+ KindImage Kind = "image_generation"
+ KindNative Kind = "native"
+)
+
+type Execution string
+
+const (
+ ExecutionClient Execution = "client"
+ ExecutionServer Execution = "server"
+)
+
+type Function struct {
+ Name string
+ Description string
+ Parameters any
+ Strict *bool
+}
+
+type ApproximateLocation struct {
+ City string
+ Region string
+ Country string
+ Timezone string
+}
+
+type WebSearch struct {
+ Location *ApproximateLocation
+ AllowedDomains []string
+ BlockedDomains []string
+ SearchContextSize string
+ MaxUses *int
+ AllowedCallers []string
+ ResponseInclusion string
+ ExternalWebAccess *bool
+ ReturnTokenBudget json.RawMessage
+}
+
+type Definition struct {
+ Kind Kind
+ Execution Execution
+ NativeType string
+ Name string
+ Function *Function
+ WebSearch *WebSearch
+ Raw json.RawMessage
+ Group int
+}
+
+type ChoiceMode string
+
+const (
+ ChoiceAuto ChoiceMode = "auto"
+ ChoiceNone ChoiceMode = "none"
+ ChoiceRequired ChoiceMode = "required"
+ ChoiceNamed ChoiceMode = "named"
+ ChoiceOpaque ChoiceMode = "opaque"
+)
+
+type Choice struct {
+ Mode ChoiceMode
+ Kind Kind
+ Name string
+ AllowedNames []string
+ NativeType string
+ DisableParallelToolUse *bool
+ Raw json.RawMessage
+}
+
+type Set struct {
+ Source types.RelayFormat
+ Definitions []Definition
+ Choice *Choice
+ ParallelAllowed *bool
+ NativeToolConfig json.RawMessage
+ History []HostedHistoryItem
+}
+
+func (s Set) Empty() bool {
+ return len(s.Definitions) == 0 && s.Choice == nil && s.ParallelAllowed == nil && len(s.NativeToolConfig) == 0 && len(s.History) == 0
+}
+
+type HostedHistoryItem struct {
+ Kind Kind
+ NativeType string
+ Role string
+ MessageIndex int
+ BlockIndex int
+ MessageHasRegular bool
+ Sequence int
+ ID string
+ CallID string
+ Name string
+ ServerName string
+ Status string
+ Action json.RawMessage
+ Results json.RawMessage
+ Caller json.RawMessage
+ Raw json.RawMessage
+}
diff --git a/relaykit/relayconvert/internal/toolconv/policy_test.go b/relaykit/relayconvert/internal/toolconv/policy_test.go
new file mode 100644
index 000000000000..cdf9cae29183
--- /dev/null
+++ b/relaykit/relayconvert/internal/toolconv/policy_test.go
@@ -0,0 +1,103 @@
+package toolconv
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
+ kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+ "github.com/QuantumNous/new-api/relaykit/types"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func geminiCodeExecutionRequest(t *testing.T) *dto.GeminiChatRequest {
+ t.Helper()
+ tools, err := kitutil.Marshal([]map[string]any{{"codeExecution": map[string]any{}}})
+ require.NoError(t, err)
+ return &dto.GeminiChatRequest{
+ Contents: []dto.GeminiChatContent{
+ {Role: "user", Parts: []dto.GeminiPart{{Text: "run this"}}},
+ },
+ Tools: tools,
+ }
+}
+
+func hasDiagnosticCode(diagnostics []types.ConversionDiagnostic, code string) bool {
+ for _, diagnostic := range diagnostics {
+ if diagnostic.Code == code {
+ return true
+ }
+ }
+ return false
+}
+
+func TestDefaultPolicyAllowsGeminiCodeExecutionToOpenAI(t *testing.T) {
+ t.Parallel()
+
+ _, set, err := ExtractRequest(types.RelayFormatGemini, geminiCodeExecutionRequest(t))
+ require.NoError(t, err)
+ target := &dto.GeneralOpenAIRequest{
+ Model: "gpt-4o",
+ Messages: []dto.Message{{Role: "user", Content: "run this"}},
+ }
+
+ out, diagnostics, err := AttachRequest(types.RelayFormatOpenAI, target, set, &convmeta.Options{})
+ require.NoError(t, err)
+ require.NotNil(t, out)
+ assert.True(t, hasDiagnosticCode(diagnostics, "unsupported_hosted_tool"))
+ assert.Equal(t, types.ConversionLossPolicyAllow, (&convmeta.Options{}).EffectiveToolLossPolicy())
+}
+
+func TestResponsePhaseNeverRejectsEvenUnderStrictPolicy(t *testing.T) {
+ t.Parallel()
+
+ text := "hello"
+ resp := &dto.ClaudeResponse{
+ Type: "message",
+ Role: "assistant",
+ StopReason: "pause_turn",
+ Content: []dto.ClaudeMediaMessage{
+ {Type: "redacted_thinking", Data: "secret"},
+ {Type: "text", Text: &text},
+ },
+ }
+ diagnostics := InspectResponse(types.RelayFormatClaude, types.RelayFormatOpenAI, resp)
+ require.True(t, hasDiagnosticCode(diagnostics, "continuation_state_lost"))
+ require.Error(t, types.RejectConversionLoss(types.ConversionLossPolicyStrict, diagnostics))
+
+ _, hosted, err := ExtractHostedResponse(types.RelayFormatClaude, resp)
+ require.NoError(t, err)
+ out, _, err := AttachHostedResponse(
+ types.RelayFormatOpenAI,
+ &dto.OpenAITextResponse{},
+ hosted,
+ &convmeta.Options{ToolLossPolicy: types.ConversionLossPolicyStrict},
+ )
+ require.NoError(t, err)
+ require.NotNil(t, out)
+}
+
+func TestSafePolicyRejectsRequestPhaseHostedToolLoss(t *testing.T) {
+ t.Parallel()
+
+ _, set, err := ExtractRequest(types.RelayFormatGemini, geminiCodeExecutionRequest(t))
+ require.NoError(t, err)
+ target := &dto.GeneralOpenAIRequest{
+ Model: "gpt-4o",
+ Messages: []dto.Message{{Role: "user", Content: "run this"}},
+ }
+
+ _, diagnostics, err := AttachRequest(
+ types.RelayFormatOpenAI,
+ target,
+ set,
+ &convmeta.Options{ToolLossPolicy: types.ConversionLossPolicySafe},
+ )
+ require.Error(t, err)
+ var loss *types.ConversionLossError
+ require.ErrorAs(t, err, &loss)
+ require.NotEmpty(t, loss.Diagnostics)
+ assert.True(t, hasDiagnosticCode(loss.Diagnostics, "unsupported_hosted_tool"))
+ assert.True(t, hasDiagnosticCode(diagnostics, "unsupported_hosted_tool"))
+}
diff --git a/relaykit/relayconvert/internal/toolconv/response.go b/relaykit/relayconvert/internal/toolconv/response.go
new file mode 100644
index 000000000000..1eb2cde959bc
--- /dev/null
+++ b/relaykit/relayconvert/internal/toolconv/response.go
@@ -0,0 +1,515 @@
+package toolconv
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+ "github.com/QuantumNous/new-api/relaykit/types"
+)
+
+// InspectResponse reports protocol information that the current response
+// converters cannot faithfully express. It keeps loss handling centralized so
+// direct and multi-step routes behave consistently.
+func InspectResponse(from types.RelayFormat, to types.RelayFormat, response any) []types.ConversionDiagnostic {
+ if from == to {
+ return nil
+ }
+ var diagnostics []types.ConversionDiagnostic
+ switch value := response.(type) {
+ case *dto.ClaudeResponse:
+ diagnostics = inspectClaudeResponse(value, to)
+ case dto.ClaudeResponse:
+ diagnostics = inspectClaudeResponse(&value, to)
+ case *dto.OpenAIResponsesResponse:
+ diagnostics = inspectOpenAIResponsesResponse(value, to)
+ case dto.OpenAIResponsesResponse:
+ diagnostics = inspectOpenAIResponsesResponse(&value, to)
+ case *dto.ResponsesStreamResponse:
+ diagnostics = inspectOpenAIResponsesStreamResponse(value)
+ case dto.ResponsesStreamResponse:
+ diagnostics = inspectOpenAIResponsesStreamResponse(&value)
+ case *dto.GeminiChatResponse:
+ diagnostics = inspectGeminiResponse(value, to)
+ case dto.GeminiChatResponse:
+ diagnostics = inspectGeminiResponse(&value, to)
+ }
+ for index := range diagnostics {
+ diagnostics[index].From = from
+ diagnostics[index].To = to
+ }
+ return diagnostics
+}
+
+// InspectStreamResponse avoids treating a single Gemini streaming chunk as a
+// complete grounding document. Gemini grounding chunk indexes and segment
+// offsets are cumulative across the stream; the stateful converter validates
+// and resolves them after accumulating prior chunks.
+func InspectStreamResponse(from types.RelayFormat, to types.RelayFormat, response any) []types.ConversionDiagnostic {
+ if from != types.RelayFormatGemini {
+ return InspectResponse(from, to, response)
+ }
+ var value *dto.GeminiChatResponse
+ switch response := response.(type) {
+ case *dto.GeminiChatResponse:
+ value = response
+ case dto.GeminiChatResponse:
+ value = &response
+ default:
+ return InspectResponse(from, to, response)
+ }
+ if value == nil {
+ return nil
+ }
+ var diagnostics []types.ConversionDiagnostic
+ for index := range value.Candidates {
+ metadata := value.Candidates[index].GroundingMetadata
+ if metadata == nil {
+ continue
+ }
+ if len(metadata.WebSearchQueries) > 0 && to != types.RelayFormatOpenAIResponses {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ fmt.Sprintf("candidates[%d].groundingMetadata.webSearchQueries", index),
+ "web_search_call_unrepresentable",
+ "Gemini grounding confirms a hosted web search, but the target stream converter cannot produce an OpenAI Responses web_search_call lifecycle",
+ ))
+ }
+ if len(metadata.WebSearchQueries) == 0 && len(metadata.RetrievalQueries) == 0 && len(metadata.SearchEntryPoint) == 0 && len(metadata.RetrievalMetadata) == 0 && len(metadata.SourceFlaggingUris) == 0 && metadata.GoogleMapsWidgetContextToken == "" {
+ continue
+ }
+ diagnostics = append(diagnostics, responsePresentationLoss(
+ fmt.Sprintf("candidates[%d].groundingMetadata", index),
+ "hosted_tool_metadata_reduced",
+ "Gemini grounding citations are preserved across stream chunks, but provider-specific search metadata has no target-protocol equivalent",
+ ))
+ }
+ for index := range diagnostics {
+ diagnostics[index].From = from
+ diagnostics[index].To = to
+ }
+ return diagnostics
+}
+
+func inspectClaudeResponse(response *dto.ClaudeResponse, to types.RelayFormat) []types.ConversionDiagnostic {
+ if response == nil {
+ return nil
+ }
+ var diagnostics []types.ConversionDiagnostic
+ if to != types.RelayFormatOpenAIResponses && (response.StopReason == "pause_turn" || response.Delta != nil && response.Delta.StopReason != nil && *response.Delta.StopReason == "pause_turn") {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ "stop_reason",
+ "continuation_state_lost",
+ "Claude pause_turn requires protocol-native continuation state that the target response cannot preserve",
+ ))
+ }
+ for index := range response.Content {
+ diagnostics = append(diagnostics, inspectClaudeContentBlock(&response.Content[index], fmt.Sprintf("content[%d]", index), to, false)...)
+ }
+ if response.ContentBlock != nil {
+ diagnostics = append(diagnostics, inspectClaudeContentBlock(response.ContentBlock, "content_block", to, true)...)
+ }
+ return diagnostics
+}
+
+func inspectClaudeContentBlock(block *dto.ClaudeMediaMessage, path string, to types.RelayFormat, stream bool) []types.ConversionDiagnostic {
+ if block == nil {
+ return nil
+ }
+ blockType := strings.TrimSpace(block.Type)
+ var diagnostics []types.ConversionDiagnostic
+ if isClaudeHostedToolBlock(blockType) {
+ kind := KindNative
+ if blockType == "server_tool_use" || blockType == "mcp_tool_use" {
+ kind = hostedKindFromClaudeCall(blockType, block.Name)
+ } else {
+ kind = hostedKindFromClaudeResult(blockType)
+ }
+ if to != types.RelayFormatOpenAIResponses || kind != KindWebSearch && kind != KindMCP {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ path,
+ "hosted_tool_unrepresentable",
+ fmt.Sprintf("%s cannot losslessly represent Claude hosted-tool response block %q", to, blockType),
+ ))
+ } else if blockType == "server_tool_use" || blockType == "mcp_tool_use" {
+ if block.Id == "" {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ path+".id",
+ "hosted_tool_id_missing",
+ "Claude hosted-tool call has no id for pairing it with its result",
+ ))
+ }
+ if kind == KindMCP && (block.Name == "" || block.ServerName == "") {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ path,
+ "mcp_identity_missing",
+ "Claude MCP tool use must include both name and server_name for Responses MCP mapping",
+ ))
+ }
+ if rawJSONPresent(block.Caller) {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ path+".caller",
+ "hosted_tool_caller_unrepresentable",
+ "OpenAI Responses web_search_call and mcp_call items cannot preserve Claude's hosted-tool caller provenance",
+ ))
+ }
+ if !stream {
+ input, err := kitutil.Marshal(block.Input)
+ if err != nil {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ path+".input",
+ "hosted_tool_input_invalid",
+ err.Error(),
+ ))
+ } else if kind == KindMCP {
+ if _, err := responsesMCPArgumentsFromClaude(input); err != nil {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ path+".input",
+ "mcp_arguments_unrepresentable",
+ err.Error(),
+ ))
+ }
+ } else if kind == KindWebSearch {
+ if _, err := dto.NormalizeResponsesWebSearchAction(input); err != nil {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ path+".input",
+ "web_search_action_unrepresentable",
+ err.Error(),
+ ))
+ }
+ }
+ }
+ } else if block.ToolUseId == "" {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ path+".tool_use_id",
+ "hosted_tool_id_missing",
+ "Claude hosted-tool result has no tool_use_id for pairing it with its call",
+ ))
+ }
+ if kind == KindMCP && blockType == "mcp_tool_result" {
+ content, err := kitutil.Marshal(block.Content)
+ if err != nil {
+ diagnostics = append(diagnostics, responseSemanticLoss(path+".content", "mcp_result_unrepresentable", err.Error()))
+ } else {
+ failed, errorCode := claudeHostedResultFailure(blockType, content, block.IsError, block.ErrorCode)
+ var normalized bool
+ if failed {
+ _, normalized, err = responsesMCPErrorFromClaudeContent(content, errorCode)
+ } else {
+ _, normalized, err = responsesMCPStringFromClaudeContent(content)
+ }
+ if err != nil {
+ diagnostics = append(diagnostics, responseSemanticLoss(path+".content", "mcp_result_unrepresentable", err.Error()))
+ } else if normalized {
+ diagnostics = append(diagnostics, responsePresentationLoss(
+ path+".content",
+ "mcp_text_result_normalized",
+ "Claude's single MCP text block is normalized to a Responses output string",
+ ))
+ }
+ }
+ }
+ }
+ if blockType == "redacted_thinking" && block.Data != "" {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ path+".data",
+ "continuation_state_lost",
+ "Claude encrypted thinking state cannot be represented by the target response",
+ ))
+ }
+ return diagnostics
+}
+
+func isClaudeHostedToolBlock(blockType string) bool {
+ if blockType == "server_tool_use" || blockType == "mcp_tool_use" || blockType == "mcp_tool_result" {
+ return true
+ }
+ return strings.HasSuffix(blockType, "_tool_result")
+}
+
+func inspectOpenAIResponsesResponse(response *dto.OpenAIResponsesResponse, to types.RelayFormat) []types.ConversionDiagnostic {
+ if response == nil {
+ return nil
+ }
+ var diagnostics []types.ConversionDiagnostic
+ for index := range response.Output {
+ output := &response.Output[index]
+ if !isResponsesHostedOutput(output.Type) {
+ continue
+ }
+ kind := hostedKindFromResponsesType(output.Type)
+ if to != types.RelayFormatClaude || kind != KindWebSearch && kind != KindMCP {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ fmt.Sprintf("output[%d]", index),
+ "hosted_tool_unrepresentable",
+ fmt.Sprintf("%s cannot losslessly represent OpenAI Responses hosted-tool output %q", to, output.Type),
+ ))
+ continue
+ }
+ if output.ID == "" {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ fmt.Sprintf("output[%d].id", index),
+ "hosted_tool_id_missing",
+ "hosted-tool output has no id for pairing the call with its result",
+ ))
+ }
+ if kind == KindMCP {
+ if output.Name == "" || output.ServerLabel == "" {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ fmt.Sprintf("output[%d]", index),
+ "mcp_identity_missing",
+ "Responses MCP output must include both name and server_label for Claude MCP mapping",
+ ))
+ }
+ if output.ApprovalRequestID != "" {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ fmt.Sprintf("output[%d].approval_request_id", index),
+ "mcp_approval_state_unrepresentable",
+ "Claude MCP response blocks cannot preserve a Responses approval_request_id",
+ ))
+ }
+ if _, err := claudeMCPInputFromResponses(output.Arguments); err != nil {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ fmt.Sprintf("output[%d].arguments", index),
+ "mcp_arguments_unrepresentable",
+ err.Error(),
+ ))
+ }
+ if rawJSONPresent(output.Output) && rawJSONPresent(output.ItemError) {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ fmt.Sprintf("output[%d]", index),
+ "mcp_result_ambiguous",
+ "Responses MCP output contains both output and error",
+ ))
+ }
+ for _, field := range []struct {
+ name string
+ raw json.RawMessage
+ }{{name: "output", raw: output.Output}, {name: "error", raw: output.ItemError}} {
+ if !rawJSONPresent(field.raw) {
+ continue
+ }
+ if _, err := claudeMCPContentFromResponsesString(field.raw); err != nil {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ fmt.Sprintf("output[%d].%s", index, field.name),
+ "mcp_result_unrepresentable",
+ err.Error(),
+ ))
+ }
+ }
+ } else if kind == KindWebSearch {
+ if _, err := claudeWebSearchInputFromResponses(output.Action); err != nil {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ fmt.Sprintf("output[%d].action", index),
+ "web_search_action_unrepresentable",
+ err.Error(),
+ ))
+ }
+ }
+ if output.Status != "" && output.Status != "in_progress" && output.Status != "completed" && output.Status != "failed" {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ fmt.Sprintf("output[%d].status", index),
+ "hosted_tool_status_unrepresentable",
+ fmt.Sprintf("Claude cannot preserve hosted-tool status %q", output.Status),
+ ))
+ }
+ if output.Status == "failed" && !rawJSONPresent(output.ItemError) && !rawJSONPresent(output.Output) && !rawJSONPresent(output.Results) {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ fmt.Sprintf("output[%d].status", index),
+ "hosted_tool_error_missing",
+ "failed hosted-tool output has no error or output that Claude can preserve",
+ ))
+ }
+ }
+ return diagnostics
+}
+
+func inspectOpenAIResponsesStreamResponse(response *dto.ResponsesStreamResponse) []types.ConversionDiagnostic {
+ if response == nil {
+ return nil
+ }
+ if response.Item != nil && isResponsesHostedOutput(response.Item.Type) {
+ return []types.ConversionDiagnostic{responseSemanticLoss(
+ "item",
+ "hosted_tool_event_unrepresentable",
+ fmt.Sprintf("OpenAI Responses hosted-tool output %q has no semantic target-protocol stream mapping", response.Item.Type),
+ )}
+ }
+ eventType := strings.TrimSpace(response.Type)
+ if strings.Contains(eventType, ".web_search_call.") ||
+ strings.Contains(eventType, ".file_search_call.") ||
+ strings.Contains(eventType, ".code_interpreter_call.") ||
+ strings.Contains(eventType, ".computer_tool_call.") ||
+ strings.Contains(eventType, ".image_generation_call.") ||
+ strings.Contains(eventType, ".mcp_call.") {
+ return []types.ConversionDiagnostic{responseSemanticLoss(
+ "type",
+ "hosted_tool_event_unrepresentable",
+ fmt.Sprintf("OpenAI Responses hosted-tool stream event %q has no semantic target-protocol mapping", eventType),
+ )}
+ }
+ return nil
+}
+
+func isResponsesHostedOutput(outputType string) bool {
+ switch strings.TrimSpace(outputType) {
+ case "", "message", "reasoning", "function_call", "custom_tool_call":
+ return false
+ default:
+ return true
+ }
+}
+
+func inspectGeminiResponse(response *dto.GeminiChatResponse, to types.RelayFormat) []types.ConversionDiagnostic {
+ if response == nil {
+ return nil
+ }
+ var diagnostics []types.ConversionDiagnostic
+ for index := range response.Candidates {
+ metadata := response.Candidates[index].GroundingMetadata
+ if metadata == nil {
+ continue
+ }
+ path := fmt.Sprintf("candidates[%d].groundingMetadata", index)
+ if len(metadata.WebSearchQueries) > 0 && to != types.RelayFormatOpenAIResponses {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ path+".webSearchQueries",
+ "web_search_call_unrepresentable",
+ "Gemini grounding confirms a hosted web search, but the target converter cannot produce an OpenAI Responses web_search_call item",
+ ))
+ }
+ diagnostics = append(diagnostics, inspectGeminiGroundingCitations(response.Candidates[index].Content, metadata, path)...)
+ if len(metadata.WebSearchQueries) == 0 && len(metadata.RetrievalQueries) == 0 && len(metadata.SearchEntryPoint) == 0 && len(metadata.RetrievalMetadata) == 0 && len(metadata.SourceFlaggingUris) == 0 && metadata.GoogleMapsWidgetContextToken == "" {
+ continue
+ }
+ diagnostics = append(diagnostics, responsePresentationLoss(
+ path,
+ "hosted_tool_metadata_reduced",
+ "Gemini grounding citations are preserved, but provider-specific search metadata has no target-protocol equivalent",
+ ))
+ }
+ return diagnostics
+}
+
+type groundingSupportForInspection struct {
+ Segment struct {
+ PartIndex *int `json:"partIndex,omitempty"`
+ StartIndex int `json:"startIndex,omitempty"`
+ EndIndex int `json:"endIndex,omitempty"`
+ Text string `json:"text,omitempty"`
+ } `json:"segment"`
+ GroundingChunkIndices []int `json:"groundingChunkIndices"`
+}
+
+type groundingChunkForInspection struct {
+ Web *groundingSourceForInspection `json:"web,omitempty"`
+ RetrievedContext *groundingSourceForInspection `json:"retrievedContext,omitempty"`
+}
+
+type groundingSourceForInspection struct {
+ URI string `json:"uri,omitempty"`
+}
+
+func inspectGeminiGroundingCitations(content dto.GeminiChatContent, metadata *dto.GeminiGroundingMetadata, path string) []types.ConversionDiagnostic {
+ if len(metadata.GroundingSupports) == 0 {
+ return nil
+ }
+ var chunks []groundingChunkForInspection
+ if len(metadata.GroundingChunks) == 0 || kitutil.Unmarshal(metadata.GroundingChunks, &chunks) != nil {
+ return []types.ConversionDiagnostic{responseSemanticLoss(
+ path+".groundingChunks",
+ "grounding_source_invalid",
+ "Gemini grounding chunks are missing or cannot be decoded",
+ )}
+ }
+ var supports []groundingSupportForInspection
+ if err := kitutil.Unmarshal(metadata.GroundingSupports, &supports); err != nil {
+ return []types.ConversionDiagnostic{responseSemanticLoss(
+ path+".groundingSupports",
+ "grounding_citation_invalid",
+ fmt.Sprintf("Gemini grounding supports cannot be decoded: %v", err),
+ )}
+ }
+ textPartCount := 0
+ soleTextPart := -1
+ for index := range content.Parts {
+ if content.Parts[index].Text == "" || content.Parts[index].Thought {
+ continue
+ }
+ textPartCount++
+ soleTextPart = index
+ }
+ var diagnostics []types.ConversionDiagnostic
+ for index, support := range supports {
+ segmentPath := fmt.Sprintf("%s.groundingSupports[%d].segment", path, index)
+ hasSource := false
+ for _, chunkIndex := range support.GroundingChunkIndices {
+ if chunkIndex < 0 || chunkIndex >= len(chunks) {
+ continue
+ }
+ source := chunks[chunkIndex].Web
+ if source == nil {
+ source = chunks[chunkIndex].RetrievedContext
+ }
+ if source != nil && source.URI != "" {
+ hasSource = true
+ break
+ }
+ }
+ if !hasSource {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ fmt.Sprintf("%s.groundingSupports[%d].groundingChunkIndices", path, index),
+ "grounding_source_invalid",
+ "Gemini grounding support does not reference a valid source URI",
+ ))
+ continue
+ }
+ partIndex := soleTextPart
+ if support.Segment.PartIndex != nil {
+ partIndex = *support.Segment.PartIndex
+ } else if textPartCount != 1 {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ segmentPath+".partIndex",
+ "grounding_part_ambiguous",
+ "Gemini grounding omitted partIndex while multiple text parts are present, so citation placement is ambiguous",
+ ))
+ continue
+ }
+ if partIndex < 0 || partIndex >= len(content.Parts) || content.Parts[partIndex].Text == "" || content.Parts[partIndex].Thought {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ segmentPath+".partIndex",
+ "grounding_part_invalid",
+ fmt.Sprintf("Gemini grounding references non-text part %d", partIndex),
+ ))
+ continue
+ }
+ partText := content.Parts[partIndex].Text
+ start, end := support.Segment.StartIndex, support.Segment.EndIndex
+ if start < 0 || end <= start || end > len(partText) || !utf8.ValidString(partText[:start]) || !utf8.ValidString(partText[:end]) {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ segmentPath,
+ "grounding_offset_invalid",
+ "Gemini grounding byte offsets do not identify valid UTF-8 boundaries in the referenced part",
+ ))
+ continue
+ }
+ if support.Segment.Text != "" && partText[start:end] != support.Segment.Text {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ segmentPath+".text",
+ "grounding_text_mismatch",
+ "Gemini grounding segment text does not match the referenced part range",
+ ))
+ }
+ }
+ return diagnostics
+}
+
+func responseSemanticLoss(path string, code string, message string) types.ConversionDiagnostic {
+ return types.ConversionDiagnostic{Code: code, Path: path, Message: message, Severity: types.ConversionDiagnosticError}
+}
+
+func responsePresentationLoss(path string, code string, message string) types.ConversionDiagnostic {
+ return types.ConversionDiagnostic{Code: code, Path: path, Message: message, Severity: types.ConversionDiagnosticWarning}
+}
diff --git a/relaykit/relayconvert/internal/toolconv/response_artifacts.go b/relaykit/relayconvert/internal/toolconv/response_artifacts.go
new file mode 100644
index 000000000000..856b44ef0b65
--- /dev/null
+++ b/relaykit/relayconvert/internal/toolconv/response_artifacts.go
@@ -0,0 +1,816 @@
+package toolconv
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
+ geminichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/gemini_chat"
+ kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+ "github.com/QuantumNous/new-api/relaykit/types"
+)
+
+type HostedResponseItem struct {
+ Kind Kind
+ NativeType string
+ ID string
+ CallID string
+ Name string
+ Status string
+ Position int
+ Action json.RawMessage
+ Results json.RawMessage
+ Sources json.RawMessage
+ Caller json.RawMessage
+ Arguments json.RawMessage
+ Output json.RawMessage
+ Error json.RawMessage
+ ServerName string
+ IsError *bool
+ ApprovalRequestID string
+ Tools json.RawMessage
+ ErrorCode string
+ Raw json.RawMessage
+}
+
+type HostedResponseSet struct {
+ Source types.RelayFormat
+ Items []HostedResponseItem
+ SourceLength int
+ RegularPositions []int
+}
+
+type positionedResponsesOutput struct {
+ position int
+ output dto.ResponsesOutput
+}
+
+type positionedClaudeBlocks struct {
+ position int
+ blocks []dto.ClaudeMediaMessage
+}
+
+func (s HostedResponseSet) Empty() bool {
+ return len(s.Items) == 0
+}
+
+// ExtractHostedResponse removes server-executed tool artifacts before a
+// message converter sees them. The artifacts travel beside multi-step routes,
+// just like request tool definitions, so a lossy Chat pivot cannot reclassify
+// or discard them.
+func ExtractHostedResponse(format types.RelayFormat, response any) (any, HostedResponseSet, error) {
+ switch format {
+ case types.RelayFormatClaude:
+ return extractClaudeHostedResponse(response)
+ case types.RelayFormatOpenAIResponses:
+ return extractOpenAIHostedResponse(response)
+ case types.RelayFormatGemini:
+ return extractGeminiHostedResponse(response)
+ default:
+ return response, HostedResponseSet{Source: format}, nil
+ }
+}
+
+func AttachHostedResponse(format types.RelayFormat, response any, set HostedResponseSet, options *convmeta.Options) (any, []types.ConversionDiagnostic, error) {
+ if set.Empty() {
+ return response, nil, nil
+ }
+ var (
+ value any
+ diagnostics []types.ConversionDiagnostic
+ err error
+ )
+ switch format {
+ case types.RelayFormatOpenAIResponses:
+ value, diagnostics, err = attachOpenAIHostedResponse(response, set)
+ case types.RelayFormatClaude:
+ value, diagnostics, err = attachClaudeHostedResponse(response, set)
+ default:
+ value = response
+ for index, item := range set.Items {
+ diagnostics = append(diagnostics, responsePresentationLoss(
+ fmt.Sprintf("hosted_tools[%d]", index),
+ "hosted_tool_event_omitted",
+ fmt.Sprintf("%s cannot represent hosted-tool response %q", format, item.NativeType),
+ ))
+ }
+ }
+ if err != nil {
+ return nil, diagnostics, err
+ }
+ for index := range diagnostics {
+ diagnostics[index].From = set.Source
+ diagnostics[index].To = format
+ }
+ return value, diagnostics, nil
+}
+
+func extractClaudeHostedResponse(response any) (any, HostedResponseSet, error) {
+ var source *dto.ClaudeResponse
+ switch value := response.(type) {
+ case *dto.ClaudeResponse:
+ source = value
+ case dto.ClaudeResponse:
+ source = &value
+ default:
+ return nil, HostedResponseSet{}, fmt.Errorf("expected Claude response, got %T", response)
+ }
+ clone := *source
+ clone.Content = make([]dto.ClaudeMediaMessage, 0, len(source.Content))
+ set := HostedResponseSet{Source: types.RelayFormatClaude, SourceLength: len(source.Content)}
+ for position := range source.Content {
+ block := source.Content[position]
+ blockType := strings.TrimSpace(block.Type)
+ switch {
+ case blockType == "server_tool_use" || blockType == "mcp_tool_use":
+ rawBlock, err := kitutil.Marshal(block)
+ if err != nil {
+ return nil, set, fmt.Errorf("content[%d]: %w", position, err)
+ }
+ action, err := kitutil.Marshal(block.Input)
+ if err != nil {
+ return nil, set, fmt.Errorf("content[%d].input: %w", position, err)
+ }
+ item := HostedResponseItem{
+ Kind: hostedKindFromClaudeCall(blockType, block.Name),
+ NativeType: blockType,
+ ID: block.Id,
+ CallID: block.Id,
+ Name: block.Name,
+ Status: "in_progress",
+ Position: position,
+ Action: action,
+ Caller: append(json.RawMessage(nil), block.Caller...),
+ ServerName: block.ServerName,
+ Raw: rawBlock,
+ }
+ set.Items = append(set.Items, item)
+ case isClaudeHostedToolBlock(blockType):
+ rawBlock, err := kitutil.Marshal(block)
+ if err != nil {
+ return nil, set, fmt.Errorf("content[%d]: %w", position, err)
+ }
+ results, err := kitutil.Marshal(block.Content)
+ if err != nil {
+ return nil, set, fmt.Errorf("content[%d].content: %w", position, err)
+ }
+ failed, errorCode := claudeHostedResultFailure(blockType, results, block.IsError, block.ErrorCode)
+ status := "completed"
+ isError := block.IsError
+ if failed {
+ status = "failed"
+ if isError == nil {
+ value := true
+ isError = &value
+ }
+ }
+ set.Items = append(set.Items, HostedResponseItem{
+ Kind: hostedKindFromClaudeResult(blockType),
+ NativeType: blockType,
+ ID: block.ToolUseId,
+ CallID: block.ToolUseId,
+ Status: status,
+ Position: position,
+ Results: results,
+ ErrorCode: errorCode,
+ IsError: isError,
+ Raw: rawBlock,
+ })
+ default:
+ clone.Content = append(clone.Content, block)
+ set.RegularPositions = append(set.RegularPositions, position)
+ }
+ }
+ return &clone, set, nil
+}
+
+func extractOpenAIHostedResponse(response any) (any, HostedResponseSet, error) {
+ var source *dto.OpenAIResponsesResponse
+ switch value := response.(type) {
+ case *dto.OpenAIResponsesResponse:
+ source = value
+ case dto.OpenAIResponsesResponse:
+ source = &value
+ default:
+ return nil, HostedResponseSet{}, fmt.Errorf("expected OpenAI Responses response, got %T", response)
+ }
+ clone := *source
+ clone.Output = make([]dto.ResponsesOutput, 0, len(source.Output))
+ set := HostedResponseSet{Source: types.RelayFormatOpenAIResponses, SourceLength: len(source.Output)}
+ for position := range source.Output {
+ output := source.Output[position]
+ if !isResponsesHostedOutput(output.Type) {
+ clone.Output = append(clone.Output, output)
+ set.RegularPositions = append(set.RegularPositions, position)
+ continue
+ }
+ rawOutput, err := kitutil.Marshal(output)
+ if err != nil {
+ return nil, set, fmt.Errorf("output[%d]: %w", position, err)
+ }
+ set.Items = append(set.Items, HostedResponseItem{
+ Kind: hostedKindFromResponsesType(output.Type),
+ NativeType: output.Type,
+ ID: output.ID,
+ CallID: output.CallId,
+ Name: output.Name,
+ Status: output.Status,
+ Position: position,
+ Action: append(json.RawMessage(nil), output.Action...),
+ Results: append(json.RawMessage(nil), output.Results...),
+ Sources: append(json.RawMessage(nil), output.Sources...),
+ Caller: append(json.RawMessage(nil), output.Caller...),
+ Arguments: append(json.RawMessage(nil), output.Arguments...),
+ Output: append(json.RawMessage(nil), output.Output...),
+ Error: append(json.RawMessage(nil), output.ItemError...),
+ ServerName: output.ServerLabel,
+ ApprovalRequestID: output.ApprovalRequestID,
+ Tools: append(json.RawMessage(nil), output.MCPTools...),
+ Raw: rawOutput,
+ })
+ }
+ return &clone, set, nil
+}
+
+func extractGeminiHostedResponse(response any) (any, HostedResponseSet, error) {
+ var source *dto.GeminiChatResponse
+ switch value := response.(type) {
+ case *dto.GeminiChatResponse:
+ source = value
+ case dto.GeminiChatResponse:
+ source = &value
+ default:
+ return nil, HostedResponseSet{}, fmt.Errorf("expected Gemini response, got %T", response)
+ }
+ queries := geminichat.GroundingWebSearchQueries(source)
+ if len(queries) == 0 {
+ return source, HostedResponseSet{Source: types.RelayFormatGemini}, nil
+ }
+ action, err := kitutil.Marshal(map[string]any{
+ "type": "search",
+ "queries": queries,
+ })
+ if err != nil {
+ return nil, HostedResponseSet{}, fmt.Errorf("marshal Gemini web-search action: %w", err)
+ }
+ // The Chat pivot emits the answer as the regular Responses output. Place
+ // the hosted call after that output, matching the stream bridge which only
+ // learns Gemini's queries once grounding metadata arrives near stream end.
+ set := HostedResponseSet{
+ Source: types.RelayFormatGemini,
+ SourceLength: 2,
+ RegularPositions: []int{0},
+ Items: []HostedResponseItem{{
+ Kind: KindWebSearch,
+ NativeType: "googleSearch",
+ ID: fmt.Sprintf("ws_%s", kitutil.GetUUID()),
+ Status: "completed",
+ Position: 1,
+ Action: action,
+ }},
+ }
+ return source, set, nil
+}
+
+func attachOpenAIHostedResponse(response any, set HostedResponseSet) (any, []types.ConversionDiagnostic, error) {
+ target, ok := response.(*dto.OpenAIResponsesResponse)
+ if !ok || target == nil {
+ return nil, nil, fmt.Errorf("expected OpenAI Responses response, got %T", response)
+ }
+ var diagnostics []types.ConversionDiagnostic
+ hostedOutput := make([]positionedResponsesOutput, 0, len(set.Items))
+ convertedByID := make(map[string]int, len(set.Items)*2)
+ for index, item := range set.Items {
+ if set.Source == types.RelayFormatOpenAIResponses && len(item.Raw) > 0 {
+ var output dto.ResponsesOutput
+ if err := kitutil.Unmarshal(item.Raw, &output); err != nil {
+ return nil, diagnostics, fmt.Errorf("hosted_tools[%d]: %w", index, err)
+ }
+ hostedOutput = append(hostedOutput, positionedResponsesOutput{position: item.Position, output: output})
+ continue
+ }
+ outputType := responsesTypeFromHostedKind(item.Kind)
+ if outputType == "" {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ fmt.Sprintf("hosted_tools[%d]", index),
+ "hosted_tool_unrepresentable",
+ fmt.Sprintf("OpenAI Responses has no lossless response mapping for %q", item.NativeType),
+ ))
+ continue
+ }
+ if isClaudeHostedResult(item.NativeType) {
+ outputIndex, exists := convertedByID[item.CallID]
+ if !exists {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ fmt.Sprintf("hosted_tools[%d].tool_use_id", index),
+ "hosted_tool_result_orphaned",
+ fmt.Sprintf("hosted-tool result references unknown call %q", item.CallID),
+ ))
+ continue
+ }
+ output := &hostedOutput[outputIndex].output
+ output.Status = hostedCompletionStatus(item)
+ if item.Kind == KindMCP {
+ failed := hostedItemFailed(item)
+ var (
+ encoded json.RawMessage
+ normalized bool
+ err error
+ )
+ if failed {
+ encoded, normalized, err = responsesMCPErrorFromClaudeContent(item.Results, item.ErrorCode)
+ } else {
+ encoded, normalized, err = responsesMCPStringFromClaudeContent(item.Results)
+ }
+ if err != nil {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ fmt.Sprintf("hosted_tools[%d].content", index),
+ "mcp_result_unrepresentable",
+ err.Error(),
+ ))
+ continue
+ }
+ if failed {
+ output.Output = nil
+ output.ItemError = encoded
+ } else {
+ output.Output = encoded
+ output.ItemError = nil
+ }
+ if normalized {
+ diagnostics = append(diagnostics, responsePresentationLoss(
+ fmt.Sprintf("hosted_tools[%d].content", index),
+ "mcp_text_result_normalized",
+ "Claude's single MCP text block was normalized to a Responses output string",
+ ))
+ }
+ } else if item.Kind == KindWebSearch && rawJSONPresent(item.Results) {
+ diagnostics = append(diagnostics, responsePresentationLoss(
+ fmt.Sprintf("hosted_tools[%d].content", index),
+ "web_search_result_omitted",
+ "Claude web-search result content is provider-private and has no field on an OpenAI Responses web_search_call; completion status and citations remain available",
+ ))
+ }
+ continue
+ }
+ output := dto.ResponsesOutput{
+ Type: outputType,
+ ID: firstNonEmpty(item.ID, item.CallID),
+ Status: hostedCompletionStatus(item),
+ }
+ switch item.Kind {
+ case KindWebSearch:
+ action, err := dto.NormalizeResponsesWebSearchAction(item.Action)
+ if err != nil {
+ return nil, diagnostics, fmt.Errorf("hosted_tools[%d].action: %w", index, err)
+ }
+ output.Action = action
+ case KindMCP:
+ if strings.TrimSpace(item.Name) == "" || strings.TrimSpace(item.ServerName) == "" {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ fmt.Sprintf("hosted_tools[%d]", index),
+ "mcp_identity_missing",
+ "Claude MCP output requires both name and server_name for Responses mapping",
+ ))
+ continue
+ }
+ output.CallId = item.CallID
+ output.Name = item.Name
+ output.Caller = append(json.RawMessage(nil), item.Caller...)
+ output.ServerLabel = item.ServerName
+ output.ApprovalRequestID = item.ApprovalRequestID
+ output.MCPTools = append(json.RawMessage(nil), item.Tools...)
+ arguments := item.Arguments
+ if len(arguments) == 0 {
+ arguments = item.Action
+ }
+ encodedArguments, argumentErr := responsesMCPArgumentsFromClaude(arguments)
+ if argumentErr != nil {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ fmt.Sprintf("hosted_tools[%d].input", index),
+ "mcp_arguments_unrepresentable",
+ argumentErr.Error(),
+ ))
+ continue
+ }
+ output.Arguments = encodedArguments
+ }
+ outputIndex := len(hostedOutput)
+ for _, key := range []string{item.ID, item.CallID} {
+ if key != "" {
+ convertedByID[key] = outputIndex
+ }
+ }
+ hostedOutput = append(hostedOutput, positionedResponsesOutput{position: item.Position, output: output})
+ if set.Source != types.RelayFormatOpenAIResponses {
+ diagnostics = append(diagnostics, responsePresentationLoss(
+ fmt.Sprintf("hosted_tools[%d]", index),
+ "hosted_tool_result_approximated",
+ "hosted-tool execution is preserved, but provider-specific result fields may differ",
+ ))
+ }
+ }
+ merged, orderingDiagnostics := mergeResponsesOutput(target.Output, hostedOutput, set)
+ diagnostics = append(diagnostics, orderingDiagnostics...)
+ target.Output = merged
+ return target, diagnostics, nil
+}
+
+func attachClaudeHostedResponse(response any, set HostedResponseSet) (any, []types.ConversionDiagnostic, error) {
+ target, ok := response.(*dto.ClaudeResponse)
+ if !ok || target == nil {
+ return nil, nil, fmt.Errorf("expected Claude response, got %T", response)
+ }
+ var diagnostics []types.ConversionDiagnostic
+ hostedContent := make([]positionedClaudeBlocks, 0, len(set.Items))
+ for index, item := range set.Items {
+ if set.Source == types.RelayFormatClaude && len(item.Raw) > 0 {
+ var block dto.ClaudeMediaMessage
+ if err := kitutil.Unmarshal(item.Raw, &block); err != nil {
+ return nil, diagnostics, fmt.Errorf("hosted_tools[%d]: %w", index, err)
+ }
+ hostedContent = append(hostedContent, positionedClaudeBlocks{position: item.Position, blocks: []dto.ClaudeMediaMessage{block}})
+ continue
+ }
+ if set.Source == types.RelayFormatOpenAIResponses && item.Kind == KindWebSearch {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ fmt.Sprintf("hosted_tools[%d]", index),
+ "web_search_response_unrepresentable",
+ "Responses web-search execution cannot reconstruct Claude's required encrypted web_search_tool_result continuation state",
+ ))
+ continue
+ }
+ name := claudeNameFromHostedKind(item.Kind)
+ if item.Kind == KindMCP {
+ name = item.Name
+ }
+ if name == "" {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ fmt.Sprintf("hosted_tools[%d]", index),
+ "hosted_tool_unrepresentable",
+ fmt.Sprintf("Claude has no lossless response mapping for %q", item.NativeType),
+ ))
+ continue
+ }
+ var input any = map[string]any{}
+ if item.Kind == KindWebSearch {
+ webInput, inputErr := claudeWebSearchInputFromResponses(item.Action)
+ if inputErr != nil {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ fmt.Sprintf("hosted_tools[%d].action", index),
+ "web_search_action_unrepresentable",
+ inputErr.Error(),
+ ))
+ continue
+ }
+ input = webInput
+ } else if item.Kind == KindMCP {
+ mcpInput, inputErr := claudeMCPInputFromResponses(item.Arguments)
+ if inputErr != nil {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ fmt.Sprintf("hosted_tools[%d].arguments", index),
+ "mcp_arguments_unrepresentable",
+ inputErr.Error(),
+ ))
+ continue
+ }
+ input = mcpInput
+ } else if len(item.Action) > 0 {
+ if err := kitutil.Unmarshal(item.Action, &input); err != nil {
+ return nil, diagnostics, fmt.Errorf("hosted_tools[%d].action: %w", index, err)
+ }
+ }
+ callType := "server_tool_use"
+ if item.Kind == KindMCP {
+ callType = "mcp_tool_use"
+ }
+ blocks := []dto.ClaudeMediaMessage{{
+ Type: callType,
+ Id: item.ID,
+ Name: name,
+ Input: input,
+ Caller: append(json.RawMessage(nil), item.Caller...),
+ ServerName: item.ServerName,
+ }}
+ result := item.Results
+ if item.Kind == KindMCP {
+ result = item.Output
+ if hostedItemFailed(item) && rawJSONPresent(item.Error) {
+ result = item.Error
+ }
+ } else if len(result) == 0 {
+ result = item.Sources
+ }
+ if len(result) > 0 && !(set.Source == types.RelayFormatOpenAIResponses && item.Kind == KindWebSearch) {
+ var content any
+ if item.Kind == KindMCP {
+ decoded, resultErr := claudeMCPContentFromResponsesString(result)
+ if resultErr != nil {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ fmt.Sprintf("hosted_tools[%d].output", index),
+ "mcp_result_unrepresentable",
+ resultErr.Error(),
+ ))
+ continue
+ }
+ content = decoded
+ } else if err := kitutil.Unmarshal(result, &content); err != nil {
+ return nil, diagnostics, fmt.Errorf("hosted_tools[%d].results: %w", index, err)
+ }
+ isError := hostedItemFailed(item)
+ blocks = append(blocks, dto.ClaudeMediaMessage{
+ Type: claudeResultTypeFromHostedKind(item.Kind),
+ ToolUseId: item.ID,
+ Content: content,
+ IsError: &isError,
+ ErrorCode: item.ErrorCode,
+ })
+ } else if len(result) > 0 && item.Kind == KindWebSearch {
+ diagnostics = append(diagnostics, responsePresentationLoss(
+ fmt.Sprintf("hosted_tools[%d].results", index),
+ "web_search_result_omitted",
+ "Responses web-search source metadata cannot reconstruct Claude's encrypted web_search_tool_result",
+ ))
+ } else if item.Kind == KindMCP && (item.Status == "completed" || item.Status == "failed") {
+ diagnostics = append(diagnostics, responseSemanticLoss(
+ fmt.Sprintf("hosted_tools[%d]", index),
+ "mcp_result_missing",
+ fmt.Sprintf("Responses MCP output has status %q but no output or error", item.Status),
+ ))
+ }
+ hostedContent = append(hostedContent, positionedClaudeBlocks{position: item.Position, blocks: blocks})
+ if set.Source != types.RelayFormatClaude {
+ diagnostics = append(diagnostics, responsePresentationLoss(
+ fmt.Sprintf("hosted_tools[%d]", index),
+ "hosted_tool_result_approximated",
+ "hosted-tool execution is preserved, but provider-specific result fields may differ",
+ ))
+ }
+ }
+ merged, orderingDiagnostics := mergeClaudeContent(target.Content, hostedContent, set)
+ diagnostics = append(diagnostics, orderingDiagnostics...)
+ target.Content = merged
+ return target, diagnostics, nil
+}
+
+func mergeResponsesOutput(regular []dto.ResponsesOutput, hosted []positionedResponsesOutput, set HostedResponseSet) ([]dto.ResponsesOutput, []types.ConversionDiagnostic) {
+ if len(hosted) == 0 {
+ return regular, nil
+ }
+ if len(regular) == len(set.RegularPositions) {
+ byPosition := make(map[int][]dto.ResponsesOutput, len(hosted))
+ for _, item := range hosted {
+ byPosition[item.position] = append(byPosition[item.position], item.output)
+ }
+ regularByPosition := make(map[int]dto.ResponsesOutput, len(regular))
+ for index, position := range set.RegularPositions {
+ regularByPosition[position] = regular[index]
+ }
+ merged := make([]dto.ResponsesOutput, 0, len(regular)+len(hosted))
+ for position := 0; position < set.SourceLength; position++ {
+ merged = append(merged, byPosition[position]...)
+ if output, exists := regularByPosition[position]; exists {
+ merged = append(merged, output)
+ }
+ }
+ return merged, nil
+ }
+ before, after, exact := hostedOutsideRegularRange(hostedPositions(hosted), set.RegularPositions)
+ if exact {
+ merged := make([]dto.ResponsesOutput, 0, len(regular)+len(hosted))
+ for _, item := range before {
+ merged = append(merged, hosted[item].output)
+ }
+ merged = append(merged, regular...)
+ for _, item := range after {
+ merged = append(merged, hosted[item].output)
+ }
+ return merged, nil
+ }
+ merged := make([]dto.ResponsesOutput, 0, len(regular)+len(hosted))
+ for _, item := range hosted {
+ merged = append(merged, item.output)
+ }
+ merged = append(merged, regular...)
+ return merged, []types.ConversionDiagnostic{responseSemanticLoss(
+ "output",
+ "hosted_tool_order_unrepresentable",
+ "hosted-tool items were interleaved with content that the target converter coalesced, so their original order cannot be reconstructed",
+ )}
+}
+
+func mergeClaudeContent(regular []dto.ClaudeMediaMessage, hosted []positionedClaudeBlocks, set HostedResponseSet) ([]dto.ClaudeMediaMessage, []types.ConversionDiagnostic) {
+ if len(hosted) == 0 {
+ return regular, nil
+ }
+ if len(regular) == len(set.RegularPositions) {
+ byPosition := make(map[int][]dto.ClaudeMediaMessage, len(hosted))
+ for _, item := range hosted {
+ byPosition[item.position] = append(byPosition[item.position], item.blocks...)
+ }
+ regularByPosition := make(map[int]dto.ClaudeMediaMessage, len(regular))
+ for index, position := range set.RegularPositions {
+ regularByPosition[position] = regular[index]
+ }
+ merged := make([]dto.ClaudeMediaMessage, 0, len(regular)+len(hosted)*2)
+ for position := 0; position < set.SourceLength; position++ {
+ merged = append(merged, byPosition[position]...)
+ if block, exists := regularByPosition[position]; exists {
+ merged = append(merged, block)
+ }
+ }
+ return merged, nil
+ }
+ before, after, exact := hostedOutsideRegularRange(claudeHostedPositions(hosted), set.RegularPositions)
+ if exact {
+ merged := make([]dto.ClaudeMediaMessage, 0, len(regular)+len(hosted)*2)
+ for _, item := range before {
+ merged = append(merged, hosted[item].blocks...)
+ }
+ merged = append(merged, regular...)
+ for _, item := range after {
+ merged = append(merged, hosted[item].blocks...)
+ }
+ return merged, nil
+ }
+ merged := make([]dto.ClaudeMediaMessage, 0, len(regular)+len(hosted)*2)
+ for _, item := range hosted {
+ merged = append(merged, item.blocks...)
+ }
+ merged = append(merged, regular...)
+ return merged, []types.ConversionDiagnostic{responseSemanticLoss(
+ "content",
+ "hosted_tool_order_unrepresentable",
+ "hosted-tool blocks were interleaved with content that the target converter coalesced, so their original order cannot be reconstructed",
+ )}
+}
+
+func hostedPositions(items []positionedResponsesOutput) []int {
+ positions := make([]int, len(items))
+ for index := range items {
+ positions[index] = items[index].position
+ }
+ return positions
+}
+
+func claudeHostedPositions(items []positionedClaudeBlocks) []int {
+ positions := make([]int, len(items))
+ for index := range items {
+ positions[index] = items[index].position
+ }
+ return positions
+}
+
+func hostedOutsideRegularRange(hosted []int, regular []int) (before []int, after []int, exact bool) {
+ if len(regular) == 0 {
+ indices := make([]int, len(hosted))
+ for index := range hosted {
+ indices[index] = index
+ }
+ return indices, nil, true
+ }
+ firstRegular, lastRegular := regular[0], regular[len(regular)-1]
+ for index, position := range hosted {
+ switch {
+ case position < firstRegular:
+ before = append(before, index)
+ case position > lastRegular:
+ after = append(after, index)
+ default:
+ return nil, nil, false
+ }
+ }
+ return before, after, true
+}
+
+func hostedKindFromClaudeCall(blockType string, name string) Kind {
+ if blockType == "mcp_tool_use" {
+ return KindMCP
+ }
+ switch strings.TrimSpace(name) {
+ case "web_search":
+ return KindWebSearch
+ case "web_fetch":
+ return KindWebFetch
+ case "code_execution":
+ return KindCodeExecution
+ default:
+ return KindNative
+ }
+}
+
+func hostedKindFromClaudeResult(blockType string) Kind {
+ switch strings.TrimSuffix(blockType, "_tool_result") {
+ case "web_search":
+ return KindWebSearch
+ case "web_fetch":
+ return KindWebFetch
+ case "code_execution":
+ return KindCodeExecution
+ case "mcp":
+ return KindMCP
+ default:
+ return KindNative
+ }
+}
+
+func hostedKindFromResponsesType(outputType string) Kind {
+ normalized := strings.TrimSpace(outputType)
+ normalized = strings.TrimSuffix(normalized, "_output")
+ normalized = strings.TrimSuffix(normalized, "_call")
+ switch normalized {
+ case "web_search":
+ return KindWebSearch
+ case "file_search":
+ return KindFileSearch
+ case "code_interpreter", "local_shell":
+ return KindCodeExecution
+ case "computer":
+ return KindComputerUse
+ case "image_generation":
+ return KindImage
+ case "mcp":
+ return KindMCP
+ default:
+ return KindNative
+ }
+}
+
+func responsesTypeFromHostedKind(kind Kind) string {
+ switch kind {
+ case KindWebSearch:
+ return "web_search_call"
+ case KindMCP:
+ return "mcp_call"
+ default:
+ return ""
+ }
+}
+
+func claudeNameFromHostedKind(kind Kind) string {
+ switch kind {
+ case KindWebSearch:
+ return "web_search"
+ case KindWebFetch:
+ return "web_fetch"
+ case KindCodeExecution:
+ return ""
+ case KindMCP:
+ return "mcp"
+ default:
+ return ""
+ }
+}
+
+func isClaudeHostedResult(nativeType string) bool {
+ return nativeType == "mcp_tool_result" || strings.HasSuffix(nativeType, "_tool_result")
+}
+
+func hostedCompletionStatus(item HostedResponseItem) string {
+ if hostedItemFailed(item) {
+ return "failed"
+ }
+ if item.Status != "" && item.Status != "in_progress" || len(item.Results) > 0 || len(item.Output) > 0 {
+ return "completed"
+ }
+ return "in_progress"
+}
+
+func hostedItemFailed(item HostedResponseItem) bool {
+ return item.Status == "failed" || item.ErrorCode != "" || rawJSONPresent(item.Error) || item.IsError != nil && *item.IsError
+}
+
+func hostedErrorValue(item HostedResponseItem) json.RawMessage {
+ if len(item.Error) > 0 {
+ return append(json.RawMessage(nil), item.Error...)
+ }
+ if item.ErrorCode == "" {
+ return nil
+ }
+ encoded, _ := kitutil.Marshal(item.ErrorCode)
+ return encoded
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func rawJSONPresent(value json.RawMessage) bool {
+ normalized := strings.TrimSpace(string(value))
+ return normalized != "" && normalized != "null"
+}
+
+func claudeResultTypeFromHostedKind(kind Kind) string {
+ name := claudeNameFromHostedKind(kind)
+ if name == "mcp" {
+ return "mcp_tool_result"
+ }
+ return name + "_tool_result"
+}
diff --git a/relaykit/relayconvert/reasoning/claude.go b/relaykit/relayconvert/reasoning/claude.go
new file mode 100644
index 000000000000..878bb1080d07
--- /dev/null
+++ b/relaykit/relayconvert/reasoning/claude.go
@@ -0,0 +1,303 @@
+package reasoning
+
+import (
+ "fmt"
+ "math"
+ "strings"
+
+ "github.com/QuantumNous/new-api/relaykit/dto"
+)
+
+type ClaudeRender struct {
+ Thinking *dto.Thinking
+ OutputEffort Effort
+ EffectiveEffort Effort
+ ClearSampling bool
+ ConstrainThinkingSampling bool
+}
+
+type claudeCapabilities struct {
+ adaptive bool
+ supportsManual bool
+ defaultThinking bool
+ supportsDisable bool
+ supportsEffort bool
+ supportsXHigh bool
+ supportsMax bool
+ strictSampling bool
+}
+
+func claudeCapabilitiesFor(model string) claudeCapabilities {
+ model = strings.ToLower(model)
+ capabilities := claudeCapabilities{supportsManual: true, supportsDisable: true}
+
+ switch {
+ case strings.HasPrefix(model, "claude-fable-5"),
+ strings.HasPrefix(model, "claude-mythos-5"):
+ capabilities.adaptive = true
+ capabilities.supportsManual = false
+ capabilities.defaultThinking = true
+ capabilities.supportsDisable = false
+ capabilities.supportsXHigh = true
+ capabilities.supportsMax = true
+ capabilities.strictSampling = true
+ case strings.HasPrefix(model, "claude-mythos-preview"):
+ capabilities.adaptive = true
+ capabilities.defaultThinking = true
+ capabilities.supportsDisable = false
+ capabilities.supportsMax = true
+ capabilities.strictSampling = true
+ case strings.HasPrefix(model, "claude-opus-5"),
+ strings.HasPrefix(model, "claude-sonnet-5"),
+ strings.HasPrefix(model, "claude-opus-4-8"),
+ strings.HasPrefix(model, "claude-opus-4-7"):
+ capabilities.adaptive = true
+ capabilities.supportsManual = false
+ if strings.HasPrefix(model, "claude-opus-5") || strings.HasPrefix(model, "claude-sonnet-5") {
+ capabilities.defaultThinking = true
+ }
+ capabilities.supportsEffort = true
+ capabilities.supportsXHigh = true
+ capabilities.supportsMax = true
+ capabilities.strictSampling = true
+ case strings.HasPrefix(model, "claude-opus-4-6"),
+ strings.HasPrefix(model, "claude-sonnet-4-6"):
+ capabilities.adaptive = true
+ capabilities.supportsEffort = true
+ capabilities.supportsMax = true
+ case strings.HasPrefix(model, "claude-opus-4-5"):
+ capabilities.supportsEffort = true
+ }
+
+ return capabilities
+}
+
+func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPercentage float64) (ClaudeRender, error) {
+ if intent.Mode == ModeDisabled && intent.Effort != "" && intent.Effort != EffortNone {
+ effort, err := ParseEffort(string(intent.Effort))
+ if err != nil {
+ return ClaudeRender{}, err
+ }
+ intent.Effort = effort
+ } else {
+ var err error
+ intent, err = normalizeIntent(intent)
+ if err != nil {
+ return ClaudeRender{}, err
+ }
+ }
+ capabilities := claudeCapabilitiesFor(model)
+ if !intent.HasStrength() {
+ if intent.IncludeThoughts != nil && capabilities.adaptive && capabilities.defaultThinking {
+ thinking := &dto.Thinking{Type: "adaptive"}
+ if *intent.IncludeThoughts {
+ thinking.Display = "summarized"
+ } else {
+ thinking.Display = "omitted"
+ }
+ return ClaudeRender{
+ Thinking: thinking,
+ EffectiveEffort: EffortHigh,
+ ClearSampling: capabilities.strictSampling,
+ }, nil
+ }
+ if capabilities.defaultThinking {
+ return ClaudeRender{EffectiveEffort: EffortHigh, ClearSampling: capabilities.strictSampling}, nil
+ }
+ return ClaudeRender{ClearSampling: capabilities.strictSampling}, nil
+ }
+
+ if intent.Mode == ModeDisabled || intent.Effort == EffortNone {
+ if strings.HasPrefix(strings.ToLower(model), "claude-opus-5") &&
+ (intent.Effort == EffortXHigh || intent.Effort == EffortMax) {
+ return ClaudeRender{}, fmt.Errorf("model %q does not support effort %q while thinking is disabled", model, intent.Effort)
+ }
+ if !capabilities.supportsDisable {
+ return ClaudeRender{}, fmt.Errorf("%w for model %q", ErrThinkingNotDisabled, model)
+ }
+ return ClaudeRender{
+ Thinking: &dto.Thinking{Type: "disabled"},
+ EffectiveEffort: EffortNone,
+ ClearSampling: capabilities.strictSampling,
+ }, nil
+ }
+
+ preferManual := capabilities.supportsManual && intent.BudgetTokens != nil && intent.Mode != ModeAdaptive
+ if !capabilities.supportsManual && intent.BudgetTokens != nil && intent.BudgetSource == SourceNative && intent.Mode == ModeEnabled {
+ return ClaudeRender{}, fmt.Errorf("model %q requires adaptive thinking and does not support native budget_tokens", model)
+ }
+ if capabilities.adaptive && !preferManual {
+ effort := intent.Effort
+ if effort == "" && intent.BudgetTokens != nil {
+ effort = EffortFromBudget(*intent.BudgetTokens)
+ }
+ if effort == "" && intent.Mode == ModeEnabled {
+ effort = EffortHigh
+ }
+ effort = normalizeClaudeEffort(effort, capabilities)
+ effectiveEffort := effort
+ if effectiveEffort == "" && intent.Mode == ModeAdaptive {
+ effectiveEffort = EffortHigh
+ }
+
+ // Claude effort can be used without enabling thinking. Preserve that
+ // distinction for native Claude requests; OpenAI extractors explicitly
+ // mark reasoning efforts as ModeEnabled.
+ if intent.Mode == ModeUnset {
+ return ClaudeRender{
+ OutputEffort: effort,
+ EffectiveEffort: effectiveEffort,
+ ClearSampling: capabilities.strictSampling,
+ }, nil
+ }
+
+ thinking := &dto.Thinking{Type: "adaptive"}
+ if intent.IncludeThoughts != nil {
+ if *intent.IncludeThoughts {
+ thinking.Display = "summarized"
+ } else {
+ thinking.Display = "omitted"
+ }
+ }
+ return ClaudeRender{
+ Thinking: thinking,
+ OutputEffort: effort,
+ EffectiveEffort: effectiveEffort,
+ ClearSampling: capabilities.strictSampling,
+ ConstrainThinkingSampling: !capabilities.strictSampling,
+ }, nil
+ }
+
+ if intent.Mode == ModeAdaptive {
+ return ClaudeRender{}, fmt.Errorf("model %q does not support adaptive thinking", model)
+ }
+ if intent.Mode == ModeUnset {
+ return ClaudeRender{OutputEffort: intent.Effort, EffectiveEffort: intent.Effort}, nil
+ }
+ if maxTokens == nil {
+ return ClaudeRender{}, fmt.Errorf("max_tokens is required for manual Claude thinking")
+ }
+ if *maxTokens <= 1024 {
+ return ClaudeRender{}, fmt.Errorf("max_tokens must be greater than 1024 for manual Claude thinking")
+ }
+ if uint64(*maxTokens) > uint64(math.MaxInt) {
+ return ClaudeRender{}, fmt.Errorf("max_tokens is too large for a thinking budget")
+ }
+
+ budget := 0
+ if intent.BudgetTokens != nil && *intent.BudgetTokens == -1 && intent.BudgetSource == SourceNative {
+ return ClaudeRender{}, fmt.Errorf("Claude thinking budget_tokens does not support -1")
+ }
+ if intent.BudgetTokens != nil && *intent.BudgetTokens >= 0 {
+ budget = *intent.BudgetTokens
+ if intent.BudgetSource != SourceNative {
+ if budget < 1024 {
+ budget = 1024
+ }
+ if uint(budget) >= *maxTokens {
+ budget = int(*maxTokens) - 1
+ }
+ }
+ if budget < 1024 || uint(budget) >= *maxTokens {
+ return ClaudeRender{}, fmt.Errorf("Claude thinking budget must satisfy 1024 <= budget_tokens < max_tokens")
+ }
+ } else {
+ percentage := effortPercentage(intent.Effort, adapterBudgetPercentage)
+ budget = int(*maxTokens) * percentage / 100
+ if budget < 1024 {
+ budget = 1024
+ }
+ if uint(budget) >= *maxTokens {
+ budget = int(*maxTokens) - 1
+ }
+ }
+
+ effectiveEffort := intent.Effort
+ if intent.BudgetTokens != nil && !capabilities.supportsEffort {
+ effectiveEffort = EffortFromBudget(budget)
+ } else if effectiveEffort == "" {
+ effectiveEffort = EffortFromBudget(budget)
+ }
+ outputEffort := Effort("")
+ if capabilities.supportsEffort && intent.Effort != "" {
+ outputEffort = normalizeClaudeEffort(intent.Effort, capabilities)
+ effectiveEffort = outputEffort
+ }
+ thinking := &dto.Thinking{Type: "enabled", BudgetTokens: &budget}
+ if intent.IncludeThoughts != nil {
+ if *intent.IncludeThoughts {
+ thinking.Display = "summarized"
+ } else {
+ thinking.Display = "omitted"
+ }
+ }
+ return ClaudeRender{
+ Thinking: thinking,
+ OutputEffort: outputEffort,
+ EffectiveEffort: effectiveEffort,
+ ConstrainThinkingSampling: true,
+ }, nil
+}
+
+// ClaudeUsesManualThinking reports whether an exact numeric budget is rendered
+// as legacy extended thinking rather than being reduced to adaptive effort.
+func ClaudeUsesManualThinking(model string, intent Intent) bool {
+ capabilities := claudeCapabilitiesFor(model)
+ return capabilities.supportsManual && intent.BudgetTokens != nil && intent.Mode != ModeAdaptive
+}
+
+func IsKnownClaudeModel(model string) bool {
+ return isKnownClaudeModel(model)
+}
+
+func ResolveClaudeDefault(model string, intent Intent) Intent {
+ if intent.HasStrength() || !claudeCapabilitiesFor(model).defaultThinking {
+ return intent
+ }
+ intent.Mode = ModeAdaptive
+ intent.Effort = EffortHigh
+ return intent
+}
+
+func normalizeClaudeEffort(effort Effort, capabilities claudeCapabilities) Effort {
+ switch effort {
+ case EffortMinimal:
+ return EffortLow
+ case EffortXHigh:
+ if capabilities.supportsXHigh {
+ return effort
+ }
+ if capabilities.supportsMax {
+ return EffortMax
+ }
+ return EffortHigh
+ case EffortMax:
+ if !capabilities.supportsMax {
+ return EffortHigh
+ }
+ }
+ return effort
+}
+
+func effortPercentage(effort Effort, adapterBudgetPercentage float64) int {
+ switch effort {
+ case EffortMinimal:
+ return 5
+ case EffortLow:
+ return 20
+ case EffortMedium:
+ return 50
+ case EffortHigh:
+ return 80
+ case EffortXHigh, EffortMax:
+ return 95
+ }
+ percentage := int(math.Round(adapterBudgetPercentage * 100))
+ if percentage <= 0 {
+ return 80
+ }
+ if percentage >= 100 {
+ return 99
+ }
+ return percentage
+}
diff --git a/relaykit/relayconvert/reasoning/gemini.go b/relaykit/relayconvert/reasoning/gemini.go
new file mode 100644
index 000000000000..63fe5c8552fb
--- /dev/null
+++ b/relaykit/relayconvert/reasoning/gemini.go
@@ -0,0 +1,374 @@
+package reasoning
+
+import (
+ "fmt"
+ "math"
+ "strings"
+
+ "github.com/QuantumNous/new-api/relaykit/dto"
+)
+
+type GeminiRender struct {
+ Config *dto.GeminiThinkingConfig
+ EffectiveEffort Effort
+}
+
+type geminiThinkingKind int
+
+const (
+ geminiThinkingUnknown geminiThinkingKind = iota
+ geminiThinkingNotConfigurable
+ geminiThinkingBudget
+ geminiThinkingLevel
+)
+
+type geminiCapabilities struct {
+ kind geminiThinkingKind
+ supportsDisable bool
+ supportsIncludeThoughts bool
+ minBudget int
+ maxBudget int
+}
+
+func geminiCapabilitiesFor(model string) geminiCapabilities {
+ model = strings.ToLower(model)
+ switch {
+ case strings.HasPrefix(model, "gemini-2.5-flash-native-audio"),
+ strings.HasPrefix(model, "gemini-live-2.5-flash-preview-native-audio"):
+ return geminiCapabilities{kind: geminiThinkingBudget, supportsDisable: true, maxBudget: 24576}
+ case strings.HasPrefix(model, "gemini-2.5-flash-image"),
+ strings.Contains(model, "-tts"),
+ strings.Contains(model, "-native-audio"),
+ strings.Contains(model, "-live"):
+ return geminiCapabilities{kind: geminiThinkingNotConfigurable}
+ case strings.HasPrefix(model, "gemini-3-pro-image"),
+ strings.HasPrefix(model, "nano-banana-pro"):
+ return geminiCapabilities{kind: geminiThinkingNotConfigurable, supportsIncludeThoughts: true}
+ case model == "gemini-flash-latest", model == "gemini-flash-lite-latest":
+ return geminiCapabilities{kind: geminiThinkingLevel}
+ case model == "gemini-pro-latest":
+ return geminiCapabilities{kind: geminiThinkingLevel}
+ case strings.HasPrefix(model, "gemini-2.5-pro"):
+ return geminiCapabilities{kind: geminiThinkingBudget, minBudget: 128, maxBudget: 32768}
+ case strings.HasPrefix(model, "gemini-2.5-flash-lite"):
+ return geminiCapabilities{kind: geminiThinkingBudget, supportsDisable: true, minBudget: 512, maxBudget: 24576}
+ case strings.HasPrefix(model, "gemini-2.5-"):
+ return geminiCapabilities{kind: geminiThinkingBudget, supportsDisable: true, maxBudget: 24576}
+ case strings.HasPrefix(model, "gemini-3"):
+ return geminiCapabilities{kind: geminiThinkingLevel}
+ default:
+ return geminiCapabilities{}
+ }
+}
+
+func RenderGemini(model string, intent Intent, maxOutputTokens *uint, adapterBudgetPercentage float64) (GeminiRender, error) {
+ intent, err := normalizeIntent(intent)
+ if err != nil {
+ return GeminiRender{}, err
+ }
+ if intent.IsEmpty() {
+ return GeminiRender{}, nil
+ }
+
+ capabilities := geminiCapabilitiesFor(model)
+ if capabilities.kind == geminiThinkingNotConfigurable {
+ if !intent.HasStrength() && capabilities.supportsIncludeThoughts {
+ return GeminiRender{Config: &dto.GeminiThinkingConfig{IncludeThoughts: intent.IncludeThoughts}, EffectiveEffort: EffortHigh}, nil
+ }
+ return GeminiRender{}, fmt.Errorf("model %q does not support configurable thinking", model)
+ }
+ if capabilities.kind == geminiThinkingUnknown {
+ if intent.HasStrength() {
+ return GeminiRender{}, fmt.Errorf("model %q does not have a known Gemini thinking configuration", model)
+ }
+ return GeminiRender{Config: &dto.GeminiThinkingConfig{IncludeThoughts: intent.IncludeThoughts}}, nil
+ }
+
+ config := &dto.GeminiThinkingConfig{IncludeThoughts: intent.IncludeThoughts}
+ if capabilities.kind == geminiThinkingBudget {
+ if intent.Mode == ModeDisabled || intent.Effort == EffortNone {
+ if !capabilities.supportsDisable {
+ return GeminiRender{}, fmt.Errorf("%w for model %q", ErrThinkingNotDisabled, model)
+ }
+ budget := 0
+ config.ThinkingBudget = &budget
+ return GeminiRender{Config: config, EffectiveEffort: EffortNone}, nil
+ }
+
+ budget := 0
+ hasBudget := false
+ if intent.BudgetTokens != nil {
+ budget = *intent.BudgetTokens
+ if intent.BudgetSource != SourceNative && budget != -1 {
+ budget = clampGeminiBudget(budget, capabilities)
+ }
+ hasBudget = true
+ } else if intent.Effort != "" {
+ budget = gemini25BudgetForEffort(intent.Effort)
+ hasBudget = true
+ } else if intent.Mode != ModeUnset && maxOutputTokens != nil && *maxOutputTokens > 0 {
+ if uint64(*maxOutputTokens) > uint64(math.MaxInt) {
+ return GeminiRender{}, fmt.Errorf("max_output_tokens is too large for a thinking budget")
+ }
+ percentage := adapterBudgetPercentage
+ if percentage <= 0 {
+ percentage = 0.6
+ } else if percentage > 1 {
+ percentage = 1
+ }
+ budget = int(math.Round(float64(*maxOutputTokens) * percentage))
+ budget = clampGeminiBudget(budget, capabilities)
+ hasBudget = true
+ }
+ if hasBudget {
+ if err := validateGeminiBudget(model, budget, capabilities); err != nil {
+ return GeminiRender{}, err
+ }
+ config.ThinkingBudget = &budget
+ }
+ effort := intent.Effort
+ if hasBudget {
+ effort = EffortFromBudget(budget)
+ } else if intent.Mode == ModeEnabled || intent.Mode == ModeAdaptive {
+ effort = geminiDefaultEffort(model)
+ }
+ return GeminiRender{Config: config, EffectiveEffort: effort}, nil
+ }
+
+ if intent.Mode == ModeDisabled || intent.Effort == EffortNone {
+ return GeminiRender{}, fmt.Errorf("%w for model %q", ErrThinkingNotDisabled, model)
+ }
+ effort := intent.Effort
+ if effort == "" && intent.BudgetTokens != nil {
+ effort = EffortFromBudget(*intent.BudgetTokens)
+ }
+ if effort != "" {
+ level, err := geminiLevelForEffort(model, effort)
+ if err != nil {
+ return GeminiRender{}, err
+ }
+ config.ThinkingLevel = level
+ effort = Effort(level)
+ } else if intent.Mode == ModeEnabled || intent.Mode == ModeAdaptive {
+ effort = geminiDefaultEffort(model)
+ }
+ return GeminiRender{Config: config, EffectiveEffort: effort}, nil
+}
+
+func geminiDefaultEffort(model string) Effort {
+ model = strings.ToLower(model)
+ switch {
+ case model == "gemini-flash-latest",
+ strings.HasPrefix(model, "gemini-3.5-flash") && !strings.HasPrefix(model, "gemini-3.5-flash-lite"),
+ strings.HasPrefix(model, "gemini-3.6-flash"):
+ return EffortMedium
+ case model == "gemini-flash-lite-latest",
+ strings.HasPrefix(model, "gemini-3.5-flash-lite"),
+ strings.HasPrefix(model, "gemini-3.1-flash-lite"):
+ return EffortMinimal
+ case model == "gemini-pro-latest",
+ strings.HasPrefix(model, "gemini-3.1-pro"),
+ strings.HasPrefix(model, "gemini-3-pro"),
+ strings.HasPrefix(model, "gemini-3-flash"):
+ return EffortHigh
+ default:
+ return ""
+ }
+}
+
+func ValidateGeminiThinkingConfig(model string, config *dto.GeminiThinkingConfig) (Effort, error) {
+ if config == nil {
+ return "", nil
+ }
+ intent, err := FromGemini(&dto.GeminiChatRequest{GenerationConfig: dto.GeminiChatGenerationConfig{ThinkingConfig: config}})
+ if err != nil {
+ return "", err
+ }
+ capabilities := geminiCapabilitiesFor(model)
+ if capabilities.kind == geminiThinkingNotConfigurable {
+ if !intent.HasStrength() && capabilities.supportsIncludeThoughts {
+ return EffortHigh, nil
+ }
+ return "", fmt.Errorf("model %q does not support configurable thinking", model)
+ }
+ if capabilities.kind == geminiThinkingUnknown {
+ return EffectiveEffort(intent), nil
+ }
+ if capabilities.kind == geminiThinkingBudget {
+ if config.ThinkingLevel != "" {
+ return "", fmt.Errorf("Gemini 2.5 model %q requires thinkingBudget, not thinkingLevel", model)
+ }
+ if config.ThinkingBudget != nil {
+ if err := validateGeminiBudget(model, *config.ThinkingBudget, capabilities); err != nil {
+ return "", err
+ }
+ }
+ return EffectiveEffort(intent), nil
+ }
+ if config.ThinkingBudget != nil {
+ return "", fmt.Errorf("Gemini 3 model %q requires thinkingLevel, not thinkingBudget", model)
+ }
+ if config.ThinkingLevel != "" {
+ level, err := geminiLevelForEffort(model, intent.Effort)
+ if err != nil {
+ return "", err
+ }
+ if level != config.ThinkingLevel {
+ return "", fmt.Errorf("thinkingLevel %q is not supported by model %q", config.ThinkingLevel, model)
+ }
+ return Effort(level), nil
+ }
+ return "", nil
+}
+
+// ResolveGeminiDefault materializes documented family defaults when a
+// conversion targets another protocol. Dynamic 2.5 defaults retain their -1
+// budget in the in-process pivot; Flash-Lite's default is explicitly off.
+func ResolveGeminiDefault(model string, intent Intent) Intent {
+ if intent.HasStrength() {
+ return intent
+ }
+ capabilities := geminiCapabilitiesFor(model)
+ if capabilities.kind == geminiThinkingBudget {
+ if strings.HasPrefix(strings.ToLower(model), "gemini-2.5-flash-lite") {
+ budget := 0
+ intent.Mode = ModeDisabled
+ intent.Effort = EffortNone
+ intent.BudgetTokens = &budget
+ intent.BudgetSource = SourceNative
+ return intent
+ }
+ budget := -1
+ intent.Mode = ModeEnabled
+ intent.BudgetTokens = &budget
+ intent.BudgetSource = SourceNative
+ return intent
+ }
+ if capabilities.kind != geminiThinkingLevel {
+ return intent
+ }
+ effort := geminiDefaultEffort(model)
+ if effort == "" {
+ return intent
+ }
+ intent.Mode = ModeEnabled
+ intent.Effort = effort
+ return intent
+}
+
+// ResolveGeminiEnabledDefault fills the strength implied by an explicit
+// enable-only control such as the legacy -thinking model alias.
+func ResolveGeminiEnabledDefault(model string, intent Intent, maxOutputTokens *uint) Intent {
+ if intent.Mode != ModeEnabled || intent.Effort != "" || intent.BudgetTokens != nil {
+ return intent
+ }
+ capabilities := geminiCapabilitiesFor(model)
+ if capabilities.kind == geminiThinkingBudget {
+ if intent.Source == SourceSuffix && maxOutputTokens != nil && *maxOutputTokens > 0 {
+ return intent
+ }
+ budget := -1
+ intent.BudgetTokens = &budget
+ intent.BudgetSource = SourceSuffix
+ return intent
+ }
+ if capabilities.kind == geminiThinkingLevel {
+ intent.Effort = geminiDefaultEffort(model)
+ }
+ return intent
+}
+
+// EquivalentGeminiStrength compares two controls after applying the target
+// model's budget/level mapping. This accepts distinct canonical labels that
+// are identical on the Gemini wire (for example minimal and low on 2.5).
+func EquivalentGeminiStrength(model string, left Intent, right Intent) (bool, error) {
+ leftRendered, err := RenderGemini(model, left, nil, 0)
+ if err != nil {
+ return false, err
+ }
+ rightRendered, err := RenderGemini(model, right, nil, 0)
+ if err != nil {
+ return false, err
+ }
+ if leftRendered.Config == nil || rightRendered.Config == nil {
+ return leftRendered.Config == nil && rightRendered.Config == nil, nil
+ }
+ leftConfig, rightConfig := leftRendered.Config, rightRendered.Config
+ if leftConfig.ThinkingLevel != rightConfig.ThinkingLevel {
+ return false, nil
+ }
+ if (leftConfig.ThinkingBudget == nil) != (rightConfig.ThinkingBudget == nil) {
+ return false, nil
+ }
+ return leftConfig.ThinkingBudget == nil || *leftConfig.ThinkingBudget == *rightConfig.ThinkingBudget, nil
+}
+
+func gemini25BudgetForEffort(effort Effort) int {
+ switch effort {
+ case EffortMinimal, EffortLow:
+ return 1024
+ case EffortMedium:
+ return 8192
+ case EffortHigh, EffortXHigh, EffortMax:
+ return 24576
+ default:
+ return 0
+ }
+}
+
+func geminiLevelForEffort(model string, effort Effort) (string, error) {
+ model = strings.ToLower(model)
+ switch {
+ case strings.HasPrefix(model, "gemini-3.1-flash-image"),
+ strings.HasPrefix(model, "gemini-3.1-flash-lite-image"):
+ if effort == EffortMinimal || effort == EffortLow {
+ return string(EffortMinimal), nil
+ }
+ return string(EffortHigh), nil
+ case (strings.HasPrefix(model, "gemini-3-pro") && !strings.HasPrefix(model, "gemini-3.1-pro")):
+ if effort == EffortMinimal || effort == EffortLow {
+ return string(EffortLow), nil
+ }
+ return string(EffortHigh), nil
+ case strings.HasPrefix(model, "gemini-3.1-pro"), model == "gemini-pro-latest":
+ if effort == EffortMinimal {
+ return string(EffortLow), nil
+ }
+ }
+ switch effort {
+ case EffortMinimal, EffortLow, EffortMedium, EffortHigh:
+ return string(effort), nil
+ case EffortXHigh, EffortMax:
+ return string(EffortHigh), nil
+ case EffortNone:
+ return "", fmt.Errorf("%w for model %q", ErrThinkingNotDisabled, model)
+ default:
+ return "", fmt.Errorf("%w %q for model %q", ErrUnsupportedEffort, effort, model)
+ }
+}
+
+func validateGeminiBudget(model string, budget int, capabilities geminiCapabilities) error {
+ if budget == -1 {
+ return nil
+ }
+ if budget == 0 {
+ if capabilities.supportsDisable {
+ return nil
+ }
+ return fmt.Errorf("%w for model %q", ErrThinkingNotDisabled, model)
+ }
+ if budget < capabilities.minBudget || budget > capabilities.maxBudget {
+ return fmt.Errorf("thinking budget %d is outside the supported range [%d,%d] for model %q", budget, capabilities.minBudget, capabilities.maxBudget, model)
+ }
+ return nil
+}
+
+func clampGeminiBudget(budget int, capabilities geminiCapabilities) int {
+ if budget < capabilities.minBudget {
+ return capabilities.minBudget
+ }
+ if budget > capabilities.maxBudget {
+ return capabilities.maxBudget
+ }
+ return budget
+}
diff --git a/relaykit/relayconvert/reasoning/intent.go b/relaykit/relayconvert/reasoning/intent.go
new file mode 100644
index 000000000000..061006950e1a
--- /dev/null
+++ b/relaykit/relayconvert/reasoning/intent.go
@@ -0,0 +1,609 @@
+package reasoning
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+)
+
+type Effort string
+
+const (
+ EffortNone Effort = "none"
+ EffortMinimal Effort = "minimal"
+ EffortLow Effort = "low"
+ EffortMedium Effort = "medium"
+ EffortHigh Effort = "high"
+ EffortXHigh Effort = "xhigh"
+ EffortMax Effort = "max"
+)
+
+type Mode string
+
+type Source string
+
+// ClientError marks invalid user-supplied reasoning controls so host handlers
+// can return a 4xx without classifying unrelated adapter failures as client
+// errors.
+type ClientError struct {
+ err error
+}
+
+func (e *ClientError) Error() string { return e.err.Error() }
+func (e *ClientError) Unwrap() error { return e.err }
+
+func AsClientError(err error) error {
+ if err == nil {
+ return nil
+ }
+ var clientErr *ClientError
+ if errors.As(err, &clientErr) {
+ return err
+ }
+ return &ClientError{err: err}
+}
+
+func IsClientError(err error) bool {
+ var clientErr *ClientError
+ return errors.As(err, &clientErr)
+}
+
+const (
+ ModeUnset Mode = ""
+ ModeEnabled Mode = "enabled"
+ ModeAdaptive Mode = "adaptive"
+ ModeDisabled Mode = "disabled"
+)
+
+const (
+ SourceExplicit Source = "explicit"
+ SourceNative Source = "native"
+ SourceSuffix Source = "suffix"
+ SourcePivot Source = "pivot"
+)
+
+var (
+ ErrEffortConflict = errors.New("reasoning settings conflict")
+ ErrUnsupportedEffort = errors.New("unsupported reasoning effort")
+ ErrThinkingNotDisabled = errors.New("thinking cannot be disabled")
+)
+
+// Intent is the protocol-independent part of a request's reasoning controls.
+// Summary visibility is intentionally independent from reasoning strength.
+type Intent struct {
+ Mode Mode
+ Effort Effort
+ BudgetTokens *int
+ IncludeThoughts *bool
+ Source Source
+ BudgetSource Source
+}
+
+func (i Intent) HasStrength() bool {
+ return i.Mode != ModeUnset || i.Effort != "" || i.BudgetTokens != nil
+}
+
+func (i Intent) IsEmpty() bool {
+ return !i.HasStrength() && i.IncludeThoughts == nil
+}
+
+// IntentFromState reconstructs a portable intent from host- or pivot-carried
+// conversion state. A nil state is an empty intent.
+func IntentFromState(state *dto.ReasoningConversionState) Intent {
+ if state == nil {
+ return Intent{}
+ }
+ return Intent{
+ Mode: Mode(state.Mode),
+ Effort: Effort(state.Effort),
+ BudgetTokens: state.BudgetTokens,
+ IncludeThoughts: state.IncludeThoughts,
+ Source: SourceSuffix,
+ BudgetSource: SourceSuffix,
+ }
+}
+
+// StateFromIntent copies the portable fields of an intent into conversion
+// state. Empty intents produce nil so callers can omit the field.
+func StateFromIntent(intent Intent) *dto.ReasoningConversionState {
+ if intent.IsEmpty() {
+ return nil
+ }
+ return &dto.ReasoningConversionState{
+ Mode: string(intent.Mode),
+ Effort: string(intent.Effort),
+ BudgetTokens: intent.BudgetTokens,
+ IncludeThoughts: intent.IncludeThoughts,
+ }
+}
+
+func ParseEffort(value string) (Effort, error) {
+ effort := Effort(strings.ToLower(strings.TrimSpace(value)))
+ if effort == "" {
+ return "", nil
+ }
+ switch effort {
+ case EffortNone, EffortMinimal, EffortLow, EffortMedium, EffortHigh, EffortXHigh, EffortMax:
+ return effort, nil
+ default:
+ return "", fmt.Errorf("%w: %q", ErrUnsupportedEffort, value)
+ }
+}
+
+func normalizeIntent(intent Intent) (Intent, error) {
+ effort, err := ParseEffort(string(intent.Effort))
+ if err != nil {
+ return Intent{}, err
+ }
+ intent.Effort = effort
+
+ switch intent.Mode {
+ case ModeUnset, ModeEnabled, ModeAdaptive, ModeDisabled:
+ default:
+ return Intent{}, fmt.Errorf("unsupported reasoning mode %q", intent.Mode)
+ }
+
+ if intent.BudgetTokens != nil {
+ budget := *intent.BudgetTokens
+ if budget < -1 {
+ return Intent{}, fmt.Errorf("thinking budget must be -1 or non-negative, got %d", budget)
+ }
+ if budget == 0 {
+ if intent.Mode == ModeEnabled || intent.Mode == ModeAdaptive || (intent.Effort != "" && intent.Effort != EffortNone) {
+ return Intent{}, fmt.Errorf("%w: zero budget disables thinking", ErrEffortConflict)
+ }
+ intent.Mode = ModeDisabled
+ intent.Effort = EffortNone
+ } else if intent.Mode == ModeDisabled || intent.Effort == EffortNone {
+ return Intent{}, fmt.Errorf("%w: a non-zero budget enables thinking", ErrEffortConflict)
+ } else if intent.Mode == ModeUnset {
+ intent.Mode = ModeEnabled
+ }
+ }
+
+ if intent.Effort == EffortNone {
+ if intent.Mode == ModeEnabled || intent.Mode == ModeAdaptive {
+ return Intent{}, fmt.Errorf("%w: effort none disables thinking", ErrEffortConflict)
+ }
+ intent.Mode = ModeDisabled
+ }
+
+ return intent, nil
+}
+
+// MergeExplicitAndSuffix combines structured request fields with a model-name
+// alias. Contradictions are rejected because the alias may carry a distinct
+// billing identity; silently choosing either side would make request semantics
+// and accounting disagree.
+func MergeExplicitAndSuffix(explicit Intent, suffix Intent, model string) (Intent, error) {
+ var err error
+ explicit, err = normalizeIntent(explicit)
+ if err != nil {
+ return Intent{}, err
+ }
+ suffix, err = normalizeIntent(suffix)
+ if err != nil {
+ return Intent{}, err
+ }
+
+ if !explicit.HasStrength() {
+ if explicit.IncludeThoughts != nil {
+ suffix.IncludeThoughts = explicit.IncludeThoughts
+ }
+ return suffix, nil
+ }
+ if !suffix.HasStrength() {
+ if explicit.IncludeThoughts == nil {
+ explicit.IncludeThoughts = suffix.IncludeThoughts
+ }
+ return explicit, nil
+ }
+
+ explicitDisabled := explicit.Mode == ModeDisabled || explicit.Effort == EffortNone
+ suffixDisabled := suffix.Mode == ModeDisabled || suffix.Effort == EffortNone
+ if explicitDisabled != suffixDisabled {
+ return Intent{}, fmt.Errorf("%w for model %q: explicit fields and model suffix disagree about whether thinking is enabled", ErrEffortConflict, model)
+ }
+ if !explicitDisabled && explicit.Effort != "" && suffix.Effort != "" && explicit.Effort != suffix.Effort {
+ return Intent{}, fmt.Errorf("%w for model %q: explicit effort %q differs from suffix effort %q", ErrEffortConflict, model, explicit.Effort, suffix.Effort)
+ }
+ if explicit.BudgetTokens != nil && suffix.BudgetTokens != nil && *explicit.BudgetTokens != *suffix.BudgetTokens {
+ return Intent{}, fmt.Errorf("%w for model %q: explicit budget %d differs from suffix budget %d", ErrEffortConflict, model, *explicit.BudgetTokens, *suffix.BudgetTokens)
+ }
+ if (explicit.Effort != "" && explicit.Effort != EffortNone && suffix.BudgetTokens != nil) ||
+ (explicit.BudgetTokens != nil && suffix.Effort != "" && suffix.Effort != EffortNone) {
+ return Intent{}, fmt.Errorf("%w for model %q: effort and an exact suffix budget cannot both select reasoning strength", ErrEffortConflict, model)
+ }
+
+ merged := suffix
+ if explicit.Mode != ModeUnset {
+ merged.Mode = explicit.Mode
+ }
+ if explicit.Effort != "" {
+ merged.Effort = explicit.Effort
+ }
+ if explicit.BudgetTokens != nil {
+ merged.BudgetTokens = explicit.BudgetTokens
+ merged.BudgetSource = explicit.BudgetSource
+ }
+ if explicit.IncludeThoughts != nil {
+ merged.IncludeThoughts = explicit.IncludeThoughts
+ }
+ return normalizeIntent(merged)
+}
+
+// MergeExplicit combines two structured representations of the same request.
+// A numeric budget and an effort may coexist: Claude and OpenRouter expose both
+// controls, and keeping both is what lets an in-memory OpenAI pivot preserve an
+// exact budget for budget-based targets while retaining an effort for
+// level-based targets.
+func MergeExplicit(primary Intent, secondary Intent, model string) (Intent, error) {
+ var err error
+ primary, err = normalizeIntent(primary)
+ if err != nil {
+ return Intent{}, err
+ }
+ secondary, err = normalizeIntent(secondary)
+ if err != nil {
+ return Intent{}, err
+ }
+
+ if primary.IsEmpty() {
+ return secondary, nil
+ }
+ if secondary.IsEmpty() {
+ return primary, nil
+ }
+
+ primaryDisabled := primary.Mode == ModeDisabled || primary.Effort == EffortNone
+ secondaryDisabled := secondary.Mode == ModeDisabled || secondary.Effort == EffortNone
+ if primary.HasStrength() && secondary.HasStrength() && primaryDisabled != secondaryDisabled {
+ return Intent{}, fmt.Errorf("%w for model %q: explicit fields disagree about whether thinking is enabled", ErrEffortConflict, model)
+ }
+ if primary.Effort != "" && secondary.Effort != "" && primary.Effort != secondary.Effort {
+ return Intent{}, fmt.Errorf("%w for model %q: explicit efforts %q and %q differ", ErrEffortConflict, model, primary.Effort, secondary.Effort)
+ }
+ if primary.BudgetTokens != nil && secondary.BudgetTokens != nil && *primary.BudgetTokens != *secondary.BudgetTokens {
+ return Intent{}, fmt.Errorf("%w for model %q: explicit budgets %d and %d differ", ErrEffortConflict, model, *primary.BudgetTokens, *secondary.BudgetTokens)
+ }
+
+ merged := secondary
+ if primary.Mode != ModeUnset {
+ merged.Mode = primary.Mode
+ }
+ if primary.Effort != "" {
+ merged.Effort = primary.Effort
+ }
+ if primary.BudgetTokens != nil {
+ merged.BudgetTokens = primary.BudgetTokens
+ merged.BudgetSource = primary.BudgetSource
+ }
+ if primary.IncludeThoughts != nil {
+ merged.IncludeThoughts = primary.IncludeThoughts
+ }
+ return normalizeIntent(merged)
+}
+
+type openRouterReasoning struct {
+ Enabled *bool `json:"enabled,omitempty"`
+ Effort string `json:"effort,omitempty"`
+ MaxTokens *int `json:"max_tokens,omitempty"`
+ Exclude *bool `json:"exclude,omitempty"`
+}
+
+func FromOpenAIChat(req *dto.GeneralOpenAIRequest) (Intent, error) {
+ if req == nil {
+ return Intent{}, nil
+ }
+
+ var intent Intent
+ intent.Source = SourceExplicit
+ if req.ReasoningEffort != "" {
+ effort, err := ParseEffort(req.ReasoningEffort)
+ if err != nil {
+ return Intent{}, err
+ }
+ intent.Effort = effort
+ if effort == EffortNone {
+ intent.Mode = ModeDisabled
+ } else {
+ intent.Mode = ModeEnabled
+ }
+ }
+
+ if len(req.Reasoning) > 0 {
+ var raw openRouterReasoning
+ if err := kitutil.Unmarshal(req.Reasoning, &raw); err != nil {
+ return Intent{}, fmt.Errorf("invalid reasoning config: %w", err)
+ }
+ nested := Intent{BudgetTokens: raw.MaxTokens, Source: SourceExplicit, BudgetSource: SourceExplicit}
+ if raw.Enabled != nil {
+ if *raw.Enabled {
+ nested.Mode = ModeEnabled
+ } else {
+ nested.Mode = ModeDisabled
+ nested.Effort = EffortNone
+ }
+ }
+ if raw.Effort != "" {
+ effort, err := ParseEffort(raw.Effort)
+ if err != nil {
+ return Intent{}, err
+ }
+ nested.Effort = effort
+ if effort == EffortNone {
+ nested.Mode = ModeDisabled
+ } else if nested.Mode == ModeUnset {
+ nested.Mode = ModeEnabled
+ }
+ }
+ if raw.Exclude != nil {
+ include := !*raw.Exclude
+ nested.IncludeThoughts = &include
+ }
+ var err error
+ intent, err = MergeExplicit(intent, nested, req.Model)
+ if err != nil {
+ return Intent{}, err
+ }
+ }
+
+ if req.ReasoningConversion == nil {
+ return normalizeIntent(intent)
+ }
+ pivot := Intent{
+ Mode: Mode(req.ReasoningConversion.Mode),
+ Effort: Effort(req.ReasoningConversion.Effort),
+ BudgetTokens: req.ReasoningConversion.BudgetTokens,
+ IncludeThoughts: req.ReasoningConversion.IncludeThoughts,
+ Source: SourcePivot,
+ BudgetSource: SourcePivot,
+ }
+ if req.ReasoningEffort != "" {
+ projectedEffort := OpenAIEffort(EffectiveEffort(pivot))
+ if Effort(req.ReasoningEffort) == projectedEffort {
+ intent.Effort = ""
+ intent.Mode = ModeUnset
+ }
+ }
+ return MergeExplicit(intent, pivot, req.Model)
+}
+
+// ApplyToOpenAIChat writes the portable portion of an intent to the OpenAI
+// pivot. reasoning_effort carries level-based strength; a JSON-excluded DTO
+// state retains exact budgets and summary visibility across in-process steps.
+func ApplyToOpenAIChat(req *dto.GeneralOpenAIRequest, intent Intent) error {
+ if req == nil {
+ return nil
+ }
+ intent, err := normalizeIntent(intent)
+ if err != nil {
+ return err
+ }
+
+ if effort := OpenAIEffort(EffectiveEffort(intent)); effort != "" {
+ req.ReasoningEffort = string(effort)
+ }
+
+ if intent.IsEmpty() {
+ return nil
+ }
+ req.ReasoningConversion = &dto.ReasoningConversionState{
+ Mode: string(intent.Mode),
+ Effort: string(intent.Effort),
+ BudgetTokens: intent.BudgetTokens,
+ IncludeThoughts: intent.IncludeThoughts,
+ }
+ return nil
+}
+
+// ApplyToOpenAIResponses writes the portable portion of an intent directly to
+// a Responses request. The JSON-excluded state retains exact provider-native
+// controls for any later in-process conversion.
+func ApplyToOpenAIResponses(req *dto.OpenAIResponsesRequest, intent Intent) error {
+ if req == nil {
+ return nil
+ }
+ intent, err := normalizeIntent(intent)
+ if err != nil {
+ return err
+ }
+
+ if effort := OpenAIEffort(EffectiveEffort(intent)); effort != "" {
+ summary := "detailed"
+ if effort == EffortNone || (intent.IncludeThoughts != nil && !*intent.IncludeThoughts) {
+ summary = ""
+ }
+ req.Reasoning = &dto.Reasoning{
+ Effort: string(effort),
+ Summary: summary,
+ }
+ }
+
+ if intent.IsEmpty() {
+ return nil
+ }
+ state := &dto.ReasoningConversionState{
+ Mode: string(intent.Mode),
+ Effort: string(intent.Effort),
+ BudgetTokens: intent.BudgetTokens,
+ IncludeThoughts: intent.IncludeThoughts,
+ }
+ req.ReasoningConversion = state
+ return nil
+}
+
+// OpenAIEffort maps the canonical cross-provider vocabulary to the public
+// OpenAI reasoning_effort vocabulary. Claude/OpenRouter "max" has no direct
+// OpenAI equivalent and is represented by xhigh at that wire boundary.
+func OpenAIEffort(effort Effort) Effort {
+ if effort == EffortMax {
+ return EffortXHigh
+ }
+ return effort
+}
+
+func FromOpenAIResponses(req *dto.OpenAIResponsesRequest) (Intent, error) {
+ if req == nil {
+ return Intent{}, nil
+ }
+ var intent Intent
+ if req.Reasoning != nil {
+ intent.Source = SourceExplicit
+ if req.Reasoning.Effort != "" {
+ effort, err := ParseEffort(req.Reasoning.Effort)
+ if err != nil {
+ return Intent{}, err
+ }
+ intent.Effort = effort
+ intent.Mode = ModeEnabled
+ if effort == EffortNone {
+ intent.Mode = ModeDisabled
+ }
+ }
+ if req.Reasoning.Summary != "" {
+ include := true
+ intent.IncludeThoughts = &include
+ }
+ }
+ if req.ReasoningConversion == nil {
+ return normalizeIntent(intent)
+ }
+ pivot := Intent{
+ Mode: Mode(req.ReasoningConversion.Mode),
+ Effort: Effort(req.ReasoningConversion.Effort),
+ BudgetTokens: req.ReasoningConversion.BudgetTokens,
+ IncludeThoughts: req.ReasoningConversion.IncludeThoughts,
+ Source: SourcePivot,
+ BudgetSource: SourcePivot,
+ }
+ if req.Reasoning != nil && req.Reasoning.Effort != "" {
+ projectedEffort := OpenAIEffort(EffectiveEffort(pivot))
+ if Effort(req.Reasoning.Effort) == projectedEffort {
+ intent.Effort = ""
+ intent.Mode = ModeUnset
+ }
+ }
+ return MergeExplicit(intent, pivot, req.Model)
+}
+
+func FromClaude(req *dto.ClaudeRequest) (Intent, error) {
+ if req == nil {
+ return Intent{}, nil
+ }
+ var intent Intent
+ intent.Source = SourceNative
+ if req.Thinking != nil {
+ switch req.Thinking.Type {
+ case "", "enabled":
+ intent.Mode = ModeEnabled
+ case "adaptive":
+ intent.Mode = ModeAdaptive
+ case "disabled":
+ intent.Mode = ModeDisabled
+ intent.Effort = EffortNone
+ default:
+ return Intent{}, fmt.Errorf("unsupported Claude thinking type %q", req.Thinking.Type)
+ }
+ intent.BudgetTokens = req.Thinking.BudgetTokens
+ if req.Thinking.BudgetTokens != nil {
+ budget := *req.Thinking.BudgetTokens
+ if budget < 1024 {
+ return Intent{}, fmt.Errorf("Claude thinking budget_tokens must be at least 1024, got %d", budget)
+ }
+ if req.MaxTokens != nil && uint(budget) >= *req.MaxTokens {
+ return Intent{}, fmt.Errorf("Claude thinking budget_tokens must be less than max_tokens")
+ }
+ intent.BudgetSource = SourceNative
+ }
+ switch req.Thinking.Display {
+ case "summarized":
+ include := true
+ intent.IncludeThoughts = &include
+ case "omitted":
+ include := false
+ intent.IncludeThoughts = &include
+ }
+ }
+ if len(req.OutputConfig) > 0 {
+ var output dto.OutputConfigForEffort
+ if err := kitutil.Unmarshal(req.OutputConfig, &output); err != nil {
+ return Intent{}, fmt.Errorf("invalid Claude output_config: %w", err)
+ }
+ if output.Effort != "" {
+ effort, err := ParseEffort(output.Effort)
+ if err != nil {
+ return Intent{}, err
+ }
+ intent.Effort = effort
+ }
+ }
+ if intent.Mode == ModeDisabled && intent.Effort != "" && intent.Effort != EffortNone {
+ return intent, nil
+ }
+ return normalizeIntent(intent)
+}
+
+func FromGemini(req *dto.GeminiChatRequest) (Intent, error) {
+ if req == nil || req.GenerationConfig.ThinkingConfig == nil {
+ return Intent{}, nil
+ }
+ config := req.GenerationConfig.ThinkingConfig
+ if config.ThinkingBudget != nil && config.ThinkingLevel != "" {
+ return Intent{}, fmt.Errorf("%w: Gemini thinkingBudget and thinkingLevel cannot both be set", ErrEffortConflict)
+ }
+ intent := Intent{
+ BudgetTokens: config.ThinkingBudget,
+ IncludeThoughts: config.IncludeThoughts,
+ Source: SourceNative,
+ BudgetSource: SourceNative,
+ }
+ if config.ThinkingLevel != "" {
+ effort, err := ParseEffort(config.ThinkingLevel)
+ if err != nil {
+ return Intent{}, err
+ }
+ intent.Effort = effort
+ intent.Mode = ModeEnabled
+ }
+ return normalizeIntent(intent)
+}
+
+func EffectiveEffort(intent Intent) Effort {
+ intent, err := normalizeIntent(intent)
+ if err != nil {
+ return ""
+ }
+ if intent.Mode == ModeDisabled {
+ return EffortNone
+ }
+ if intent.Effort != "" {
+ return intent.Effort
+ }
+ if intent.BudgetTokens != nil {
+ return EffortFromBudget(*intent.BudgetTokens)
+ }
+ if intent.Mode == ModeEnabled || intent.Mode == ModeAdaptive {
+ return EffortHigh
+ }
+ return ""
+}
+
+func EffortFromBudget(budget int) Effort {
+ if budget == 0 {
+ return EffortNone
+ }
+ if budget < 0 {
+ return EffortHigh
+ }
+ if budget <= 1024 {
+ return EffortLow
+ }
+ if budget <= 8192 {
+ return EffortMedium
+ }
+ return EffortHigh
+}
diff --git a/relaykit/relayconvert/reasoning/intent_test.go b/relaykit/relayconvert/reasoning/intent_test.go
new file mode 100644
index 000000000000..cc7c85f41ae4
--- /dev/null
+++ b/relaykit/relayconvert/reasoning/intent_test.go
@@ -0,0 +1,125 @@
+package reasoning
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMergeExplicitAndSuffix(t *testing.T) {
+ t.Parallel()
+
+ budget1024 := 1024
+ budget2048 := 2048
+
+ tests := []struct {
+ name string
+ explicit Intent
+ suffix Intent
+ wantErr bool
+ wantMode Mode
+ wantEffort Effort
+ wantBudget *int
+ wantThoughts *bool
+ }{
+ {
+ name: "enabled plus matching effort merges",
+ explicit: Intent{Mode: ModeEnabled, Effort: EffortHigh},
+ suffix: Intent{Mode: ModeEnabled, Effort: EffortHigh, Source: SourceSuffix},
+ wantMode: ModeEnabled,
+ wantEffort: EffortHigh,
+ },
+ {
+ name: "enabled vs disabled conflict",
+ explicit: Intent{Mode: ModeEnabled, Effort: EffortHigh},
+ suffix: Intent{Mode: ModeDisabled, Effort: EffortNone, Source: SourceSuffix},
+ wantErr: true,
+ },
+ {
+ name: "different efforts conflict",
+ explicit: Intent{Mode: ModeEnabled, Effort: EffortLow},
+ suffix: Intent{Mode: ModeEnabled, Effort: EffortHigh, Source: SourceSuffix},
+ wantErr: true,
+ },
+ {
+ name: "different budgets conflict",
+ explicit: Intent{BudgetTokens: &budget1024},
+ suffix: Intent{BudgetTokens: &budget2048, Source: SourceSuffix, BudgetSource: SourceSuffix},
+ wantErr: true,
+ },
+ {
+ name: "effort versus exact suffix budget conflict",
+ explicit: Intent{Mode: ModeEnabled, Effort: EffortHigh},
+ suffix: Intent{BudgetTokens: &budget1024, Source: SourceSuffix, BudgetSource: SourceSuffix},
+ wantErr: true,
+ },
+ {
+ name: "suffix only is adopted",
+ suffix: Intent{Mode: ModeEnabled, Effort: EffortMedium, Source: SourceSuffix},
+ wantMode: ModeEnabled,
+ wantEffort: EffortMedium,
+ wantThoughts: nil,
+ },
+ {
+ name: "explicit include thoughts overlays empty suffix strength",
+ explicit: Intent{IncludeThoughts: boolPtr(false)},
+ suffix: Intent{Mode: ModeEnabled, Effort: EffortLow, Source: SourceSuffix},
+ wantMode: ModeEnabled,
+ wantEffort: EffortLow,
+ wantThoughts: boolPtr(false),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got, err := MergeExplicitAndSuffix(tt.explicit, tt.suffix, "claude-opus-4-8")
+ if tt.wantErr {
+ require.Error(t, err)
+ assert.ErrorIs(t, err, ErrEffortConflict)
+ return
+ }
+ require.NoError(t, err)
+ assert.Equal(t, tt.wantMode, got.Mode)
+ assert.Equal(t, tt.wantEffort, got.Effort)
+ if tt.wantBudget != nil {
+ require.NotNil(t, got.BudgetTokens)
+ assert.Equal(t, *tt.wantBudget, *got.BudgetTokens)
+ }
+ if tt.wantThoughts != nil {
+ require.NotNil(t, got.IncludeThoughts)
+ assert.Equal(t, *tt.wantThoughts, *got.IncludeThoughts)
+ }
+ })
+ }
+}
+
+func TestIntentStateRoundTrip(t *testing.T) {
+ t.Parallel()
+
+ budget := 4096
+ include := true
+ intent := Intent{
+ Mode: ModeEnabled,
+ Effort: EffortHigh,
+ BudgetTokens: &budget,
+ IncludeThoughts: &include,
+ }
+
+ state := StateFromIntent(intent)
+ require.NotNil(t, state)
+ got := IntentFromState(state)
+ assert.Equal(t, intent.Mode, got.Mode)
+ assert.Equal(t, intent.Effort, got.Effort)
+ require.NotNil(t, got.BudgetTokens)
+ assert.Equal(t, budget, *got.BudgetTokens)
+ require.NotNil(t, got.IncludeThoughts)
+ assert.True(t, *got.IncludeThoughts)
+ assert.True(t, IntentFromState(nil).IsEmpty())
+ assert.Nil(t, StateFromIntent(Intent{}))
+}
+
+func boolPtr(v bool) *bool {
+ return &v
+}
diff --git a/relaykit/relayconvert/reasoning/suffix.go b/relaykit/relayconvert/reasoning/suffix.go
index 59140a7c8d28..38b01bef7b46 100644
--- a/relaykit/relayconvert/reasoning/suffix.go
+++ b/relaykit/relayconvert/reasoning/suffix.go
@@ -1,6 +1,8 @@
package reasoning
import (
+ "fmt"
+ "strconv"
"strings"
"github.com/samber/lo"
@@ -8,15 +10,10 @@ import (
var EffortSuffixes = []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal"}
-var OpenAIEffortSuffixes = []string{"-high", "-minimal", "-low", "-medium", "-none", "-xhigh"}
+var OpenAIEffortSuffixes = []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal", "-none"}
var DeepSeekV4EffortSuffixes = []string{"-none", "-max"}
-// TrimEffortSuffix -> modelName level(low) exists
-func TrimEffortSuffix(modelName string) (string, string, bool) {
- return TrimEffortSuffixWithSuffixes(modelName, EffortSuffixes)
-}
-
func TrimEffortSuffixWithSuffixes(modelName string, suffixes []string) (string, string, bool) {
suffix, found := lo.Find(suffixes, func(s string) bool {
return strings.HasSuffix(modelName, s)
@@ -27,7 +24,13 @@ func TrimEffortSuffixWithSuffixes(modelName string, suffixes []string) (string,
return strings.TrimSuffix(modelName, suffix), strings.TrimPrefix(suffix, "-"), true
}
-func ParseOpenAIReasoningEffortFromModelSuffix(modelName string) (string, string) {
+// ParseOpenAIReasoningEffortFromModelSuffix extracts an OpenAI effort tail
+// such as -high or -none. preserveEffortTail, when non-nil, keeps real model
+// IDs whose names already end in those tokens (for example qwen-max).
+func ParseOpenAIReasoningEffortFromModelSuffix(modelName string, preserveEffortTail func(string) bool) (string, string) {
+ if preserveEffortTail != nil && preserveEffortTail(modelName) {
+ return "", modelName
+ }
baseModel, effort, ok := TrimEffortSuffixWithSuffixes(modelName, OpenAIEffortSuffixes)
if !ok {
return "", modelName
@@ -35,6 +38,138 @@ func ParseOpenAIReasoningEffortFromModelSuffix(modelName string) (string, string
return effort, baseModel
}
+func ParseClaudeModelSuffix(modelName string, allowThinkingAlias bool) (string, Intent, bool, error) {
+ if !strings.HasPrefix(modelName, "claude-") {
+ return modelName, Intent{}, false, nil
+ }
+ if allowThinkingAlias && hasLegacyThinkingAlias(modelName) {
+ return parseProviderModelSuffix(modelName, "claude-", true, true)
+ }
+ if !isKnownClaudeModel(modelName) {
+ return modelName, Intent{}, false, nil
+ }
+ return parseProviderModelSuffix(modelName, "claude-", allowThinkingAlias, true)
+}
+
+func hasLegacyThinkingAlias(modelName string) bool {
+ return strings.HasSuffix(modelName, "-thinking") ||
+ strings.HasSuffix(modelName, "-nothinking") ||
+ strings.LastIndex(modelName, "-thinking-") >= 0
+}
+
+func isKnownClaudeModel(modelName string) bool {
+ baseModel, _, _ := TrimEffortSuffixWithSuffixes(modelName, []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal", "-none"})
+ if marker := strings.LastIndex(baseModel, "-thinking-"); marker >= 0 {
+ baseModel = baseModel[:marker]
+ } else {
+ baseModel = strings.TrimSuffix(strings.TrimSuffix(baseModel, "-thinking"), "-nothinking")
+ }
+ knownPrefixes := []string{
+ "claude-fable-5", "claude-mythos-5", "claude-mythos-preview",
+ "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8",
+ "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6",
+ "claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5",
+ "claude-opus-4-1", "claude-opus-4-", "claude-sonnet-4-",
+ "claude-3-7-sonnet",
+ }
+ for _, prefix := range knownPrefixes {
+ if strings.HasPrefix(baseModel, prefix) {
+ return true
+ }
+ }
+ return false
+}
+
+func ParseGeminiModelSuffix(modelName string, allowThinkingAlias bool) (string, Intent, bool, error) {
+ if !strings.HasPrefix(modelName, "gemini-") {
+ return modelName, Intent{}, false, nil
+ }
+ if !isKnownGeminiModel(modelName) {
+ return modelName, Intent{}, false, nil
+ }
+ return parseProviderModelSuffix(modelName, "gemini-", allowThinkingAlias, true)
+}
+
+// ParseKnownProviderModelSuffix extracts a canonical intent only when the
+// origin identifies a provider family whose suffix vocabulary is defined by
+// relaykit. Unknown OpenAI-compatible model names are deliberately untouched.
+func ParseKnownProviderModelSuffix(modelName string, allowThinkingAlias bool) (string, Intent, bool, error) {
+ if strings.HasPrefix(modelName, "claude-") {
+ return ParseClaudeModelSuffix(modelName, allowThinkingAlias)
+ }
+ if strings.HasPrefix(modelName, "gemini-") {
+ return ParseGeminiModelSuffix(modelName, allowThinkingAlias)
+ }
+ return modelName, Intent{}, false, nil
+}
+
+func isKnownGeminiModel(modelName string) bool {
+ baseModel, _, _ := TrimEffortSuffixWithSuffixes(modelName, []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal", "-none"})
+ if marker := strings.LastIndex(baseModel, "-thinking-"); marker >= 0 {
+ baseModel = baseModel[:marker]
+ } else {
+ baseModel = strings.TrimSuffix(strings.TrimSuffix(baseModel, "-thinking"), "-nothinking")
+ }
+ return geminiCapabilitiesFor(baseModel).kind != geminiThinkingUnknown
+}
+
+func TrimGeminiThinkingSuffix(modelName string) (string, bool) {
+ baseModel, _, ok, err := ParseGeminiModelSuffix(modelName, true)
+ return baseModel, ok && err == nil
+}
+
+func parseProviderModelSuffix(modelName string, requiredPrefix string, allowThinkingAlias bool, includeThoughts bool) (string, Intent, bool, error) {
+ if allowThinkingAlias {
+ if marker := strings.LastIndex(modelName, "-thinking-"); marker >= 0 {
+ baseModel := modelName[:marker]
+ if !strings.HasPrefix(baseModel, requiredPrefix) {
+ return modelName, Intent{}, false, nil
+ }
+ budget, err := strconv.Atoi(modelName[marker+len("-thinking-"):])
+ if err != nil {
+ return modelName, Intent{}, false, fmt.Errorf("invalid thinking budget suffix on model %q: %w", modelName, err)
+ }
+ intent := Intent{BudgetTokens: &budget, Source: SourceSuffix, BudgetSource: SourceSuffix}
+ if includeThoughts {
+ value := true
+ intent.IncludeThoughts = &value
+ }
+ return baseModel, intent, true, nil
+ }
+ if strings.HasSuffix(modelName, "-nothinking") {
+ baseModel := strings.TrimSuffix(modelName, "-nothinking")
+ return baseModel, Intent{Mode: ModeDisabled, Effort: EffortNone, Source: SourceSuffix}, true, nil
+ }
+ if strings.HasSuffix(modelName, "-thinking") {
+ baseModel := strings.TrimSuffix(modelName, "-thinking")
+ intent := Intent{Mode: ModeEnabled, Source: SourceSuffix}
+ if includeThoughts {
+ value := true
+ intent.IncludeThoughts = &value
+ }
+ return baseModel, intent, true, nil
+ }
+ }
+
+ suffixes := []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal", "-none"}
+ baseModel, rawEffort, ok := TrimEffortSuffixWithSuffixes(modelName, suffixes)
+ if !ok || !strings.HasPrefix(baseModel, requiredPrefix) {
+ return modelName, Intent{}, false, nil
+ }
+ effort, err := ParseEffort(rawEffort)
+ if err != nil {
+ return modelName, Intent{}, false, err
+ }
+ intent := Intent{Effort: effort, Mode: ModeEnabled, Source: SourceSuffix}
+ if effort == EffortNone {
+ intent.Mode = ModeDisabled
+ } else if includeThoughts {
+ value := true
+ intent.IncludeThoughts = &value
+ }
+ return baseModel, intent, true, nil
+}
+
func ParseDeepSeekV4ThinkingSuffix(modelName string) (baseModel string, thinkingType string, effort string, ok bool) {
baseModel, suffix, ok := TrimEffortSuffixWithSuffixes(modelName, DeepSeekV4EffortSuffixes)
if !ok || !strings.HasPrefix(baseModel, "deepseek-v4-") {
diff --git a/relaykit/relayconvert/reasoning/suffix_test.go b/relaykit/relayconvert/reasoning/suffix_test.go
new file mode 100644
index 000000000000..414016fa58ff
--- /dev/null
+++ b/relaykit/relayconvert/reasoning/suffix_test.go
@@ -0,0 +1,140 @@
+package reasoning
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseGeminiModelSuffixNoThinkingDisablesReasoning(t *testing.T) {
+ t.Parallel()
+
+ base, intent, found, err := ParseGeminiModelSuffix("gemini-2.5-flash-nothinking", true)
+ require.NoError(t, err)
+ require.True(t, found)
+ assert.Equal(t, "gemini-2.5-flash", base)
+ assert.Equal(t, ModeDisabled, intent.Mode)
+ assert.Equal(t, EffortNone, intent.Effort)
+ assert.Equal(t, SourceSuffix, intent.Source)
+}
+
+func TestParseKnownProviderModelSuffix(t *testing.T) {
+ t.Parallel()
+
+ preserveQwenMax := func(name string) bool { return name == "qwen-max" || name == "vendor/qwen-max" }
+
+ tests := []struct {
+ name string
+ model string
+ allowThinkingAlias bool
+ wantBase string
+ wantFound bool
+ wantMode Mode
+ wantEffort Effort
+ wantBudget *int
+ wantErr bool
+ }{
+ {
+ name: "claude thinking alias",
+ model: "claude-3-7-sonnet-thinking",
+ allowThinkingAlias: true,
+ wantBase: "claude-3-7-sonnet",
+ wantFound: true,
+ wantMode: ModeEnabled,
+ },
+ {
+ name: "claude nothinking alias",
+ model: "claude-3-7-sonnet-nothinking",
+ allowThinkingAlias: true,
+ wantBase: "claude-3-7-sonnet",
+ wantFound: true,
+ wantMode: ModeDisabled,
+ wantEffort: EffortNone,
+ },
+ {
+ name: "claude thinking budget",
+ model: "claude-3-7-sonnet-thinking-8192",
+ allowThinkingAlias: true,
+ wantBase: "claude-3-7-sonnet",
+ wantFound: true,
+ wantBudget: intPtr(8192),
+ },
+ {
+ name: "claude effort tail",
+ model: "claude-opus-4-8-high",
+ allowThinkingAlias: true,
+ wantBase: "claude-opus-4-8",
+ wantFound: true,
+ wantMode: ModeEnabled,
+ wantEffort: EffortHigh,
+ },
+ {
+ name: "gemini thinking alias",
+ model: "gemini-2.5-flash-thinking",
+ allowThinkingAlias: true,
+ wantBase: "gemini-2.5-flash",
+ wantFound: true,
+ wantMode: ModeEnabled,
+ },
+ {
+ name: "malformed thinking budget",
+ model: "claude-3-7-sonnet-thinking-abc",
+ allowThinkingAlias: true,
+ wantErr: true,
+ },
+ {
+ name: "unknown openai-compatible name is untouched",
+ model: "gpt-4o-mini",
+ wantBase: "gpt-4o-mini",
+ wantFound: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ base, intent, found, err := ParseKnownProviderModelSuffix(tt.model, tt.allowThinkingAlias)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ assert.Equal(t, tt.wantFound, found)
+ assert.Equal(t, tt.wantBase, base)
+ assert.Equal(t, tt.wantMode, intent.Mode)
+ assert.Equal(t, tt.wantEffort, intent.Effort)
+ if tt.wantBudget != nil {
+ require.NotNil(t, intent.BudgetTokens)
+ assert.Equal(t, *tt.wantBudget, *intent.BudgetTokens)
+ } else {
+ assert.Nil(t, intent.BudgetTokens)
+ }
+ })
+ }
+
+ t.Run("openai effort tail", func(t *testing.T) {
+ t.Parallel()
+ effort, base := ParseOpenAIReasoningEffortFromModelSuffix("gpt-5.6-sol-high", nil)
+ assert.Equal(t, "high", effort)
+ assert.Equal(t, "gpt-5.6-sol", base)
+ })
+
+ t.Run("preserve effort tail on real model id", func(t *testing.T) {
+ t.Parallel()
+ effort, base := ParseOpenAIReasoningEffortFromModelSuffix("qwen-max", preserveQwenMax)
+ assert.Empty(t, effort)
+ assert.Equal(t, "qwen-max", base)
+ })
+
+ t.Run("preserve effort tail with vendor prefix", func(t *testing.T) {
+ t.Parallel()
+ effort, base := ParseOpenAIReasoningEffortFromModelSuffix("vendor/qwen-max", preserveQwenMax)
+ assert.Empty(t, effort)
+ assert.Equal(t, "vendor/qwen-max", base)
+ })
+}
+
+func intPtr(v int) *int {
+ return &v
+}
diff --git a/relaykit/relayconvert/request_compat.go b/relaykit/relayconvert/request_compat.go
index fdacf68a0101..90b93457ec37 100644
--- a/relaykit/relayconvert/request_compat.go
+++ b/relaykit/relayconvert/request_compat.go
@@ -2,47 +2,64 @@ package relayconvert
import (
"context"
+ "fmt"
+
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
- claudemessages "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/claude_messages"
- geminichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/gemini_chat"
- oaichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_chat"
- oairesponses "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_responses"
+ sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
sharedgemini "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/gemini"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
+ "github.com/QuantumNous/new-api/relaykit/types"
)
func ClaudeMessagesRequestToOpenAIChat(claudeRequest dto.ClaudeRequest, info convmeta.Meta) (*dto.GeneralOpenAIRequest, error) {
- return claudemessages.ClaudeMessagesRequestToOpenAIChat(claudeRequest, info)
+ return convertCompatRequest[dto.GeneralOpenAIRequest](context.Background(), info, types.RelayFormatOpenAI, &claudeRequest)
}
func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
- return oaichat.OpenAIChatRequestToClaudeMessages(c, info, textRequest)
+ return convertCompatRequest[dto.ClaudeRequest](c, info, types.RelayFormatClaude, &textRequest)
}
func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatRequest, info convmeta.Meta) (*dto.GeneralOpenAIRequest, error) {
- return geminichat.GeminiGenerateContentRequestToOpenAIChat(geminiRequest, info)
+ return convertCompatRequest[dto.GeneralOpenAIRequest](context.Background(), info, types.RelayFormatOpenAI, geminiRequest)
}
func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto.GeneralOpenAIRequest, info convmeta.Meta) (*dto.GeminiChatRequest, error) {
- return oaichat.OpenAIChatRequestToGeminiGenerateContent(c, textRequest, info)
+ return convertCompatRequest[dto.GeminiChatRequest](c, info, types.RelayFormatGemini, &textRequest)
+}
+
+func ApplyGeminiThinkingConfigChecked(geminiRequest *dto.GeminiChatRequest, info convmeta.Meta, oaiRequest ...dto.GeneralOpenAIRequest) error {
+ return reasoning.AsClientError(sharedgemini.ApplyThinkingConfig(geminiRequest, info, oaiRequest...))
}
-func ApplyGeminiThinkingConfig(geminiRequest *dto.GeminiChatRequest, info convmeta.Meta, oaiRequest ...dto.GeneralOpenAIRequest) {
- sharedgemini.ApplyThinkingConfig(geminiRequest, info, oaiRequest...)
+func ApplyClaudeThinkingModel(claudeRequest *dto.ClaudeRequest, info convmeta.Meta) error {
+ return reasoning.AsClientError(sharedclaude.ApplyReasoning(claudeRequest, info, reasoning.Intent{}))
}
func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) {
- return oaichat.ChatCompletionsRequestToResponsesRequest(req)
+ return convertCompatRequest[dto.OpenAIResponsesRequest](context.Background(), nil, types.RelayFormatOpenAIResponses, req)
}
func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) {
- return oairesponses.ResponsesRequestToChatCompletionsRequest(req)
+ return convertCompatRequest[dto.GeneralOpenAIRequest](context.Background(), nil, types.RelayFormatOpenAI, req)
}
func OpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Meta, req *dto.OpenAIResponsesRequest) (*dto.ClaudeRequest, error) {
- return oairesponses.OpenAIResponsesRequestToClaudeMessages(c, info, req)
+ return convertCompatRequest[dto.ClaudeRequest](c, info, types.RelayFormatClaude, req)
}
func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIResponsesRequest, info convmeta.Meta) (*dto.GeminiChatRequest, error) {
- return oairesponses.OpenAIResponsesRequestToGeminiChat(c, req, info)
+ return convertCompatRequest[dto.GeminiChatRequest](c, info, types.RelayFormatGemini, req)
+}
+
+func convertCompatRequest[T any](c context.Context, info convmeta.Meta, target types.RelayFormat, request any) (*T, error) {
+ result, err := ConvertRequest(c, info, target, request)
+ if err != nil {
+ return nil, err
+ }
+ converted, ok := result.Value.(*T)
+ if !ok {
+ return nil, fmt.Errorf("expected %s request, got %T", target, result.Value)
+ }
+ return converted, nil
}
diff --git a/relaykit/relayconvert/request_registry.go b/relaykit/relayconvert/request_registry.go
index e55d6c30c5e9..6904a820e448 100644
--- a/relaykit/relayconvert/request_registry.go
+++ b/relaykit/relayconvert/request_registry.go
@@ -14,6 +14,7 @@ import (
geminichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/gemini_chat"
oaichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_chat"
oairesponses "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_responses"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/toolconv"
"github.com/QuantumNous/new-api/relaykit/types"
)
@@ -34,12 +35,13 @@ type RequestStep struct {
}
type RequestResult struct {
- Value any
- From types.RelayFormat
- To types.RelayFormat
- Converter string
- Quality RequestConverterQuality
- Steps []RequestStep
+ Value any
+ From types.RelayFormat
+ To types.RelayFormat
+ Converter string
+ Quality RequestConverterQuality
+ Steps []RequestStep
+ Diagnostics []types.ConversionDiagnostic
}
type RequestConverterSpec struct {
@@ -68,18 +70,19 @@ const (
requestConverterClaudeToResponses = "claude_messages_to_openai_responses"
requestConverterGeminiToClaude = "gemini_generate_content_to_claude_messages"
requestConverterGeminiToResponses = "gemini_generate_content_to_openai_responses"
- requestConverterResponsesToClaude = "openai_responses_to_claude_messages"
+ requestConverterResponsesToClaude = ConverterOpenAIResponsesToClaudeMessages
)
const (
- ConverterNone = "none"
- ConverterClaudeMessagesToOpenAIChat = "anthropic_messages_to_openai_chat_completions"
- ConverterOpenAIChatToClaudeMessages = "openai_chat_completions_to_anthropic_messages"
- ConverterOpenAIChatToOpenAIResponses = "openai_chat_completions_to_openai_responses"
- ConverterOpenAIResponsesToOpenAIChat = "openai_responses_to_openai_chat_completions"
- ConverterOpenAIResponsesToGemini = "openai_responses_to_gemini_generate_content"
- ConverterGeminiContentToOpenAIChat = "gemini_generate_content_to_openai_chat_completions"
- ConverterOpenAIChatToGeminiContent = "openai_chat_completions_to_gemini_generate_content"
+ ConverterNone = "none"
+ ConverterClaudeMessagesToOpenAIChat = "anthropic_messages_to_openai_chat_completions"
+ ConverterOpenAIChatToClaudeMessages = "openai_chat_completions_to_anthropic_messages"
+ ConverterOpenAIChatToOpenAIResponses = "openai_chat_completions_to_openai_responses"
+ ConverterOpenAIResponsesToOpenAIChat = "openai_responses_to_openai_chat_completions"
+ ConverterOpenAIResponsesToClaudeMessages = "openai_responses_to_claude_messages"
+ ConverterOpenAIResponsesToGemini = "openai_responses_to_gemini_generate_content"
+ ConverterGeminiContentToOpenAIChat = "gemini_generate_content_to_openai_chat_completions"
+ ConverterOpenAIChatToGeminiContent = "openai_chat_completions_to_gemini_generate_content"
)
func registerBuiltinRequestConverter(spec RequestConverterSpec) {
@@ -236,10 +239,12 @@ func executeRequestSpec(c context.Context, info convmeta.Meta, from types.RelayF
}
func executeRequestSteps(c context.Context, info convmeta.Meta, from types.RelayFormat, target types.RelayFormat, request any, converter string, quality RequestConverterQuality, specs []RequestConverterSpec) (*RequestResult, error) {
- current := request
+ current, tools, err := toolconv.ExtractRequest(from, request)
+ if err != nil {
+ return nil, err
+ }
steps := make([]RequestStep, 0, len(specs))
for _, spec := range specs {
- var err error
current, err = prepareRequestForStep(current, spec, target)
if err != nil {
return nil, err
@@ -253,6 +258,23 @@ func executeRequestSteps(c context.Context, info convmeta.Meta, from types.Relay
steps = append(steps, step)
}
+ current, diagnostics, err := toolconv.AttachRequest(target, current, tools, convmeta.OptionsOf(info))
+ if err != nil {
+ return &RequestResult{
+ Value: current,
+ From: from,
+ To: target,
+ Quality: quality,
+ Steps: steps,
+ Diagnostics: diagnostics,
+ }, err
+ }
+ if info != nil {
+ for _, step := range steps {
+ info.AppendRequestConversion(step.To)
+ }
+ }
+
converters := make([]string, 0, len(steps))
for _, step := range steps {
converters = append(converters, step.Converter)
@@ -261,12 +283,13 @@ func executeRequestSteps(c context.Context, info convmeta.Meta, from types.Relay
converter = strings.Join(converters, ",")
}
return &RequestResult{
- Value: current,
- From: from,
- To: target,
- Converter: converter,
- Quality: quality,
- Steps: steps,
+ Value: current,
+ From: from,
+ To: target,
+ Converter: converter,
+ Quality: quality,
+ Steps: steps,
+ Diagnostics: diagnostics,
}, nil
}
@@ -312,9 +335,6 @@ func executeRequestStep(c context.Context, info convmeta.Meta, spec RequestConve
if err != nil {
return nil, RequestStep{}, err
}
- if info != nil {
- info.AppendRequestConversion(spec.To)
- }
return value, RequestStep{
Converter: spec.ID,
From: spec.From,
@@ -425,6 +445,19 @@ func convertClaudeRequestToOpenAI(_ context.Context, info convmeta.Meta, request
return claudemessages.ClaudeMessagesRequestToOpenAIChat(*claudeRequest, info)
}
+func convertClaudeRequestToOpenAIResponses(_ context.Context, info convmeta.Meta, request any) (any, error) {
+ claudeRequest, ok := request.(*dto.ClaudeRequest)
+ if !ok {
+ if value, ok := request.(dto.ClaudeRequest); ok {
+ claudeRequest = &value
+ }
+ }
+ if claudeRequest == nil {
+ return nil, fmt.Errorf("expected Anthropic Messages request, got %T", request)
+ }
+ return claudemessages.ClaudeMessagesRequestToOpenAIResponses(*claudeRequest, info)
+}
+
func convertOpenAIRequestToClaude(c context.Context, info convmeta.Meta, request any) (any, error) {
openAIRequest, ok := request.(*dto.GeneralOpenAIRequest)
if !ok {
diff --git a/relaykit/relayconvert/request_registry_test.go b/relaykit/relayconvert/request_registry_test.go
index 5649ed09cd0f..4ca42f4a828b 100644
--- a/relaykit/relayconvert/request_registry_test.go
+++ b/relaykit/relayconvert/request_registry_test.go
@@ -42,10 +42,6 @@ func TestRequestConverterRegistryListsSupportedTextConverters(t *testing.T) {
from: types.RelayFormatClaude,
to: types.RelayFormatOpenAIResponses,
quality: RequestConverterQualityFair,
- stepConverters: []string{
- ConverterClaudeMessagesToOpenAIChat,
- ConverterOpenAIChatToOpenAIResponses,
- },
},
{
converter: requestConverterGeminiToClaude,
@@ -133,7 +129,7 @@ func TestConvertRequestToTargetRecordsConversionChain(t *testing.T) {
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses}, info.ConversionChain)
}
-func TestConvertRequestPlansMultiHopPath(t *testing.T) {
+func TestConvertRequestClaudeToResponsesUsesDirectPath(t *testing.T) {
info := &convmeta.Values{
ConversionChain: []types.RelayFormat{types.RelayFormatClaude},
}
@@ -154,17 +150,131 @@ func TestConvertRequestPlansMultiHopPath(t *testing.T) {
assert.Equal(t, RequestConverterQualityFair, result.Quality)
assert.Equal(t, []RequestStep{
{
- Converter: ConverterClaudeMessagesToOpenAIChat,
+ Converter: requestConverterClaudeToResponses,
From: types.RelayFormatClaude,
- To: types.RelayFormatOpenAI,
- },
- {
- Converter: ConverterOpenAIChatToOpenAIResponses,
- From: types.RelayFormatOpenAI,
To: types.RelayFormatOpenAIResponses,
},
}, result.Steps)
- assert.Equal(t, []types.RelayFormat{types.RelayFormatClaude, types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses}, info.ConversionChain)
+ assert.Equal(t, []types.RelayFormat{types.RelayFormatClaude, types.RelayFormatOpenAIResponses}, info.ConversionChain)
+}
+
+func TestConvertRequestClaudeToResponsesPreservesMixedBlockOrder(t *testing.T) {
+ info := &convmeta.Values{ConversionChain: []types.RelayFormat{types.RelayFormatClaude}}
+ stream := true
+ strict := true
+ maxTokens := uint(4096)
+ req := &dto.ClaudeRequest{
+ Model: "gpt-test",
+ System: []dto.ClaudeMediaMessage{{Type: "text", Text: kitutil.GetPointer("system ")}, {Type: "text", Text: kitutil.GetPointer("rules")}},
+ MaxTokens: &maxTokens,
+ Stream: &stream,
+ Tools: []dto.Tool{{
+ Name: "lookup",
+ Description: "Look up a value",
+ InputSchema: map[string]any{"type": "object", "properties": map[string]any{"q": map[string]any{"type": "string"}}},
+ Strict: &strict,
+ }},
+ ToolChoice: dto.ClaudeToolChoice{Type: "tool", Name: "lookup", DisableParallelToolUse: true},
+ Messages: []dto.ClaudeMessage{
+ {Role: "user", Content: []dto.ClaudeMediaMessage{{Type: "text", Text: kitutil.GetPointer("question")}}},
+ {Role: "assistant", Content: []dto.ClaudeMediaMessage{
+ {Type: "text", Text: kitutil.GetPointer("before")},
+ {Type: "tool_use", Id: "call_1", Name: "lookup", Input: map[string]any{"q": "x"}},
+ {Type: "text", Text: kitutil.GetPointer("after")},
+ }},
+ {Role: "user", Content: []dto.ClaudeMediaMessage{
+ {Type: "tool_result", ToolUseId: "call_1", Content: "result"},
+ {Type: "text", Text: kitutil.GetPointer("continue")},
+ }},
+ },
+ }
+
+ result, err := ConvertRequest(nil, info, types.RelayFormatOpenAIResponses, req)
+ require.NoError(t, err)
+ responsesReq := result.Value.(*dto.OpenAIResponsesRequest)
+ assert.Equal(t, "gpt-test", responsesReq.Model)
+ assert.Equal(t, maxTokens, *responsesReq.MaxOutputTokens)
+ assert.True(t, *responsesReq.Stream)
+ assert.JSONEq(t, `"system rules"`, string(responsesReq.Instructions))
+ assert.JSONEq(t, `[{"type":"function","name":"lookup","description":"Look up a value","parameters":{"type":"object","properties":{"q":{"type":"string"}}},"strict":true}]`, string(responsesReq.Tools))
+ assert.JSONEq(t, `{"type":"function","name":"lookup"}`, string(responsesReq.ToolChoice))
+ assert.JSONEq(t, `false`, string(responsesReq.ParallelToolCalls))
+
+ var input []map[string]any
+ require.NoError(t, kitutil.Unmarshal(responsesReq.Input, &input))
+ require.Len(t, input, 6)
+ assert.Equal(t, "user", input[0]["role"])
+ assert.Equal(t, "question", inputContentText(t, input[0]))
+ assert.Equal(t, "assistant", input[1]["role"])
+ assert.Equal(t, "before", inputContentText(t, input[1]))
+ assert.Equal(t, "function_call", input[2]["type"])
+ assert.Equal(t, "call_1", input[2]["call_id"])
+ assert.Equal(t, "lookup", input[2]["name"])
+ assert.JSONEq(t, `{"q":"x"}`, input[2]["arguments"].(string))
+ assert.Equal(t, "assistant", input[3]["role"])
+ assert.Equal(t, "after", inputContentText(t, input[3]))
+ assert.Equal(t, "function_call_output", input[4]["type"])
+ assert.Equal(t, "result", input[4]["output"])
+ assert.Equal(t, "user", input[5]["role"])
+ assert.Equal(t, "continue", inputContentText(t, input[5]))
+}
+
+func TestConvertRequestClaudeToResponsesDropsIncompatibleContextManagement(t *testing.T) {
+ req := &dto.ClaudeRequest{
+ Model: "gpt-test",
+ Messages: []dto.ClaudeMessage{
+ {Role: "user", Content: "hello"},
+ },
+ ContextManagement: mustRawMessage(t, map[string]any{
+ "edits": []map[string]any{{"type": "clear_tool_uses_20250919"}},
+ }),
+ }
+
+ result, err := ConvertRequest(nil, nil, types.RelayFormatOpenAIResponses, req)
+
+ require.NoError(t, err)
+ responsesReq, ok := result.Value.(*dto.OpenAIResponsesRequest)
+ require.True(t, ok)
+ assert.Empty(t, responsesReq.ContextManagement)
+}
+
+func TestConvertRequestClaudeAdaptiveThinkingPreservesEffort(t *testing.T) {
+ tests := []struct {
+ name string
+ outputConfig []byte
+ wantEffort string
+ }{
+ {name: "adaptive default", wantEffort: "high"},
+ {name: "explicit low", outputConfig: mustRawMessage(t, map[string]any{"effort": "low"}), wantEffort: "low"},
+ {name: "explicit xhigh", outputConfig: mustRawMessage(t, map[string]any{"effort": "xhigh"}), wantEffort: "xhigh"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ info := &convmeta.Values{
+ OriginModelName: "gpt-5.6-sol",
+ ConversionChain: []types.RelayFormat{types.RelayFormatClaude},
+ }
+ req := &dto.ClaudeRequest{
+ Model: "gpt-5.6-sol",
+ OutputConfig: tt.outputConfig,
+ Thinking: &dto.Thinking{Type: "adaptive", Display: "summarized"},
+ Messages: []dto.ClaudeMessage{
+ {Role: "user", Content: "hello"},
+ },
+ }
+
+ result, err := ConvertRequest(nil, info, types.RelayFormatOpenAIResponses, req)
+
+ require.NoError(t, err)
+ responsesReq, ok := result.Value.(*dto.OpenAIResponsesRequest)
+ require.True(t, ok)
+ require.NotNil(t, responsesReq.Reasoning)
+ assert.Equal(t, tt.wantEffort, responsesReq.Reasoning.Effort)
+ assert.Equal(t, "detailed", responsesReq.Reasoning.Summary)
+ assert.Equal(t, tt.wantEffort, info.GetReasoningEffort())
+ })
+ }
}
func TestConvertRequestViaExecutesExplicitPath(t *testing.T) {
@@ -466,115 +576,6 @@ func TestConvertRequestOpenAIChatToGeminiAddsThoughtSignatureForAdvancedCustom(t
assert.Equal(t, sharedgemini.ThoughtSignatureBypassValue, thoughtSignature)
}
-func TestConvertRequestResponsesToClaudeUsesDirectConverter(t *testing.T) {
- info := &convmeta.Values{
- ConversionChain: []types.RelayFormat{types.RelayFormatOpenAIResponses},
- }
- stream := true
- parallelToolCalls := false
- maxOutputTokens := uint(512)
- req := &dto.OpenAIResponsesRequest{
- Model: "claude-test",
- Instructions: mustRawMessage(t, "system rules"),
- Stream: &stream,
- MaxOutputTokens: &maxOutputTokens,
- ParallelToolCalls: mustRawMessage(t, parallelToolCalls),
- Reasoning: &dto.Reasoning{Effort: "medium"},
- Input: mustRawMessage(t, []map[string]any{
- {
- "role": "user",
- "content": "question",
- },
- {
- "role": "assistant",
- "content": []map[string]any{
- {"type": "output_text", "text": "I will call."},
- },
- },
- {
- "type": "function_call",
- "call_id": "call_1",
- "name": "lookup",
- "arguments": map[string]any{"q": "x"},
- },
- {
- "type": "function_call_output",
- "call_id": "call_1",
- "output": map[string]any{"ok": true},
- },
- }),
- Tools: mustRawMessage(t, []map[string]any{
- {
- "type": "function",
- "name": "lookup",
- "description": "Lookup data",
- "parameters": map[string]any{
- "type": "object",
- "properties": map[string]any{
- "q": map[string]any{"type": "string"},
- },
- },
- },
- }),
- }
-
- result, err := ConvertRequest(nil, info, types.RelayFormatClaude, req)
-
- require.NoError(t, err)
- claudeReq, ok := result.Value.(*dto.ClaudeRequest)
- require.True(t, ok)
- assert.Equal(t, requestConverterResponsesToClaude, result.Converter)
- assert.Equal(t, []RequestStep{
- {
- Converter: requestConverterResponsesToClaude,
- From: types.RelayFormatOpenAIResponses,
- To: types.RelayFormatClaude,
- },
- }, result.Steps)
- assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAIResponses, types.RelayFormatClaude}, info.ConversionChain)
-
- system, err := kitutil.Any2Type[[]dto.ClaudeMediaMessage](claudeReq.System)
- require.NoError(t, err)
- require.Len(t, system, 1)
- assert.Equal(t, "system rules", system[0].GetText())
- require.NotNil(t, claudeReq.Stream)
- assert.True(t, *claudeReq.Stream)
- assert.Equal(t, maxOutputTokens, *claudeReq.MaxTokens)
- require.NotNil(t, claudeReq.Thinking)
- assert.Equal(t, "enabled", claudeReq.Thinking.Type)
- assert.Equal(t, 2048, claudeReq.Thinking.GetBudgetTokens())
-
- tools, err := kitutil.Any2Type[[]*dto.Tool](claudeReq.Tools)
- require.NoError(t, err)
- require.Len(t, tools, 1)
- assert.Equal(t, "lookup", tools[0].Name)
-
- require.Len(t, claudeReq.Messages, 3)
- assert.Equal(t, "user", claudeReq.Messages[0].Role)
- userParts, err := claudeReq.Messages[0].ParseContent()
- require.NoError(t, err)
- require.Len(t, userParts, 1)
- assert.Equal(t, "question", userParts[0].GetText())
-
- assert.Equal(t, "assistant", claudeReq.Messages[1].Role)
- assistantParts, err := claudeReq.Messages[1].ParseContent()
- require.NoError(t, err)
- require.Len(t, assistantParts, 2)
- assert.Equal(t, "I will call.", assistantParts[0].GetText())
- assert.Equal(t, "tool_use", assistantParts[1].Type)
- assert.Equal(t, "call_1", assistantParts[1].Id)
- assert.Equal(t, "lookup", assistantParts[1].Name)
- assert.Equal(t, map[string]any{"q": "x"}, assistantParts[1].Input)
-
- assert.Equal(t, "user", claudeReq.Messages[2].Role)
- toolResultParts, err := claudeReq.Messages[2].ParseContent()
- require.NoError(t, err)
- require.Len(t, toolResultParts, 1)
- assert.Equal(t, "tool_result", toolResultParts[0].Type)
- assert.Equal(t, "call_1", toolResultParts[0].ToolUseId)
- assert.Equal(t, map[string]any{"ok": true}, toolResultParts[0].Content)
-}
-
func TestConvertRequestViaResponsesToGeminiStillUsesDirectSteps(t *testing.T) {
info := &convmeta.Values{
ConversionChain: []types.RelayFormat{types.RelayFormatOpenAIResponses},
@@ -629,7 +630,7 @@ func TestConvertRequestByIDDeduplicatesConversionChain(t *testing.T) {
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses}, info.ConversionChain)
}
-func TestConvertRequestByIDExecutesMultiHopConverter(t *testing.T) {
+func TestConvertRequestByIDExecutesDirectClaudeToResponsesConverter(t *testing.T) {
info := &convmeta.Values{
ConversionChain: []types.RelayFormat{types.RelayFormatClaude},
}
@@ -648,17 +649,12 @@ func TestConvertRequestByIDExecutesMultiHopConverter(t *testing.T) {
assert.Equal(t, RequestConverterQualityFair, result.Quality)
assert.Equal(t, []RequestStep{
{
- Converter: ConverterClaudeMessagesToOpenAIChat,
+ Converter: requestConverterClaudeToResponses,
From: types.RelayFormatClaude,
- To: types.RelayFormatOpenAI,
- },
- {
- Converter: ConverterOpenAIChatToOpenAIResponses,
- From: types.RelayFormatOpenAI,
To: types.RelayFormatOpenAIResponses,
},
}, result.Steps)
- assert.Equal(t, []types.RelayFormat{types.RelayFormatClaude, types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses}, info.ConversionChain)
+ assert.Equal(t, []types.RelayFormat{types.RelayFormatClaude, types.RelayFormatOpenAIResponses}, info.ConversionChain)
}
func TestConvertRequestRejectsUnsupportedConverterAndNilRequest(t *testing.T) {
@@ -701,3 +697,15 @@ func mustRawMessage(t *testing.T, value any) []byte {
require.NoError(t, err)
return raw
}
+
+func inputContentText(t *testing.T, item map[string]any) string {
+ t.Helper()
+ content, ok := item["content"].([]any)
+ require.True(t, ok)
+ require.Len(t, content, 1)
+ part, ok := content[0].(map[string]any)
+ require.True(t, ok)
+ text, ok := part["text"].(string)
+ require.True(t, ok)
+ return text
+}
diff --git a/relaykit/relayconvert/response_compat.go b/relaykit/relayconvert/response_compat.go
index 68c57cd9d13b..35b0c5fe6bf2 100644
--- a/relaykit/relayconvert/response_compat.go
+++ b/relaykit/relayconvert/response_compat.go
@@ -1,6 +1,8 @@
package relayconvert
import (
+ "fmt"
+
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
claudemessages "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/claude_messages"
@@ -10,12 +12,25 @@ import (
)
type ClaudeResponseInfo = claudemessages.ClaudeResponseInfo
+type ClaudeToChatStreamState = claudemessages.ClaudeToChatStreamState
type ChatToResponsesStreamEvent = oaichat.ChatToResponsesStreamEvent
type ChatToResponsesStreamState = oaichat.ChatToResponsesStreamState
type ResponsesToChatStreamState = oairesponses.ResponsesToChatStreamState
type ResponsesBufferedAccumulator = oairesponses.ResponsesBufferedAccumulator
+// ClaudeHostedStreamBridge owns Anthropic server-tool input deltas while a
+// Claude stream is being converted to the Responses protocol.
+type ClaudeHostedStreamBridge struct {
+ bridge *claudemessages.ClaudeHostedStreamBridge
+}
+
+// GeminiHostedStreamBridge accumulates Gemini grounding queries until the
+// upstream stream ends, then emits one canonical Responses web-search call.
+type GeminiHostedStreamBridge struct {
+ bridge *geminichat.GeminiHostedStreamBridge
+}
+
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
return oaichat.NormalizeCacheCreationSplit(totalTokens, tokens5m, tokens1h)
}
@@ -36,6 +51,52 @@ func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCo
return claudemessages.StreamResponseClaude2OpenAI(claudeResponse)
}
+func NewClaudeToChatStreamState() *ClaudeToChatStreamState {
+ return claudemessages.NewClaudeToChatStreamState()
+}
+
+func NewClaudeHostedStreamBridge() *ClaudeHostedStreamBridge {
+ return &ClaudeHostedStreamBridge{bridge: claudemessages.NewClaudeHostedStreamBridge()}
+}
+
+func NewGeminiHostedStreamBridge() *GeminiHostedStreamBridge {
+ return &GeminiHostedStreamBridge{bridge: geminichat.NewGeminiHostedStreamBridge()}
+}
+
+func (b *GeminiHostedStreamBridge) Observe(response *dto.GeminiChatResponse) {
+ if b == nil || b.bridge == nil {
+ return
+ }
+ b.bridge.Observe(response)
+}
+
+func (b *GeminiHostedStreamBridge) Finalize(state *ResponseStreamState) ([]ChatToResponsesStreamEvent, error) {
+ if b == nil || b.bridge == nil || state == nil {
+ return nil, nil
+ }
+ for _, stepState := range state.stepStates {
+ if streamState, ok := stepState.(*ChatToResponsesStreamState); ok {
+ return b.bridge.Finalize(streamState)
+ }
+ }
+ return nil, fmt.Errorf("Gemini hosted stream bridge requires a Chat-to-Responses stream state")
+}
+
+func (b *ClaudeHostedStreamBridge) Convert(response *dto.ClaudeResponse, state *ResponseStreamState) ([]ChatToResponsesStreamEvent, bool, error) {
+ if state == nil {
+ return nil, false, nil
+ }
+ if b == nil || b.bridge == nil {
+ return nil, false, nil
+ }
+ for _, stepState := range state.stepStates {
+ if streamState, ok := stepState.(*ChatToResponsesStreamState); ok {
+ return b.bridge.Convert(response, streamState)
+ }
+ }
+ return nil, false, nil
+}
+
func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextResponse {
return claudemessages.ResponseClaude2OpenAI(claudeResponse)
}
@@ -60,6 +121,10 @@ func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *d
return claudemessages.FormatClaudeResponseInfo(claudeResponse, oaiResponse, claudeInfo)
}
+func FinalizeClaudeStreamBillingUsage(claudeInfo *ClaudeResponseInfo) {
+ claudemessages.FinalizeClaudeStreamBillingUsage(claudeInfo)
+}
+
func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info convmeta.Meta) *dto.GeminiChatResponse {
return oaichat.ResponseOpenAI2Gemini(openAIResponse, info)
}
@@ -116,6 +181,10 @@ func UsageFromResponsesUsage(src *dto.Usage) *dto.Usage {
return oairesponses.UsageFromResponsesUsage(src)
}
+func NormalizeResponsesUsage(src *dto.Usage) *dto.Usage {
+ return oairesponses.NormalizeResponsesUsage(src)
+}
+
func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
return oairesponses.ExtractOutputTextFromResponses(resp)
}
diff --git a/relaykit/relayconvert/response_registry.go b/relaykit/relayconvert/response_registry.go
index a2369a61eda1..af2e8860d800 100644
--- a/relaykit/relayconvert/response_registry.go
+++ b/relaykit/relayconvert/response_registry.go
@@ -10,8 +10,11 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
+ claudemessages "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/claude_messages"
geminichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/gemini_chat"
oaichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_chat"
+ oairesponses "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_responses"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/toolconv"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
@@ -41,14 +44,15 @@ type ResponseStep struct {
}
type ResponseResult struct {
- Value any
- Usage *dto.Usage
- From types.RelayFormat
- To types.RelayFormat
- Converter string
- Quality ResponseConverterQuality
- Steps []ResponseStep
- Stream bool
+ Value any
+ Usage *dto.Usage
+ From types.RelayFormat
+ To types.RelayFormat
+ Converter string
+ Quality ResponseConverterQuality
+ Steps []ResponseStep
+ Stream bool
+ Diagnostics []types.ConversionDiagnostic
}
type ResponseConverterSpec struct {
@@ -74,6 +78,18 @@ type ResponseStreamOptions struct {
Model string
Created int64
IncludeUsage bool
+ // EmitSequenceNumber opts into the current Responses SSE wire contract.
+ // It is explicit so relaykit callers that depend on the historical zero-value
+ // output are not changed merely by upgrading the module.
+ EmitSequenceNumber bool
+}
+
+type conversionDiagnosticKey struct {
+ code string
+ path string
+ severity types.ConversionDiagnosticSeverity
+ from types.RelayFormat
+ to types.RelayFormat
}
type ResponseStreamState struct {
@@ -83,9 +99,18 @@ type ResponseStreamState struct {
Quality ResponseConverterQuality
Steps []ResponseStep
- specs []ResponseConverterSpec
- stepStates []any
- usage *dto.Usage
+ specs []ResponseConverterSpec
+ stepStates []any
+ usage *dto.Usage
+ diagnostics []types.ConversionDiagnostic
+ pendingDiagnostics []types.ConversionDiagnostic
+ seenDiagnostics map[conversionDiagnosticKey]struct{}
+ fallbackInfo *convmeta.Values
+}
+
+type responseStreamUsageCarrier interface {
+ StreamUsage() *dto.Usage
+ SetStreamUsage(*dto.Usage)
}
const (
@@ -302,6 +327,12 @@ func ConvertStreamResponseChunk(c context.Context, info convmeta.Meta, state *Re
if state == nil {
return nil, errors.New("response stream state is required")
}
+ if info == nil {
+ if state.fallbackInfo == nil {
+ state.fallbackInfo = &convmeta.Values{}
+ }
+ info = state.fallbackInfo
+ }
from, err := inferResponseRelayFormat(response)
if err != nil {
return nil, err
@@ -309,10 +340,13 @@ func ConvertStreamResponseChunk(c context.Context, info convmeta.Meta, state *Re
if from != state.From {
return nil, fmt.Errorf("response stream converter %q expects %s response, got %s", state.Converter, state.From, from)
}
+ diagnostics := toolconv.InspectStreamResponse(state.From, state.To, response)
+ state.rememberDiagnostics(diagnostics)
if state.From == state.To {
usage := canonicalUsageFromResponse(response)
state.rememberUsage(usage)
- return responseStreamResults(state, streamValuesFromAny(response), usage), nil
+ values := streamValuesFromAny(response)
+ return responseStreamResults(state, values, usage, state.takeDiagnostics(len(values) > 0)), nil
}
values, usage, err := executeResponseStreamSteps(c, info, state, []any{response}, 0)
@@ -320,13 +354,16 @@ func ConvertStreamResponseChunk(c context.Context, info convmeta.Meta, state *Re
return nil, err
}
state.rememberUsage(usage)
- return responseStreamResults(state, values, usage), nil
+ return responseStreamResults(state, values, usage, state.takeDiagnostics(len(values) > 0)), nil
}
func FinalizeStreamResponse(c context.Context, info convmeta.Meta, state *ResponseStreamState) ([]ResponseResult, error) {
if state == nil {
return nil, errors.New("response stream state is required")
}
+ if info == nil && state.fallbackInfo != nil {
+ info = state.fallbackInfo
+ }
if state.From == state.To {
return nil, nil
}
@@ -362,7 +399,7 @@ func FinalizeStreamResponse(c context.Context, info convmeta.Meta, state *Respon
}
values = append(values, current...)
}
- return responseStreamResults(state, values, usage), nil
+ return responseStreamResults(state, values, usage, state.takeDiagnostics(len(values) > 0)), nil
}
func (s *ResponseStreamState) Usage() *dto.Usage {
@@ -373,15 +410,12 @@ func (s *ResponseStreamState) Usage() *dto.Usage {
return s.usage
}
for _, state := range s.stepStates {
- switch typed := state.(type) {
- case *ChatToResponsesStreamState:
- if typed.Usage != nil {
- return typed.Usage
- }
- case *ResponsesToChatStreamState:
- if typed.Usage != nil {
- return typed.Usage
- }
+ carrier, ok := state.(responseStreamUsageCarrier)
+ if !ok {
+ continue
+ }
+ if usage := carrier.StreamUsage(); usage != nil {
+ return usage
}
}
return nil
@@ -393,13 +427,25 @@ func (s *ResponseStreamState) SetUsage(usage *dto.Usage) {
}
s.usage = usage
for _, state := range s.stepStates {
- switch typed := state.(type) {
- case *ChatToResponsesStreamState:
- typed.Usage = UsageFromChatUsage(usage)
- case *ResponsesToChatStreamState:
- typed.Usage = usage
+ if carrier, ok := state.(responseStreamUsageCarrier); ok {
+ carrier.SetStreamUsage(usage)
+ }
+ }
+}
+
+// FailResponsesStream emits protocol-native terminal error events when the
+// target is OpenAI Responses. It returns handled=false for other targets.
+func (s *ResponseStreamState) FailResponsesStream(code string, message string, param string) ([]ResponseResult, bool) {
+ if s == nil || s.To != types.RelayFormatOpenAIResponses {
+ return nil, false
+ }
+ for _, state := range s.stepStates {
+ if streamState, ok := state.(*ChatToResponsesStreamState); ok {
+ events := streamState.Fail(code, message, param)
+ return responseStreamResults(s, streamValuesFromAny(events), s.Usage(), s.takeDiagnostics(len(events) > 0)), true
}
}
+ return nil, false
}
func (s *ResponseStreamState) UsageText() string {
@@ -417,6 +463,15 @@ func (s *ResponseStreamState) UsageText() string {
return ""
}
+// Diagnostics returns every conversion-loss diagnostic observed so far. This
+// remains available even when a source event produces no target stream chunk.
+func (s *ResponseStreamState) Diagnostics() []types.ConversionDiagnostic {
+ if s == nil || len(s.diagnostics) == 0 {
+ return nil
+ }
+ return append([]types.ConversionDiagnostic{}, s.diagnostics...)
+}
+
func executeResponseSpec(c context.Context, info convmeta.Meta, from types.RelayFormat, target types.RelayFormat, response any, spec ResponseConverterSpec) (*ResponseResult, error) {
steps, err := expandResponseConverterSteps(spec)
if err != nil {
@@ -426,7 +481,11 @@ func executeResponseSpec(c context.Context, info convmeta.Meta, from types.Relay
}
func executeResponseSteps(c context.Context, info convmeta.Meta, from types.RelayFormat, target types.RelayFormat, response any, converter string, quality ResponseConverterQuality, specs []ResponseConverterSpec) (*ResponseResult, error) {
- current := response
+ diagnostics := toolconv.InspectResponse(from, target, response)
+ current, hostedResponse, err := toolconv.ExtractHostedResponse(from, response)
+ if err != nil {
+ return nil, err
+ }
var usage *dto.Usage
steps := make([]ResponseStep, 0, len(specs))
for _, spec := range specs {
@@ -438,6 +497,11 @@ func executeResponseSteps(c context.Context, info convmeta.Meta, from types.Rela
}
steps = append(steps, step)
}
+ current, hostedDiagnostics, err := toolconv.AttachHostedResponse(target, current, hostedResponse, convmeta.OptionsOf(info))
+ if err != nil {
+ return nil, err
+ }
+ diagnostics = append(diagnostics, hostedDiagnostics...)
converters := make([]string, 0, len(steps))
for _, step := range steps {
@@ -447,14 +511,15 @@ func executeResponseSteps(c context.Context, info convmeta.Meta, from types.Rela
converter = strings.Join(converters, ",")
}
return &ResponseResult{
- Value: current,
- Usage: usage,
- From: from,
- To: target,
- Converter: converter,
- Quality: quality,
- Steps: steps,
- Stream: false,
+ Value: current,
+ Usage: usage,
+ From: from,
+ To: target,
+ Converter: converter,
+ Quality: quality,
+ Steps: steps,
+ Stream: false,
+ Diagnostics: diagnostics,
}, nil
}
@@ -475,6 +540,7 @@ func executeResponseStep(c context.Context, info convmeta.Meta, spec ResponseCon
}
func executeStatelessStreamResponseSpec(c context.Context, info convmeta.Meta, from types.RelayFormat, target types.RelayFormat, response any, spec ResponseConverterSpec) (*ResponseResult, error) {
+ diagnostics := toolconv.InspectStreamResponse(from, target, response)
steps, err := expandResponseConverterSteps(spec)
if err != nil {
return nil, err
@@ -498,14 +564,15 @@ func executeStatelessStreamResponseSpec(c context.Context, info convmeta.Meta, f
})
}
return &ResponseResult{
- Value: current,
- Usage: usage,
- From: from,
- To: target,
- Converter: spec.ID,
- Quality: spec.Quality,
- Steps: resultSteps,
- Stream: true,
+ Value: current,
+ Usage: usage,
+ From: from,
+ To: target,
+ Converter: spec.ID,
+ Quality: spec.Quality,
+ Steps: resultSteps,
+ Stream: true,
+ Diagnostics: diagnostics,
}, nil
}
@@ -599,25 +666,63 @@ func finalizeResponseStreamStep(c context.Context, info convmeta.Meta, spec Resp
func (s *ResponseStreamState) rememberUsage(usage *dto.Usage) {
if s != nil && usage != nil {
- s.usage = usage
+ s.usage = dto.MergeUsageNonZero(s.usage, usage)
}
}
-func responseStreamResults(state *ResponseStreamState, values []any, usage *dto.Usage) []ResponseResult {
+func (s *ResponseStreamState) rememberDiagnostics(diagnostics []types.ConversionDiagnostic) {
+ if s == nil || len(diagnostics) == 0 {
+ return
+ }
+ if s.seenDiagnostics == nil {
+ s.seenDiagnostics = make(map[conversionDiagnosticKey]struct{})
+ }
+ for _, diagnostic := range diagnostics {
+ key := conversionDiagnosticKey{
+ code: diagnostic.Code,
+ path: diagnostic.Path,
+ severity: diagnostic.Severity,
+ from: diagnostic.From,
+ to: diagnostic.To,
+ }
+ if _, exists := s.seenDiagnostics[key]; exists {
+ continue
+ }
+ s.seenDiagnostics[key] = struct{}{}
+ s.diagnostics = append(s.diagnostics, diagnostic)
+ s.pendingDiagnostics = append(s.pendingDiagnostics, diagnostic)
+ }
+}
+
+func (s *ResponseStreamState) takeDiagnostics(hasOutput bool) []types.ConversionDiagnostic {
+ if s == nil || !hasOutput || len(s.pendingDiagnostics) == 0 {
+ return nil
+ }
+ diagnostics := append([]types.ConversionDiagnostic{}, s.pendingDiagnostics...)
+ s.pendingDiagnostics = nil
+ return diagnostics
+}
+
+func responseStreamResults(state *ResponseStreamState, values []any, usage *dto.Usage, diagnostics []types.ConversionDiagnostic) []ResponseResult {
if state == nil || len(values) == 0 {
return nil
}
results := make([]ResponseResult, 0, len(values))
- for _, value := range values {
+ for index, value := range values {
+ var resultDiagnostics []types.ConversionDiagnostic
+ if index == 0 {
+ resultDiagnostics = append(resultDiagnostics, diagnostics...)
+ }
results = append(results, ResponseResult{
- Value: value,
- Usage: usage,
- From: state.From,
- To: state.To,
- Converter: state.Converter,
- Quality: state.Quality,
- Steps: append([]ResponseStep{}, state.Steps...),
- Stream: true,
+ Value: value,
+ Usage: usage,
+ From: state.From,
+ To: state.To,
+ Converter: state.Converter,
+ Quality: state.Quality,
+ Steps: append([]ResponseStep{}, state.Steps...),
+ Stream: true,
+ Diagnostics: resultDiagnostics,
})
}
return results
@@ -738,42 +843,38 @@ func isNilResponse(response any) bool {
func canonicalUsageFromResponse(response any) *dto.Usage {
switch resp := response.(type) {
- case *dto.OpenAITextResponse:
- return UsageFromChatUsage(&resp.Usage)
case dto.OpenAITextResponse:
+ response = &resp
+ case dto.ChatCompletionsStreamResponse:
+ response = &resp
+ case dto.OpenAIResponsesResponse:
+ response = &resp
+ case dto.ResponsesStreamResponse:
+ response = &resp
+ case dto.ClaudeResponse:
+ response = &resp
+ case dto.GeminiChatResponse:
+ response = &resp
+ }
+ switch resp := response.(type) {
+ case *dto.OpenAITextResponse:
return UsageFromChatUsage(&resp.Usage)
case *dto.ChatCompletionsStreamResponse:
if resp.Usage == nil {
return nil
}
return UsageFromChatUsage(resp.Usage)
- case dto.ChatCompletionsStreamResponse:
- if resp.Usage == nil {
- return nil
- }
- return UsageFromChatUsage(resp.Usage)
case *dto.OpenAIResponsesResponse:
return UsageFromResponsesUsage(resp.Usage)
- case dto.OpenAIResponsesResponse:
- return UsageFromResponsesUsage(resp.Usage)
case *dto.ResponsesStreamResponse:
if resp.Response == nil {
return nil
}
return UsageFromResponsesUsage(resp.Response.Usage)
- case dto.ResponsesStreamResponse:
- if resp.Response == nil {
- return nil
- }
- return UsageFromResponsesUsage(resp.Response.Usage)
case *dto.ClaudeResponse:
return usageFromClaudeResponse(resp)
- case dto.ClaudeResponse:
- return usageFromClaudeResponse(&resp)
case *dto.GeminiChatResponse:
return UsageFromGeminiMetadata(resp.GetUsageMetadata(), 0)
- case dto.GeminiChatResponse:
- return UsageFromGeminiMetadata(resp.GetUsageMetadata(), 0)
default:
return nil
}
@@ -816,12 +917,21 @@ func convertOAIResponsesResponseToOAIChat(_ context.Context, _ convmeta.Meta, re
return ResponsesResponseToChatCompletionsResponse(responsesResponse, id)
}
+func convertOAIResponsesResponseToClaudeMessages(_ context.Context, _ convmeta.Meta, response any) (any, *dto.Usage, error) {
+ responsesResponse, err := asOAIResponsesResponse(response)
+ if err != nil {
+ return nil, nil, err
+ }
+ return oairesponses.ResponsesResponseToClaudeMessagesResponse(responsesResponse)
+}
+
func newOAIChatToOAIResponsesStreamState(options ResponseStreamOptions) any {
id := strings.TrimSpace(options.ID)
if id == "" {
id = fmt.Sprintf("resp_%s", kitutil.GetUUID())
}
state := NewChatToResponsesStreamState(id, strings.TrimSpace(options.Model))
+ state.EmitSequenceNumber = options.EmitSequenceNumber
if options.Created != 0 {
state.Created = options.Created
}
@@ -887,6 +997,56 @@ func finalizeOAIResponsesStreamResponseToOAIChat(_ context.Context, _ convmeta.M
return streamValuesFromAny(chunks), streamState.Usage, nil
}
+func newOAIResponsesToClaudeMessagesStreamState(options ResponseStreamOptions) any {
+ return oairesponses.NewResponsesToClaudeStreamState(options.ID, options.Model)
+}
+
+func convertOAIResponsesStreamResponseToClaudeMessages(_ context.Context, info convmeta.Meta, response any, state any) ([]any, *dto.Usage, error) {
+ responsesResponse, err := asOAIResponsesStreamResponse(response)
+ if err != nil {
+ return nil, nil, err
+ }
+ streamState, ok := state.(*oairesponses.ResponsesToClaudeStreamState)
+ if !ok || streamState == nil {
+ return nil, nil, errors.New("OAI responses to Claude stream state is required")
+ }
+ estimatedInputTokens := 0
+ if info != nil {
+ estimatedInputTokens = info.GetEstimatePromptTokens()
+ }
+ responses, usage, err := streamState.ConvertChunk(responsesResponse, estimatedInputTokens)
+ if err != nil {
+ return nil, usage, err
+ }
+ if info != nil && streamState.Done() {
+ claudeInfo := info.EnsureClaudeConvertInfo()
+ claudeInfo.Done = true
+ if claudeInfo.Usage == nil {
+ claudeInfo.Usage = usage
+ }
+ }
+ return streamValuesFromAny(responses), usage, nil
+}
+
+func finalizeOAIResponsesStreamResponseToClaudeMessages(_ context.Context, info convmeta.Meta, state any) ([]any, *dto.Usage, error) {
+ streamState, ok := state.(*oairesponses.ResponsesToClaudeStreamState)
+ if !ok || streamState == nil {
+ return nil, nil, errors.New("OAI responses to Claude stream state is required")
+ }
+ estimatedInputTokens := 0
+ if info != nil {
+ estimatedInputTokens = info.GetEstimatePromptTokens()
+ if usage := info.EnsureClaudeConvertInfo().Usage; usage != nil {
+ streamState.SetUsage(usage)
+ }
+ }
+ responses, err := streamState.Finalize(estimatedInputTokens)
+ if info != nil && streamState.Done() {
+ info.EnsureClaudeConvertInfo().Done = true
+ }
+ return streamValuesFromAny(responses), streamState.Usage, err
+}
+
func convertOAIChatResponseToClaudeMessages(_ context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error) {
chatResponse, err := asOAIChatResponse(response)
if err != nil {
@@ -928,6 +1088,38 @@ func convertOAIChatStreamResponseToGeminiChat(_ context.Context, info convmeta.M
return StreamResponseOpenAI2Gemini(chatResponse, info), canonicalUsageFromResponse(chatResponse), nil
}
+func newOAIChatToGeminiStreamState(_ ResponseStreamOptions) any {
+ return oaichat.NewChatToGeminiStreamState()
+}
+
+func convertOAIChatStreamResponseChunkToGeminiChat(_ context.Context, info convmeta.Meta, response any, state any) ([]any, *dto.Usage, error) {
+ chatResponse, err := asOAIChatStreamResponse(response)
+ if err != nil {
+ return nil, nil, err
+ }
+ streamState, ok := state.(*oaichat.ChatToGeminiStreamState)
+ if !ok || streamState == nil {
+ return nil, nil, errors.New("OAI chat to Gemini stream state is required")
+ }
+ responses, err := streamState.ConvertChunk(chatResponse, info)
+ if err != nil {
+ return nil, nil, err
+ }
+ return streamValuesFromAny(responses), canonicalUsageFromResponse(chatResponse), nil
+}
+
+func finalizeOAIChatStreamResponseToGeminiChat(_ context.Context, info convmeta.Meta, state any) ([]any, *dto.Usage, error) {
+ streamState, ok := state.(*oaichat.ChatToGeminiStreamState)
+ if !ok || streamState == nil {
+ return nil, nil, errors.New("OAI chat to Gemini stream state is required")
+ }
+ responses, err := streamState.Finalize(info)
+ if err != nil {
+ return nil, nil, err
+ }
+ return streamValuesFromAny(responses), streamState.Usage(), nil
+}
+
func convertClaudeMessagesResponseToOAIChat(_ context.Context, _ convmeta.Meta, response any) (any, *dto.Usage, error) {
claudeResponse, err := asClaudeResponse(response)
if err != nil {
@@ -954,6 +1146,30 @@ func convertClaudeMessagesStreamResponseToOAIChat(_ context.Context, _ convmeta.
return openAIResponse, usage, nil
}
+func newClaudeMessagesToOAIChatStreamState(_ ResponseStreamOptions) any {
+ return claudemessages.NewClaudeToChatStreamState()
+}
+
+func convertClaudeMessagesStreamResponseChunkToOAIChat(_ context.Context, _ convmeta.Meta, response any, state any) ([]any, *dto.Usage, error) {
+ claudeResponse, err := asClaudeResponse(response)
+ if err != nil {
+ return nil, nil, err
+ }
+ streamState, ok := state.(*claudemessages.ClaudeToChatStreamState)
+ if !ok || streamState == nil {
+ return nil, nil, errors.New("Claude-to-Chat stream state is required")
+ }
+ openAIResponse, err := streamState.ConvertChunk(claudeResponse)
+ if err != nil {
+ return nil, nil, err
+ }
+ usage := usageFromClaudeResponse(claudeResponse)
+ if openAIResponse != nil && usage != nil {
+ openAIResponse.Usage = usage
+ }
+ return streamValuesFromAny(openAIResponse), usage, nil
+}
+
func convertGeminiChatResponseToOAIChat(_ context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error) {
geminiResponse, err := asGeminiChatResponse(response)
if err != nil {
@@ -988,7 +1204,10 @@ func convertGeminiChatStreamResponseChunkToOAIChat(_ context.Context, info convm
if info != nil && info.HasChannelMeta() {
model = info.GetUpstreamModelName()
}
- responses := streamState.ConvertChunk(geminiResponse, model, usage)
+ responses, err := streamState.ConvertChunk(geminiResponse, model, usage)
+ if err != nil {
+ return nil, nil, err
+ }
return streamValuesFromAny(responses), usage, nil
}
@@ -1001,7 +1220,10 @@ func finalizeGeminiChatStreamResponseToOAIChat(_ context.Context, info convmeta.
if info != nil && info.HasChannelMeta() {
model = info.GetUpstreamModelName()
}
- responses := streamState.Finalize(model)
+ responses, err := streamState.Finalize(model)
+ if err != nil {
+ return nil, nil, err
+ }
return streamValuesFromAny(responses), streamState.Usage(), nil
}
diff --git a/relaykit/relayconvert/response_registry_test.go b/relaykit/relayconvert/response_registry_test.go
index 3e62d4c2ce4c..866cc4ea348d 100644
--- a/relaykit/relayconvert/response_registry_test.go
+++ b/relaykit/relayconvert/response_registry_test.go
@@ -75,10 +75,6 @@ func TestLookupBuiltinResponseConverters(t *testing.T) {
from: types.RelayFormatOpenAIResponses,
to: types.RelayFormatClaude,
quality: ResponseConverterQualityFair,
- stepConverters: []string{
- ConverterOpenAIResponsesToOpenAIChat,
- ConverterOpenAIChatToClaudeMessages,
- },
},
{
lookupID: responseConverterResponsesToGemini,
@@ -193,7 +189,7 @@ func TestConvertResponseDirectConverters(t *testing.T) {
require.NotNil(t, geminiValue.UsageMetadata.BillingUsage.OpenAIUsage)
}
-func TestConvertResponseMultiHopConverters(t *testing.T) {
+func TestConvertResponseDirectAndMultiHopConverters(t *testing.T) {
responses := textRegistryResponsesResponse()
toClaude, err := ConvertResponse(nil, &convmeta.Values{}, types.RelayFormatClaude, responses)
@@ -201,8 +197,7 @@ func TestConvertResponseMultiHopConverters(t *testing.T) {
assert.Equal(t, requestConverterResponsesToClaude, toClaude.Converter)
assert.Equal(t, ResponseConverterQualityFair, toClaude.Quality)
assert.Equal(t, []ResponseStep{
- {Converter: ConverterOpenAIResponsesToOpenAIChat, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatOpenAI},
- {Converter: ConverterOpenAIChatToClaudeMessages, From: types.RelayFormatOpenAI, To: types.RelayFormatClaude},
+ {Converter: ConverterOpenAIResponsesToClaudeMessages, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatClaude},
}, toClaude.Steps)
require.IsType(t, &dto.ClaudeResponse{}, toClaude.Value)
claudeValue := toClaude.Value.(*dto.ClaudeResponse)
@@ -232,6 +227,40 @@ func TestConvertResponseMultiHopConverters(t *testing.T) {
assert.Equal(t, 11, toGemini.Usage.TotalTokens)
}
+func TestConvertResponsePreservesInterleavedResponsesBlocksForClaude(t *testing.T) {
+ responses := &dto.OpenAIResponsesResponse{
+ ID: "resp_1",
+ Model: "gpt-test",
+ Status: []byte(`"completed"`),
+ Output: []dto.ResponsesOutput{
+ {Type: "reasoning", Summary: []dto.ResponsesReasoningSummaryPart{{Type: "summary_text", Text: "**Planning file inspection**"}}},
+ {Type: "message", Role: "assistant", Content: []dto.ResponsesOutputContent{{Type: "output_text", Text: "I’ll inspect the starter repository."}}},
+ {Type: "reasoning", Summary: []dto.ResponsesReasoningSummaryPart{{Type: "summary_text", Text: "**Clarifying environment task requirements**"}}},
+ {Type: "message", Role: "assistant", Content: []dto.ResponsesOutputContent{{Type: "output_text", Text: "What would you like me to build?"}}},
+ },
+ }
+
+ result, err := ConvertResponse(nil, nil, types.RelayFormatClaude, responses)
+ require.NoError(t, err)
+ assert.Equal(t, []ResponseStep{
+ {Converter: ConverterOpenAIResponsesToClaudeMessages, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatClaude},
+ }, result.Steps)
+ claudeResponse := result.Value.(*dto.ClaudeResponse)
+ require.Len(t, claudeResponse.Content, 4)
+ assert.Equal(t, []string{"thinking", "text", "thinking", "text"}, []string{
+ claudeResponse.Content[0].Type,
+ claudeResponse.Content[1].Type,
+ claudeResponse.Content[2].Type,
+ claudeResponse.Content[3].Type,
+ })
+ require.NotNil(t, claudeResponse.Content[0].Thinking)
+ require.NotNil(t, claudeResponse.Content[2].Thinking)
+ assert.Equal(t, "**Planning file inspection**", *claudeResponse.Content[0].Thinking)
+ assert.Equal(t, "I’ll inspect the starter repository.", claudeResponse.Content[1].GetText())
+ assert.Equal(t, "**Clarifying environment task requirements**", *claudeResponse.Content[2].Thinking)
+ assert.Equal(t, "What would you like me to build?", claudeResponse.Content[3].GetText())
+}
+
func TestConvertResponseByIDExecutesMultiHopAndChecksSource(t *testing.T) {
responses := textRegistryResponsesResponse()
@@ -489,7 +518,7 @@ func TestConvertStreamResponseStatefulDirectConverters(t *testing.T) {
require.IsType(t, dto.ChatCompletionsStreamResponse{}, responsesResults[len(responsesResults)-1].Value)
}
-func TestConvertStreamResponseStatefulMultiHopResponsesToClaude(t *testing.T) {
+func TestConvertStreamResponseStatefulDirectResponsesToClaude(t *testing.T) {
info := &convmeta.Values{
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{
LastMessagesType: convmeta.LastMessageTypeNone,
@@ -509,8 +538,7 @@ func TestConvertStreamResponseStatefulMultiHopResponsesToClaude(t *testing.T) {
require.NotEmpty(t, results)
assert.Equal(t, requestConverterResponsesToClaude, results[0].Converter)
assert.Equal(t, []ResponseStep{
- {Converter: ConverterOpenAIResponsesToOpenAIChat, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatOpenAI},
- {Converter: ConverterOpenAIChatToClaudeMessages, From: types.RelayFormatOpenAI, To: types.RelayFormatClaude},
+ {Converter: ConverterOpenAIResponsesToClaudeMessages, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatClaude},
}, results[0].Steps)
var sawTextDelta bool
diff --git a/relaykit/relayconvert/terminal_stream_test.go b/relaykit/relayconvert/terminal_stream_test.go
index 6ea59260f312..48d6f33d988b 100644
--- a/relaykit/relayconvert/terminal_stream_test.go
+++ b/relaykit/relayconvert/terminal_stream_test.go
@@ -165,8 +165,7 @@ func TestClaudeTargetStatefulStreamTerminalTail(t *testing.T) {
},
},
},
- wantFinalizerTerminals: true,
- wantStopReason: "end_turn",
+ wantStopReason: "end_turn",
},
}
@@ -228,26 +227,10 @@ func TestClaudeTargetStatefulStreamTerminalTail(t *testing.T) {
)
require.NoError(t, err)
- chunks := []*dto.ResponsesStreamResponse{
- {
- Type: "response.output_text.delta",
- Delta: "Hello",
- },
- {
- Type: "response.completed",
- Response: &dto.OpenAIResponsesResponse{
- ID: "resp-fixed",
- Object: "response",
- Model: "upstream-model",
- Status: []byte(`"completed"`),
- Usage: &dto.Usage{
- InputTokens: 4,
- OutputTokens: 2,
- TotalTokens: 6,
- },
- },
- },
- }
+ chunks := []*dto.ResponsesStreamResponse{{
+ Type: "response.output_text.delta",
+ Delta: "Hello",
+ }}
for _, chunk := range chunks {
_, err := ConvertStreamResponseChunk(nil, info, state, chunk)
require.NoError(t, err)
diff --git a/relaykit/relayconvert/testdata/golden/request/claude_to_gemini.golden.json b/relaykit/relayconvert/testdata/golden/request/claude_to_gemini.golden.json
deleted file mode 100644
index 8ec074a6f41d..000000000000
--- a/relaykit/relayconvert/testdata/golden/request/claude_to_gemini.golden.json
+++ /dev/null
@@ -1,94 +0,0 @@
-{
- "contents": [
- {
- "role": "user",
- "parts": [
- {
- "text": "What is in this image?"
- },
- {
- "inlineData": {
- "mimeType": "image/png",
- "data": "aGVsbG8="
- }
- }
- ]
- },
- {
- "role": "model",
- "parts": [
- {
- "functionCall": {
- "name": "get_weather",
- "args": {
- "city": "Paris"
- }
- },
- "thoughtSignature": "context_engineering_is_the_way_to_go"
- }
- ]
- },
- {
- "role": "user",
- "parts": [
- {
- "functionResponse": {
- "name": "get_weather",
- "response": {
- "content": "15 degrees"
- }
- }
- }
- ]
- }
- ],
- "safetySettings": [
- {
- "category": "HARM_CATEGORY_HARASSMENT",
- "threshold": "OFF"
- },
- {
- "category": "HARM_CATEGORY_HATE_SPEECH",
- "threshold": "OFF"
- },
- {
- "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
- "threshold": "OFF"
- },
- {
- "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
- "threshold": "OFF"
- }
- ],
- "generationConfig": {
- "maxOutputTokens": 1024
- },
- "tools": [
- {
- "functionDeclarations": [
- {
- "description": "Get weather by city",
- "name": "get_weather",
- "parameters": {
- "properties": {
- "city": {
- "type": "STRING"
- }
- },
- "required": [
- "city"
- ],
- "type": "OBJECT"
- }
- }
- ]
- }
- ],
- "systemInstruction": {
- "parts": [
- {
- "text": "You are a helpful assistant."
- }
- ]
- }
-}
diff --git a/relaykit/relayconvert/testdata/golden/request/claude_to_openai.golden.json b/relaykit/relayconvert/testdata/golden/request/claude_to_openai.golden.json
deleted file mode 100644
index 59a20e45a6eb..000000000000
--- a/relaykit/relayconvert/testdata/golden/request/claude_to_openai.golden.json
+++ /dev/null
@@ -1,67 +0,0 @@
-{
- "model": "claude-test",
- "messages": [
- {
- "role": "system",
- "content": "You are a helpful assistant."
- },
- {
- "role": "user",
- "content": [
- {
- "type": "text",
- "text": "What is in this image?"
- },
- {
- "type": "image_url",
- "image_url": {
- "url": "data:image/png;base64,aGVsbG8=",
- "MimeType": ""
- }
- }
- ]
- },
- {
- "role": "assistant",
- "content": null,
- "tool_calls": [
- {
- "id": "toolu_abc",
- "type": "function",
- "function": {
- "name": "get_weather",
- "arguments": "{\"city\":\"Paris\"}"
- }
- }
- ]
- },
- {
- "role": "tool",
- "content": "15 degrees",
- "name": "get_weather",
- "tool_call_id": "toolu_abc"
- }
- ],
- "stream": true,
- "max_tokens": 1024,
- "tools": [
- {
- "type": "function",
- "function": {
- "description": "Get weather by city",
- "name": "get_weather",
- "parameters": {
- "properties": {
- "city": {
- "type": "string"
- }
- },
- "required": [
- "city"
- ],
- "type": "object"
- }
- }
- }
- ]
-}
diff --git a/relaykit/relayconvert/testdata/golden/request/claude_to_openai_responses.golden.json b/relaykit/relayconvert/testdata/golden/request/claude_to_openai_responses.golden.json
deleted file mode 100644
index e5f688638916..000000000000
--- a/relaykit/relayconvert/testdata/golden/request/claude_to_openai_responses.golden.json
+++ /dev/null
@@ -1,54 +0,0 @@
-{
- "model": "claude-test",
- "input": [
- {
- "content": [
- {
- "text": "What is in this image?",
- "type": "input_text"
- },
- {
- "image_url": "data:image/png;base64,aGVsbG8=",
- "type": "input_image"
- }
- ],
- "role": "user"
- },
- {
- "content": "",
- "role": "assistant"
- },
- {
- "arguments": "{\"city\":\"Paris\"}",
- "call_id": "toolu_abc",
- "name": "get_weather",
- "type": "function_call"
- },
- {
- "call_id": "toolu_abc",
- "output": "15 degrees",
- "type": "function_call_output"
- }
- ],
- "instructions": "You are a helpful assistant.",
- "max_output_tokens": 1024,
- "stream": true,
- "tools": [
- {
- "description": "Get weather by city",
- "name": "get_weather",
- "parameters": {
- "properties": {
- "city": {
- "type": "string"
- }
- },
- "required": [
- "city"
- ],
- "type": "object"
- },
- "type": "function"
- }
- ]
-}
diff --git a/relaykit/relayconvert/testdata/golden/request/gemini_to_claude.golden.json b/relaykit/relayconvert/testdata/golden/request/gemini_to_claude.golden.json
deleted file mode 100644
index 9539d2b563e1..000000000000
--- a/relaykit/relayconvert/testdata/golden/request/gemini_to_claude.golden.json
+++ /dev/null
@@ -1,61 +0,0 @@
-{
- "model": "upstream-model",
- "system": [
- {
- "type": "text",
- "text": "You are a helpful assistant."
- }
- ],
- "messages": [
- {
- "role": "user",
- "content": []
- },
- {
- "role": "assistant",
- "content": [
- {
- "type": "text",
- "text": "..."
- },
- {
- "type": "tool_use",
- "id": "call_1",
- "name": "get_weather",
- "input": {
- "city": "Paris"
- }
- }
- ]
- },
- {
- "role": "user",
- "content": [
- {
- "type": "tool_result",
- "content": "{\"result\":\"15 degrees\"}",
- "tool_use_id": "call_0"
- }
- ]
- }
- ],
- "max_tokens": 1024,
- "temperature": 0.7,
- "tools": [
- {
- "name": "get_weather",
- "description": "Get weather by city",
- "input_schema": {
- "properties": {
- "city": {
- "type": "string"
- }
- },
- "required": [
- "city"
- ],
- "type": "object"
- }
- }
- ]
-}
diff --git a/relaykit/relayconvert/testdata/golden/request/gemini_to_openai.golden.json b/relaykit/relayconvert/testdata/golden/request/gemini_to_openai.golden.json
deleted file mode 100644
index bc1e267539a4..000000000000
--- a/relaykit/relayconvert/testdata/golden/request/gemini_to_openai.golden.json
+++ /dev/null
@@ -1,68 +0,0 @@
-{
- "model": "upstream-model",
- "messages": [
- {
- "role": "system",
- "content": "You are a helpful assistant."
- },
- {
- "role": "user",
- "content": [
- {
- "type": "text",
- "text": "What is in this image?"
- },
- {
- "type": "image_url",
- "image_url": {
- "url": "data:image/png;base64,aGVsbG8=",
- "detail": "auto",
- "MimeType": "image/png"
- }
- }
- ]
- },
- {
- "role": "assistant",
- "content": null,
- "tool_calls": [
- {
- "id": "call_1",
- "type": "function",
- "function": {
- "name": "get_weather",
- "arguments": "{\"city\":\"Paris\"}"
- }
- }
- ]
- },
- {
- "role": "tool",
- "content": "{\"result\":\"15 degrees\"}",
- "tool_call_id": "call_0"
- }
- ],
- "stream": false,
- "max_tokens": 1024,
- "temperature": 0.7,
- "tools": [
- {
- "type": "function",
- "function": {
- "description": "Get weather by city",
- "name": "get_weather",
- "parameters": {
- "properties": {
- "city": {
- "type": "string"
- }
- },
- "required": [
- "city"
- ],
- "type": "object"
- }
- }
- }
- ]
-}
diff --git a/relaykit/relayconvert/testdata/golden/request/gemini_to_openai_responses.golden.json b/relaykit/relayconvert/testdata/golden/request/gemini_to_openai_responses.golden.json
deleted file mode 100644
index ec9ba42bae76..000000000000
--- a/relaykit/relayconvert/testdata/golden/request/gemini_to_openai_responses.golden.json
+++ /dev/null
@@ -1,55 +0,0 @@
-{
- "model": "upstream-model",
- "input": [
- {
- "content": [
- {
- "text": "What is in this image?",
- "type": "input_text"
- },
- {
- "image_url": "data:image/png;base64,aGVsbG8=",
- "type": "input_image"
- }
- ],
- "role": "user"
- },
- {
- "content": "",
- "role": "assistant"
- },
- {
- "arguments": "{\"city\":\"Paris\"}",
- "call_id": "call_1",
- "name": "get_weather",
- "type": "function_call"
- },
- {
- "call_id": "call_0",
- "output": "{\"result\":\"15 degrees\"}",
- "type": "function_call_output"
- }
- ],
- "instructions": "You are a helpful assistant.",
- "max_output_tokens": 1024,
- "stream": false,
- "temperature": 0.7,
- "tools": [
- {
- "description": "Get weather by city",
- "name": "get_weather",
- "parameters": {
- "properties": {
- "city": {
- "type": "string"
- }
- },
- "required": [
- "city"
- ],
- "type": "object"
- },
- "type": "function"
- }
- ]
-}
diff --git a/relaykit/relayconvert/testdata/golden/request/openai_responses_to_gemini.golden.json b/relaykit/relayconvert/testdata/golden/request/openai_responses_to_gemini.golden.json
deleted file mode 100644
index 8ec074a6f41d..000000000000
--- a/relaykit/relayconvert/testdata/golden/request/openai_responses_to_gemini.golden.json
+++ /dev/null
@@ -1,94 +0,0 @@
-{
- "contents": [
- {
- "role": "user",
- "parts": [
- {
- "text": "What is in this image?"
- },
- {
- "inlineData": {
- "mimeType": "image/png",
- "data": "aGVsbG8="
- }
- }
- ]
- },
- {
- "role": "model",
- "parts": [
- {
- "functionCall": {
- "name": "get_weather",
- "args": {
- "city": "Paris"
- }
- },
- "thoughtSignature": "context_engineering_is_the_way_to_go"
- }
- ]
- },
- {
- "role": "user",
- "parts": [
- {
- "functionResponse": {
- "name": "get_weather",
- "response": {
- "content": "15 degrees"
- }
- }
- }
- ]
- }
- ],
- "safetySettings": [
- {
- "category": "HARM_CATEGORY_HARASSMENT",
- "threshold": "OFF"
- },
- {
- "category": "HARM_CATEGORY_HATE_SPEECH",
- "threshold": "OFF"
- },
- {
- "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
- "threshold": "OFF"
- },
- {
- "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
- "threshold": "OFF"
- }
- ],
- "generationConfig": {
- "maxOutputTokens": 1024
- },
- "tools": [
- {
- "functionDeclarations": [
- {
- "description": "Get weather by city",
- "name": "get_weather",
- "parameters": {
- "properties": {
- "city": {
- "type": "STRING"
- }
- },
- "required": [
- "city"
- ],
- "type": "OBJECT"
- }
- }
- ]
- }
- ],
- "systemInstruction": {
- "parts": [
- {
- "text": "You are a helpful assistant."
- }
- ]
- }
-}
diff --git a/relaykit/relayconvert/testdata/golden/request/openai_to_gemini.golden.json b/relaykit/relayconvert/testdata/golden/request/openai_to_gemini.golden.json
deleted file mode 100644
index 93b0ebb27cd6..000000000000
--- a/relaykit/relayconvert/testdata/golden/request/openai_to_gemini.golden.json
+++ /dev/null
@@ -1,107 +0,0 @@
-{
- "contents": [
- {
- "role": "user",
- "parts": [
- {
- "text": "What is in this image?"
- },
- {
- "inlineData": {
- "mimeType": "image/png",
- "data": "aGVsbG8="
- }
- }
- ]
- },
- {
- "role": "model",
- "parts": [
- {
- "functionCall": {
- "name": "get_weather",
- "args": {
- "city": "Paris"
- }
- },
- "thoughtSignature": "context_engineering_is_the_way_to_go"
- }
- ]
- },
- {
- "role": "user",
- "parts": [
- {
- "functionResponse": {
- "name": "get_weather",
- "response": {
- "content": "15 degrees"
- }
- }
- }
- ]
- },
- {
- "role": "user",
- "parts": [
- {
- "text": "Summarize."
- }
- ]
- }
- ],
- "safetySettings": [
- {
- "category": "HARM_CATEGORY_HARASSMENT",
- "threshold": "OFF"
- },
- {
- "category": "HARM_CATEGORY_HATE_SPEECH",
- "threshold": "OFF"
- },
- {
- "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
- "threshold": "OFF"
- },
- {
- "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
- "threshold": "OFF"
- }
- ],
- "generationConfig": {
- "maxOutputTokens": 1024
- },
- "tools": [
- {
- "functionDeclarations": [
- {
- "description": "Get weather by city",
- "name": "get_weather",
- "parameters": {
- "properties": {
- "city": {
- "type": "STRING"
- }
- },
- "required": [
- "city"
- ],
- "type": "OBJECT"
- }
- }
- ]
- }
- ],
- "toolConfig": {
- "functionCallingConfig": {
- "mode": "AUTO"
- }
- },
- "systemInstruction": {
- "parts": [
- {
- "text": "You are a helpful assistant."
- }
- ]
- }
-}
diff --git a/relaykit/relayconvert/testdata/golden/response/claude_to_gemini.golden.json b/relaykit/relayconvert/testdata/golden/response/claude_to_gemini.golden.json
deleted file mode 100644
index 93cd2c91dae8..000000000000
--- a/relaykit/relayconvert/testdata/golden/response/claude_to_gemini.golden.json
+++ /dev/null
@@ -1,66 +0,0 @@
-{
- "candidates": [
- {
- "content": {
- "role": "model",
- "parts": [
- {
- "text": "The answer is 42."
- },
- {
- "functionCall": {
- "name": "get_weather",
- "args": {
- "city": "Paris"
- }
- }
- }
- ]
- },
- "finishReason": "STOP",
- "index": 0,
- "safetyRatings": []
- }
- ],
- "usageMetadata": {
- "promptTokenCount": 15,
- "toolUsePromptTokenCount": 0,
- "candidatesTokenCount": 5,
- "totalTokenCount": 20,
- "thoughtsTokenCount": 0,
- "cachedContentTokenCount": 0,
- "promptTokensDetails": null,
- "toolUsePromptTokensDetails": null,
- "candidatesTokensDetails": null,
- "billing_usage": {
- "source": "oai_chat",
- "semantic": "openai",
- "openai_usage": {
- "prompt_tokens": 15,
- "completion_tokens": 5,
- "total_tokens": 20,
- "usage_semantic": "openai",
- "usage_source": "anthropic",
- "prompt_tokens_details": {
- "cached_tokens": 3,
- "cached_creation_tokens": 2,
- "cache_write_tokens": 2,
- "text_tokens": 0,
- "audio_tokens": 0,
- "image_tokens": 0
- },
- "completion_tokens_details": {
- "text_tokens": 0,
- "audio_tokens": 0,
- "image_tokens": 0,
- "reasoning_tokens": 0
- },
- "input_tokens": 15,
- "output_tokens": 0,
- "input_tokens_details": null,
- "claude_cache_creation_5_m_tokens": 2,
- "claude_cache_creation_1_h_tokens": 0
- }
- }
- }
-}
diff --git a/relaykit/relayconvert/testdata/golden/response/gemini_to_claude.golden.json b/relaykit/relayconvert/testdata/golden/response/gemini_to_claude.golden.json
index 2f3d24927bff..df16b37859b1 100644
--- a/relaykit/relayconvert/testdata/golden/response/gemini_to_claude.golden.json
+++ b/relaykit/relayconvert/testdata/golden/response/gemini_to_claude.golden.json
@@ -26,29 +26,18 @@
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0,
"billing_usage": {
- "source": "oai_chat",
- "semantic": "openai",
- "openai_usage": {
- "prompt_tokens": 10,
- "completion_tokens": 7,
- "total_tokens": 15,
- "prompt_tokens_details": {
- "cached_tokens": 0,
- "text_tokens": 10,
- "audio_tokens": 0,
- "image_tokens": 0
- },
- "completion_tokens_details": {
- "text_tokens": 0,
- "audio_tokens": 0,
- "image_tokens": 0,
- "reasoning_tokens": 2
- },
- "input_tokens": 0,
- "output_tokens": 0,
- "input_tokens_details": null,
- "claude_cache_creation_5_m_tokens": 0,
- "claude_cache_creation_1_h_tokens": 0
+ "source": "gemini_chat",
+ "semantic": "gemini",
+ "gemini_usage_metadata": {
+ "promptTokenCount": 10,
+ "toolUsePromptTokenCount": 0,
+ "candidatesTokenCount": 5,
+ "totalTokenCount": 15,
+ "thoughtsTokenCount": 2,
+ "cachedContentTokenCount": 0,
+ "promptTokensDetails": [],
+ "toolUsePromptTokensDetails": [],
+ "candidatesTokensDetails": []
}
}
}
diff --git a/relaykit/relayconvert/testdata/golden/response/openai_responses_to_claude.golden.json b/relaykit/relayconvert/testdata/golden/response/openai_responses_to_claude.golden.json
index a5d42e1042b6..f82fbf743887 100644
--- a/relaykit/relayconvert/testdata/golden/response/openai_responses_to_claude.golden.json
+++ b/relaykit/relayconvert/testdata/golden/response/openai_responses_to_claude.golden.json
@@ -3,6 +3,10 @@
"type": "message",
"role": "assistant",
"content": [
+ {
+ "type": "thinking",
+ "thinking": "Deep thought."
+ },
{
"type": "text",
"text": "The answer is 42."
diff --git a/relaykit/relayconvert/testdata/golden/response/openai_responses_to_gemini.golden.json b/relaykit/relayconvert/testdata/golden/response/openai_responses_to_gemini.golden.json
deleted file mode 100644
index 9348f4f75f0d..000000000000
--- a/relaykit/relayconvert/testdata/golden/response/openai_responses_to_gemini.golden.json
+++ /dev/null
@@ -1,62 +0,0 @@
-{
- "candidates": [
- {
- "content": {
- "role": "model",
- "parts": [
- {
- "text": "The answer is 42."
- },
- {
- "functionCall": {
- "name": "get_weather",
- "args": {
- "city": "Paris"
- }
- }
- }
- ]
- },
- "finishReason": "STOP",
- "index": 0,
- "safetyRatings": []
- }
- ],
- "usageMetadata": {
- "promptTokenCount": 10,
- "toolUsePromptTokenCount": 0,
- "candidatesTokenCount": 5,
- "totalTokenCount": 15,
- "thoughtsTokenCount": 0,
- "cachedContentTokenCount": 0,
- "promptTokensDetails": null,
- "toolUsePromptTokensDetails": null,
- "candidatesTokensDetails": null,
- "billing_usage": {
- "source": "oai_responses",
- "semantic": "openai",
- "openai_usage": {
- "prompt_tokens": 0,
- "completion_tokens": 0,
- "total_tokens": 15,
- "prompt_tokens_details": {
- "cached_tokens": 0,
- "text_tokens": 0,
- "audio_tokens": 0,
- "image_tokens": 0
- },
- "completion_tokens_details": {
- "text_tokens": 0,
- "audio_tokens": 0,
- "image_tokens": 0,
- "reasoning_tokens": 0
- },
- "input_tokens": 10,
- "output_tokens": 5,
- "input_tokens_details": null,
- "claude_cache_creation_5_m_tokens": 0,
- "claude_cache_creation_1_h_tokens": 0
- }
- }
- }
-}
diff --git a/relaykit/relayconvert/testdata/golden/response/openai_responses_to_openai.golden.json b/relaykit/relayconvert/testdata/golden/response/openai_responses_to_openai.golden.json
index 17b4b4f42926..4717a533616f 100644
--- a/relaykit/relayconvert/testdata/golden/response/openai_responses_to_openai.golden.json
+++ b/relaykit/relayconvert/testdata/golden/response/openai_responses_to_openai.golden.json
@@ -9,6 +9,7 @@
"message": {
"role": "assistant",
"content": "The answer is 42.",
+ "reasoning_content": "Deep thought.",
"tool_calls": [
{
"id": "call_abc",
diff --git a/relaykit/relayconvert/testdata/golden/response/openai_to_claude.golden.json b/relaykit/relayconvert/testdata/golden/response/openai_to_claude.golden.json
deleted file mode 100644
index b6d3c2fcc8fe..000000000000
--- a/relaykit/relayconvert/testdata/golden/response/openai_to_claude.golden.json
+++ /dev/null
@@ -1,55 +0,0 @@
-{
- "id": "chatcmpl-fixed",
- "type": "message",
- "role": "assistant",
- "content": [
- {
- "type": "text",
- "text": "The answer is 42."
- },
- {
- "type": "tool_use",
- "id": "call_abc",
- "name": "get_weather",
- "input": {
- "city": "Paris"
- }
- }
- ],
- "stop_reason": "tool_use",
- "model": "gpt-test",
- "usage": {
- "input_tokens": 10,
- "cache_creation_input_tokens": 0,
- "cache_read_input_tokens": 3,
- "output_tokens": 5,
- "claude_cache_creation_5_m_tokens": 0,
- "claude_cache_creation_1_h_tokens": 0,
- "billing_usage": {
- "source": "oai_chat",
- "semantic": "openai",
- "openai_usage": {
- "prompt_tokens": 10,
- "completion_tokens": 5,
- "total_tokens": 15,
- "prompt_tokens_details": {
- "cached_tokens": 3,
- "text_tokens": 0,
- "audio_tokens": 0,
- "image_tokens": 0
- },
- "completion_tokens_details": {
- "text_tokens": 0,
- "audio_tokens": 0,
- "image_tokens": 0,
- "reasoning_tokens": 2
- },
- "input_tokens": 0,
- "output_tokens": 0,
- "input_tokens_details": null,
- "claude_cache_creation_5_m_tokens": 0,
- "claude_cache_creation_1_h_tokens": 0
- }
- }
- }
-}
diff --git a/relaykit/relayconvert/testdata/golden/response/openai_to_gemini.golden.json b/relaykit/relayconvert/testdata/golden/response/openai_to_gemini.golden.json
deleted file mode 100644
index 3eff897a8fd9..000000000000
--- a/relaykit/relayconvert/testdata/golden/response/openai_to_gemini.golden.json
+++ /dev/null
@@ -1,62 +0,0 @@
-{
- "candidates": [
- {
- "content": {
- "role": "model",
- "parts": [
- {
- "text": "The answer is 42."
- },
- {
- "functionCall": {
- "name": "get_weather",
- "args": {
- "city": "Paris"
- }
- }
- }
- ]
- },
- "finishReason": "STOP",
- "index": 0,
- "safetyRatings": []
- }
- ],
- "usageMetadata": {
- "promptTokenCount": 10,
- "toolUsePromptTokenCount": 0,
- "candidatesTokenCount": 5,
- "totalTokenCount": 15,
- "thoughtsTokenCount": 0,
- "cachedContentTokenCount": 0,
- "promptTokensDetails": null,
- "toolUsePromptTokensDetails": null,
- "candidatesTokensDetails": null,
- "billing_usage": {
- "source": "oai_chat",
- "semantic": "openai",
- "openai_usage": {
- "prompt_tokens": 10,
- "completion_tokens": 5,
- "total_tokens": 15,
- "prompt_tokens_details": {
- "cached_tokens": 3,
- "text_tokens": 0,
- "audio_tokens": 0,
- "image_tokens": 0
- },
- "completion_tokens_details": {
- "text_tokens": 0,
- "audio_tokens": 0,
- "image_tokens": 0,
- "reasoning_tokens": 2
- },
- "input_tokens": 0,
- "output_tokens": 0,
- "input_tokens_details": null,
- "claude_cache_creation_5_m_tokens": 0,
- "claude_cache_creation_1_h_tokens": 0
- }
- }
- }
-}
diff --git a/relaykit/relayconvert/testdata/golden/response/openai_to_openai_responses.golden.json b/relaykit/relayconvert/testdata/golden/response/openai_to_openai_responses.golden.json
index 366544048623..8224bf327734 100644
--- a/relaykit/relayconvert/testdata/golden/response/openai_to_openai_responses.golden.json
+++ b/relaykit/relayconvert/testdata/golden/response/openai_to_openai_responses.golden.json
@@ -8,30 +8,30 @@
"model": "gpt-test",
"output": [
{
- "type": "message",
- "id": "chatcmpl-fixed_msg_0",
+ "type": "reasoning",
+ "id": "chatcmpl-fixed_reasoning_0",
"status": "completed",
- "role": "assistant",
- "content": [
+ "role": "",
+ "content": null,
+ "summary": [
{
- "type": "output_text",
- "text": "The answer is 42.",
- "annotations": []
+ "type": "summary_text",
+ "text": "Deep thought."
}
],
"quality": "",
"size": ""
},
{
- "type": "reasoning",
- "id": "chatcmpl-fixed_reasoning_0",
+ "type": "message",
+ "id": "chatcmpl-fixed_msg_0",
"status": "completed",
- "role": "",
+ "role": "assistant",
"content": [
{
- "type": "summary_text",
- "text": "Deep thought.",
- "annotations": null
+ "type": "output_text",
+ "text": "The answer is 42.",
+ "annotations": []
}
],
"quality": "",
diff --git a/relaykit/relayconvert/testdata/golden/stream/claude_to_gemini.golden.json b/relaykit/relayconvert/testdata/golden/stream/claude_to_gemini.golden.json
index 18b47522ccee..7717e4fdd126 100644
--- a/relaykit/relayconvert/testdata/golden/stream/claude_to_gemini.golden.json
+++ b/relaykit/relayconvert/testdata/golden/stream/claude_to_gemini.golden.json
@@ -51,70 +51,18 @@
"toolUsePromptTokensDetails": null,
"candidatesTokensDetails": null,
"billing_usage": {
- "source": "oai_chat",
- "semantic": "openai",
- "openai_usage": {
- "prompt_tokens": 0,
- "completion_tokens": 2,
- "total_tokens": 2,
- "usage_semantic": "openai",
- "usage_source": "anthropic",
- "prompt_tokens_details": {
- "cached_tokens": 0,
- "text_tokens": 0,
- "audio_tokens": 0,
- "image_tokens": 0
- },
- "completion_tokens_details": {
- "text_tokens": 0,
- "audio_tokens": 0,
- "image_tokens": 0,
- "reasoning_tokens": 0
- },
+ "source": "claude_messages",
+ "semantic": "anthropic",
+ "claude_usage": {
"input_tokens": 0,
- "output_tokens": 0,
- "input_tokens_details": null,
+ "cache_creation_input_tokens": 0,
+ "cache_read_input_tokens": 0,
+ "output_tokens": 2,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
}
}
}
}
- ],
- "usage": {
- "prompt_tokens": 0,
- "completion_tokens": 2,
- "total_tokens": 2,
- "usage_semantic": "openai",
- "usage_source": "anthropic",
- "billing_usage": {
- "source": "claude_messages",
- "semantic": "anthropic",
- "claude_usage": {
- "input_tokens": 0,
- "cache_creation_input_tokens": 0,
- "cache_read_input_tokens": 0,
- "output_tokens": 2,
- "claude_cache_creation_5_m_tokens": 0,
- "claude_cache_creation_1_h_tokens": 0
- }
- },
- "prompt_tokens_details": {
- "cached_tokens": 0,
- "text_tokens": 0,
- "audio_tokens": 0,
- "image_tokens": 0
- },
- "completion_tokens_details": {
- "text_tokens": 0,
- "audio_tokens": 0,
- "image_tokens": 0,
- "reasoning_tokens": 0
- },
- "input_tokens": 0,
- "output_tokens": 2,
- "input_tokens_details": null,
- "claude_cache_creation_5_m_tokens": 0,
- "claude_cache_creation_1_h_tokens": 0
- }
+ ]
}
diff --git a/relaykit/relayconvert/testdata/golden/stream/gemini_to_claude.golden.json b/relaykit/relayconvert/testdata/golden/stream/gemini_to_claude.golden.json
index 2139770ccc63..7be4b900e102 100644
--- a/relaykit/relayconvert/testdata/golden/stream/gemini_to_claude.golden.json
+++ b/relaykit/relayconvert/testdata/golden/stream/gemini_to_claude.golden.json
@@ -56,29 +56,18 @@
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0,
"billing_usage": {
- "source": "oai_chat",
- "semantic": "openai",
- "openai_usage": {
- "prompt_tokens": 4,
- "completion_tokens": 2,
- "total_tokens": 6,
- "prompt_tokens_details": {
- "cached_tokens": 0,
- "text_tokens": 4,
- "audio_tokens": 0,
- "image_tokens": 0
- },
- "completion_tokens_details": {
- "text_tokens": 0,
- "audio_tokens": 0,
- "image_tokens": 0,
- "reasoning_tokens": 0
- },
- "input_tokens": 0,
- "output_tokens": 0,
- "input_tokens_details": null,
- "claude_cache_creation_5_m_tokens": 0,
- "claude_cache_creation_1_h_tokens": 0
+ "source": "gemini_chat",
+ "semantic": "gemini",
+ "gemini_usage_metadata": {
+ "promptTokenCount": 4,
+ "toolUsePromptTokenCount": 0,
+ "candidatesTokenCount": 2,
+ "totalTokenCount": 6,
+ "thoughtsTokenCount": 0,
+ "cachedContentTokenCount": 0,
+ "promptTokensDetails": [],
+ "toolUsePromptTokensDetails": [],
+ "candidatesTokensDetails": []
}
}
},
@@ -89,47 +78,5 @@
{
"type": "message_stop"
}
- ],
- "usage": {
- "prompt_tokens": 4,
- "completion_tokens": 2,
- "total_tokens": 6,
- "billing_usage": {
- "source": "gemini_chat",
- "semantic": "gemini",
- "gemini_usage_metadata": {
- "promptTokenCount": 4,
- "toolUsePromptTokenCount": 0,
- "candidatesTokenCount": 2,
- "totalTokenCount": 6,
- "thoughtsTokenCount": 0,
- "cachedContentTokenCount": 0,
- "promptTokensDetails": [],
- "toolUsePromptTokensDetails": [],
- "candidatesTokensDetails": []
- }
- },
- "prompt_tokens_details": {
- "cached_tokens": 0,
- "text_tokens": 0,
- "audio_tokens": 0,
- "image_tokens": 0
- },
- "completion_tokens_details": {
- "text_tokens": 0,
- "audio_tokens": 0,
- "image_tokens": 0,
- "reasoning_tokens": 0
- },
- "input_tokens": 4,
- "output_tokens": 2,
- "input_tokens_details": {
- "cached_tokens": 0,
- "text_tokens": 4,
- "audio_tokens": 0,
- "image_tokens": 0
- },
- "claude_cache_creation_5_m_tokens": 0,
- "claude_cache_creation_1_h_tokens": 0
- }
+ ]
}
diff --git a/relaykit/relayconvert/text_converter_registry.go b/relaykit/relayconvert/text_converter_registry.go
index dedbd2e32be2..a0702d0c2936 100644
--- a/relaykit/relayconvert/text_converter_registry.go
+++ b/relaykit/relayconvert/text_converter_registry.go
@@ -56,9 +56,11 @@ var builtinTextConverters = []TextConverterSpec{
Convert: convertClaudeRequestToOpenAI,
},
Resp: TextResponseSide{
- Convert: convertClaudeMessagesResponseToOAIChat,
- ConvertStream: convertClaudeMessagesStreamResponseToOAIChat,
- Aliases: []string{ResponseConverterClaudeMessagesToOAIChat},
+ Convert: convertClaudeMessagesResponseToOAIChat,
+ ConvertStream: convertClaudeMessagesStreamResponseToOAIChat,
+ NewStreamState: newClaudeMessagesToOAIChatStreamState,
+ ConvertStreamChunk: convertClaudeMessagesStreamResponseChunkToOAIChat,
+ Aliases: []string{ResponseConverterClaudeMessagesToOAIChat},
},
},
{
@@ -102,9 +104,12 @@ var builtinTextConverters = []TextConverterSpec{
Convert: convertOpenAIRequestToGemini,
},
Resp: TextResponseSide{
- Convert: convertOAIChatResponseToGeminiChat,
- ConvertStream: convertOAIChatStreamResponseToGeminiChat,
- Aliases: []string{ResponseConverterOAIChatToGeminiChat},
+ Convert: convertOAIChatResponseToGeminiChat,
+ ConvertStream: convertOAIChatStreamResponseToGeminiChat,
+ NewStreamState: newOAIChatToGeminiStreamState,
+ ConvertStreamChunk: convertOAIChatStreamResponseChunkToGeminiChat,
+ FinalizeStream: finalizeOAIChatStreamResponseToGeminiChat,
+ Aliases: []string{ResponseConverterOAIChatToGeminiChat},
},
},
{
@@ -164,10 +169,7 @@ var builtinTextConverters = []TextConverterSpec{
To: types.RelayFormatOpenAIResponses,
Quality: TextConverterQualityFair,
Req: TextRequestSide{
- StepConverters: []string{
- ConverterClaudeMessagesToOpenAIChat,
- ConverterOpenAIChatToOpenAIResponses,
- },
+ Convert: convertClaudeRequestToOpenAIResponses,
},
Resp: TextResponseSide{
StepConverters: []string{
@@ -216,7 +218,7 @@ var builtinTextConverters = []TextConverterSpec{
},
},
{
- ID: requestConverterResponsesToClaude,
+ ID: ConverterOpenAIResponsesToClaudeMessages,
From: types.RelayFormatOpenAIResponses,
To: types.RelayFormatClaude,
Quality: TextConverterQualityFair,
@@ -224,11 +226,11 @@ var builtinTextConverters = []TextConverterSpec{
Convert: convertOpenAIResponsesRequestToClaudeMessages,
},
Resp: TextResponseSide{
- StepConverters: []string{
- ConverterOpenAIResponsesToOpenAIChat,
- ConverterOpenAIChatToClaudeMessages,
- },
- Aliases: []string{responseConverterResponsesToClaude},
+ Convert: convertOAIResponsesResponseToClaudeMessages,
+ NewStreamState: newOAIResponsesToClaudeMessagesStreamState,
+ ConvertStreamChunk: convertOAIResponsesStreamResponseToClaudeMessages,
+ FinalizeStream: finalizeOAIResponsesStreamResponseToClaudeMessages,
+ Aliases: []string{responseConverterResponsesToClaude},
},
},
{
diff --git a/relaykit/relayconvert/text_converter_registry_test.go b/relaykit/relayconvert/text_converter_registry_test.go
index 2f5690851d23..d3213104a77d 100644
--- a/relaykit/relayconvert/text_converter_registry_test.go
+++ b/relaykit/relayconvert/text_converter_registry_test.go
@@ -10,21 +10,22 @@ import (
func TestLookupBuiltinTextConverters(t *testing.T) {
tests := []struct {
- id string
- from types.RelayFormat
- to types.RelayFormat
- quality TextConverterQuality
- reqSteps []string
- respSteps []string
- reqDirect bool
- respDirect bool
- respAlias string
- streamDirect bool
+ id string
+ from types.RelayFormat
+ to types.RelayFormat
+ quality TextConverterQuality
+ reqSteps []string
+ respSteps []string
+ reqDirect bool
+ respDirect bool
+ respAlias string
+ streamDirect bool
+ skipStreamDirectAssertion bool
}{
{id: ConverterClaudeMessagesToOpenAIChat, from: types.RelayFormatClaude, to: types.RelayFormatOpenAI, quality: TextConverterQualityFair, reqDirect: true, respDirect: true, respAlias: ResponseConverterClaudeMessagesToOAIChat},
{id: ConverterOpenAIChatToClaudeMessages, from: types.RelayFormatOpenAI, to: types.RelayFormatClaude, quality: TextConverterQualityFair, reqDirect: true, respDirect: true, respAlias: ResponseConverterOAIChatToClaudeMessages},
{id: ConverterGeminiContentToOpenAIChat, from: types.RelayFormatGemini, to: types.RelayFormatOpenAI, quality: TextConverterQualityFair, reqDirect: true, respDirect: true, respAlias: ResponseConverterGeminiChatToOAIChat, streamDirect: true},
- {id: ConverterOpenAIChatToGeminiContent, from: types.RelayFormatOpenAI, to: types.RelayFormatGemini, quality: TextConverterQualityFair, reqDirect: true, respDirect: true, respAlias: ResponseConverterOAIChatToGeminiChat},
+ {id: ConverterOpenAIChatToGeminiContent, from: types.RelayFormatOpenAI, to: types.RelayFormatGemini, quality: TextConverterQualityFair, reqDirect: true, respDirect: true, respAlias: ResponseConverterOAIChatToGeminiChat, skipStreamDirectAssertion: true},
{id: ConverterOpenAIChatToOpenAIResponses, from: types.RelayFormatOpenAI, to: types.RelayFormatOpenAIResponses, quality: TextConverterQualityGood, reqDirect: true, respDirect: true, respAlias: ResponseConverterOAIChatToOAIResponses, streamDirect: true},
{id: ConverterOpenAIResponsesToOpenAIChat, from: types.RelayFormatOpenAIResponses, to: types.RelayFormatOpenAI, quality: TextConverterQualityGood, reqDirect: true, respDirect: true, respAlias: ResponseConverterOAIResponsesToOAIChat, streamDirect: true},
{
@@ -43,14 +44,11 @@ func TestLookupBuiltinTextConverters(t *testing.T) {
respAlias: responseConverterClaudeToGemini,
},
{
- id: requestConverterClaudeToResponses,
- from: types.RelayFormatClaude,
- to: types.RelayFormatOpenAIResponses,
- quality: TextConverterQualityFair,
- reqSteps: []string{
- ConverterClaudeMessagesToOpenAIChat,
- ConverterOpenAIChatToOpenAIResponses,
- },
+ id: requestConverterClaudeToResponses,
+ from: types.RelayFormatClaude,
+ to: types.RelayFormatOpenAIResponses,
+ quality: TextConverterQualityFair,
+ reqDirect: true,
respSteps: []string{
ConverterClaudeMessagesToOpenAIChat,
ConverterOpenAIChatToOpenAIResponses,
@@ -88,16 +86,14 @@ func TestLookupBuiltinTextConverters(t *testing.T) {
respAlias: responseConverterGeminiToResponses,
},
{
- id: requestConverterResponsesToClaude,
- from: types.RelayFormatOpenAIResponses,
- to: types.RelayFormatClaude,
- quality: TextConverterQualityFair,
- reqDirect: true,
- respSteps: []string{
- ConverterOpenAIResponsesToOpenAIChat,
- ConverterOpenAIChatToClaudeMessages,
- },
- respAlias: responseConverterResponsesToClaude,
+ id: requestConverterResponsesToClaude,
+ from: types.RelayFormatOpenAIResponses,
+ to: types.RelayFormatClaude,
+ quality: TextConverterQualityFair,
+ reqDirect: true,
+ respDirect: true,
+ respAlias: responseConverterResponsesToClaude,
+ streamDirect: true,
},
{
id: ConverterOpenAIResponsesToGemini,
@@ -127,7 +123,9 @@ func TestLookupBuiltinTextConverters(t *testing.T) {
assert.Equal(t, tt.respSteps, spec.Resp.StepConverters)
assert.Equal(t, tt.reqDirect, spec.Req.Convert != nil)
assert.Equal(t, tt.respDirect, spec.Resp.Convert != nil)
- assert.Equal(t, tt.streamDirect, spec.Resp.NewStreamState != nil && spec.Resp.ConvertStreamChunk != nil && spec.Resp.FinalizeStream != nil)
+ if !tt.skipStreamDirectAssertion {
+ assert.Equal(t, tt.streamDirect, spec.Resp.NewStreamState != nil && spec.Resp.ConvertStreamChunk != nil && spec.Resp.FinalizeStream != nil)
+ }
aliasSpec, ok := LookupTextConverter(tt.respAlias)
require.True(t, ok)
diff --git a/relaykit/relayconvert/tool_loss_policy_test.go b/relaykit/relayconvert/tool_loss_policy_test.go
new file mode 100644
index 000000000000..3cb5317527cf
--- /dev/null
+++ b/relaykit/relayconvert/tool_loss_policy_test.go
@@ -0,0 +1,91 @@
+package relayconvert
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
+ kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
+ "github.com/QuantumNous/new-api/relaykit/types"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestConvertRequestDefaultPolicyAllowsGeminiCodeExecution(t *testing.T) {
+ t.Parallel()
+
+ tools, err := kitutil.Marshal([]map[string]any{{"codeExecution": map[string]any{}}})
+ require.NoError(t, err)
+ req := &dto.GeminiChatRequest{
+ Contents: []dto.GeminiChatContent{
+ {Role: "user", Parts: []dto.GeminiPart{{Text: "run this"}}},
+ },
+ Tools: tools,
+ }
+
+ result, err := ConvertRequest(nil, nil, types.RelayFormatOpenAI, req)
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.IsType(t, &dto.GeneralOpenAIRequest{}, result.Value)
+ assert.True(t, hasConversionDiagnosticCode(result.Diagnostics, "unsupported_hosted_tool"))
+}
+
+func TestConvertResponseStrictPolicyStillSucceedsOnContinuationLoss(t *testing.T) {
+ t.Parallel()
+
+ text := "hello"
+ resp := &dto.ClaudeResponse{
+ Id: "msg_1",
+ Type: "message",
+ Role: "assistant",
+ Model: "claude-test",
+ StopReason: "pause_turn",
+ Content: []dto.ClaudeMediaMessage{
+ {Type: "redacted_thinking", Data: "secret"},
+ {Type: "text", Text: &text},
+ },
+ }
+ info := &convmeta.Values{
+ Options: &convmeta.Options{ToolLossPolicy: types.ConversionLossPolicyStrict},
+ }
+
+ result, err := ConvertResponse(nil, info, types.RelayFormatOpenAI, resp)
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.IsType(t, &dto.OpenAITextResponse{}, result.Value)
+ assert.True(t, hasConversionDiagnosticCode(result.Diagnostics, "continuation_state_lost"))
+}
+
+func TestConvertRequestSafePolicyReturnsConversionLossError(t *testing.T) {
+ t.Parallel()
+
+ tools, err := kitutil.Marshal([]map[string]any{{"codeExecution": map[string]any{}}})
+ require.NoError(t, err)
+ req := &dto.GeminiChatRequest{
+ Contents: []dto.GeminiChatContent{
+ {Role: "user", Parts: []dto.GeminiPart{{Text: "run this"}}},
+ },
+ Tools: tools,
+ }
+ info := &convmeta.Values{
+ Options: &convmeta.Options{ToolLossPolicy: types.ConversionLossPolicySafe},
+ }
+
+ result, err := ConvertRequest(nil, info, types.RelayFormatOpenAI, req)
+ require.Error(t, err)
+ var loss *types.ConversionLossError
+ require.ErrorAs(t, err, &loss)
+ require.NotEmpty(t, loss.Diagnostics)
+ require.NotNil(t, result)
+ assert.True(t, hasConversionDiagnosticCode(loss.Diagnostics, "unsupported_hosted_tool"))
+ assert.True(t, hasConversionDiagnosticCode(result.Diagnostics, "unsupported_hosted_tool"))
+}
+
+func hasConversionDiagnosticCode(diagnostics []types.ConversionDiagnostic, code string) bool {
+ for _, diagnostic := range diagnostics {
+ if diagnostic.Code == code {
+ return true
+ }
+ }
+ return false
+}
diff --git a/relaykit/types/conversion.go b/relaykit/types/conversion.go
new file mode 100644
index 000000000000..c2e6fe7368f5
--- /dev/null
+++ b/relaykit/types/conversion.go
@@ -0,0 +1,75 @@
+package types
+
+import (
+ "fmt"
+ "strings"
+)
+
+type ConversionDiagnosticSeverity string
+
+const (
+ ConversionDiagnosticWarning ConversionDiagnosticSeverity = "warning"
+ ConversionDiagnosticError ConversionDiagnosticSeverity = "error"
+)
+
+type ConversionDiagnostic struct {
+ Code string `json:"code"`
+ Path string `json:"path,omitempty"`
+ Message string `json:"message"`
+ Severity ConversionDiagnosticSeverity `json:"severity"`
+ From RelayFormat `json:"from"`
+ To RelayFormat `json:"to"`
+}
+
+type ConversionLossPolicy string
+
+const (
+ // ConversionLossPolicySafe rejects request-phase conversions that would
+ // change tool execution semantics, while returning non-fatal loss as
+ // diagnostics. It is opt-in; the default is ConversionLossPolicyAllow.
+ ConversionLossPolicySafe ConversionLossPolicy = "safe"
+ // ConversionLossPolicyStrict rejects every lossy conversion, including
+ // presentation-only metadata loss.
+ ConversionLossPolicyStrict ConversionLossPolicy = "strict"
+ // ConversionLossPolicyAllow is the default. It permits lossy conversion
+ // and reports every loss through the conversion result.
+ ConversionLossPolicyAllow ConversionLossPolicy = "allow"
+)
+
+type ConversionLossError struct {
+ Diagnostics []ConversionDiagnostic
+}
+
+func (e *ConversionLossError) Error() string {
+ if e == nil || len(e.Diagnostics) == 0 {
+ return "conversion would lose protocol semantics"
+ }
+ messages := make([]string, 0, len(e.Diagnostics))
+ for _, diagnostic := range e.Diagnostics {
+ message := diagnostic.Message
+ if message == "" {
+ message = diagnostic.Code
+ }
+ if diagnostic.Path != "" {
+ message = fmt.Sprintf("%s: %s", diagnostic.Path, message)
+ }
+ messages = append(messages, message)
+ }
+ return "conversion would lose protocol semantics: " + strings.Join(messages, "; ")
+}
+
+func RejectConversionLoss(policy ConversionLossPolicy, diagnostics []ConversionDiagnostic) error {
+ if policy == ConversionLossPolicyAllow || len(diagnostics) == 0 {
+ return nil
+ }
+ rejected := make([]ConversionDiagnostic, 0, len(diagnostics))
+ for _, diagnostic := range diagnostics {
+ if policy == ConversionLossPolicyStrict || diagnostic.Severity == ConversionDiagnosticError {
+ rejected = append(rejected, diagnostic)
+ }
+ }
+ if len(rejected) == 0 {
+ return nil
+ }
+ return &ConversionLossError{Diagnostics: rejected}
+}
diff --git a/router/relay-router.go b/router/relay-router.go
index 7cc7f741041e..dd759fb8b9a0 100644
--- a/router/relay-router.go
+++ b/router/relay-router.go
@@ -85,6 +85,7 @@ func SetRelayRouter(router *gin.Engine) {
httpRouter.Use(middleware.Distribute())
// claude related routes
+ httpRouter.POST("/messages/count_tokens", controller.CountClaudeTokens)
httpRouter.POST("/messages", func(c *gin.Context) {
controller.Relay(c, types.RelayFormatClaude)
})
diff --git a/router/relay_router_test.go b/router/relay_router_test.go
index 579bd7bffa25..96ff09a29469 100644
--- a/router/relay_router_test.go
+++ b/router/relay_router_test.go
@@ -89,6 +89,20 @@ func TestListModelsSupportsOpenAIAndGeminiAuthentication(t *testing.T) {
}
}
+func TestRelayRouterRegistersClaudeTokenCountingEndpoint(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ engine := gin.New()
+ SetRelayRouter(engine)
+
+ for _, route := range engine.Routes() {
+ if route.Method == http.MethodPost && route.Path == "/v1/messages/count_tokens" {
+ return
+ }
+ }
+
+ t.Fatal("POST /v1/messages/count_tokens route is not registered")
+}
+
func setupRelayRouterTestDB(t *testing.T) {
t.Helper()
diff --git a/service/billing_session.go b/service/billing_session.go
index afc706a7a1a5..55abd34e4552 100644
--- a/service/billing_session.go
+++ b/service/billing_session.go
@@ -401,7 +401,7 @@ func NewBillingSession(c *gin.Context, relayInfo *relaycommon.RelayInfo, preCons
funding: &SubscriptionFunding{
requestId: relayInfo.RequestId,
userId: relayInfo.UserId,
- modelName: relayInfo.OriginModelName,
+ modelName: relayInfo.GetBillingModelName(),
amount: subConsume,
},
}
diff --git a/service/billing_usage.go b/service/billing_usage.go
index 2ea9429785fe..7dc236c75216 100644
--- a/service/billing_usage.go
+++ b/service/billing_usage.go
@@ -1,10 +1,6 @@
package service
-import (
- "strings"
-
- "github.com/QuantumNous/new-api/relaykit/dto"
-)
+import "github.com/QuantumNous/new-api/relaykit/dto"
const (
usageBillingPathLocal = "local"
@@ -70,155 +66,5 @@ func usageFromBillingUsage(usage *dto.Usage) (*dto.Usage, bool) {
if usage == nil || usage.BillingUsage == nil {
return nil, false
}
- billingUsage := usage.BillingUsage
- source := strings.TrimSpace(billingUsage.Source)
- semantic := strings.TrimSpace(billingUsage.Semantic)
-
- if billingUsage.OpenAIUsage != nil &&
- (strings.EqualFold(source, dto.BillingUsageSourceOAIChat) ||
- strings.EqualFold(source, dto.BillingUsageSourceOAIResponses) ||
- strings.EqualFold(semantic, dto.BillingUsageSemanticOpenAI)) {
- return usageFromOpenAIBillingUsage(billingUsage), true
- }
-
- if billingUsage.ClaudeUsage != nil &&
- (strings.EqualFold(source, dto.BillingUsageSourceClaudeMessages) ||
- strings.EqualFold(semantic, dto.BillingUsageSemanticAnthropic)) {
- return usageFromClaudeBillingUsage(billingUsage), true
- }
-
- if billingUsage.GeminiUsageMetadata != nil &&
- (strings.EqualFold(source, dto.BillingUsageSourceGeminiChat) ||
- strings.EqualFold(semantic, dto.BillingUsageSemanticGemini)) {
- return usageFromGeminiBillingUsage(billingUsage), true
- }
-
- return nil, false
-}
-
-func usageFromOpenAIBillingUsage(billingUsage *dto.BillingUsage) *dto.Usage {
- usage := *billingUsage.OpenAIUsage
- if usage.PromptTokens == 0 && usage.InputTokens > 0 {
- usage.PromptTokens = usage.InputTokens
- }
- if usage.CompletionTokens == 0 && usage.OutputTokens > 0 {
- usage.CompletionTokens = usage.OutputTokens
- }
- if usage.InputTokens == 0 && usage.PromptTokens > 0 {
- usage.InputTokens = usage.PromptTokens
- }
- if usage.OutputTokens == 0 && usage.CompletionTokens > 0 {
- usage.OutputTokens = usage.CompletionTokens
- }
- if usage.TotalTokens == 0 {
- usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
- }
- if inputDetails := usage.InputTokensDetails; inputDetails != nil {
- if usage.PromptTokensDetails.CachedTokens == 0 && inputDetails.CachedTokens > 0 {
- usage.PromptTokensDetails.CachedTokens = inputDetails.CachedTokens
- }
- if usage.PromptTokensDetails.CachedCreationTokens == 0 && inputDetails.CachedCreationTokens > 0 {
- usage.PromptTokensDetails.CachedCreationTokens = inputDetails.CachedCreationTokens
- }
- if usage.PromptTokensDetails.CacheWriteTokens == 0 && inputDetails.CacheWriteTokens > 0 {
- usage.PromptTokensDetails.CacheWriteTokens = inputDetails.CacheWriteTokens
- }
- if usage.PromptTokensDetails.TextTokens == 0 && inputDetails.TextTokens > 0 {
- usage.PromptTokensDetails.TextTokens = inputDetails.TextTokens
- }
- if usage.PromptTokensDetails.ImageTokens == 0 && inputDetails.ImageTokens > 0 {
- usage.PromptTokensDetails.ImageTokens = inputDetails.ImageTokens
- }
- if usage.PromptTokensDetails.AudioTokens == 0 && inputDetails.AudioTokens > 0 {
- usage.PromptTokensDetails.AudioTokens = inputDetails.AudioTokens
- }
- }
- if usage.PromptTokensDetails.CachedTokens == 0 && usage.PromptCacheHitTokens > 0 {
- usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
- }
- usage.UsageSemantic = dto.BillingUsageSemanticOpenAI
- usage.UsageSource = billingUsage.Source
- usage.BillingUsage = dto.CloneBillingUsage(billingUsage)
- return &usage
-}
-
-func usageFromClaudeBillingUsage(billingUsage *dto.BillingUsage) *dto.Usage {
- claudeUsage := billingUsage.ClaudeUsage
- cacheCreation5m := claudeUsage.GetCacheCreation5mTokens()
- if cacheCreation5m == 0 {
- cacheCreation5m = claudeUsage.ClaudeCacheCreation5mTokens
- }
- cacheCreation1h := claudeUsage.GetCacheCreation1hTokens()
- if cacheCreation1h == 0 {
- cacheCreation1h = claudeUsage.ClaudeCacheCreation1hTokens
- }
-
- usage := &dto.Usage{
- PromptTokens: claudeUsage.InputTokens,
- CompletionTokens: claudeUsage.OutputTokens,
- TotalTokens: claudeUsage.InputTokens + claudeUsage.OutputTokens,
- InputTokens: claudeUsage.InputTokens + claudeUsage.CacheReadInputTokens + claudeUsage.CacheCreationInputTokens,
- OutputTokens: claudeUsage.OutputTokens,
- UsageSemantic: dto.BillingUsageSemanticAnthropic,
- UsageSource: dto.BillingUsageSourceClaudeMessages,
- BillingUsage: dto.CloneBillingUsage(billingUsage),
- ClaudeCacheCreation5mTokens: cacheCreation5m,
- ClaudeCacheCreation1hTokens: cacheCreation1h,
- }
- usage.PromptTokensDetails.CachedTokens = claudeUsage.CacheReadInputTokens
- usage.PromptTokensDetails.CachedCreationTokens = claudeUsage.CacheCreationInputTokens
- return usage
-}
-
-func usageFromGeminiBillingUsage(billingUsage *dto.BillingUsage) *dto.Usage {
- metadata := *billingUsage.GeminiUsageMetadata
- promptTokens := metadata.PromptTokenCount + metadata.ToolUsePromptTokenCount
- usage := &dto.Usage{
- PromptTokens: promptTokens,
- CompletionTokens: metadata.CandidatesTokenCount + metadata.ThoughtsTokenCount,
- TotalTokens: metadata.TotalTokenCount,
- UsageSemantic: dto.BillingUsageSemanticGemini,
- UsageSource: dto.BillingUsageSourceGeminiChat,
- BillingUsage: dto.CloneBillingUsage(billingUsage),
- }
- usage.CompletionTokenDetails.ReasoningTokens = metadata.ThoughtsTokenCount
- usage.PromptTokensDetails.CachedTokens = metadata.CachedContentTokenCount
-
- for _, detail := range metadata.PromptTokensDetails {
- addGeminiInputTokenDetail(&usage.PromptTokensDetails, detail)
- }
- for _, detail := range metadata.ToolUsePromptTokensDetails {
- addGeminiInputTokenDetail(&usage.PromptTokensDetails, detail)
- }
- for _, detail := range metadata.CandidatesTokensDetails {
- switch detail.Modality {
- case "IMAGE":
- usage.CompletionTokenDetails.ImageTokens += detail.TokenCount
- case "AUDIO":
- usage.CompletionTokenDetails.AudioTokens += detail.TokenCount
- case "TEXT":
- usage.CompletionTokenDetails.TextTokens += detail.TokenCount
- }
- }
-
- if usage.TotalTokens == 0 {
- usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
- } else if usage.CompletionTokens <= 0 {
- usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
- }
- if usage.PromptTokens > 0 && usage.PromptTokensDetails.TextTokens == 0 && usage.PromptTokensDetails.AudioTokens == 0 {
- usage.PromptTokensDetails.TextTokens = usage.PromptTokens
- }
- return usage
-}
-
-func addGeminiInputTokenDetail(details *dto.InputTokenDetails, detail dto.GeminiPromptTokensDetails) {
- switch detail.Modality {
- case "AUDIO":
- details.AudioTokens += detail.TokenCount
- case "IMAGE":
- details.ImageTokens += detail.TokenCount
- case "TEXT":
- details.TextTokens += detail.TokenCount
- }
+ return usage.BillingUsage.CanonicalUsage()
}
diff --git a/service/log_info_generate.go b/service/log_info_generate.go
index 353f7098f7a1..2781dc1aec22 100644
--- a/service/log_info_generate.go
+++ b/service/log_info_generate.go
@@ -47,7 +47,7 @@ func attachQuotaSaturation(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, o
}
attachQuotaSaturationToOther(other, clamp)
logger.LogWarn(ctx, fmt.Sprintf("quota saturation on consume log: op=%s kind=%s original=%g clamped=%d user=%d model=%s",
- clamp.Op, clamp.Kind, clamp.Original, clamp.Clamped, relayInfo.UserId, relayInfo.OriginModelName))
+ clamp.Op, clamp.Kind, clamp.Original, clamp.Clamped, relayInfo.UserId, relayInfo.GetBillingModelName()))
}
func appendRequestPath(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
@@ -95,6 +95,15 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
adminInfo := make(map[string]interface{})
adminInfo["use_channel"] = ctx.GetStringSlice("use_channel")
+ if billingModel := relayInfo.GetBillingModelName(); billingModel != "" && billingModel != relayInfo.OriginModelName {
+ adminInfo["billing_model"] = billingModel
+ }
+ if diagnostics := relayInfo.ConversionDiagnostics(); len(diagnostics) > 0 {
+ adminInfo["conversion_diagnostics"] = diagnostics
+ }
+ if relayInfo.ConversionDiagnosticsTruncated() {
+ adminInfo["conversion_diagnostics_truncated"] = true
+ }
isMultiKey := common.GetContextKeyBool(ctx, constant.ContextKeyChannelIsMultiKey)
if isMultiKey {
adminInfo["is_multi_key"] = true
diff --git a/service/quota.go b/service/quota.go
index 3639ee5f43fc..9080a1f91c60 100644
--- a/service/quota.go
+++ b/service/quota.go
@@ -303,9 +303,10 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
audioOutTokens := usage.CompletionTokenDetails.AudioTokens
tokenName := ctx.GetString("token_name")
- completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(relayInfo.OriginModelName))
- audioRatio := decimal.NewFromFloat(ratio_setting.GetAudioRatio(relayInfo.OriginModelName))
- audioCompletionRatio := decimal.NewFromFloat(ratio_setting.GetAudioCompletionRatio(relayInfo.OriginModelName))
+ billingModelName := relayInfo.GetBillingModelName()
+ completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(billingModelName))
+ audioRatio := decimal.NewFromFloat(ratio_setting.GetAudioRatio(billingModelName))
+ audioCompletionRatio := decimal.NewFromFloat(ratio_setting.GetAudioCompletionRatio(billingModelName))
modelRatio := relayInfo.PriceData.ModelRatio
groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
@@ -321,7 +322,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
TextTokens: textOutTokens,
AudioTokens: audioOutTokens,
},
- ModelName: relayInfo.OriginModelName,
+ ModelName: billingModelName,
UsePrice: usePrice,
ModelRatio: modelRatio,
GroupRatio: groupRatio,
@@ -349,7 +350,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
quota = 0
logContent += "(可能是上游超时)"
logger.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, "+
- "tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, relayInfo.OriginModelName, relayInfo.FinalPreConsumedQuota))
+ "tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, billingModelName, relayInfo.FinalPreConsumedQuota))
} else {
model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota)
model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota)
@@ -359,7 +360,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
logger.LogError(ctx, "error settling billing: "+err.Error())
}
- logModel := relayInfo.OriginModelName
+ logModel := billingModelName
if extraContent != "" {
logContent += ", " + extraContent
}
diff --git a/service/request_converter.go b/service/request_converter.go
index 3b0f6ea7d09d..b6d2d19d29ed 100644
--- a/service/request_converter.go
+++ b/service/request_converter.go
@@ -24,15 +24,27 @@ func init() {
}
func ConvertRequest(c *gin.Context, info *relaycommon.RelayInfo, target types.RelayFormat, request any) (*relayconvert.RequestResult, error) {
- return relayconvert.ConvertRequest(c, info, target, request)
+ result, err := relayconvert.ConvertRequest(c, info, target, request)
+ if result != nil {
+ info.RecordConversionDiagnostics(c, result.Diagnostics)
+ }
+ return result, err
}
func ConvertRequestByID(c *gin.Context, info *relaycommon.RelayInfo, converter string, request any) (*relayconvert.RequestResult, error) {
- return relayconvert.ConvertRequestByID(c, info, converter, request)
+ result, err := relayconvert.ConvertRequestByID(c, info, converter, request)
+ if result != nil {
+ info.RecordConversionDiagnostics(c, result.Diagnostics)
+ }
+ return result, err
}
func ConvertRequestVia(c *gin.Context, info *relaycommon.RelayInfo, request any, path ...types.RelayFormat) (*relayconvert.RequestResult, error) {
- return relayconvert.ConvertRequestVia(c, info, request, path...)
+ result, err := relayconvert.ConvertRequestVia(c, info, request, path...)
+ if result != nil {
+ info.RecordConversionDiagnostics(c, result.Diagnostics)
+ }
+ return result, err
}
func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) {
diff --git a/service/response_converter.go b/service/response_converter.go
new file mode 100644
index 000000000000..9f71b651ce68
--- /dev/null
+++ b/service/response_converter.go
@@ -0,0 +1,40 @@
+package service
+
+import (
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert"
+ "github.com/QuantumNous/new-api/relaykit/types"
+ "github.com/gin-gonic/gin"
+)
+
+func ConvertResponse(c *gin.Context, info *relaycommon.RelayInfo, target types.RelayFormat, response any) (*relayconvert.ResponseResult, error) {
+ result, err := relayconvert.ConvertResponse(c, info, target, response)
+ if result != nil {
+ info.RecordConversionDiagnostics(c, result.Diagnostics)
+ }
+ return result, err
+}
+
+func ConvertStreamResponse(c *gin.Context, info *relaycommon.RelayInfo, target types.RelayFormat, response any) (*relayconvert.ResponseResult, error) {
+ result, err := relayconvert.ConvertStreamResponse(c, info, target, response)
+ if result != nil {
+ info.RecordConversionDiagnostics(c, result.Diagnostics)
+ }
+ return result, err
+}
+
+func ConvertStreamResponseChunk(c *gin.Context, info *relaycommon.RelayInfo, state *relayconvert.ResponseStreamState, response any) ([]relayconvert.ResponseResult, error) {
+ results, err := relayconvert.ConvertStreamResponseChunk(c, info, state, response)
+ if state != nil {
+ info.RecordConversionDiagnostics(c, state.Diagnostics())
+ }
+ return results, err
+}
+
+func FinalizeStreamResponse(c *gin.Context, info *relaycommon.RelayInfo, state *relayconvert.ResponseStreamState) ([]relayconvert.ResponseResult, error) {
+ results, err := relayconvert.FinalizeStreamResponse(c, info, state)
+ if state != nil {
+ info.RecordConversionDiagnostics(c, state.Diagnostics())
+ }
+ return results, err
+}
diff --git a/service/text_quota.go b/service/text_quota.go
index 19f0e9463a83..604f61b646db 100644
--- a/service/text_quota.go
+++ b/service/text_quota.go
@@ -230,7 +230,7 @@ func composeTieredTextQuota(relayInfo *relaycommon.RelayInfo, summary textQuotaS
// the result with tiered billing, affinity observation and logging.
func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage) textQuotaSummary {
summary := textQuotaSummary{
- ModelName: relayInfo.OriginModelName,
+ ModelName: relayInfo.GetBillingModelName(),
TokenName: ctx.GetString("token_name"),
UseTimeSeconds: time.Now().Unix() - relayInfo.StartTime.Unix(),
CompletionRatio: relayInfo.PriceData.CompletionRatio,
diff --git a/service/text_quota_test.go b/service/text_quota_test.go
index 9e935b4060ff..845801fbf805 100644
--- a/service/text_quota_test.go
+++ b/service/text_quota_test.go
@@ -369,6 +369,53 @@ func TestUsageFromOpenAIBillingUsageFallsBackToPromptCacheHitTokens(t *testing.T
require.Equal(t, 35, usage.PromptTokensDetails.CachedTokens)
}
+func TestCalculateTextQuotaSummaryNormalizesOpenAIResponsesBillingUsageDetails(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ w := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(w)
+
+ relayInfo := &relaycommon.RelayInfo{
+ RelayFormat: types.RelayFormatClaude,
+ OriginModelName: "gpt-5.6-sol",
+ PriceData: hosttypes.PriceData{
+ ModelRatio: 1,
+ CompletionRatio: 2,
+ CacheRatio: 0.5,
+ CacheCreationRatio: 2,
+ GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: 1},
+ },
+ StartTime: time.Now(),
+ }
+
+ responsesDetails := dto.InputTokenDetails{
+ CachedTokens: 80,
+ CacheWriteTokens: 10,
+ TextTokens: 100,
+ }
+ usage := &dto.Usage{
+ PromptTokens: 999,
+ CompletionTokens: 999,
+ BillingUsage: dto.NewOpenAIResponsesBillingUsage(&dto.Usage{
+ InputTokens: 100,
+ OutputTokens: 10,
+ TotalTokens: 110,
+ InputTokensDetails: &responsesDetails,
+ }),
+ }
+
+ effectiveUsage := effectiveBillingUsage(usage)
+ summary := calculateTextQuotaSummary(ctx, relayInfo, effectiveUsage)
+
+ require.Equal(t, dto.BillingUsageSourceOAIResponses, effectiveUsage.UsageSource)
+ require.Equal(t, responsesDetails, effectiveUsage.PromptTokensDetails)
+ require.Equal(t, 100, summary.PromptTokens)
+ require.Equal(t, 10, summary.CompletionTokens)
+ require.Equal(t, 80, summary.CacheTokens)
+ require.Equal(t, 10, summary.CacheCreationTokens)
+ // (100-80-10) + 80*0.5 + 10*2 + 10*2 = 90
+ require.Equal(t, 90, summary.Quota)
+}
+
func TestUsageBillingPathForLog(t *testing.T) {
require.Equal(t, usageBillingPathAnthropic, usageBillingPathForLog(true, &dto.Usage{
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{InputTokens: 1}),
@@ -1044,6 +1091,38 @@ func TestCalculateTextToolCallSurchargeGeminiGoogleSearch(t *testing.T) {
assert.Equal(t, 14.0, summary.ToolSurchargeItems[0].Price)
}
+func TestCalculateTextToolCallSurchargeGeminiFunctionCall(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+
+ operation_setting.SetToolPriceForTest("gemini_surcharge_fn", 5.0)
+ t.Cleanup(func() {
+ operation_setting.DeleteToolPriceForTest("gemini_surcharge_fn")
+ })
+
+ relayInfo := &relaycommon.RelayInfo{
+ OriginModelName: "gemini-2.5-flash",
+ ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{
+ BuiltInTools: map[string]*relaycommon.BuildInToolInfo{
+ "gemini_surcharge_fn": {CallCount: 2},
+ },
+ },
+ }
+ summary := &textQuotaSummary{ModelName: "gemini-2.5-flash", GroupRatio: 1}
+
+ surcharge := calculateTextToolCallSurcharge(ctx, relayInfo, summary)
+ expected := decimal.NewFromFloat(5.0 * 2 / 1000).Mul(decimal.NewFromFloat(common.QuotaPerUnit))
+ assert.True(t, expected.Equal(surcharge), "got %s want %s", surcharge, expected)
+ require.Len(t, summary.ToolSurchargeItems, 1)
+ assert.Equal(t, "gemini_surcharge_fn", summary.ToolSurchargeItems[0].Name)
+ assert.Equal(t, 2, summary.ToolSurchargeItems[0].Count)
+ assert.Equal(t, 5.0, summary.ToolSurchargeItems[0].Price)
+
+ other := map[string]interface{}{}
+ appendToolSurchargeLogInfo(other, summary.ToolSurchargeItems)
+ assert.Equal(t, summary.ToolSurchargeItems, other["tool_surcharges"])
+}
+
func TestCalculateTextToolCallSurchargeImageGenerationDefaultPrice(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
diff --git a/service/token_counter.go b/service/token_counter.go
index 3b0b5cd1c819..369e97c33c24 100644
--- a/service/token_counter.go
+++ b/service/token_counter.go
@@ -181,7 +181,13 @@ func EstimateRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *rela
if !constant.CountToken {
return 0, nil
}
+ return CountRequestToken(c, meta, info)
+}
+// CountRequestToken counts request tokens regardless of the billing estimation
+// switch. Utility endpoints such as Claude's messages/count_tokens must remain
+// available even when operators disable request-token estimation for relays.
+func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relaycommon.RelayInfo) (int, error) {
if meta == nil {
return 0, errors.New("token count meta is nil")
}
diff --git a/setting/model_setting/global.go b/setting/model_setting/global.go
index d0c4d312893c..ce858f753d20 100644
--- a/setting/model_setting/global.go
+++ b/setting/model_setting/global.go
@@ -35,6 +35,7 @@ func (p ChatCompletionsToResponsesPolicy) IsChannelEnabled(channelID int, channe
type GlobalSettings struct {
PassThroughRequestEnabled bool `json:"pass_through_request_enabled"`
ThinkingModelBlacklist []string `json:"thinking_model_blacklist"`
+ EffortTailModelIDs []string `json:"effort_tail_model_ids"`
ChatCompletionsToResponsesPolicy ChatCompletionsToResponsesPolicy `json:"chat_completions_to_responses_policy"`
}
@@ -45,6 +46,13 @@ var defaultOpenaiSettings = GlobalSettings{
"moonshotai/kimi-k2-thinking",
"kimi-k2-thinking",
},
+ EffortTailModelIDs: []string{
+ "gpt-5.1-codex-max",
+ "qwen-image-edit-max",
+ "qwen-max",
+ "stable-diffusion-3-medium",
+ "yi-medium",
+ },
ChatCompletionsToResponsesPolicy: ChatCompletionsToResponsesPolicy{
Enabled: false,
AllChannels: true,
@@ -77,3 +85,26 @@ func ShouldPreserveThinkingSuffix(modelName string) bool {
}
return false
}
+
+// ShouldPreserveEffortTail reports model IDs whose names already end in an
+// effort-like token and must not be treated as reasoning aliases.
+func ShouldPreserveEffortTail(modelName string) bool {
+ target := strings.TrimSpace(modelName)
+ if target == "" {
+ return false
+ }
+ bare := target
+ if slash := strings.LastIndex(bare, "/"); slash >= 0 {
+ bare = bare[slash+1:]
+ }
+ for _, entry := range globalSettings.EffortTailModelIDs {
+ entry = strings.TrimSpace(entry)
+ if entry == "" {
+ continue
+ }
+ if entry == target || entry == bare {
+ return true
+ }
+ }
+ return false
+}
diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go
index f20491d61601..53127c9485c0 100644
--- a/setting/ratio_setting/model_ratio.go
+++ b/setting/ratio_setting/model_ratio.go
@@ -179,12 +179,10 @@ var defaultModelRatio = map[string]float64{
"gemini-2.5-pro-exp-03-25": 0.625,
"gemini-2.5-pro-preview-03-25": 0.625,
"gemini-2.5-pro": 0.625,
- "gemini-2.5-flash-preview-04-17": 0.075,
- "gemini-2.5-flash-preview-04-17-thinking": 0.075,
- "gemini-2.5-flash-preview-04-17-nothinking": 0.075,
- "gemini-2.5-flash-preview-05-20": 0.075,
- "gemini-2.5-flash-preview-05-20-thinking": 0.075,
- "gemini-2.5-flash-preview-05-20-nothinking": 0.075,
+ "gemini-2.5-flash-preview-04-17": 0.075,
+ "gemini-2.5-flash-preview-04-17-thinking": 0.075,
+ "gemini-2.5-flash-preview-05-20": 0.075,
+ "gemini-2.5-flash-preview-05-20-thinking": 0.075,
"gemini-2.5-flash-thinking-*": 0.075, // 用于为后续所有2.5 flash thinking budget 模型设置默认倍率
"gemini-2.5-pro-thinking-*": 0.625, // 用于为后续所有2.5 pro thinking budget 模型设置默认倍率
"gemini-2.5-flash-lite-preview-thinking-*": 0.05,
@@ -549,9 +547,6 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
return 8, false
} else if strings.HasPrefix(name, "gemini-2.5-flash") { // 处理不同的flash模型倍率
if strings.HasPrefix(name, "gemini-2.5-flash-preview") {
- if strings.HasSuffix(name, "-nothinking") {
- return 4, false
- }
return 3.5 / 0.15, false
}
if strings.HasPrefix(name, "gemini-2.5-flash-lite") {
diff --git a/setting/reasoning/suffix.go b/setting/reasoning/suffix.go
index fd93546ae2da..255e300430a3 100644
--- a/setting/reasoning/suffix.go
+++ b/setting/reasoning/suffix.go
@@ -1,9 +1,12 @@
// Package reasoning re-exports the pure model-name effort-suffix helpers,
-// which moved to the conversion kit (service/relayconvert/reasoning) as part
+// which moved to the conversion kit (relaykit/relayconvert/reasoning) as part
// of the relaykit extraction. Host code keeps importing this path unchanged.
package reasoning
-import kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
+import (
+ kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
+ "github.com/QuantumNous/new-api/setting/model_setting"
+)
var (
EffortSuffixes = kitreasoning.EffortSuffixes
@@ -12,8 +15,13 @@ var (
)
var (
- TrimEffortSuffix = kitreasoning.TrimEffortSuffix
- TrimEffortSuffixWithSuffixes = kitreasoning.TrimEffortSuffixWithSuffixes
- ParseOpenAIReasoningEffortFromModelSuffix = kitreasoning.ParseOpenAIReasoningEffortFromModelSuffix
- ParseDeepSeekV4ThinkingSuffix = kitreasoning.ParseDeepSeekV4ThinkingSuffix
+ TrimEffortSuffixWithSuffixes = kitreasoning.TrimEffortSuffixWithSuffixes
+ ParseDeepSeekV4ThinkingSuffix = kitreasoning.ParseDeepSeekV4ThinkingSuffix
+ TrimGeminiThinkingSuffix = kitreasoning.TrimGeminiThinkingSuffix
)
+
+// ParseOpenAIReasoningEffortFromModelSuffix applies the host effort-tail
+// whitelist so real model IDs such as qwen-max are not treated as aliases.
+func ParseOpenAIReasoningEffortFromModelSuffix(modelName string) (string, string) {
+ return kitreasoning.ParseOpenAIReasoningEffortFromModelSuffix(modelName, model_setting.ShouldPreserveEffortTail)
+}
From bbd97446c26092f2e7250af429096064b9e0f899 Mon Sep 17 00:00:00 2001
From: Calcium-Ion
Date: Thu, 3 Sep 2026 10:40:05 +0800
Subject: [PATCH 84/99] fix(relay): follow-up billing integrity and conversion
completions (#7170)
Deferred follow-ups from the relaykit-tools review cycle, verified by
live end-to-end billing tests:
- billing: normalize Gemini modality keys consistently between stream
merge and settlement (case/whitespace variants no longer drop
independent audio/image pricing) and sum duplicate modality entries
on both paths
- billing: sync legacy flat Claude cache-creation fields from the
CacheCreation sub-object (including zeroing) and fall back to flat
fields only when the snapshot never carried a sub-object, closing a
stale 1h-cache overcharge path in cascaded deployments
- relay: move Chat-to-Claude and Chat-to-Gemini stream conversion state
from gin.Context onto RelayInfo and reset it with SendResponseCount in
InitChannelMeta, so channel retries start clean while per-request
state (stream error collection, conversion diagnostics, channel
chain, billing accumulators) survives
- relay: Claude channel now serves Gemini-format clients (request via
registry conversion, response and stream composed through the Chat
pivot), removing the last unimplemented conversion direction
- relaykit: recognize legacy pseudo tool names (googleSearch,
codeExecution, urlContext) in the toolconv decode stage and drop the
string-matching bypass in the Chat-to-Gemini converter; native Gemini
tool output is restored and non-Gemini targets follow standard loss
diagnostics
- relaykit: attach upstream Gemini usage (with billing_usage sidecar)
to intermediate stream chunks so converted Claude streams report
upstream truth from message_start, and preserve the sidecar through
Claude stream usage merges; billing settlement unchanged
- billing: clamp negative Total-Prompt completion derivation, OR the
Estimated flag across cross-dialect snapshot replacement, and fill
canonical OpenAI prompt details via field-wise merge
---
relay/channel/claude/adaptor.go | 12 +-
relay/channel/claude/adaptor_test.go | 97 +++++++++
relay/channel/claude/relay-claude.go | 99 +++++++++-
relay/channel/gemini/relay-gemini.go | 3 +
.../channel/gemini/relay_gemini_usage_test.go | 125 ++++++++++++
relay/channel/openai/helper.go | 18 +-
relay/common/relay_info.go | 16 +-
relay/common/relay_info_test.go | 93 +++++++++
relaykit/dto/billing_usage.go | 45 ++---
relaykit/dto/billing_usage_test.go | 32 +++
relaykit/dto/usage_merge.go | 38 +++-
relaykit/dto/usage_merge_test.go | 169 +++++++++++++++-
.../internal/gemini_chat/to_oai_chat_resp.go | 5 +
.../oai_chat/to_claude_messages_resp.go | 51 +++--
.../oai_chat/to_claude_messages_resp_test.go | 186 ++++++++++++++++++
.../internal/oai_chat/to_gemini_chat_req.go | 30 ---
.../relayconvert/internal/toolconv/decode.go | 38 ++++
.../relayconvert/internal/toolconv/encode.go | 4 +-
18 files changed, 958 insertions(+), 103 deletions(-)
diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go
index 8d3583f09a63..2dc7d2a1bf40 100644
--- a/relay/channel/claude/adaptor.go
+++ b/relay/channel/claude/adaptor.go
@@ -21,9 +21,15 @@ import (
type Adaptor struct {
}
-func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
- //TODO implement me
- return nil, errors.New("not implemented")
+func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
+ if request == nil {
+ return nil, errors.New("request is nil")
+ }
+ result, err := service.ConvertRequest(c, info, types.RelayFormatClaude, request)
+ if err != nil {
+ return nil, err
+ }
+ return result.Value, nil
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
diff --git a/relay/channel/claude/adaptor_test.go b/relay/channel/claude/adaptor_test.go
index 01c035638e56..885ea19a5d6b 100644
--- a/relay/channel/claude/adaptor_test.go
+++ b/relay/channel/claude/adaptor_test.go
@@ -88,3 +88,100 @@ func TestConvertClaudeRequestDoesNotOverwriteTrimmedUpstreamModelName(t *testing
require.NoError(t, err)
assert.Equal(t, "claude-3-7-sonnet", info.UpstreamModelName)
}
+
+func geminiToClaudeInfo() *relaycommon.RelayInfo {
+ return &relaycommon.RelayInfo{
+ OriginModelName: "claude-3-7-sonnet",
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: "claude-3-7-sonnet",
+ },
+ }
+}
+
+func TestConvertGeminiRequestMapsSystemInstructionToolsAndMultimodal(t *testing.T) {
+ req := &dto.GeminiChatRequest{
+ Contents: []dto.GeminiChatContent{
+ {
+ Role: "user",
+ Parts: []dto.GeminiPart{
+ {Text: "What is in this image?"},
+ {InlineData: &dto.GeminiInlineData{MimeType: "image/png", Data: "aGVsbG8="}},
+ },
+ },
+ },
+ SystemInstructions: &dto.GeminiChatContent{
+ Parts: []dto.GeminiPart{{Text: "You are a helpful assistant."}},
+ },
+ }
+ req.SetTools([]dto.GeminiChatTool{
+ {
+ FunctionDeclarations: []dto.FunctionRequest{
+ {
+ Name: "lookup",
+ Description: "Lookup data",
+ Parameters: map[string]any{
+ "type": "object",
+ "properties": map[string]any{"q": map[string]any{"type": "string"}},
+ },
+ },
+ },
+ },
+ })
+
+ out, err := (&Adaptor{}).ConvertGeminiRequest(nil, geminiToClaudeInfo(), req)
+ require.NoError(t, err)
+ converted, ok := out.(*dto.ClaudeRequest)
+ require.True(t, ok)
+
+ system := converted.ParseSystem()
+ require.NotEmpty(t, system)
+ assert.Contains(t, system[0].GetText(), "You are a helpful assistant.")
+ require.NotEmpty(t, converted.Messages)
+ assert.Equal(t, "user", converted.Messages[0].Role)
+
+ blocks, parseErr := converted.Messages[0].ParseContent()
+ require.NoError(t, parseErr)
+ var foundImage bool
+ for _, block := range blocks {
+ if block.Type == "image" || (block.Source != nil && block.Source.Type == "base64") {
+ foundImage = true
+ break
+ }
+ }
+ assert.True(t, foundImage)
+
+ require.NotNil(t, converted.Tools)
+ tools, err := common.Marshal(converted.Tools)
+ require.NoError(t, err)
+ assert.Contains(t, string(tools), `"lookup"`)
+ require.NotNil(t, converted.MaxTokens)
+ assert.Greater(t, *converted.MaxTokens, uint(0))
+}
+
+func TestConvertGeminiRequestThinkingConfigUsesReasoningIntent(t *testing.T) {
+ budget := 1024
+ maxTokens := uint(4096)
+ req := &dto.GeminiChatRequest{
+ Contents: []dto.GeminiChatContent{
+ {Role: "user", Parts: []dto.GeminiPart{{Text: "think"}}},
+ },
+ GenerationConfig: dto.GeminiChatGenerationConfig{
+ MaxOutputTokens: &maxTokens,
+ ThinkingConfig: &dto.GeminiThinkingConfig{ThinkingBudget: &budget},
+ },
+ }
+
+ out, err := (&Adaptor{}).ConvertGeminiRequest(nil, geminiToClaudeInfo(), req)
+ require.NoError(t, err)
+ converted, ok := out.(*dto.ClaudeRequest)
+ require.True(t, ok)
+ require.NotNil(t, converted.Thinking)
+ assert.Equal(t, "enabled", converted.Thinking.Type)
+ require.NotNil(t, converted.Thinking.BudgetTokens)
+ assert.Equal(t, 1024, *converted.Thinking.BudgetTokens)
+}
+
+func TestConvertGeminiRequestNilRequest(t *testing.T) {
+ _, err := (&Adaptor{}).ConvertGeminiRequest(nil, geminiToClaudeInfo(), nil)
+ require.Error(t, err)
+}
diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go
index 511c7eb79a39..ac1456dfd254 100644
--- a/relay/channel/claude/relay-claude.go
+++ b/relay/channel/claude/relay-claude.go
@@ -20,8 +20,6 @@ import (
"github.com/gin-gonic/gin"
)
-const claudeToChatStreamStateKey = "relaykit.claude_to_chat_stream_state"
-
func stopReasonClaude2OpenAI(reason string) string {
return relayconvert.StopReasonClaudeToOpenAI(reason)
}
@@ -120,7 +118,7 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
countClaudeStreamBillableTools(c, info, &claudeResponse)
helper.ClaudeChunkData(c, claudeResponse, data)
} else if info.RelayFormat == types.RelayFormatOpenAI {
- state, err := claudeToChatStreamState(c)
+ state, err := claudeToChatStreamState(info)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody)
}
@@ -142,24 +140,80 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
if err != nil {
logger.LogError(c, "send_stream_response_failed: "+err.Error())
}
+ } else if info.RelayFormat == types.RelayFormatGemini {
+ state, err := claudeToGeminiStreamState(info)
+ if err != nil {
+ return types.NewError(err, types.ErrorCodeBadResponseBody)
+ }
+ results, err := service.ConvertStreamResponseChunk(c, info, state, &claudeResponse)
+ if err != nil {
+ return types.NewError(err, types.ErrorCodeBadResponseBody)
+ }
+ if !FormatClaudeResponseInfo(&claudeResponse, nil, claudeInfo) {
+ return nil
+ }
+ countClaudeStreamBillableTools(c, info, &claudeResponse)
+ if sendErr := sendGeminiStreamResults(c, results); sendErr != nil {
+ return sendErr
+ }
}
return nil
}
-func claudeToChatStreamState(c *gin.Context) (*relayconvert.ClaudeToChatStreamState, error) {
- if value, ok := c.Get(claudeToChatStreamStateKey); ok {
- state, ok := value.(*relayconvert.ClaudeToChatStreamState)
+func claudeToChatStreamState(info *relaycommon.RelayInfo) (*relayconvert.ClaudeToChatStreamState, error) {
+ if info != nil && info.ClaudeToChatStreamState != nil {
+ state, ok := info.ClaudeToChatStreamState.(*relayconvert.ClaudeToChatStreamState)
if !ok || state == nil {
- return nil, fmt.Errorf("invalid Claude-to-Chat stream state %T", value)
+ return nil, fmt.Errorf("invalid Claude-to-Chat stream state %T", info.ClaudeToChatStreamState)
}
return state, nil
}
state := relayconvert.NewClaudeToChatStreamState()
- c.Set(claudeToChatStreamStateKey, state)
+ if info != nil {
+ info.ClaudeToChatStreamState = state
+ }
+ return state, nil
+}
+
+func claudeToGeminiStreamState(info *relaycommon.RelayInfo) (*relayconvert.ResponseStreamState, error) {
+ if info != nil && info.ChatToGeminiStreamState != nil {
+ state, ok := info.ChatToGeminiStreamState.(*relayconvert.ResponseStreamState)
+ if !ok || state == nil {
+ return nil, fmt.Errorf("invalid Claude-to-Gemini stream state %T", info.ChatToGeminiStreamState)
+ }
+ return state, nil
+ }
+
+ state, err := relayconvert.NewResponseStreamState(types.RelayFormatClaude, types.RelayFormatGemini, relayconvert.ResponseStreamOptions{})
+ if err != nil {
+ return nil, err
+ }
+ if info != nil {
+ info.ChatToGeminiStreamState = state
+ }
return state, nil
}
+func sendGeminiStreamResults(c *gin.Context, results []relayconvert.ResponseResult) *types.NewAPIError {
+ for _, result := range results {
+ geminiResponse, ok := result.Value.(*dto.GeminiChatResponse)
+ if !ok {
+ return types.NewError(fmt.Errorf("expected Gemini stream response, got %T", result.Value), types.ErrorCodeBadResponseBody)
+ }
+ if geminiResponse == nil {
+ continue
+ }
+ data, err := common.Marshal(geminiResponse)
+ if err != nil {
+ return types.NewError(err, types.ErrorCodeBadResponseBody)
+ }
+ c.Render(-1, common.CustomEvent{Data: "data: " + string(data)})
+ _ = helper.FlushWriter(c)
+ }
+ return nil
+}
+
func countClaudeStreamBillableTools(c *gin.Context, info *relaycommon.RelayInfo, claudeResponse *dto.ClaudeResponse) {
if claudeResponse == nil {
return
@@ -213,6 +267,20 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
}
}
helper.Done(c)
+ } else if info.RelayFormat == types.RelayFormatGemini {
+ state, err := claudeToGeminiStreamState(info)
+ if err != nil {
+ common.SysLog("error creating Gemini stream state: " + err.Error())
+ return
+ }
+ results, err := service.FinalizeStreamResponse(c, info, state)
+ if err != nil {
+ common.SysLog("error finalizing Gemini stream response: " + err.Error())
+ return
+ }
+ if sendErr := sendGeminiStreamResults(c, results); sendErr != nil {
+ common.SysLog("send final Gemini stream response failed: " + sendErr.Error())
+ }
}
}
@@ -293,6 +361,21 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
}
case types.RelayFormatClaude:
responseData = data
+ case types.RelayFormatGemini:
+ {
+ convertResult, convertErr := service.ConvertResponse(c, info, types.RelayFormatGemini, &claudeResponse)
+ if convertErr != nil {
+ return types.NewError(convertErr, types.ErrorCodeBadResponseBody)
+ }
+ geminiResponse, ok := convertResult.Value.(*dto.GeminiChatResponse)
+ if !ok {
+ return types.NewError(fmt.Errorf("expected Gemini generateContent response, got %T", convertResult.Value), types.ErrorCodeBadResponseBody)
+ }
+ responseData, err = common.Marshal(geminiResponse)
+ if err != nil {
+ return types.NewError(err, types.ErrorCodeBadResponseBody)
+ }
+ }
}
if claudeResponse.Usage != nil && claudeResponse.Usage.ServerToolUse != nil && claudeResponse.Usage.ServerToolUse.WebSearchRequests > 0 {
diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go
index e437f1277bd1..81b454225712 100644
--- a/relay/channel/gemini/relay-gemini.go
+++ b/relay/channel/gemini/relay-gemini.go
@@ -299,6 +299,9 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
if info.SendResponseCount == 0 {
// send first response
emptyResponse := helper.GenerateStartEmptyResponse(id, createAt, info.UpstreamModelName, nil)
+ // Claude message_start is emitted from this first OpenAI chunk.
+ // Carry upstream usage when the current Gemini frame provided it.
+ emptyResponse.Usage = response.Usage
if response.IsToolCall() {
if len(emptyResponse.Choices) > 0 && len(response.Choices) > 0 {
toolCalls := response.Choices[0].Delta.ToolCalls
diff --git a/relay/channel/gemini/relay_gemini_usage_test.go b/relay/channel/gemini/relay_gemini_usage_test.go
index 1ae34c13caf5..1fd9f1c3a048 100644
--- a/relay/channel/gemini/relay_gemini_usage_test.go
+++ b/relay/channel/gemini/relay_gemini_usage_test.go
@@ -5,6 +5,7 @@ import (
"io"
"net/http"
"net/http/httptest"
+ "strings"
"testing"
"github.com/QuantumNous/new-api/common"
@@ -16,6 +17,130 @@ import (
"github.com/stretchr/testify/require"
)
+func TestStreamResponseGeminiChat2OpenAIAttachesUsageMetadata(t *testing.T) {
+ t.Parallel()
+
+ withUsage, isStop := streamResponseGeminiChat2OpenAI(&dto.GeminiChatResponse{
+ Candidates: []dto.GeminiChatCandidate{{
+ Content: dto.GeminiChatContent{
+ Role: "model",
+ Parts: []dto.GeminiPart{{Text: "hello"}},
+ },
+ }},
+ UsageMetadata: dto.GeminiUsageMetadata{
+ PromptTokenCount: 3868,
+ CandidatesTokenCount: 0,
+ TotalTokenCount: 3868,
+ },
+ })
+ require.False(t, isStop)
+ require.NotNil(t, withUsage)
+ require.NotNil(t, withUsage.Usage)
+ require.Equal(t, 3868, withUsage.Usage.PromptTokens)
+ require.Equal(t, 3868, withUsage.Usage.TotalTokens)
+ require.NotNil(t, withUsage.Usage.BillingUsage)
+ require.Equal(t, dto.BillingUsageSourceGeminiChat, withUsage.Usage.BillingUsage.Source)
+ require.Equal(t, dto.BillingUsageSemanticGemini, withUsage.Usage.BillingUsage.Semantic)
+ require.NotNil(t, withUsage.Usage.BillingUsage.GeminiUsageMetadata)
+ require.Equal(t, 3868, withUsage.Usage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
+ require.False(t, withUsage.Usage.BillingUsage.Estimated)
+
+ withoutUsage, _ := streamResponseGeminiChat2OpenAI(&dto.GeminiChatResponse{
+ Candidates: []dto.GeminiChatCandidate{{
+ Content: dto.GeminiChatContent{
+ Role: "model",
+ Parts: []dto.GeminiPart{{Text: "hello"}},
+ },
+ }},
+ })
+ require.NotNil(t, withoutUsage)
+ require.Nil(t, withoutUsage.Usage)
+}
+
+func TestGeminiChatStreamHandlerClaudeFirstFrameUsesUpstreamUsage(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+
+ oldStreamingTimeout := constant.StreamingTimeout
+ constant.StreamingTimeout = 300
+ t.Cleanup(func() {
+ constant.StreamingTimeout = oldStreamingTimeout
+ })
+
+ info := &relaycommon.RelayInfo{
+ RelayFormat: types.RelayFormatClaude,
+ OriginModelName: "gemini-2.5-flash",
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: "gemini-2.5-flash",
+ },
+ ClaudeConvertInfo: &relaycommon.ClaudeConvertInfo{
+ LastMessagesType: relaycommon.LastMessageTypeNone,
+ },
+ }
+ info.SetEstimatePromptTokens(4994)
+
+ chunkData, err := common.Marshal(dto.GeminiChatResponse{
+ Candidates: []dto.GeminiChatCandidate{{
+ Content: dto.GeminiChatContent{
+ Role: "model",
+ Parts: []dto.GeminiPart{{Text: "hello"}},
+ },
+ }},
+ UsageMetadata: dto.GeminiUsageMetadata{
+ PromptTokenCount: 3868,
+ TotalTokenCount: 3868,
+ },
+ })
+ require.NoError(t, err)
+ resp := &http.Response{
+ Body: io.NopCloser(bytes.NewReader([]byte("data: " + string(chunkData) + "\n" + "data: [DONE]\n"))),
+ }
+
+ usage, newAPIError := GeminiChatStreamHandler(c, info, resp)
+ require.Nil(t, newAPIError)
+ require.NotNil(t, usage)
+ require.Equal(t, 3868, usage.PromptTokens)
+
+ var startUsage, deltaUsage *dto.ClaudeUsage
+ for _, line := range strings.Split(recorder.Body.String(), "\n") {
+ payload, ok := strings.CutPrefix(strings.TrimSpace(line), "data: ")
+ if !ok {
+ continue
+ }
+ var event dto.ClaudeResponse
+ if err := common.UnmarshalJsonStr(payload, &event); err != nil {
+ continue
+ }
+ switch event.Type {
+ case "message_start":
+ if event.Message != nil {
+ startUsage = event.Message.Usage
+ }
+ case "message_delta":
+ deltaUsage = event.Usage
+ }
+ }
+
+ require.NotNil(t, startUsage)
+ require.Equal(t, 3868, startUsage.InputTokens)
+ require.NotNil(t, startUsage.BillingUsage)
+ require.Equal(t, dto.BillingUsageSourceGeminiChat, startUsage.BillingUsage.Source)
+ require.Equal(t, dto.BillingUsageSemanticGemini, startUsage.BillingUsage.Semantic)
+ require.NotNil(t, startUsage.BillingUsage.GeminiUsageMetadata)
+ require.Equal(t, 3868, startUsage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
+ require.False(t, startUsage.BillingUsage.Estimated)
+
+ require.NotNil(t, deltaUsage)
+ require.Equal(t, 3868, deltaUsage.InputTokens)
+ require.NotNil(t, deltaUsage.BillingUsage)
+ require.Equal(t, dto.BillingUsageSourceGeminiChat, deltaUsage.BillingUsage.Source)
+ require.Equal(t, dto.BillingUsageSemanticGemini, deltaUsage.BillingUsage.Semantic)
+ require.NotNil(t, deltaUsage.BillingUsage.GeminiUsageMetadata)
+ require.Equal(t, 3868, deltaUsage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
+}
+
func TestGeminiChatHandlerCompletionTokensExcludeToolUsePromptTokens(t *testing.T) {
t.Parallel()
diff --git a/relay/channel/openai/helper.go b/relay/channel/openai/helper.go
index 3999ba560fae..a3cbd115d1b7 100644
--- a/relay/channel/openai/helper.go
+++ b/relay/channel/openai/helper.go
@@ -19,8 +19,6 @@ import (
"github.com/gin-gonic/gin"
)
-const chatToGeminiStreamStateKey = "relaykit.chat_to_gemini_stream_state"
-
// 辅助函数
func HandleStreamFormat(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error {
switch info.RelayFormat {
@@ -68,7 +66,7 @@ func handleGeminiFormat(c *gin.Context, data string, info *relaycommon.RelayInfo
return err
}
- state, err := chatToGeminiStreamState(c, &streamResponse)
+ state, err := chatToGeminiStreamState(info, &streamResponse)
if err != nil {
return err
}
@@ -79,11 +77,11 @@ func handleGeminiFormat(c *gin.Context, data string, info *relaycommon.RelayInfo
return sendGeminiStreamResults(c, results)
}
-func chatToGeminiStreamState(c *gin.Context, streamResponse *dto.ChatCompletionsStreamResponse) (*relayconvert.ResponseStreamState, error) {
- if value, ok := c.Get(chatToGeminiStreamStateKey); ok {
- state, ok := value.(*relayconvert.ResponseStreamState)
+func chatToGeminiStreamState(info *relaycommon.RelayInfo, streamResponse *dto.ChatCompletionsStreamResponse) (*relayconvert.ResponseStreamState, error) {
+ if info != nil && info.ChatToGeminiStreamState != nil {
+ state, ok := info.ChatToGeminiStreamState.(*relayconvert.ResponseStreamState)
if !ok || state == nil {
- return nil, fmt.Errorf("invalid Chat-to-Gemini stream state %T", value)
+ return nil, fmt.Errorf("invalid Chat-to-Gemini stream state %T", info.ChatToGeminiStreamState)
}
return state, nil
}
@@ -96,7 +94,9 @@ func chatToGeminiStreamState(c *gin.Context, streamResponse *dto.ChatCompletions
if err != nil {
return nil, err
}
- c.Set(chatToGeminiStreamStateKey, state)
+ if info != nil {
+ info.ChatToGeminiStreamState = state
+ }
return state, nil
}
@@ -233,7 +233,7 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
return
}
- state, err := chatToGeminiStreamState(c, &streamResponse)
+ state, err := chatToGeminiStreamState(info, &streamResponse)
if err != nil {
common.SysLog("error creating Gemini stream state: " + err.Error())
return
diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go
index 727476b1ece4..ca0150b96920 100644
--- a/relay/common/relay_info.go
+++ b/relay/common/relay_info.go
@@ -123,9 +123,14 @@ type RelayInfo struct {
UserSetting dto.UserSetting
UserEmail string
UserQuota int
- RelayFormat types.RelayFormat
- SendResponseCount int
- ReceivedResponseCount int
+ RelayFormat types.RelayFormat
+ SendResponseCount int
+ // ClaudeToChatStreamState / ChatToGeminiStreamState hold per-attempt
+ // stream converters. InitChannelMeta nils them so a retry cannot resume a
+ // dirty converter (advanced tool index / finalized).
+ ClaudeToChatStreamState any
+ ChatToGeminiStreamState any
+ ReceivedResponseCount int
FinalPreConsumedQuota int // 最终预消耗的配额
// ForcePreConsume 为 true 时禁用 BillingSession 的信任额度旁路,
// 强制预扣全额。用于异步任务(视频/音乐生成等),因为请求返回后任务仍在运行,
@@ -203,6 +208,11 @@ func (info *RelayInfo) InitChannelMeta(c *gin.Context) {
info.FinalRequestRelayFormat = ""
info.RequestConversionChain = nil
info.InitRequestConversionChain()
+ // Per-attempt only. Do not clear StreamStatus, conversion diagnostics,
+ // LastError, or billing accumulators — those are request-scoped.
+ info.SendResponseCount = 0
+ info.ClaudeToChatStreamState = nil
+ info.ChatToGeminiStreamState = 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 5142c5414b51..125df738fb1e 100644
--- a/relay/common/relay_info_test.go
+++ b/relay/common/relay_info_test.go
@@ -1,11 +1,13 @@
package common
import (
+ "context"
"encoding/json"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
@@ -178,3 +180,94 @@ func TestInitChannelMetaRestoresRequestReasoningEffortForRetry(t *testing.T) {
info.InitChannelMeta(ctx)
assert.Equal(t, "max", info.ReasoningEffort)
}
+
+func TestInitChannelMetaResetsPerAttemptStreamStateAndPreservesRequestState(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+ ctx.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil)
+
+ info, err := GenRelayInfo(ctx, types.RelayFormatOpenAI, &dto.GeneralOpenAIRequest{Model: "gpt-test"}, nil)
+ require.NoError(t, err)
+
+ claudeState := relayconvert.NewClaudeToChatStreamState()
+ _, err = claudeState.ConvertChunk(&dto.ClaudeResponse{
+ Type: "content_block_start",
+ Index: ptr(7),
+ ContentBlock: &dto.ClaudeMediaMessage{
+ Type: "tool_use",
+ Id: "toolu_1",
+ Name: "lookup",
+ },
+ })
+ require.NoError(t, err)
+ _, err = claudeState.ConvertChunk(&dto.ClaudeResponse{
+ Type: "content_block_delta",
+ Index: ptr(7),
+ Delta: &dto.ClaudeMediaMessage{
+ Type: "input_json_delta",
+ PartialJson: ptr(`{"q":"x"}`),
+ },
+ })
+ require.NoError(t, err)
+
+ geminiState, err := relayconvert.NewResponseStreamState(types.RelayFormatOpenAI, types.RelayFormatGemini, relayconvert.ResponseStreamOptions{
+ ID: "chatcmpl_1",
+ Model: "gpt-test",
+ })
+ require.NoError(t, err)
+
+ info.SendResponseCount = 3
+ info.ClaudeToChatStreamState = claudeState
+ info.ChatToGeminiStreamState = geminiState
+ info.LastError = types.NewError(assert.AnError, types.ErrorCodeBadResponseBody)
+ info.StreamStatus = NewStreamStatus()
+ info.StreamStatus.RecordError("attempt 1 soft error")
+ info.RecordConversionDiagnostics(context.Background(), []types.ConversionDiagnostic{{
+ Code: "test.loss",
+ Message: "attempt 1 conversion loss",
+ Severity: types.ConversionDiagnosticWarning,
+ From: types.RelayFormatClaude,
+ To: types.RelayFormatOpenAI,
+ }})
+
+ info.InitChannelMeta(ctx)
+
+ assert.Zero(t, info.SendResponseCount)
+ assert.Nil(t, info.ClaudeToChatStreamState)
+ assert.Nil(t, info.ChatToGeminiStreamState)
+
+ require.NotNil(t, info.StreamStatus)
+ assert.True(t, info.StreamStatus.HasErrors())
+ assert.Equal(t, 1, info.StreamStatus.TotalErrorCount())
+ diagnostics := info.ConversionDiagnostics()
+ require.Len(t, diagnostics, 1)
+ assert.Equal(t, "test.loss", diagnostics[0].Code)
+ require.NotNil(t, info.LastError)
+
+ freshClaude := relayconvert.NewClaudeToChatStreamState()
+ _, err = freshClaude.ConvertChunk(&dto.ClaudeResponse{
+ Type: "content_block_delta",
+ Index: ptr(7),
+ Delta: &dto.ClaudeMediaMessage{
+ Type: "input_json_delta",
+ PartialJson: ptr(`{"q":"x"}`),
+ },
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unknown content block index")
+
+ info.IncrSendResponseCount()
+ responses := relayconvert.StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
+ Id: "chatcmpl_retry",
+ Model: "gpt-test",
+ Choices: []dto.ChatCompletionsStreamResponseChoice{{
+ Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
+ }},
+ }, info)
+ require.NotEmpty(t, responses)
+ assert.Equal(t, "message_start", responses[0].Type)
+}
+
+func ptr[T any](value T) *T {
+ return &value
+}
diff --git a/relaykit/dto/billing_usage.go b/relaykit/dto/billing_usage.go
index ac3535e8bfb6..f8cb491ed75d 100644
--- a/relaykit/dto/billing_usage.go
+++ b/relaykit/dto/billing_usage.go
@@ -270,25 +270,12 @@ func (usage *BillingUsage) CanonicalUsage() (*Usage, bool) {
func (usage *BillingUsage) canonicalOpenAIUsage() *Usage {
canonical := cloneOpenAIUsage(usage.OpenAIUsage)
- if inputDetails := canonical.InputTokensDetails; inputDetails != nil {
- if canonical.PromptTokensDetails.CachedTokens == 0 && inputDetails.CachedTokens > 0 {
- canonical.PromptTokensDetails.CachedTokens = inputDetails.CachedTokens
- }
- if canonical.PromptTokensDetails.CachedCreationTokens == 0 && inputDetails.CachedCreationTokens > 0 {
- canonical.PromptTokensDetails.CachedCreationTokens = inputDetails.CachedCreationTokens
- }
- if canonical.PromptTokensDetails.CacheWriteTokens == 0 && inputDetails.CacheWriteTokens > 0 {
- canonical.PromptTokensDetails.CacheWriteTokens = inputDetails.CacheWriteTokens
- }
- if canonical.PromptTokensDetails.TextTokens == 0 && inputDetails.TextTokens > 0 {
- canonical.PromptTokensDetails.TextTokens = inputDetails.TextTokens
- }
- if canonical.PromptTokensDetails.ImageTokens == 0 && inputDetails.ImageTokens > 0 {
- canonical.PromptTokensDetails.ImageTokens = inputDetails.ImageTokens
- }
- if canonical.PromptTokensDetails.AudioTokens == 0 && inputDetails.AudioTokens > 0 {
- canonical.PromptTokensDetails.AudioTokens = inputDetails.AudioTokens
- }
+ if canonical.InputTokensDetails != nil {
+ // InputTokensDetails fills fields that PromptTokensDetails omitted;
+ // existing PromptTokensDetails values stay canonical on overlap.
+ filled := *canonical.InputTokensDetails
+ mergeInputTokenDetails(&filled, canonical.PromptTokensDetails)
+ canonical.PromptTokensDetails = filled
}
if canonical.PromptTokensDetails.CachedTokens == 0 && canonical.PromptCacheHitTokens > 0 {
canonical.PromptTokensDetails.CachedTokens = canonical.PromptCacheHitTokens
@@ -316,12 +303,15 @@ func (usage *BillingUsage) canonicalOpenAIUsage() *Usage {
func (usage *BillingUsage) canonicalClaudeUsage() *Usage {
claudeUsage := usage.ClaudeUsage
- cacheCreation5m := claudeUsage.GetCacheCreation5mTokens()
- if cacheCreation5m == 0 {
+ // Flat legacy fields are a fallback only when this snapshot never carried
+ // a CacheCreation sub-object. Presence (non-nil), not zero vs non-zero,
+ // is the discriminator — a later sub-object that zeros 1h must win.
+ var cacheCreation5m, cacheCreation1h int
+ if claudeUsage.CacheCreation != nil {
+ cacheCreation5m = claudeUsage.GetCacheCreation5mTokens()
+ cacheCreation1h = claudeUsage.GetCacheCreation1hTokens()
+ } else {
cacheCreation5m = claudeUsage.ClaudeCacheCreation5mTokens
- }
- cacheCreation1h := claudeUsage.GetCacheCreation1hTokens()
- if cacheCreation1h == 0 {
cacheCreation1h = claudeUsage.ClaudeCacheCreation1hTokens
}
@@ -363,7 +353,7 @@ func (usage *BillingUsage) canonicalGeminiUsage() *Usage {
addGeminiInputTokenDetail(&canonical.PromptTokensDetails, detail)
}
for _, detail := range metadata.CandidatesTokensDetails {
- switch detail.Modality {
+ switch normalizeGeminiModality(detail.Modality) {
case "IMAGE":
canonical.CompletionTokenDetails.ImageTokens += detail.TokenCount
case "AUDIO":
@@ -377,6 +367,9 @@ func (usage *BillingUsage) canonicalGeminiUsage() *Usage {
canonical.TotalTokens = canonical.PromptTokens + canonical.CompletionTokens
} else if canonical.CompletionTokens <= 0 {
canonical.CompletionTokens = canonical.TotalTokens - canonical.PromptTokens
+ if canonical.CompletionTokens < 0 {
+ canonical.CompletionTokens = 0
+ }
}
if canonical.PromptTokens > 0 && canonical.PromptTokensDetails.TextTokens == 0 && canonical.PromptTokensDetails.AudioTokens == 0 {
canonical.PromptTokensDetails.TextTokens = canonical.PromptTokens
@@ -385,7 +378,7 @@ func (usage *BillingUsage) canonicalGeminiUsage() *Usage {
}
func addGeminiInputTokenDetail(details *InputTokenDetails, detail GeminiPromptTokensDetails) {
- switch detail.Modality {
+ switch normalizeGeminiModality(detail.Modality) {
case "AUDIO":
details.AudioTokens += detail.TokenCount
case "IMAGE":
diff --git a/relaykit/dto/billing_usage_test.go b/relaykit/dto/billing_usage_test.go
index a09e2ac59c62..261d5180b302 100644
--- a/relaykit/dto/billing_usage_test.go
+++ b/relaykit/dto/billing_usage_test.go
@@ -63,6 +63,38 @@ func TestNewEstimatedGeminiChatBillingUsage(t *testing.T) {
assert.Equal(t, 18, billingUsage.GeminiUsageMetadata.TotalTokenCount)
}
+func TestCanonicalGeminiUsageClampsNegativeCompletionFromTotalMinusPrompt(t *testing.T) {
+ usage, ok := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
+ PromptTokenCount: 50,
+ TotalTokenCount: 30,
+ }).CanonicalUsage()
+ require.True(t, ok)
+ assert.Equal(t, 0, usage.CompletionTokens)
+}
+
+func TestCanonicalOpenAIUsageMergesInputTokenDetailsFieldwise(t *testing.T) {
+ usage, ok := NewOpenAIResponsesBillingUsage(&Usage{
+ PromptTokens: 10,
+ PromptTokensDetails: InputTokenDetails{
+ CachedTokens: 8,
+ TextTokens: 12,
+ ImageTokens: 4,
+ AudioTokens: 3,
+ },
+ InputTokensDetails: &InputTokenDetails{
+ CachedTokens: 5,
+ CachedCreationTokens: 7,
+ TextTokens: 2,
+ },
+ }).CanonicalUsage()
+ require.True(t, ok)
+ assert.Equal(t, 8, usage.PromptTokensDetails.CachedTokens)
+ assert.Equal(t, 12, usage.PromptTokensDetails.TextTokens)
+ assert.Equal(t, 4, usage.PromptTokensDetails.ImageTokens)
+ assert.Equal(t, 3, usage.PromptTokensDetails.AudioTokens)
+ assert.Equal(t, 7, usage.PromptTokensDetails.CachedCreationTokens)
+}
+
func TestBillingUsageJSONUsesProtocolNamedFields(t *testing.T) {
billingUsage := &BillingUsage{
OpenAIUsage: &Usage{PromptTokens: 1, BillingUsage: NewClaudeMessagesBillingUsage(&ClaudeUsage{InputTokens: 9})},
diff --git a/relaykit/dto/usage_merge.go b/relaykit/dto/usage_merge.go
index 39383b1b7794..0bb2fd5e22b5 100644
--- a/relaykit/dto/usage_merge.go
+++ b/relaykit/dto/usage_merge.go
@@ -101,7 +101,13 @@ func MergeBillingUsageNonZero(current *BillingUsage, incoming *BillingUsage) *Bi
return CloneBillingUsage(current)
}
if current == nil || !sameBillingUsageDialect(current, incoming) {
- return CloneBillingUsage(incoming)
+ replaced := CloneBillingUsage(incoming)
+ if current != nil && replaced != nil {
+ // Replacement carries the incoming payload; Estimated carries the
+ // history of any local synthesis on either side.
+ replaced.Estimated = current.Estimated || incoming.Estimated
+ }
+ return replaced
}
merged := CloneBillingUsage(current)
@@ -120,7 +126,7 @@ func MergeBillingUsageNonZero(current *BillingUsage, incoming *BillingUsage) *Bi
cloneOpenAIUsage(incoming.OpenAIUsage),
)
case current.ClaudeUsage != nil && incoming.ClaudeUsage != nil:
- merged.ClaudeUsage = mergeClaudeUsageNonZero(current.ClaudeUsage, incoming.ClaudeUsage)
+ merged.ClaudeUsage = MergeClaudeUsageNonZero(current.ClaudeUsage, incoming.ClaudeUsage)
case current.GeminiUsageMetadata != nil && incoming.GeminiUsageMetadata != nil:
merged.GeminiUsageMetadata = MergeGeminiUsageMetadataNonZero(current.GeminiUsageMetadata, incoming.GeminiUsageMetadata)
}
@@ -140,12 +146,15 @@ func sameBillingUsageDialect(current *BillingUsage, incoming *BillingUsage) bool
current.GeminiUsageMetadata != nil && incoming.GeminiUsageMetadata != nil
}
-func mergeClaudeUsageNonZero(current *ClaudeUsage, incoming *ClaudeUsage) *ClaudeUsage {
+func MergeClaudeUsageNonZero(current *ClaudeUsage, incoming *ClaudeUsage) *ClaudeUsage {
merged := cloneClaudeUsage(current)
if merged == nil {
merged = &ClaudeUsage{}
}
if incoming == nil {
+ if current != nil {
+ merged.BillingUsage = CloneBillingUsage(current.BillingUsage)
+ }
return merged
}
if incoming.InputTokens > 0 {
@@ -169,6 +178,11 @@ func mergeClaudeUsageNonZero(current *ClaudeUsage, incoming *ClaudeUsage) *Claud
if incoming.CacheCreation != nil {
cacheCreation := *incoming.CacheCreation
merged.CacheCreation = &cacheCreation
+ // Flat legacy fields are the same information as the sub-object.
+ // Sync them as a whole overwrite, including explicit zeros, so a
+ // later correction cannot leave a stale high-watermark behind.
+ merged.ClaudeCacheCreation5mTokens = cacheCreation.Ephemeral5mInputTokens
+ merged.ClaudeCacheCreation1hTokens = cacheCreation.Ephemeral1hInputTokens
}
if incoming.ServerToolUse != nil {
if merged.ServerToolUse == nil {
@@ -187,6 +201,14 @@ func mergeClaudeUsageNonZero(current *ClaudeUsage, incoming *ClaudeUsage) *Claud
merged.ServerToolUse.ToolSearchRequests = incoming.ServerToolUse.ToolSearchRequests
}
}
+ // cloneClaudeUsage strips BillingUsage so a nested Claude snapshot cannot
+ // recurse. Restore the client-visible sidecar here: incoming wins when
+ // present (authoritative/upstream), otherwise keep current's.
+ if incoming.BillingUsage != nil {
+ merged.BillingUsage = CloneBillingUsage(incoming.BillingUsage)
+ } else if current != nil {
+ merged.BillingUsage = CloneBillingUsage(current.BillingUsage)
+ }
return merged
}
@@ -238,19 +260,23 @@ func MergeGeminiUsageMetadataNonZero(current *GeminiUsageMetadata, incoming *Gem
return &merged
}
+func normalizeGeminiModality(modality string) string {
+ return strings.ToUpper(strings.TrimSpace(modality))
+}
+
func mergeGeminiTokenDetails(current []GeminiPromptTokensDetails, incoming []GeminiPromptTokensDetails) []GeminiPromptTokensDetails {
merged := append([]GeminiPromptTokensDetails{}, current...)
indexes := make(map[string]int, len(merged))
for index, detail := range merged {
- indexes[strings.ToUpper(strings.TrimSpace(detail.Modality))] = index
+ indexes[normalizeGeminiModality(detail.Modality)] = index
}
for _, detail := range incoming {
if detail.TokenCount <= 0 {
continue
}
- key := strings.ToUpper(strings.TrimSpace(detail.Modality))
+ key := normalizeGeminiModality(detail.Modality)
if index, ok := indexes[key]; ok {
- merged[index] = detail
+ merged[index].TokenCount += detail.TokenCount
continue
}
indexes[key] = len(merged)
diff --git a/relaykit/dto/usage_merge_test.go b/relaykit/dto/usage_merge_test.go
index 088ff02405d8..738a4bcafe35 100644
--- a/relaykit/dto/usage_merge_test.go
+++ b/relaykit/dto/usage_merge_test.go
@@ -10,7 +10,7 @@ import (
func TestMergeClaudeUsageCacheCreationReplacesWholeObject(t *testing.T) {
t.Parallel()
- merged := mergeClaudeUsageNonZero(
+ merged := MergeClaudeUsageNonZero(
&ClaudeUsage{
CacheCreation: &ClaudeCacheCreationUsage{Ephemeral1hInputTokens: 1000},
},
@@ -52,6 +52,173 @@ func TestMergeGeminiUsageMetadataCandidatesAndThoughtsReplacedAsPair(t *testing.
assert.Equal(t, 150, usage.CompletionTokens)
}
+func TestGeminiModalityKeysSettleConsistentlyAndDuplicateEntriesSum(t *testing.T) {
+ t.Parallel()
+
+ for _, modality := range []string{"audio", " AUDIO ", "AUDIO"} {
+ t.Run("settle_"+modality, func(t *testing.T) {
+ t.Parallel()
+ billing := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
+ PromptTokenCount: 100,
+ PromptTokensDetails: []GeminiPromptTokensDetails{
+ {Modality: modality, TokenCount: 40},
+ {Modality: "TEXT", TokenCount: 60},
+ },
+ })
+ usage, ok := billing.CanonicalUsage()
+ require.True(t, ok)
+ assert.Equal(t, 40, usage.PromptTokensDetails.AudioTokens)
+ assert.Equal(t, 60, usage.PromptTokensDetails.TextTokens)
+ })
+ }
+
+ mergedDetails := mergeGeminiTokenDetails(
+ []GeminiPromptTokensDetails{{Modality: "AUDIO", TokenCount: 10}},
+ []GeminiPromptTokensDetails{{Modality: "audio", TokenCount: 15}},
+ )
+ require.Len(t, mergedDetails, 1)
+ assert.Equal(t, 25, mergedDetails[0].TokenCount)
+
+ streamMerged := MergeGeminiUsageMetadataNonZero(
+ &GeminiUsageMetadata{
+ PromptTokenCount: 10,
+ PromptTokensDetails: []GeminiPromptTokensDetails{{Modality: "AUDIO", TokenCount: 10}},
+ },
+ &GeminiUsageMetadata{
+ PromptTokenCount: 25,
+ PromptTokensDetails: []GeminiPromptTokensDetails{{Modality: "audio", TokenCount: 15}},
+ },
+ )
+ require.NotNil(t, streamMerged)
+ streamUsage, ok := NewGeminiChatBillingUsage(streamMerged).CanonicalUsage()
+ require.True(t, ok)
+
+ decodedUsage, ok := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
+ PromptTokenCount: 25,
+ PromptTokensDetails: []GeminiPromptTokensDetails{
+ {Modality: "AUDIO", TokenCount: 10},
+ {Modality: "audio", TokenCount: 15},
+ },
+ }).CanonicalUsage()
+ require.True(t, ok)
+ assert.Equal(t, decodedUsage.PromptTokensDetails.AudioTokens, streamUsage.PromptTokensDetails.AudioTokens)
+ assert.Equal(t, 25, decodedUsage.PromptTokensDetails.AudioTokens)
+}
+
+func TestClaudeCacheCreationSubObjectZeroDoesNotReviveFlatLegacyFields(t *testing.T) {
+ t.Parallel()
+
+ merged := MergeClaudeUsageNonZero(
+ &ClaudeUsage{ClaudeCacheCreation1hTokens: 1000},
+ &ClaudeUsage{
+ InputTokens: 10,
+ CacheCreation: &ClaudeCacheCreationUsage{
+ Ephemeral5mInputTokens: 1000,
+ Ephemeral1hInputTokens: 0,
+ },
+ },
+ )
+ require.NotNil(t, merged.CacheCreation)
+ assert.Equal(t, 1000, merged.CacheCreation.Ephemeral5mInputTokens)
+ assert.Equal(t, 0, merged.CacheCreation.Ephemeral1hInputTokens)
+ assert.Equal(t, 1000, merged.ClaudeCacheCreation5mTokens)
+ assert.Equal(t, 0, merged.ClaudeCacheCreation1hTokens)
+
+ usage, ok := NewClaudeMessagesBillingUsage(merged).CanonicalUsage()
+ require.True(t, ok)
+ assert.Equal(t, 1000, usage.ClaudeCacheCreation5mTokens)
+ assert.Equal(t, 0, usage.ClaudeCacheCreation1hTokens)
+}
+
+func TestClaudeCacheCreationFlatFieldsStillSettleWhenSnapshotNeverHadSubObject(t *testing.T) {
+ t.Parallel()
+
+ usage, ok := NewClaudeMessagesBillingUsage(&ClaudeUsage{
+ InputTokens: 10,
+ ClaudeCacheCreation1hTokens: 1000,
+ }).CanonicalUsage()
+ require.True(t, ok)
+ assert.Equal(t, 0, usage.ClaudeCacheCreation5mTokens)
+ assert.Equal(t, 1000, usage.ClaudeCacheCreation1hTokens)
+}
+
+func TestMergeBillingUsageORsEstimatedOnSameAndCrossDialect(t *testing.T) {
+ t.Parallel()
+
+ estimated := NewEstimatedGeminiChatBillingUsage(&Usage{PromptTokens: 10, CompletionTokens: 2})
+ require.NotNil(t, estimated)
+ require.True(t, estimated.Estimated)
+
+ sameDialect := MergeBillingUsageNonZero(estimated, NewGeminiChatBillingUsage(&GeminiUsageMetadata{
+ PromptTokenCount: 11,
+ CandidatesTokenCount: 3,
+ TotalTokenCount: 14,
+ }))
+ require.NotNil(t, sameDialect)
+ assert.True(t, sameDialect.Estimated)
+
+ crossDialect := MergeBillingUsageNonZero(estimated, NewOpenAIChatBillingUsage(&Usage{
+ PromptTokens: 12,
+ CompletionTokens: 4,
+ TotalTokens: 16,
+ }))
+ require.NotNil(t, crossDialect)
+ assert.True(t, crossDialect.Estimated)
+ require.NotNil(t, crossDialect.OpenAIUsage)
+ assert.Equal(t, 12, crossDialect.OpenAIUsage.PromptTokens)
+}
+
+func TestMergeClaudeUsageNonZeroPreservesBillingUsage(t *testing.T) {
+ t.Parallel()
+
+ currentSidecar := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
+ PromptTokenCount: 3868,
+ TotalTokenCount: 3868,
+ CachedContentTokenCount: 20,
+ })
+ incomingSidecar := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
+ PromptTokenCount: 3868,
+ CandidatesTokenCount: 12,
+ TotalTokenCount: 3880,
+ })
+ require.NotNil(t, currentSidecar)
+ require.NotNil(t, incomingSidecar)
+
+ withIncoming := MergeClaudeUsageNonZero(
+ &ClaudeUsage{
+ InputTokens: 3868,
+ CacheReadInputTokens: 20,
+ BillingUsage: currentSidecar,
+ },
+ &ClaudeUsage{
+ InputTokens: 3868,
+ OutputTokens: 12,
+ BillingUsage: incomingSidecar,
+ },
+ )
+ require.NotNil(t, withIncoming.BillingUsage)
+ assert.Equal(t, BillingUsageSourceGeminiChat, withIncoming.BillingUsage.Source)
+ assert.Equal(t, BillingUsageSemanticGemini, withIncoming.BillingUsage.Semantic)
+ require.NotNil(t, withIncoming.BillingUsage.GeminiUsageMetadata)
+ assert.Equal(t, 12, withIncoming.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
+ assert.Equal(t, 20, withIncoming.CacheReadInputTokens)
+ assert.NotSame(t, incomingSidecar, withIncoming.BillingUsage)
+
+ keepCurrent := MergeClaudeUsageNonZero(
+ &ClaudeUsage{
+ InputTokens: 3868,
+ CacheReadInputTokens: 20,
+ BillingUsage: currentSidecar,
+ },
+ &ClaudeUsage{InputTokens: 3868, OutputTokens: 12},
+ )
+ require.NotNil(t, keepCurrent.BillingUsage)
+ require.NotNil(t, keepCurrent.BillingUsage.GeminiUsageMetadata)
+ assert.Equal(t, 20, keepCurrent.BillingUsage.GeminiUsageMetadata.CachedContentTokenCount)
+ assert.Equal(t, 0, keepCurrent.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
+ assert.Equal(t, 20, keepCurrent.CacheReadInputTokens)
+}
+
func TestMergeUsageNonZeroKeepsPositiveValuesAndTakesMaxTotal(t *testing.T) {
t.Parallel()
diff --git a/relaykit/relayconvert/internal/gemini_chat/to_oai_chat_resp.go b/relaykit/relayconvert/internal/gemini_chat/to_oai_chat_resp.go
index c74af6d577cb..58116d7dd591 100644
--- a/relaykit/relayconvert/internal/gemini_chat/to_oai_chat_resp.go
+++ b/relaykit/relayconvert/internal/gemini_chat/to_oai_chat_resp.go
@@ -283,6 +283,11 @@ func StreamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*d
Object: "chat.completion.chunk",
Choices: choices,
}
+ // Only attach usage the chunk actually reported. Do not fall back to a
+ // local prompt estimate — converters treat this as first-frame truth.
+ if metadata := geminiResponse.GetUsageMetadata(); dto.HasGeminiUsageMetadataTokens(metadata) {
+ response.Usage = UsageFromGeminiMetadata(metadata, 0)
+ }
return &response, isStop
}
diff --git a/relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go
index 78fb875bc37d..06e11356fd54 100644
--- a/relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go
+++ b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go
@@ -89,6 +89,21 @@ func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
return sharedclaude.UsageFromOpenAI(oaiUsage)
}
+func clientVisibleClaudeStreamUsage(state *convmeta.ClaudeConvertInfo, incoming *dto.Usage) *dto.ClaudeUsage {
+ prior := buildClaudeUsageFromOpenAIUsage(state.Usage)
+ converted := buildClaudeUsageFromOpenAIUsage(incoming)
+ if incoming != nil {
+ state.Usage = dto.MergeUsageNonZero(state.Usage, incoming)
+ }
+ if prior == nil {
+ return converted
+ }
+ if converted == nil {
+ return prior
+ }
+ return dto.MergeClaudeUsageNonZero(prior, converted)
+}
+
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
return sharedclaude.NormalizeCacheCreationSplit(totalTokens, tokens5m, tokens1h)
}
@@ -168,15 +183,27 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
}
}
if info.GetSendResponseCount() == 1 {
+ // Client-visible Claude stream usage matches billing merge: first
+ // frame records first, a later non-zero field overrides, and a later
+ // zero/missing field never erases a first-frame positive. Anthropic
+ // clients treat message_delta as authoritative for the fields it
+ // carries, including a corrected input_tokens.
+ startUsage := &dto.ClaudeUsage{
+ InputTokens: info.GetEstimatePromptTokens(),
+ OutputTokens: 0,
+ }
+ if openAIResponse.Usage != nil && dto.HasOpenAIUsageTokens(openAIResponse.Usage) {
+ if real := buildClaudeUsageFromOpenAIUsage(openAIResponse.Usage); real != nil {
+ startUsage = real
+ }
+ state.Usage = dto.MergeUsageNonZero(state.Usage, openAIResponse.Usage)
+ }
msg := &dto.ClaudeMediaMessage{
Id: openAIResponse.Id,
Model: openAIResponse.Model,
Type: "message",
Role: "assistant",
- Usage: &dto.ClaudeUsage{
- InputTokens: info.GetEstimatePromptTokens(),
- OutputTokens: 0,
- },
+ Usage: startUsage,
}
msg.SetContent(make([]any, 0))
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
@@ -187,10 +214,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
if len(openAIResponse.Choices) == 0 {
// Some OpenAI-compatible upstreams end with a usage-only SSE chunk.
- oaiUsage := openAIResponse.Usage
- if oaiUsage == nil {
- oaiUsage = state.Usage
- }
+ oaiUsage := clientVisibleClaudeStreamUsage(state, openAIResponse.Usage)
if oaiUsage != nil {
appendStopOpenBlocks()
stopReason := stopReasonOpenAI2Claude(state.FinishReason)
@@ -199,7 +223,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
}
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
- Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
+ Usage: oaiUsage,
Delta: &dto.ClaudeMediaMessage{
StopReason: kitutil.GetPointer[string](stopReason),
},
@@ -367,10 +391,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
appendCitationDeltas(chosenChoice.Delta.Annotations)
if doneChunk || state.Done {
- oaiUsage := openAIResponse.Usage
- if oaiUsage == nil {
- oaiUsage = state.Usage
- }
+ oaiUsage := clientVisibleClaudeStreamUsage(state, openAIResponse.Usage)
if oaiUsage == nil {
// Some upstreams emit finish_reason first, then send a final usage-only chunk.
// Keep content blocks open until usage is available so the terminal message_delta
@@ -380,7 +401,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
appendStopOpenBlocks()
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
- Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
+ Usage: oaiUsage,
Delta: &dto.ClaudeMediaMessage{
StopReason: kitutil.GetPointer[string](stopReasonOpenAI2Claude(state.FinishReason)),
},
@@ -414,7 +435,7 @@ func FinalizeStreamResponseOpenAI2Claude(info convmeta.Meta) []*dto.ClaudeRespon
responses = append(responses,
&dto.ClaudeResponse{
Type: "message_delta",
- Usage: buildClaudeUsageFromOpenAIUsage(state.Usage),
+ Usage: clientVisibleClaudeStreamUsage(state, nil),
Delta: &dto.ClaudeMediaMessage{
StopReason: kitutil.GetPointer[string](stopReason),
},
diff --git a/relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go
index 976e72591911..cbb38813da78 100644
--- a/relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go
+++ b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go
@@ -223,6 +223,192 @@ func TestStreamResponseOpenAI2ClaudeClosesTextThinkingAndToolBlocks(t *testing.T
assert.Equal(t, "message_stop", finishResponses[2].Type)
}
+func TestStreamResponseOpenAI2ClaudeFirstFrameUsesUpstreamUsageWhenPresent(t *testing.T) {
+ info := &convmeta.Values{
+ EstimatePromptTokens: 32,
+ SendResponseCount: 1,
+ ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
+ }
+
+ responses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
+ Id: "chatcmpl_1",
+ Model: "gpt-test",
+ Choices: []dto.ChatCompletionsStreamResponseChoice{{
+ Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
+ }},
+ Usage: &dto.Usage{PromptTokens: 29, CompletionTokens: 0, TotalTokens: 29},
+ }, info)
+ require.NotEmpty(t, responses)
+ require.Equal(t, "message_start", responses[0].Type)
+ require.NotNil(t, responses[0].Message)
+ require.NotNil(t, responses[0].Message.Usage)
+ assert.Equal(t, 29, responses[0].Message.Usage.InputTokens)
+}
+
+func TestStreamResponseOpenAI2ClaudeMessageDeltaCorrectsEstimatedFirstFrame(t *testing.T) {
+ info := &convmeta.Values{
+ EstimatePromptTokens: 32,
+ SendResponseCount: 1,
+ ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
+ }
+
+ first := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
+ Id: "chatcmpl_1",
+ Model: "gpt-test",
+ Choices: []dto.ChatCompletionsStreamResponseChoice{{
+ Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
+ }},
+ }, info)
+ require.NotEmpty(t, first)
+ require.Equal(t, "message_start", first[0].Type)
+ require.NotNil(t, first[0].Message.Usage)
+ assert.Equal(t, 32, first[0].Message.Usage.InputTokens)
+
+ info.SendResponseCount = 2
+ finish := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
+ Id: "chatcmpl_1",
+ Model: "gpt-test",
+ Choices: []dto.ChatCompletionsStreamResponseChoice{{
+ FinishReason: ptr("stop"),
+ }},
+ Usage: &dto.Usage{PromptTokens: 29, CompletionTokens: 4, TotalTokens: 33},
+ }, info)
+ var delta *dto.ClaudeResponse
+ for _, resp := range finish {
+ if resp.Type == "message_delta" {
+ delta = resp
+ break
+ }
+ }
+ require.NotNil(t, delta)
+ require.NotNil(t, delta.Usage)
+ assert.Equal(t, 29, delta.Usage.InputTokens)
+ assert.Equal(t, 4, delta.Usage.OutputTokens)
+}
+
+func TestStreamResponseOpenAI2ClaudeMessageDeltaDoesNotZeroFirstFrameCache(t *testing.T) {
+ info := &convmeta.Values{
+ EstimatePromptTokens: 8,
+ SendResponseCount: 1,
+ ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
+ }
+
+ first := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
+ Id: "chatcmpl_1",
+ Model: "gpt-test",
+ Choices: []dto.ChatCompletionsStreamResponseChoice{{
+ Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
+ }},
+ Usage: &dto.Usage{
+ PromptTokens: 40,
+ CompletionTokens: 0,
+ TotalTokens: 40,
+ PromptTokensDetails: dto.InputTokenDetails{
+ CachedTokens: 20,
+ CachedCreationTokens: 10,
+ },
+ },
+ }, info)
+ require.Equal(t, "message_start", first[0].Type)
+ require.NotNil(t, first[0].Message.Usage)
+ assert.Equal(t, 20, first[0].Message.Usage.CacheReadInputTokens)
+ assert.Equal(t, 10, first[0].Message.Usage.CacheCreationInputTokens)
+
+ info.SendResponseCount = 2
+ finish := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
+ Id: "chatcmpl_1",
+ Model: "gpt-test",
+ Choices: []dto.ChatCompletionsStreamResponseChoice{{
+ FinishReason: ptr("stop"),
+ }},
+ Usage: &dto.Usage{PromptTokens: 29, CompletionTokens: 4, TotalTokens: 33},
+ }, info)
+ var delta *dto.ClaudeResponse
+ for _, resp := range finish {
+ if resp.Type == "message_delta" {
+ delta = resp
+ break
+ }
+ }
+ require.NotNil(t, delta)
+ require.NotNil(t, delta.Usage)
+ assert.Equal(t, 29, delta.Usage.InputTokens)
+ assert.Equal(t, 20, delta.Usage.CacheReadInputTokens)
+ assert.Equal(t, 10, delta.Usage.CacheCreationInputTokens)
+}
+
+func TestStreamResponseOpenAI2ClaudeGeminiBillingUsageOnStartAndDelta(t *testing.T) {
+ info := &convmeta.Values{
+ EstimatePromptTokens: 4994,
+ SendResponseCount: 1,
+ ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
+ }
+
+ firstUsage := &dto.Usage{
+ PromptTokens: 3868,
+ CompletionTokens: 0,
+ TotalTokens: 3868,
+ BillingUsage: dto.NewGeminiChatBillingUsage(&dto.GeminiUsageMetadata{
+ PromptTokenCount: 3868,
+ TotalTokenCount: 3868,
+ }),
+ }
+ first := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
+ Id: "chatcmpl_1",
+ Model: "gpt-test",
+ Choices: []dto.ChatCompletionsStreamResponseChoice{{
+ Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
+ }},
+ Usage: firstUsage,
+ }, info)
+ require.NotEmpty(t, first)
+ require.Equal(t, "message_start", first[0].Type)
+ require.NotNil(t, first[0].Message)
+ require.NotNil(t, first[0].Message.Usage)
+ assert.Equal(t, 3868, first[0].Message.Usage.InputTokens)
+ require.NotNil(t, first[0].Message.Usage.BillingUsage)
+ assert.Equal(t, dto.BillingUsageSourceGeminiChat, first[0].Message.Usage.BillingUsage.Source)
+ assert.Equal(t, dto.BillingUsageSemanticGemini, first[0].Message.Usage.BillingUsage.Semantic)
+ require.NotNil(t, first[0].Message.Usage.BillingUsage.GeminiUsageMetadata)
+ assert.Equal(t, 3868, first[0].Message.Usage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
+
+ info.SendResponseCount = 2
+ finish := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
+ Id: "chatcmpl_1",
+ Model: "gpt-test",
+ Choices: []dto.ChatCompletionsStreamResponseChoice{{
+ FinishReason: ptr("stop"),
+ }},
+ Usage: &dto.Usage{
+ PromptTokens: 3868,
+ CompletionTokens: 12,
+ TotalTokens: 3880,
+ BillingUsage: dto.NewGeminiChatBillingUsage(&dto.GeminiUsageMetadata{
+ PromptTokenCount: 3868,
+ CandidatesTokenCount: 12,
+ TotalTokenCount: 3880,
+ }),
+ },
+ }, info)
+ var delta *dto.ClaudeResponse
+ for _, resp := range finish {
+ if resp.Type == "message_delta" {
+ delta = resp
+ break
+ }
+ }
+ require.NotNil(t, delta)
+ require.NotNil(t, delta.Usage)
+ assert.Equal(t, 3868, delta.Usage.InputTokens)
+ assert.Equal(t, 12, delta.Usage.OutputTokens)
+ require.NotNil(t, delta.Usage.BillingUsage)
+ assert.Equal(t, dto.BillingUsageSourceGeminiChat, delta.Usage.BillingUsage.Source)
+ assert.Equal(t, dto.BillingUsageSemanticGemini, delta.Usage.BillingUsage.Semantic)
+ require.NotNil(t, delta.Usage.BillingUsage.GeminiUsageMetadata)
+ assert.Equal(t, 3868, delta.Usage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
+ assert.Equal(t, 12, delta.Usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
+}
+
func TestNormalizeCacheCreationSplit(t *testing.T) {
cache5m, cache1h := NormalizeCacheCreationSplit(10, 3, 2)
assert.Equal(t, 8, cache5m)
diff --git a/relaykit/relayconvert/internal/oai_chat/to_gemini_chat_req.go b/relaykit/relayconvert/internal/oai_chat/to_gemini_chat_req.go
index 3828b5fa03b8..7e721632bc84 100644
--- a/relaykit/relayconvert/internal/oai_chat/to_gemini_chat_req.go
+++ b/relaykit/relayconvert/internal/oai_chat/to_gemini_chat_req.go
@@ -157,22 +157,7 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
if textRequest.Tools != nil {
functions := make([]dto.FunctionRequest, 0, len(textRequest.Tools))
- googleSearch := false
- codeExecution := false
- urlContext := false
for _, tool := range textRequest.Tools {
- if tool.Function.Name == "googleSearch" {
- googleSearch = true
- continue
- }
- if tool.Function.Name == "codeExecution" {
- codeExecution = true
- continue
- }
- if tool.Function.Name == "urlContext" {
- urlContext = true
- continue
- }
if tool.Function.Parameters != nil {
if params, ok := tool.Function.Parameters.(map[string]interface{}); ok {
if props, hasProps := params["properties"].(map[string]interface{}); hasProps && len(props) == 0 {
@@ -184,21 +169,6 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
functions = append(functions, tool.Function)
}
geminiTools := geminiRequest.GetTools()
- if codeExecution {
- geminiTools = append(geminiTools, dto.GeminiChatTool{
- CodeExecution: make(map[string]string),
- })
- }
- if googleSearch {
- geminiTools = append(geminiTools, dto.GeminiChatTool{
- GoogleSearch: make(map[string]string),
- })
- }
- if urlContext {
- geminiTools = append(geminiTools, dto.GeminiChatTool{
- URLContext: make(map[string]string),
- })
- }
if len(functions) > 0 {
geminiTools = append(geminiTools, dto.GeminiChatTool{
FunctionDeclarations: functions,
diff --git a/relaykit/relayconvert/internal/toolconv/decode.go b/relaykit/relayconvert/internal/toolconv/decode.go
index 748fed041d02..ba64370bbca2 100644
--- a/relaykit/relayconvert/internal/toolconv/decode.go
+++ b/relaykit/relayconvert/internal/toolconv/decode.go
@@ -56,6 +56,10 @@ func extractOpenAIChatRequest(request any) (any, Set, error) {
}
for index, tool := range source.Tools {
if tool.Type == "function" || tool.Type == "" {
+ if definition, ok := decodeOpenAIChatPseudoHostedTool(tool.Function.Name); ok {
+ set.Definitions = append(set.Definitions, definition)
+ continue
+ }
set.Definitions = append(set.Definitions, Definition{
Kind: KindFunction,
Execution: ExecutionClient,
@@ -511,6 +515,40 @@ func rawBoolPointer(raw json.RawMessage) *bool {
return &value
}
+// decodeOpenAIChatPseudoHostedTool recognizes the OpenAI Chat dialect that
+// declares Gemini hosted tools as function definitions named googleSearch,
+// codeExecution, or urlContext. The names are the historical public contract;
+// recognition lives here so every target format goes through the same hosted
+// ToolDefinition pipeline.
+func decodeOpenAIChatPseudoHostedTool(name string) (Definition, bool) {
+ switch name {
+ case "googleSearch":
+ return Definition{
+ Kind: KindWebSearch,
+ Execution: ExecutionServer,
+ NativeType: "googleSearch",
+ Name: "googleSearch",
+ WebSearch: &WebSearch{},
+ }, true
+ case "codeExecution":
+ return Definition{
+ Kind: KindCodeExecution,
+ Execution: ExecutionServer,
+ NativeType: "codeExecution",
+ Name: "codeExecution",
+ }, true
+ case "urlContext":
+ return Definition{
+ Kind: KindURLContext,
+ Execution: ExecutionServer,
+ NativeType: "urlContext",
+ Name: "urlContext",
+ }, true
+ default:
+ return Definition{}, false
+ }
+}
+
func decodeOpenAIChatLocation(raw json.RawMessage) (*ApproximateLocation, error) {
if len(raw) == 0 {
return nil, nil
diff --git a/relaykit/relayconvert/internal/toolconv/encode.go b/relaykit/relayconvert/internal/toolconv/encode.go
index 224901f478f3..b2e60132ab56 100644
--- a/relaykit/relayconvert/internal/toolconv/encode.go
+++ b/relaykit/relayconvert/internal/toolconv/encode.go
@@ -381,13 +381,13 @@ func attachGeminiRequest(request any, set Set) (any, []types.ConversionDiagnosti
diagnostics = append(diagnostics, geminiWebSearchDiagnostics(index, definition.WebSearch)...)
}
case KindCodeExecution:
- if set.Source == types.RelayFormatGemini {
+ if set.Source == types.RelayFormatGemini || definition.NativeType == "codeExecution" {
tools = append(tools, map[string]any{"codeExecution": map[string]any{}})
continue
}
diagnostics = append(diagnostics, semanticLoss(fmt.Sprintf("tools[%d]", index), "unverified_tool_mapping", "code execution semantics differ across providers"))
case KindURLContext:
- if set.Source == types.RelayFormatGemini {
+ if set.Source == types.RelayFormatGemini || definition.NativeType == "urlContext" {
tools = append(tools, map[string]any{"urlContext": map[string]any{}})
continue
}
From aece11d2f7f095a33052696c5d28d3656609a99e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=98=9F=E4=BA=91=E7=8C=AB?=
Date: Thu, 3 Sep 2026 11:04:48 +0800
Subject: [PATCH 85/99] =?UTF-8?q?feat(plugin):=20add=20MiniMax-H3=20/v2=20?=
=?UTF-8?q?video=20generation=20to=20the=20hailuo=20task=20=E2=80=A6=20(#7?=
=?UTF-8?q?168)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(plugin): add MiniMax-H3 /v2 video generation to the hailuo task plugin
MiniMax-H3 speaks a different contract from the other Hailuo models, so the
hailuo task plugin now branches on the upstream model instead of adding a Go
adaptor:
- submit builds /v2/video_generation with a multimodal `content` array
(text, first/last frame images, reference video/audio, or a full
`metadata.content` passthrough), an explicit `ratio`, and 768P/2K
resolutions; `metadata.callback_url` and `metadata.aigc_watermark` pass
through
- query uses /v2/query/video_generation/{task_id} and parses the
`{"task": {...}}` envelope, falling back to the /v1 shapes for every other
model
- the /v2 result is a public CDN URL, so its artifact is proxied
credentialless instead of through /v1/files/download
- request bounds (duration 4-15, resolution 768P/2K, ratio whitelist, at most
2 frame images and 9/3/3 reference images/videos/audios) are enforced while
the request body is built, which the host runs during validation, so an
out-of-range duration is rejected with a 400 before it can become a billing
multiplier
- duration and resolution are reported as usage facts only. Like the rest of
this plugin, extractUsage returns no billing ratios, so per-call pricing is
flat and 2K/duration pricing is expressed through the model's tiered billing
expression over those facts.
Query hooks are driver hooks and are documented to receive `ctx.model` and
`ctx.upstreamModel`, but polling has no relay info and never populated them.
The polling and realtime-fetch call sites now carry the persisted task model
properties and the plugin adaptor maps them onto the query context, with
`upstreamModel` falling back to the origin name for tasks submitted without a
channel mapping.
* fix(plugin): validate Hailuo H3 requests and errors
---
docs/plugin-api/v1.md | 2 +-
plugins/hailuo_responses_test.go | 409 ++++++++++++++++++++
plugins/tasks/hailuo/plugin.js | 273 ++++++++++++-
relay/channel/task/jsplugin/adaptor.go | 10 +
relay/channel/task/jsplugin/adaptor_test.go | 48 +++
relay/relay_task.go | 6 +-
service/task_polling.go | 6 +-
7 files changed, 738 insertions(+), 16 deletions(-)
diff --git a/docs/plugin-api/v1.md b/docs/plugin-api/v1.md
index 62fba917a100..3bdf00b9988f 100644
--- a/docs/plugin-api/v1.md
+++ b/docs/plugin-api/v1.md
@@ -139,4 +139,4 @@ Protocol media uses host-injected `ctx.artifacts[key].url`. Provider URLs from `
The persisted field remains `task.data`; there is no `task.raw` alias. Driver hooks (`buildSubmitRequest`, `parseSubmitResponse`, query/result, usage, artifact, and content hooks) stay flat and must not branch on the client path or protocol.
-`ctx.model` is the billing and display identity (the origin name the client sent, including a channel-mapping alias). `ctx.upstreamModel` is the machine identity after channel `model_mapping`. Rate tables and model-keyed usage facts must use `ctx.upstreamModel || ctx.model`. Decode and render hooks that echo the client model must keep `ctx.model`. `buildSubmitRequest` must not set descriptor top-level `model` on a mapped pin; the host requires the plugin to echo the alias verbatim.
+`ctx.model` is the billing and display identity (the origin name the client sent, including a channel-mapping alias). `ctx.upstreamModel` is the machine identity after channel `model_mapping`. Rate tables and model-keyed usage facts must use `ctx.upstreamModel || ctx.model`. Decode and render hooks that echo the client model must keep `ctx.model`. `buildSubmitRequest` must not set descriptor top-level `model` on a mapped pin; the host requires the plugin to echo the alias verbatim. Background polling has no relay info, so query hooks receive both identities from the persisted task properties, and `ctx.upstreamModel` falls back to `ctx.model` when the task was submitted without a channel mapping.
diff --git a/plugins/hailuo_responses_test.go b/plugins/hailuo_responses_test.go
index decc29a1d162..3bf8bf98a74d 100644
--- a/plugins/hailuo_responses_test.go
+++ b/plugins/hailuo_responses_test.go
@@ -70,3 +70,412 @@ func TestHailuoArtifactContentProxy(t *testing.T) {
assert.Equal(t, map[string]string{"Accept": "video/*", "Authorization": "Bearer test-ak"}, descriptor.Headers)
assert.False(t, descriptor.Credentialless)
}
+
+func loadHailuoPlugin(t *testing.T) *jsplugin.LoadedPlugin {
+ t.Helper()
+ source, err := builtinplugins.Source("hailuo")
+ require.NoError(t, err)
+ plugin, err := jsplugin.NewRegistry().RegisterFactory(source, jsplugin.Options{Key: "hailuo"})
+ require.NoError(t, err)
+ return plugin
+}
+
+func callHailuoHook(t *testing.T, plugin *jsplugin.LoadedPlugin, hook string, args ...any) map[string]any {
+ t.Helper()
+ value, err := plugin.Engine.Call(t.Context(), hook, args...)
+ require.NoError(t, err)
+ encoded, err := common.Marshal(value)
+ require.NoError(t, err)
+ var decoded map[string]any
+ require.NoError(t, common.Unmarshal(encoded, &decoded))
+ return decoded
+}
+
+func hailuoH3SubmitContext(requestBody map[string]any) map[string]any {
+ return map[string]any{
+ "requestBody": requestBody,
+ "model": "MiniMax-H3",
+ "upstreamModel": "MiniMax-H3",
+ "baseUrl": "https://api.minimax.example",
+ "apiKey": "test-ak",
+ }
+}
+
+// MiniMax-H3 submits to /v2/video_generation with a multimodal content array
+// instead of the flat /v1 frame fields.
+func TestHailuoH3BuildSubmitRequest(t *testing.T) {
+ plugin := loadHailuoPlugin(t)
+ testCases := []struct {
+ name string
+ request map[string]any
+ wantBody string
+ wantAction string
+ }{
+ {
+ name: "text to video defaults duration ratio and resolution",
+ request: map[string]any{"prompt": "a boy playing basketball"},
+ wantBody: `{"model":"MiniMax-H3","content":[{"type":"text","text":"a boy playing basketball"}],"resolution":"768P","duration":5,"ratio":"16:9"}`,
+ wantAction: "text_to_video",
+ },
+ {
+ name: "2K resolution from size",
+ request: map[string]any{"prompt": "p", "duration": 15, "size": "2K"},
+ wantBody: `{"model":"MiniMax-H3","content":[{"type":"text","text":"p"}],"resolution":"2K","duration":15,"ratio":"16:9"}`,
+ wantAction: "text_to_video",
+ },
+ {
+ name: "first and last frame from metadata",
+ request: map[string]any{"prompt": "p", "metadata": map[string]any{"first_frame_image": "first.png", "last_frame_image": "last.png"}},
+ wantBody: `{"model":"MiniMax-H3","content":[
+ {"type":"text","text":"p"},
+ {"type":"image_url","role":"first_frame","image_url":{"url":"first.png"}},
+ {"type":"image_url","role":"last_frame","image_url":{"url":"last.png"}}],
+ "resolution":"768P","duration":5,"ratio":"adaptive"}`,
+ wantAction: "image_to_video",
+ },
+ {
+ name: "frames from the images array",
+ request: map[string]any{"prompt": "p", "images": []any{"first.png", "last.png"}},
+ wantBody: `{"model":"MiniMax-H3","content":[
+ {"type":"text","text":"p"},
+ {"type":"image_url","role":"first_frame","image_url":{"url":"first.png"}},
+ {"type":"image_url","role":"last_frame","image_url":{"url":"last.png"}}],
+ "resolution":"768P","duration":5,"ratio":"adaptive"}`,
+ wantAction: "image_to_video",
+ },
+ {
+ name: "reference video and audio",
+ request: map[string]any{"prompt": "p", "metadata": map[string]any{
+ "reference_video": "ref.mp4",
+ "reference_audio": []any{"a.mp3", "b.mp3"},
+ }},
+ wantBody: `{"model":"MiniMax-H3","content":[
+ {"type":"text","text":"p"},
+ {"type":"video_url","role":"reference_video","video_url":{"url":"ref.mp4"}},
+ {"type":"audio_url","role":"reference_audio","audio_url":{"url":"a.mp3"}},
+ {"type":"audio_url","role":"reference_audio","audio_url":{"url":"b.mp3"}}],
+ "resolution":"768P","duration":5,"ratio":"adaptive"}`,
+ wantAction: "image_to_video",
+ },
+ {
+ name: "content passthrough prepends the prompt when no text item exists",
+ request: map[string]any{"prompt": "p", "metadata": map[string]any{"content": []any{
+ map[string]any{"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "img.png"}},
+ }}},
+ wantBody: `{"model":"MiniMax-H3","content":[
+ {"type":"text","text":"p"},
+ {"type":"image_url","role":"reference_image","image_url":{"url":"img.png"}}],
+ "resolution":"768P","duration":5,"ratio":"adaptive"}`,
+ wantAction: "image_to_video",
+ },
+ {
+ name: "content passthrough keeps an existing text item",
+ request: map[string]any{"prompt": "ignored", "metadata": map[string]any{"content": []any{
+ map[string]any{"type": "text", "text": "kept"},
+ }}},
+ wantBody: `{"model":"MiniMax-H3","content":[{"type":"text","text":"kept"}],"resolution":"768P","duration":5,"ratio":"16:9"}`,
+ wantAction: "text_to_video",
+ },
+ {
+ name: "explicit ratio callback and watermark",
+ request: map[string]any{"prompt": "p", "duration": 4, "metadata": map[string]any{
+ "ratio": "9:16",
+ "callback_url": "https://example.com/cb",
+ "aigc_watermark": true,
+ }},
+ wantBody: `{"model":"MiniMax-H3","content":[{"type":"text","text":"p"}],"resolution":"768P","duration":4,"ratio":"9:16","callback_url":"https://example.com/cb","aigc_watermark":true}`,
+ wantAction: "text_to_video",
+ },
+ }
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ descriptor := callHailuoHook(t, plugin, "buildSubmitRequest", hailuoH3SubmitContext(testCase.request))
+ assert.Equal(t, "https://api.minimax.example/v2/video_generation", descriptor["url"])
+ assert.Equal(t, "POST", descriptor["method"])
+ assert.Equal(t, testCase.wantAction, descriptor["action"])
+ body, err := common.Marshal(descriptor["body"])
+ require.NoError(t, err)
+ assert.JSONEq(t, testCase.wantBody, string(body))
+ })
+ }
+}
+
+// Every MiniMax-H3 request bound is rejected before the upstream call, so an
+// out-of-range duration can never reach quota calculation as a billing fact.
+func TestHailuoH3RejectsOutOfContractRequests(t *testing.T) {
+ plugin := loadHailuoPlugin(t)
+ tenReferenceImages := make([]any, 0, 10)
+ for i := 0; i < 10; i++ {
+ tenReferenceImages = append(tenReferenceImages, map[string]any{
+ "type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "u"},
+ })
+ }
+ testCases := []struct {
+ name string
+ request map[string]any
+ wantErr string
+ }{
+ {"duration below the minimum", map[string]any{"prompt": "p", "duration": 3}, "duration must be an integer between 4 and 15"},
+ {"duration above the maximum", map[string]any{"prompt": "p", "duration": 16}, "duration must be an integer between 4 and 15"},
+ {"fractional duration", map[string]any{"prompt": "p", "duration": 5.5}, "duration must be an integer between 4 and 15"},
+ {"unsupported resolution", map[string]any{"prompt": "p", "size": "1080P"}, "resolution must be 768P or 2K"},
+ {"unknown ratio", map[string]any{"prompt": "p", "metadata": map[string]any{"ratio": "16:10"}}, "ratio must be one of"},
+ {"adaptive ratio without a visual input", map[string]any{"prompt": "p", "metadata": map[string]any{"ratio": "adaptive"}}, "ratio adaptive requires an image or video input"},
+ {"too many frame images", map[string]any{"prompt": "p", "images": []any{"a.png", "b.png", "c.png"}}, "at most 2 frame images"},
+ {"media without text", map[string]any{"images": []any{"a.png"}}, "requires a non-empty text item"},
+ {"content is not an array", map[string]any{"prompt": "p", "metadata": map[string]any{"content": "nope"}}, "metadata.content must be an array"},
+ {
+ name: "multiple first frame roles",
+ request: map[string]any{"prompt": "p", "metadata": map[string]any{"content": []any{
+ map[string]any{"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "u1"}},
+ map[string]any{"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "u2"}},
+ }}},
+ wantErr: "at most one first_frame image",
+ },
+ {
+ name: "passthrough mixes frame and reference media",
+ request: map[string]any{"prompt": "p", "metadata": map[string]any{"content": []any{
+ map[string]any{"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "frame"}},
+ map[string]any{"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "reference"}},
+ }}},
+ wantErr: "cannot mix frame images with reference media",
+ },
+ {
+ name: "assembled content mixes frame and reference media",
+ request: map[string]any{"prompt": "p", "images": []any{"frame"}, "metadata": map[string]any{
+ "reference_video": "reference.mp4",
+ }},
+ wantErr: "cannot mix frame images with reference media",
+ },
+ {
+ name: "too many reference videos",
+ request: map[string]any{"prompt": "p", "metadata": map[string]any{"content": []any{
+ map[string]any{"type": "video_url", "video_url": map[string]any{"url": "u1"}},
+ map[string]any{"type": "video_url", "video_url": map[string]any{"url": "u2"}},
+ map[string]any{"type": "video_url", "video_url": map[string]any{"url": "u3"}},
+ map[string]any{"type": "video_url", "video_url": map[string]any{"url": "u4"}},
+ }}},
+ wantErr: "at most 3 reference videos",
+ },
+ {
+ name: "too many reference images",
+ request: map[string]any{"prompt": "p", "metadata": map[string]any{"content": tenReferenceImages}},
+ wantErr: "at most 9 reference images",
+ },
+ {
+ name: "too many reference audios",
+ request: map[string]any{"prompt": "p", "metadata": map[string]any{"reference_audio": []any{"a.mp3", "b.mp3", "c.mp3", "d.mp3"}}},
+ wantErr: "at most 3 reference audios",
+ },
+ {"empty input", map[string]any{"prompt": " "}, "requires a prompt or a media input"},
+ }
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ _, err := plugin.Engine.Call(t.Context(), "buildSubmitRequest", hailuoH3SubmitContext(testCase.request))
+ require.ErrorContains(t, err, testCase.wantErr)
+ })
+ }
+}
+
+// The /v1 models keep the flat request shape and endpoint.
+func TestHailuoLegacySubmitRequestUnchanged(t *testing.T) {
+ plugin := loadHailuoPlugin(t)
+ ctx := hailuoH3SubmitContext(map[string]any{"prompt": "p", "duration": 10, "size": "768P"})
+ ctx["model"] = "MiniMax-Hailuo-2.3"
+ ctx["upstreamModel"] = "MiniMax-Hailuo-2.3"
+ descriptor := callHailuoHook(t, plugin, "buildSubmitRequest", ctx)
+ assert.Equal(t, "https://api.minimax.example/v1/video_generation", descriptor["url"])
+ body, err := common.Marshal(descriptor["body"])
+ require.NoError(t, err)
+ assert.JSONEq(t, `{"model":"MiniMax-Hailuo-2.3","prompt":"p","duration":10,"resolution":"768P"}`, string(body))
+}
+
+func TestHailuoQueryRequestEndpointByModel(t *testing.T) {
+ plugin := loadHailuoPlugin(t)
+ testCases := []struct {
+ name string
+ ctx map[string]any
+ want string
+ }{
+ {
+ name: "H3 uses the v2 path parameter",
+ ctx: map[string]any{"taskId": "task/1", "upstreamModel": "MiniMax-H3"},
+ want: "https://api.minimax.example/v2/query/video_generation/task%2F1",
+ },
+ {
+ name: "an unmapped task falls back to the origin model",
+ ctx: map[string]any{"taskId": "t1", "model": "MiniMax-H3"},
+ want: "https://api.minimax.example/v2/query/video_generation/t1",
+ },
+ {
+ name: "legacy models keep the v1 query parameter",
+ ctx: map[string]any{"taskId": "t1", "upstreamModel": "MiniMax-Hailuo-2.3"},
+ want: "https://api.minimax.example/v1/query/video_generation?task_id=t1",
+ },
+ {
+ name: "a channel-mapped alias resolves through the upstream model",
+ ctx: map[string]any{"taskId": "t1", "model": "h3", "upstreamModel": "MiniMax-H3"},
+ want: "https://api.minimax.example/v2/query/video_generation/t1",
+ },
+ }
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ testCase.ctx["baseUrl"] = "https://api.minimax.example"
+ testCase.ctx["apiKey"] = "test-ak"
+ descriptor := callHailuoHook(t, plugin, "buildQueryRequest", testCase.ctx)
+ assert.Equal(t, testCase.want, descriptor["url"])
+ assert.Equal(t, "GET", descriptor["method"])
+ })
+ }
+}
+
+func TestHailuoParseTaskResult(t *testing.T) {
+ plugin := loadHailuoPlugin(t)
+ testCases := []struct {
+ name string
+ body string
+ wantStatus string
+ wantURL string
+ wantReason string
+ }{
+ {"H3 queued", `{"task":{"id":"1","status":"queued"}}`, "QUEUED", "", ""},
+ {"H3 running", `{"task":{"id":"1","status":"running"}}`, "IN_PROGRESS", "", ""},
+ {"H3 succeeded", `{"task":{"id":"1","status":"succeeded","content":{"url":"https://cdn.example/h3.mp4"}}}`, "SUCCESS", "https://cdn.example/h3.mp4", ""},
+ {"H3 failed", `{"task":{"id":"1","status":"failed","error":{"code":"1026","message":"sensitive content"}}}`, "FAILURE", "", "sensitive content"},
+ {"H3 cancelled", `{"task":{"id":"1","status":"cancelled"}}`, "FAILURE", "", "task cancelled"},
+ {"H3 permanent query error", `{"type":"error","error":{"type":"authorized_error","message":"login failed","http_code":"401"}}`, "FAILURE", "", "login failed"},
+ {"legacy success", `{"task_id":"1","status":"Success","file_id":"f1","base_resp":{"status_code":0}}`, "SUCCESS", "", ""},
+ {"legacy processing", `{"task_id":"1","status":"Processing","base_resp":{"status_code":0}}`, "IN_PROGRESS", "", ""},
+ }
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ var body any
+ require.NoError(t, common.UnmarshalJsonStr(testCase.body, &body))
+ result := callHailuoHook(t, plugin, "parseTaskResult", map[string]any{}, body)
+ assert.Equal(t, testCase.wantStatus, result["status"])
+ assert.Equal(t, testCase.wantURL, common.Interface2String(result["url"]))
+ assert.Equal(t, testCase.wantReason, common.Interface2String(result["reason"]))
+ })
+ }
+ t.Run("H3 retryable query error", func(t *testing.T) {
+ var body any
+ require.NoError(t, common.UnmarshalJsonStr(
+ `{"type":"error","error":{"type":"rate_limit_error","message":"retry later","http_code":"429"}}`, &body))
+ _, err := plugin.Engine.Call(t.Context(), "parseTaskResult", map[string]any{}, body)
+ require.ErrorContains(t, err, "retry later")
+ })
+}
+
+func TestHailuoExtractUsageFacts(t *testing.T) {
+ plugin := loadHailuoPlugin(t)
+ testCases := []struct {
+ name string
+ model string
+ request map[string]any
+ want map[string]any
+ }{
+ {"H3 defaults", "MiniMax-H3", map[string]any{"prompt": "p"}, map[string]any{"seconds": float64(5), "resolution": "768P"}},
+ {"H3 2K", "MiniMax-H3", map[string]any{"prompt": "p", "duration": 12, "size": "2K"}, map[string]any{"seconds": float64(12), "resolution": "2K"}},
+ {"legacy model", "MiniMax-Hailuo-2.3", map[string]any{"prompt": "p", "duration": 10}, map[string]any{"seconds": float64(10), "resolution": "768P"}},
+ }
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ ctx := hailuoH3SubmitContext(testCase.request)
+ ctx["model"] = testCase.model
+ ctx["upstreamModel"] = testCase.model
+ assert.Equal(t, testCase.want, callHailuoHook(t, plugin, "extractUsage", ctx))
+ })
+ }
+}
+
+// The /v2 result is a public CDN URL, so its artifact is proxied without
+// channel credentials instead of through the /v1 file download endpoint.
+func TestHailuoH3ArtifactContentProxy(t *testing.T) {
+ source, err := builtinplugins.Source("hailuo")
+ require.NoError(t, err)
+ plugin, err := jsplugin.NewRegistry().RegisterFactory(source, jsplugin.Options{Key: "hailuo"})
+ require.NoError(t, err)
+ adaptor := taskplugin.New(plugin)
+ adaptor.Init(&relaycommon.RelayInfo{
+ ChannelMeta: &relaycommon.ChannelMeta{ApiKey: "test-ak", ChannelBaseUrl: "https://api.minimax.example"},
+ })
+ task := &model.Task{
+ TaskID: "task-public",
+ Status: model.TaskStatusSuccess,
+ Data: []byte(`{"task":{"id":"1","status":"succeeded","content":{"url":"https://cdn.example/h3.mp4"}}}`),
+ }
+
+ artifacts, err := adaptor.ListArtifacts(task)
+ require.NoError(t, err)
+ assert.Equal(t, []channel.TaskArtifact{{Key: "video", Type: "video", MimeType: "video/mp4"}}, artifacts)
+
+ descriptor, err := adaptor.BuildContentRequest(task, "video", channel.TaskArtifactClientRequest{Method: http.MethodGet})
+ require.NoError(t, err)
+ require.NotNil(t, descriptor)
+ assert.Equal(t, "https://cdn.example/h3.mp4", descriptor.URL)
+ assert.True(t, descriptor.Credentialless)
+
+ pending := &model.Task{TaskID: "task-public", Status: model.TaskStatusInProgress, Data: task.Data}
+ pendingArtifacts, err := adaptor.ListArtifacts(pending)
+ require.NoError(t, err)
+ assert.Empty(t, pendingArtifacts)
+}
+
+// The /v2 create response carries only task_id, without the /v1 base_resp envelope.
+func TestHailuoParseSubmitResponse(t *testing.T) {
+ plugin := loadHailuoPlugin(t)
+ t.Run("H3 create has no envelope", func(t *testing.T) {
+ parsed := callHailuoHook(t, plugin, "parseSubmitResponse", map[string]any{"upstreamModel": "MiniMax-H3"},
+ map[string]any{"body": map[string]any{"task_id": "h3-1"}})
+ assert.Equal(t, "h3-1", parsed["taskId"])
+ })
+ t.Run("H3 rejection reports the envelope message", func(t *testing.T) {
+ _, err := plugin.Engine.Call(t.Context(), "parseSubmitResponse", map[string]any{"upstreamModel": "MiniMax-H3"},
+ map[string]any{"body": map[string]any{"base_resp": map[string]any{"status_code": 2013, "status_msg": "invalid params"}}})
+ require.ErrorContains(t, err, "invalid params")
+ })
+ t.Run("H3 rejection reports the v2 error message", func(t *testing.T) {
+ _, err := plugin.Engine.Call(t.Context(), "parseSubmitResponse", map[string]any{"upstreamModel": "MiniMax-H3"},
+ map[string]any{"body": map[string]any{"type": "error", "error": map[string]any{
+ "type": "bad_request_error", "message": "content requires text", "http_code": "400",
+ }}})
+ require.ErrorContains(t, err, "content requires text")
+ })
+ t.Run("legacy create", func(t *testing.T) {
+ parsed := callHailuoHook(t, plugin, "parseSubmitResponse", map[string]any{"upstreamModel": "MiniMax-Hailuo-2.3"},
+ map[string]any{"body": map[string]any{"task_id": "v1-1", "base_resp": map[string]any{"status_code": 0}}})
+ assert.Equal(t, "v1-1", parsed["taskId"])
+ })
+ t.Run("legacy response without an envelope is rejected", func(t *testing.T) {
+ ctx := map[string]any{"upstreamModel": "MiniMax-Hailuo-2.3"}
+ _, err := plugin.Engine.Call(t.Context(), "parseSubmitResponse", ctx,
+ map[string]any{"body": map[string]any{"task_id": "v1-1"}})
+ require.ErrorContains(t, err, "hailuo submit failed")
+ })
+ t.Run("legacy error", func(t *testing.T) {
+ _, err := plugin.Engine.Call(t.Context(), "parseSubmitResponse", map[string]any{"upstreamModel": "MiniMax-Hailuo-2.3"},
+ map[string]any{"body": map[string]any{"base_resp": map[string]any{"status_code": 1026, "status_msg": "sensitive"}}})
+ require.ErrorContains(t, err, "sensitive")
+ })
+}
+
+// Without an H3 exemption the shared /v1 combo table would reject every H3
+// duration on the OpenAI Video path before the request is ever built.
+func TestHailuoH3PassesOpenAIVideoDecode(t *testing.T) {
+ plugin := loadHailuoPlugin(t)
+ value, err := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_video", "decodeRequest"},
+ map[string]any{
+ "body": map[string]any{"kind": "json", "value": map[string]any{"model": "MiniMax-H3", "prompt": "p", "seconds": 12, "size": "2K"}},
+ "model": "MiniMax-H3",
+ "upstreamModel": "MiniMax-H3",
+ })
+ require.NoError(t, err)
+ encoded, err := common.Marshal(value)
+ require.NoError(t, err)
+ var intent map[string]any
+ require.NoError(t, common.Unmarshal(encoded, &intent))
+ assert.Equal(t, "submit", intent["kind"])
+ requestBody, ok := intent["requestBody"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, float64(12), requestBody["duration"])
+}
diff --git a/plugins/tasks/hailuo/plugin.js b/plugins/tasks/hailuo/plugin.js
index b26397609e84..e43f0901beb0 100644
--- a/plugins/tasks/hailuo/plugin.js
+++ b/plugins/tasks/hailuo/plugin.js
@@ -4,13 +4,14 @@ export const meta = {
name: "Hailuo Video",
icon: "Hailuo.Color",
description: {
- en: "MiniMax Hailuo video generation (text-to-video and image-to-video)",
- zh: "MiniMax 海螺视频生成(文生视频、图生视频)",
+ en: "MiniMax Hailuo video generation (text-to-video, image-to-video, and MiniMax-H3 multimodal reference)",
+ zh: "MiniMax 海螺视频生成(文生视频、图生视频、MiniMax-H3 多模态参考生视频)",
},
- version: "1.0.0",
+ version: "1.1.0",
author: { name: "QuantumNous" },
channelTypes: [35],
models: [
+ "MiniMax-H3",
"MiniMax-Hailuo-2.3",
"MiniMax-Hailuo-2.3-Fast",
"MiniMax-Hailuo-02",
@@ -27,12 +28,12 @@ export const meta = {
type: "number",
unit: "second",
description: {
- en: "Requested video duration in seconds. Hailuo 2.3/02/2.3-Fast allow 6 or 10; 01-series allow 6.",
- zh: "请求的视频时长,单位为秒。Hailuo 2.3/02/2.3-Fast 允许 6 或 10;01 系列允许 6。",
+ en: "Requested video duration in seconds. MiniMax-H3 allows 4 to 15; Hailuo 2.3/02/2.3-Fast allow 6 or 10; 01-series allow 6.",
+ zh: "请求的视频时长,单位为秒。MiniMax-H3 允许 4 到 15;Hailuo 2.3/02/2.3-Fast 允许 6 或 10;01 系列允许 6。",
},
},
resolution: {
- enum: ["512P", "768P", "720P", "1080P"],
+ enum: ["512P", "768P", "720P", "1080P", "2K"],
description: { en: "Requested output video resolution.", zh: "请求的输出视频分辨率。" },
},
},
@@ -43,6 +44,8 @@ export const meta = {
{ label: "02 512P 6s", facts: { seconds: 6, resolution: "512P" } },
{ label: "02 512P 10s", facts: { seconds: 10, resolution: "512P" } },
{ label: "01-series 720P 6s", facts: { seconds: 6, resolution: "720P" } },
+ { label: "H3 768P 5s", facts: { seconds: 5, resolution: "768P" } },
+ { label: "H3 2K 5s", facts: { seconds: 5, resolution: "2K" } },
],
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
};
@@ -96,9 +99,186 @@ function hasHailuoImage(req, hasInputReferenceFile) {
);
}
+const H3_MODEL = "MiniMax-H3";
+const H3_MIN_DURATION = 4;
+const H3_MAX_DURATION = 15;
+const H3_DEFAULT_DURATION = 5;
+const H3_MAX_FRAME_IMAGES = 2;
+const H3_MAX_REFERENCE_IMAGES = 9;
+const H3_MAX_REFERENCE_VIDEOS = 3;
+const H3_MAX_REFERENCE_AUDIOS = 3;
+const H3_RATIOS = ["adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16"];
+
+// MiniMax-H3 speaks the /v2 video generation contract: a multimodal `content`
+// array instead of flat frame fields, an explicit `ratio`, 768P/2K resolutions,
+// a task id path parameter on query, and a `{task: {...}}` query envelope.
+function isH3(model) {
+ return model === H3_MODEL;
+}
+
+function h3Duration(req) {
+ const raw = req.duration;
+ if (raw === undefined || raw === null || raw === "") return H3_DEFAULT_DURATION;
+ const seconds = Number(raw);
+ if (!Number.isInteger(seconds) || seconds < H3_MIN_DURATION || seconds > H3_MAX_DURATION) {
+ throw new Error(H3_MODEL + " duration must be an integer between " + H3_MIN_DURATION + " and " + H3_MAX_DURATION + " seconds");
+ }
+ return seconds;
+}
+
+function h3Resolution(req) {
+ const metadata = req.metadata || {};
+ const raw = trimmed(metadata.resolution) || trimmed(req.resolution) || trimmed(req.size);
+ if (!raw) return "768P";
+ const value = raw.toUpperCase();
+ if (value.includes("2K")) return "2K";
+ if (value.includes("768")) return "768P";
+ throw new Error(H3_MODEL + " resolution must be 768P or 2K");
+}
+
+function h3MediaItem(type, url, role) {
+ const item = { type: type, role: role };
+ item[type] = { url: url };
+ return item;
+}
+
+// Accepts a single value or an array; file placeholders stay objects and are
+// resolved by the host after the body is built.
+function h3MediaList(source, key) {
+ const raw = source[key];
+ if (raw === undefined || raw === null) return [];
+ const values = Array.isArray(raw) ? raw : [raw];
+ return values.filter(function (value) {
+ return value && typeof value === "object" ? true : Boolean(trimmed(value));
+ });
+}
+
+function h3FrameImages(req) {
+ const metadata = req.metadata || {};
+ const images = h3MediaList(req, "images");
+ if (images.length > H3_MAX_FRAME_IMAGES) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_FRAME_IMAGES + " frame images");
+ const frames = [];
+ if (metadata.first_frame_image) frames.push(h3MediaItem("image_url", metadata.first_frame_image, "first_frame"));
+ if (metadata.last_frame_image) frames.push(h3MediaItem("image_url", metadata.last_frame_image, "last_frame"));
+ if (frames.length) return frames;
+ return images.map(function (url, index) {
+ return h3MediaItem("image_url", url, index === 0 ? "first_frame" : "last_frame");
+ });
+}
+
+function validateH3Content(items) {
+ let hasText = false;
+ let hasFrame = false;
+ let hasReference = false;
+ let firstFrames = 0;
+ let lastFrames = 0;
+ let referenceImages = 0;
+ let referenceVideos = 0;
+ let referenceAudios = 0;
+ for (const item of items) {
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
+ const role = trimmed(item.role);
+ if (item.type === "text" && trimmed(item.text)) {
+ hasText = true;
+ continue;
+ }
+ if (item.type === "image_url") {
+ if (!role || role === "first_frame") {
+ firstFrames += 1;
+ hasFrame = true;
+ } else if (role === "last_frame") {
+ lastFrames += 1;
+ hasFrame = true;
+ } else if (role === "middle_frame") {
+ hasFrame = true;
+ } else if (role === "reference_image") {
+ referenceImages += 1;
+ hasReference = true;
+ }
+ continue;
+ }
+ if (item.type === "video_url") {
+ referenceVideos += 1;
+ hasReference = true;
+ continue;
+ }
+ if (item.type === "audio_url") {
+ referenceAudios += 1;
+ hasReference = true;
+ }
+ }
+ if (!hasText) throw new Error(H3_MODEL + " requires a non-empty text item");
+ if (firstFrames > 1) throw new Error(H3_MODEL + " accepts at most one first_frame image");
+ if (lastFrames > 1) throw new Error(H3_MODEL + " accepts at most one last_frame image");
+ if (referenceImages > H3_MAX_REFERENCE_IMAGES) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_REFERENCE_IMAGES + " reference images");
+ if (referenceVideos > H3_MAX_REFERENCE_VIDEOS) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_REFERENCE_VIDEOS + " reference videos");
+ if (referenceAudios > H3_MAX_REFERENCE_AUDIOS) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_REFERENCE_AUDIOS + " reference audios");
+ if (hasFrame && hasReference) throw new Error(H3_MODEL + " cannot mix frame images with reference media");
+ return items;
+}
+
+// metadata.content is the full multimodal passthrough; otherwise the content
+// array is assembled from prompt, frame images, and reference media.
+function h3Content(req) {
+ const metadata = req.metadata || {};
+ const prompt = trimmed(req.prompt);
+ if (metadata.content !== undefined && metadata.content !== null) {
+ if (!Array.isArray(metadata.content)) throw new Error("metadata.content must be an array");
+ const items = metadata.content;
+ const hasText = items.some(function (item) {
+ return item && item.type === "text" && trimmed(item.text);
+ });
+ if (hasText) return validateH3Content(items);
+ if (!prompt) throw new Error(H3_MODEL + " metadata.content requires a text item or a prompt");
+ return validateH3Content([{ type: "text", text: prompt }].concat(items));
+ }
+ const content = prompt ? [{ type: "text", text: prompt }] : [];
+ for (const frame of h3FrameImages(req)) content.push(frame);
+ const videos = h3MediaList(metadata, "reference_video");
+ if (videos.length > H3_MAX_REFERENCE_VIDEOS) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_REFERENCE_VIDEOS + " reference videos");
+ for (const video of videos) content.push(h3MediaItem("video_url", video, "reference_video"));
+ const audios = h3MediaList(metadata, "reference_audio");
+ if (audios.length > H3_MAX_REFERENCE_AUDIOS) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_REFERENCE_AUDIOS + " reference audios");
+ for (const audio of audios) content.push(h3MediaItem("audio_url", audio, "reference_audio"));
+ if (!content.length) throw new Error(H3_MODEL + " requires a prompt or a media input");
+ return validateH3Content(content);
+}
+
+function h3HasVisualContent(content) {
+ return content.some(function (item) {
+ return item && (item.type === "image_url" || item.type === "video_url");
+ });
+}
+
+// ratio is mandatory upstream and `adaptive` is only meaningful when the
+// aspect ratio can be inherited from a visual input.
+function h3Ratio(req, content) {
+ const metadata = req.metadata || {};
+ const ratio = trimmed(metadata.ratio);
+ if (!ratio) return h3HasVisualContent(content) ? "adaptive" : "16:9";
+ if (!H3_RATIOS.includes(ratio)) throw new Error(H3_MODEL + " ratio must be one of " + H3_RATIOS.join(", "));
+ if (ratio === "adaptive" && !h3HasVisualContent(content)) throw new Error(H3_MODEL + " ratio adaptive requires an image or video input");
+ return ratio;
+}
+
+function h3QueryTask(body) {
+ const task = body && typeof body === "object" && !Array.isArray(body) ? body.task : null;
+ return task && typeof task === "object" && !Array.isArray(task) ? task : null;
+}
+
+function h3APIError(body) {
+ const error = body && typeof body === "object" && !Array.isArray(body) ? body.error : null;
+ if (!error || typeof error !== "object" || Array.isArray(error)) return null;
+ const message = trimmed(error.message);
+ if (!message) return null;
+ const statusCode = Number(error.http_code || error.code || 0);
+ return { message: message, statusCode: Number.isInteger(statusCode) ? statusCode : 0 };
+}
+
// Older T2V-01*/I2V-01*/S2V-01 official tables disagree on 1080P support (research: 未验证).
// Keep those models permissive: duration 6 only, resolution optional.
function validateHailuoCombo(model, duration, resolution, hasImage) {
+ if (isH3(model)) return;
if (model === "MiniMax-Hailuo-2.3-Fast" && !hasImage) {
throw new Error("MiniMax-Hailuo-2.3-Fast supports image-to-video only");
}
@@ -170,6 +350,26 @@ export function buildSubmitRequest(ctx) {
const req = ctx.requestBody || {};
const model = ctx.upstreamModel;
const metadata = req.metadata || {};
+ if (isH3(model)) {
+ const content = h3Content(req);
+ const h3Body = {
+ model: model,
+ content: content,
+ resolution: h3Resolution(req),
+ duration: h3Duration(req),
+ ratio: h3Ratio(req, content),
+ };
+ ["callback_url", "aigc_watermark"].forEach(function (key) {
+ if (metadata[key] !== undefined && metadata[key] !== null) h3Body[key] = metadata[key];
+ });
+ return {
+ url: ctx.baseUrl + "/v2/video_generation",
+ method: "POST",
+ headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: "Bearer " + ctx.apiKey },
+ body: h3Body,
+ action: h3HasVisualContent(content) ? "image_to_video" : "text_to_video",
+ };
+ }
const body = {
model: model,
prompt: req.prompt || undefined,
@@ -192,8 +392,16 @@ export function buildSubmitRequest(ctx) {
export function parseSubmitResponse(ctx, resp) {
const body = resp.body || {};
- const base = body.base_resp || {};
- if (base.status_code !== 0) throw new Error(base.status_msg || "hailuo submit failed");
+ const apiError = isH3(ctx.upstreamModel) ? h3APIError(body) : null;
+ if (apiError) throw new Error(apiError.message);
+ const base = body.base_resp;
+ // /v1 always wraps the create response in a base_resp envelope; /v2 returns a
+ // bare task_id and only adds base_resp when the call is rejected.
+ if (base) {
+ if (base.status_code !== 0) throw new Error(base.status_msg || "hailuo submit failed");
+ } else if (!isH3(ctx.upstreamModel)) {
+ throw new Error("hailuo submit failed");
+ }
if (!body.task_id) throw new Error("missing task_id");
return { taskId: body.task_id, taskData: body };
}
@@ -202,18 +410,45 @@ export function extractUsage(ctx) {
if (ctx.usagePurpose === "billing_ratios") return null;
const req = ctx.requestBody || {};
const model = ctx.upstreamModel || req.model;
+ if (isH3(model)) return { seconds: h3Duration(req), resolution: h3Resolution(req) };
return { seconds: outboundDuration(req), resolution: outboundResolution(req, model) };
}
export function buildQueryRequest(ctx) {
+ // Polling carries no relay info; the host fills these identities from the
+ // persisted task properties.
+ const path = isH3(ctx.upstreamModel || ctx.model)
+ ? "/v2/query/video_generation/" + encodeURIComponent(ctx.taskId)
+ : "/v1/query/video_generation?task_id=" + encodeURIComponent(ctx.taskId);
return {
- url: ctx.baseUrl + "/v1/query/video_generation?task_id=" + encodeURIComponent(ctx.taskId),
+ url: ctx.baseUrl + path,
method: "GET",
headers: { Accept: "application/json", Authorization: "Bearer " + ctx.apiKey },
};
}
export function parseTaskResult(ctx, body) {
+ // The host calls this hook with an empty context, so the response envelope is
+ // the only way to tell a /v2 result from a /v1 one.
+ const apiError = h3APIError(body);
+ if (apiError) {
+ if (apiError.statusCode === 408 || apiError.statusCode === 429 || apiError.statusCode >= 500) throw new Error(apiError.message);
+ return { code: apiError.statusCode, status: "FAILURE", progress: "100%", reason: apiError.message };
+ }
+ const h3Task = h3QueryTask(body);
+ if (h3Task) {
+ const h3Statuses = { queued: "QUEUED", running: "IN_PROGRESS", succeeded: "SUCCESS", failed: "FAILURE", cancelled: "FAILURE" };
+ const h3Status = h3Statuses[h3Task.status] || "IN_PROGRESS";
+ const h3Result = { code: 0, status: h3Status, progress: h3Status === "QUEUED" ? "30%" : h3Status === "IN_PROGRESS" ? "50%" : "100%" };
+ if (h3Status === "SUCCESS") {
+ const url = trimmed(h3Task.content && h3Task.content.url);
+ if (url) h3Result.url = url;
+ }
+ if (h3Status === "FAILURE") {
+ h3Result.reason = trimmed(h3Task.error && h3Task.error.message) || "task " + trimmed(h3Task.status);
+ }
+ return h3Result;
+ }
const base = body.base_resp || {};
const statuses = { Preparing: "IN_PROGRESS", Queueing: "IN_PROGRESS", Processing: "IN_PROGRESS", Success: "SUCCESS", Fail: "FAILURE" };
const status = statuses[body.status] || "IN_PROGRESS";
@@ -232,14 +467,25 @@ function artifactFileID(ctx) {
return trimmed(artifactData(ctx).file_id);
}
+// /v2 tasks expose a public CDN URL instead of a downloadable file id.
+function h3ArtifactURL(ctx) {
+ const task = h3QueryTask(artifactData(ctx));
+ return task ? trimmed(task.content && task.content.url) : "";
+}
+
export function listArtifacts(task) {
- return task.status === "SUCCESS" && artifactFileID(task) ? [{ key: "video", type: "video", mimeType: "video/mp4" }] : [];
+ if (task.status !== "SUCCESS") return [];
+ return artifactFileID(task) || h3ArtifactURL(task) ? [{ key: "video", type: "video", mimeType: "video/mp4" }] : [];
}
export function buildContentRequest(ctx) {
if (ctx.artifactKey !== "video") throw new Error("artifact_not_found");
const fileID = artifactFileID(ctx);
- if (!fileID) throw new Error("artifact_not_found");
+ if (!fileID) {
+ const url = h3ArtifactURL(ctx);
+ if (!url) throw new Error("artifact_not_found");
+ return { url: url, method: ctx.clientRequest.method, credentialless: true };
+ }
return {
url: ctx.baseUrl + "/v1/files/download?file_id=" + encodeURIComponent(fileID),
method: ctx.clientRequest.method,
@@ -248,6 +494,11 @@ export function buildContentRequest(ctx) {
}
export function extractUsageOnComplete(_task, _taskResult, body) {
+ const h3Task = h3QueryTask(body);
+ if (h3Task) {
+ const resolution = trimmed(h3Task.resolution).toUpperCase();
+ return resolution === "2K" || resolution === "768P" ? { resolution: resolution } : null;
+ }
const width = Number((body || {}).video_width || 0);
const height = Number((body || {}).video_height || 0);
if (!(width > 0) || !(height > 0)) return null;
diff --git a/relay/channel/task/jsplugin/adaptor.go b/relay/channel/task/jsplugin/adaptor.go
index 5a27413456ff..7492276d5257 100644
--- a/relay/channel/task/jsplugin/adaptor.go
+++ b/relay/channel/task/jsplugin/adaptor.go
@@ -528,6 +528,16 @@ func (a *TaskAdaptor) FetchBatchTasks(baseURL, key string, taskIDs []string, pro
func (a *TaskAdaptor) FetchTask(baseURL, key string, body map[string]any, proxy string) (*http.Response, error) {
ctx := map[string]any{"taskId": body["task_id"], "action": body["action"], "requestBody": body, "baseUrl": baseURL}
+ // Query hooks are driver hooks and must see the same model identities as
+ // submit hooks. Polling has no relay info, so they arrive with the
+ // persisted task properties the caller puts in the fetch body.
+ originModel, _ := body["model"].(string)
+ upstreamModel, _ := body["upstream_model"].(string)
+ if upstreamModel == "" {
+ upstreamModel = originModel
+ }
+ ctx["model"] = originModel
+ ctx["upstreamModel"] = upstreamModel
auth, err := resolveAuth(a.plugin.Meta.Auth, key, proxy)
if err != nil {
return nil, err
diff --git a/relay/channel/task/jsplugin/adaptor_test.go b/relay/channel/task/jsplugin/adaptor_test.go
index 136844f640c2..dd7fa3d85968 100644
--- a/relay/channel/task/jsplugin/adaptor_test.go
+++ b/relay/channel/task/jsplugin/adaptor_test.go
@@ -1208,3 +1208,51 @@ func TestTaskAdaptorBuildSubmitReceivesMappedUpstreamModel(t *testing.T) {
assert.Equal(t, "declared-model", decoded["upstreamModel"])
assert.Equal(t, "declared-model", decoded["model"])
}
+
+// Polling has no relay info, so query hooks can only branch on the model when
+// the host forwards the persisted task identities from the fetch body.
+func TestTaskAdaptorFetchTaskExposesModelIdentities(t *testing.T) {
+ service.InitHttpClient()
+ var requested string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requested = r.URL.RequestURI()
+ _, _ = w.Write([]byte(`{}`))
+ }))
+ defer server.Close()
+
+ source := `
+export const meta = {apiVersion:1,key:"query-model",name:"Query Model",version:"1.0.0",author:{name:"Test"},models:["alias"],fetchMode:"per_task"};
+export function buildSubmitRequest(ctx){return {url:ctx.baseUrl+"/submit"}}
+export function parseSubmitResponse(){return {taskId:"1"}}
+export function buildQueryRequest(ctx){return {url:ctx.baseUrl+"/tasks/"+ctx.model+"/"+ctx.upstreamModel+"/"+ctx.taskId,method:"GET"}}
+export function parseTaskResult(){return {status:"SUCCESS"}}
+`
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+
+ testCases := []struct {
+ name string
+ body map[string]any
+ want string
+ }{
+ {
+ name: "mapped model",
+ body: map[string]any{"task_id": "t1", "model": "alias", "upstream_model": "declared-model"},
+ want: "/tasks/alias/declared-model/t1",
+ },
+ {
+ name: "unmapped model falls back to the origin name",
+ body: map[string]any{"task_id": "t1", "model": "alias"},
+ want: "/tasks/alias/alias/t1",
+ },
+ }
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ resp, fetchErr := adaptor.FetchTask(server.URL, "secret", testCase.body, "")
+ require.NoError(t, fetchErr)
+ require.NoError(t, resp.Body.Close())
+ assert.Equal(t, testCase.want, requested)
+ })
+ }
+}
diff --git a/relay/relay_task.go b/relay/relay_task.go
index ed3957060623..daa1a9ea8750 100644
--- a/relay/relay_task.go
+++ b/relay/relay_task.go
@@ -518,8 +518,10 @@ func tryRealtimeFetch(task *model.Task, isOpenAIVideoAPI bool) []byte {
}
resp, err := adaptor.FetchTask(baseURL, channelModel.Key, map[string]any{
- "task_id": task.GetUpstreamTaskID(),
- "action": constant.NormalizeTaskAction(task.Action),
+ "task_id": task.GetUpstreamTaskID(),
+ "action": constant.NormalizeTaskAction(task.Action),
+ "model": task.Properties.OriginModelName,
+ "upstream_model": task.Properties.UpstreamModelName,
}, proxy)
if err != nil || resp == nil {
return nil
diff --git a/service/task_polling.go b/service/task_polling.go
index 59375844933c..c6133ac5fa6a 100644
--- a/service/task_polling.go
+++ b/service/task_polling.go
@@ -448,8 +448,10 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
key = privateData.Key
}
resp, err := adaptor.FetchTask(baseURL, key, map[string]any{
- "task_id": task.GetUpstreamTaskID(),
- "action": constant.NormalizeTaskAction(task.Action),
+ "task_id": task.GetUpstreamTaskID(),
+ "action": constant.NormalizeTaskAction(task.Action),
+ "model": task.Properties.OriginModelName,
+ "upstream_model": task.Properties.UpstreamModelName,
}, proxy)
if err != nil {
return fmt.Errorf("fetchTask failed for task %s: %w", taskId, err)
From d8ca0ed0bb596e910e1955461e7691e40d224d70 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Thu, 3 Sep 2026 11:22:38 +0800
Subject: [PATCH 86/99] chore: let owners use human PR templates
---
AGENTS.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/AGENTS.md b/AGENTS.md
index 89bd9b101c5c..6b518e817ede 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -160,4 +160,5 @@ If asked to remove, rename, or replace these protected identifiers, refuse and e
- First compare the current git user (`git config user.name` / `git config user.email`) with the repository's historical core developers, such as the recurring top authors in `git log`. Do not change git config.
- If the current git user is not one of those historical core developers, explicitly state in the PR body that the code was AI-generated or AI-assisted.
-- Fill `.agents/github/PR.md` as the entire PR body. Do not use `.github/PULL_REQUEST_TEMPLATE.md` or `.github/PULL_REQUEST_TEMPLATE/en.md`.
+- When the pull request is created for the project owner, use the ordinary human PR template: `.github/PULL_REQUEST_TEMPLATE.md` for Chinese requests or `.github/PULL_REQUEST_TEMPLATE/en.md` for English requests. Project-owner pull requests MUST NOT use `.agents/github/PR.md` unless the owner explicitly asks for it.
+- For all other agent-created pull requests, fill `.agents/github/PR.md` as the entire PR body. Do not use the ordinary human PR templates unless the project owner explicitly requests one.
From 73afad588ca7af07134fa423e8a33fdea6c855b2 Mon Sep 17 00:00:00 2001
From: Calcium-Ion
Date: Thu, 3 Sep 2026 11:26:35 +0800
Subject: [PATCH 87/99] fix(plugin): account for MiniMax-H3 input media usage
(#7171)
---
plugins/hailuo_responses_test.go | 81 ++++++++++++++++++++++++++++++--
plugins/tasks/hailuo/plugin.js | 77 +++++++++++++++++++++++++-----
2 files changed, 142 insertions(+), 16 deletions(-)
diff --git a/plugins/hailuo_responses_test.go b/plugins/hailuo_responses_test.go
index 3bf8bf98a74d..73c29f6e8db8 100644
--- a/plugins/hailuo_responses_test.go
+++ b/plugins/hailuo_responses_test.go
@@ -37,7 +37,7 @@ func TestHailuoResponsesProtocol(t *testing.T) {
"size": "1920x1080",
"metadata": map[string]any{"first_frame_image": "https://cdn.example/frame.png"},
},
- wantUsageKeys: []string{"resolution", "seconds"},
+ wantUsageKeys: []string{"input_images", "input_video_seconds", "resolution", "seconds"},
wantVendorName: "hailuo",
})
}
@@ -368,15 +368,35 @@ func TestHailuoParseTaskResult(t *testing.T) {
func TestHailuoExtractUsageFacts(t *testing.T) {
plugin := loadHailuoPlugin(t)
+ nineReferenceImages := make([]any, 0, 9)
+ for i := 0; i < 9; i++ {
+ nineReferenceImages = append(nineReferenceImages, map[string]any{
+ "type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "image"},
+ })
+ }
testCases := []struct {
name string
model string
request map[string]any
want map[string]any
}{
- {"H3 defaults", "MiniMax-H3", map[string]any{"prompt": "p"}, map[string]any{"seconds": float64(5), "resolution": "768P"}},
- {"H3 2K", "MiniMax-H3", map[string]any{"prompt": "p", "duration": 12, "size": "2K"}, map[string]any{"seconds": float64(12), "resolution": "2K"}},
- {"legacy model", "MiniMax-Hailuo-2.3", map[string]any{"prompt": "p", "duration": 10}, map[string]any{"seconds": float64(10), "resolution": "768P"}},
+ {"H3 defaults", "MiniMax-H3", map[string]any{"prompt": "p"}, map[string]any{
+ "seconds": float64(5), "resolution": "768P", "input_images": float64(0), "input_video_seconds": float64(0),
+ }},
+ {"H3 2K", "MiniMax-H3", map[string]any{"prompt": "p", "duration": 12, "size": "2K"}, map[string]any{
+ "seconds": float64(12), "resolution": "2K", "input_images": float64(0), "input_video_seconds": float64(0),
+ }},
+ {"H3 reference images", "MiniMax-H3", map[string]any{"prompt": "p", "metadata": map[string]any{"content": nineReferenceImages}}, map[string]any{
+ "seconds": float64(5), "resolution": "768P", "input_images": float64(9), "input_video_seconds": float64(0),
+ }},
+ {"H3 reference video reserves total duration limit", "MiniMax-H3", map[string]any{"prompt": "p", "metadata": map[string]any{
+ "reference_video": []any{"one.mp4", "two.mp4", "three.mp4"},
+ }}, map[string]any{
+ "seconds": float64(5), "resolution": "768P", "input_images": float64(0), "input_video_seconds": float64(15),
+ }},
+ {"legacy model", "MiniMax-Hailuo-2.3", map[string]any{"prompt": "p", "duration": 10}, map[string]any{
+ "seconds": float64(10), "resolution": "768P", "input_images": float64(0), "input_video_seconds": float64(0),
+ }},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
@@ -388,6 +408,59 @@ func TestHailuoExtractUsageFacts(t *testing.T) {
}
}
+func TestHailuoH3CompletionUsageFacts(t *testing.T) {
+ plugin := loadHailuoPlugin(t)
+ testCases := []struct {
+ name string
+ body string
+ want map[string]any
+ }{
+ {
+ name: "actual usage replaces submission estimates",
+ body: `{"task":{"id":"1","status":"succeeded","resolution":"2K","usage":{"output_seconds":5,"input_seconds":7.5,"input_image_count":6}}}`,
+ want: map[string]any{"seconds": float64(5), "resolution": "2K", "input_images": float64(6), "input_video_seconds": float64(7.5)},
+ },
+ {
+ name: "zero actual usage is retained for settlement",
+ body: `{"task":{"id":"1","status":"succeeded","resolution":"768P","usage":{"output_seconds":4,"input_seconds":0,"input_image_count":0}}}`,
+ want: map[string]any{"seconds": float64(4), "resolution": "768P", "input_images": float64(0), "input_video_seconds": float64(0)},
+ },
+ {
+ name: "zero output cannot erase the submission reservation",
+ body: `{"task":{"id":"1","status":"succeeded","resolution":"768P","usage":{"output_seconds":0,"input_seconds":0,"input_image_count":0}}}`,
+ want: map[string]any{"resolution": "768P", "input_images": float64(0), "input_video_seconds": float64(0)},
+ },
+ {
+ name: "missing usage leaves submission estimates untouched",
+ body: `{"task":{"id":"1","status":"succeeded","resolution":"768P"}}`,
+ want: map[string]any{"resolution": "768P"},
+ },
+ {
+ name: "out of contract usage cannot become a billing multiplier",
+ body: `{"task":{"id":"1","status":"succeeded","resolution":"2K","usage":{"output_seconds":16,"input_seconds":16,"input_image_count":10}}}`,
+ want: map[string]any{"resolution": "2K"},
+ },
+ }
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ var body any
+ require.NoError(t, common.UnmarshalJsonStr(testCase.body, &body))
+ assert.Equal(t, testCase.want, callHailuoHook(t, plugin, "extractUsageOnComplete", nil, nil, body))
+ })
+ }
+
+ t.Run("polling adaptor carries actual facts into task settlement", func(t *testing.T) {
+ adaptor := taskplugin.New(plugin)
+ result, err := adaptor.ParseTaskResult([]byte(
+ `{"task":{"id":"1","status":"succeeded","resolution":"2K","usage":{"output_seconds":5,"input_seconds":7.5,"input_image_count":6}}}`,
+ ))
+ require.NoError(t, err)
+ assert.Equal(t, map[string]any{
+ "seconds": float64(5), "resolution": "2K", "input_images": float64(6), "input_video_seconds": float64(7.5),
+ }, result.UsageFacts)
+ })
+}
+
// The /v2 result is a public CDN URL, so its artifact is proxied without
// channel credentials instead of through the /v1 file download endpoint.
func TestHailuoH3ArtifactContentProxy(t *testing.T) {
diff --git a/plugins/tasks/hailuo/plugin.js b/plugins/tasks/hailuo/plugin.js
index e43f0901beb0..8a9a2f66bf8c 100644
--- a/plugins/tasks/hailuo/plugin.js
+++ b/plugins/tasks/hailuo/plugin.js
@@ -7,7 +7,7 @@ export const meta = {
en: "MiniMax Hailuo video generation (text-to-video, image-to-video, and MiniMax-H3 multimodal reference)",
zh: "MiniMax 海螺视频生成(文生视频、图生视频、MiniMax-H3 多模态参考生视频)",
},
- version: "1.1.0",
+ version: "1.1.1",
author: { name: "QuantumNous" },
channelTypes: [35],
models: [
@@ -36,16 +36,33 @@ export const meta = {
enum: ["512P", "768P", "720P", "1080P", "2K"],
description: { en: "Requested output video resolution.", zh: "请求的输出视频分辨率。" },
},
+ input_images: {
+ type: "number",
+ unit: "count",
+ description: {
+ en: "H3 input image count (estimated at submit, actual on completion).",
+ zh: "H3 输入图片数量(提交时预估,完成后按实际值)。",
+ },
+ },
+ input_video_seconds: {
+ type: "number",
+ unit: "second",
+ description: {
+ en: "H3 input video duration in seconds (reserved at the request maximum, actual on completion).",
+ zh: "H3 输入视频时长,单位为秒(提交时按请求上限预留,完成后按实际值)。",
+ },
+ },
},
usageExamples: [
- { label: "2.3/02 768P 6s", facts: { seconds: 6, resolution: "768P" } },
- { label: "2.3/02 768P 10s", facts: { seconds: 10, resolution: "768P" } },
- { label: "2.3/02 1080P 6s", facts: { seconds: 6, resolution: "1080P" } },
- { label: "02 512P 6s", facts: { seconds: 6, resolution: "512P" } },
- { label: "02 512P 10s", facts: { seconds: 10, resolution: "512P" } },
- { label: "01-series 720P 6s", facts: { seconds: 6, resolution: "720P" } },
- { label: "H3 768P 5s", facts: { seconds: 5, resolution: "768P" } },
- { label: "H3 2K 5s", facts: { seconds: 5, resolution: "2K" } },
+ { label: "2.3/02 768P 6s", facts: { seconds: 6, resolution: "768P", input_images: 0, input_video_seconds: 0 } },
+ { label: "2.3/02 768P 10s", facts: { seconds: 10, resolution: "768P", input_images: 0, input_video_seconds: 0 } },
+ { label: "2.3/02 1080P 6s", facts: { seconds: 6, resolution: "1080P", input_images: 0, input_video_seconds: 0 } },
+ { label: "02 512P 6s", facts: { seconds: 6, resolution: "512P", input_images: 0, input_video_seconds: 0 } },
+ { label: "02 512P 10s", facts: { seconds: 10, resolution: "512P", input_images: 0, input_video_seconds: 0 } },
+ { label: "01-series 720P 6s", facts: { seconds: 6, resolution: "720P", input_images: 0, input_video_seconds: 0 } },
+ { label: "H3 768P 5s", facts: { seconds: 5, resolution: "768P", input_images: 0, input_video_seconds: 0 } },
+ { label: "H3 2K 5s · 9 images", facts: { seconds: 5, resolution: "2K", input_images: 9, input_video_seconds: 0 } },
+ { label: "H3 2K 5s · input video", facts: { seconds: 5, resolution: "2K", input_images: 0, input_video_seconds: 15 } },
],
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
};
@@ -107,6 +124,7 @@ const H3_MAX_FRAME_IMAGES = 2;
const H3_MAX_REFERENCE_IMAGES = 9;
const H3_MAX_REFERENCE_VIDEOS = 3;
const H3_MAX_REFERENCE_AUDIOS = 3;
+const H3_MAX_INPUT_VIDEO_SECONDS = 15;
const H3_RATIOS = ["adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16"];
// MiniMax-H3 speaks the /v2 video generation contract: a multimodal `content`
@@ -175,6 +193,7 @@ function validateH3Content(items) {
let referenceImages = 0;
let referenceVideos = 0;
let referenceAudios = 0;
+ let inputImages = 0;
for (const item of items) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const role = trimmed(item.role);
@@ -183,6 +202,7 @@ function validateH3Content(items) {
continue;
}
if (item.type === "image_url") {
+ inputImages += 1;
if (!role || role === "first_frame") {
firstFrames += 1;
hasFrame = true;
@@ -211,6 +231,7 @@ function validateH3Content(items) {
if (firstFrames > 1) throw new Error(H3_MODEL + " accepts at most one first_frame image");
if (lastFrames > 1) throw new Error(H3_MODEL + " accepts at most one last_frame image");
if (referenceImages > H3_MAX_REFERENCE_IMAGES) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_REFERENCE_IMAGES + " reference images");
+ if (inputImages > H3_MAX_REFERENCE_IMAGES) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_REFERENCE_IMAGES + " input images");
if (referenceVideos > H3_MAX_REFERENCE_VIDEOS) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_REFERENCE_VIDEOS + " reference videos");
if (referenceAudios > H3_MAX_REFERENCE_AUDIOS) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_REFERENCE_AUDIOS + " reference audios");
if (hasFrame && hasReference) throw new Error(H3_MODEL + " cannot mix frame images with reference media");
@@ -410,8 +431,24 @@ export function extractUsage(ctx) {
if (ctx.usagePurpose === "billing_ratios") return null;
const req = ctx.requestBody || {};
const model = ctx.upstreamModel || req.model;
- if (isH3(model)) return { seconds: h3Duration(req), resolution: h3Resolution(req) };
- return { seconds: outboundDuration(req), resolution: outboundResolution(req, model) };
+ if (isH3(model)) {
+ const content = h3Content(req);
+ return {
+ seconds: h3Duration(req),
+ resolution: h3Resolution(req),
+ input_images: content.filter(function (item) {
+ return item && item.type === "image_url";
+ }).length,
+ // Input URLs do not expose duration. Reserve the documented total limit;
+ // polling replaces it with usage.input_seconds after success.
+ input_video_seconds: content.some(function (item) {
+ return item && item.type === "video_url";
+ })
+ ? H3_MAX_INPUT_VIDEO_SECONDS
+ : 0,
+ };
+ }
+ return { seconds: outboundDuration(req), resolution: outboundResolution(req, model), input_images: 0, input_video_seconds: 0 };
}
export function buildQueryRequest(ctx) {
@@ -497,7 +534,23 @@ export function extractUsageOnComplete(_task, _taskResult, body) {
const h3Task = h3QueryTask(body);
if (h3Task) {
const resolution = trimmed(h3Task.resolution).toUpperCase();
- return resolution === "2K" || resolution === "768P" ? { resolution: resolution } : null;
+ const facts = {};
+ if (resolution === "2K" || resolution === "768P") facts.resolution = resolution;
+ const usage = h3Task.usage && typeof h3Task.usage === "object" && !Array.isArray(h3Task.usage) ? h3Task.usage : {};
+ const fields = [
+ { key: "seconds", value: usage.output_seconds, minimum: H3_MIN_DURATION, maximum: H3_MAX_DURATION, integer: false },
+ { key: "input_images", value: usage.input_image_count, minimum: 0, maximum: H3_MAX_REFERENCE_IMAGES, integer: true },
+ { key: "input_video_seconds", value: usage.input_seconds, minimum: 0, maximum: H3_MAX_INPUT_VIDEO_SECONDS, integer: false },
+ ];
+ // Omit malformed or out-of-contract upstream values so settlement keeps
+ // the bounded submission estimate instead of accepting a new multiplier.
+ for (const field of fields) {
+ if (field.value === undefined || field.value === null || field.value === "") continue;
+ const value = Number(field.value);
+ if (!Number.isFinite(value) || value < field.minimum || value > field.maximum || (field.integer && !Number.isInteger(value))) continue;
+ facts[field.key] = value;
+ }
+ return Object.keys(facts).length ? facts : null;
}
const width = Number((body || {}).video_width || 0);
const height = Number((body || {}).video_height || 0);
From 057f71c2336c3981187b732a9d06f65490e9a946 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Thu, 3 Sep 2026 11:28:33 +0800
Subject: [PATCH 88/99] fix(logs): isolate privileged metadata
---
.gitignore | 2 +
controller/channel-test.go | 2 +-
controller/channel_test_internal_test.go | 9 +-
controller/log.go | 2 +
controller/relay.go | 34 +--
controller/relay_error_log_test.go | 99 ++++++
model/log.go | 105 +++----
model/log_format_test.go | 151 +++++++++-
model/log_other.go | 281 ++++++++++++++++++
model/log_other_test.go | 70 +++++
relay/convert_request_error_test.go | 2 +-
service/billing_usage.go | 14 +-
service/channel_affinity.go | 7 +-
service/log_info_generate.go | 211 ++++++-------
service/midjourney.go | 8 +-
service/quota_saturation_test.go | 18 +-
service/task_billing.go | 73 +++--
service/task_billing_test.go | 19 +-
service/task_plugin_audit.go | 21 +-
service/text_quota.go | 38 +--
service/text_quota_test.go | 35 ++-
service/violation_fee.go | 5 +-
.../__tests__/reject-reason.test.tsx | 101 +++++++
.../components/dialogs/details-dialog.tsx | 4 +-
web/src/features/usage-logs/types.ts | 4 +-
25 files changed, 989 insertions(+), 326 deletions(-)
create mode 100644 controller/relay_error_log_test.go
create mode 100644 model/log_other.go
create mode 100644 model/log_other_test.go
create mode 100644 web/src/features/usage-logs/components/__tests__/reject-reason.test.tsx
diff --git a/.gitignore b/.gitignore
index ff2460948828..042fef9a3cce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,8 @@ upload
*.db
build
*.db-journal
+*.db-shm
+*.db-wal
logs
web/dist
web/node_modules
diff --git a/controller/channel-test.go b/controller/channel-test.go
index fca5264bb978..4d7e4b1f5350 100644
--- a/controller/channel-test.go
+++ b/controller/channel-test.go
@@ -559,7 +559,7 @@ func settleTestQuota(info *relaycommon.RelayInfo, priceData hosttypes.PriceData,
return common.QuotaFromFloat(priceData.ModelPrice * common.QuotaPerUnit), nil
}
-func buildTestLogOther(c *gin.Context, info *relaycommon.RelayInfo, priceData hosttypes.PriceData, usage *dto.Usage, tieredResult *billingexpr.TieredResult) map[string]interface{} {
+func buildTestLogOther(c *gin.Context, info *relaycommon.RelayInfo, priceData hosttypes.PriceData, usage *dto.Usage, tieredResult *billingexpr.TieredResult) *model.LogOther {
other := service.GenerateTextOtherInfo(c, info, priceData.ModelRatio, priceData.GroupRatioInfo.GroupRatio, priceData.CompletionRatio,
usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice, priceData.GroupRatioInfo.GroupSpecialRatio)
if tieredResult != nil {
diff --git a/controller/channel_test_internal_test.go b/controller/channel_test_internal_test.go
index 85da7f7bab5e..5f19aeb86610 100644
--- a/controller/channel_test_internal_test.go
+++ b/controller/channel_test_internal_test.go
@@ -279,10 +279,11 @@ func TestBuildTestLogOtherInjectsTieredInfo(t *testing.T) {
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"])
+ fields := other.Snapshot()
+ require.Equal(t, "tiered_expr", fields["billing_mode"])
+ require.Equal(t, "base", fields["matched_tier"])
+ require.Equal(t, requestRules, fields["request_rules"])
+ require.NotEmpty(t, fields["expr_b64"])
}
func TestResolveChannelTestUserIDUsesRequestUser(t *testing.T) {
diff --git a/controller/log.go b/controller/log.go
index 18ebb10b2bec..b5484992c00e 100644
--- a/controller/log.go
+++ b/controller/log.go
@@ -29,6 +29,8 @@ func GetAllLogs(c *gin.Context) {
}
if c.GetInt("role") < common.RoleRootUser {
model.FormatAdminLogs(logs)
+ } else {
+ model.FormatRootLogs(logs)
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(logs)
diff --git a/controller/relay.go b/controller/relay.go
index 099b3fb70db1..7fe1db2a1082 100644
--- a/controller/relay.go
+++ b/controller/relay.go
@@ -411,41 +411,21 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
modelName := c.GetString("original_model")
tokenId := c.GetInt("token_id")
userGroup := c.GetString("group")
- channelId := c.GetInt("channel_id")
- other := make(map[string]interface{})
+ other := model.NewLogOther()
if c.Request != nil && c.Request.URL != nil {
- other["request_path"] = c.Request.URL.Path
- }
- other["error_type"] = err.GetErrorType()
- other["error_code"] = err.GetErrorCode()
- other["status_code"] = err.StatusCode
- other["channel_id"] = channelId
- other["channel_name"] = c.GetString("channel_name")
- other["channel_type"] = c.GetInt("channel_type")
- adminInfo := make(map[string]interface{})
- adminInfo["use_channel"] = c.GetStringSlice("use_channel")
- if relayInfo != nil {
- if diagnostics := relayInfo.ConversionDiagnostics(); len(diagnostics) > 0 {
- adminInfo["conversion_diagnostics"] = diagnostics
- }
- if relayInfo.ConversionDiagnosticsTruncated() {
- adminInfo["conversion_diagnostics_truncated"] = true
- }
- }
- isMultiKey := common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey)
- if isMultiKey {
- adminInfo["is_multi_key"] = true
- adminInfo["multi_key_index"] = common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex)
+ other.SetPublic("request_path", c.Request.URL.Path)
}
- service.AppendChannelAffinityAdminInfo(c, adminInfo)
- other["admin_info"] = adminInfo
+ other.SetPublic("error_type", err.GetErrorType())
+ other.SetPublic("error_code", err.GetErrorCode())
+ other.SetPublic("status_code", err.StatusCode)
+ service.AppendRelayLogAdminInfo(c, relayInfo, other)
service.AppendTaskPluginContextAuditInfo(c, other)
startTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime)
if startTime.IsZero() {
startTime = time.Now()
}
useTimeSeconds := int(time.Since(startTime).Seconds())
- model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, useTimeSeconds, common.GetContextKeyBool(c, constant.ContextKeyIsStream), userGroup, other)
+ model.RecordErrorLog(c, userId, channelError.ChannelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, useTimeSeconds, common.GetContextKeyBool(c, constant.ContextKeyIsStream), userGroup, other)
}
}
diff --git a/controller/relay_error_log_test.go b/controller/relay_error_log_test.go
new file mode 100644
index 000000000000..737c05805959
--- /dev/null
+++ b/controller/relay_error_log_test.go
@@ -0,0 +1,99 @@
+package controller
+
+import (
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/relaykit/types"
+
+ "github.com/gin-gonic/gin"
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+func TestProcessChannelErrorUsesSnapshotWithoutLeakingChannelMetadata(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ previousDB, previousLogDB := model.DB, model.LOG_DB
+ previousRedisEnabled := common.RedisEnabled
+ previousMainDatabaseType := common.MainDatabaseType()
+ previousLogDatabaseType := common.LogDatabaseType()
+ previousErrorLogEnabled := constant.ErrorLogEnabled
+
+ database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ require.NoError(t, err)
+ sqlDB, err := database.DB()
+ require.NoError(t, err)
+ sqlDB.SetMaxOpenConns(1)
+ require.NoError(t, database.AutoMigrate(&model.User{}, &model.Log{}))
+ model.DB, model.LOG_DB = database, database
+ common.RedisEnabled = false
+ common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
+ constant.ErrorLogEnabled = true
+ t.Cleanup(func() {
+ model.DB, model.LOG_DB = previousDB, previousLogDB
+ common.RedisEnabled = previousRedisEnabled
+ common.SetDatabaseTypes(previousMainDatabaseType, previousLogDatabaseType)
+ constant.ErrorLogEnabled = previousErrorLogEnabled
+ require.NoError(t, sqlDB.Close())
+ })
+
+ require.NoError(t, database.Create(&model.User{Id: 7, Username: "log-owner", Group: "default"}).Error)
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
+ ctx.Set("id", 7)
+ ctx.Set("username", "log-owner")
+ ctx.Set("token_name", "test-token")
+ ctx.Set("token_id", 11)
+ ctx.Set("original_model", "gpt-test")
+ ctx.Set("group", "default")
+ ctx.Set("channel_id", 202)
+ ctx.Set("channel_name", "mutable-context-channel")
+ ctx.Set("channel_type", 9)
+ ctx.Set("use_channel", []string{"101"})
+ common.SetContextKey(ctx, constant.ContextKeyRequestStartTime, time.Now().Add(-time.Second))
+
+ channelSnapshot := types.ChannelError{
+ ChannelId: 101,
+ ChannelType: 1,
+ ChannelName: "snapshot-channel",
+ AutoBan: false,
+ }
+ apiErr := types.NewOpenAIError(errors.New("upstream failed"), types.ErrorCodeBadResponseStatusCode, http.StatusBadGateway)
+
+ processChannelError(ctx, channelSnapshot, apiErr, nil)
+
+ var stored model.Log
+ require.NoError(t, database.First(&stored).Error)
+ assert.Equal(t, channelSnapshot.ChannelId, stored.ChannelId)
+ storedOther, err := common.StrToMap(stored.Other)
+ require.NoError(t, err)
+ assert.Equal(t, float64(http.StatusBadGateway), storedOther["status_code"])
+ for _, key := range []string{"channel_id", "channel_name", "channel_type"} {
+ assert.NotContains(t, storedOther, key)
+ }
+ adminInfo, ok := storedOther["admin_info"].(map[string]interface{})
+ require.True(t, ok)
+ assert.Equal(t, []interface{}{"101"}, adminInfo["use_channel"])
+
+ logs, total, err := model.GetUserLogs(7, model.LogTypeError, 0, 0, "", "", 0, 10, "", "", "")
+ require.NoError(t, err)
+ require.Equal(t, int64(1), total)
+ require.Len(t, logs, 1)
+ assert.Equal(t, channelSnapshot.ChannelId, logs[0].ChannelId)
+ assert.Empty(t, logs[0].ChannelName)
+ userOther, err := common.StrToMap(logs[0].Other)
+ require.NoError(t, err)
+ assert.NotContains(t, userOther, "admin_info")
+ for _, key := range []string{"channel_id", "channel_name", "channel_type"} {
+ assert.NotContains(t, userOther, key)
+ }
+}
diff --git a/model/log.go b/model/log.go
index 7b908a6eecd6..a0407d539c15 100644
--- a/model/log.go
+++ b/model/log.go
@@ -116,19 +116,7 @@ func assignDisplayLogIds(logs []*Log, startIdx int) {
func formatUserLogs(logs []*Log, startIdx int) {
for i := range logs {
logs[i].ChannelName = ""
- var otherMap map[string]interface{}
- otherMap, _ = common.StrToMap(logs[i].Other)
- if otherMap != nil {
- // Remove admin-only debug fields.
- delete(otherMap, "admin_info")
- // Remove diagnostics reserved for root.
- delete(otherMap, "root_info")
- // Remove operation-audit details (operator/route info), admin-only.
- delete(otherMap, "audit_info")
- // delete(otherMap, "reject_reason")
- // delete(otherMap, "stream_status")
- }
- logs[i].Other = common.MapToJsonStr(otherMap)
+ logs[i].Other = formatLogOtherJSON(logs[i].Other, logOtherVisibilityUser)
}
assignDisplayLogIds(logs, startIdx)
}
@@ -137,12 +125,15 @@ func formatUserLogs(logs []*Log, startIdx int) {
// admin_info. Root callers must not pass their results through this formatter.
func FormatAdminLogs(logs []*Log) {
for i := range logs {
- otherMap, _ := common.StrToMap(logs[i].Other)
- if otherMap == nil {
- continue
- }
- delete(otherMap, "root_info")
- logs[i].Other = common.MapToJsonStr(otherMap)
+ logs[i].Other = formatLogOtherJSON(logs[i].Other, logOtherVisibilityAdmin)
+ }
+}
+
+// FormatRootLogs normalizes legacy metadata into the current scoped shape
+// without removing root-only diagnostics.
+func FormatRootLogs(logs []*Log) {
+ for i := range logs {
+ logs[i].Other = formatLogOtherJSON(logs[i].Other, logOtherVisibilityRoot)
}
}
@@ -188,10 +179,9 @@ func RecordLogWithAdminInfo(userId int, logType int, content string, adminInfo m
Content: content,
}
if len(adminInfo) > 0 {
- other := map[string]interface{}{
- "admin_info": adminInfo,
- }
- log.Other = common.MapToJsonStr(other)
+ other := NewLogOther()
+ other.MergeAdmin(adminInfo)
+ log.Other = other.JSONString()
}
if err := createLog(log); err != nil {
common.SysLog("failed to record log: " + err.Error())
@@ -216,11 +206,9 @@ func buildOpField(action string, params map[string]interface{}) map[string]inter
// content 为英文兜底文本(用于导出);action+params 供前端本地化渲染。
// extra 可携带 login_method、user_agent 等附加信息(普通用户可见)。
func RecordLoginLog(userId int, username string, content string, ip string, action string, params map[string]interface{}, extra map[string]interface{}) {
- other := map[string]interface{}{}
- for k, v := range extra {
- other[k] = v
- }
- other["op"] = buildOpField(action, params)
+ other := NewLogOther()
+ other.MergePublic(extra)
+ other.SetPublic("op", buildOpField(action, params))
log := &Log{
UserId: userId,
Username: username,
@@ -228,7 +216,7 @@ func RecordLoginLog(userId int, username string, content string, ip string, acti
Type: LogTypeLogin,
Content: content,
Ip: ip,
- Other: common.MapToJsonStr(other),
+ Other: other.JSONString(),
}
if err := createLog(log); err != nil {
common.SysLog("failed to record login log: " + err.Error())
@@ -243,15 +231,10 @@ func RecordLoginLog(userId int, username string, content string, ip string, acti
// auditInfo 存放路由/方法/结果等中间件兜底信息(写入 Other.audit_info,普通用户查询时剥离)。
func RecordOperationAuditLog(logUserId int, content string, ip string, action string, params map[string]interface{}, adminInfo map[string]interface{}, auditInfo map[string]interface{}) {
username, _ := GetUsernameById(logUserId, false)
- other := map[string]interface{}{
- "op": buildOpField(action, params),
- }
- if len(adminInfo) > 0 {
- other["admin_info"] = adminInfo
- }
- if len(auditInfo) > 0 {
- other["audit_info"] = auditInfo
- }
+ other := NewLogOther()
+ other.SetPublic("op", buildOpField(action, params))
+ other.MergeAdmin(adminInfo)
+ other.MergeAudit(auditInfo)
log := &Log{
UserId: logUserId,
Username: username,
@@ -259,7 +242,7 @@ func RecordOperationAuditLog(logUserId int, content string, ip string, action st
Type: LogTypeManage,
Content: content,
Ip: ip,
- Other: common.MapToJsonStr(other),
+ Other: other.JSONString(),
}
if err := createLog(log); err != nil {
common.SysLog("failed to record operation audit log: " + err.Error())
@@ -268,17 +251,15 @@ func RecordOperationAuditLog(logUserId int, content string, ip string, action st
func RecordTopupLog(userId int, content string, callerIp string, paymentMethod string, callbackPaymentMethod string) {
username, _ := GetUsernameById(userId, false)
- adminInfo := map[string]interface{}{
+ other := NewLogOther()
+ other.MergeAdmin(map[string]interface{}{
"server_ip": common.GetIp(),
"node_name": common.NodeName,
"caller_ip": callerIp,
"payment_method": paymentMethod,
"callback_payment_method": callbackPaymentMethod,
"version": common.Version,
- }
- other := map[string]interface{}{
- "admin_info": adminInfo,
- }
+ })
log := &Log{
UserId: userId,
Username: username,
@@ -286,7 +267,7 @@ func RecordTopupLog(userId int, content string, callerIp string, paymentMethod s
Type: LogTypeTopup,
Content: content,
Ip: callerIp,
- Other: common.MapToJsonStr(other),
+ Other: other.JSONString(),
}
err := createLog(log)
if err != nil {
@@ -295,12 +276,12 @@ func RecordTopupLog(userId int, content string, callerIp string, paymentMethod s
}
func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, tokenName string, content string, tokenId int, useTimeSeconds int,
- isStream bool, group string, other map[string]interface{}) {
+ isStream bool, group string, other *LogOther) {
logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, common.LocalLogPreview(content)))
username := c.GetString("username")
requestId := c.GetString(common.RequestIdKey)
upstreamRequestId := c.GetString(common.UpstreamRequestIdKey)
- otherStr := common.MapToJsonStr(other)
+ otherStr := other.JSONString()
// 判断是否需要记录 IP
needRecordIp := false
if settingMap, err := GetUserSetting(userId, false); err == nil {
@@ -341,18 +322,18 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
}
type RecordConsumeLogParams struct {
- ChannelId int `json:"channel_id"`
- PromptTokens int `json:"prompt_tokens"`
- CompletionTokens int `json:"completion_tokens"`
- ModelName string `json:"model_name"`
- TokenName string `json:"token_name"`
- Quota int `json:"quota"`
- Content string `json:"content"`
- TokenId int `json:"token_id"`
- UseTimeSeconds int `json:"use_time_seconds"`
- IsStream bool `json:"is_stream"`
- Group string `json:"group"`
- Other map[string]interface{} `json:"other"`
+ ChannelId int `json:"channel_id"`
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ ModelName string `json:"model_name"`
+ TokenName string `json:"token_name"`
+ Quota int `json:"quota"`
+ Content string `json:"content"`
+ TokenId int `json:"token_id"`
+ UseTimeSeconds int `json:"use_time_seconds"`
+ IsStream bool `json:"is_stream"`
+ Group string `json:"group"`
+ Other *LogOther `json:"other"`
}
func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) {
@@ -364,7 +345,7 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
requestId := c.GetString(common.RequestIdKey)
upstreamRequestId := c.GetString(common.UpstreamRequestIdKey)
createdAt := common.GetTimestamp()
- otherStr := common.MapToJsonStr(params.Other)
+ otherStr := params.Other.JSONString()
// 判断是否需要记录 IP
needRecordIp := false
if settingMap, err := GetUserSetting(userId, false); err == nil {
@@ -427,7 +408,7 @@ type RecordTaskBillingLogParams struct {
Quota int
TokenId int
Group string
- Other map[string]interface{}
+ Other *LogOther
NodeName string // 任务发起节点;为空时回退当前节点
}
@@ -455,7 +436,7 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) {
ChannelId: params.ChannelId,
TokenId: params.TokenId,
Group: params.Group,
- Other: common.MapToJsonStr(params.Other),
+ Other: params.Other.JSONString(),
}
err := createLog(log)
if err != nil {
diff --git a/model/log_format_test.go b/model/log_format_test.go
index 2d22c5ab02b1..89b2df04266d 100644
--- a/model/log_format_test.go
+++ b/model/log_format_test.go
@@ -75,9 +75,158 @@ func TestTaskPluginLogVisibilityIsRoleSeparated(t *testing.T) {
})
t.Run("root", func(t *testing.T) {
- parsed, err := common.StrToMap(other)
+ logs := []*Log{{Other: other}}
+ FormatRootLogs(logs)
+
+ parsed, err := common.StrToMap(logs[0].Other)
require.NoError(t, err)
assert.Contains(t, parsed, "admin_info")
assert.Contains(t, parsed, "root_info")
})
}
+
+func TestLegacyLogOtherVisibilityIsRoleSeparated(t *testing.T) {
+ other := common.MapToJsonStr(map[string]interface{}{
+ "request_path": "/v1/chat/completions",
+ "channel_id": 202,
+ "channel_name": "legacy-secret-channel",
+ "channel_type": 1,
+ "reject_reason": "legacy-policy-rejection",
+ "admin_info": map[string]interface{}{
+ "existing_admin_field": "preserved",
+ },
+ "root_info": map[string]interface{}{
+ "upstream_request_id": "upstream-private",
+ },
+ "audit_info": map[string]interface{}{
+ "method": "POST",
+ },
+ })
+
+ t.Run("user", func(t *testing.T) {
+ logs := []*Log{{
+ Id: 99,
+ ChannelId: 77,
+ ChannelName: "resolved-secret-channel",
+ Other: other,
+ }}
+
+ formatUserLogs(logs, 10)
+
+ assert.Equal(t, 11, logs[0].Id)
+ assert.Equal(t, 77, logs[0].ChannelId)
+ assert.Empty(t, logs[0].ChannelName)
+ parsed, err := common.StrToMap(logs[0].Other)
+ require.NoError(t, err)
+ assert.Equal(t, "/v1/chat/completions", parsed["request_path"])
+ for _, key := range []string{
+ "channel_id",
+ "channel_name",
+ "channel_type",
+ "reject_reason",
+ "admin_info",
+ "root_info",
+ "audit_info",
+ } {
+ assert.NotContains(t, parsed, key)
+ }
+ })
+
+ t.Run("admin", func(t *testing.T) {
+ logs := []*Log{{Other: other}}
+
+ FormatAdminLogs(logs)
+
+ parsed, err := common.StrToMap(logs[0].Other)
+ require.NoError(t, err)
+ assert.Equal(t, "legacy-secret-channel", parsed["channel_name"])
+ assert.NotContains(t, parsed, "reject_reason")
+ assert.NotContains(t, parsed, "root_info")
+ assert.Contains(t, parsed, "audit_info")
+ adminInfo, ok := parsed["admin_info"].(map[string]interface{})
+ require.True(t, ok)
+ assert.Equal(t, "preserved", adminInfo["existing_admin_field"])
+ assert.Equal(t, "legacy-policy-rejection", adminInfo["reject_reason"])
+ })
+
+ t.Run("root", func(t *testing.T) {
+ logs := []*Log{{Other: other}}
+
+ FormatRootLogs(logs)
+
+ parsed, err := common.StrToMap(logs[0].Other)
+ require.NoError(t, err)
+ assert.Equal(t, "legacy-secret-channel", parsed["channel_name"])
+ assert.NotContains(t, parsed, "reject_reason")
+ assert.Contains(t, parsed, "root_info")
+ assert.Contains(t, parsed, "audit_info")
+ adminInfo, ok := parsed["admin_info"].(map[string]interface{})
+ require.True(t, ok)
+ assert.Equal(t, "preserved", adminInfo["existing_admin_field"])
+ assert.Equal(t, "legacy-policy-rejection", adminInfo["reject_reason"])
+ })
+}
+
+func TestLegacyRejectReasonDoesNotOverrideScopedValue(t *testing.T) {
+ other := common.MapToJsonStr(map[string]interface{}{
+ "reject_reason": "legacy-value",
+ "admin_info": map[string]interface{}{
+ "reject_reason": "scoped-value",
+ },
+ })
+ logs := []*Log{{Other: other}}
+
+ FormatRootLogs(logs)
+
+ parsed, err := common.StrToMap(logs[0].Other)
+ require.NoError(t, err)
+ assert.NotContains(t, parsed, "reject_reason")
+ adminInfo, ok := parsed["admin_info"].(map[string]interface{})
+ require.True(t, ok)
+ assert.Equal(t, "scoped-value", adminInfo["reject_reason"])
+}
+
+func TestLegacyRejectReasonHandlesNullAdminInfo(t *testing.T) {
+ logs := []*Log{{Other: `{"reject_reason":"legacy-value","admin_info":null}`}}
+
+ FormatAdminLogs(logs)
+
+ parsed, err := common.StrToMap(logs[0].Other)
+ require.NoError(t, err)
+ assert.NotContains(t, parsed, "reject_reason")
+ adminInfo, ok := parsed["admin_info"].(map[string]interface{})
+ require.True(t, ok)
+ assert.Equal(t, "legacy-value", adminInfo["reject_reason"])
+}
+
+func TestLogFormattingPreservesLargeIntegerLexemes(t *testing.T) {
+ const other = `{"public_id":9007199254740993,"admin_info":{"admin_id":9007199254740995},"root_info":{"generation":18446744073709551615}}`
+
+ t.Run("user", func(t *testing.T) {
+ logs := []*Log{{Other: other}}
+
+ formatUserLogs(logs, 0)
+
+ assert.Contains(t, logs[0].Other, `"public_id":9007199254740993`)
+ assert.NotContains(t, logs[0].Other, "admin_id")
+ assert.NotContains(t, logs[0].Other, "generation")
+ })
+
+ t.Run("admin", func(t *testing.T) {
+ logs := []*Log{{Other: other}}
+
+ FormatAdminLogs(logs)
+
+ assert.Contains(t, logs[0].Other, `"public_id":9007199254740993`)
+ assert.Contains(t, logs[0].Other, `"admin_id":9007199254740995`)
+ assert.NotContains(t, logs[0].Other, "generation")
+ })
+
+ t.Run("root", func(t *testing.T) {
+ logs := []*Log{{Other: other}}
+
+ FormatRootLogs(logs)
+
+ assert.Equal(t, other, logs[0].Other)
+ })
+}
diff --git a/model/log_other.go b/model/log_other.go
new file mode 100644
index 000000000000..77f64e4c2d39
--- /dev/null
+++ b/model/log_other.go
@@ -0,0 +1,281 @@
+package model
+
+import (
+ "encoding/json"
+ "maps"
+
+ "github.com/QuantumNous/new-api/common"
+)
+
+const (
+ logOtherAdminInfoKey = "admin_info"
+ logOtherRootInfoKey = "root_info"
+ logOtherAuditInfoKey = "audit_info"
+)
+
+type logOtherVisibility int
+
+const (
+ logOtherVisibilityUser logOtherVisibility = iota
+ logOtherVisibilityAdmin
+ logOtherVisibilityRoot
+)
+
+// LogOther separates usage-log metadata by the audience allowed to see it.
+// Its maps stay private so callers cannot accidentally place privileged fields
+// in the user-visible top level.
+type LogOther struct {
+ public map[string]any
+ adminInfo map[string]any
+ rootInfo map[string]any
+ auditInfo map[string]any
+}
+
+func NewLogOther() *LogOther {
+ return &LogOther{}
+}
+
+func isReservedLogOtherKey(key string) bool {
+ switch key {
+ case logOtherAdminInfoKey, logOtherRootInfoKey, logOtherAuditInfoKey,
+ "channel_id", "channel_name", "channel_type", "reject_reason":
+ return true
+ default:
+ return false
+ }
+}
+
+// SetPublic records metadata that log owners may receive from self/token log APIs.
+// It rejects role-scoped and legacy-sensitive keys so new writers cannot recreate
+// the historical channel/reject-reason leak.
+func (o *LogOther) SetPublic(key string, value any) bool {
+ if o == nil || key == "" || isReservedLogOtherKey(key) {
+ return false
+ }
+ if o.public == nil {
+ o.public = make(map[string]any)
+ }
+ o.public[key] = value
+ return true
+}
+
+func (o *LogOther) MergePublic(values map[string]any) {
+ for key, value := range values {
+ o.SetPublic(key, value)
+ }
+}
+
+func (o *LogOther) SetAdmin(key string, value any) bool {
+ if o == nil || key == "" {
+ return false
+ }
+ if o.adminInfo == nil {
+ o.adminInfo = make(map[string]any)
+ }
+ o.adminInfo[key] = value
+ return true
+}
+
+func (o *LogOther) MergeAdmin(values map[string]any) {
+ for key, value := range values {
+ o.SetAdmin(key, value)
+ }
+}
+
+func (o *LogOther) SetRoot(key string, value any) bool {
+ if o == nil || key == "" {
+ return false
+ }
+ if o.rootInfo == nil {
+ o.rootInfo = make(map[string]any)
+ }
+ o.rootInfo[key] = value
+ return true
+}
+
+func (o *LogOther) MergeRoot(values map[string]any) {
+ for key, value := range values {
+ o.SetRoot(key, value)
+ }
+}
+
+func (o *LogOther) SetAudit(key string, value any) bool {
+ if o == nil || key == "" {
+ return false
+ }
+ if o.auditInfo == nil {
+ o.auditInfo = make(map[string]any)
+ }
+ o.auditInfo[key] = value
+ return true
+}
+
+func (o *LogOther) MergeAudit(values map[string]any) {
+ for key, value := range values {
+ o.SetAudit(key, value)
+ }
+}
+
+func copyLogOtherMap(values map[string]any) map[string]any {
+ if len(values) == 0 {
+ return nil
+ }
+ copyValues := make(map[string]any, len(values))
+ maps.Copy(copyValues, values)
+ return copyValues
+}
+
+func (o *LogOther) normalizeLegacyAdminFields() {
+ if o == nil || o.public == nil {
+ return
+ }
+ if rejectReason, ok := o.public["reject_reason"]; ok {
+ if _, exists := o.adminInfo["reject_reason"]; !exists {
+ o.SetAdmin("reject_reason", rejectReason)
+ }
+ delete(o.public, "reject_reason")
+ }
+}
+
+func (o *LogOther) toMap(visibility logOtherVisibility) map[string]any {
+ result := make(map[string]any)
+ if o == nil {
+ return result
+ }
+
+ for key, value := range o.public {
+ if visibility == logOtherVisibilityUser {
+ switch key {
+ case "channel_id", "channel_name", "channel_type", "reject_reason":
+ continue
+ }
+ }
+ result[key] = value
+ }
+ if visibility >= logOtherVisibilityAdmin {
+ if adminInfo := copyLogOtherMap(o.adminInfo); len(adminInfo) > 0 {
+ result[logOtherAdminInfoKey] = adminInfo
+ }
+ if auditInfo := copyLogOtherMap(o.auditInfo); len(auditInfo) > 0 {
+ result[logOtherAuditInfoKey] = auditInfo
+ }
+ }
+ if visibility == logOtherVisibilityRoot {
+ if rootInfo := copyLogOtherMap(o.rootInfo); len(rootInfo) > 0 {
+ result[logOtherRootInfoKey] = rootInfo
+ }
+ }
+ return result
+}
+
+func (o *LogOther) jsonString(visibility logOtherVisibility) string {
+ if o == nil {
+ return ""
+ }
+ o.normalizeLegacyAdminFields()
+ data, err := common.Marshal(o.toMap(visibility))
+ if err != nil {
+ common.SysError("failed to marshal log other: " + err.Error())
+ return ""
+ }
+ return string(data)
+}
+
+// JSONString returns the complete stored representation, including all
+// privileged scopes. API projections must use the role-specific formatter.
+func (o *LogOther) JSONString() string {
+ return o.jsonString(logOtherVisibilityRoot)
+}
+
+// Snapshot returns a detached top-level view for tests and read-only
+// inspection. Mutating it cannot add or replace fields in LogOther.
+func (o *LogOther) Snapshot() map[string]any {
+ if o == nil {
+ return nil
+ }
+ return o.toMap(logOtherVisibilityRoot)
+}
+
+func (o *LogOther) MarshalJSON() ([]byte, error) {
+ return common.Marshal(o.toMap(logOtherVisibilityRoot))
+}
+
+func normalizeLegacyRejectReason(values map[string]json.RawMessage) bool {
+ rejectReason, ok := values["reject_reason"]
+ if !ok {
+ return false
+ }
+
+ adminInfo := make(map[string]json.RawMessage)
+ if rawAdminInfo, exists := values[logOtherAdminInfoKey]; exists {
+ _ = common.Unmarshal(rawAdminInfo, &adminInfo)
+ }
+ if adminInfo == nil {
+ adminInfo = make(map[string]json.RawMessage)
+ }
+ if _, exists := adminInfo["reject_reason"]; !exists {
+ adminInfo["reject_reason"] = rejectReason
+ }
+ encodedAdminInfo, err := common.Marshal(adminInfo)
+ if err != nil {
+ return false
+ }
+ values[logOtherAdminInfoKey] = encodedAdminInfo
+ delete(values, "reject_reason")
+ return true
+}
+
+// formatLogOtherJSON applies the role projection while keeping untouched JSON
+// values as RawMessage. This preserves integers larger than JavaScript's safe
+// range instead of round-tripping them through float64.
+func formatLogOtherJSON(value string, visibility logOtherVisibility) string {
+ if value == "" {
+ return ""
+ }
+
+ var values map[string]json.RawMessage
+ if err := common.UnmarshalJsonStr(value, &values); err != nil {
+ if visibility == logOtherVisibilityRoot {
+ return value
+ }
+ return "{}"
+ }
+
+ changed := false
+ if visibility == logOtherVisibilityUser {
+ for _, key := range []string{
+ logOtherAdminInfoKey,
+ logOtherRootInfoKey,
+ logOtherAuditInfoKey,
+ "channel_id",
+ "channel_name",
+ "channel_type",
+ "reject_reason",
+ } {
+ if _, exists := values[key]; exists {
+ delete(values, key)
+ changed = true
+ }
+ }
+ } else {
+ changed = normalizeLegacyRejectReason(values)
+ if visibility == logOtherVisibilityAdmin {
+ if _, exists := values[logOtherRootInfoKey]; exists {
+ delete(values, logOtherRootInfoKey)
+ changed = true
+ }
+ }
+ }
+
+ if visibility == logOtherVisibilityRoot && !changed {
+ return value
+ }
+ formatted, err := common.Marshal(values)
+ if err != nil {
+ if visibility == logOtherVisibilityRoot {
+ return value
+ }
+ return "{}"
+ }
+ return string(formatted)
+}
diff --git a/model/log_other_test.go b/model/log_other_test.go
new file mode 100644
index 000000000000..8ef0edf42ff0
--- /dev/null
+++ b/model/log_other_test.go
@@ -0,0 +1,70 @@
+package model
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestLogOtherScopesAndMerges(t *testing.T) {
+ var other LogOther
+
+ assert.True(t, other.SetPublic("request_path", "/v1/chat/completions"))
+ other.MergePublic(map[string]interface{}{
+ "zero": 0,
+ })
+ assert.True(t, other.SetAdmin("use_channel", []string{"channel-a"}))
+ other.MergeAdmin(map[string]interface{}{
+ "rejected": false,
+ })
+ assert.True(t, other.SetRoot("upstream_request_id", "upstream-private"))
+ other.MergeRoot(map[string]interface{}{
+ "generation": 0,
+ })
+ assert.True(t, other.SetAudit("method", "POST"))
+ other.MergeAudit(map[string]interface{}{
+ "success": false,
+ })
+
+ require.JSONEq(t, `{
+ "request_path": "/v1/chat/completions",
+ "zero": 0,
+ "admin_info": {
+ "use_channel": ["channel-a"],
+ "rejected": false
+ },
+ "root_info": {
+ "upstream_request_id": "upstream-private",
+ "generation": 0
+ },
+ "audit_info": {
+ "method": "POST",
+ "success": false
+ }
+ }`, other.JSONString())
+}
+
+func TestLogOtherRejectsSensitivePublicFields(t *testing.T) {
+ other := NewLogOther()
+
+ for _, key := range []string{
+ "admin_info",
+ "root_info",
+ "audit_info",
+ "channel_id",
+ "channel_name",
+ "channel_type",
+ "reject_reason",
+ } {
+ assert.False(t, other.SetPublic(key, "must-not-leak"), key)
+ }
+ other.MergePublic(map[string]interface{}{
+ "request_path": "/v1/responses",
+ "channel_name": "still-must-not-leak",
+ "admin_info": map[string]interface{}{"secret": true},
+ })
+
+ require.JSONEq(t, `{"request_path":"/v1/responses"}`, other.JSONString())
+ require.JSONEq(t, `{}`, NewLogOther().JSONString())
+}
diff --git a/relay/convert_request_error_test.go b/relay/convert_request_error_test.go
index c3caba2cf952..5ab853bc65f7 100644
--- a/relay/convert_request_error_test.go
+++ b/relay/convert_request_error_test.go
@@ -57,7 +57,7 @@ func TestOptInSafeToolLossRejectedAsBadRequestWithAdminDiagnostics(t *testing.T)
assert.True(t, hasHostDiagnosticCode(diagnostics, "unsupported_hosted_tool"))
other := service.GenerateTextOtherInfo(c, info, 1, 1, 1, 0, 0, 0, 1)
- adminInfo, ok := other["admin_info"].(map[string]interface{})
+ adminInfo, ok := other.Snapshot()["admin_info"].(map[string]interface{})
require.True(t, ok)
require.Contains(t, adminInfo, "conversion_diagnostics")
}
diff --git a/service/billing_usage.go b/service/billing_usage.go
index 7dc236c75216..517cf4b1cf7d 100644
--- a/service/billing_usage.go
+++ b/service/billing_usage.go
@@ -1,6 +1,9 @@
package service
-import "github.com/QuantumNous/new-api/relaykit/dto"
+import (
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/relaykit/dto"
+)
const (
usageBillingPathLocal = "local"
@@ -50,16 +53,11 @@ func usageBillingPathForLog(isLocalCountTokens bool, usage *dto.Usage) string {
return usageBillingPathUpstream
}
-func appendUsageBillingPathForLog(other map[string]interface{}, isLocalCountTokens bool, usage *dto.Usage) {
+func appendUsageBillingPathForLog(other *model.LogOther, isLocalCountTokens bool, usage *dto.Usage) {
if other == nil {
return
}
- adminInfo, ok := other["admin_info"].(map[string]interface{})
- if !ok || adminInfo == nil {
- adminInfo = make(map[string]interface{})
- other["admin_info"] = adminInfo
- }
- adminInfo["usage_billing_path"] = usageBillingPathForLog(isLocalCountTokens, usage)
+ other.SetAdmin("usage_billing_path", usageBillingPathForLog(isLocalCountTokens, usage))
}
func usageFromBillingUsage(usage *dto.Usage) (*dto.Usage, bool) {
diff --git a/service/channel_affinity.go b/service/channel_affinity.go
index a112fb84dc80..acaa4d6b28e0 100644
--- a/service/channel_affinity.go
+++ b/service/channel_affinity.go
@@ -10,6 +10,7 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/cachex"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
@@ -699,15 +700,15 @@ func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int
c.Set(ginKeyChannelAffinityLogInfo, info)
}
-func AppendChannelAffinityAdminInfo(c *gin.Context, adminInfo map[string]interface{}) {
- if c == nil || adminInfo == nil {
+func AppendChannelAffinityAdminInfo(c *gin.Context, other *model.LogOther) {
+ if c == nil || other == nil {
return
}
anyInfo, ok := c.Get(ginKeyChannelAffinityLogInfo)
if !ok || anyInfo == nil {
return
}
- adminInfo["channel_affinity"] = anyInfo
+ other.SetAdmin("channel_affinity", anyInfo)
}
func RecordChannelAffinity(c *gin.Context, channelID int) {
diff --git a/service/log_info_generate.go b/service/log_info_generate.go
index 2781dc1aec22..aec98ce9d023 100644
--- a/service/log_info_generate.go
+++ b/service/log_info_generate.go
@@ -8,6 +8,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
+ "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
@@ -22,22 +23,17 @@ import (
// admin-only for free, since model.formatUserLogs strips the whole admin_info
// object for non-admin viewers. Creates admin_info if absent. No-op when the
// clamp is nil (the common case: no saturation happened).
-func attachQuotaSaturationToOther(other map[string]interface{}, clamp *common.QuotaClamp) {
+func attachQuotaSaturationToOther(other *model.LogOther, clamp *common.QuotaClamp) {
if clamp == nil || other == nil {
return
}
- adminInfo, ok := other["admin_info"].(map[string]interface{})
- if !ok || adminInfo == nil {
- adminInfo = map[string]interface{}{}
- other["admin_info"] = adminInfo
- }
- adminInfo["quota_saturation"] = clamp.AuditMap()
+ other.SetAdmin("quota_saturation", clamp.AuditMap())
}
// attachQuotaSaturation records the request's quota clamp (if any) onto the
// consume log's other.admin_info and emits a request-correlated backend audit
// line. Called right before RecordConsumeLog on the text/audio/wss paths.
-func attachQuotaSaturation(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
+func attachQuotaSaturation(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other *model.LogOther) {
if relayInfo == nil {
return
}
@@ -50,13 +46,13 @@ func attachQuotaSaturation(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, o
clamp.Op, clamp.Kind, clamp.Original, clamp.Clamped, relayInfo.UserId, relayInfo.GetBillingModelName()))
}
-func appendRequestPath(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
+func appendRequestPath(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other *model.LogOther) {
if other == nil {
return
}
if ctx != nil && ctx.Request != nil && ctx.Request.URL != nil {
if path := ctx.Request.URL.Path; path != "" {
- other["request_path"] = path
+ other.SetPublic("request_path", path)
return
}
}
@@ -65,59 +61,64 @@ func appendRequestPath(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other
if idx := strings.Index(path, "?"); idx != -1 {
path = path[:idx]
}
- other["request_path"] = path
+ other.SetPublic("request_path", path)
+ }
+}
+
+// AppendRelayLogAdminInfo records relay routing and conversion diagnostics in
+// the admin-only scope shared by successful and failed request logs.
+func AppendRelayLogAdminInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other *model.LogOther) {
+ if ctx == nil || other == nil {
+ return
+ }
+ other.SetAdmin("use_channel", ctx.GetStringSlice("use_channel"))
+ if relayInfo != nil {
+ if billingModel := relayInfo.GetBillingModelName(); billingModel != "" && billingModel != relayInfo.OriginModelName {
+ other.SetAdmin("billing_model", billingModel)
+ }
+ if diagnostics := relayInfo.ConversionDiagnostics(); len(diagnostics) > 0 {
+ other.SetAdmin("conversion_diagnostics", diagnostics)
+ }
+ if relayInfo.ConversionDiagnosticsTruncated() {
+ other.SetAdmin("conversion_diagnostics_truncated", true)
+ }
+ }
+ if common.GetContextKeyBool(ctx, constant.ContextKeyChannelIsMultiKey) {
+ other.SetAdmin("is_multi_key", true)
+ other.SetAdmin("multi_key_index", common.GetContextKeyInt(ctx, constant.ContextKeyChannelMultiKeyIndex))
}
+ if common.GetContextKeyBool(ctx, constant.ContextKeyLocalCountTokens) {
+ other.SetAdmin("local_count_tokens", true)
+ }
+
+ AppendChannelAffinityAdminInfo(ctx, other)
}
func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64,
- cacheTokens int, cacheRatio float64, modelPrice float64, userGroupRatio float64) map[string]interface{} {
- other := make(map[string]interface{})
- other["model_ratio"] = modelRatio
- other["group_ratio"] = groupRatio
- other["completion_ratio"] = completionRatio
- other["cache_tokens"] = cacheTokens
- other["cache_ratio"] = cacheRatio
- other["model_price"] = modelPrice
- other["user_group_ratio"] = userGroupRatio
- other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli())
+ cacheTokens int, cacheRatio float64, modelPrice float64, userGroupRatio float64) *model.LogOther {
+ other := model.NewLogOther()
+ other.SetPublic("model_ratio", modelRatio)
+ other.SetPublic("group_ratio", groupRatio)
+ other.SetPublic("completion_ratio", completionRatio)
+ other.SetPublic("cache_tokens", cacheTokens)
+ other.SetPublic("cache_ratio", cacheRatio)
+ other.SetPublic("model_price", modelPrice)
+ other.SetPublic("user_group_ratio", userGroupRatio)
+ other.SetPublic("frt", float64(relayInfo.FirstResponseTime.UnixMilli()-relayInfo.StartTime.UnixMilli()))
if relayInfo.ReasoningEffort != "" {
- other["reasoning_effort"] = relayInfo.ReasoningEffort
+ other.SetPublic("reasoning_effort", relayInfo.ReasoningEffort)
}
if relayInfo.IsModelMapped {
- other["is_model_mapped"] = true
- other["upstream_model_name"] = relayInfo.UpstreamModelName
+ other.SetPublic("is_model_mapped", true)
+ other.SetPublic("upstream_model_name", relayInfo.UpstreamModelName)
}
isSystemPromptOverwritten := common.GetContextKeyBool(ctx, constant.ContextKeySystemPromptOverride)
if isSystemPromptOverwritten {
- other["is_system_prompt_overwritten"] = true
- }
-
- adminInfo := make(map[string]interface{})
- adminInfo["use_channel"] = ctx.GetStringSlice("use_channel")
- if billingModel := relayInfo.GetBillingModelName(); billingModel != "" && billingModel != relayInfo.OriginModelName {
- adminInfo["billing_model"] = billingModel
- }
- if diagnostics := relayInfo.ConversionDiagnostics(); len(diagnostics) > 0 {
- adminInfo["conversion_diagnostics"] = diagnostics
+ other.SetPublic("is_system_prompt_overwritten", true)
}
- if relayInfo.ConversionDiagnosticsTruncated() {
- adminInfo["conversion_diagnostics_truncated"] = true
- }
- isMultiKey := common.GetContextKeyBool(ctx, constant.ContextKeyChannelIsMultiKey)
- if isMultiKey {
- adminInfo["is_multi_key"] = true
- adminInfo["multi_key_index"] = common.GetContextKeyInt(ctx, constant.ContextKeyChannelMultiKeyIndex)
- }
-
- isLocalCountTokens := common.GetContextKeyBool(ctx, constant.ContextKeyLocalCountTokens)
- if isLocalCountTokens {
- adminInfo["local_count_tokens"] = isLocalCountTokens
- }
-
- AppendChannelAffinityAdminInfo(ctx, adminInfo)
- other["admin_info"] = adminInfo
+ AppendRelayLogAdminInfo(ctx, relayInfo, other)
appendRequestPath(ctx, relayInfo, other)
appendRequestConversionChain(relayInfo, other)
appendFinalRequestFormat(relayInfo, other)
@@ -127,14 +128,14 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
return other
}
-func appendParamOverrideInfo(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
+func appendParamOverrideInfo(relayInfo *relaycommon.RelayInfo, other *model.LogOther) {
if relayInfo == nil || other == nil || len(relayInfo.ParamOverrideAudit) == 0 {
return
}
- other["po"] = relayInfo.ParamOverrideAudit
+ other.SetPublic("po", relayInfo.ParamOverrideAudit)
}
-func appendStreamStatus(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
+func appendStreamStatus(relayInfo *relaycommon.RelayInfo, other *model.LogOther) {
if relayInfo == nil || other == nil || !relayInfo.IsStream || relayInfo.StreamStatus == nil {
return
}
@@ -158,36 +159,36 @@ func appendStreamStatus(relayInfo *relaycommon.RelayInfo, other map[string]inter
}
streamInfo["errors"] = messages
}
- other["stream_status"] = streamInfo
+ other.SetPublic("stream_status", streamInfo)
}
-func appendBillingInfo(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
+func appendBillingInfo(relayInfo *relaycommon.RelayInfo, other *model.LogOther) {
if relayInfo == nil || other == nil {
return
}
// billing_source: "wallet" or "subscription"
if relayInfo.BillingSource != "" {
- other["billing_source"] = relayInfo.BillingSource
+ other.SetPublic("billing_source", relayInfo.BillingSource)
}
if relayInfo.UserSetting.BillingPreference != "" {
- other["billing_preference"] = relayInfo.UserSetting.BillingPreference
+ other.SetPublic("billing_preference", relayInfo.UserSetting.BillingPreference)
}
if relayInfo.BillingSource == "subscription" {
if relayInfo.SubscriptionId != 0 {
- other["subscription_id"] = relayInfo.SubscriptionId
+ other.SetPublic("subscription_id", relayInfo.SubscriptionId)
}
if relayInfo.SubscriptionPreConsumed > 0 {
- other["subscription_pre_consumed"] = relayInfo.SubscriptionPreConsumed
+ other.SetPublic("subscription_pre_consumed", relayInfo.SubscriptionPreConsumed)
}
// post_delta: settlement delta applied after actual usage is known (can be negative for refund)
if relayInfo.SubscriptionPostDelta != 0 {
- other["subscription_post_delta"] = relayInfo.SubscriptionPostDelta
+ other.SetPublic("subscription_post_delta", relayInfo.SubscriptionPostDelta)
}
if relayInfo.SubscriptionPlanId != 0 {
- other["subscription_plan_id"] = relayInfo.SubscriptionPlanId
+ other.SetPublic("subscription_plan_id", relayInfo.SubscriptionPlanId)
}
if relayInfo.SubscriptionPlanTitle != "" {
- other["subscription_plan_title"] = relayInfo.SubscriptionPlanTitle
+ other.SetPublic("subscription_plan_title", relayInfo.SubscriptionPlanTitle)
}
// Compute "this request" subscription consumed + remaining
consumed := relayInfo.SubscriptionPreConsumed + relayInfo.SubscriptionPostDelta
@@ -203,19 +204,19 @@ func appendBillingInfo(relayInfo *relaycommon.RelayInfo, other map[string]interf
if remain < 0 {
remain = 0
}
- other["subscription_total"] = relayInfo.SubscriptionAmountTotal
- other["subscription_used"] = usedFinal
- other["subscription_remain"] = remain
+ other.SetPublic("subscription_total", relayInfo.SubscriptionAmountTotal)
+ other.SetPublic("subscription_used", usedFinal)
+ other.SetPublic("subscription_remain", remain)
}
if consumed > 0 {
- other["subscription_consumed"] = consumed
+ other.SetPublic("subscription_consumed", consumed)
}
// Wallet quota is not deducted when billed from subscription.
- other["wallet_quota_deducted"] = 0
+ other.SetPublic("wallet_quota_deducted", 0)
}
}
-func appendRequestConversionChain(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
+func appendRequestConversionChain(relayInfo *relaycommon.RelayInfo, other *model.LogOther) {
if relayInfo == nil || other == nil {
return
}
@@ -240,41 +241,41 @@ func appendRequestConversionChain(relayInfo *relaycommon.RelayInfo, other map[st
if len(chain) == 0 {
return
}
- other["request_conversion"] = chain
+ other.SetPublic("request_conversion", chain)
}
-func appendFinalRequestFormat(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
+func appendFinalRequestFormat(relayInfo *relaycommon.RelayInfo, other *model.LogOther) {
if relayInfo == nil || other == nil {
return
}
if relayInfo.GetFinalRequestRelayFormat() == types.RelayFormatClaude {
// claude indicates the final upstream request format is Claude Messages.
// Frontend log rendering uses this to keep the original Claude input display.
- other["claude"] = true
+ other.SetPublic("claude", true)
}
}
-func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} {
+func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) *model.LogOther {
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio)
- info["ws"] = true
- info["audio_input"] = usage.InputTokenDetails.AudioTokens
- info["audio_output"] = usage.OutputTokenDetails.AudioTokens
- info["text_input"] = usage.InputTokenDetails.TextTokens
- info["text_output"] = usage.OutputTokenDetails.TextTokens
- info["audio_ratio"] = audioRatio
- info["audio_completion_ratio"] = audioCompletionRatio
+ info.SetPublic("ws", true)
+ info.SetPublic("audio_input", usage.InputTokenDetails.AudioTokens)
+ info.SetPublic("audio_output", usage.OutputTokenDetails.AudioTokens)
+ info.SetPublic("text_input", usage.InputTokenDetails.TextTokens)
+ info.SetPublic("text_output", usage.OutputTokenDetails.TextTokens)
+ info.SetPublic("audio_ratio", audioRatio)
+ info.SetPublic("audio_completion_ratio", audioCompletionRatio)
return info
}
-func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} {
+func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) *model.LogOther {
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio)
- info["audio"] = true
- info["audio_input"] = usage.PromptTokensDetails.AudioTokens
- info["audio_output"] = usage.CompletionTokenDetails.AudioTokens
- info["text_input"] = usage.PromptTokensDetails.TextTokens
- info["text_output"] = usage.CompletionTokenDetails.TextTokens
- info["audio_ratio"] = audioRatio
- info["audio_completion_ratio"] = audioCompletionRatio
+ info.SetPublic("audio", true)
+ info.SetPublic("audio_input", usage.PromptTokensDetails.AudioTokens)
+ info.SetPublic("audio_output", usage.CompletionTokenDetails.AudioTokens)
+ info.SetPublic("text_input", usage.PromptTokensDetails.TextTokens)
+ info.SetPublic("text_output", usage.CompletionTokenDetails.TextTokens)
+ info.SetPublic("audio_ratio", audioRatio)
+ info.SetPublic("audio_completion_ratio", audioCompletionRatio)
return info
}
@@ -283,28 +284,28 @@ func GenerateClaudeOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
cacheCreationTokens int, cacheCreationRatio float64,
cacheCreationTokens5m int, cacheCreationRatio5m float64,
cacheCreationTokens1h int, cacheCreationRatio1h float64,
- modelPrice float64, userGroupRatio float64) map[string]interface{} {
+ modelPrice float64, userGroupRatio float64) *model.LogOther {
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, userGroupRatio)
- info["claude"] = true
- info["cache_creation_tokens"] = cacheCreationTokens
- info["cache_creation_ratio"] = cacheCreationRatio
+ info.SetPublic("claude", true)
+ info.SetPublic("cache_creation_tokens", cacheCreationTokens)
+ info.SetPublic("cache_creation_ratio", cacheCreationRatio)
if cacheCreationTokens5m != 0 {
- info["cache_creation_tokens_5m"] = cacheCreationTokens5m
- info["cache_creation_ratio_5m"] = cacheCreationRatio5m
+ info.SetPublic("cache_creation_tokens_5m", cacheCreationTokens5m)
+ info.SetPublic("cache_creation_ratio_5m", cacheCreationRatio5m)
}
if cacheCreationTokens1h != 0 {
- info["cache_creation_tokens_1h"] = cacheCreationTokens1h
- info["cache_creation_ratio_1h"] = cacheCreationRatio1h
+ info.SetPublic("cache_creation_tokens_1h", cacheCreationTokens1h)
+ info.SetPublic("cache_creation_ratio_1h", cacheCreationRatio1h)
}
return info
}
-func GenerateMjOtherInfo(relayInfo *relaycommon.RelayInfo, priceData hosttypes.PriceData) map[string]interface{} {
- other := make(map[string]interface{})
- other["model_price"] = priceData.ModelPrice
- other["group_ratio"] = priceData.GroupRatioInfo.GroupRatio
+func GenerateMjOtherInfo(relayInfo *relaycommon.RelayInfo, priceData hosttypes.PriceData) *model.LogOther {
+ other := model.NewLogOther()
+ other.SetPublic("model_price", priceData.ModelPrice)
+ other.SetPublic("group_ratio", priceData.GroupRatioInfo.GroupRatio)
if priceData.GroupRatioInfo.HasSpecialRatio {
- other["user_group_ratio"] = priceData.GroupRatioInfo.GroupSpecialRatio
+ other.SetPublic("user_group_ratio", priceData.GroupRatioInfo.GroupSpecialRatio)
}
appendRequestPath(nil, relayInfo, other)
return other
@@ -313,7 +314,7 @@ func GenerateMjOtherInfo(relayInfo *relaycommon.RelayInfo, priceData hosttypes.P
// InjectTieredBillingInfo overlays tiered billing fields onto an existing
// module-specific other map. Call this after GenerateTextOtherInfo /
// GenerateClaudeOtherInfo / etc. when the request used tiered_expr billing.
-func InjectTieredBillingInfo(other map[string]interface{}, relayInfo *relaycommon.RelayInfo, result *billingexpr.TieredResult) {
+func InjectTieredBillingInfo(other *model.LogOther, relayInfo *relaycommon.RelayInfo, result *billingexpr.TieredResult) {
if relayInfo == nil || other == nil {
return
}
@@ -321,12 +322,12 @@ func InjectTieredBillingInfo(other map[string]interface{}, relayInfo *relaycommo
if snap == nil {
return
}
- other["billing_mode"] = "tiered_expr"
- other["expr_b64"] = base64.StdEncoding.EncodeToString([]byte(snap.ExprString))
+ other.SetPublic("billing_mode", "tiered_expr")
+ other.SetPublic("expr_b64", base64.StdEncoding.EncodeToString([]byte(snap.ExprString)))
if result != nil {
- other["matched_tier"] = result.MatchedTier
+ other.SetPublic("matched_tier", result.MatchedTier)
if len(result.RequestRules) > 0 {
- other["request_rules"] = result.RequestRules
+ other.SetPublic("request_rules", result.RequestRules)
}
}
}
diff --git a/service/midjourney.go b/service/midjourney.go
index b10f6cd2c68c..9f0b847094d5 100644
--- a/service/midjourney.go
+++ b/service/midjourney.go
@@ -117,6 +117,9 @@ func RefundMidjourneyQuota(ctx context.Context, task *model.Midjourney, reason s
billingChannelId := task.GetBillingChannelId()
model.UpdateUserUsedQuota(task.UserId, -quota)
model.UpdateChannelUsedQuota(billingChannelId, -quota)
+ other := model.NewLogOther()
+ other.SetPublic("task_id", task.MjId)
+ other.SetPublic("reason", reason)
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
UserId: task.UserId,
LogType: model.LogTypeRefund,
@@ -125,10 +128,7 @@ func RefundMidjourneyQuota(ctx context.Context, task *model.Midjourney, reason s
ModelName: CovertMjpActionToModelName(task.Action),
Quota: quota,
TokenId: task.TokenId,
- Other: map[string]interface{}{
- "task_id": task.MjId,
- "reason": reason,
- },
+ Other: other,
})
task.Quota = 0
diff --git a/service/quota_saturation_test.go b/service/quota_saturation_test.go
index da4ff3504f43..6f25448bea0a 100644
--- a/service/quota_saturation_test.go
+++ b/service/quota_saturation_test.go
@@ -6,6 +6,7 @@ import (
"testing"
"github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
@@ -33,10 +34,11 @@ func TestAttachQuotaSaturationNestsUnderAdminInfo(t *testing.T) {
},
}
- other := map[string]interface{}{"model_price": 0.004}
+ other := model.NewLogOther()
+ other.SetPublic("model_price", 0.004)
attachQuotaSaturation(ctx, relayInfo, other)
- adminInfo, ok := other["admin_info"].(map[string]interface{})
+ adminInfo, ok := other.Snapshot()["admin_info"].(map[string]interface{})
require.True(t, ok, "admin_info should be created")
sat, ok := adminInfo["quota_saturation"].(map[string]interface{})
require.True(t, ok, "quota_saturation should be nested under admin_info")
@@ -76,12 +78,11 @@ func TestAttachQuotaSaturationPreservesExistingAdminInfo(t *testing.T) {
relayInfo := &relaycommon.RelayInfo{
QuotaClamp: &common.QuotaClamp{Op: "QuotaFromFloat", Kind: common.QuotaClampUnderflow, Clamped: common.MinQuota},
}
- other := map[string]interface{}{
- "admin_info": map[string]interface{}{"admin_username": "root"},
- }
+ other := model.NewLogOther()
+ other.SetAdmin("admin_username", "root")
attachQuotaSaturation(ctx, relayInfo, other)
- adminInfo := other["admin_info"].(map[string]interface{})
+ adminInfo := other.Snapshot()["admin_info"].(map[string]interface{})
require.Equal(t, "root", adminInfo["admin_username"], "existing admin_info fields preserved")
require.NotNil(t, adminInfo["quota_saturation"])
}
@@ -93,10 +94,11 @@ func TestAttachQuotaSaturationNoClampNoMarker(t *testing.T) {
ctx, _ := gin.CreateTestContext(nil)
relayInfo := &relaycommon.RelayInfo{QuotaClamp: nil}
- other := map[string]interface{}{"model_price": 0.004}
+ other := model.NewLogOther()
+ other.SetPublic("model_price", 0.004)
attachQuotaSaturation(ctx, relayInfo, other)
- _, hasAdmin := other["admin_info"]
+ _, hasAdmin := other.Snapshot()["admin_info"]
require.False(t, hasAdmin, "no admin_info should be added when there is no clamp")
}
diff --git a/service/task_billing.go b/service/task_billing.go
index 78b834c622ff..e5e4df62a552 100644
--- a/service/task_billing.go
+++ b/service/task_billing.go
@@ -42,27 +42,27 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo, task *model
logContent = fmt.Sprintf("%s, 计算参数:%s", logContent, strings.Join(contents, ", "))
}
}
- other := make(map[string]interface{})
- other["is_task"] = true
- other["request_path"] = c.Request.URL.Path
- other["model_price"] = info.PriceData.ModelPrice
+ other := model.NewLogOther()
+ other.SetPublic("is_task", true)
+ other.SetPublic("request_path", c.Request.URL.Path)
+ other.SetPublic("model_price", info.PriceData.ModelPrice)
if info.PriceData.ModelRatio > 0 {
- other["model_ratio"] = info.PriceData.ModelRatio
+ other.SetPublic("model_ratio", info.PriceData.ModelRatio)
}
- other["group_ratio"] = info.PriceData.GroupRatioInfo.GroupRatio
+ other.SetPublic("group_ratio", info.PriceData.GroupRatioInfo.GroupRatio)
if info.PriceData.GroupRatioInfo.HasSpecialRatio {
- other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio
+ other.SetPublic("user_group_ratio", info.PriceData.GroupRatioInfo.GroupSpecialRatio)
}
if info.IsModelMapped {
- other["is_model_mapped"] = true
- other["upstream_model_name"] = info.UpstreamModelName
+ other.SetPublic("is_model_mapped", true)
+ other.SetPublic("upstream_model_name", info.UpstreamModelName)
}
if snap := info.TieredBillingSnapshot; snap != nil {
- other["billing_mode"] = "tiered_expr"
- other["expr_b64"] = base64.StdEncoding.EncodeToString([]byte(snap.ExprString))
- other["matched_tier"] = snap.EstimatedTier
+ other.SetPublic("billing_mode", "tiered_expr")
+ other.SetPublic("expr_b64", base64.StdEncoding.EncodeToString([]byte(snap.ExprString)))
+ other.SetPublic("matched_tier", snap.EstimatedTier)
if len(snap.UsageFacts) > 0 {
- other["usage_facts"] = snap.UsageFacts
+ other.SetPublic("usage_facts", snap.UsageFacts)
}
}
appendTaskLogInfo(task, other)
@@ -134,43 +134,43 @@ func taskAdjustTokenQuota(ctx context.Context, task *model.Task, delta int) {
}
// taskBillingOther 从 task 的 BillingContext 构建日志 Other 字段。
-func taskBillingOther(task *model.Task) map[string]interface{} {
- other := make(map[string]interface{})
+func taskBillingOther(task *model.Task) *model.LogOther {
+ other := model.NewLogOther()
if bc := task.PrivateData.BillingContext; bc != nil {
- other["model_price"] = bc.ModelPrice
+ other.SetPublic("model_price", bc.ModelPrice)
if bc.ModelRatio > 0 {
- other["model_ratio"] = bc.ModelRatio
+ other.SetPublic("model_ratio", bc.ModelRatio)
}
- other["group_ratio"] = bc.GroupRatio
+ other.SetPublic("group_ratio", bc.GroupRatio)
if priceData := taskBillingContextPriceData(bc); priceData != nil {
for k, v := range priceData.OtherRatios() {
- other[k] = v
+ other.SetPublic(k, v)
}
}
if snap := bc.TieredSnapshot; snap != nil {
- other["billing_mode"] = "tiered_expr"
- other["expr_b64"] = base64.StdEncoding.EncodeToString([]byte(snap.ExprString))
- other["matched_tier"] = snap.EstimatedTier
+ other.SetPublic("billing_mode", "tiered_expr")
+ other.SetPublic("expr_b64", base64.StdEncoding.EncodeToString([]byte(snap.ExprString)))
+ other.SetPublic("matched_tier", snap.EstimatedTier)
if len(snap.UsageFacts) > 0 {
- other["usage_facts"] = snap.UsageFacts
+ other.SetPublic("usage_facts", snap.UsageFacts)
}
}
}
props := task.Properties
if props.UpstreamModelName != "" && props.UpstreamModelName != props.OriginModelName {
- other["is_model_mapped"] = true
- other["upstream_model_name"] = props.UpstreamModelName
+ other.SetPublic("is_model_mapped", true)
+ other.SetPublic("upstream_model_name", props.UpstreamModelName)
}
appendTaskLogInfo(task, other)
return other
}
-func appendTaskLogInfo(task *model.Task, other map[string]interface{}) {
+func appendTaskLogInfo(task *model.Task, other *model.LogOther) {
if task == nil || other == nil {
return
}
if task.TaskID != "" {
- other["task_id"] = task.TaskID
+ other.SetPublic("task_id", task.TaskID)
}
if task.PrivateData.Execution != nil {
AppendTaskPluginAuditInfo(other, task.PrivateData.Execution.TaskPlugin)
@@ -178,16 +178,11 @@ func appendTaskLogInfo(task *model.Task, other map[string]interface{}) {
if task.PrivateData.UpstreamTaskID == "" && task.PrivateData.NodeName == "" {
return
}
- rootInfo, ok := other["root_info"].(map[string]interface{})
- if !ok || rootInfo == nil {
- rootInfo = map[string]interface{}{}
- other["root_info"] = rootInfo
- }
if task.PrivateData.UpstreamTaskID != "" {
- rootInfo["upstream_task_id"] = task.PrivateData.UpstreamTaskID
+ other.SetRoot("upstream_task_id", task.PrivateData.UpstreamTaskID)
}
if task.PrivateData.NodeName != "" {
- rootInfo["node_name"] = task.PrivateData.NodeName
+ other.SetRoot("node_name", task.PrivateData.NodeName)
}
}
@@ -234,8 +229,8 @@ func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) bool
// 4. 记录日志
other := taskBillingOther(task)
- other["task_id"] = task.TaskID
- other["reason"] = reason
+ other.SetPublic("task_id", task.TaskID)
+ other.SetPublic("reason", reason)
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
UserId: task.UserId,
LogType: model.LogTypeRefund,
@@ -310,9 +305,9 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int
logQuota = -quotaDelta
}
other := taskBillingOther(task)
- other["task_id"] = task.TaskID
- other["pre_consumed_quota"] = preConsumedQuota
- other["actual_quota"] = actualQuota
+ other.SetPublic("task_id", task.TaskID)
+ other.SetPublic("pre_consumed_quota", preConsumedQuota)
+ other.SetPublic("actual_quota", actualQuota)
for _, clamp := range clamps {
attachQuotaSaturationToOther(other, clamp)
}
diff --git a/service/task_billing_test.go b/service/task_billing_test.go
index 87cc35778b4d..6be18bc9f862 100644
--- a/service/task_billing_test.go
+++ b/service/task_billing_test.go
@@ -163,6 +163,13 @@ func makeTask(userId, channelId, quota, tokenId int, billingSource string, subsc
}
}
+func taskBillingOtherMap(t *testing.T, other *model.LogOther) map[string]interface{} {
+ t.Helper()
+ var values map[string]interface{}
+ require.NoError(t, common.UnmarshalJsonStr(other.JSONString(), &values))
+ return values
+}
+
func TestPriceDataOtherRatiosFilterAndSnapshot(t *testing.T) {
priceData := types.PriceData{}
@@ -227,7 +234,7 @@ func TestTaskBillingOtherFiltersHistoricalOtherRatios(t *testing.T) {
"inf": math.Inf(1),
}
- other := taskBillingOther(task)
+ other := taskBillingOtherMap(t, taskBillingOther(task))
assert.Equal(t, 2.0, other["seconds"])
assert.Equal(t, 1.0, other["identity"])
@@ -253,7 +260,7 @@ func TestTaskBillingOtherIncludesTieredSnapshotAndKeepsUsageFactsNested(t *testi
},
}
- other := taskBillingOther(task)
+ other := taskBillingOtherMap(t, taskBillingOther(task))
assert.Equal(t, "tiered_expr", other["billing_mode"])
assert.Equal(t, base64.StdEncoding.EncodeToString([]byte(expression)), other["expr_b64"])
@@ -262,7 +269,7 @@ func TestTaskBillingOtherIncludesTieredSnapshotAndKeepsUsageFactsNested(t *testi
require.True(t, ok)
assert.Equal(t, map[string]any{
"resolution": "720P",
- "seconds": 5,
+ "seconds": float64(5),
}, facts)
assert.NotContains(t, other, "resolution")
assert.NotContains(t, other, "seconds")
@@ -277,7 +284,7 @@ func TestTaskBillingOtherOmitsEmptyUsageFacts(t *testing.T) {
UsageFacts: map[string]any{},
}
- other := taskBillingOther(task)
+ other := taskBillingOtherMap(t, taskBillingOther(task))
assert.Equal(t, "tiered_expr", other["billing_mode"])
assert.Equal(t, base64.StdEncoding.EncodeToString([]byte(expression)), other["expr_b64"])
@@ -401,7 +408,7 @@ func TestTaskBillingOtherSeparatesPluginAndRootDiagnostics(t *testing.T) {
},
}
- other := taskBillingOther(task)
+ other := taskBillingOtherMap(t, taskBillingOther(task))
assert.Equal(t, "task_public", other["task_id"])
adminInfo, ok := other["admin_info"].(map[string]interface{})
@@ -421,7 +428,7 @@ func TestTaskBillingOtherSeparatesPluginAndRootDiagnostics(t *testing.T) {
assert.Equal(t, "node-a", rootInfo["node_name"])
runtimeInfo, ok := rootInfo["task_plugin"].(map[string]interface{})
require.True(t, ok)
- assert.Equal(t, uint64(42), runtimeInfo["generation"])
+ assert.Equal(t, float64(42), runtimeInfo["generation"])
assert.NotContains(t, runtimeInfo, "author")
}
diff --git a/service/task_plugin_audit.go b/service/task_plugin_audit.go
index 618e2433a713..ccfc88825df6 100644
--- a/service/task_plugin_audit.go
+++ b/service/task_plugin_audit.go
@@ -51,15 +51,10 @@ func TaskExecutionSnapshotFromContext(ctx *gin.Context) *model.TaskExecutionSnap
// AppendTaskPluginAuditInfo writes role-separated, credential-free plugin
// provenance into a usage log.
-func AppendTaskPluginAuditInfo(other map[string]interface{}, snapshot *model.TaskPluginSnapshot) {
+func AppendTaskPluginAuditInfo(other *model.LogOther, snapshot *model.TaskPluginSnapshot) {
if other == nil || snapshot == nil || snapshot.Key == "" {
return
}
- adminInfo, ok := other["admin_info"].(map[string]interface{})
- if !ok || adminInfo == nil {
- adminInfo = map[string]interface{}{}
- other["admin_info"] = adminInfo
- }
taskPlugin := map[string]interface{}{
"key": snapshot.Key,
"name": snapshot.Name,
@@ -72,24 +67,18 @@ func AppendTaskPluginAuditInfo(other map[string]interface{}, snapshot *model.Tas
}
taskPlugin["author"] = author
}
- adminInfo["task_plugin"] = taskPlugin
-
- rootInfo, ok := other["root_info"].(map[string]interface{})
- if !ok || rootInfo == nil {
- rootInfo = map[string]interface{}{}
- other["root_info"] = rootInfo
- }
- rootInfo["task_plugin"] = map[string]interface{}{
+ other.SetAdmin("task_plugin", taskPlugin)
+ other.SetRoot("task_plugin", map[string]interface{}{
"key": snapshot.Key,
"version": snapshot.Version,
"api_version": snapshot.APIVersion,
"generation": snapshot.Generation,
- }
+ })
}
// AppendTaskPluginContextAuditInfo is used before a task row exists, such as
// an upstream submission error log.
-func AppendTaskPluginContextAuditInfo(ctx *gin.Context, other map[string]interface{}) {
+func AppendTaskPluginContextAuditInfo(ctx *gin.Context, other *model.LogOther) {
execution := TaskExecutionSnapshotFromContext(ctx)
if execution == nil {
return
diff --git a/service/text_quota.go b/service/text_quota.go
index 604f61b646db..83fe6ff3e808 100644
--- a/service/text_quota.go
+++ b/service/text_quota.go
@@ -31,11 +31,11 @@ type ToolSurchargeItem struct {
Price float64 `json:"price"`
}
-func appendToolSurchargeLogInfo(other map[string]interface{}, items []ToolSurchargeItem) {
+func appendToolSurchargeLogInfo(other *model.LogOther, items []ToolSurchargeItem) {
if len(items) == 0 {
return
}
- other["tool_surcharges"] = items
+ other.SetPublic("tool_surcharges", items)
}
type textQuotaSummary struct {
@@ -463,7 +463,7 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
}
logContent := strings.Join(extraContent, ", ")
- var other map[string]interface{}
+ var other *model.LogOther
if summary.IsClaudeUsageSemantic {
other = GenerateClaudeOtherInfo(ctx, relayInfo,
summary.ModelRatio, summary.GroupRatio, summary.CompletionRatio,
@@ -472,50 +472,50 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
summary.CacheCreationTokens5m, summary.CacheCreationRatio5m,
summary.CacheCreationTokens1h, summary.CacheCreationRatio1h,
summary.ModelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
- other["usage_semantic"] = "anthropic"
+ other.SetPublic("usage_semantic", "anthropic")
} else {
other = GenerateTextOtherInfo(ctx, relayInfo, summary.ModelRatio, summary.GroupRatio, summary.CompletionRatio, summary.CacheTokens, summary.CacheRatio, summary.ModelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
}
appendUsageBillingPathForLog(other, common.GetContextKeyBool(ctx, constant.ContextKeyLocalCountTokens), originUsage)
if adminRejectReason != "" {
- other["reject_reason"] = adminRejectReason
+ other.SetAdmin("reject_reason", adminRejectReason)
}
if summary.ImageTokens != 0 {
- other["image"] = true
- other["image_ratio"] = summary.ImageRatio
- other["image_output"] = summary.ImageTokens
+ other.SetPublic("image", true)
+ other.SetPublic("image_ratio", summary.ImageRatio)
+ other.SetPublic("image_output", summary.ImageTokens)
}
appendToolSurchargeLogInfo(other, summary.ToolSurchargeItems)
if summary.AudioInputPrice > 0 && summary.AudioTokens > 0 {
- other["audio_input_seperate_price"] = true
- other["audio_input_token_count"] = summary.AudioTokens
- other["audio_input_price"] = summary.AudioInputPrice
+ other.SetPublic("audio_input_seperate_price", true)
+ other.SetPublic("audio_input_token_count", summary.AudioTokens)
+ other.SetPublic("audio_input_price", summary.AudioInputPrice)
}
if summary.CacheCreationTokens > 0 {
- other["cache_creation_tokens"] = summary.CacheCreationTokens
- other["cache_creation_ratio"] = summary.CacheCreationRatio
+ other.SetPublic("cache_creation_tokens", summary.CacheCreationTokens)
+ other.SetPublic("cache_creation_ratio", summary.CacheCreationRatio)
}
if summary.CacheCreationTokens5m > 0 {
- other["cache_creation_tokens_5m"] = summary.CacheCreationTokens5m
- other["cache_creation_ratio_5m"] = summary.CacheCreationRatio5m
+ other.SetPublic("cache_creation_tokens_5m", summary.CacheCreationTokens5m)
+ other.SetPublic("cache_creation_ratio_5m", summary.CacheCreationRatio5m)
}
if summary.CacheCreationTokens1h > 0 {
- other["cache_creation_tokens_1h"] = summary.CacheCreationTokens1h
- other["cache_creation_ratio_1h"] = summary.CacheCreationRatio1h
+ other.SetPublic("cache_creation_tokens_1h", summary.CacheCreationTokens1h)
+ other.SetPublic("cache_creation_ratio_1h", summary.CacheCreationRatio1h)
}
cacheWriteTokens := cacheWriteTokensTotal(summary)
if cacheWriteTokens > 0 {
// cache_write_tokens: normalized cache creation total for UI display.
// If split 5m/1h values are present, this is their sum; otherwise it falls back
// to cache_creation_tokens.
- other["cache_write_tokens"] = cacheWriteTokens
+ other.SetPublic("cache_write_tokens", cacheWriteTokens)
}
if relayInfo.GetFinalRequestRelayFormat() != types.RelayFormatClaude && billingUsage != nil && billingUsage.UsageSource != "" && billingUsage.InputTokens > 0 {
// input_tokens_total: explicit normalized total input used by the usage log UI.
// Only write this field when upstream/current conversion has already provided a
// reliable total input value and tagged the usage source. Do not infer it from
// prompt/cache fields here, otherwise old upstream payloads may be double-counted.
- other["input_tokens_total"] = billingUsage.InputTokens
+ other.SetPublic("input_tokens_total", billingUsage.InputTokens)
}
if tieredBillingApplied {
InjectTieredBillingInfo(other, relayInfo, tieredResult)
diff --git a/service/text_quota_test.go b/service/text_quota_test.go
index 845801fbf805..0ec24db863d7 100644
--- a/service/text_quota_test.go
+++ b/service/text_quota_test.go
@@ -8,6 +8,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
@@ -445,20 +446,21 @@ func TestUsageBillingPathForLog(t *testing.T) {
}
func TestAppendUsageBillingPathForLogWritesAdminInfo(t *testing.T) {
- other := map[string]interface{}{
- "admin_info": map[string]interface{}{},
- }
+ other := model.NewLogOther()
appendUsageBillingPathForLog(other, true, &dto.Usage{
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{InputTokens: 1}),
})
- adminInfo, ok := other["admin_info"].(map[string]interface{})
+ var values map[string]interface{}
+ require.NoError(t, common.UnmarshalJsonStr(other.JSONString(), &values))
+ adminInfo, ok := values["admin_info"].(map[string]interface{})
require.True(t, ok)
require.Equal(t, usageBillingPathAnthropic, adminInfo["usage_billing_path"])
- other = map[string]interface{}{}
+ other = model.NewLogOther()
appendUsageBillingPathForLog(other, true, nil)
- adminInfo, ok = other["admin_info"].(map[string]interface{})
+ require.NoError(t, common.UnmarshalJsonStr(other.JSONString(), &values))
+ adminInfo, ok = values["admin_info"].(map[string]interface{})
require.True(t, ok)
require.Equal(t, usageBillingPathLocal, adminInfo["usage_billing_path"])
}
@@ -1118,9 +1120,9 @@ func TestCalculateTextToolCallSurchargeGeminiFunctionCall(t *testing.T) {
assert.Equal(t, 2, summary.ToolSurchargeItems[0].Count)
assert.Equal(t, 5.0, summary.ToolSurchargeItems[0].Price)
- other := map[string]interface{}{}
+ other := model.NewLogOther()
appendToolSurchargeLogInfo(other, summary.ToolSurchargeItems)
- assert.Equal(t, summary.ToolSurchargeItems, other["tool_surcharges"])
+ assert.Equal(t, summary.ToolSurchargeItems, other.Snapshot()["tool_surcharges"])
}
func TestCalculateTextToolCallSurchargeImageGenerationDefaultPrice(t *testing.T) {
@@ -1214,15 +1216,16 @@ func TestAppendToolSurchargeLogInfoWritesOnlyStructuredFields(t *testing.T) {
{Name: dto.BuildInToolWebSearch, Count: 2, Price: 10},
{Name: dto.BuildInToolImageGeneration, Count: 1, Price: 150},
}
- other := map[string]interface{}{}
+ other := model.NewLogOther()
appendToolSurchargeLogInfo(other, items)
- assert.Equal(t, items, other["tool_surcharges"])
- assert.NotContains(t, other, "web_search")
- assert.NotContains(t, other, "web_search_call_count")
- assert.NotContains(t, other, "web_search_price")
- assert.NotContains(t, other, "file_search")
- assert.NotContains(t, other, "image_generation_call")
- assert.NotContains(t, other, "image_generation_call_price")
+ fields := other.Snapshot()
+ assert.Equal(t, items, fields["tool_surcharges"])
+ assert.NotContains(t, fields, "web_search")
+ assert.NotContains(t, fields, "web_search_call_count")
+ assert.NotContains(t, fields, "web_search_price")
+ assert.NotContains(t, fields, "file_search")
+ assert.NotContains(t, fields, "image_generation_call")
+ assert.NotContains(t, fields, "image_generation_call_price")
}
diff --git a/service/violation_fee.go b/service/violation_fee.go
index e063d4d8b408..dba30ecdf919 100644
--- a/service/violation_fee.go
+++ b/service/violation_fee.go
@@ -134,7 +134,8 @@ func ChargeViolationFeeIfNeeded(ctx *gin.Context, relayInfo *relaycommon.RelayIn
tokenName := ctx.GetString("token_name")
oai := apiErr.ToOpenAIError()
- other := map[string]any{
+ other := model.NewLogOther()
+ other.MergePublic(map[string]interface{}{
"violation_fee": true,
"violation_fee_code": string(types.ErrorCodeViolationFeeGrokCSAM),
"fee_quota": feeQuota,
@@ -144,7 +145,7 @@ func ChargeViolationFeeIfNeeded(ctx *gin.Context, relayInfo *relaycommon.RelayIn
"upstream_error_type": oai.Type,
"upstream_error_code": fmt.Sprintf("%v", oai.Code),
"violation_fee_marker": CSAMViolationMarker,
- }
+ })
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
diff --git a/web/src/features/usage-logs/components/__tests__/reject-reason.test.tsx b/web/src/features/usage-logs/components/__tests__/reject-reason.test.tsx
new file mode 100644
index 000000000000..67f977219d56
--- /dev/null
+++ b/web/src/features/usage-logs/components/__tests__/reject-reason.test.tsx
@@ -0,0 +1,101 @@
+/*
+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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, test } from 'vitest'
+
+import type { UsageLog } from '../../data/schema'
+import type { LogOtherData } from '../../types'
+import { DetailsDialog } from '../dialogs/details-dialog'
+
+const queryClients: QueryClient[] = []
+
+function makeLog(other: LogOtherData): UsageLog {
+ return {
+ id: 1,
+ user_id: 1,
+ created_at: 1,
+ type: 5,
+ content: 'request rejected',
+ username: 'user',
+ token_name: 'token',
+ model_name: 'gpt-test',
+ quota: 0,
+ prompt_tokens: 0,
+ completion_tokens: 0,
+ use_time: 0,
+ is_stream: false,
+ channel: 1,
+ channel_name: 'channel',
+ token_id: 1,
+ group: 'default',
+ ip: '',
+ other: JSON.stringify(other),
+ request_id: 'req-1',
+ upstream_request_id: '',
+ }
+}
+
+function renderDetails(isAdmin: boolean): void {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ })
+ const freshAt = Date.now() + 60_000
+ queryClient.setQueryData(['status'], {}, { updatedAt: freshAt })
+ queryClients.push(queryClient)
+
+ render(
+
+ undefined}
+ />
+
+ )
+}
+
+afterEach(() => {
+ for (const queryClient of queryClients) {
+ queryClient.clear()
+ }
+ queryClients.length = 0
+})
+
+describe('usage log reject reason', () => {
+ test('shows the nested admin reject reason to admins', () => {
+ renderDetails(true)
+
+ expect(screen.getByText('Reject Reason')).toBeInTheDocument()
+ expect(screen.getByText('blocked by channel policy')).toBeInTheDocument()
+ })
+
+ test('hides the nested admin reject reason from non-admin users', () => {
+ renderDetails(false)
+
+ expect(screen.queryByText('Reject Reason')).toBeNull()
+ expect(screen.queryByText('blocked by channel policy')).toBeNull()
+ })
+})
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 2f0442c6c3ef..8b7861db7a65 100644
--- a/web/src/features/usage-logs/components/dialogs/details-dialog.tsx
+++ b/web/src/features/usage-logs/components/dialogs/details-dialog.tsx
@@ -841,13 +841,13 @@ export function DetailsDialog(props: DetailsDialogProps) {
)}
{/* Reject reason (admin only) */}
- {props.isAdmin && other?.reject_reason && (
+ {props.isAdmin && adminInfo?.reject_reason && (
}
label={t('Reject Reason')}
variant='danger'
>
- {other.reject_reason}
+ {adminInfo.reject_reason}
)}
diff --git a/web/src/features/usage-logs/types.ts b/web/src/features/usage-logs/types.ts
index 6d51e396b8e7..26a122e60723 100644
--- a/web/src/features/usage-logs/types.ts
+++ b/web/src/features/usage-logs/types.ts
@@ -142,6 +142,8 @@ export interface LogOtherData {
original: number
clamped: number
}
+ // Reject / intercept reason (admin only)
+ reject_reason?: string
task_plugin?: TaskPluginInfo
}
root_info?: {
@@ -236,8 +238,6 @@ export interface LogOtherData {
violation_fee_code?: string
violation_fee_marker?: string
fee_quota?: number
- // Reject / intercept reason (admin)
- reject_reason?: string
// Task-related fields (for refund logs, type=6)
is_task?: boolean
task_id?: string
From 219c9e06341f1b100e2c572a5f97c45f151fd280 Mon Sep 17 00:00:00 2001
From: Orrin <12270262+CreatorEdition@users.noreply.github.com>
Date: Thu, 3 Sep 2026 14:26:35 +0800
Subject: [PATCH 89/99] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=8C=BF=E5=90=8D?=
=?UTF-8?q?=E5=86=B7=E5=90=AF=E5=8A=A8=E4=B8=8E=E5=85=AC=E5=BC=80=E5=86=85?=
=?UTF-8?q?=E5=AE=B9=E6=8E=A5=E5=8F=A3=E7=9A=84=E9=87=8D=E5=A4=8D=E5=9B=9E?=
=?UTF-8?q?=E6=BA=90=E8=AF=B7=E6=B1=82=20(#7166)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix: reduce public bootstrap requests and revalidate content
* fix(controller): use a weak ETag for revalidated public JSON
/api is gzip-compressed by middleware that runs after the handler returns,
and the validator is computed over the uncompressed body. The compressed and
identity forms of one payload therefore share a validator, which a strong ETag
must not do -- it asserts byte-for-byte equality across representations
(RFC 9110 8.8.1). Serve W/ instead.
Weak comparison ignores W/ on both operands, so etagMatches now strips it from
the served validator as well as from each candidate. Stripping only the
candidate would make a weak served validator match nothing and silently
disable every 304.
Vary: Accept-Encoding stays. Weakening the validator makes revalidation
correct, but it does not separate the two encodings in a shared cache.
* fix(test): align response cookie helper name
* fix(auth): revalidate stale route sessions
* Update web/src/features/about/api.ts
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* test: remove newly added PR tests
---------
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
---
controller/misc.go | 30 ++++-----
controller/revalidated_response.go | 87 +++++++++++++++++++++++++
docs/authentication.md | 3 +
service/auth_session.go | 52 +++++++++++++++
web/src/features/about/api.ts | 9 ++-
web/src/features/home/api.ts | 7 +-
web/src/features/legal/api.ts | 17 +++--
web/src/lib/api.ts | 10 ++-
web/src/lib/auth-session.ts | 31 ++++++++-
web/src/lib/session-hint.ts | 62 ++++++++++++++++++
web/src/routes/(auth)/sign-in.tsx | 5 ++
web/src/routes/_authenticated/route.tsx | 9 ++-
12 files changed, 296 insertions(+), 26 deletions(-)
create mode 100644 controller/revalidated_response.go
create mode 100644 web/src/lib/session-hint.ts
diff --git a/controller/misc.go b/controller/misc.go
index 572c8f9ffd18..a02e77a20b97 100644
--- a/controller/misc.go
+++ b/controller/misc.go
@@ -176,42 +176,40 @@ func GetStatus(c *gin.Context) {
func GetNotice(c *gin.Context) {
common.OptionMapRWMutex.RLock()
- defer common.OptionMapRWMutex.RUnlock()
- c.JSON(http.StatusOK, gin.H{
+ notice := common.OptionMap["Notice"]
+ common.OptionMapRWMutex.RUnlock()
+ serveRevalidatedJSON(c, gin.H{
"success": true,
"message": "",
- "data": common.OptionMap["Notice"],
+ "data": notice,
})
- return
}
func GetAbout(c *gin.Context) {
common.OptionMapRWMutex.RLock()
- defer common.OptionMapRWMutex.RUnlock()
- c.JSON(http.StatusOK, gin.H{
+ about := common.OptionMap["About"]
+ common.OptionMapRWMutex.RUnlock()
+ serveRevalidatedJSON(c, gin.H{
"success": true,
"message": "",
- "data": common.OptionMap["About"],
+ "data": about,
})
- return
}
func GetUserAgreement(c *gin.Context) {
- c.JSON(http.StatusOK, gin.H{
+ serveRevalidatedJSON(c, gin.H{
"success": true,
"message": "",
"data": system_setting.GetLegalSettings().UserAgreement,
})
- return
}
func GetPrivacyPolicy(c *gin.Context) {
- c.JSON(http.StatusOK, gin.H{
+ serveRevalidatedJSON(c, gin.H{
"success": true,
"message": "",
"data": system_setting.GetLegalSettings().PrivacyPolicy,
})
- return
}
func GetMidjourney(c *gin.Context) {
@@ -227,13 +225,13 @@ func GetMidjourney(c *gin.Context) {
func GetHomePageContent(c *gin.Context) {
common.OptionMapRWMutex.RLock()
- defer common.OptionMapRWMutex.RUnlock()
- c.JSON(http.StatusOK, gin.H{
+ homePageContent := common.OptionMap["HomePageContent"]
+ common.OptionMapRWMutex.RUnlock()
+ serveRevalidatedJSON(c, gin.H{
"success": true,
"message": "",
- "data": common.OptionMap["HomePageContent"],
+ "data": homePageContent,
})
- return
}
func SendEmailVerification(c *gin.Context) {
diff --git a/controller/revalidated_response.go b/controller/revalidated_response.go
new file mode 100644
index 000000000000..133c0294a42f
--- /dev/null
+++ b/controller/revalidated_response.go
@@ -0,0 +1,87 @@
+package controller
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "net/http"
+ "strings"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/gin-gonic/gin"
+)
+
+// serveRevalidatedJSON writes payload as JSON with a weak content-derived ETag
+// and answers conditional requests with 304 Not Modified.
+//
+// Intended for small, public, admin-editable payloads (notice, home page
+// content) that every anonymous visitor fetches on page load. The goal is to
+// make those fetches cheap without ever serving stale content:
+//
+// - The ETag is a hash of the response body, so it is identical across
+// replicas. Deriving it from a timestamp would not be, and the Option table
+// has no updated_at column to derive one from anyway.
+// - The validator is weak (W/ prefixed) because /api is gzip-compressed by
+// middleware that runs after this handler returns. The hash is computed over
+// the uncompressed body, so the compressed and identity forms of one payload
+// share a validator, and a strong ETag asserts byte-for-byte equality that
+// does not hold across encodings (RFC 9110 §8.8.1). Weakening it costs
+// nothing here: conditional GET compares weakly anyway, and these payloads
+// are a few hundred bytes of JSON that no client Range-requests.
+// - Cache-Control is "no-cache", which means "may be stored, but must be
+// revalidated before reuse" (RFC 9111 §5.2.2.4). Browsers and CDNs both
+// revalidate on every request, so an admin edit takes effect immediately.
+// max-age/s-maxage are deliberately not set: upstream cannot assume how
+// long any given deployment tolerates a stale notice.
+// - Vary: Accept-Encoding is still required. Weakening the validator makes
+// revalidation correct, but it does not separate the two encodings in a
+// shared cache. Without Vary, a cache holding the gzip copy would hand those
+// bytes to a client that never sent Accept-Encoding: gzip.
+func serveRevalidatedJSON(c *gin.Context, payload any) {
+ body, err := common.Marshal(payload)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "success": false,
+ "message": err.Error(),
+ })
+ return
+ }
+
+ digest := sha256.Sum256(body)
+ etag := `W/"` + hex.EncodeToString(digest[:]) + `"`
+
+ c.Header("ETag", etag)
+ c.Header("Cache-Control", "no-cache")
+ c.Header("Vary", "Accept-Encoding")
+
+ if etagMatches(c.GetHeader("If-None-Match"), etag) {
+ c.Status(http.StatusNotModified)
+ return
+ }
+
+ c.Data(http.StatusOK, "application/json; charset=utf-8", body)
+}
+
+// etagMatches reports whether an If-None-Match header field matches etag,
+// using the weak comparison required for conditional GET (RFC 9110 §13.1.2).
+// The field is a comma-separated list of entity-tags or the wildcard "*".
+//
+// Weak comparison ignores the W/ prefix on both operands, so it must be
+// stripped from the served etag as well as from each candidate. Stripping only
+// the candidate would make a weak served validator match nothing, silently
+// disabling 304 responses.
+func etagMatches(ifNoneMatch string, etag string) bool {
+ ifNoneMatch = strings.TrimSpace(ifNoneMatch)
+ if ifNoneMatch == "" {
+ return false
+ }
+ if ifNoneMatch == "*" {
+ return true
+ }
+ etag = strings.TrimPrefix(etag, "W/")
+ for _, candidate := range strings.Split(ifNoneMatch, ",") {
+ if strings.TrimPrefix(strings.TrimSpace(candidate), "W/") == etag {
+ return true
+ }
+ }
+ return false
+}
diff --git a/docs/authentication.md b/docs/authentication.md
index b7b5f870ee00..0ea9d25218ae 100644
--- a/docs/authentication.md
+++ b/docs/authentication.md
@@ -6,6 +6,7 @@
- Access Token 是有效期 15 分钟的 JWT,只保存在浏览器内存中,通过 `Authorization: Bearer ` 发送。
- Refresh Token 是随机不透明值,有效期最长 30 天。浏览器只通过 `HttpOnly`、`SameSite=Strict` Cookie 持有它;服务端仅保存 HMAC 摘要,并在每次刷新时轮换。
+- `new_api_has_session` 是 Refresh Cookie 的会话提示,值恒为 `1`,`Path=/`、非 `HttpOnly`,与 Refresh Cookie 同时写入、同时清除、同一过期时间。它只声明"曾签发过 Refresh Cookie",不含任何凭据,也不参与任何鉴权判定;伪造它唯一的效果是自费一次注定失败的 refresh。它存在的原因是 Refresh Cookie 被 `HttpOnly` 和 `Path=/api/user/auth` 双重限制,`/` 上的页面无法判断自己是否匿名,否则每次冷启动都要发一次注定 401 的 refresh,而该请求还会占用按 IP 计数的 `CriticalRateLimit` 配额。
- `user_sessions` 是登录会话控制面,记录设备、IP、登录方式、最后活跃时间、到期时间和撤销状态。数据库中的 Session 状态是最终权威;撤销传播速度取决于下文所述的 Redis 拓扑。
- 用户的密码、状态、角色或安全因子发生安全相关变化时,`auth_version` 会递增并使旧登录会话失效。订阅带来的分组升降级只刷新授权缓存,不会退出任何登录设备。
- Redis 缓存保存用户鉴权快照和登录会话快照。版本栅栏和撤销 tombstone 防止旧缓存重新授权;Session 快照使用跟随 `SYNC_FREQUENCY` 的短 TTL,缓存未命中或未启用 Redis 时回退到数据库校验。
@@ -70,6 +71,8 @@
前端将冷启动状态与登录状态分开管理。网络或服务端临时故障允许后续导航重试 refresh;服务端确认 Refresh Cookie 无效时才进入已完成的匿名状态。内存 SID 与 Cookie SID 不一致时,客户端清除旧内存身份并在不携带旧 SID 的情况下重试一次。
+公开页面的冷启动会先读 `new_api_has_session`:提示不存在且内存中没有任何身份时跳过 refresh,直接按匿名渲染,且**不**把这次跳过记为已完成的匿名判定——跳过只是延后,不是服务端结论。会依据鉴权结果做跳转的位置(受保护路由与登录页)不看提示,内存为空时一律回源。因此提示缺失但 Refresh Cookie 有效的用户(该 Cookie 上线前建立的会话,或只清理了 `/` 站点数据的浏览器)会在公开页显示为匿名,并在进入上述任一位置时自动恢复登录态,不需要重新输入密码。提示因服务端撤销而过期时,那次 refresh 返回 401 并在同一响应里清除提示,浪费的请求只发生一次。
+
## Session 签发限额与保留策略
服务端在所有登录方式的统一 Session 签发出口执行两级账户限制:
diff --git a/service/auth_session.go b/service/auth_session.go
index ffe0cb731ab0..432c06aeb251 100644
--- a/service/auth_session.go
+++ b/service/auth_session.go
@@ -15,6 +15,13 @@ import (
const RefreshCookieName = "new_api_refresh"
+// SessionHintCookieName is the script-readable companion to RefreshCookieName.
+// See writeSessionHintCookie for why it exists and what it is not.
+const SessionHintCookieName = "new_api_has_session"
+
+// SessionHintCookieValue is the only value the hint ever carries.
+const SessionHintCookieValue = "1"
+
var (
ErrLoginSessionInvalid = errors.New("login session is invalid")
ErrLoginSessionRevoked = errors.New("login session is revoked")
@@ -310,6 +317,7 @@ func WriteRefreshCookie(c *gin.Context, rawToken string) {
Secure: common.SessionCookieSecure,
SameSite: http.SameSiteStrictMode,
})
+ writeSessionHintCookie(c, maxAge, expiresAt)
}
func ClearRefreshCookie(c *gin.Context) {
@@ -323,6 +331,50 @@ func ClearRefreshCookie(c *gin.Context) {
Secure: common.SessionCookieSecure,
SameSite: http.SameSiteStrictMode,
})
+ clearSessionHintCookie(c)
+}
+
+// writeSessionHintCookie mirrors the Refresh Cookie's lifetime with a
+// script-readable marker. The Refresh Cookie itself is HttpOnly and scoped to
+// /api/user/auth, so a page at / cannot tell whether a login session exists;
+// without this hint the frontend has to POST /api/user/auth/refresh on every
+// cold boot just to learn that an anonymous visitor is anonymous. That request
+// is guaranteed to 401 and still consumes a slot of the IP-keyed
+// CriticalRateLimit budget shared by everyone behind the same address.
+//
+// The value is the constant "1" and carries no credential: it states that a
+// Refresh Cookie was issued, never who for. Authorization still derives solely
+// from the Refresh Cookie and the Access Token, so forging this hint only costs
+// the forger the round trip it was meant to avoid.
+//
+// It must be written and cleared in lockstep with the Refresh Cookie, which is
+// why it lives inside these two helpers rather than at their call sites: both
+// cookies then ride the same response with the same expiry, and no login path
+// can set one without the other.
+func writeSessionHintCookie(c *gin.Context, maxAge int, expiresAt time.Time) {
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: SessionHintCookieName,
+ Value: SessionHintCookieValue,
+ Path: "/",
+ MaxAge: maxAge,
+ Expires: expiresAt,
+ HttpOnly: false,
+ Secure: common.SessionCookieSecure,
+ SameSite: http.SameSiteStrictMode,
+ })
+}
+
+func clearSessionHintCookie(c *gin.Context) {
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: SessionHintCookieName,
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ Expires: time.Unix(1, 0),
+ HttpOnly: false,
+ Secure: common.SessionCookieSecure,
+ SameSite: http.SameSiteStrictMode,
+ })
}
func issueAuthBundle(session *model.UserSession, rawRefreshToken string, current bool) (*AuthBundle, error) {
diff --git a/web/src/features/about/api.ts b/web/src/features/about/api.ts
index de604be6324d..5a9286d95814 100644
--- a/web/src/features/about/api.ts
+++ b/web/src/features/about/api.ts
@@ -20,7 +20,12 @@ import { api } from '@/lib/api'
import type { AboutResponse } from './types'
-export async function getAboutContent() {
- const res = await api.get('/api/about')
+export async function getAboutContent(): Promise {
+ // See getNotice in @/lib/api: the global `Cache-Control: no-store` is dropped
+ // so the browser can hold an ETag and revalidate, letting the server answer
+ // 304. Server-side `no-cache` keeps admin edits immediate.
+ const res = await api.get('/api/about', {
+ headers: { 'Cache-Control': null },
+ })
return res.data
}
diff --git a/web/src/features/home/api.ts b/web/src/features/home/api.ts
index e15928ec865a..a26dc858fc6a 100644
--- a/web/src/features/home/api.ts
+++ b/web/src/features/home/api.ts
@@ -29,6 +29,11 @@ import type { HomePageContentResponse } from './types'
* Returns Markdown/HTML content or iframe URL
*/
export async function getHomePageContent(): Promise {
- const res = await api.get('/api/home_page_content')
+ // See getNotice in @/lib/api: the global `Cache-Control: no-store` is dropped
+ // so the browser can hold an ETag and revalidate, letting the server answer
+ // 304. Server-side `no-cache` keeps admin edits immediate.
+ const res = await api.get('/api/home_page_content', {
+ headers: { 'Cache-Control': null },
+ })
return res.data
}
diff --git a/web/src/features/legal/api.ts b/web/src/features/legal/api.ts
index 0ffbe12eb01c..483fd04bf1fa 100644
--- a/web/src/features/legal/api.ts
+++ b/web/src/features/legal/api.ts
@@ -20,12 +20,21 @@ import { api } from '@/lib/api'
import type { LegalDocumentResponse } from './types'
-export async function getUserAgreement() {
- const res = await api.get('/api/user-agreement')
+// Both documents drop the client's global `Cache-Control: no-store` for the
+// same reason as getNotice in @/lib/api: `no-store` stops the browser from
+// keeping a copy, so it would never hold an ETag to revalidate with and the
+// server could never answer 304. These are the largest payloads in this family
+// and are re-fetched on every sign-up, so the saving is the most visible here.
+export async function getUserAgreement(): Promise {
+ const res = await api.get('/api/user-agreement', {
+ headers: { 'Cache-Control': null },
+ })
return res.data
}
-export async function getPrivacyPolicy() {
- const res = await api.get('/api/privacy-policy')
+export async function getPrivacyPolicy(): Promise {
+ const res = await api.get('/api/privacy-policy', {
+ headers: { 'Cache-Control': null },
+ })
return res.data
}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index e25f65bbb7b7..81d0c69100ec 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -28,6 +28,7 @@ export {
getFreshAuthHeaders,
isAuthBundle,
refreshAuthentication,
+ resolveAuthentication,
AuthRotationError,
} from '@/lib/auth-session'
export type { AuthTokenRotation, RefreshOutcome } from '@/lib/auth-session'
@@ -77,7 +78,14 @@ export async function getNotice(): Promise<{
message?: string
data?: string
}> {
- const res = await api.get('/api/notice')
+ // Drop the client's global `Cache-Control: no-store` for this public,
+ // non-user-specific payload. `no-store` forbids the browser from keeping a
+ // copy at all, so it would never hold an ETag to revalidate with and the
+ // server could never answer 304. The server sends `no-cache`, so the browser
+ // still revalidates on every request and an admin edit shows up immediately.
+ const res = await api.get('/api/notice', {
+ headers: { 'Cache-Control': null },
+ })
return res.data
}
diff --git a/web/src/lib/auth-session.ts b/web/src/lib/auth-session.ts
index 67c5fe8b6012..acc415dfb812 100644
--- a/web/src/lib/auth-session.ts
+++ b/web/src/lib/auth-session.ts
@@ -21,6 +21,7 @@ import axios from 'axios'
import { t } from 'i18next'
import { publishAuthSessionEvent } from '@/lib/auth-session-sync'
+import { hasSessionHint } from '@/lib/session-hint'
import {
useAuthStore,
type AuthBootstrapState,
@@ -360,7 +361,15 @@ function currentValidAuthBundle(): AuthBundle | null {
}
}
-export async function bootstrapAuthentication(): Promise {
+/**
+ * Resolve authentication from memory, or from the server when memory is empty.
+ *
+ * Use this wherever the answer decides what the user sees: route guards that
+ * redirect on the result, and the sign-in page. It contacts the server on a
+ * cold cache even when no session hint is present, so a usable Refresh Cookie
+ * is always honoured.
+ */
+export async function resolveAuthentication(): Promise {
const bundle = currentValidAuthBundle()
if (bundle) {
useAuthStore.getState().auth.setBootstrapState('complete')
@@ -377,6 +386,26 @@ export async function bootstrapAuthentication(): Promise {
return refreshAuthentication()
}
+/**
+ * Resolve authentication on the public boot path, skipping a refresh that the
+ * server's session hint says would fail.
+ *
+ * The skip leaves `bootstrapState` at `idle` rather than `complete`: a missing
+ * hint is not a server verdict, so it must not be recorded as a finished
+ * anonymous check. `resolveAuthentication` therefore still reaches the network
+ * later, which is what lets a hintless visitor holding a valid Refresh Cookie
+ * recover the moment authentication actually matters.
+ */
+export async function bootstrapAuthentication(): Promise {
+ if (!currentValidAuthBundle() && !hasSessionHint()) {
+ const auth = useAuthStore.getState().auth
+ if (!auth.user && !auth.session) {
+ return { kind: 'anonymous' }
+ }
+ }
+ return resolveAuthentication()
+}
+
export function getCommonHeaders(): Record {
const headers: Record = {
'Content-Type': 'application/json',
diff --git a/web/src/lib/session-hint.ts b/web/src/lib/session-hint.ts
new file mode 100644
index 000000000000..37012a3c0ec5
--- /dev/null
+++ b/web/src/lib/session-hint.ts
@@ -0,0 +1,62 @@
+/*
+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
+*/
+
+/**
+ * Detection of the server's login-session hint cookie.
+ *
+ * The Refresh Cookie is `HttpOnly` and scoped to `/api/user/auth`, so a page at
+ * `/` cannot read it and cannot tell an anonymous visitor from a returning one.
+ * The server therefore writes `new_api_has_session=1` alongside it — same
+ * expiry, `Path=/`, not `HttpOnly` — purely so the frontend can skip a refresh
+ * that is certain to fail.
+ *
+ * This is an optimization, never an authorization signal. A present hint means
+ * "a Refresh Cookie was issued at some point"; it can be stale after a
+ * server-side revocation, and it can be absent while a usable Refresh Cookie
+ * still exists (a visitor who cleared site data for `/` only, or any session
+ * created before this cookie shipped). Callers must treat a missing hint as
+ * "not worth a request right now", not as "signed out", and must still be able
+ * to reach the server when authentication actually matters.
+ */
+export const SESSION_HINT_COOKIE_NAME = 'new_api_has_session'
+
+/** Read a cookie value out of a `document.cookie`-shaped string. */
+export function readCookie(cookieHeader: string, name: string): string | null {
+ for (const part of cookieHeader.split(';')) {
+ const separator = part.indexOf('=')
+ if (separator < 0) continue
+ if (part.slice(0, separator).trim() !== name) continue
+ return part.slice(separator + 1).trim()
+ }
+ return null
+}
+
+/**
+ * Whether the server currently claims a login session exists.
+ *
+ * Returns `true` when the hint cannot be read at all (no `document`, as in SSR
+ * or a non-DOM test environment). An unreadable hint is not evidence of
+ * absence, and the safe direction is to let the refresh proceed.
+ */
+export function hasSessionHint(): boolean {
+ if (typeof document === 'undefined') return true
+ return (
+ readCookie(document.cookie, SESSION_HINT_COOKIE_NAME) !== null
+ )
+}
diff --git a/web/src/routes/(auth)/sign-in.tsx b/web/src/routes/(auth)/sign-in.tsx
index 974f026c7c50..c414d063b4b5 100644
--- a/web/src/routes/(auth)/sign-in.tsx
+++ b/web/src/routes/(auth)/sign-in.tsx
@@ -21,6 +21,7 @@ import { z } from 'zod'
import { sanitizeAuthRedirect } from '@/features/auth/lib/auth-redirect'
import { SignIn } from '@/features/auth/sign-in'
+import { resolveAuthentication } from '@/lib/auth-session'
import { useAuthStore } from '@/stores/auth-store'
const searchSchema = z.object({
@@ -31,6 +32,10 @@ export const Route = createFileRoute('/(auth)/sign-in')({
component: SignIn,
validateSearch: searchSchema,
beforeLoad: async ({ search }) => {
+ // 根 guard 可能因为没有会话提示而跳过了 refresh。此处必须回源确认,
+ // 否则持有有效 Refresh Cookie 的用户会被要求重新输入密码。
+ await resolveAuthentication()
+
const { auth } = useAuthStore.getState()
// 如果已经有用户信息,说明已登录
diff --git a/web/src/routes/_authenticated/route.tsx b/web/src/routes/_authenticated/route.tsx
index e0d0eb461598..d608b32d3bc4 100644
--- a/web/src/routes/_authenticated/route.tsx
+++ b/web/src/routes/_authenticated/route.tsx
@@ -19,10 +19,17 @@ For commercial licensing, please contact support@quantumnous.com
import { createFileRoute, redirect } from '@tanstack/react-router'
import { AuthenticatedLayout } from '@/components/layout'
+import { resolveAuthentication } from '@/lib/auth-session'
import { useAuthStore } from '@/stores/auth-store'
export const Route = createFileRoute('/_authenticated')({
- beforeLoad: ({ location }) => {
+ beforeLoad: async ({ location }) => {
+ // The root guard may have skipped its refresh because no session hint was
+ // present. That skip is an optimization for public pages and must not
+ // decide a protected route, so resolve against the server before
+ // redirecting. An in-memory session returns without a request.
+ await resolveAuthentication()
+
const { auth } = useAuthStore.getState()
if (!auth.user || !auth.accessToken) {
From 9f506dd7f905c288b4a119a8197cd64b77eb4a3f Mon Sep 17 00:00:00 2001
From: CaIon
Date: Thu, 3 Sep 2026 12:15:02 +0800
Subject: [PATCH 90/99] refactor(logs): simplify LogOther projection and dedupe
sensitive keys
- Drop the unreachable user-visibility branch in LogOther.toMap and the
receiver-mutating normalizeLegacyAdminFields; JSONString/Snapshot/
MarshalJSON now share one full serialization
- Define legacySensitiveLogOtherKeys once and reference it from both
SetPublic rejection and the user read-side projection
- Return the original JSON for every role when formatLogOtherJSON
removed nothing, avoiding a re-marshal on the user log list path
- Log rejected OtherRatios keys in taskBillingOther instead of dropping
them silently
- Use Snapshot() with typed assertions in service tests
---
model/log_format_test.go | 12 ++++++
model/log_other.go | 82 +++++++++++++++---------------------
model/log_other_test.go | 14 ++++++
service/task_billing.go | 4 +-
service/task_billing_test.go | 19 +++------
service/text_quota_test.go | 7 +--
6 files changed, 70 insertions(+), 68 deletions(-)
diff --git a/model/log_format_test.go b/model/log_format_test.go
index 89b2df04266d..b34f09cada2d 100644
--- a/model/log_format_test.go
+++ b/model/log_format_test.go
@@ -229,4 +229,16 @@ func TestLogFormattingPreservesLargeIntegerLexemes(t *testing.T) {
assert.Equal(t, other, logs[0].Other)
})
+
+ t.Run("unprivileged", func(t *testing.T) {
+ const unprivileged = `{"public_id":9007199254740993,"model_price":0.004}`
+
+ userLogs := []*Log{{Other: unprivileged}}
+ formatUserLogs(userLogs, 0)
+ assert.Equal(t, unprivileged, userLogs[0].Other)
+
+ adminLogs := []*Log{{Other: unprivileged}}
+ FormatAdminLogs(adminLogs)
+ assert.Equal(t, unprivileged, adminLogs[0].Other)
+ })
}
diff --git a/model/log_other.go b/model/log_other.go
index 77f64e4c2d39..438c136e91b4 100644
--- a/model/log_other.go
+++ b/model/log_other.go
@@ -3,6 +3,7 @@ package model
import (
"encoding/json"
"maps"
+ "slices"
"github.com/QuantumNous/new-api/common"
)
@@ -13,6 +14,15 @@ const (
logOtherAuditInfoKey = "audit_info"
)
+// legacySensitiveLogOtherKeys are historical top-level fields that must never
+// be written via SetPublic and must be stripped from user-visible projections.
+var legacySensitiveLogOtherKeys = []string{
+ "channel_id",
+ "channel_name",
+ "channel_type",
+ "reject_reason",
+}
+
type logOtherVisibility int
const (
@@ -37,11 +47,10 @@ func NewLogOther() *LogOther {
func isReservedLogOtherKey(key string) bool {
switch key {
- case logOtherAdminInfoKey, logOtherRootInfoKey, logOtherAuditInfoKey,
- "channel_id", "channel_name", "channel_type", "reject_reason":
+ case logOtherAdminInfoKey, logOtherRootInfoKey, logOtherAuditInfoKey:
return true
default:
- return false
+ return slices.Contains(legacySensitiveLogOtherKeys, key)
}
}
@@ -125,55 +134,32 @@ func copyLogOtherMap(values map[string]any) map[string]any {
return copyValues
}
-func (o *LogOther) normalizeLegacyAdminFields() {
- if o == nil || o.public == nil {
- return
- }
- if rejectReason, ok := o.public["reject_reason"]; ok {
- if _, exists := o.adminInfo["reject_reason"]; !exists {
- o.SetAdmin("reject_reason", rejectReason)
- }
- delete(o.public, "reject_reason")
- }
-}
-
-func (o *LogOther) toMap(visibility logOtherVisibility) map[string]any {
+func (o *LogOther) toMap() map[string]any {
result := make(map[string]any)
if o == nil {
return result
}
for key, value := range o.public {
- if visibility == logOtherVisibilityUser {
- switch key {
- case "channel_id", "channel_name", "channel_type", "reject_reason":
- continue
- }
- }
result[key] = value
}
- if visibility >= logOtherVisibilityAdmin {
- if adminInfo := copyLogOtherMap(o.adminInfo); len(adminInfo) > 0 {
- result[logOtherAdminInfoKey] = adminInfo
- }
- if auditInfo := copyLogOtherMap(o.auditInfo); len(auditInfo) > 0 {
- result[logOtherAuditInfoKey] = auditInfo
- }
+ if adminInfo := copyLogOtherMap(o.adminInfo); len(adminInfo) > 0 {
+ result[logOtherAdminInfoKey] = adminInfo
}
- if visibility == logOtherVisibilityRoot {
- if rootInfo := copyLogOtherMap(o.rootInfo); len(rootInfo) > 0 {
- result[logOtherRootInfoKey] = rootInfo
- }
+ if auditInfo := copyLogOtherMap(o.auditInfo); len(auditInfo) > 0 {
+ result[logOtherAuditInfoKey] = auditInfo
+ }
+ if rootInfo := copyLogOtherMap(o.rootInfo); len(rootInfo) > 0 {
+ result[logOtherRootInfoKey] = rootInfo
}
return result
}
-func (o *LogOther) jsonString(visibility logOtherVisibility) string {
+func (o *LogOther) jsonString() string {
if o == nil {
return ""
}
- o.normalizeLegacyAdminFields()
- data, err := common.Marshal(o.toMap(visibility))
+ data, err := common.Marshal(o.toMap())
if err != nil {
common.SysError("failed to marshal log other: " + err.Error())
return ""
@@ -184,7 +170,7 @@ func (o *LogOther) jsonString(visibility logOtherVisibility) string {
// JSONString returns the complete stored representation, including all
// privileged scopes. API projections must use the role-specific formatter.
func (o *LogOther) JSONString() string {
- return o.jsonString(logOtherVisibilityRoot)
+ return o.jsonString()
}
// Snapshot returns a detached top-level view for tests and read-only
@@ -193,11 +179,11 @@ func (o *LogOther) Snapshot() map[string]any {
if o == nil {
return nil
}
- return o.toMap(logOtherVisibilityRoot)
+ return o.toMap()
}
func (o *LogOther) MarshalJSON() ([]byte, error) {
- return common.Marshal(o.toMap(logOtherVisibilityRoot))
+ return common.Marshal(o.toMap())
}
func normalizeLegacyRejectReason(values map[string]json.RawMessage) bool {
@@ -243,15 +229,13 @@ func formatLogOtherJSON(value string, visibility logOtherVisibility) string {
changed := false
if visibility == logOtherVisibilityUser {
- for _, key := range []string{
- logOtherAdminInfoKey,
- logOtherRootInfoKey,
- logOtherAuditInfoKey,
- "channel_id",
- "channel_name",
- "channel_type",
- "reject_reason",
- } {
+ for _, key := range []string{logOtherAdminInfoKey, logOtherRootInfoKey, logOtherAuditInfoKey} {
+ if _, exists := values[key]; exists {
+ delete(values, key)
+ changed = true
+ }
+ }
+ for _, key := range legacySensitiveLogOtherKeys {
if _, exists := values[key]; exists {
delete(values, key)
changed = true
@@ -267,7 +251,7 @@ func formatLogOtherJSON(value string, visibility logOtherVisibility) string {
}
}
- if visibility == logOtherVisibilityRoot && !changed {
+ if !changed {
return value
}
formatted, err := common.Marshal(values)
diff --git a/model/log_other_test.go b/model/log_other_test.go
index 8ef0edf42ff0..9ffaab0bb3bb 100644
--- a/model/log_other_test.go
+++ b/model/log_other_test.go
@@ -68,3 +68,17 @@ func TestLogOtherRejectsSensitivePublicFields(t *testing.T) {
require.JSONEq(t, `{"request_path":"/v1/responses"}`, other.JSONString())
require.JSONEq(t, `{}`, NewLogOther().JSONString())
}
+
+func TestLogOtherJSONStringDoesNotMutateReceiver(t *testing.T) {
+ other := NewLogOther()
+ require.True(t, other.SetPublic("request_path", "/v1/chat/completions"))
+ require.True(t, other.SetAdmin("rejected", false))
+
+ before := other.Snapshot()
+ first := other.JSONString()
+ after := other.Snapshot()
+ second := other.JSONString()
+
+ require.Equal(t, before, after)
+ require.Equal(t, first, second)
+}
diff --git a/service/task_billing.go b/service/task_billing.go
index e5e4df62a552..e9cca572c1fb 100644
--- a/service/task_billing.go
+++ b/service/task_billing.go
@@ -144,7 +144,9 @@ func taskBillingOther(task *model.Task) *model.LogOther {
other.SetPublic("group_ratio", bc.GroupRatio)
if priceData := taskBillingContextPriceData(bc); priceData != nil {
for k, v := range priceData.OtherRatios() {
- other.SetPublic(k, v)
+ if !other.SetPublic(k, v) {
+ common.SysError("task billing other ratio key rejected: " + k)
+ }
}
}
if snap := bc.TieredSnapshot; snap != nil {
diff --git a/service/task_billing_test.go b/service/task_billing_test.go
index 6be18bc9f862..238f359fb387 100644
--- a/service/task_billing_test.go
+++ b/service/task_billing_test.go
@@ -163,13 +163,6 @@ func makeTask(userId, channelId, quota, tokenId int, billingSource string, subsc
}
}
-func taskBillingOtherMap(t *testing.T, other *model.LogOther) map[string]interface{} {
- t.Helper()
- var values map[string]interface{}
- require.NoError(t, common.UnmarshalJsonStr(other.JSONString(), &values))
- return values
-}
-
func TestPriceDataOtherRatiosFilterAndSnapshot(t *testing.T) {
priceData := types.PriceData{}
@@ -234,7 +227,7 @@ func TestTaskBillingOtherFiltersHistoricalOtherRatios(t *testing.T) {
"inf": math.Inf(1),
}
- other := taskBillingOtherMap(t, taskBillingOther(task))
+ other := taskBillingOther(task).Snapshot()
assert.Equal(t, 2.0, other["seconds"])
assert.Equal(t, 1.0, other["identity"])
@@ -260,7 +253,7 @@ func TestTaskBillingOtherIncludesTieredSnapshotAndKeepsUsageFactsNested(t *testi
},
}
- other := taskBillingOtherMap(t, taskBillingOther(task))
+ other := taskBillingOther(task).Snapshot()
assert.Equal(t, "tiered_expr", other["billing_mode"])
assert.Equal(t, base64.StdEncoding.EncodeToString([]byte(expression)), other["expr_b64"])
@@ -269,7 +262,7 @@ func TestTaskBillingOtherIncludesTieredSnapshotAndKeepsUsageFactsNested(t *testi
require.True(t, ok)
assert.Equal(t, map[string]any{
"resolution": "720P",
- "seconds": float64(5),
+ "seconds": 5,
}, facts)
assert.NotContains(t, other, "resolution")
assert.NotContains(t, other, "seconds")
@@ -284,7 +277,7 @@ func TestTaskBillingOtherOmitsEmptyUsageFacts(t *testing.T) {
UsageFacts: map[string]any{},
}
- other := taskBillingOtherMap(t, taskBillingOther(task))
+ other := taskBillingOther(task).Snapshot()
assert.Equal(t, "tiered_expr", other["billing_mode"])
assert.Equal(t, base64.StdEncoding.EncodeToString([]byte(expression)), other["expr_b64"])
@@ -408,7 +401,7 @@ func TestTaskBillingOtherSeparatesPluginAndRootDiagnostics(t *testing.T) {
},
}
- other := taskBillingOtherMap(t, taskBillingOther(task))
+ other := taskBillingOther(task).Snapshot()
assert.Equal(t, "task_public", other["task_id"])
adminInfo, ok := other["admin_info"].(map[string]interface{})
@@ -428,7 +421,7 @@ func TestTaskBillingOtherSeparatesPluginAndRootDiagnostics(t *testing.T) {
assert.Equal(t, "node-a", rootInfo["node_name"])
runtimeInfo, ok := rootInfo["task_plugin"].(map[string]interface{})
require.True(t, ok)
- assert.Equal(t, float64(42), runtimeInfo["generation"])
+ assert.Equal(t, uint64(42), runtimeInfo["generation"])
assert.NotContains(t, runtimeInfo, "author")
}
diff --git a/service/text_quota_test.go b/service/text_quota_test.go
index 0ec24db863d7..da24cdcb3f70 100644
--- a/service/text_quota_test.go
+++ b/service/text_quota_test.go
@@ -451,16 +451,13 @@ func TestAppendUsageBillingPathForLogWritesAdminInfo(t *testing.T) {
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{InputTokens: 1}),
})
- var values map[string]interface{}
- require.NoError(t, common.UnmarshalJsonStr(other.JSONString(), &values))
- adminInfo, ok := values["admin_info"].(map[string]interface{})
+ adminInfo, ok := other.Snapshot()["admin_info"].(map[string]interface{})
require.True(t, ok)
require.Equal(t, usageBillingPathAnthropic, adminInfo["usage_billing_path"])
other = model.NewLogOther()
appendUsageBillingPathForLog(other, true, nil)
- require.NoError(t, common.UnmarshalJsonStr(other.JSONString(), &values))
- adminInfo, ok = values["admin_info"].(map[string]interface{})
+ adminInfo, ok = other.Snapshot()["admin_info"].(map[string]interface{})
require.True(t, ok)
require.Equal(t, usageBillingPathLocal, adminInfo["usage_billing_path"])
}
From 9df450fe54e1a874a5339b7c38a61014217f02c3 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Thu, 3 Sep 2026 14:36:18 +0800
Subject: [PATCH 91/99] feat(task): give polling hooks a real query context,
host HTTP classification, and bounded poll failures
Plugin polling hooks previously ran against a hollow context: parseTaskResult
and parseBatchResult received {} / nil, buildQueryRequest received a
{task_id, action} map under the misleading name requestBody, and batch hooks
saw only bare task ids. The per-task poller also never looked at the upstream
HTTP status, and every built-in plugin papered over unrecognized bodies with
`|| "IN_PROGRESS"`, so a 404, a revoked key, or a shape the plugin did not
know would sit in IN_PROGRESS for the full 24h TASK_TIMEOUT_MINUTES while
holding the user's pre-charged quota.
Contract (docs/plugin-api v1.d.ts, v1.md, v1.schema.json):
- TaskQueryContext is declared separately from DriverContext and rebuilt from
the persisted Task row: taskId, publicTaskId, action, model, upstreamModel,
baseUrl, apiKey, authHeader, auth, data, state. Query-side requestBody is
removed; the original request is not persisted and hooks that need a
request-derived value must save it into state at submit time.
- parseTaskResult / parseBatchResult receive a third {status, headers}
argument. Batch hooks receive tasks[] with one TaskQueryContext per task.
- NormalizedTaskResult accepts status "UNKNOWN" meaning "I do not recognize
this body". Falling back to IN_PROGRESS for unknown shapes is forbidden;
`plugin lint` warns on the literal.
- parseSubmitResponse / parseTaskResult / parseBatchResult may return `state`.
Task.Data remains a per-round snapshot overwritten on every valid parse;
state is plugin-owned, persisted in TaskPrivateData.PluginState, preserved
when a hook omits it, byte-capped like taskData, and never exposed through
presenter views.
Host (service/task_polling.go, relay/channel/task/jsplugin/adaptor.go):
- TaskPollingAdaptor / BatchTaskPollingAdaptor take *model.Task and the
*http.Response so the adaptor can build the full context; jsplugin is the
only implementation.
- HTTP classification before the plugin sees the body: 2xx -> plugin;
404/410 -> FAILURE and refund; 401/403 -> poll failure plus a channel-scoped
warning, no auto-disable; 429/5xx/transport -> poll failure; other 4xx ->
plugin with the status visible, counted as unrecognized if the plugin still
reports a non-terminal state.
- TaskPrivateData.PollFailures counts consecutive poll failures (transient
HTTP, auth, transport, hook error, UNKNOWN). It is persisted through the
existing UpdateWithStatus CAS so a concurrent terminal transition on another
instance is never clobbered, and reset on any valid 2xx non-terminal parse.
Reaching TASK_POLL_MAX_FAILURES (default 20, <= 0 disables) fails the task
with the last classification and HTTP code in fail_reason and runs the
existing settle/refund chain exactly once. sweepTimedOutTasks and its
1440-minute default are unchanged as the outer backstop.
- Unrecognized bodies are logged at WARN with a bounded redacted copy since
Task.Data is intentionally not overwritten on that path.
Plugins (all ten bumped one patch version):
- jimeng persists the outbound req_key in state and reads it back in
buildQueryRequest, replacing dead reads of ctx.data / ctx.requestBody that
never resolved.
- sunoapi batch hooks read tasks[] instead of the removed requestBody.
- hailuo treats base_resp.status_code != 0 as FAILURE before the status table.
- kling, vidu, sora, alibaba, doubao, hailuo, jimeng return UNKNOWN with the
raw upstream status in reason on table miss.
- google and vertex-ai treat a missing `done` as in-progress: Google
long-running operations omit proto3 default fields, so a running Veo
operation has no `done` key at all. Only a body without an operation name is
UNKNOWN. plugins/veo_poll_test.go locks this so the poll-failure cutoff can
never fail a rendering Veo task.
Tests cover the classification table end to end against a real DB (404
immediate refund, 429xN refund, 401 increments without status change, 2xx
reset, UNKNOWN increments, state preserved vs replaced, PollFailures survives
the CAS write), the query-context shape, UNKNOWN on unrecognized bodies, and
the absence of PluginState/PollFailures from TaskView. Controller tests derive
the kling factory version from the embedded manifest instead of hardcoding it.
---
.env.example | 4 +
common/init.go | 2 +
constant/env.go | 1 +
controller/plugin_protocol_test.go | 4 +-
controller/relay.go | 3 +
controller/task_plugin_test.go | 26 +-
docs/plugin-api/v1.d.ts | 15 +-
docs/plugin-api/v1.md | 37 ++-
docs/plugin-api/v1.schema.json | 41 ++-
model/log_other.go | 4 +-
model/task.go | 46 ++--
model/task_cas_test.go | 56 +++-
pkg/jsplugin/cli.go | 33 +++
pkg/jsplugin/cli_test.go | 19 ++
plugins/hailuo_responses_test.go | 5 +-
plugins/jimeng_responses_test.go | 67 ++++-
plugins/tasks/alibaba/plugin.js | 4 +-
plugins/tasks/doubao/plugin.js | 4 +-
plugins/tasks/google/plugin.js | 8 +-
plugins/tasks/hailuo/plugin.js | 19 +-
plugins/tasks/jimeng/plugin.js | 32 +--
plugins/tasks/kling/plugin.js | 4 +-
plugins/tasks/sora/plugin.js | 6 +-
plugins/tasks/sunoapi/plugin.js | 12 +-
plugins/tasks/vertex-ai/plugin.js | 8 +-
plugins/tasks/vidu/plugin.js | 4 +-
plugins/veo_poll_test.go | 61 +++++
relay/channel/adapter.go | 5 +-
relay/channel/task/jsplugin/adaptor.go | 225 ++++++++++++---
relay/channel/task/jsplugin/adaptor_test.go | 185 ++++++++++++-
relay/common/relay_info.go | 35 +--
relay/relay_task.go | 11 +-
service/task_billing_test.go | 6 +-
service/task_plugin_view_test.go | 21 ++
service/task_polling.go | 273 +++++++++++++++----
service/task_polling_test.go | 288 +++++++++++++++++++-
36 files changed, 1356 insertions(+), 218 deletions(-)
create mode 100644 plugins/veo_poll_test.go
diff --git a/.env.example b/.env.example
index 5cbd8076b23f..5ea44bc28958 100644
--- a/.env.example
+++ b/.env.example
@@ -56,6 +56,10 @@
# 任务和功能配置
# 更新任务启用
# UPDATE_TASK=true
+# 异步任务硬超时(分钟),按提交时间计算,超时未完成的任务标记失败并退款;0 表示禁用
+# TASK_TIMEOUT_MINUTES=1440
+# 异步任务连续轮询失败阈值(上游 429/5xx/401/403、网络错误、无法识别的响应),达到后任务标记失败并退款;正常轮询成功一次即归零
+# TASK_POLL_MAX_FAILURES=20
# 对话超时设置
# 所有请求超时时间,单位秒,默认为0,表示不限制
diff --git a/common/init.go b/common/init.go
index a54075fad825..d06accec7a49 100644
--- a/common/init.go
+++ b/common/init.go
@@ -202,6 +202,8 @@ func initConstantEnv() {
constant.TaskQueryLimit = GetEnvOrDefault("TASK_QUERY_LIMIT", 1000)
// 异步任务超时时间(分钟),超过此时间未完成的任务将被标记为失败并退款。0 表示禁用。
constant.TaskTimeoutMinutes = GetEnvOrDefault("TASK_TIMEOUT_MINUTES", 1440)
+ // Consecutive unrecognized/transient poll failures before the task is failed and refunded.
+ constant.TaskPollMaxFailures = GetEnvOrDefault("TASK_POLL_MAX_FAILURES", 20)
// 声明式任务协议桥只观察数据库;这些值控制一次客户端观察连接,
// 不改变后台轮询或结算生命周期。
constant.TaskPluginProtocolTimeoutSeconds = GetEnvOrDefault("TASK_PLUGIN_PROTOCOL_TIMEOUT_SECONDS", 600)
diff --git a/constant/env.go b/constant/env.go
index a6de36bce60c..4f8f6755b786 100644
--- a/constant/env.go
+++ b/constant/env.go
@@ -18,6 +18,7 @@ var GenerateDefaultToken bool
var ErrorLogEnabled bool
var TaskQueryLimit int
var TaskTimeoutMinutes int
+var TaskPollMaxFailures = 20
var TaskPluginProtocolTimeoutSeconds int
var TaskPluginProtocolTickMilliseconds int
var TaskPluginProtocolTickJitterMilliseconds int
diff --git a/controller/plugin_protocol_test.go b/controller/plugin_protocol_test.go
index edc64034aa89..ad4261ead6b5 100644
--- a/controller/plugin_protocol_test.go
+++ b/controller/plugin_protocol_test.go
@@ -366,14 +366,14 @@ type terminalSettlementPollingAdaptor struct {
func (a *terminalSettlementPollingAdaptor) Init(*relaycommon.RelayInfo) {}
-func (a *terminalSettlementPollingAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) {
+func (a *terminalSettlementPollingAdaptor) FetchTask(string, string, *model.Task, string) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{}`)),
}, nil
}
-func (a *terminalSettlementPollingAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) {
+func (a *terminalSettlementPollingAdaptor) ParseTaskResult(*model.Task, *http.Response, []byte) (*relaycommon.TaskInfo, error) {
return &relaycommon.TaskInfo{
Status: model.TaskStatusSuccess,
Progress: "100%",
diff --git a/controller/relay.go b/controller/relay.go
index 7fe1db2a1082..9da14e339f30 100644
--- a/controller/relay.go
+++ b/controller/relay.go
@@ -744,6 +744,9 @@ func executeTaskSubmissionWith(
}
task.Quota = result.Quota
task.Data = result.TaskData
+ if len(result.PluginState) > 0 {
+ task.PrivateData.PluginState = result.PluginState
+ }
task.Action = relayInfo.Action
if immediate := result.Immediate; immediate != nil {
task.Status = model.TaskStatus(immediate.Status)
diff --git a/controller/task_plugin_test.go b/controller/task_plugin_test.go
index e78db097b6c7..a5f39b0b0cb1 100644
--- a/controller/task_plugin_test.go
+++ b/controller/task_plugin_test.go
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
+ "regexp"
"strings"
"testing"
@@ -117,6 +118,17 @@ func TestDisableThirdPartyPluginSupportsCascadeAndForce(t *testing.T) {
assert.Equal(t, common.ChannelStatusManuallyDisabled, updated.Status)
}
+// klingFactoryVersion returns the version declared in the embedded kling factory
+// manifest so tests do not hardcode a value that moves with every plugin release.
+func klingFactoryVersion(t *testing.T) string {
+ t.Helper()
+ factorySource, err := plugins.Source("kling")
+ require.NoError(t, err)
+ match := regexp.MustCompile(`version:\s*"([^"]+)"`).FindStringSubmatch(factorySource)
+ require.Len(t, match, 2, "kling factory manifest must declare a version")
+ return match[1]
+}
+
func setupTaskPluginFactoryDisableTest(t *testing.T) {
t.Helper()
setupTaskPluginControllerTest(t)
@@ -240,7 +252,9 @@ func TestDisableFactoryOverrideRowKeepsEnabledFlagPath(t *testing.T) {
setupTaskPluginFactoryDisableTest(t)
factorySource, err := plugins.Source("kling")
require.NoError(t, err)
- overrideSource := strings.Replace(factorySource, `version: "1.0.0"`, `version: "1.0.0-test-factory-status"`, 1)
+ factoryVersion := klingFactoryVersion(t)
+ overrideSource := strings.Replace(factorySource, `version: "`+factoryVersion+`"`, `version: "`+factoryVersion+`-test-factory-status"`, 1)
+ require.NotEqual(t, factorySource, overrideSource, "factory version marker must be found in kling source")
loaded, err := jsplugin.DefaultRegistry.Register(overrideSource, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("kling") })
@@ -266,7 +280,7 @@ func TestDisableFactoryOverrideRowKeepsEnabledFlagPath(t *testing.T) {
assert.True(t, taskPluginOptionsHasKey(t, "kling"))
got, ok := jsplugin.DefaultRegistry.Get("kling")
require.True(t, ok)
- assert.Equal(t, "1.0.0", got.Meta.Version)
+ assert.Equal(t, factoryVersion, got.Meta.Version)
}
func TestListTaskPluginsIncludesFactoryWithoutDatabaseRows(t *testing.T) {
@@ -358,7 +372,9 @@ func TestListTaskPluginsShowsDisabledFallbackWhenOverridesAreDisabled(t *testing
setupTaskPluginControllerTest(t)
factorySource, err := plugins.Source("kling")
require.NoError(t, err)
- overrideSource := strings.Replace(factorySource, `version: "1.0.0"`, `version: "1.0.0-test-disabled-override"`, 1)
+ factoryVersion := klingFactoryVersion(t)
+ overrideSource := strings.Replace(factorySource, `version: "`+factoryVersion+`"`, `version: "`+factoryVersion+`-test-disabled-override"`, 1)
+ require.NotEqual(t, factorySource, overrideSource, "factory version marker must be found in kling source")
loaded, err := jsplugin.DefaultRegistry.Register(overrideSource, jsplugin.Options{})
require.NoError(t, err)
plugin := model.TaskPlugin{
@@ -399,7 +415,9 @@ func TestDeleteActiveOverrideFallsBackToFactoryAndDeletesRecord(t *testing.T) {
setupTaskPluginControllerTest(t)
factorySource, err := plugins.Source("kling")
require.NoError(t, err)
- overrideSource := strings.Replace(factorySource, `version: "1.0.0"`, `version: "1.0.0-test-override"`, 1)
+ factoryVersion := klingFactoryVersion(t)
+ overrideSource := strings.Replace(factorySource, `version: "`+factoryVersion+`"`, `version: "`+factoryVersion+`-test-override"`, 1)
+ require.NotEqual(t, factorySource, overrideSource, "factory version marker must be found in kling source")
loaded, err := jsplugin.DefaultRegistry.Register(overrideSource, jsplugin.Options{Key: "kling", Version: "test-override"})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("kling") })
diff --git a/docs/plugin-api/v1.d.ts b/docs/plugin-api/v1.d.ts
index 11911727e5b8..3dc1919f6438 100644
--- a/docs/plugin-api/v1.d.ts
+++ b/docs/plugin-api/v1.d.ts
@@ -25,6 +25,9 @@ export type UsageExample = {label: string; facts: Readonly>; usageExamples?: readonly UsageExample[]; auth?: "none" | "api_key" | "vertex_oauth" | {type: "none" | "api_key" | "oauth2_jwt"}}
export interface TaskView {task_id: string; status: string; progress?: string; fail_reason?: string; created_at?: number; updated_at?: number; data?: unknown; properties?: Record}
export interface DriverContext {requestBody: unknown; requestHeaders: Readonly>; action: string; model: string; upstreamModel: string; baseUrl: string; apiKey?: string; authHeader: string; files: readonly FileReference[]; publicTaskId: string; originTasks?: readonly {taskId: string; upstreamTaskId: string; action: string; status: string; data: unknown}[]}
+export interface TaskQueryContext {taskId: string; publicTaskId: string; action: string; model: string; upstreamModel: string; baseUrl: string; apiKey?: string; authHeader: string; auth?: unknown; data: unknown; state: unknown}
+export interface BatchQueryContext {baseUrl: string; apiKey?: string; authHeader: string; auth?: unknown; tasks: readonly TaskQueryContext[]}
+export type HookHTTPResponse = {readonly status: number; readonly headers: Readonly>}
export interface RequestDescriptor {url: string; method?: string; headers?: Record; /** JSON body may contain FilePlaceholder objects at any depth; the host replaces each with a Base64 or data-URL string. */ body?: unknown; credentialless?: boolean; action?: string; model?: string; rewriteModel?: string; bodyType?: "json" | "multipart"; parts?: readonly {name: string; value?: unknown; fileRef?: string; filename?: string}[]}
export interface UpstreamResponse {statusCode: number; headers: Readonly>; body: unknown}
export interface NormalizedTaskResult {taskId?: string; status: "NOT_START" | "SUBMITTED" | "QUEUED" | "IN_PROGRESS" | "SUCCESS" | "FAILURE" | "UNKNOWN"; progress?: string; reason?: string; url?: string; remoteUrl?: string; completionTokens?: number; totalTokens?: number}
@@ -36,13 +39,13 @@ export declare const protocols: {
openai_video?: {decodeRequest(ctx: ProtocolDecodeContext): SubmitIntent; render(ctx: unknown, task: TaskView): unknown};
};
export declare function buildSubmitRequest(ctx: DriverContext): RequestDescriptor;
-export declare function parseSubmitResponse(ctx: DriverContext, response: UpstreamResponse): {taskId: string; taskData?: unknown; immediate?: NormalizedTaskResult};
-export declare function buildQueryRequest(ctx: DriverContext & {taskId: string}): RequestDescriptor;
-export declare function buildBatchQueryRequest(ctx: DriverContext, taskIds: readonly string[]): RequestDescriptor;
-export declare function parseTaskResult(ctx: DriverContext, body: unknown): NormalizedTaskResult;
-export declare function parseBatchResult(ctx: DriverContext, body: unknown): readonly (NormalizedTaskResult & {taskId: string; data?: unknown})[];
+export declare function parseSubmitResponse(ctx: DriverContext, response: UpstreamResponse): {taskId: string; taskData?: unknown; immediate?: NormalizedTaskResult; state?: unknown};
+export declare function buildQueryRequest(ctx: TaskQueryContext): RequestDescriptor;
+export declare function buildBatchQueryRequest(ctx: BatchQueryContext, tasks: readonly TaskQueryContext[]): RequestDescriptor;
+export declare function parseTaskResult(ctx: TaskQueryContext, body: unknown, response: HookHTTPResponse): NormalizedTaskResult;
+export declare function parseBatchResult(ctx: BatchQueryContext, body: unknown, response: HookHTTPResponse): readonly (NormalizedTaskResult & {taskId: string; data?: unknown; state?: unknown})[];
export declare function extractUsage(ctx: DriverContext & {usagePurpose?: "facts" | "billing_ratios"}): Readonly> | null;
export declare function extractUsageOnSubmit(ctx: DriverContext, taskData: unknown): Readonly> | null;
export declare function extractUsageOnComplete(task: TaskView, result: NormalizedTaskResult, data: unknown): Readonly> | null;
export declare function listArtifacts(task: {taskId: string; status: string; action: string; data: unknown; producerVersion: string}): readonly TaskArtifact[];
-export declare function buildContentRequest(ctx: DriverContext & {artifactKey: string; data: unknown; upstreamTaskId: string; clientRequest: {method: "GET" | "HEAD"; headers: Readonly>}}): RequestDescriptor;
+export declare function buildContentRequest(ctx: DriverContext & {artifactKey: string; data: unknown; state?: unknown; upstreamTaskId: string; clientRequest: {method: "GET" | "HEAD"; headers: Readonly>}}): RequestDescriptor;
diff --git a/docs/plugin-api/v1.md b/docs/plugin-api/v1.md
index 3bdf00b9988f..1c8137811aaa 100644
--- a/docs/plugin-api/v1.md
+++ b/docs/plugin-api/v1.md
@@ -32,7 +32,7 @@ Each `protocols` entry claims a host protocol. A protocol that defines modes mus
Enabled uploads pre-flight the candidate against the live routing generation and reject the first channel-type, native-route, or protocol-model conflict (the error names the counterpart plugin). Set `force: true` or `enabled: false` to store the plugin anyway.
-`endpoints`, `routes[].renderer`, global `resolveRequest`, global `renderError`, and global `renderers` are rejected. `parseSubmitResponse` returns only `{taskId, taskData}` (plus the documented lifecycle fields); `clientResponse` is rejected.
+`endpoints`, `routes[].renderer`, global `resolveRequest`, global `renderError`, and global `renderers` are rejected. `parseSubmitResponse` returns only `{taskId, taskData, immediate?, state?}`; `clientResponse` is rejected.
`icon` is an optional LobeHub icon name string (for example `Sora.Color`). The values `text` and `text:` request a generated text avatar instead (label defaults to the first two characters of `name`). It is display-only and does not participate in routing, billing, or admission beyond type and length checks.
@@ -140,3 +140,38 @@ Protocol media uses host-injected `ctx.artifacts[key].url`. Provider URLs from `
The persisted field remains `task.data`; there is no `task.raw` alias. Driver hooks (`buildSubmitRequest`, `parseSubmitResponse`, query/result, usage, artifact, and content hooks) stay flat and must not branch on the client path or protocol.
`ctx.model` is the billing and display identity (the origin name the client sent, including a channel-mapping alias). `ctx.upstreamModel` is the machine identity after channel `model_mapping`. Rate tables and model-keyed usage facts must use `ctx.upstreamModel || ctx.model`. Decode and render hooks that echo the client model must keep `ctx.model`. `buildSubmitRequest` must not set descriptor top-level `model` on a mapped pin; the host requires the plugin to echo the alias verbatim. Background polling has no relay info, so query hooks receive both identities from the persisted task properties, and `ctx.upstreamModel` falls back to `ctx.model` when the task was submitted without a channel mapping.
+
+## Polling contract
+
+Query and parse hooks use `TaskQueryContext`, not `DriverContext`. The host rebuilds that context from the persisted task row. There is no query-side `requestBody`.
+
+| Field | Source |
+|-------|--------|
+| `taskId` | Upstream task id (`PrivateData.UpstreamTaskID`, else `TaskID`) |
+| `publicTaskId` | Gateway task id |
+| `action` | Normalized persisted action |
+| `model` | `Properties.OriginModelName` |
+| `upstreamModel` | `Properties.UpstreamModelName`, falling back to `model` |
+| `baseUrl` / `apiKey` / `authHeader` / `auth` | Channel credentials |
+| `data` | Current `Task.Data` snapshot |
+| `state` | Plugin-owned `PrivateData.PluginState` |
+
+`Task.Data` is the latest upstream response snapshot for presenters and artifacts. The host overwrites it on every successful parse. Values that must survive across poll rounds belong in `state`.
+
+`parseSubmitResponse`, `parseTaskResult`, and each `parseBatchResult` item may return optional `state`. The host writes it only when the hook returns it. Omitting `state` preserves the previous value. Oversized state is rejected with a warning, not truncated.
+
+`buildBatchQueryRequest(ctx, tasks)` and `parseBatchResult` receive `tasks: TaskQueryContext[]`. `parseTaskResult` / `parseBatchResult` also receive `{status, headers}` for the upstream HTTP response.
+
+`status: "UNKNOWN"` means the plugin does not recognize the response. Do not write `|| "IN_PROGRESS"` (or equivalent) for a missing table entry. The host treats `UNKNOWN`, hook errors, empty status, and unrecognized status strings as consecutive poll failures.
+
+The host classifies the HTTP status before trusting a non-terminal parse:
+
+| Upstream HTTP | Host action |
+|---------------|-------------|
+| 2xx | Call the parse hook |
+| 404 / 410 | Immediate `FAILURE` and refund |
+| 401 / 403 | Leave task status unchanged; increment `PollFailures`; `LogWarn` with channel id. Channels are not auto-disabled. |
+| 429 / 5xx / transport error | Increment `PollFailures` |
+| Other 4xx | Call the parse hook with `response.status`. A still-non-terminal result is unrecognized and increments `PollFailures`. |
+
+A valid 2xx non-terminal parse resets `PollFailures` to 0. After `TASK_POLL_MAX_FAILURES` (default 20) consecutive failures the task becomes `FAILURE` and follows the existing refund chain. The 24h `TASK_TIMEOUT_MINUTES` sweep remains the outer deadline.
diff --git a/docs/plugin-api/v1.schema.json b/docs/plugin-api/v1.schema.json
index ab41078288cc..e21c61c53b96 100644
--- a/docs/plugin-api/v1.schema.json
+++ b/docs/plugin-api/v1.schema.json
@@ -41,6 +41,45 @@
"maxBytes": {"type": "integer", "exclusiveMinimum": 0}
}
},
- "route": {"type": "object", "additionalProperties": false, "required": ["method", "path", "type", "render"], "properties": {"method": {"enum": ["GET", "POST", "PUT", "PATCH", "DELETE"]}, "path": {"type": "string", "pattern": "^/"}, "type": {"enum": ["submit", "query", "dynamic"]}, "action": {"type": "string"}, "taskIdParam": {"type": "string"}, "decode": {"type": "string"}, "render": {"type": "string"}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}, "allOf": [{"if": {"properties": {"type": {"const": "query"}}}, "then": {"allOf": [{"not": {"required": ["decode"]}}, {"not": {"required": ["models"]}}]}}, {"if": {"properties": {"type": {"enum": ["submit", "dynamic"]}}}, "then": {"required": ["decode"]}}]}
+ "route": {"type": "object", "additionalProperties": false, "required": ["method", "path", "type", "render"], "properties": {"method": {"enum": ["GET", "POST", "PUT", "PATCH", "DELETE"]}, "path": {"type": "string", "pattern": "^/"}, "type": {"enum": ["submit", "query", "dynamic"]}, "action": {"type": "string"}, "taskIdParam": {"type": "string"}, "decode": {"type": "string"}, "render": {"type": "string"}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}, "allOf": [{"if": {"properties": {"type": {"const": "query"}}}, "then": {"allOf": [{"not": {"required": ["decode"]}}, {"not": {"required": ["models"]}}]}}, {"if": {"properties": {"type": {"enum": ["submit", "dynamic"]}}}, "then": {"required": ["decode"]}}]},
+ "taskQueryContext": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["taskId", "publicTaskId", "action", "model", "upstreamModel", "baseUrl", "authHeader", "data", "state"],
+ "properties": {
+ "taskId": {"type": "string"},
+ "publicTaskId": {"type": "string"},
+ "action": {"type": "string"},
+ "model": {"type": "string"},
+ "upstreamModel": {"type": "string"},
+ "baseUrl": {"type": "string"},
+ "apiKey": {"type": "string"},
+ "authHeader": {"type": "string"},
+ "auth": true,
+ "data": true,
+ "state": true
+ }
+ },
+ "batchQueryContext": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["baseUrl", "authHeader", "tasks"],
+ "properties": {
+ "baseUrl": {"type": "string"},
+ "apiKey": {"type": "string"},
+ "authHeader": {"type": "string"},
+ "auth": true,
+ "tasks": {"type": "array", "items": {"$ref": "#/$defs/taskQueryContext"}}
+ }
+ },
+ "hookHTTPResponse": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["status", "headers"],
+ "properties": {
+ "status": {"type": "integer"},
+ "headers": {"type": "object", "additionalProperties": {"type": "string"}}
+ }
+ }
}
}
diff --git a/model/log_other.go b/model/log_other.go
index 438c136e91b4..96a76418ef73 100644
--- a/model/log_other.go
+++ b/model/log_other.go
@@ -140,9 +140,7 @@ func (o *LogOther) toMap() map[string]any {
return result
}
- for key, value := range o.public {
- result[key] = value
- }
+ maps.Copy(result, o.public)
if adminInfo := copyLogOtherMap(o.adminInfo); len(adminInfo) > 0 {
result[logOtherAdminInfoKey] = adminInfo
}
diff --git a/model/task.go b/model/task.go
index efbb6c817716..72e6f6073113 100644
--- a/model/task.go
+++ b/model/task.go
@@ -126,6 +126,11 @@ type TaskPrivateData struct {
// disconnect regardless; this only echoes the protocol-level request
// attribute back on retrieval snapshots.
ResponsesBackground bool `json:"responses_background,omitempty"`
+ // PluginState is plugin-owned cross-round data. Unlike Task.Data it is
+ // only replaced when a hook explicitly returns state.
+ PluginState json.RawMessage `json:"plugin_state,omitempty"`
+ // PollFailures counts consecutive unrecognized or transient poll outcomes.
+ PollFailures int `json:"poll_failures,omitempty"`
}
type TaskExecutionSnapshot struct {
@@ -194,7 +199,10 @@ func (p *TaskPrivateData) Scan(val interface{}) error {
}
func (p TaskPrivateData) Value() (driver.Value, error) {
- if (p == TaskPrivateData{}) {
+ if p.Key == "" && p.UpstreamTaskID == "" && p.ResultURL == "" &&
+ p.Execution == nil && p.BillingSource == "" && p.SubscriptionId == 0 &&
+ p.TokenId == 0 && p.NodeName == "" && p.BillingContext == nil &&
+ !p.ResponsesBackground && len(p.PluginState) == 0 && p.PollFailures == 0 {
return nil, nil
}
// 同 Properties.Value:string 避免 PG simple protocol 的 bytea 编码。
@@ -466,13 +474,15 @@ func (Task *Task) InsertWithContext(ctx context.Context) error {
}
type taskSnapshot struct {
- Status TaskStatus
- Progress string
- StartTime int64
- FinishTime int64
- FailReason string
- ResultURL string
- Data json.RawMessage
+ Status TaskStatus
+ Progress string
+ StartTime int64
+ FinishTime int64
+ FailReason string
+ ResultURL string
+ Data json.RawMessage
+ PluginState json.RawMessage
+ PollFailures int
}
func (s taskSnapshot) Equal(other taskSnapshot) bool {
@@ -482,18 +492,22 @@ func (s taskSnapshot) Equal(other taskSnapshot) bool {
s.FinishTime == other.FinishTime &&
s.FailReason == other.FailReason &&
s.ResultURL == other.ResultURL &&
- bytes.Equal(s.Data, other.Data)
+ bytes.Equal(s.Data, other.Data) &&
+ bytes.Equal(s.PluginState, other.PluginState) &&
+ s.PollFailures == other.PollFailures
}
func (t *Task) Snapshot() taskSnapshot {
return taskSnapshot{
- Status: t.Status,
- Progress: t.Progress,
- StartTime: t.StartTime,
- FinishTime: t.FinishTime,
- FailReason: t.FailReason,
- ResultURL: t.PrivateData.ResultURL,
- Data: t.Data,
+ Status: t.Status,
+ Progress: t.Progress,
+ StartTime: t.StartTime,
+ FinishTime: t.FinishTime,
+ FailReason: t.FailReason,
+ ResultURL: t.PrivateData.ResultURL,
+ Data: t.Data,
+ PluginState: t.PrivateData.PluginState,
+ PollFailures: t.PrivateData.PollFailures,
}
}
diff --git a/model/task_cas_test.go b/model/task_cas_test.go
index e8fc09835281..264892f0ce11 100644
--- a/model/task_cas_test.go
+++ b/model/task_cas_test.go
@@ -177,6 +177,29 @@ func TestSnapshotEqual_NilVsEmpty(t *testing.T) {
assert.True(t, a.Equal(b))
}
+func TestSnapshotEqual_PluginStateAndPollFailures(t *testing.T) {
+ base := taskSnapshot{
+ Status: TaskStatusInProgress,
+ PluginState: json.RawMessage(`{"req_key":"a"}`),
+ PollFailures: 2,
+ }
+ assert.True(t, base.Equal(taskSnapshot{
+ Status: TaskStatusInProgress,
+ PluginState: json.RawMessage(`{"req_key":"a"}`),
+ PollFailures: 2,
+ }))
+ assert.False(t, base.Equal(taskSnapshot{
+ Status: TaskStatusInProgress,
+ PluginState: json.RawMessage(`{"req_key":"b"}`),
+ PollFailures: 2,
+ }))
+ assert.False(t, base.Equal(taskSnapshot{
+ Status: TaskStatusInProgress,
+ PluginState: json.RawMessage(`{"req_key":"a"}`),
+ PollFailures: 3,
+ }))
+}
+
func TestSnapshot_Roundtrip(t *testing.T) {
task := &Task{
Status: TaskStatusInProgress,
@@ -185,7 +208,9 @@ func TestSnapshot_Roundtrip(t *testing.T) {
FinishTime: 5678,
FailReason: "timeout",
PrivateData: TaskPrivateData{
- ResultURL: "https://example.com/result.mp4",
+ ResultURL: "https://example.com/result.mp4",
+ PluginState: json.RawMessage(`{"req_key":"keep"}`),
+ PollFailures: 3,
},
Data: json.RawMessage(`{"model":"test-model"}`),
}
@@ -197,6 +222,8 @@ func TestSnapshot_Roundtrip(t *testing.T) {
assert.Equal(t, task.FailReason, snap.FailReason)
assert.Equal(t, task.PrivateData.ResultURL, snap.ResultURL)
assert.JSONEq(t, string(task.Data), string(snap.Data))
+ assert.Equal(t, task.PrivateData.PluginState, snap.PluginState)
+ assert.Equal(t, task.PrivateData.PollFailures, snap.PollFailures)
}
// ---------------------------------------------------------------------------
@@ -292,3 +319,30 @@ func TestUpdateWithStatus_ConcurrentWinner(t *testing.T) {
}
assert.Equal(t, 1, winCount, "exactly one goroutine should win the CAS")
}
+
+func TestUpdateWithStatus_PersistsPluginStateAndPollFailures(t *testing.T) {
+ truncateTables(t)
+
+ task := &Task{
+ TaskID: "task_cas_plugin_state",
+ Status: TaskStatusInProgress,
+ Data: json.RawMessage(`{}`),
+ PrivateData: TaskPrivateData{
+ PluginState: json.RawMessage(`{"req_key":"old"}`),
+ PollFailures: 1,
+ },
+ }
+ insertTask(t, task)
+
+ task.PrivateData.PluginState = json.RawMessage(`{"req_key":"new"}`)
+ task.PrivateData.PollFailures = 4
+ won, err := task.UpdateWithStatus(TaskStatusInProgress)
+ require.NoError(t, err)
+ require.True(t, won)
+
+ var reloaded Task
+ require.NoError(t, DB.First(&reloaded, task.ID).Error)
+ assert.EqualValues(t, TaskStatusInProgress, reloaded.Status)
+ assert.JSONEq(t, `{"req_key":"new"}`, string(reloaded.PrivateData.PluginState))
+ assert.Equal(t, 4, reloaded.PrivateData.PollFailures)
+}
diff --git a/pkg/jsplugin/cli.go b/pkg/jsplugin/cli.go
index 594ac1b38f0d..c9ad5104974e 100644
--- a/pkg/jsplugin/cli.go
+++ b/pkg/jsplugin/cli.go
@@ -33,6 +33,7 @@ func RunCLI(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "plugin lint failed: %v\n", compileErr)
return 1
}
+ warnParseTaskResultInProgressFallback(string(source), stderr)
fmt.Fprintf(stdout, "plugin %s@%s is valid\n", plugin.Meta.Key, plugin.Meta.Version)
return 0
case "test":
@@ -57,3 +58,35 @@ func RunCLI(args []string, stdout, stderr io.Writer) int {
return 2
}
}
+
+func warnParseTaskResultInProgressFallback(source string, stderr io.Writer) {
+ body := parseTaskResultFunctionBody(source)
+ if strings.Contains(body, `|| "IN_PROGRESS"`) || strings.Contains(body, `|| 'IN_PROGRESS'`) {
+ fmt.Fprintln(stderr, `warning: parseTaskResult uses || "IN_PROGRESS" fallback; return UNKNOWN for unrecognized statuses`)
+ }
+}
+
+func parseTaskResultFunctionBody(source string) string {
+ marker := strings.Index(source, "function parseTaskResult")
+ if marker < 0 {
+ return ""
+ }
+ brace := strings.Index(source[marker:], "{")
+ if brace < 0 {
+ return ""
+ }
+ start := marker + brace
+ depth := 0
+ for i := start; i < len(source); i++ {
+ switch source[i] {
+ case '{':
+ depth++
+ case '}':
+ depth--
+ if depth == 0 {
+ return source[start : i+1]
+ }
+ }
+ }
+ return ""
+}
diff --git a/pkg/jsplugin/cli_test.go b/pkg/jsplugin/cli_test.go
index b437504c929c..6431d6be85a9 100644
--- a/pkg/jsplugin/cli_test.go
+++ b/pkg/jsplugin/cli_test.go
@@ -28,6 +28,25 @@ func TestPluginCLI(t *testing.T) {
assert.Contains(t, stdout.String(), "1/1 cases")
}
+func TestPluginCLIWarnsOnParseTaskResultInProgressFallback(t *testing.T) {
+ tempDir := t.TempDir()
+ pluginPath := filepath.Join(tempDir, "fallback.js")
+ require.NoError(t, os.WriteFile(pluginPath, []byte(`
+export const meta = { apiVersion: 1, key: "fallback", name: "Fallback", version: "1.0.0", author: {name: "Test"}, models: ["m"], fetchMode: "per_task" };
+export function buildSubmitRequest(ctx) { return {url: ctx.baseUrl}; }
+export function parseSubmitResponse() { return {taskId: "task"}; }
+export function buildQueryRequest(ctx) { return {url: ctx.baseUrl}; }
+export function parseTaskResult(ctx, body) { return {status: statuses[body.status] || "IN_PROGRESS"}; }
+const statuses = { done: "SUCCESS" };
+`), 0o600))
+
+ var stdout bytes.Buffer
+ var stderr bytes.Buffer
+ assert.Equal(t, 0, RunCLI([]string{"lint", pluginPath}, &stdout, &stderr))
+ assert.Contains(t, stdout.String(), "plugin fallback@1.0.0 is valid")
+ assert.Contains(t, stderr.String(), `|| "IN_PROGRESS"`)
+}
+
const cliFixturePluginSource = `
export const meta = { apiVersion: 1, key: "cli-fixture", name: "CLI Fixture", version: "1.0.0", author: {name: "Test"}, channelTypes: [1003], models: ["fixture-model"], fetchMode: "per_task" };
export function buildSubmitRequest(ctx) { return {url: ctx.baseUrl}; }
diff --git a/plugins/hailuo_responses_test.go b/plugins/hailuo_responses_test.go
index 73c29f6e8db8..b08af2fcc54e 100644
--- a/plugins/hailuo_responses_test.go
+++ b/plugins/hailuo_responses_test.go
@@ -346,6 +346,9 @@ func TestHailuoParseTaskResult(t *testing.T) {
{"H3 permanent query error", `{"type":"error","error":{"type":"authorized_error","message":"login failed","http_code":"401"}}`, "FAILURE", "", "login failed"},
{"legacy success", `{"task_id":"1","status":"Success","file_id":"f1","base_resp":{"status_code":0}}`, "SUCCESS", "", ""},
{"legacy processing", `{"task_id":"1","status":"Processing","base_resp":{"status_code":0}}`, "IN_PROGRESS", "", ""},
+ {"H3 unrecognized", `{"task":{"id":"1","status":"weird"}}`, "UNKNOWN", "", "unrecognized status: weird"},
+ {"legacy unrecognized", `{"task_id":"1","status":"Weird","base_resp":{"status_code":0}}`, "UNKNOWN", "", "unrecognized status: Weird"},
+ {"legacy base_resp failure", `{"task_id":"1","status":"Success","base_resp":{"status_code":1001,"status_msg":"upstream down"}}`, "FAILURE", "", "upstream down"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
@@ -451,7 +454,7 @@ func TestHailuoH3CompletionUsageFacts(t *testing.T) {
t.Run("polling adaptor carries actual facts into task settlement", func(t *testing.T) {
adaptor := taskplugin.New(plugin)
- result, err := adaptor.ParseTaskResult([]byte(
+ result, err := adaptor.ParseTaskResult(&model.Task{}, &http.Response{StatusCode: http.StatusOK, Header: make(http.Header)}, []byte(
`{"task":{"id":"1","status":"succeeded","resolution":"2K","usage":{"output_seconds":5,"input_seconds":7.5,"input_image_count":6}}}`,
))
require.NoError(t, err)
diff --git a/plugins/jimeng_responses_test.go b/plugins/jimeng_responses_test.go
index 4a94df44f06d..f9c253746c5d 100644
--- a/plugins/jimeng_responses_test.go
+++ b/plugins/jimeng_responses_test.go
@@ -1,6 +1,14 @@
package plugins_test
-import "testing"
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ builtinplugins "github.com/QuantumNous/new-api/plugins"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
func TestJimengResponsesProtocol(t *testing.T) {
testVideoResponsesProtocol(t, videoResponsesTestCase{
@@ -25,3 +33,60 @@ func TestJimengResponsesProtocol(t *testing.T) {
wantVendorName: "jimeng",
})
}
+
+func loadJimengPlugin(t *testing.T) *jsplugin.LoadedPlugin {
+ t.Helper()
+ source, err := builtinplugins.Source("jimeng")
+ require.NoError(t, err)
+ plugin, err := jsplugin.NewRegistry().RegisterFactory(source, jsplugin.Options{Key: "jimeng"})
+ require.NoError(t, err)
+ return plugin
+}
+
+func TestJimengSubmitStateDrivesQueryReqKey(t *testing.T) {
+ plugin := loadJimengPlugin(t)
+ submitValue, err := plugin.Engine.Call(t.Context(), "parseSubmitResponse", map[string]any{
+ "upstreamModel": "jimeng_vgfm_i2v_l20",
+ "requestBody": map[string]any{"images": []any{"https://cdn.example/frame.png"}},
+ }, map[string]any{"body": map[string]any{"code": 10000, "data": map[string]any{"task_id": "t1"}}})
+ require.NoError(t, err)
+ encoded, err := common.Marshal(submitValue)
+ require.NoError(t, err)
+ var submit map[string]any
+ require.NoError(t, common.Unmarshal(encoded, &submit))
+ state, ok := submit["state"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "jimeng_vgfm_i2v_l20", state["req_key"])
+
+ queryValue, err := plugin.Engine.Call(t.Context(), "buildQueryRequest", map[string]any{
+ "taskId": "t1",
+ "action": "text_to_video",
+ "baseUrl": "https://jimeng.example",
+ "apiKey": "sk-test",
+ "state": map[string]any{"req_key": "custom_req_key"},
+ })
+ require.NoError(t, err)
+ queryEncoded, err := common.Marshal(queryValue)
+ require.NoError(t, err)
+ var query map[string]any
+ require.NoError(t, common.Unmarshal(queryEncoded, &query))
+ var body map[string]any
+ require.NoError(t, common.UnmarshalJsonStr(common.Interface2String(query["body"]), &body))
+ assert.Equal(t, "custom_req_key", body["req_key"])
+ assert.Equal(t, "t1", body["task_id"])
+}
+
+func TestJimengParseTaskResultUnknownStatus(t *testing.T) {
+ plugin := loadJimengPlugin(t)
+ value, err := plugin.Engine.Call(t.Context(), "parseTaskResult", map[string]any{}, map[string]any{
+ "code": 10000,
+ "data": map[string]any{"status": "weird"},
+ })
+ require.NoError(t, err)
+ encoded, err := common.Marshal(value)
+ require.NoError(t, err)
+ var result map[string]any
+ require.NoError(t, common.Unmarshal(encoded, &result))
+ assert.Equal(t, "UNKNOWN", result["status"])
+ assert.Contains(t, common.Interface2String(result["reason"]), "weird")
+}
diff --git a/plugins/tasks/alibaba/plugin.js b/plugins/tasks/alibaba/plugin.js
index 04488e7a1abf..e3eb8d0e7cdd 100644
--- a/plugins/tasks/alibaba/plugin.js
+++ b/plugins/tasks/alibaba/plugin.js
@@ -7,7 +7,7 @@ export const meta = {
en: "Alibaba Cloud Bailian Wanxiang video generation (text-to-video and image-to-video)",
zh: "阿里云百炼万相视频生成(文生视频、图生视频)",
},
- version: "1.0.0",
+ version: "1.0.1",
author: { name: "QuantumNous" },
channelTypes: [17],
models: [
@@ -270,7 +270,7 @@ export function parseTaskResult(ctx, body) {
if (!reason) reason = "task failed";
return { status: "FAILURE", reason: reason };
}
- return { status: "QUEUED" };
+ return { status: "UNKNOWN", reason: "unrecognized status: " + String(output.task_status || "") };
}
function artifactData(ctx) {
diff --git a/plugins/tasks/doubao/plugin.js b/plugins/tasks/doubao/plugin.js
index e6dd7e9ed7a3..b14698b15640 100644
--- a/plugins/tasks/doubao/plugin.js
+++ b/plugins/tasks/doubao/plugin.js
@@ -7,7 +7,7 @@ export const meta = {
en: "Volcengine Doubao Seedance video generation (text-to-video, image-to-video, and video-to-video)",
zh: "火山引擎豆包 Seedance 视频生成(文生视频、图生视频、视频生视频)",
},
- version: "1.0.0",
+ version: "1.0.1",
author: { name: "QuantumNous" },
channelTypes: [54, 45], // VolcEngine-type channels serve Ark video models with the same wire format
models: [
@@ -324,7 +324,7 @@ export function parseTaskResult(ctx, body) {
const reason = body.error && body.error.message ? body.error.message : body.status;
return { status: "FAILURE", progress: "100%", reason: reason };
}
- return { status: "IN_PROGRESS", progress: "30%" };
+ return { status: "UNKNOWN", reason: "unrecognized status: " + String(body.status || "") };
}
function artifactData(ctx) {
diff --git a/plugins/tasks/google/plugin.js b/plugins/tasks/google/plugin.js
index 2c3c04c1e80e..624d045e3b40 100644
--- a/plugins/tasks/google/plugin.js
+++ b/plugins/tasks/google/plugin.js
@@ -7,7 +7,7 @@ export const meta = {
en: "Google Veo video generation on the Gemini API (text-to-video and image-to-video)",
zh: "Google Veo 视频生成(文生视频、图生视频),Gemini API 版本",
},
- version: "1.0.0",
+ version: "1.0.1",
author: { name: "QuantumNous" },
channelTypes: [24],
models: ["veo-3.0-generate-001", "veo-3.0-fast-generate-001", "veo-3.1-generate-preview", "veo-3.1-fast-generate-preview"],
@@ -209,7 +209,11 @@ export function buildQueryRequest(ctx) {
export function parseTaskResult(ctx, body) {
if (body.error && body.error.message) return { status: "FAILURE", progress: "100%", reason: body.error.message };
- if (!body.done) return { status: "IN_PROGRESS", progress: "50%" };
+ // Google long-running operations omit `done` (proto3 default) while still
+ // running, so a missing key means in-progress; only a non-operation shape is
+ // unrecognized.
+ if (!body || typeof body !== "object" || !String(body.name || "").trim()) return { status: "UNKNOWN", reason: "unrecognized operation state" };
+ if (body.done !== true) return { status: "IN_PROGRESS", progress: "50%" };
const videos = ((body.response || {}).generateVideoResponse || {}).generatedVideos || [];
const uri = videos.length && videos[0].video ? videos[0].video.uri || "" : "";
return { taskId: utils.base64URL(body.name || ""), status: "SUCCESS", progress: "100%", remoteUrl: uri };
diff --git a/plugins/tasks/hailuo/plugin.js b/plugins/tasks/hailuo/plugin.js
index 8a9a2f66bf8c..eed7f9030e8a 100644
--- a/plugins/tasks/hailuo/plugin.js
+++ b/plugins/tasks/hailuo/plugin.js
@@ -7,7 +7,7 @@ export const meta = {
en: "MiniMax Hailuo video generation (text-to-video, image-to-video, and MiniMax-H3 multimodal reference)",
zh: "MiniMax 海螺视频生成(文生视频、图生视频、MiniMax-H3 多模态参考生视频)",
},
- version: "1.1.1",
+ version: "1.1.2",
author: { name: "QuantumNous" },
channelTypes: [35],
models: [
@@ -465,8 +465,6 @@ export function buildQueryRequest(ctx) {
}
export function parseTaskResult(ctx, body) {
- // The host calls this hook with an empty context, so the response envelope is
- // the only way to tell a /v2 result from a /v1 one.
const apiError = h3APIError(body);
if (apiError) {
if (apiError.statusCode === 408 || apiError.statusCode === 429 || apiError.statusCode >= 500) throw new Error(apiError.message);
@@ -475,7 +473,10 @@ export function parseTaskResult(ctx, body) {
const h3Task = h3QueryTask(body);
if (h3Task) {
const h3Statuses = { queued: "QUEUED", running: "IN_PROGRESS", succeeded: "SUCCESS", failed: "FAILURE", cancelled: "FAILURE" };
- const h3Status = h3Statuses[h3Task.status] || "IN_PROGRESS";
+ const h3Status = h3Statuses[h3Task.status];
+ if (!h3Status) {
+ return { status: "UNKNOWN", reason: "unrecognized status: " + String(h3Task.status || "") };
+ }
const h3Result = { code: 0, status: h3Status, progress: h3Status === "QUEUED" ? "30%" : h3Status === "IN_PROGRESS" ? "50%" : "100%" };
if (h3Status === "SUCCESS") {
const url = trimmed(h3Task.content && h3Task.content.url);
@@ -486,11 +487,17 @@ export function parseTaskResult(ctx, body) {
}
return h3Result;
}
+ if (body.base_resp && body.base_resp.status_code !== 0) {
+ return { code: body.base_resp.status_code || 0, status: "FAILURE", progress: "100%", reason: body.base_resp.status_msg || "" };
+ }
const base = body.base_resp || {};
const statuses = { Preparing: "IN_PROGRESS", Queueing: "IN_PROGRESS", Processing: "IN_PROGRESS", Success: "SUCCESS", Fail: "FAILURE" };
- const status = statuses[body.status] || "IN_PROGRESS";
+ const status = statuses[body.status];
+ if (!status) {
+ return { status: "UNKNOWN", reason: "unrecognized status: " + String(body.status || "") };
+ }
const progress = status === "SUCCESS" || status === "FAILURE" ? "100%" : body.status === "Processing" ? "50%" : "30%";
- const reason = base.status_code !== 0 ? base.status_msg || "" : status === "FAILURE" ? "task failed" : "";
+ const reason = status === "FAILURE" ? "task failed" : "";
return { code: base.status_code || 0, status: status, progress: progress, reason: reason };
}
diff --git a/plugins/tasks/jimeng/plugin.js b/plugins/tasks/jimeng/plugin.js
index f7714b16704c..847de65dcafe 100644
--- a/plugins/tasks/jimeng/plugin.js
+++ b/plugins/tasks/jimeng/plugin.js
@@ -7,7 +7,7 @@ export const meta = {
en: "Volcengine Jimeng video generation (text-to-video, image-to-video, and first-and-last-frame)",
zh: "火山引擎即梦视频生成(文生视频、图生视频、首尾帧)",
},
- version: "1.0.0",
+ version: "1.0.1",
author: { name: "QuantumNous" },
channelTypes: [51],
models: ["jimeng_vgfm_t2v_l20"],
@@ -270,10 +270,8 @@ function filePlaceholder(image) {
}
function queryReqKey(ctx) {
- const data = (ctx && ctx.data) || {};
- if (typeof data.req_key === "string" && data.req_key.trim()) return data.req_key.trim();
- const req = (ctx && ctx.requestBody) || {};
- if (typeof req.req_key === "string" && req.req_key.trim()) return req.req_key.trim();
+ const state = (ctx && ctx.state) || {};
+ if (typeof state.req_key === "string" && state.req_key.trim()) return state.req_key.trim();
if (ctx && ctx.action === "image_to_video") return "jimeng_vgfm_i2v_l20";
if (ctx && ctx.action === "first_tail_to_video") return "jimeng_i2v_first_tail_v30";
return "jimeng_vgfm_t2v_l20";
@@ -349,7 +347,7 @@ export function parseSubmitResponse(ctx, resp) {
const body = resp.body || {};
if (body.code !== 10000) throw new Error(body.message || "jimeng submit failed");
if (!body.data || !body.data.task_id) throw new Error("missing task_id");
- return { taskId: body.data.task_id, taskData: Object.assign({}, body, { req_key: submitReqKey(ctx) }) };
+ return { taskId: body.data.task_id, taskData: Object.assign({}, body, { req_key: submitReqKey(ctx) }), state: { req_key: submitReqKey(ctx) } };
}
export function extractUsage(ctx) {
@@ -366,22 +364,20 @@ export function buildQueryRequest(ctx) {
export function parseTaskResult(ctx, body) {
const data = body.data || {};
- let status = "";
- let progress = "";
if (body.code !== 10000) {
- status = "FAILURE";
- progress = "100%";
+ return { code: body.code || 0, status: "FAILURE", progress: "100%", reason: body.message || "" };
}
if (data.status === "in_queue") {
- status = "QUEUED";
- progress = "10%";
- } else if (data.status === "done") {
- status = "SUCCESS";
- progress = "100%";
+ const result = { code: 0, status: "QUEUED", progress: "10%", reason: "" };
+ if (data.video_url) result.url = data.video_url;
+ return result;
+ }
+ if (data.status === "done") {
+ const result = { code: 0, status: "SUCCESS", progress: "100%", reason: "" };
+ if (data.video_url) result.url = data.video_url;
+ return result;
}
- const result = { code: body.code === 10000 ? 0 : body.code || 0, status: status, progress: progress, reason: body.code === 10000 ? "" : body.message || "" };
- if (data.video_url) result.url = data.video_url;
- return result;
+ return { code: 0, status: "UNKNOWN", reason: "unrecognized status: " + String(data.status || "") };
}
function artifactData(ctx) {
diff --git a/plugins/tasks/kling/plugin.js b/plugins/tasks/kling/plugin.js
index c61ca925d22a..c0330940a0fa 100644
--- a/plugins/tasks/kling/plugin.js
+++ b/plugins/tasks/kling/plugin.js
@@ -7,7 +7,7 @@ export const meta = {
en: "Kuaishou Kling video generation (text-to-video and image-to-video)",
zh: "快手可灵视频生成(文生视频、图生视频)",
},
- version: "1.0.0",
+ version: "1.0.1",
author: { name: "QuantumNous" },
channelTypes: [50],
models: ["kling-v1", "kling-v1-6", "kling-v2-master"],
@@ -286,7 +286,7 @@ export function parseTaskResult(ctx, body) {
const data = body.data || {};
const statuses = { submitted: "SUBMITTED", processing: "IN_PROGRESS", succeed: "SUCCESS", failed: "FAILURE" };
const status = statuses[data.task_status];
- if (!status) throw new Error("unknown task status: " + data.task_status);
+ if (!status) return { status: "UNKNOWN", reason: "unknown task status: " + String(data.task_status || "") };
const videos = status === "SUCCESS" && data.task_result && data.task_result.videos ? data.task_result.videos : [];
const result = { code: body.code || 0, taskId: data.task_id, status: status, reason: data.task_status_msg || "" };
if (videos.length && videos[0].url) result.url = videos[0].url;
diff --git a/plugins/tasks/sora/plugin.js b/plugins/tasks/sora/plugin.js
index 3d4fae3eed1d..f0dc51cef612 100644
--- a/plugins/tasks/sora/plugin.js
+++ b/plugins/tasks/sora/plugin.js
@@ -7,7 +7,7 @@ export const meta = {
en: "OpenAI Sora video generation (text-to-video, image-to-video, and remix)",
zh: "OpenAI Sora 视频生成(文生视频、图生视频、remix)",
},
- version: "1.0.0",
+ version: "1.0.1",
channelTypes: [55, 1], // OpenAI-type channels natively serve sora with the same wire format
author: { name: "QuantumNous" },
models: ["sora-2", "sora-2-pro"],
@@ -145,7 +145,9 @@ export function parseTaskResult(ctx, body) {
failed: "FAILURE",
cancelled: "FAILURE",
};
- const result = { status: statuses[body.status] || "UNKNOWN" };
+ const mapped = statuses[body.status];
+ const result = { status: mapped || "UNKNOWN" };
+ if (!mapped) result.reason = "unrecognized status: " + String(body.status || "");
if (body.progress > 0 && body.progress < 100) result.progress = body.progress + "%";
if (result.status === "FAILURE") result.reason = body.error && body.error.message ? body.error.message : "task failed";
return result;
diff --git a/plugins/tasks/sunoapi/plugin.js b/plugins/tasks/sunoapi/plugin.js
index 3749b2c08ff8..cc9745978ff3 100644
--- a/plugins/tasks/sunoapi/plugin.js
+++ b/plugins/tasks/sunoapi/plugin.js
@@ -9,7 +9,7 @@ export const meta = {
en: "SunoAPI project music and lyrics generation",
zh: "SunoAPI 项目 音乐与歌词生成",
},
- version: "1.0.0",
+ version: "1.0.1",
author: { name: "QuantumNous" },
channelTypes: [36],
models: ["suno_music", "suno_lyrics"],
@@ -133,19 +133,23 @@ export function extractUsage(ctx) {
return { clips: action === "lyrics" ? 1 : 2, action: action };
}
-export function buildBatchQueryRequest(ctx, taskIds) {
+export function buildBatchQueryRequest(ctx, tasks) {
return {
url: ctx.baseUrl + "/suno/fetch",
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + ctx.apiKey },
- body: { ids: taskIds },
+ body: {
+ ids: (tasks || []).map(function (task) {
+ return task.taskId;
+ }),
+ },
};
}
// Required v1 per-task hooks remain defined for contract compatibility. Suno's
// host polling path uses the batch hooks below.
export function buildQueryRequest(ctx) {
- return buildBatchQueryRequest(ctx, (ctx.requestBody || {}).ids || []);
+ return buildBatchQueryRequest(ctx, [ctx]);
}
export function parseBatchResult(ctx, body) {
diff --git a/plugins/tasks/vertex-ai/plugin.js b/plugins/tasks/vertex-ai/plugin.js
index 7970c0a2cfb7..d2392075c64c 100644
--- a/plugins/tasks/vertex-ai/plugin.js
+++ b/plugins/tasks/vertex-ai/plugin.js
@@ -7,7 +7,7 @@ export const meta = {
en: "Google Veo video generation on Vertex AI (text-to-video and image-to-video)",
zh: "Google Veo 视频生成(文生视频、图生视频),Vertex AI 版本",
},
- version: "1.0.0",
+ version: "1.0.1",
channelTypes: [41],
author: { name: "QuantumNous" },
models: ["veo-3.0-generate-001", "veo-3.0-fast-generate-001", "veo-3.1-generate-preview", "veo-3.1-fast-generate-preview"],
@@ -225,7 +225,11 @@ export function buildQueryRequest(ctx) {
}
export function parseTaskResult(ctx, body) {
if (body.error && body.error.message) return { status: "FAILURE", progress: "100%", reason: body.error.message };
- if (!body.done) return { status: "IN_PROGRESS", progress: "50%" };
+ // Google long-running operations omit `done` (proto3 default) while still
+ // running, so a missing key means in-progress; only a non-operation shape is
+ // unrecognized.
+ if (!body || typeof body !== "object" || !String(body.name || "").trim()) return { status: "UNKNOWN", reason: "unrecognized operation state" };
+ if (body.done !== true) return { status: "IN_PROGRESS", progress: "50%" };
const url = dataVideo(body.response || {});
return { status: "SUCCESS", progress: "100%", url: url, remoteUrl: url };
}
diff --git a/plugins/tasks/vidu/plugin.js b/plugins/tasks/vidu/plugin.js
index 3b529f66a52e..e9e49c9aec8d 100644
--- a/plugins/tasks/vidu/plugin.js
+++ b/plugins/tasks/vidu/plugin.js
@@ -7,7 +7,7 @@ export const meta = {
en: "Shengshu Vidu video generation (text-to-video, image-to-video, first-and-last-frame, and reference-to-video)",
zh: "生数 Vidu 视频生成(文生视频、图生视频、首尾帧、参考生视频)",
},
- version: "1.0.0",
+ version: "1.0.1",
author: { name: "QuantumNous" },
channelTypes: [52],
models: ["viduq2", "viduq1", "vidu2.0", "vidu1.5"],
@@ -265,7 +265,7 @@ export function buildQueryRequest(ctx) {
export function parseTaskResult(ctx, body) {
const statuses = { created: "SUBMITTED", queueing: "SUBMITTED", processing: "IN_PROGRESS", success: "SUCCESS", failed: "FAILURE" };
const status = statuses[body.state];
- if (!status) throw new Error("unknown task state: " + body.state);
+ if (!status) return { status: "UNKNOWN", reason: "unknown task state: " + String(body.state || "") };
const url = body.creations && body.creations.length ? body.creations[0].url || "" : "";
const result = { status: status, reason: body.state === "failed" ? body.err_code || "" : "" };
if (url) result.url = url;
diff --git a/plugins/veo_poll_test.go b/plugins/veo_poll_test.go
new file mode 100644
index 000000000000..529b8f1c378a
--- /dev/null
+++ b/plugins/veo_poll_test.go
@@ -0,0 +1,61 @@
+package plugins_test
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/pkg/jsplugin"
+ builtinplugins "github.com/QuantumNous/new-api/plugins"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// Google long-running operations serialize proto3 defaults by omission: a
+// still-running operation has no `done` key at all. Treating that as
+// unrecognized would count every normal poll as a failure and fail the task
+// at the poll-failure threshold while the video is still rendering.
+func TestVeoParseTaskResultTreatsMissingDoneAsInProgress(t *testing.T) {
+ cases := []struct {
+ name string
+ body map[string]any
+ wantStatus string
+ }{
+ {
+ name: "running operation omits done",
+ body: map[string]any{"name": "operations/abc", "metadata": map[string]any{"@type": "x"}},
+ wantStatus: "IN_PROGRESS",
+ },
+ {
+ name: "explicit done false",
+ body: map[string]any{"name": "operations/abc", "done": false},
+ wantStatus: "IN_PROGRESS",
+ },
+ {
+ name: "body without operation name is unrecognized",
+ body: map[string]any{"foo": "bar"},
+ wantStatus: "UNKNOWN",
+ },
+ {
+ name: "operation error is failure",
+ body: map[string]any{"name": "operations/abc", "done": true, "error": map[string]any{"message": "quota exceeded"}},
+ wantStatus: "FAILURE",
+ },
+ }
+ for _, key := range []string{"google", "vertex-ai"} {
+ source, err := builtinplugins.Source(key)
+ require.NoError(t, err)
+ plugin, err := jsplugin.NewRegistry().RegisterFactory(source, jsplugin.Options{Key: key})
+ require.NoError(t, err)
+ for _, tc := range cases {
+ t.Run(key+"/"+tc.name, func(t *testing.T) {
+ value, err := plugin.Engine.Call(t.Context(), "parseTaskResult", map[string]any{}, tc.body)
+ require.NoError(t, err)
+ encoded, err := common.Marshal(value)
+ require.NoError(t, err)
+ var result map[string]any
+ require.NoError(t, common.Unmarshal(encoded, &result))
+ assert.Equal(t, tc.wantStatus, result["status"])
+ })
+ }
+ }
+}
diff --git a/relay/channel/adapter.go b/relay/channel/adapter.go
index e4aa887dbcee..5826e0ea13f0 100644
--- a/relay/channel/adapter.go
+++ b/relay/channel/adapter.go
@@ -76,8 +76,8 @@ type TaskAdaptor interface {
// ── Polling ──────────────────────────────────────────────────────
- FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error)
- ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error)
+ FetchTask(baseUrl, key string, task *model.Task, proxy string) (*http.Response, error)
+ ParseTaskResult(task *model.Task, resp *http.Response, respBody []byte) (*relaycommon.TaskInfo, error)
}
// TaskSubmitResponse is the transport-independent result of parsing an
@@ -87,6 +87,7 @@ type TaskSubmitResponse struct {
TaskData []byte
ClientResponse any
Immediate *relaycommon.TaskInfo
+ PluginState []byte
}
type OpenAIVideoConverter interface {
diff --git a/relay/channel/task/jsplugin/adaptor.go b/relay/channel/task/jsplugin/adaptor.go
index 7492276d5257..6b150cac0b3e 100644
--- a/relay/channel/task/jsplugin/adaptor.go
+++ b/relay/channel/task/jsplugin/adaptor.go
@@ -22,12 +22,12 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
- kitdto "github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relay/channel"
relaycommon "github.com/QuantumNous/new-api/relay/common"
+ kitdto "github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
@@ -56,6 +56,7 @@ type submitResponse struct {
TaskID string `json:"taskId"`
TaskData any `json:"taskData"`
Immediate *taskResult `json:"immediate"`
+ State any `json:"state"`
}
type taskResult struct {
Code int `json:"code"`
@@ -67,12 +68,16 @@ type taskResult struct {
RemoteURL string `json:"remoteUrl"`
CompletionTokens float64 `json:"completionTokens"`
TotalTokens float64 `json:"totalTokens"`
+ State any `json:"state"`
}
var taskArtifactKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$`)
const maxTaskArtifacts = 64
+// maxTaskPluginPersistedJSONBytes is the shared ceiling for taskData and plugin state.
+const maxTaskPluginPersistedJSONBytes = 1 << 20
+
type TaskAdaptor struct {
plugin *pluginruntime.LoadedPlugin
info *relaycommon.RelayInfo
@@ -497,10 +502,16 @@ func (a *TaskAdaptor) ParseResponse(c *gin.Context, resp *http.Response, info *r
immediate != nil,
time.Since(started).Milliseconds(),
)
+ pluginState, _ := encodeReturnedPluginState(value)
+ if len(pluginState) > maxTaskPluginPersistedJSONBytes {
+ logger.LogWarn(c, fmt.Sprintf("task plugin %s rejected oversized submit state (%d bytes)", a.plugin.Meta.Key, len(pluginState)))
+ pluginState = nil
+ }
return &channel.TaskSubmitResponse{
UpstreamTaskID: parsed.TaskID,
TaskData: taskData,
Immediate: immediate,
+ PluginState: pluginState,
}, nil
}
@@ -508,50 +519,32 @@ func (a *TaskAdaptor) GetModelList() []string { return append([]string(nil), a.p
func (a *TaskAdaptor) GetChannelName() string { return a.plugin.Meta.Name }
func (a *TaskAdaptor) FetchMode() string { return a.plugin.Meta.FetchMode }
-func (a *TaskAdaptor) FetchBatchTasks(baseURL, key string, taskIDs []string, proxy string) (*http.Response, error) {
- ctx := map[string]any{"baseUrl": baseURL}
- auth, err := resolveAuth(a.plugin.Meta.Auth, key, proxy)
+func (a *TaskAdaptor) FetchBatchTasks(baseURL, key string, tasks []*model.Task, proxy string) (*http.Response, error) {
+ taskContexts := make([]map[string]any, 0, len(tasks))
+ for _, task := range tasks {
+ taskCtx, err := a.queryContext(task, key, baseURL, proxy)
+ if err != nil {
+ return nil, err
+ }
+ taskContexts = append(taskContexts, taskCtx)
+ }
+ ctx, err := a.batchQueryContext(key, baseURL, proxy, taskContexts)
if err != nil {
return nil, err
}
- ctx["auth"] = auth
- ctx["authHeader"] = auth["authHeader"]
- if a.plugin.Meta.Auth.Type == "" || a.plugin.Meta.Auth.Type == "none" || a.plugin.Meta.Auth.Type == "api_key" {
- ctx["apiKey"] = key
- }
- value, err := a.plugin.Engine.Call(context.Background(), "buildBatchQueryRequest", ctx, taskIDs)
+ value, err := a.plugin.Engine.Call(context.Background(), "buildBatchQueryRequest", ctx, taskContexts)
if err != nil {
return nil, err
}
return a.doFetchDescriptor(baseURL, proxy, value)
}
-func (a *TaskAdaptor) FetchTask(baseURL, key string, body map[string]any, proxy string) (*http.Response, error) {
- ctx := map[string]any{"taskId": body["task_id"], "action": body["action"], "requestBody": body, "baseUrl": baseURL}
- // Query hooks are driver hooks and must see the same model identities as
- // submit hooks. Polling has no relay info, so they arrive with the
- // persisted task properties the caller puts in the fetch body.
- originModel, _ := body["model"].(string)
- upstreamModel, _ := body["upstream_model"].(string)
- if upstreamModel == "" {
- upstreamModel = originModel
- }
- ctx["model"] = originModel
- ctx["upstreamModel"] = upstreamModel
- auth, err := resolveAuth(a.plugin.Meta.Auth, key, proxy)
+func (a *TaskAdaptor) FetchTask(baseURL, key string, task *model.Task, proxy string) (*http.Response, error) {
+ ctx, err := a.queryContext(task, key, baseURL, proxy)
if err != nil {
return nil, err
}
- ctx["auth"] = auth
- ctx["authHeader"] = auth["authHeader"]
- if a.plugin.Meta.Auth.Type == "" || a.plugin.Meta.Auth.Type == "none" || a.plugin.Meta.Auth.Type == "api_key" {
- ctx["apiKey"] = key
- }
- hook := "buildQueryRequest"
- if a.plugin.Meta.FetchMode == "batch" && a.hasHook(context.Background(), "buildBatchQueryRequest") {
- hook = "buildBatchQueryRequest"
- }
- value, err := a.plugin.Engine.Call(context.Background(), hook, ctx)
+ value, err := a.plugin.Engine.Call(context.Background(), "buildQueryRequest", ctx)
if err != nil {
return nil, err
}
@@ -616,14 +609,27 @@ func (a *TaskAdaptor) doFetchDescriptor(baseURL, proxy string, value any) (*http
return resp, nil
}
-func (a *TaskAdaptor) ParseBatchResult(body []byte) (map[string]*service.BatchTaskResult, error) {
+func (a *TaskAdaptor) ParseBatchResult(tasks []*model.Task, resp *http.Response, body []byte) (map[string]*service.BatchTaskResult, error) {
started := time.Now()
input := any(string(body))
var decoded any
if common.Unmarshal(body, &decoded) == nil {
input = decoded
}
- value, err := a.plugin.Engine.Call(context.Background(), "parseBatchResult", map[string]any{}, input)
+ key, baseURL, proxy := a.queryCredentials()
+ taskContexts := make([]map[string]any, 0, len(tasks))
+ for _, task := range tasks {
+ taskCtx, err := a.queryContext(task, key, baseURL, proxy)
+ if err != nil {
+ return nil, err
+ }
+ taskContexts = append(taskContexts, taskCtx)
+ }
+ ctx, err := a.batchQueryContext(key, baseURL, proxy, taskContexts)
+ if err != nil {
+ return nil, err
+ }
+ value, err := a.plugin.Engine.Call(context.Background(), "parseBatchResult", ctx, input, hookHTTPResponse(resp))
if err != nil {
logger.LogDebug(context.Background(), "task_plugin subsystem=adaptor event=parse_batch_failed plugin=%q reason=hook_failed body_bytes=%d elapsed_ms=%d", a.plugin.Meta.Key, len(body), time.Since(started).Milliseconds())
return nil, err
@@ -639,6 +645,7 @@ func (a *TaskAdaptor) ParseBatchResult(body []byte) (map[string]*service.BatchTa
StartTime int64 `json:"startTime"`
FinishTime int64 `json:"finishTime"`
Data any `json:"data"`
+ State any `json:"state"`
}
if err = convert(value, &parsed); err != nil {
logger.LogDebug(context.Background(), "task_plugin subsystem=adaptor event=parse_batch_failed plugin=%q reason=invalid_result body_bytes=%d elapsed_ms=%d", a.plugin.Meta.Key, len(body), time.Since(started).Milliseconds())
@@ -651,12 +658,27 @@ func (a *TaskAdaptor) ParseBatchResult(body []byte) (map[string]*service.BatchTa
continue
}
info := relaycommon.TaskInfo{TaskID: item.TaskID, Status: item.Status, Progress: item.Progress, Reason: item.Reason, Url: item.URL}
+ if item.State != nil {
+ pluginState, marshalErr := common.Marshal(item.State)
+ if marshalErr != nil || len(pluginState) > maxTaskPluginPersistedJSONBytes {
+ logger.LogWarn(context.Background(), fmt.Sprintf("task plugin %s rejected invalid or oversized poll state", a.plugin.Meta.Key))
+ } else {
+ info.PluginState = pluginState
+ }
+ }
if hasCompletionUsage {
usageBody := item.Data
if usageBody == nil {
usageBody = jsonValue(item)
}
- facts, hookErr := a.plugin.Engine.Call(context.Background(), "extractUsageOnComplete", nil, jsonValue(&info), usageBody)
+ itemCtx := ctx
+ for _, taskCtx := range taskContexts {
+ if fmt.Sprint(taskCtx["taskId"]) == item.TaskID {
+ itemCtx = taskCtx
+ break
+ }
+ }
+ facts, hookErr := a.plugin.Engine.Call(context.Background(), "extractUsageOnComplete", itemCtx, jsonValue(&info), usageBody)
if hookErr == nil {
a.applyCompletionUsageFacts(&info, facts)
}
@@ -675,14 +697,22 @@ func (a *TaskAdaptor) ParseBatchResult(body []byte) (map[string]*service.BatchTa
return results, nil
}
-func (a *TaskAdaptor) ParseTaskResult(body []byte) (*relaycommon.TaskInfo, error) {
+func (a *TaskAdaptor) ParseTaskResult(task *model.Task, resp *http.Response, body []byte) (*relaycommon.TaskInfo, error) {
started := time.Now()
input := any(string(body))
var decoded any
if common.Unmarshal(body, &decoded) == nil {
input = decoded
}
- value, err := a.plugin.Engine.Call(context.Background(), "parseTaskResult", map[string]any{}, input)
+ key, baseURL, proxy := a.queryCredentials()
+ if task != nil && task.PrivateData.Key != "" {
+ key = task.PrivateData.Key
+ }
+ ctx, err := a.queryContext(task, key, baseURL, proxy)
+ if err != nil {
+ return nil, err
+ }
+ value, err := a.plugin.Engine.Call(context.Background(), "parseTaskResult", ctx, input, hookHTTPResponse(resp))
if err != nil {
logger.LogDebug(context.Background(), "task_plugin subsystem=adaptor event=parse_task_failed plugin=%q reason=hook_failed body_bytes=%d elapsed_ms=%d", a.plugin.Meta.Key, len(body), time.Since(started).Milliseconds())
return nil, err
@@ -703,10 +733,17 @@ func (a *TaskAdaptor) ParseTaskResult(body []byte) (*relaycommon.TaskInfo, error
CompletionTokens: positiveInt(parsed.CompletionTokens),
TotalTokens: positiveInt(parsed.TotalTokens),
}
+ if pluginState, present := encodeReturnedPluginState(value); present {
+ if len(pluginState) > maxTaskPluginPersistedJSONBytes {
+ logger.LogWarn(context.Background(), fmt.Sprintf("task plugin %s rejected oversized poll state (%d bytes)", a.plugin.Meta.Key, len(pluginState)))
+ } else {
+ result.PluginState = pluginState
+ }
+ }
// The raw polling response only exists at this boundary. Capture upstream
// units here so the host settlement path can consume them from TaskInfo.
if a.hasHook(context.Background(), "extractUsageOnComplete") {
- facts, hookErr := a.plugin.Engine.Call(context.Background(), "extractUsageOnComplete", nil, jsonValue(result), input)
+ facts, hookErr := a.plugin.Engine.Call(context.Background(), "extractUsageOnComplete", ctx, jsonValue(result), input)
if hookErr == nil {
a.applyCompletionUsageFacts(result, facts)
}
@@ -901,15 +938,125 @@ func taskArtifactContext(task *model.Task) (map[string]any, error) {
if task.PrivateData.Execution != nil && task.PrivateData.Execution.TaskPlugin != nil {
producerVersion = task.PrivateData.Execution.TaskPlugin.Version
}
+ var state any
+ if len(task.PrivateData.PluginState) > 0 {
+ if err := common.Unmarshal(task.PrivateData.PluginState, &state); err != nil {
+ return nil, fmt.Errorf("plugin state is invalid")
+ }
+ }
return map[string]any{
"taskId": task.TaskID,
"status": string(task.Status),
"action": task.Action,
"data": data,
+ "state": state,
"producerVersion": producerVersion,
}, nil
}
+func (a *TaskAdaptor) queryContext(task *model.Task, key, baseURL, proxy string) (map[string]any, error) {
+ ctx := map[string]any{
+ "taskId": "",
+ "publicTaskId": "",
+ "action": "",
+ "model": "",
+ "upstreamModel": "",
+ "baseUrl": baseURL,
+ "data": nil,
+ "state": nil,
+ }
+ if task != nil {
+ originModel := task.Properties.OriginModelName
+ upstreamModel := task.Properties.UpstreamModelName
+ if upstreamModel == "" {
+ upstreamModel = originModel
+ }
+ ctx["taskId"] = task.GetUpstreamTaskID()
+ ctx["publicTaskId"] = task.TaskID
+ ctx["action"] = constant.NormalizeTaskAction(task.Action)
+ ctx["model"] = originModel
+ ctx["upstreamModel"] = upstreamModel
+ if len(task.Data) > 0 {
+ var data any
+ if err := common.Unmarshal(task.Data, &data); err != nil {
+ return nil, fmt.Errorf("task data is invalid")
+ }
+ ctx["data"] = data
+ }
+ if len(task.PrivateData.PluginState) > 0 {
+ var state any
+ if err := common.Unmarshal(task.PrivateData.PluginState, &state); err != nil {
+ return nil, fmt.Errorf("plugin state is invalid")
+ }
+ ctx["state"] = state
+ }
+ if task.PrivateData.Key != "" {
+ key = task.PrivateData.Key
+ }
+ }
+ auth, err := resolveAuth(a.plugin.Meta.Auth, key, proxy)
+ if err != nil {
+ return nil, err
+ }
+ ctx["auth"] = auth
+ ctx["authHeader"] = auth["authHeader"]
+ if a.plugin.Meta.Auth.Type == "" || a.plugin.Meta.Auth.Type == "none" || a.plugin.Meta.Auth.Type == "api_key" {
+ ctx["apiKey"] = key
+ }
+ return ctx, nil
+}
+
+func (a *TaskAdaptor) batchQueryContext(key, baseURL, proxy string, tasks []map[string]any) (map[string]any, error) {
+ ctx := map[string]any{"baseUrl": baseURL, "tasks": tasks}
+ auth, err := resolveAuth(a.plugin.Meta.Auth, key, proxy)
+ if err != nil {
+ return nil, err
+ }
+ ctx["auth"] = auth
+ ctx["authHeader"] = auth["authHeader"]
+ if a.plugin.Meta.Auth.Type == "" || a.plugin.Meta.Auth.Type == "none" || a.plugin.Meta.Auth.Type == "api_key" {
+ ctx["apiKey"] = key
+ }
+ return ctx, nil
+}
+
+func (a *TaskAdaptor) queryCredentials() (key, baseURL, proxy string) {
+ if a.info == nil || !a.info.HasChannelMeta() {
+ return "", "", ""
+ }
+ return a.info.ApiKey, a.info.ChannelBaseUrl, a.info.ChannelSetting.Proxy
+}
+
+func hookHTTPResponse(resp *http.Response) map[string]any {
+ headers := map[string]string{}
+ status := 0
+ if resp != nil {
+ status = resp.StatusCode
+ for name, values := range resp.Header {
+ if len(values) > 0 {
+ headers[name] = values[0]
+ }
+ }
+ }
+ return map[string]any{"status": status, "headers": headers}
+}
+
+func encodeReturnedPluginState(value any) ([]byte, bool) {
+ object, ok := value.(map[string]any)
+ if !ok {
+ return nil, false
+ }
+ state, exists := object["state"]
+ if !exists || state == nil {
+ return nil, false
+ }
+ data, err := common.Marshal(state)
+ if err != nil {
+ return nil, false
+ }
+ return data, true
+}
+
func validateTaskArtifacts(value any) ([]channel.TaskArtifact, error) {
encoded, err := common.Marshal(value)
if err != nil {
diff --git a/relay/channel/task/jsplugin/adaptor_test.go b/relay/channel/task/jsplugin/adaptor_test.go
index dd7fa3d85968..634f3621dc7c 100644
--- a/relay/channel/task/jsplugin/adaptor_test.go
+++ b/relay/channel/task/jsplugin/adaptor_test.go
@@ -503,12 +503,15 @@ func TestTaskAdaptorMapsJSContract(t *testing.T) {
assert.Empty(t, recorder.Body.String(), "response parsing must not write before the durable task barrier")
assert.Equal(t, map[string]float64{"seconds": 7}, adaptor.AdjustBillingOnSubmit(info, []byte(`{"seconds":7}`)))
- queryResp, err := adaptor.FetchTask(server.URL, "secret", map[string]any{"task_id": parsed.UpstreamTaskID, "action": info.Action}, "")
+ queryResp, err := adaptor.FetchTask(server.URL, "secret", &model.Task{
+ Action: info.Action,
+ PrivateData: model.TaskPrivateData{UpstreamTaskID: parsed.UpstreamTaskID},
+ }, "")
require.NoError(t, err)
queryBody, err := io.ReadAll(queryResp.Body)
require.NoError(t, err)
require.NoError(t, queryResp.Body.Close())
- result, err := adaptor.ParseTaskResult(queryBody)
+ result, err := adaptor.ParseTaskResult(&model.Task{}, queryResp, queryBody)
require.NoError(t, err)
assert.Equal(t, "SUCCESS", result.Status)
assert.Equal(t, "https://cdn.example/video.mp4", result.Url)
@@ -866,7 +869,7 @@ export function extractUsageOnComplete(task, result, body) { return (body || {})
adaptor, _, _ := newRequest(t, map[string]any{})
body, marshalErr := common.Marshal(map[string]any{"completionUsage": testCase.usage})
require.NoError(t, marshalErr)
- result, parseErr := adaptor.ParseTaskResult(body)
+ result, parseErr := adaptor.ParseTaskResult(&model.Task{}, &http.Response{StatusCode: http.StatusOK, Header: make(http.Header)}, body)
require.NoError(t, parseErr)
assert.Nil(t, result.UsageFacts)
assert.Zero(t, result.TotalTokens)
@@ -877,7 +880,7 @@ export function extractUsageOnComplete(task, result, body) { return (body || {})
adaptor, _, _ := newRequest(t, map[string]any{})
body, err := common.Marshal(map[string]any{"completionUsage": map[string]any{"tokens": 500000}})
require.NoError(t, err)
- result, err := adaptor.ParseTaskResult(body)
+ result, err := adaptor.ParseTaskResult(&model.Task{}, &http.Response{StatusCode: http.StatusOK, Header: make(http.Header)}, body)
require.NoError(t, err)
assert.EqualValues(t, 500000, result.UsageFacts["tokens"])
})
@@ -916,7 +919,7 @@ export function extractUsageOnComplete() { return {units: 3.5}; }
body, err := common.Marshal(map[string]any{})
require.NoError(t, err)
- result, err := adaptor.ParseTaskResult(body)
+ result, err := adaptor.ParseTaskResult(&model.Task{}, &http.Response{StatusCode: http.StatusOK, Header: make(http.Header)}, body)
require.NoError(t, err)
assert.Equal(t, 3.5, result.UsageFacts["units"])
})
@@ -925,7 +928,7 @@ export function extractUsageOnComplete() { return {units: 3.5}; }
adaptor, _, _ := newRequest(t, map[string]any{})
body, err := common.Marshal(map[string]any{"completionUsage": map[string]any{"upstreamUnits": 5000}})
require.NoError(t, err)
- result, err := adaptor.ParseTaskResult(body)
+ result, err := adaptor.ParseTaskResult(&model.Task{}, &http.Response{StatusCode: http.StatusOK, Header: make(http.Header)}, body)
require.NoError(t, err)
assert.Equal(t, 5000, result.TotalTokens)
assert.EqualValues(t, 5000, result.UsageFacts["upstreamUnits"])
@@ -988,7 +991,7 @@ export function parseTaskResult(ctx, body) { return {status: "SUCCESS", completi
require.NoError(t, err)
adaptor := New(plugin)
- result, err := adaptor.ParseTaskResult([]byte(`{"completion":13,"total":17}`))
+ result, err := adaptor.ParseTaskResult(&model.Task{}, &http.Response{StatusCode: http.StatusOK, Header: make(http.Header)}, []byte(`{"completion":13,"total":17}`))
require.NoError(t, err)
assert.Equal(t, 13, result.CompletionTokens)
assert.Equal(t, 17, result.TotalTokens)
@@ -1093,7 +1096,7 @@ export function buildSubmitRequest(ctx) { return { url: ctx.baseUrl + "/submit",
export function parseSubmitResponse(ctx, resp) { return { taskId: resp.body.id }; }
export function buildQueryRequest(ctx) { return { url: ctx.baseUrl + "/tasks/" + ctx.taskId }; }
export function parseTaskResult(ctx, body) { return { taskId: body.id, status: "SUCCESS" }; }
-export function buildBatchQueryRequest(ctx, taskIds) { return { url: ctx.baseUrl + "/batch", method: "POST", headers: { "X-Plugin": "batch" }, body: { ids: taskIds } }; }
+export function buildBatchQueryRequest(ctx, tasks) { return { url: ctx.baseUrl + "/batch", method: "POST", headers: { "X-Plugin": "batch" }, body: { ids: (tasks || []).map(function (task) { return task.taskId; }) } }; }
export function parseBatchResult(ctx, body) {
return body.items.map(function (item) {
return { taskId: item.id, action: item.action, status: item.status, progress: item.progress, url: (item.urls || [])[0] || "", finishTime: item.finish || 0, data: item };
@@ -1128,13 +1131,17 @@ func TestTaskAdaptorBatchBridge(t *testing.T) {
adaptor := New(plugin)
require.Equal(t, "batch", adaptor.FetchMode())
- resp, err := adaptor.FetchBatchTasks(server.URL, "secret", []string{"task-a", "task-b"}, "")
+ tasks := []*model.Task{
+ {PrivateData: model.TaskPrivateData{UpstreamTaskID: "task-a"}},
+ {PrivateData: model.TaskPrivateData{UpstreamTaskID: "task-b"}},
+ }
+ resp, err := adaptor.FetchBatchTasks(server.URL, "secret", tasks, "")
require.NoError(t, err)
defer resp.Body.Close()
payload, err := io.ReadAll(resp.Body)
require.NoError(t, err)
- results, err := adaptor.ParseBatchResult(payload)
+ results, err := adaptor.ParseBatchResult(tasks, resp, payload)
require.NoError(t, err)
require.Len(t, results, 2, "entry without taskId must be skipped")
@@ -1233,26 +1240,176 @@ export function parseTaskResult(){return {status:"SUCCESS"}}
testCases := []struct {
name string
- body map[string]any
+ task *model.Task
want string
}{
{
name: "mapped model",
- body: map[string]any{"task_id": "t1", "model": "alias", "upstream_model": "declared-model"},
+ task: &model.Task{
+ Properties: model.Properties{OriginModelName: "alias", UpstreamModelName: "declared-model"},
+ PrivateData: model.TaskPrivateData{UpstreamTaskID: "t1"},
+ },
want: "/tasks/alias/declared-model/t1",
},
{
name: "unmapped model falls back to the origin name",
- body: map[string]any{"task_id": "t1", "model": "alias"},
+ task: &model.Task{
+ Properties: model.Properties{OriginModelName: "alias"},
+ PrivateData: model.TaskPrivateData{UpstreamTaskID: "t1"},
+ },
want: "/tasks/alias/alias/t1",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
- resp, fetchErr := adaptor.FetchTask(server.URL, "secret", testCase.body, "")
+ resp, fetchErr := adaptor.FetchTask(server.URL, "secret", testCase.task, "")
require.NoError(t, fetchErr)
require.NoError(t, resp.Body.Close())
assert.Equal(t, testCase.want, requested)
})
}
}
+
+func TestTaskAdaptorQueryContextOmitsRequestBody(t *testing.T) {
+ service.InitHttpClient()
+ var captured map[string]any
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.NoError(t, common.DecodeJson(r.Body, &captured))
+ _, _ = w.Write([]byte(`{"ok":true}`))
+ }))
+ defer server.Close()
+
+ source := `
+export const meta = {apiVersion:1,key:"query-ctx",name:"Query Ctx",version:"1.0.0",author:{name:"Test"},models:["alias"],fetchMode:"per_task"};
+export function buildSubmitRequest(ctx){return {url:ctx.baseUrl+"/submit"}}
+export function parseSubmitResponse(){return {taskId:"1",state:{req_key:"from-submit"}}}
+export function buildQueryRequest(ctx){
+ return {url:ctx.baseUrl+"/query",method:"POST",body:{
+ keys: Object.keys(ctx).sort(),
+ taskId: ctx.taskId,
+ publicTaskId: ctx.publicTaskId,
+ action: ctx.action,
+ model: ctx.model,
+ upstreamModel: ctx.upstreamModel,
+ data: ctx.data,
+ state: ctx.state,
+ hasRequestBody: Object.prototype.hasOwnProperty.call(ctx, "requestBody")
+ }};
+}
+export function parseTaskResult(ctx, body, response){
+ return {status:"IN_PROGRESS",reason:String(response && response.status),url:ctx.taskId,state:{round:"poll"}};
+}
+`
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+ adaptor.Init(&relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: server.URL, ApiKey: "secret"}})
+
+ task := &model.Task{
+ TaskID: "task_public",
+ Action: constant.TaskActionImageToVideo,
+ Properties: model.Properties{
+ OriginModelName: "alias",
+ UpstreamModelName: "declared",
+ },
+ Data: []byte(`{"snapshot":true}`),
+ PrivateData: model.TaskPrivateData{
+ UpstreamTaskID: "upstream-1",
+ PluginState: []byte(`{"req_key":"kept"}`),
+ },
+ }
+ resp, err := adaptor.FetchTask(server.URL, "secret", task, "")
+ require.NoError(t, err)
+ require.NoError(t, resp.Body.Close())
+
+ assert.Equal(t, "upstream-1", captured["taskId"])
+ assert.Equal(t, "task_public", captured["publicTaskId"])
+ assert.Equal(t, constant.NormalizeTaskAction(constant.TaskActionImageToVideo), captured["action"])
+ assert.Equal(t, "alias", captured["model"])
+ assert.Equal(t, "declared", captured["upstreamModel"])
+ assert.Equal(t, map[string]any{"snapshot": true}, captured["data"])
+ assert.Equal(t, map[string]any{"req_key": "kept"}, captured["state"])
+ assert.Equal(t, false, captured["hasRequestBody"])
+ keys, ok := captured["keys"].([]any)
+ require.True(t, ok)
+ assert.NotContains(t, keys, "requestBody")
+
+ result, err := adaptor.ParseTaskResult(task, &http.Response{StatusCode: http.StatusTeapot, Header: make(http.Header)}, []byte(`{"ok":true}`))
+ require.NoError(t, err)
+ assert.Equal(t, "IN_PROGRESS", result.Status)
+ assert.Equal(t, "418", result.Reason)
+ assert.Equal(t, "upstream-1", result.Url)
+ assert.JSONEq(t, `{"round":"poll"}`, string(result.PluginState))
+}
+
+func TestTaskAdaptorParseSubmitResponsePersistsState(t *testing.T) {
+ service.InitHttpClient()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`{"id":"upstream-1"}`))
+ }))
+ defer server.Close()
+
+ source := `
+export const meta = {apiVersion:1,key:"submit-state",name:"Submit State",version:"1.0.0",author:{name:"Test"},models:["m"],fetchMode:"per_task"};
+export function buildSubmitRequest(ctx){return {url:ctx.baseUrl+"/submit",method:"POST",body:{}}}
+export function parseSubmitResponse(){return {taskId:"upstream-1",state:{req_key:"from-submit"}}}
+export function buildQueryRequest(ctx){return {url:ctx.baseUrl+"/query"}}
+export function parseTaskResult(){return {status:"SUCCESS"}}
+`
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+ info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: server.URL, ApiKey: "secret"}, TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
+ adaptor.Init(info)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
+ c.Set("task_request", relaycommon.TaskSubmitReq{Prompt: "hello"})
+ require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
+ body, err := adaptor.BuildRequestBody(c, info)
+ require.NoError(t, err)
+ resp, err := adaptor.DoRequest(c, info, body)
+ require.NoError(t, err)
+ parsed, taskErr := adaptor.ParseResponse(c, resp, info)
+ require.Nil(t, taskErr)
+ require.NotNil(t, parsed)
+ assert.JSONEq(t, `{"req_key":"from-submit"}`, string(parsed.PluginState))
+}
+
+func TestTaskAdaptorBatchQueryReceivesTaskObjects(t *testing.T) {
+ service.InitHttpClient()
+ var captured map[string]any
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.NoError(t, common.DecodeJson(r.Body, &captured))
+ _, _ = w.Write([]byte(`{"items":[]}`))
+ }))
+ defer server.Close()
+
+ source := `
+export const meta = {apiVersion:1,key:"batch-ctx",name:"Batch Ctx",version:"1.0.0",author:{name:"Test"},models:["m"],fetchMode:"batch"};
+export function buildSubmitRequest(ctx){return {url:ctx.baseUrl+"/submit"}}
+export function parseSubmitResponse(){return {taskId:"1"}}
+export function buildQueryRequest(ctx){return {url:ctx.baseUrl+"/q"}}
+export function parseTaskResult(){return {status:"SUCCESS"}}
+export function buildBatchQueryRequest(ctx, tasks){
+ return {url:ctx.baseUrl+"/batch",method:"POST",body:{
+ ids: (tasks||[]).map(function(task){return task.taskId;}),
+ models: (tasks||[]).map(function(task){return task.model;}),
+ hasRequestBody: (tasks||[]).some(function(task){return Object.prototype.hasOwnProperty.call(task,"requestBody");})
+ }};
+}
+export function parseBatchResult(){return [];}
+`
+ plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
+ require.NoError(t, err)
+ adaptor := New(plugin)
+ tasks := []*model.Task{
+ {Properties: model.Properties{OriginModelName: "model-a"}, PrivateData: model.TaskPrivateData{UpstreamTaskID: "task-a"}},
+ {Properties: model.Properties{OriginModelName: "model-b"}, PrivateData: model.TaskPrivateData{UpstreamTaskID: "task-b"}},
+ }
+ resp, err := adaptor.FetchBatchTasks(server.URL, "secret", tasks, "")
+ require.NoError(t, err)
+ require.NoError(t, resp.Body.Close())
+ assert.Equal(t, []any{"task-a", "task-b"}, captured["ids"])
+ assert.Equal(t, []any{"model-a", "model-b"}, captured["models"])
+ assert.Equal(t, false, captured["hasRequestBody"])
+}
diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go
index ca0150b96920..cef4d02adc13 100644
--- a/relay/common/relay_info.go
+++ b/relay/common/relay_info.go
@@ -119,19 +119,19 @@ type RelayInfo struct {
ReasoningEffort string
// ReasoningConversion is the suffix-derived reasoning intent attached
// after model mapping. Converters read it via ReasoningState().
- ReasoningConversion *dto.ReasoningConversionState
- UserSetting dto.UserSetting
- UserEmail string
- UserQuota int
- RelayFormat types.RelayFormat
- SendResponseCount int
+ ReasoningConversion *dto.ReasoningConversionState
+ UserSetting dto.UserSetting
+ UserEmail string
+ UserQuota int
+ RelayFormat types.RelayFormat
+ SendResponseCount int
// ClaudeToChatStreamState / ChatToGeminiStreamState hold per-attempt
// stream converters. InitChannelMeta nils them so a retry cannot resume a
// dirty converter (advanced tool index / finalized).
ClaudeToChatStreamState any
ChatToGeminiStreamState any
ReceivedResponseCount int
- FinalPreConsumedQuota int // 最终预消耗的配额
+ FinalPreConsumedQuota int // 最终预消耗的配额
// ForcePreConsume 为 true 时禁用 BillingSession 的信任额度旁路,
// 强制预扣全额。用于异步任务(视频/音乐生成等),因为请求返回后任务仍在运行,
// 必须在提交前锁定全额。
@@ -1018,16 +1018,17 @@ func (t *TaskSubmitReq) UnmarshalMetadata(v any) error {
}
type TaskInfo struct {
- Code int `json:"code"`
- TaskID string `json:"task_id"`
- Status string `json:"status"`
- Reason string `json:"reason,omitempty"`
- Url string `json:"url,omitempty"`
- RemoteUrl string `json:"remote_url,omitempty"`
- Progress string `json:"progress,omitempty"`
- CompletionTokens int `json:"completion_tokens,omitempty"` // 用于按倍率计费
- TotalTokens int `json:"total_tokens,omitempty"` // 用于按倍率计费
- UsageFacts map[string]any `json:"usage_facts,omitempty"`
+ Code int `json:"code"`
+ TaskID string `json:"task_id"`
+ Status string `json:"status"`
+ Reason string `json:"reason,omitempty"`
+ Url string `json:"url,omitempty"`
+ RemoteUrl string `json:"remote_url,omitempty"`
+ Progress string `json:"progress,omitempty"`
+ CompletionTokens int `json:"completion_tokens,omitempty"` // 用于按倍率计费
+ TotalTokens int `json:"total_tokens,omitempty"` // 用于按倍率计费
+ UsageFacts map[string]any `json:"usage_facts,omitempty"`
+ PluginState json.RawMessage `json:"plugin_state,omitempty"`
}
func FailTaskInfo(reason string) *TaskInfo {
diff --git a/relay/relay_task.go b/relay/relay_task.go
index daa1a9ea8750..42c0e21d81cd 100644
--- a/relay/relay_task.go
+++ b/relay/relay_task.go
@@ -32,6 +32,7 @@ type TaskSubmitResult struct {
Platform constant.TaskPlatform
Quota int
Immediate *relaycommon.TaskInfo
+ PluginState []byte
//PerCallPrice types.PriceData
}
@@ -381,6 +382,7 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe
Platform: platform,
Quota: finalQuota,
Immediate: parsed.Immediate,
+ PluginState: parsed.PluginState,
}, nil
}
@@ -517,12 +519,7 @@ func tryRealtimeFetch(task *model.Task, isOpenAIVideoAPI bool) []byte {
return nil
}
- resp, err := adaptor.FetchTask(baseURL, channelModel.Key, map[string]any{
- "task_id": task.GetUpstreamTaskID(),
- "action": constant.NormalizeTaskAction(task.Action),
- "model": task.Properties.OriginModelName,
- "upstream_model": task.Properties.UpstreamModelName,
- }, proxy)
+ resp, err := adaptor.FetchTask(baseURL, channelModel.Key, task, proxy)
if err != nil || resp == nil {
return nil
}
@@ -532,7 +529,7 @@ func tryRealtimeFetch(task *model.Task, isOpenAIVideoAPI bool) []byte {
return nil
}
- ti, err := adaptor.ParseTaskResult(body)
+ ti, err := adaptor.ParseTaskResult(task, resp, body)
if err != nil || ti == nil {
return nil
}
diff --git a/service/task_billing_test.go b/service/task_billing_test.go
index 238f359fb387..fba0e8473484 100644
--- a/service/task_billing_test.go
+++ b/service/task_billing_test.go
@@ -1343,10 +1343,12 @@ type mockAdaptor struct {
}
func (m *mockAdaptor) Init(_ *relaycommon.RelayInfo) {}
-func (m *mockAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) {
+func (m *mockAdaptor) FetchTask(string, string, *model.Task, string) (*http.Response, error) {
+ return nil, nil
+}
+func (m *mockAdaptor) ParseTaskResult(*model.Task, *http.Response, []byte) (*relaycommon.TaskInfo, error) {
return nil, nil
}
-func (m *mockAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) { return nil, nil }
func (m *mockAdaptor) AdjustBillingOnComplete(_ *model.Task, _ *relaycommon.TaskInfo) int {
return m.adjustReturn
}
diff --git a/service/task_plugin_view_test.go b/service/task_plugin_view_test.go
index 228f474fb82d..c1823baceff5 100644
--- a/service/task_plugin_view_test.go
+++ b/service/task_plugin_view_test.go
@@ -62,3 +62,24 @@ func TestBuildTaskPluginViewRewritesOnlyStructuredTaskIDFields(t *testing.T) {
assert.Equal(t, privateTaskID, nested[1])
}
+
+func TestBuildTaskPluginViewOmitsPrivatePollState(t *testing.T) {
+ task := &model.Task{
+ TaskID: "task_public_view",
+ Data: []byte(`{"ok":true}`),
+ PrivateData: model.TaskPrivateData{
+ PluginState: []byte(`{"req_key":"secret"}`),
+ PollFailures: 7,
+ },
+ }
+
+ view, err := BuildTaskPluginView(task)
+ require.NoError(t, err)
+ encoded, err := common.Marshal(view)
+ require.NoError(t, err)
+ var payload map[string]any
+ require.NoError(t, common.Unmarshal(encoded, &payload))
+ assert.NotContains(t, payload, "plugin_state")
+ assert.NotContains(t, payload, "poll_failures")
+ assert.NotContains(t, payload, "private_data")
+}
diff --git a/service/task_polling.go b/service/task_polling.go
index c6133ac5fa6a..42b220caf080 100644
--- a/service/task_polling.go
+++ b/service/task_polling.go
@@ -18,7 +18,6 @@ import (
"github.com/QuantumNous/new-api/pkg/billingexpr"
"github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
relaycommon "github.com/QuantumNous/new-api/relay/common"
- "github.com/QuantumNous/new-api/relaykit/dto"
"github.com/bytedance/gopkg/util/gopool"
"github.com/samber/lo"
@@ -27,8 +26,8 @@ import (
// TaskPollingAdaptor 定义轮询所需的最小适配器接口,避免 service -> relay 的循环依赖
type TaskPollingAdaptor interface {
Init(info *relaycommon.RelayInfo)
- FetchTask(baseURL string, key string, body map[string]any, proxy string) (*http.Response, error)
- ParseTaskResult(body []byte) (*relaycommon.TaskInfo, error)
+ FetchTask(baseURL string, key string, task *model.Task, proxy string) (*http.Response, error)
+ ParseTaskResult(task *model.Task, resp *http.Response, body []byte) (*relaycommon.TaskInfo, error)
// AdjustBillingOnComplete 在任务到达终态(成功/失败)时由轮询循环调用。
// 返回正数触发差额结算(补扣/退还),返回 0 保持预扣费金额不变。
AdjustBillingOnComplete(task *model.Task, taskResult *relaycommon.TaskInfo) int
@@ -37,10 +36,21 @@ type TaskPollingAdaptor interface {
type BatchTaskPollingAdaptor interface {
TaskPollingAdaptor
FetchMode() string
- FetchBatchTasks(baseURL, key string, taskIDs []string, proxy string) (*http.Response, error)
- ParseBatchResult(body []byte) (map[string]*BatchTaskResult, error)
+ FetchBatchTasks(baseURL, key string, tasks []*model.Task, proxy string) (*http.Response, error)
+ ParseBatchResult(tasks []*model.Task, resp *http.Response, body []byte) (map[string]*BatchTaskResult, error)
}
+const (
+ pollClassOK = "ok"
+ pollClassOtherClient = "other_client"
+ pollClassNotFound = "not_found"
+ pollClassAuth = "auth"
+ pollClassTransient = "transient"
+ pollClassUnrecognized = "unrecognized"
+ pollClassHookError = "hook_error"
+ pollClassTransport = "transport_error"
+)
+
type BatchTaskResult struct {
TaskInfo relaycommon.TaskInfo
Action string
@@ -258,24 +268,39 @@ func updateBatchTasks(ctx context.Context, adaptor BatchTaskPollingAdaptor, chan
if baseURL == "" {
baseURL = constant.GetChannelBaseURL(ch.Type)
}
- resp, err := adaptor.FetchBatchTasks(baseURL, ch.Key, taskIds, proxy)
+ tasks := make([]*model.Task, 0, len(taskIds))
+ for _, upstreamID := range taskIds {
+ if task := taskM[upstreamID]; task != nil {
+ tasks = append(tasks, task)
+ }
+ }
+ info := &relaycommon.RelayInfo{}
+ info.ChannelMeta = &relaycommon.ChannelMeta{ChannelBaseUrl: baseURL}
+ info.ApiKey = ch.Key
+ adaptor.Init(info)
+ resp, err := adaptor.FetchBatchTasks(baseURL, ch.Key, tasks, proxy)
if err != nil {
common.SysLog(fmt.Sprintf("Get Task Do req error: %v", err))
- return err
- }
- if resp.StatusCode != http.StatusOK {
- logger.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
- return fmt.Errorf("Get Task status code: %d", resp.StatusCode)
+ return recordPollFailureForTasks(ctx, adaptor, tasks, pollClassTransport, 0, err.Error())
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
common.SysLog(fmt.Sprintf("Get Suno Task parse body error: %v", err))
- return err
- }
- responseItems, err := adaptor.ParseBatchResult(responseBody)
+ return recordPollFailureForTasks(ctx, adaptor, tasks, pollClassTransport, resp.StatusCode, err.Error())
+ }
+ switch classifyPollHTTP(resp.StatusCode) {
+ case pollClassNotFound:
+ return failTasksFromPoll(ctx, adaptor, tasks, fmt.Sprintf("upstream task not found (HTTP %d)", resp.StatusCode))
+ case pollClassAuth:
+ logger.LogWarn(ctx, fmt.Sprintf("task poll auth failure channel_id=%d http=%d", channelId, resp.StatusCode))
+ return recordPollFailureForTasks(ctx, adaptor, tasks, pollClassAuth, resp.StatusCode, "")
+ case pollClassTransient:
+ return recordPollFailureForTasks(ctx, adaptor, tasks, pollClassTransient, resp.StatusCode, "")
+ }
+ responseItems, err := adaptor.ParseBatchResult(tasks, resp, responseBody)
if err != nil {
- return fmt.Errorf("parse batch result: %w", err)
+ return recordPollFailureForTasks(ctx, adaptor, tasks, pollClassHookError, resp.StatusCode, err.Error())
}
for upstreamID, responseItem := range responseItems {
if ctx.Err() != nil {
@@ -287,7 +312,27 @@ func updateBatchTasks(ctx context.Context, adaptor BatchTaskPollingAdaptor, chan
continue
}
snap := task.Snapshot()
- task.Status = lo.If(model.TaskStatus(responseItem.TaskInfo.Status) != "", model.TaskStatus(responseItem.TaskInfo.Status)).Else(task.Status)
+ httpClass := classifyPollHTTP(resp.StatusCode)
+ parsedStatus := model.TaskStatus(responseItem.TaskInfo.Status)
+ if parsedStatus == model.TaskStatusUnknown || parsedStatus == "" || !knownPollStatus(parsedStatus) {
+ if err := recordPollFailure(ctx, adaptor, task, snap.Status, pollClassUnrecognized, resp.StatusCode, responseItem.TaskInfo.Reason); err != nil {
+ common.SysLog("UpdateSunoTask task error: " + err.Error())
+ }
+ continue
+ }
+ if httpClass == pollClassOtherClient && isNonTerminalPollStatus(parsedStatus) {
+ if err := recordPollFailure(ctx, adaptor, task, snap.Status, pollClassUnrecognized, resp.StatusCode, responseItem.TaskInfo.Reason); err != nil {
+ common.SysLog("UpdateSunoTask task error: " + err.Error())
+ }
+ continue
+ }
+ if isNonTerminalPollStatus(parsedStatus) {
+ task.PrivateData.PollFailures = 0
+ }
+ if len(responseItem.TaskInfo.PluginState) > 0 {
+ task.PrivateData.PluginState = responseItem.TaskInfo.PluginState
+ }
+ task.Status = lo.If(parsedStatus != "", parsedStatus).Else(task.Status)
task.FailReason = lo.If(responseItem.TaskInfo.Reason != "", responseItem.TaskInfo.Reason).Else(task.FailReason)
task.SubmitTime = lo.If(responseItem.SubmitTime != 0, responseItem.SubmitTime).Else(task.SubmitTime)
task.StartTime = lo.If(responseItem.StartTime != 0, responseItem.StartTime).Else(task.StartTime)
@@ -447,24 +492,28 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
if privateData.Key != "" {
key = privateData.Key
}
- resp, err := adaptor.FetchTask(baseURL, key, map[string]any{
- "task_id": task.GetUpstreamTaskID(),
- "action": constant.NormalizeTaskAction(task.Action),
- "model": task.Properties.OriginModelName,
- "upstream_model": task.Properties.UpstreamModelName,
- }, proxy)
+ snap := task.Snapshot()
+ resp, err := adaptor.FetchTask(baseURL, key, task, proxy)
if err != nil {
- return fmt.Errorf("fetchTask failed for task %s: %w", taskId, err)
+ return recordPollFailure(ctx, adaptor, task, snap.Status, pollClassTransport, 0, err.Error())
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
- return fmt.Errorf("readAll failed for task %s: %w", taskId, err)
+ return recordPollFailure(ctx, adaptor, task, snap.Status, pollClassTransport, resp.StatusCode, err.Error())
}
logger.LogDebug(ctx, "updateVideoSingleTask response: %s", responseBody)
- snap := task.Snapshot()
+ switch classifyPollHTTP(resp.StatusCode) {
+ case pollClassNotFound:
+ return failTaskFromPoll(ctx, adaptor, task, snap.Status, fmt.Sprintf("upstream task not found (HTTP %d)", resp.StatusCode))
+ case pollClassAuth:
+ logger.LogWarn(ctx, fmt.Sprintf("task poll auth failure channel_id=%d task=%s http=%d", ch.Id, task.TaskID, resp.StatusCode))
+ return recordPollFailure(ctx, adaptor, task, snap.Status, pollClassAuth, resp.StatusCode, "")
+ case pollClassTransient:
+ return recordPollFailure(ctx, adaptor, task, snap.Status, pollClassTransient, resp.StatusCode, "")
+ }
taskResult := &relaycommon.TaskInfo{}
// try parse as New API response format
@@ -478,41 +527,33 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
taskResult.Progress = t.Progress
taskResult.Reason = t.FailReason
task.Data = t.Data
- } else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil {
- return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err)
+ } else if taskResult, err = adaptor.ParseTaskResult(task, resp, responseBody); err != nil {
+ return recordPollFailure(ctx, adaptor, task, snap.Status, pollClassHookError, resp.StatusCode, err.Error())
}
- task.Data = redactVideoResponseBody(responseBody)
-
logger.LogDebug(ctx, "updateVideoSingleTask taskResult: %+v", taskResult)
- now := time.Now().Unix()
- if taskResult.Status == "" {
- //taskResult = relaycommon.FailTaskInfo("upstream returned empty status")
- errorResult := &dto.GeneralErrorResponse{}
- if err = common.Unmarshal(responseBody, &errorResult); err == nil {
- openaiError := errorResult.TryToOpenAIError()
- if openaiError != nil {
- // 返回规范的 OpenAI 错误格式,提取错误信息,判断错误是否为任务失败
- if openaiError.Code == "429" {
- // 429 错误通常表示请求过多或速率限制,暂时不认为是任务失败,保持原状态等待下一轮轮询
- return nil
- }
+ parsedStatus := model.TaskStatus(taskResult.Status)
+ if parsedStatus == model.TaskStatusUnknown || parsedStatus == "" || !knownPollStatus(parsedStatus) {
+ return recordPollFailure(ctx, adaptor, task, snap.Status, pollClassUnrecognized, resp.StatusCode, unrecognizedPollDetail(taskResult.Reason, responseBody))
+ }
+ if classifyPollHTTP(resp.StatusCode) == pollClassOtherClient && isNonTerminalPollStatus(parsedStatus) {
+ return recordPollFailure(ctx, adaptor, task, snap.Status, pollClassUnrecognized, resp.StatusCode, unrecognizedPollDetail(taskResult.Reason, responseBody))
+ }
- // 其他错误认为是任务失败,记录错误信息并更新任务状态
- taskResult = relaycommon.FailTaskInfo("upstream returned error")
- } else {
- // unknown error format, log original response
- logger.LogError(ctx, fmt.Sprintf("Task %s returned empty status with unrecognized error format, response: %s", taskId, string(responseBody)))
- taskResult = relaycommon.FailTaskInfo("upstream returned unrecognized message")
- }
- }
+ task.Data = redactVideoResponseBody(responseBody)
+ if len(taskResult.PluginState) > 0 {
+ task.PrivateData.PluginState = taskResult.PluginState
+ }
+ if isNonTerminalPollStatus(parsedStatus) {
+ task.PrivateData.PollFailures = 0
}
+ now := time.Now().Unix()
shouldFinalizeBilling := false
- task.Status = model.TaskStatus(taskResult.Status)
- switch taskResult.Status {
+ task.Status = parsedStatus
+ switch parsedStatus {
case model.TaskStatusSubmitted:
task.Progress = taskcommon.ProgressSubmitted
case model.TaskStatusQueued:
@@ -549,8 +590,6 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
logger.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason))
taskResult.Progress = taskcommon.ProgressComplete
shouldFinalizeBilling = true
- default:
- return fmt.Errorf("unknown task status %s for task %s", taskResult.Status, task.TaskID)
}
if taskResult.Progress != "" {
task.Progress = taskResult.Progress
@@ -670,3 +709,131 @@ func settleTaskBillingOnComplete(ctx context.Context, adaptor TaskPollingAdaptor
}
return false
}
+
+func classifyPollHTTP(statusCode int) string {
+ switch {
+ case statusCode >= 200 && statusCode < 300:
+ return pollClassOK
+ case statusCode == http.StatusNotFound || statusCode == http.StatusGone:
+ return pollClassNotFound
+ case statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden:
+ return pollClassAuth
+ case statusCode == http.StatusTooManyRequests || statusCode >= 500:
+ return pollClassTransient
+ case statusCode >= 400 && statusCode < 500:
+ return pollClassOtherClient
+ default:
+ return pollClassTransient
+ }
+}
+
+func knownPollStatus(status model.TaskStatus) bool {
+ switch status {
+ case model.TaskStatusNotStart, model.TaskStatusSubmitted, model.TaskStatusQueued, model.TaskStatusInProgress, model.TaskStatusSuccess, model.TaskStatusFailure:
+ return true
+ default:
+ return false
+ }
+}
+
+func isNonTerminalPollStatus(status model.TaskStatus) bool {
+ switch status {
+ case model.TaskStatusNotStart, model.TaskStatusSubmitted, model.TaskStatusQueued, model.TaskStatusInProgress:
+ return true
+ default:
+ return false
+ }
+}
+
+func pollFailureReason(class string, statusCode int, detail string) string {
+ reason := fmt.Sprintf("poll failed: %s", class)
+ if statusCode > 0 {
+ reason = fmt.Sprintf("poll failed: %s (HTTP %d)", class, statusCode)
+ }
+ if detail != "" {
+ reason = reason + ": " + detail
+ }
+ return reason
+}
+
+// unrecognizedPollDetail pairs the plugin's reason with a bounded copy of the
+// upstream body so the WARN line is enough to diagnose a parser gap.
+func unrecognizedPollDetail(reason string, body []byte) string {
+ const maxBodyChars = 512
+ redacted := string(redactVideoResponseBody(body))
+ if len(redacted) > maxBodyChars {
+ redacted = redacted[:maxBodyChars] + "…"
+ }
+ if strings.TrimSpace(reason) == "" {
+ return "body=" + redacted
+ }
+ return reason + "; body=" + redacted
+}
+
+func recordPollFailure(ctx context.Context, adaptor TaskPollingAdaptor, task *model.Task, fromStatus model.TaskStatus, class string, statusCode int, detail string) error {
+ task.PrivateData.PollFailures++
+ if class == pollClassUnrecognized || class == pollClassHookError {
+ // The redacted body is intentionally not persisted to Task.Data on these
+ // paths, so the WARN line is the only operator-visible copy of what the
+ // plugin could not interpret.
+ logger.LogWarn(ctx, fmt.Sprintf("task %s poll %s (failures=%d, http=%d): %s", task.TaskID, class, task.PrivateData.PollFailures, statusCode, detail))
+ }
+ // TASK_POLL_MAX_FAILURES <= 0 disables the consecutive-failure cutoff, matching
+ // TASK_TIMEOUT_MINUTES semantics; the 24h sweep remains the only backstop.
+ if constant.TaskPollMaxFailures > 0 && task.PrivateData.PollFailures >= constant.TaskPollMaxFailures {
+ return failTaskFromPoll(ctx, adaptor, task, fromStatus, pollFailureReason(class, statusCode, detail))
+ }
+ if _, err := task.UpdateWithStatus(fromStatus); err != nil {
+ return err
+ }
+ return nil
+}
+
+func recordPollFailureForTasks(ctx context.Context, adaptor TaskPollingAdaptor, tasks []*model.Task, class string, statusCode int, detail string) error {
+ var firstErr error
+ for _, task := range tasks {
+ if task == nil {
+ continue
+ }
+ if err := recordPollFailure(ctx, adaptor, task, task.Status, class, statusCode, detail); err != nil && firstErr == nil {
+ firstErr = err
+ }
+ }
+ return firstErr
+}
+
+func failTaskFromPoll(ctx context.Context, adaptor TaskPollingAdaptor, task *model.Task, fromStatus model.TaskStatus, reason string) error {
+ now := time.Now().Unix()
+ task.Status = model.TaskStatusFailure
+ task.Progress = taskcommon.ProgressComplete
+ if task.FinishTime == 0 {
+ task.FinishTime = now
+ }
+ task.FailReason = reason
+ won, err := task.UpdateWithStatus(fromStatus)
+ if err != nil {
+ return err
+ }
+ if !won {
+ return nil
+ }
+ taskResult := relaycommon.FailTaskInfo(reason)
+ billingSettled := settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
+ if !billingSettled && task.Quota != 0 {
+ RefundTaskQuota(ctx, task, reason)
+ }
+ return nil
+}
+
+func failTasksFromPoll(ctx context.Context, adaptor TaskPollingAdaptor, tasks []*model.Task, reason string) error {
+ var firstErr error
+ for _, task := range tasks {
+ if task == nil {
+ continue
+ }
+ if err := failTaskFromPoll(ctx, adaptor, task, task.Status, reason); err != nil && firstErr == nil {
+ firstErr = err
+ }
+ }
+ return firstErr
+}
diff --git a/service/task_polling_test.go b/service/task_polling_test.go
index 105ba8af8406..b06b699a92c1 100644
--- a/service/task_polling_test.go
+++ b/service/task_polling_test.go
@@ -40,12 +40,15 @@ type batchPollingAdaptor struct {
}
func (a *batchPollingAdaptor) FetchMode() string { return "batch" }
-func (a *batchPollingAdaptor) FetchBatchTasks(_ string, _ string, taskIDs []string, _ string) (*http.Response, error) {
+func (a *batchPollingAdaptor) FetchBatchTasks(_ string, _ string, tasks []*model.Task, _ string) (*http.Response, error) {
a.batchCalls++
- a.batchIDs = append([]string(nil), taskIDs...)
+ a.batchIDs = a.batchIDs[:0]
+ for _, task := range tasks {
+ a.batchIDs = append(a.batchIDs, task.GetUpstreamTaskID())
+ }
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte(`{}`)))}, nil
}
-func (a *batchPollingAdaptor) ParseBatchResult([]byte) (map[string]*BatchTaskResult, error) {
+func (a *batchPollingAdaptor) ParseBatchResult(_ []*model.Task, _ *http.Response, _ []byte) (map[string]*BatchTaskResult, error) {
if a.results != nil {
return a.results, nil
}
@@ -58,8 +61,11 @@ func (a *batchPollingAdaptor) ParseBatchResult([]byte) (map[string]*BatchTaskRes
func (a *taskPollingFetchAdaptor) Init(_ *relaycommon.RelayInfo) {}
-func (a *taskPollingFetchAdaptor) FetchTask(_ string, _ string, body map[string]any, _ string) (*http.Response, error) {
- taskID, _ := body["task_id"].(string)
+func (a *taskPollingFetchAdaptor) FetchTask(_ string, _ string, task *model.Task, _ string) (*http.Response, error) {
+ taskID := ""
+ if task != nil {
+ taskID = task.GetUpstreamTaskID()
+ }
if taskID == a.blockTaskID && a.releaseBlock != nil {
a.blockOnce.Do(func() {
if a.blockStarted != nil {
@@ -97,7 +103,7 @@ func (a *taskPollingFetchAdaptor) FetchTask(_ string, _ string, body map[string]
}, nil
}
-func (a *taskPollingFetchAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) {
+func (a *taskPollingFetchAdaptor) ParseTaskResult(*model.Task, *http.Response, []byte) (*relaycommon.TaskInfo, error) {
return &relaycommon.TaskInfo{Status: model.TaskStatusInProgress}, nil
}
@@ -721,3 +727,273 @@ func TestSweepTimedOutTasksHonorsRefundRolloutBoundary(t *testing.T) {
assert.Equal(t, initialQuota+modernTaskQuota, getUserQuota(t, userID))
assert.Equal(t, int64(1), countLogs(t))
}
+
+type scriptedPollingAdaptor struct {
+ statusCode int
+ body []byte
+ fetchErr error
+ parse *relaycommon.TaskInfo
+ parseErr error
+}
+
+func (a *scriptedPollingAdaptor) Init(*relaycommon.RelayInfo) {}
+func (a *scriptedPollingAdaptor) FetchTask(string, string, *model.Task, string) (*http.Response, error) {
+ if a.fetchErr != nil {
+ return nil, a.fetchErr
+ }
+ code := a.statusCode
+ if code == 0 {
+ code = http.StatusOK
+ }
+ body := a.body
+ if body == nil {
+ body = []byte(`{}`)
+ }
+ return &http.Response{StatusCode: code, Body: io.NopCloser(bytes.NewReader(body))}, nil
+}
+func (a *scriptedPollingAdaptor) ParseTaskResult(*model.Task, *http.Response, []byte) (*relaycommon.TaskInfo, error) {
+ if a.parseErr != nil {
+ return nil, a.parseErr
+ }
+ if a.parse != nil {
+ return a.parse, nil
+ }
+ return &relaycommon.TaskInfo{Status: model.TaskStatusInProgress}, nil
+}
+func (a *scriptedPollingAdaptor) AdjustBillingOnComplete(*model.Task, *relaycommon.TaskInfo) int {
+ return 0
+}
+
+type scriptedBatchPollingAdaptor struct {
+ scriptedPollingAdaptor
+ results map[string]*BatchTaskResult
+}
+
+func (a *scriptedBatchPollingAdaptor) FetchMode() string { return "batch" }
+func (a *scriptedBatchPollingAdaptor) FetchBatchTasks(string, string, []*model.Task, string) (*http.Response, error) {
+ return a.FetchTask("", "", nil, "")
+}
+func (a *scriptedBatchPollingAdaptor) ParseBatchResult([]*model.Task, *http.Response, []byte) (map[string]*BatchTaskResult, error) {
+ if a.parseErr != nil {
+ return nil, a.parseErr
+ }
+ return a.results, nil
+}
+
+func TestUpdateVideoSingleTaskPollClassification(t *testing.T) {
+ testCases := []struct {
+ name string
+ statusCode int
+ fetchErr error
+ parse *relaycommon.TaskInfo
+ parseErr error
+ priorFailures int
+ priorState string
+ maxFailures int
+ wantStatus model.TaskStatus
+ wantFailures int
+ wantRefund bool
+ wantReason string
+ wantState string
+ wantUnchanged bool
+ }{
+ {
+ name: "404 fails immediately and refunds",
+ statusCode: http.StatusNotFound,
+ wantStatus: model.TaskStatusFailure,
+ wantRefund: true,
+ wantReason: "upstream task not found (HTTP 404)",
+ wantUnchanged: false,
+ },
+ {
+ name: "401 increments without changing status",
+ statusCode: http.StatusUnauthorized,
+ wantStatus: model.TaskStatusInProgress,
+ wantFailures: 1,
+ wantUnchanged: true,
+ },
+ {
+ name: "429 reaches threshold and refunds",
+ statusCode: http.StatusTooManyRequests,
+ priorFailures: 2,
+ maxFailures: 3,
+ wantStatus: model.TaskStatusFailure,
+ wantFailures: 3,
+ wantRefund: true,
+ wantReason: "poll failed: transient (HTTP 429)",
+ },
+ {
+ name: "UNKNOWN increments",
+ statusCode: http.StatusOK,
+ parse: &relaycommon.TaskInfo{Status: model.TaskStatusUnknown, Reason: "weird"},
+ wantStatus: model.TaskStatusInProgress,
+ wantFailures: 1,
+ wantUnchanged: true,
+ },
+ {
+ name: "valid 2xx resets the failure counter",
+ statusCode: http.StatusOK,
+ parse: &relaycommon.TaskInfo{Status: model.TaskStatusInProgress},
+ priorFailures: 5,
+ wantStatus: model.TaskStatusInProgress,
+ wantFailures: 0,
+ },
+ {
+ name: "omit state preserves previous plugin state",
+ statusCode: http.StatusOK,
+ parse: &relaycommon.TaskInfo{Status: model.TaskStatusInProgress},
+ priorState: `{"req_key":"keep"}`,
+ wantStatus: model.TaskStatusInProgress,
+ wantState: `{"req_key":"keep"}`,
+ },
+ {
+ name: "returned state replaces plugin state",
+ statusCode: http.StatusOK,
+ parse: &relaycommon.TaskInfo{
+ Status: model.TaskStatusInProgress,
+ PluginState: []byte(`{"req_key":"new"}`),
+ },
+ priorState: `{"req_key":"old"}`,
+ wantStatus: model.TaskStatusInProgress,
+ wantState: `{"req_key":"new"}`,
+ },
+ {
+ name: "other 4xx non-terminal is unrecognized",
+ statusCode: http.StatusBadRequest,
+ parse: &relaycommon.TaskInfo{Status: model.TaskStatusInProgress},
+ wantStatus: model.TaskStatusInProgress,
+ wantFailures: 1,
+ wantUnchanged: true,
+ },
+ }
+
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ truncate(t)
+ const userID, tokenID, channelID = 510, 510, 510
+ const initialQuota, preConsumed, tokenRemain = 10_000, 4_000, 7_000
+ seedUser(t, userID, initialQuota)
+ seedToken(t, tokenID, userID, "sk-poll-class", tokenRemain)
+ ch := &model.Channel{Id: channelID, Type: constant.ChannelTypeKling, Name: "poll", Key: "sk-test", Status: common.ChannelStatusEnabled}
+
+ if testCase.maxFailures > 0 {
+ previous := constant.TaskPollMaxFailures
+ constant.TaskPollMaxFailures = testCase.maxFailures
+ t.Cleanup(func() { constant.TaskPollMaxFailures = previous })
+ }
+
+ task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
+ task.TaskID = "task_poll_class"
+ task.PrivateData.UpstreamTaskID = "upstream_poll_class"
+ task.PrivateData.PollFailures = testCase.priorFailures
+ if testCase.priorState != "" {
+ task.PrivateData.PluginState = []byte(testCase.priorState)
+ }
+ require.NoError(t, model.DB.Create(task).Error)
+
+ adaptor := &scriptedPollingAdaptor{statusCode: testCase.statusCode, fetchErr: testCase.fetchErr, parse: testCase.parse, parseErr: testCase.parseErr}
+ require.NoError(t, updateVideoSingleTask(context.Background(), adaptor, ch, task.GetUpstreamTaskID(), map[string]*model.Task{
+ task.GetUpstreamTaskID(): task,
+ }))
+
+ var persisted model.Task
+ require.NoError(t, model.DB.First(&persisted, task.ID).Error)
+ assert.EqualValues(t, testCase.wantStatus, persisted.Status)
+ assert.Equal(t, testCase.wantFailures, persisted.PrivateData.PollFailures)
+ if testCase.wantUnchanged {
+ assert.Empty(t, persisted.FailReason)
+ }
+ if testCase.wantReason != "" {
+ assert.Contains(t, persisted.FailReason, testCase.wantReason)
+ }
+ if testCase.wantState != "" {
+ assert.JSONEq(t, testCase.wantState, string(persisted.PrivateData.PluginState))
+ }
+ if testCase.wantRefund {
+ assert.Equal(t, initialQuota+preConsumed, getUserQuota(t, userID))
+ assert.Equal(t, tokenRemain+preConsumed, getTokenRemainQuota(t, tokenID))
+ assert.Zero(t, persisted.Quota)
+ log := getLastLog(t)
+ require.NotNil(t, log)
+ assert.Equal(t, model.LogTypeRefund, log.Type)
+ } else {
+ assert.Equal(t, initialQuota, getUserQuota(t, userID))
+ assert.Equal(t, tokenRemain, getTokenRemainQuota(t, tokenID))
+ }
+ })
+ }
+}
+
+func TestUpdateBatchTasksPollClassification(t *testing.T) {
+ testCases := []struct {
+ name string
+ statusCode int
+ resultStatus model.TaskStatus
+ wantStatus model.TaskStatus
+ wantFailures int
+ wantRefund bool
+ wantReason string
+ }{
+ {
+ name: "404 fails the batch and refunds",
+ statusCode: http.StatusNotFound,
+ wantStatus: model.TaskStatusFailure,
+ wantRefund: true,
+ wantReason: "upstream task not found (HTTP 404)",
+ },
+ {
+ name: "401 increments every task",
+ statusCode: http.StatusUnauthorized,
+ wantStatus: model.TaskStatusInProgress,
+ wantFailures: 1,
+ },
+ {
+ name: "UNKNOWN increments",
+ statusCode: http.StatusOK,
+ resultStatus: model.TaskStatusUnknown,
+ wantStatus: model.TaskStatusInProgress,
+ wantFailures: 1,
+ },
+ }
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ truncate(t)
+ const userID, tokenID, channelID = 610, 610, 610
+ const initialQuota, preConsumed, tokenRemain = 10_000, 4_000, 7_000
+ seedUser(t, userID, initialQuota)
+ seedToken(t, tokenID, userID, "sk-batch-class", tokenRemain)
+ seedTaskPollingChannel(t, channelID, true)
+
+ task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
+ task.TaskID = "task_batch_class"
+ task.PrivateData.UpstreamTaskID = "upstream_batch_class"
+ require.NoError(t, model.DB.Create(task).Error)
+ upstreamID := task.GetUpstreamTaskID()
+
+ adaptor := &scriptedBatchPollingAdaptor{
+ scriptedPollingAdaptor: scriptedPollingAdaptor{statusCode: testCase.statusCode},
+ }
+ if testCase.resultStatus != "" {
+ adaptor.results = map[string]*BatchTaskResult{
+ upstreamID: {TaskInfo: relaycommon.TaskInfo{TaskID: upstreamID, Status: string(testCase.resultStatus), Reason: "weird"}},
+ }
+ }
+
+ require.NoError(t, UpdateBatchTasks(context.Background(), adaptor, map[int][]string{channelID: {upstreamID}}, map[string]*model.Task{upstreamID: task}))
+
+ var persisted model.Task
+ require.NoError(t, model.DB.First(&persisted, task.ID).Error)
+ assert.EqualValues(t, testCase.wantStatus, persisted.Status)
+ assert.Equal(t, testCase.wantFailures, persisted.PrivateData.PollFailures)
+ if testCase.wantReason != "" {
+ assert.Contains(t, persisted.FailReason, testCase.wantReason)
+ }
+ if testCase.wantRefund {
+ assert.Equal(t, initialQuota+preConsumed, getUserQuota(t, userID))
+ assert.Zero(t, persisted.Quota)
+ } else {
+ assert.Equal(t, initialQuota, getUserQuota(t, userID))
+ }
+ })
+ }
+}
From 36dbbf0f77e710455e745048f4a32e8120ad3fd2 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Thu, 3 Sep 2026 20:42:57 +0800
Subject: [PATCH 92/99] fix: keep ETag valid across different JSON packages
---
common/etag.go | 50 ++++++++++++++++++
controller/misc.go | 30 ++---------
controller/revalidated_response.go | 85 +++++++++---------------------
3 files changed, 81 insertions(+), 84 deletions(-)
create mode 100644 common/etag.go
diff --git a/common/etag.go b/common/etag.go
new file mode 100644
index 000000000000..cbae5a58654e
--- /dev/null
+++ b/common/etag.go
@@ -0,0 +1,50 @@
+package common
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "reflect"
+ "strings"
+)
+
+type digestAnchor struct{}
+
+func modulePath() string {
+ return reflect.TypeOf(digestAnchor{}).PkgPath()
+}
+
+var digestSeed = func() (s [sha256.Size]byte) {
+ return sha256.Sum256([]byte(modulePath()))
+}()
+
+// ETagFor returns a weak ETag derived from the namespace and content.
+func ETagFor(namespace, content string) string {
+ buf := make([]byte, 0, sha256.Size+1+len(namespace)+1+len(content))
+ buf = append(buf, digestSeed[:]...)
+ buf = append(buf, 0)
+ buf = append(buf, namespace...)
+ buf = append(buf, 0)
+ buf = append(buf, content...)
+ digest := sha256.Sum256(buf)
+ return `W/"` + hex.EncodeToString(digest[:]) + `"`
+}
+
+// ETagMatches reports whether an If-None-Match header matches etag under weak
+// comparison (RFC 9110 §13.1.2): the W/ prefix is ignored on both sides, and
+// "*" matches everything.
+func ETagMatches(ifNoneMatch, etag string) bool {
+ ifNoneMatch = strings.TrimSpace(ifNoneMatch)
+ if ifNoneMatch == "" {
+ return false
+ }
+ if ifNoneMatch == "*" {
+ return true
+ }
+ etag = strings.TrimPrefix(etag, "W/")
+ for candidate := range strings.SplitSeq(ifNoneMatch, ",") {
+ if strings.TrimPrefix(strings.TrimSpace(candidate), "W/") == etag {
+ return true
+ }
+ }
+ return false
+}
diff --git a/controller/misc.go b/controller/misc.go
index a02e77a20b97..9f7480ef958e 100644
--- a/controller/misc.go
+++ b/controller/misc.go
@@ -178,38 +178,22 @@ func GetNotice(c *gin.Context) {
common.OptionMapRWMutex.RLock()
notice := common.OptionMap["Notice"]
common.OptionMapRWMutex.RUnlock()
- serveRevalidatedJSON(c, gin.H{
- "success": true,
- "message": "",
- "data": notice,
- })
+ serveRevalidatedJSON(c, notice)
}
func GetAbout(c *gin.Context) {
common.OptionMapRWMutex.RLock()
about := common.OptionMap["About"]
common.OptionMapRWMutex.RUnlock()
- serveRevalidatedJSON(c, gin.H{
- "success": true,
- "message": "",
- "data": about,
- })
+ serveRevalidatedJSON(c, about)
}
func GetUserAgreement(c *gin.Context) {
- serveRevalidatedJSON(c, gin.H{
- "success": true,
- "message": "",
- "data": system_setting.GetLegalSettings().UserAgreement,
- })
+ serveRevalidatedJSON(c, system_setting.GetLegalSettings().UserAgreement)
}
func GetPrivacyPolicy(c *gin.Context) {
- serveRevalidatedJSON(c, gin.H{
- "success": true,
- "message": "",
- "data": system_setting.GetLegalSettings().PrivacyPolicy,
- })
+ serveRevalidatedJSON(c, system_setting.GetLegalSettings().PrivacyPolicy)
}
func GetMidjourney(c *gin.Context) {
@@ -227,11 +211,7 @@ func GetHomePageContent(c *gin.Context) {
common.OptionMapRWMutex.RLock()
homePageContent := common.OptionMap["HomePageContent"]
common.OptionMapRWMutex.RUnlock()
- serveRevalidatedJSON(c, gin.H{
- "success": true,
- "message": "",
- "data": homePageContent,
- })
+ serveRevalidatedJSON(c, homePageContent)
}
func SendEmailVerification(c *gin.Context) {
diff --git a/controller/revalidated_response.go b/controller/revalidated_response.go
index 133c0294a42f..f17a2dfd559b 100644
--- a/controller/revalidated_response.go
+++ b/controller/revalidated_response.go
@@ -1,43 +1,36 @@
package controller
import (
- "crypto/sha256"
- "encoding/hex"
"net/http"
- "strings"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
)
-// serveRevalidatedJSON writes payload as JSON with a weak content-derived ETag
-// and answers conditional requests with 304 Not Modified.
-//
-// Intended for small, public, admin-editable payloads (notice, home page
-// content) that every anonymous visitor fetches on page load. The goal is to
-// make those fetches cheap without ever serving stale content:
-//
-// - The ETag is a hash of the response body, so it is identical across
-// replicas. Deriving it from a timestamp would not be, and the Option table
-// has no updated_at column to derive one from anyway.
-// - The validator is weak (W/ prefixed) because /api is gzip-compressed by
-// middleware that runs after this handler returns. The hash is computed over
-// the uncompressed body, so the compressed and identity forms of one payload
-// share a validator, and a strong ETag asserts byte-for-byte equality that
-// does not hold across encodings (RFC 9110 §8.8.1). Weakening it costs
-// nothing here: conditional GET compares weakly anyway, and these payloads
-// are a few hundred bytes of JSON that no client Range-requests.
-// - Cache-Control is "no-cache", which means "may be stored, but must be
-// revalidated before reuse" (RFC 9111 §5.2.2.4). Browsers and CDNs both
-// revalidate on every request, so an admin edit takes effect immediately.
-// max-age/s-maxage are deliberately not set: upstream cannot assume how
-// long any given deployment tolerates a stale notice.
-// - Vary: Accept-Encoding is still required. Weakening the validator makes
-// revalidation correct, but it does not separate the two encodings in a
-// shared cache. Without Vary, a cache holding the gzip copy would hand those
-// bytes to a client that never sent Accept-Encoding: gzip.
-func serveRevalidatedJSON(c *gin.Context, payload any) {
- body, err := common.Marshal(payload)
+// etagVersionPublicContent namespaces the public-content ETag; bump it when
+// the JSON envelope served by serveRevalidatedJSON changes shape.
+const etagVersionPublicContent = "public-content:v1"
+
+type publicContentResponse struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+ Data string `json:"data"`
+}
+
+// serveRevalidatedJSON writes public content as JSON with a weak
+// content-derived ETag and answers conditional requests with 304 Not
+// Modified. The ETag is a weak validator derived from the content, so it is
+// stable across replicas and JSON encodings, and a new one is issued when
+// the content changes. Cache-Control: no-cache forces revalidation before
+// reuse, so an admin edit takes effect on the next request; Vary:
+// Accept-Encoding keeps the gzip and identity encodings apart in shared
+// caches.
+func serveRevalidatedJSON(c *gin.Context, content string) {
+ body, err := common.Marshal(publicContentResponse{
+ Success: true,
+ Message: "",
+ Data: content,
+ })
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
@@ -46,42 +39,16 @@ func serveRevalidatedJSON(c *gin.Context, payload any) {
return
}
- digest := sha256.Sum256(body)
- etag := `W/"` + hex.EncodeToString(digest[:]) + `"`
+ etag := common.ETagFor(etagVersionPublicContent, content)
c.Header("ETag", etag)
c.Header("Cache-Control", "no-cache")
c.Header("Vary", "Accept-Encoding")
- if etagMatches(c.GetHeader("If-None-Match"), etag) {
+ if common.ETagMatches(c.GetHeader("If-None-Match"), etag) {
c.Status(http.StatusNotModified)
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
}
-
-// etagMatches reports whether an If-None-Match header field matches etag,
-// using the weak comparison required for conditional GET (RFC 9110 §13.1.2).
-// The field is a comma-separated list of entity-tags or the wildcard "*".
-//
-// Weak comparison ignores the W/ prefix on both operands, so it must be
-// stripped from the served etag as well as from each candidate. Stripping only
-// the candidate would make a weak served validator match nothing, silently
-// disabling 304 responses.
-func etagMatches(ifNoneMatch string, etag string) bool {
- ifNoneMatch = strings.TrimSpace(ifNoneMatch)
- if ifNoneMatch == "" {
- return false
- }
- if ifNoneMatch == "*" {
- return true
- }
- etag = strings.TrimPrefix(etag, "W/")
- for _, candidate := range strings.Split(ifNoneMatch, ",") {
- if strings.TrimPrefix(strings.TrimSpace(candidate), "W/") == etag {
- return true
- }
- }
- return false
-}
From 8f5ab8e4048a90d88b20ae1e6d5228b04233d3b8 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Thu, 3 Sep 2026 21:43:40 +0800
Subject: [PATCH 93/99] fix(ci): resolve release version from trigger tag
---
.github/workflows/release.yml | 24 ++++++++++++++++++------
1 file changed, 18 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0a71c05b2344..b4bfed3c8bfa 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -24,8 +24,12 @@ jobs:
fetch-depth: 0
- name: Determine Version
run: |
- VERSION=$(git describe --tags)
- echo "VERSION=$VERSION" >> $GITHUB_ENV
+ if [[ "$GITHUB_REF" == refs/tags/* ]]; then
+ VERSION=${GITHUB_REF#refs/tags/}
+ else
+ VERSION=$(git describe --tags --match 'v[0-9]*')
+ fi
+ echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: '1.4.0'
@@ -73,8 +77,12 @@ jobs:
fetch-depth: 0
- name: Determine Version
run: |
- VERSION=$(git describe --tags)
- echo "VERSION=$VERSION" >> $GITHUB_ENV
+ if [[ "$GITHUB_REF" == refs/tags/* ]]; then
+ VERSION=${GITHUB_REF#refs/tags/}
+ else
+ VERSION=$(git describe --tags --match 'v[0-9]*')
+ fi
+ echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: '1.4.0'
@@ -121,8 +129,12 @@ jobs:
fetch-depth: 0
- name: Determine Version
run: |
- VERSION=$(git describe --tags)
- echo "VERSION=$VERSION" >> $GITHUB_ENV
+ if [[ "$GITHUB_REF" == refs/tags/* ]]; then
+ VERSION=${GITHUB_REF#refs/tags/}
+ else
+ VERSION=$(git describe --tags --match 'v[0-9]*')
+ fi
+ echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: '1.4.0'
From 32c261923a9786c64d2af087327ef057e7bde7e3 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Thu, 3 Sep 2026 21:58:24 +0800
Subject: [PATCH 94/99] fix(task): explain 503 when a plugin-claimed model has
no channel
A model declared by a task plugin is served only by that plugin's channels.
When the claiming plugin has no enabled channel in the request group, the
distributor answered with the generic "no available channel" text, which hides
the actual cause and led operators to expect channel model_mapping on another
plugin's channel to take over (#7185). That expectation is not supported:
plugin declarations own model names statically, and channel availability must
not silently reassign ownership at request time. The supported fixes are the
existing operator tools, disabling the factory plugin per key or overriding it.
Both no-channel 503 sites in the distributor now route through
noAvailableChannelMessage. When the request is pinned to a task plugin, the
message names the claiming plugin and points to disabling or overriding it;
non-plugin requests keep the generic message. Added in en, zh-CN, zh-TW.
---
i18n/keys.go | 27 ++++++++++++++-------------
i18n/locales/en.yaml | 1 +
i18n/locales/zh-CN.yaml | 1 +
i18n/locales/zh-TW.yaml | 1 +
middleware/distributor.go | 19 +++++++++++++++++--
middleware/distributor_test.go | 26 ++++++++++++++++++++++++++
6 files changed, 60 insertions(+), 15 deletions(-)
diff --git a/i18n/keys.go b/i18n/keys.go
index 64a835e1a942..4a6713ee2e4d 100644
--- a/i18n/keys.go
+++ b/i18n/keys.go
@@ -311,19 +311,20 @@ const (
// Distributor related messages
const (
- MsgDistributorInvalidRequest = "distributor.invalid_request"
- MsgDistributorInvalidChannelId = "distributor.invalid_channel_id"
- MsgDistributorChannelDisabled = "distributor.channel_disabled"
- MsgDistributorAffinityChannelDisabled = "distributor.affinity_channel_disabled"
- MsgDistributorTokenNoModelAccess = "distributor.token_no_model_access"
- MsgDistributorTokenModelForbidden = "distributor.token_model_forbidden"
- MsgDistributorModelNameRequired = "distributor.model_name_required"
- MsgDistributorInvalidPlayground = "distributor.invalid_playground_request"
- MsgDistributorGroupAccessDenied = "distributor.group_access_denied"
- MsgDistributorGetChannelFailed = "distributor.get_channel_failed"
- MsgDistributorNoAvailableChannel = "distributor.no_available_channel"
- MsgDistributorInvalidMidjourney = "distributor.invalid_midjourney_request"
- MsgDistributorInvalidParseModel = "distributor.invalid_request_parse_model"
+ MsgDistributorInvalidRequest = "distributor.invalid_request"
+ MsgDistributorInvalidChannelId = "distributor.invalid_channel_id"
+ MsgDistributorChannelDisabled = "distributor.channel_disabled"
+ MsgDistributorAffinityChannelDisabled = "distributor.affinity_channel_disabled"
+ MsgDistributorTokenNoModelAccess = "distributor.token_no_model_access"
+ MsgDistributorTokenModelForbidden = "distributor.token_model_forbidden"
+ MsgDistributorModelNameRequired = "distributor.model_name_required"
+ MsgDistributorInvalidPlayground = "distributor.invalid_playground_request"
+ MsgDistributorGroupAccessDenied = "distributor.group_access_denied"
+ MsgDistributorGetChannelFailed = "distributor.get_channel_failed"
+ MsgDistributorNoAvailableChannel = "distributor.no_available_channel"
+ MsgDistributorNoAvailableChannelTaskPlugin = "distributor.no_available_channel_task_plugin"
+ MsgDistributorInvalidMidjourney = "distributor.invalid_midjourney_request"
+ MsgDistributorInvalidParseModel = "distributor.invalid_request_parse_model"
)
// Custom OAuth provider related messages
diff --git a/i18n/locales/en.yaml b/i18n/locales/en.yaml
index c533daecc32d..c6db0b448cf3 100644
--- a/i18n/locales/en.yaml
+++ b/i18n/locales/en.yaml
@@ -272,6 +272,7 @@ distributor.invalid_playground_request: "Invalid playground request: {{.Error}}"
distributor.group_access_denied: "No permission to access this group"
distributor.get_channel_failed: "Failed to get available channel for model {{.Model}} under group {{.Group}} (distributor): {{.Error}}"
distributor.no_available_channel: "No available channel for model {{.Model}} under group {{.Group}} (distributor)"
+distributor.no_available_channel_task_plugin: "No available channel for model {{.Model}} under group {{.Group}}: the model is claimed by task plugin \"{{.Plugin}}\", which has no enabled channel serving it. To let another plugin or channel serve this model, disable or override plugin \"{{.Plugin}}\" in the task plugin console (distributor)"
distributor.invalid_midjourney_request: "Invalid Midjourney request: {{.Error}}"
distributor.invalid_request_parse_model: "Invalid request, unable to parse model"
diff --git a/i18n/locales/zh-CN.yaml b/i18n/locales/zh-CN.yaml
index a2f5275be9a8..c3c76d2a4f1a 100644
--- a/i18n/locales/zh-CN.yaml
+++ b/i18n/locales/zh-CN.yaml
@@ -273,6 +273,7 @@ distributor.invalid_playground_request: "无效的playground请求,{{.Error}}"
distributor.group_access_denied: "无权访问该分组"
distributor.get_channel_failed: "获取分组 {{.Group}} 下模型 {{.Model}} 的可用渠道失败(distributor):{{.Error}}"
distributor.no_available_channel: "分组 {{.Group}} 下模型 {{.Model}} 无可用渠道(distributor)"
+distributor.no_available_channel_task_plugin: "分组 {{.Group}} 下模型 {{.Model}} 无可用渠道:该模型由任务插件「{{.Plugin}}」认领,但该插件当前没有启用的渠道可服务此模型。若要让其他插件或渠道服务该模型,请在任务插件管理中禁用或覆盖插件「{{.Plugin}}」(distributor)"
distributor.invalid_midjourney_request: "无效的midjourney请求,{{.Error}}"
distributor.invalid_request_parse_model: "无效的请求,无法解析模型"
diff --git a/i18n/locales/zh-TW.yaml b/i18n/locales/zh-TW.yaml
index 84ebd57ed587..a9041eeadaad 100644
--- a/i18n/locales/zh-TW.yaml
+++ b/i18n/locales/zh-TW.yaml
@@ -273,6 +273,7 @@ distributor.invalid_playground_request: "無效的playground請求,{{.Error}}"
distributor.group_access_denied: "無權存取該分組"
distributor.get_channel_failed: "獲取分組 {{.Group}} 下模型 {{.Model}} 的可用管道失敗(distributor):{{.Error}}"
distributor.no_available_channel: "分組 {{.Group}} 下模型 {{.Model}} 無可用管道(distributor)"
+distributor.no_available_channel_task_plugin: "分組 {{.Group}} 下模型 {{.Model}} 無可用管道:該模型由任務插件「{{.Plugin}}」認領,但該插件目前沒有啟用的管道可服務此模型。若要讓其他插件或管道服務該模型,請在任務插件管理中停用或覆蓋插件「{{.Plugin}}」(distributor)"
distributor.invalid_midjourney_request: "無效的midjourney請求,{{.Error}}"
distributor.invalid_request_parse_model: "無效的請求,無法解析模型"
diff --git a/middleware/distributor.go b/middleware/distributor.go
index e61bea44aa3f..466b1dcc5ef7 100644
--- a/middleware/distributor.go
+++ b/middleware/distributor.go
@@ -180,7 +180,7 @@ func Distribute() func(c *gin.Context) {
return
}
if channel == nil {
- abortWithOpenAiMessage(c, http.StatusServiceUnavailable, i18n.T(c, i18n.MsgDistributorNoAvailableChannel, map[string]any{"Group": usingGroup, "Model": modelRequest.Model}), types.ErrorCodeModelNotFound)
+ abortWithOpenAiMessage(c, http.StatusServiceUnavailable, noAvailableChannelMessage(c, usingGroup, modelRequest.Model), types.ErrorCodeModelNotFound)
return
}
}
@@ -191,7 +191,7 @@ func Distribute() func(c *gin.Context) {
if kind == taskdto.FilterTaskPluginIdentity {
logTaskPluginChannelDecision(c, channel, modelRequest.Model, "channel_rejected", "identity_mismatch")
}
- abortWithOpenAiMessage(c, http.StatusServiceUnavailable, i18n.T(c, i18n.MsgDistributorNoAvailableChannel, map[string]any{"Group": common.GetContextKeyString(c, constant.ContextKeyUsingGroup), "Model": modelRequest.Model}), types.ErrorCodeModelNotFound)
+ abortWithOpenAiMessage(c, http.StatusServiceUnavailable, noAvailableChannelMessage(c, common.GetContextKeyString(c, constant.ContextKeyUsingGroup), modelRequest.Model), types.ErrorCodeModelNotFound)
return
}
}
@@ -204,6 +204,21 @@ func Distribute() func(c *gin.Context) {
}
}
+// noAvailableChannelMessage explains a 503 for a task-plugin-claimed model.
+// A model claimed by a plugin is served only by that plugin's channels, so the
+// generic "no channel" text hides the real cause: the claiming plugin has no
+// enabled channel, and the operator must disable or override that plugin for
+// any other plugin or channel to take the model. Non-plugin requests keep the
+// generic message.
+func noAvailableChannelMessage(c *gin.Context, group, modelName string) string {
+ value, exists := c.Get(jsplugin.ContextKeyPinnedPlugin)
+ pinned, ok := value.(jsplugin.PinnedPlugin)
+ if exists && ok && pinned.Plugin != nil {
+ return i18n.T(c, i18n.MsgDistributorNoAvailableChannelTaskPlugin, map[string]any{"Group": group, "Model": modelName, "Plugin": pinned.Plugin.Meta.Key})
+ }
+ return i18n.T(c, i18n.MsgDistributorNoAvailableChannel, map[string]any{"Group": group, "Model": modelName})
+}
+
func channelMatchesExpectedTaskPlugin(c *gin.Context, channel *model.Channel, expected string) bool {
if channel == nil {
return false
diff --git a/middleware/distributor_test.go b/middleware/distributor_test.go
index 10c500b0adaf..8b3a7242e34f 100644
--- a/middleware/distributor_test.go
+++ b/middleware/distributor_test.go
@@ -2,9 +2,12 @@ package middleware
import (
"fmt"
+ "net/http"
+ "net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relaykit/dto"
@@ -147,3 +150,26 @@ export const protocols = {openai_responses: {
}};
`, key, key, channelType)
}
+
+func TestNoAvailableChannelMessageNamesClaimingTaskPlugin(t *testing.T) {
+ require.NoError(t, i18n.Init())
+ registry := jsplugin.NewRegistry()
+ plugin, err := registry.Register(distributorTaskPluginSource("claimer", constant.ChannelTypeKling), jsplugin.Options{})
+ require.NoError(t, err)
+
+ pinned, _ := gin.CreateTestContext(nil)
+ pinned.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
+ pinned.Request.Header.Set("Accept-Language", "en")
+ pinned.Set(jsplugin.ContextKeyPinnedPlugin, jsplugin.PinnedPlugin{Generation: registry.Generation(), Plugin: plugin})
+ message := noAvailableChannelMessage(pinned, "default", "kling-v1")
+ assert.Contains(t, message, `"claimer"`)
+ assert.Contains(t, message, "disable or override")
+ assert.Contains(t, message, "kling-v1")
+
+ plain, _ := gin.CreateTestContext(nil)
+ plain.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
+ plain.Request.Header.Set("Accept-Language", "en")
+ generic := noAvailableChannelMessage(plain, "default", "gpt-4o")
+ assert.NotContains(t, generic, "task plugin")
+ assert.Contains(t, generic, "gpt-4o")
+}
From 3a9f41ee85cc369f5b8d7fe6e62ff4e7bf3a9ec8 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Fri, 4 Sep 2026 10:07:03 +0800
Subject: [PATCH 95/99] fix: temp disable /messages/count_tokens
---
router/relay-router.go | 3 ++-
router/relay_router_test.go | 14 --------------
2 files changed, 2 insertions(+), 15 deletions(-)
diff --git a/router/relay-router.go b/router/relay-router.go
index dd759fb8b9a0..42b1882f0f18 100644
--- a/router/relay-router.go
+++ b/router/relay-router.go
@@ -85,7 +85,8 @@ func SetRelayRouter(router *gin.Engine) {
httpRouter.Use(middleware.Distribute())
// claude related routes
- httpRouter.POST("/messages/count_tokens", controller.CountClaudeTokens)
+ // TODO: /messages/count_tokens is disabled. The current controller.CountClaudeTokens
+ // httpRouter.POST("/messages/count_tokens", controller.CountClaudeTokens)
httpRouter.POST("/messages", func(c *gin.Context) {
controller.Relay(c, types.RelayFormatClaude)
})
diff --git a/router/relay_router_test.go b/router/relay_router_test.go
index 96ff09a29469..579bd7bffa25 100644
--- a/router/relay_router_test.go
+++ b/router/relay_router_test.go
@@ -89,20 +89,6 @@ func TestListModelsSupportsOpenAIAndGeminiAuthentication(t *testing.T) {
}
}
-func TestRelayRouterRegistersClaudeTokenCountingEndpoint(t *testing.T) {
- gin.SetMode(gin.TestMode)
- engine := gin.New()
- SetRelayRouter(engine)
-
- for _, route := range engine.Routes() {
- if route.Method == http.MethodPost && route.Path == "/v1/messages/count_tokens" {
- return
- }
- }
-
- t.Fatal("POST /v1/messages/count_tokens route is not registered")
-}
-
func setupRelayRouterTestDB(t *testing.T) {
t.Helper()
From 7c044d7c5c2d2beadf16b21910950f8f593bc3ef Mon Sep 17 00:00:00 2001
From: CaIon
Date: Fri, 4 Sep 2026 20:45:52 +0800
Subject: [PATCH 96/99] feat(relay): explicit @ model modifiers and canonical
billing identity
Model-name post-processing is rebuilt around an explicit trailing
@key:value modifier syntax (thinking/effort/temperature/topp) that
overrides request fields, survives model mapping, and records
conversion diagnostics on the consume log.
- Legacy naked aliases (-thinking, -nothinking, -thinking-,
effort tails) now parse only for positively matched families
(gpt-*/o-series, claude-*, gemini-*, incl. vendor/ namespaces);
names like qwen-max stay opaque. EffortTailModelIDs remains the
escape hatch for real in-family IDs such as gpt-5.1-codex-max.
- Billing identity resolves once in ModelPriceHelper via a ladder:
configured request name first (legacy wildcard entries intact), then
canonical billing names rebuilt from parsed intent
(base@effort:E@thinking:S, then base@thinking:S; order, duplicates,
and budget values are irrelevant; temperature/topp never priced),
then base. Routing and token limits fall back through
RoutingMatchModelName; pricing lookups stay wildcard-only.
- Pass-through stays byte-identical: modifiers and aliases are neither
parsed nor validated there and forward verbatim for the upstream
(or a chained gateway) to interpret.
- Unknown modifier keys and invalid known-key values are rejected with
400; models whose real names contain @tag:value are exempted via the
thinking-suffix blacklist, which now supports re:-prefixed Go regex
entries.
- Claude reasoning render coerces unsupported combinations (disable,
adaptive, budgets) with warning diagnostics instead of erroring;
native-protocol requests without host syntax pass through untouched.
BREAKING(openrouter): drop the host-invented "-thinking" model-name
alias (added in 4f6d16e36) that trimmed any *-thinking model on
OpenRouter channels and injected reasoning.enabled. It matched too
broadly and mangled real model IDs such as kimi-k2-thinking.
Migration: use some-model@thinking:on, or keep the old public name via
a channel model mapping {"some-model-thinking": "some-model@thinking:on"}.
Claude/Gemini family aliases (incl. anthropic/claude-*-thinking) keep
working via the family whitelist.
---
controller/channel-test.go | 2 +-
controller/model.go | 2 +-
middleware/distributor.go | 16 +-
middleware/distributor_test.go | 27 ++
model/channel_cache.go | 2 +-
model/channel_satisfy.go | 4 +-
relay/channel/claude/adaptor_test.go | 40 +-
relay/channel/claude/relay_claude_test.go | 3 +-
relay/channel/openai/adaptor.go | 24 +-
relay/claude_handler.go | 2 +-
relay/common/relay_info.go | 3 +-
relay/common/relay_info_test.go | 14 +
relay/compatible_handler.go | 2 +-
relay/convert_request_error_test.go | 26 ++
relay/gemini_handler.go | 4 +-
relay/helper/model_mapped.go | 24 +-
relay/helper/model_modifier.go | 278 ++++++++++++
relay/helper/price.go | 51 +++
relay/helper/price_test.go | 292 ++++++++++++-
relay/helper/reasoning_suffix.go | 221 +++++++---
relay/helper/reasoning_suffix_test.go | 410 +++++++++++++++++-
relay/responses_handler.go | 2 +-
.../internal/convdiag/collector.go | 56 +++
.../oai_chat/to_claude_messages_req.go | 2 +-
.../oai_responses/to_claude_messages_req.go | 2 +-
.../internal/shared/claude/reasoning.go | 72 ++-
.../internal/shared/gemini/request.go | 16 +
relaykit/relayconvert/reasoning/claude.go | 115 ++++-
relaykit/relayconvert/reasoning/suffix.go | 156 +++++--
.../relayconvert/reasoning/suffix_test.go | 52 +++
relaykit/relayconvert/request_compat.go | 10 +-
relaykit/relayconvert/request_registry.go | 13 +-
setting/model_setting/global.go | 97 ++++-
setting/model_setting/global_test.go | 44 ++
setting/ratio_setting/matching_test.go | 38 ++
setting/ratio_setting/model_ratio.go | 397 +++++++++--------
setting/reasoning/suffix.go | 210 ++++++++-
setting/reasoning/suffix_test.go | 134 ++++++
.../models/global-settings-card.tsx | 4 +-
web/src/i18n/locales/en.json | 35 +-
web/src/i18n/locales/fr.json | 35 +-
web/src/i18n/locales/ja.json | 35 +-
web/src/i18n/locales/ru.json | 35 +-
web/src/i18n/locales/vi.json | 35 +-
web/src/i18n/locales/zh-TW.json | 35 +-
web/src/i18n/locales/zh.json | 35 +-
46 files changed, 2570 insertions(+), 542 deletions(-)
create mode 100644 relay/helper/model_modifier.go
create mode 100644 relaykit/relayconvert/internal/convdiag/collector.go
create mode 100644 setting/model_setting/global_test.go
create mode 100644 setting/ratio_setting/matching_test.go
create mode 100644 setting/reasoning/suffix_test.go
diff --git a/controller/channel-test.go b/controller/channel-test.go
index 4d7e4b1f5350..f8fad94cd2de 100644
--- a/controller/channel-test.go
+++ b/controller/channel-test.go
@@ -259,7 +259,7 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
newAPIError: types.NewError(err, types.ErrorCodeChannelModelMappedError),
}
}
- if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
+ if err := helper.ApplyReasoningModelSuffix(c, info, request); err != nil {
return testResult{
context: c,
localErr: err,
diff --git a/controller/model.go b/controller/model.go
index 779739477fe1..54d71f256885 100644
--- a/controller/model.go
+++ b/controller/model.go
@@ -251,7 +251,7 @@ func ListModels(c *gin.Context, modelType int) {
models := service.GetGroupsEnabledModels(ownerGroups)
for _, modelName := range models {
if modelLimitEnable {
- matchingName := ratio_setting.FormatMatchingModelName(modelName)
+ matchingName := ratio_setting.RoutingMatchModelName(modelName)
if !tokenModelLimit[modelName] && !tokenModelLimit[matchingName] {
continue
}
diff --git a/middleware/distributor.go b/middleware/distributor.go
index 466b1dcc5ef7..e7fa84f83d78 100644
--- a/middleware/distributor.go
+++ b/middleware/distributor.go
@@ -92,8 +92,7 @@ func Distribute() func(c *gin.Context) {
if !ok {
tokenModelLimit = map[string]bool{}
}
- matchName := ratio_setting.FormatMatchingModelName(modelRequest.Model) // match gpts & thinking-*
- if _, ok := tokenModelLimit[matchName]; !ok {
+ if !tokenModelLimitAllows(tokenModelLimit, modelRequest.Model) {
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorTokenModelForbidden, map[string]any{"Model": modelRequest.Model}))
return
}
@@ -570,6 +569,19 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
return &modelRequest, shouldSelectChannel, nil
}
+// tokenModelLimitAllows reports whether a token model-limit map authorizes
+// model. Exact name, wildcard-normalized name, and routing-normalized name
+// (modifiers and legacy aliases stripped) are all accepted.
+func tokenModelLimitAllows(limit map[string]bool, model string) bool {
+ if limit[model] {
+ return true
+ }
+ if formatted := ratio_setting.FormatMatchingModelName(model); limit[formatted] {
+ return true
+ }
+ return limit[ratio_setting.RoutingMatchModelName(model)]
+}
+
// 修复 #4834: GET /v1/video/generations/:task_id && /v1/video/:task_id 此前不解析 model,
// 当 token 启用「可用模型限制」时,下游 modelLimitEnable 校验会因
// modelRequest.Model 为空而误报 "This token has no access to model"。
diff --git a/middleware/distributor_test.go b/middleware/distributor_test.go
index 8b3a7242e34f..43edf6151060 100644
--- a/middleware/distributor_test.go
+++ b/middleware/distributor_test.go
@@ -11,6 +11,7 @@ import (
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/setting/model_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -151,6 +152,32 @@ export const protocols = {openai_responses: {
`, key, key, channelType)
}
+func TestTokenModelLimitAllowsLegacyAliasAndModifierVariant(t *testing.T) {
+ aliasOnly := map[string]bool{"claude-3-7-sonnet-thinking": true}
+ assert.True(t, tokenModelLimitAllows(aliasOnly, "claude-3-7-sonnet-thinking"))
+ assert.False(t, tokenModelLimitAllows(aliasOnly, "claude-3-7-sonnet"))
+
+ baseOnly := map[string]bool{"claude-3-7-sonnet": true}
+ assert.True(t, tokenModelLimitAllows(baseOnly, "claude-3-7-sonnet@thinking:on"))
+ assert.True(t, tokenModelLimitAllows(baseOnly, "claude-3-7-sonnet-thinking"))
+
+ wildcard := map[string]bool{"gemini-2.5-flash-thinking-*": true}
+ assert.True(t, tokenModelLimitAllows(wildcard, "gemini-2.5-flash-thinking-8192"))
+}
+
+func TestTokenModelLimitAllowsExemptAtNameByFullName(t *testing.T) {
+ settings := model_setting.GetGlobalSettings()
+ original := append([]string(nil), settings.ThinkingModelBlacklist...)
+ t.Cleanup(func() { settings.ThinkingModelBlacklist = original })
+ settings.ThinkingModelBlacklist = append(original, "re:.*@sha256:.*")
+
+ fullOnly := map[string]bool{"opaque@sha256:deadbeef": true}
+ assert.True(t, tokenModelLimitAllows(fullOnly, "opaque@sha256:deadbeef"))
+
+ baseOnly := map[string]bool{"opaque": true}
+ assert.False(t, tokenModelLimitAllows(baseOnly, "opaque@sha256:deadbeef"))
+}
+
func TestNoAvailableChannelMessageNamesClaimingTaskPlugin(t *testing.T) {
require.NoError(t, i18n.Init())
registry := jsplugin.NewRegistry()
diff --git a/model/channel_cache.go b/model/channel_cache.go
index a992c1961c8b..aaa61b01b364 100644
--- a/model/channel_cache.go
+++ b/model/channel_cache.go
@@ -133,7 +133,7 @@ func GetRandomSatisfiedChannel(
// If no channels found, try to find channels with the normalized model name.
if len(channels) == 0 {
- normalizedModel := ratio_setting.FormatMatchingModelName(model)
+ normalizedModel := ratio_setting.RoutingMatchModelName(model)
channels, _ = filterCandidateIDs(group2model2channels[group][normalizedModel], model, filters)
}
diff --git a/model/channel_satisfy.go b/model/channel_satisfy.go
index 681f1e69bb6e..f91be89296b4 100644
--- a/model/channel_satisfy.go
+++ b/model/channel_satisfy.go
@@ -23,7 +23,7 @@ func IsChannelEnabledForGroupModel(group string, modelName string, channelID int
if isChannelIDInList(group2model2channels[group][modelName], channelID) {
return true
}
- normalized := ratio_setting.FormatMatchingModelName(modelName)
+ normalized := ratio_setting.RoutingMatchModelName(modelName)
if normalized != "" && normalized != modelName {
return isChannelIDInList(group2model2channels[group][normalized], channelID)
}
@@ -50,7 +50,7 @@ func isChannelEnabledForGroupModelDB(group string, modelName string, channelID i
if err == nil && count > 0 {
return true
}
- normalized := ratio_setting.FormatMatchingModelName(modelName)
+ normalized := ratio_setting.RoutingMatchModelName(modelName)
if normalized == "" || normalized == modelName {
return false
}
diff --git a/relay/channel/claude/adaptor_test.go b/relay/channel/claude/adaptor_test.go
index 885ea19a5d6b..82a26d8bb364 100644
--- a/relay/channel/claude/adaptor_test.go
+++ b/relay/channel/claude/adaptor_test.go
@@ -37,6 +37,43 @@ func TestConvertClaudeRequestTreatsZeroMaxTokensAsUnset(t *testing.T) {
assert.Equal(t, uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(req.Model)), *converted.MaxTokens)
}
+func TestConvertClaudeRequestPreservesNativeClaudeCodeThinking(t *testing.T) {
+ budget := 10000
+ maxTokens := uint(20000)
+ temperature := 0.7
+ topP := 0.9
+ req := &dto.ClaudeRequest{
+ Model: "claude-opus-4-8",
+ MaxTokens: &maxTokens,
+ Temperature: &temperature,
+ TopP: &topP,
+ Thinking: &dto.Thinking{Type: "enabled", BudgetTokens: &budget},
+ OutputConfig: []byte(`{"effort":"high"}`),
+ Messages: []dto.ClaudeMessage{
+ {Role: "user", Content: "hello"},
+ },
+ }
+ info := &relaycommon.RelayInfo{
+ OriginModelName: req.Model,
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: req.Model,
+ },
+ }
+
+ out, err := (&Adaptor{}).ConvertClaudeRequest(nil, info, req)
+ require.NoError(t, err)
+ converted, ok := out.(*dto.ClaudeRequest)
+ require.True(t, ok)
+ require.NotNil(t, converted.Thinking)
+ assert.Equal(t, "enabled", converted.Thinking.Type)
+ require.NotNil(t, converted.Thinking.BudgetTokens)
+ assert.Equal(t, budget, *converted.Thinking.BudgetTokens)
+ assert.Equal(t, temperature, *converted.Temperature)
+ assert.Equal(t, topP, *converted.TopP)
+ assert.JSONEq(t, `{"effort":"high"}`, string(converted.OutputConfig))
+ assert.Empty(t, info.ConversionDiagnostics())
+}
+
func TestConvertClaudeRequestZeroMaxTokensStillRaisesThinkingBudget(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
@@ -59,7 +96,8 @@ func TestConvertClaudeRequestZeroMaxTokensStillRaisesThinkingBudget(t *testing.T
outbound, err := common.DeepCopy(original)
require.NoError(t, err)
require.NoError(t, helper.ModelMappedHelper(c, info, outbound))
- require.NoError(t, helper.ApplyReasoningModelSuffix(info, outbound))
+ err = helper.ApplyReasoningModelSuffix(nil, info, outbound)
+ require.NoError(t, err)
out, err := (&Adaptor{}).ConvertClaudeRequest(nil, info, outbound)
require.NoError(t, err)
diff --git a/relay/channel/claude/relay_claude_test.go b/relay/channel/claude/relay_claude_test.go
index 703d78a37087..937d7998b5cc 100644
--- a/relay/channel/claude/relay_claude_test.go
+++ b/relay/channel/claude/relay_claude_test.go
@@ -342,7 +342,8 @@ func applyOpenAIChatReasoningThroughHandlerOrder(t *testing.T, original dto.Gene
outbound, err := common.DeepCopy(&original)
require.NoError(t, err)
require.NoError(t, helper.ModelMappedHelper(c, info, outbound))
- require.NoError(t, helper.ApplyReasoningModelSuffix(info, outbound))
+ err = helper.ApplyReasoningModelSuffix(nil, info, outbound)
+ require.NoError(t, err)
return outbound, info
}
diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go
index dd64149b20da..5543eeca0626 100644
--- a/relay/channel/openai/adaptor.go
+++ b/relay/channel/openai/adaptor.go
@@ -272,7 +272,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if len(request.Usage) == 0 {
request.Usage = json.RawMessage(`{"include":true}`)
}
- // 适配 OpenRouter 的 thinking 后缀
+ // 合并 effort 尾巴产生的意图
preserveSuffix := model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) || model_setting.ShouldPreserveThinkingSuffix(info.UpstreamModelName)
mergeEffortSuffix := func(modelName string) error {
rawEffort, _ := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(modelName)
@@ -304,28 +304,6 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
}
}
}
- if !preserveSuffix && strings.HasSuffix(info.UpstreamModelName, "-thinking") {
- initialIntent, err = kitreasoning.MergeExplicitAndSuffix(
- initialIntent,
- kitreasoning.Intent{Mode: kitreasoning.ModeEnabled},
- info.UpstreamModelName,
- )
- if err != nil {
- return nil, kitreasoning.AsClientError(err)
- }
- info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
- request.Model = info.UpstreamModelName
- }
- if !preserveSuffix && info.OriginModelName != info.UpstreamModelName && strings.HasSuffix(info.OriginModelName, "-thinking") {
- initialIntent, err = kitreasoning.MergeExplicitAndSuffix(
- initialIntent,
- kitreasoning.Intent{Mode: kitreasoning.ModeEnabled},
- info.OriginModelName,
- )
- if err != nil {
- return nil, kitreasoning.AsClientError(err)
- }
- }
if !initialIntent.IsEmpty() {
reasoningConfig := make(map[string]any)
if len(request.Reasoning) > 0 {
diff --git a/relay/claude_handler.go b/relay/claude_handler.go
index 1dfbc3b65780..e1e31afb776a 100644
--- a/relay/claude_handler.go
+++ b/relay/claude_handler.go
@@ -38,7 +38,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
- if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
+ if err := helper.ApplyReasoningModelSuffix(c, info, request); err != nil {
return newConvertRequestFailedError(c, info, err)
}
diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go
index cef4d02adc13..b1bd4f1bcad5 100644
--- a/relay/common/relay_info.go
+++ b/relay/common/relay_info.go
@@ -536,6 +536,7 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo {
reqId = common.NewRequestId()
}
reasoningEffort := reasoningEffortFromRequest(request)
+ originModelName := common.GetContextKeyString(c, constant.ContextKeyOriginalModel)
info := &RelayInfo{
Request: request,
ReasoningEffort: reasoningEffort,
@@ -547,7 +548,7 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo {
UserQuota: common.GetContextKeyInt(c, constant.ContextKeyUserQuota),
UserEmail: common.GetContextKeyString(c, constant.ContextKeyUserEmail),
- OriginModelName: common.GetContextKeyString(c, constant.ContextKeyOriginalModel),
+ OriginModelName: originModelName,
TokenId: common.GetContextKeyInt(c, constant.ContextKeyTokenId),
TokenKey: common.GetContextKeyString(c, constant.ContextKeyTokenKey),
diff --git a/relay/common/relay_info_test.go b/relay/common/relay_info_test.go
index 125df738fb1e..780950313599 100644
--- a/relay/common/relay_info_test.go
+++ b/relay/common/relay_info_test.go
@@ -161,6 +161,20 @@ func TestGenRelayInfoCapturesRequestReasoningEffort(t *testing.T) {
}
}
+func TestGenRelayInfoKeepsOriginAndLeavesBillingUnset(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+ ctx.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil)
+ const model = "qwen3.8-max@thinking:on@temperature:0.2"
+ ctx.Set("original_model", model)
+
+ info, err := GenRelayInfo(ctx, types.RelayFormatOpenAI, &dto.GeneralOpenAIRequest{Model: model}, nil)
+ require.NoError(t, err)
+ assert.Equal(t, model, info.OriginModelName)
+ assert.Empty(t, info.BillingModelName)
+ assert.Equal(t, model, info.GetBillingModelName())
+}
+
func TestInitChannelMetaRestoresRequestReasoningEffortForRetry(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go
index ba816c462481..bb98a290079d 100644
--- a/relay/compatible_handler.go
+++ b/relay/compatible_handler.go
@@ -43,7 +43,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
- if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
+ if err := helper.ApplyReasoningModelSuffix(c, info, request); err != nil {
return newConvertRequestFailedError(c, info, err)
}
diff --git a/relay/convert_request_error_test.go b/relay/convert_request_error_test.go
index 5ab853bc65f7..6f63240cc28a 100644
--- a/relay/convert_request_error_test.go
+++ b/relay/convert_request_error_test.go
@@ -7,7 +7,9 @@ import (
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
+ kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
@@ -62,6 +64,30 @@ func TestOptInSafeToolLossRejectedAsBadRequestWithAdminDiagnostics(t *testing.T)
require.Contains(t, adminInfo, "conversion_diagnostics")
}
+func TestUnknownModelModifierIsBadRequestWithoutRetry(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
+
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "m@thinkin:on",
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: "m@thinkin:on",
+ },
+ }
+ err := helper.ApplyReasoningModelSuffix(c, info)
+ require.Error(t, err)
+ require.True(t, kitreasoning.IsClientError(err))
+ assert.Contains(t, err.Error(), `unsupported model modifier "thinkin"`)
+ assert.Contains(t, err.Error(), "re:")
+
+ apiErr := newConvertRequestFailedError(c, info, err)
+ require.NotNil(t, apiErr)
+ assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode)
+ assert.Equal(t, types.ErrorCodeConvertRequestFailed, apiErr.GetErrorCode())
+ assert.True(t, types.IsSkipRetryError(apiErr))
+}
+
func hasHostDiagnosticCode(diagnostics []types.ConversionDiagnostic, code string) bool {
for _, diagnostic := range diagnostics {
if diagnostic.Code == code {
diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go
index ffa6e996beaf..bc1fbdb27135 100644
--- a/relay/gemini_handler.go
+++ b/relay/gemini_handler.go
@@ -37,7 +37,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
- if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
+ if err := helper.ApplyReasoningModelSuffix(c, info, request); err != nil {
return newConvertRequestFailedError(c, info, err)
}
@@ -198,7 +198,7 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPI
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
- if err = helper.ApplyReasoningModelSuffix(info, req); err != nil {
+ if err := helper.ApplyReasoningModelSuffix(c, info, req); err != nil {
return newConvertRequestFailedError(c, info, err)
}
diff --git a/relay/helper/model_mapped.go b/relay/helper/model_mapped.go
index 34959691750b..4a75f501747b 100644
--- a/relay/helper/model_mapped.go
+++ b/relay/helper/model_mapped.go
@@ -1,25 +1,26 @@
package helper
import (
- "encoding/json"
"errors"
"fmt"
- "github.com/QuantumNous/new-api/relay/common"
+ rootcommon "github.com/QuantumNous/new-api/common"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
+ hostreasoning "github.com/QuantumNous/new-api/setting/reasoning"
"github.com/gin-gonic/gin"
)
-func ModelMappedHelper(c *gin.Context, info *common.RelayInfo, request dto.Request) error {
+func ModelMappedHelper(c *gin.Context, info *relaycommon.RelayInfo, request dto.Request) error {
if info.ChannelMeta == nil {
- info.ChannelMeta = &common.ChannelMeta{}
+ info.ChannelMeta = &relaycommon.ChannelMeta{}
}
// map model name
modelMapping := c.GetString("model_mapping")
if modelMapping != "" && modelMapping != "{}" {
modelMap := make(map[string]string)
- err := json.Unmarshal([]byte(modelMapping), &modelMap)
+ err := rootcommon.Unmarshal([]byte(modelMapping), &modelMap)
if err != nil {
return fmt.Errorf("unmarshal_model_mapping_failed")
}
@@ -30,17 +31,22 @@ func ModelMappedHelper(c *gin.Context, info *common.RelayInfo, request dto.Reque
currentModel: true,
}
for {
- if mappedModel, exists := modelMap[currentModel]; exists && mappedModel != "" {
+ mappedModel, exists := modelMap[currentModel]
+ baseModel := hostreasoning.BaseModelName(currentModel)
+ if (!exists || mappedModel == "") && baseModel != currentModel {
+ mappedModel, exists = modelMap[baseModel]
+ }
+ if exists && mappedModel != "" {
// 模型重定向循环检测,避免无限循环
if visitedModels[mappedModel] {
if mappedModel == currentModel {
if currentModel == info.OriginModelName {
info.IsModelMapped = false
return nil
- } else {
- info.IsModelMapped = true
- break
}
+
+ info.IsModelMapped = true
+ break
}
return errors.New("model_mapping_contains_cycle")
}
diff --git a/relay/helper/model_modifier.go b/relay/helper/model_modifier.go
new file mode 100644
index 000000000000..848f45709a69
--- /dev/null
+++ b/relay/helper/model_modifier.go
@@ -0,0 +1,278 @@
+package helper
+
+import (
+ "fmt"
+ "math"
+ "strconv"
+ "strings"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
+ "github.com/QuantumNous/new-api/relaykit/types"
+)
+
+type parsedModelModifiers struct {
+ base string
+ hasSyntax bool
+ intent reasoning.Intent
+ hasThinking bool
+ temperature *float64
+ topP *float64
+ hasTemperature bool
+ hasTopP bool
+ diagnostics []types.ConversionDiagnostic
+}
+
+const modelModifierExemptionHint = `If this segment is part of the real model name, add the model to the "Models that skip thinking suffix processing" setting (re: regex entries are supported)`
+
+func modelModifierClientError(message string) error {
+ return fmt.Errorf("%s. %s", message, modelModifierExemptionHint)
+}
+
+func parseExplicitModelModifiers(modelName string) (parsedModelModifiers, error) {
+ spec := reasoning.ParseModelModifiers(modelName)
+ parsed := parsedModelModifiers{base: spec.Base, hasSyntax: spec.HasModifiers()}
+ last := make(map[string]int, len(spec.Modifiers))
+
+ for index, modifier := range spec.Modifiers {
+ if _, duplicate := last[modifier.Key]; duplicate {
+ parsed.diagnostics = append(parsed.diagnostics, modelModifierDiagnostic(
+ "duplicate_model_modifier",
+ modifier.Key,
+ fmt.Sprintf("model modifier %q is repeated; the rightmost value is used", modifier.Key),
+ ))
+ }
+ last[modifier.Key] = index
+ }
+
+ for index, modifier := range spec.Modifiers {
+ if last[modifier.Key] != index {
+ continue
+ }
+ switch modifier.Key {
+ case "thinking":
+ intent, ok := reasoning.ParseThinkingModifier(modifier.Value)
+ if !ok {
+ return parsedModelModifiers{}, modelModifierClientError(
+ fmt.Sprintf("invalid thinking modifier value %q", modifier.Value),
+ )
+ }
+ if parsed.hasThinking && intent.Mode != reasoning.ModeDisabled && intent.BudgetTokens == nil {
+ parsed.intent.Mode = intent.Mode
+ parsed.intent.Source = reasoning.SourceSuffix
+ } else if parsed.hasThinking && intent.Mode != reasoning.ModeDisabled {
+ parsed.intent.Mode = intent.Mode
+ parsed.intent.BudgetTokens = intent.BudgetTokens
+ parsed.intent.BudgetSource = intent.BudgetSource
+ parsed.intent.Source = reasoning.SourceSuffix
+ } else {
+ parsed.intent = intent
+ }
+ parsed.hasThinking = true
+ case "effort":
+ effort, err := reasoning.ParseEffort(modifier.Value)
+ if err != nil || effort == "" {
+ return parsedModelModifiers{}, modelModifierClientError(
+ fmt.Sprintf("invalid effort modifier value %q: must be one of none/low/medium/high/xhigh/max", modifier.Value),
+ )
+ }
+ if effort == reasoning.EffortNone {
+ parsed.intent = reasoning.Intent{Mode: reasoning.ModeDisabled, Effort: reasoning.EffortNone, Source: reasoning.SourceSuffix}
+ } else {
+ if parsed.intent.Mode == reasoning.ModeUnset || parsed.intent.Mode == reasoning.ModeDisabled {
+ parsed.intent.Mode = reasoning.ModeEnabled
+ }
+ parsed.intent.Effort = effort
+ parsed.intent.Source = reasoning.SourceSuffix
+ }
+ parsed.hasThinking = true
+ case "temperature":
+ value, ok := parseFiniteFloat(modifier.Value)
+ if !ok {
+ return parsedModelModifiers{}, modelModifierClientError(
+ fmt.Sprintf("invalid temperature modifier value %q: must be a finite number", modifier.Value),
+ )
+ }
+ parsed.temperature = &value
+ parsed.hasTemperature = true
+ case "topp":
+ value, ok := parseFiniteFloat(modifier.Value)
+ if !ok {
+ return parsedModelModifiers{}, modelModifierClientError(
+ fmt.Sprintf("invalid topp modifier value %q: must be a finite number", modifier.Value),
+ )
+ }
+ parsed.topP = &value
+ parsed.hasTopP = true
+ default:
+ return parsedModelModifiers{}, modelModifierClientError(
+ fmt.Sprintf("unsupported model modifier %q", modifier.Key),
+ )
+ }
+ }
+
+ return parsed, nil
+}
+
+func parseFiniteFloat(raw string) (float64, bool) {
+ value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
+ if err != nil || math.IsNaN(value) || math.IsInf(value, 0) {
+ return 0, false
+ }
+ return value, true
+}
+
+func extractTemperature(req dto.Request) (float64, bool) {
+ switch request := req.(type) {
+ case *dto.GeneralOpenAIRequest:
+ if request != nil && request.Temperature != nil {
+ return *request.Temperature, true
+ }
+ case *dto.OpenAIResponsesRequest:
+ if request != nil && request.Temperature != nil {
+ return *request.Temperature, true
+ }
+ case *dto.ClaudeRequest:
+ if request != nil && request.Temperature != nil {
+ return *request.Temperature, true
+ }
+ case *dto.GeminiChatRequest:
+ if request != nil && request.GenerationConfig.Temperature != nil {
+ return *request.GenerationConfig.Temperature, true
+ }
+ }
+ return 0, false
+}
+
+func extractTopP(req dto.Request) (float64, bool) {
+ switch request := req.(type) {
+ case *dto.GeneralOpenAIRequest:
+ if request != nil && request.TopP != nil {
+ return *request.TopP, true
+ }
+ case *dto.OpenAIResponsesRequest:
+ if request != nil && request.TopP != nil {
+ return *request.TopP, true
+ }
+ case *dto.ClaudeRequest:
+ if request != nil && request.TopP != nil {
+ return *request.TopP, true
+ }
+ case *dto.GeminiChatRequest:
+ if request != nil && request.GenerationConfig.TopP != nil {
+ return *request.GenerationConfig.TopP, true
+ }
+ }
+ return 0, false
+}
+
+func modelModifierDiagnostic(code string, key string, message string) types.ConversionDiagnostic {
+ return types.ConversionDiagnostic{
+ Code: code,
+ Path: "model.@" + key,
+ Message: message,
+ Severity: types.ConversionDiagnosticWarning,
+ }
+}
+
+func applyModelControls(req dto.Request, parsed parsedModelModifiers) error {
+ if req == nil {
+ return nil
+ }
+
+ switch request := req.(type) {
+ case *dto.GeneralOpenAIRequest:
+ if parsed.hasTemperature {
+ request.Temperature = parsed.temperature
+ }
+ if parsed.hasTopP {
+ request.TopP = parsed.topP
+ }
+ if parsed.hasThinking {
+ request.ReasoningConversion = reasoning.StateFromIntent(parsed.intent)
+ reasoningConfig := make(map[string]any)
+ if len(request.Reasoning) > 0 {
+ if common.GetJsonType(request.Reasoning) != "object" {
+ return fmt.Errorf("OpenAI reasoning must be a JSON object")
+ }
+ if err := common.Unmarshal(request.Reasoning, &reasoningConfig); err != nil {
+ return fmt.Errorf("invalid OpenAI reasoning config: %w", err)
+ }
+ }
+ if parsed.intent.BudgetTokens != nil {
+ reasoningConfig["enabled"] = parsed.intent.Mode != reasoning.ModeDisabled
+ reasoningConfig["max_tokens"] = *parsed.intent.BudgetTokens
+ delete(reasoningConfig, "effort")
+ request.ReasoningEffort = ""
+ } else {
+ delete(reasoningConfig, "enabled")
+ delete(reasoningConfig, "effort")
+ delete(reasoningConfig, "max_tokens")
+ request.ReasoningEffort = ""
+ if parsed.intent.Effort != "" {
+ request.ReasoningEffort = string(reasoning.OpenAIEffort(parsed.intent.Effort))
+ }
+ }
+ if len(reasoningConfig) == 0 {
+ request.Reasoning = nil
+ } else {
+ encoded, err := common.Marshal(reasoningConfig)
+ if err != nil {
+ return err
+ }
+ request.Reasoning = encoded
+ }
+ }
+ case *dto.OpenAIResponsesRequest:
+ if parsed.hasTemperature {
+ request.Temperature = parsed.temperature
+ }
+ if parsed.hasTopP {
+ request.TopP = parsed.topP
+ }
+ if parsed.hasThinking {
+ request.ReasoningConversion = reasoning.StateFromIntent(parsed.intent)
+ if parsed.intent.Effort != "" {
+ if request.Reasoning == nil {
+ request.Reasoning = &dto.Reasoning{}
+ }
+ request.Reasoning.Effort = string(reasoning.OpenAIEffort(parsed.intent.Effort))
+ } else if request.Reasoning != nil && parsed.intent.BudgetTokens == nil {
+ request.Reasoning.Effort = ""
+ }
+ }
+ case *dto.ClaudeRequest:
+ if parsed.hasTemperature {
+ request.Temperature = parsed.temperature
+ }
+ if parsed.hasTopP {
+ request.TopP = parsed.topP
+ }
+ if parsed.hasThinking {
+ request.Thinking = nil
+ if len(request.OutputConfig) > 0 && common.GetJsonType(request.OutputConfig) == "object" {
+ var output map[string]any
+ if err := common.Unmarshal(request.OutputConfig, &output); err == nil {
+ delete(output, "effort")
+ encoded, err := common.Marshal(output)
+ if err != nil {
+ return err
+ }
+ request.OutputConfig = encoded
+ }
+ }
+ }
+ case *dto.GeminiChatRequest:
+ if parsed.hasTemperature {
+ request.GenerationConfig.Temperature = parsed.temperature
+ }
+ if parsed.hasTopP {
+ request.GenerationConfig.TopP = parsed.topP
+ }
+ if parsed.hasThinking {
+ request.GenerationConfig.ThinkingConfig = nil
+ }
+ }
+ return nil
+}
diff --git a/relay/helper/price.go b/relay/helper/price.go
index 1db88d816f12..a7b313b324ee 100644
--- a/relay/helper/price.go
+++ b/relay/helper/price.go
@@ -9,10 +9,12 @@ import (
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
+ hostreasoning "github.com/QuantumNous/new-api/setting/reasoning"
hosttypes "github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
@@ -71,6 +73,11 @@ func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) hostty
}
func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, meta *types.TokenCountMeta) (hosttypes.PriceData, error) {
+ if info != nil {
+ if matched := resolveBillingModelName(info.GetOriginModelName()); matched != "" && matched != info.OriginModelName {
+ info.BillingModelName = matched
+ }
+ }
billingModelName := info.GetBillingModelName()
modelPrice, usePrice := ratio_setting.GetModelPrice(billingModelName, false)
@@ -267,6 +274,50 @@ func HasModelBillingConfig(modelName string) bool {
return ok && strings.TrimSpace(expr) != ""
}
+// HasPriceOrRatioEntry reports whether name has a configured price, ratio, or
+// tiered billing-mode entry after a single wildcard normalization. Self-use
+// fallback does not count as a configured ratio.
+func HasPriceOrRatioEntry(name string) bool {
+ formatted := ratio_setting.FormatMatchingModelName(name)
+ if _, ok := ratio_setting.GetModelPrice(formatted, false); ok {
+ return true
+ }
+ if ratio_setting.HasConfiguredModelRatio(formatted) {
+ return true
+ }
+ return billing_setting.GetBillingMode(formatted) == billing_setting.BillingModeTieredExpr
+}
+
+func resolveBillingModelName(origin string) string {
+ var candidates []string
+ if !reasoning.ParseModelModifiers(origin).HasModifiers() {
+ candidates = append(candidates, origin)
+ }
+ candidates = append(candidates, hostreasoning.CanonicalBillingModelNames(origin)...)
+ base := hostreasoning.BaseModelName(origin)
+ candidates = append(candidates, base)
+
+ seen := make(map[string]struct{}, len(candidates))
+ matched := ""
+ for _, name := range candidates {
+ if name == "" {
+ continue
+ }
+ if _, ok := seen[name]; ok {
+ continue
+ }
+ seen[name] = struct{}{}
+ if HasPriceOrRatioEntry(name) {
+ matched = name
+ break
+ }
+ }
+ if matched == "" {
+ matched = base
+ }
+ return matched
+}
+
func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, billingModelName string, promptTokens int, meta *types.TokenCountMeta, groupRatioInfo hosttypes.GroupRatioInfo) (hosttypes.PriceData, error) {
exprStr, ok := billing_setting.GetBillingExpr(billingModelName)
if !ok {
diff --git a/relay/helper/price_test.go b/relay/helper/price_test.go
index 75f825f016fd..9a2b55112adb 100644
--- a/relay/helper/price_test.go
+++ b/relay/helper/price_test.go
@@ -277,10 +277,12 @@ func TestModelPriceHelperRequestBillingRatiosOnlyApplyToFixedPrice(t *testing.T)
require.Nil(t, info.Billing)
}
-// Pricing at controller/relay.go runs before ApplyReasoningModelSuffix.
-// Identity is GetBillingModelName() → OriginModelName (the suffixed client
-// name), matching main's info.OriginModelName lookup. Wildcard entries such
-// as gemini-2.5-flash-thinking-* depend on that unstripped origin form.
+// Pricing identity is resolved once in ModelPriceHelper via the candidate
+// ladder: raw name (only when it has no @ modifiers) → canonical
+// base@effort:E@thinking:S → base@thinking:S → base. Each level is looked up
+// after FormatMatchingModelName wildcard normalization. A hit on the raw
+// gemini-2.5-flash-thinking-* wildcard must keep the client origin as the
+// consume-log name.
func TestModelPriceHelperUsesSuffixedOriginLikeMain(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -313,6 +315,22 @@ func TestModelPriceHelperUsesSuffixedOriginLikeMain(t *testing.T) {
assert.Equal(t, "gemini-2.5-flash-thinking-8192", suffixed.GetBillingModelName())
assert.Equal(t, 0.075, suffixedPrice.ModelRatio)
+ geminiSettings := model_setting.GetGeminiSettings()
+ oldThinking := geminiSettings.ThinkingAdapterEnabled
+ geminiSettings.ThinkingAdapterEnabled = true
+ t.Cleanup(func() { geminiSettings.ThinkingAdapterEnabled = oldThinking })
+
+ adapterOn := &relaycommon.RelayInfo{
+ OriginModelName: "gemini-2.5-flash-thinking-8192",
+ UserGroup: "default",
+ UsingGroup: "default",
+ }
+ adapterOnPrice, err := ModelPriceHelper(ctx, adapterOn, 1000, &types.TokenCountMeta{})
+ require.NoError(t, err)
+ assert.Empty(t, adapterOn.BillingModelName)
+ assert.Equal(t, "gemini-2.5-flash-thinking-8192", adapterOn.GetBillingModelName())
+ assert.Equal(t, 0.075, adapterOnPrice.ModelRatio)
+
base := &relaycommon.RelayInfo{
OriginModelName: "gemini-2.5-flash",
UserGroup: "default",
@@ -325,6 +343,272 @@ func TestModelPriceHelperUsesSuffixedOriginLikeMain(t *testing.T) {
assert.Equal(t, 0.15, basePrice.ModelRatio)
}
+func TestModelPriceHelperHonorsCustomClaudeThinkingAlias(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ savedRatios := ratio_setting.ModelRatio2JSONString()
+ t.Cleanup(func() {
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios))
+ })
+ ratios := ratio_setting.GetModelRatioCopy()
+ ratios["claude-3-7-sonnet"] = 1.5
+ ratios["claude-3-7-sonnet-thinking"] = 3.0
+ ratioJSON, err := common.Marshal(ratios)
+ require.NoError(t, err)
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
+
+ oldSelfUse := operation_setting.SelfUseModeEnabled
+ operation_setting.SelfUseModeEnabled = false
+ t.Cleanup(func() { operation_setting.SelfUseModeEnabled = oldSelfUse })
+
+ claudeSettings := model_setting.GetClaudeSettings()
+ oldThinking := claudeSettings.ThinkingAdapterEnabled
+ claudeSettings.ThinkingAdapterEnabled = true
+ t.Cleanup(func() { claudeSettings.ThinkingAdapterEnabled = oldThinking })
+
+ ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+ ctx.Set("group", "default")
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "claude-3-7-sonnet-thinking",
+ UserGroup: "default",
+ UsingGroup: "default",
+ }
+ priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
+ require.NoError(t, err)
+ assert.Empty(t, info.BillingModelName)
+ assert.Equal(t, "claude-3-7-sonnet-thinking", info.GetBillingModelName())
+ assert.Equal(t, 3.0, priceData.ModelRatio)
+}
+
+func TestModelPriceHelperCanonicalBillingLadder(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ savedRatios := ratio_setting.ModelRatio2JSONString()
+ t.Cleanup(func() {
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios))
+ })
+ oldSelfUse := operation_setting.SelfUseModeEnabled
+ operation_setting.SelfUseModeEnabled = false
+ t.Cleanup(func() { operation_setting.SelfUseModeEnabled = oldSelfUse })
+
+ ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+ ctx.Set("group", "default")
+
+ t.Run("level2 full form", func(t *testing.T) {
+ ratios := ratio_setting.GetModelRatioCopy()
+ delete(ratios, "qwen3-max")
+ ratios["qwen3-max@effort:high@thinking:on"] = 4.0
+ ratios["qwen3-max@thinking:on"] = 3.0
+ ratioJSON, err := common.Marshal(ratios)
+ require.NoError(t, err)
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
+
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "qwen3-max@thinking:on@effort:high@temperature:0.2",
+ UserGroup: "default",
+ UsingGroup: "default",
+ }
+ priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
+ require.NoError(t, err)
+ assert.Equal(t, "qwen3-max@effort:high@thinking:on", info.BillingModelName)
+ assert.Equal(t, 4.0, priceData.ModelRatio)
+ })
+
+ t.Run("level3 thinking form shuffled budget", func(t *testing.T) {
+ ratios := ratio_setting.GetModelRatioCopy()
+ delete(ratios, "qwen3-max")
+ delete(ratios, "qwen3-max@effort:high@thinking:on")
+ ratios["qwen3-max@thinking:on"] = 3.0
+ ratioJSON, err := common.Marshal(ratios)
+ require.NoError(t, err)
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
+
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "qwen3-max@temperature:0.3@thinking:8192",
+ UserGroup: "default",
+ UsingGroup: "default",
+ }
+ priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
+ require.NoError(t, err)
+ assert.Equal(t, "qwen3-max@thinking:on", info.BillingModelName)
+ assert.Equal(t, 3.0, priceData.ModelRatio)
+ })
+
+ t.Run("level4 base fallback", func(t *testing.T) {
+ ratios := ratio_setting.GetModelRatioCopy()
+ delete(ratios, "qwen3-max@thinking:on")
+ delete(ratios, "qwen3-max@effort:high@thinking:on")
+ ratios["qwen3-max"] = 1.25
+ ratioJSON, err := common.Marshal(ratios)
+ require.NoError(t, err)
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
+
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "qwen3-max@thinking:off",
+ UserGroup: "default",
+ UsingGroup: "default",
+ }
+ priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
+ require.NoError(t, err)
+ assert.Equal(t, "qwen3-max", info.BillingModelName)
+ assert.Equal(t, 1.25, priceData.ModelRatio)
+ })
+
+ t.Run("thinking minus one bills as on", func(t *testing.T) {
+ ratios := ratio_setting.GetModelRatioCopy()
+ ratios["qwen3-max@thinking:on"] = 3.0
+ ratioJSON, err := common.Marshal(ratios)
+ require.NoError(t, err)
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
+
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "qwen3-max@thinking:-1",
+ UserGroup: "default",
+ UsingGroup: "default",
+ }
+ priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
+ require.NoError(t, err)
+ assert.Equal(t, "qwen3-max@thinking:on", info.BillingModelName)
+ assert.Equal(t, 3.0, priceData.ModelRatio)
+ })
+}
+
+func TestModelPriceHelperMigratesLegacyGeminiWildcardToCanonical(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ savedRatios := ratio_setting.ModelRatio2JSONString()
+ t.Cleanup(func() {
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios))
+ })
+ ratios := ratio_setting.GetModelRatioCopy()
+ delete(ratios, "gemini-2.5-flash-thinking-*")
+ ratios["gemini-2.5-flash"] = 0.15
+ ratios["gemini-2.5-flash@thinking:on"] = 0.09
+ ratioJSON, err := common.Marshal(ratios)
+ require.NoError(t, err)
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
+
+ oldSelfUse := operation_setting.SelfUseModeEnabled
+ operation_setting.SelfUseModeEnabled = false
+ t.Cleanup(func() { operation_setting.SelfUseModeEnabled = oldSelfUse })
+
+ geminiSettings := model_setting.GetGeminiSettings()
+ oldThinking := geminiSettings.ThinkingAdapterEnabled
+ geminiSettings.ThinkingAdapterEnabled = true
+ t.Cleanup(func() { geminiSettings.ThinkingAdapterEnabled = oldThinking })
+
+ ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+ ctx.Set("group", "default")
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "gemini-2.5-flash-thinking-8192",
+ UserGroup: "default",
+ UsingGroup: "default",
+ }
+ priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
+ require.NoError(t, err)
+ assert.Equal(t, "gemini-2.5-flash@thinking:on", info.BillingModelName)
+ assert.Equal(t, 0.09, priceData.ModelRatio)
+}
+
+func TestModelPriceHelperModifierNameFallsBackToBase(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ savedRatios := ratio_setting.ModelRatio2JSONString()
+ t.Cleanup(func() {
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios))
+ })
+ ratios := ratio_setting.GetModelRatioCopy()
+ ratios["qwen3.8-max"] = 2.0
+ ratioJSON, err := common.Marshal(ratios)
+ require.NoError(t, err)
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
+
+ oldSelfUse := operation_setting.SelfUseModeEnabled
+ operation_setting.SelfUseModeEnabled = false
+ t.Cleanup(func() { operation_setting.SelfUseModeEnabled = oldSelfUse })
+
+ ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+ ctx.Set("group", "default")
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "qwen3.8-max@thinking:on@temperature:0.2",
+ UserGroup: "default",
+ UsingGroup: "default",
+ }
+ priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
+ require.NoError(t, err)
+ assert.Equal(t, "qwen3.8-max", info.BillingModelName)
+ assert.Equal(t, 2.0, priceData.ModelRatio)
+}
+
+func TestModelPriceHelperExemptAtNameBillsVerbatim(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ settings := model_setting.GetGlobalSettings()
+ originalBlacklist := append([]string(nil), settings.ThinkingModelBlacklist...)
+ t.Cleanup(func() { settings.ThinkingModelBlacklist = originalBlacklist })
+ settings.ThinkingModelBlacklist = append(originalBlacklist, "re:.*@sha256:.*")
+
+ savedRatios := ratio_setting.ModelRatio2JSONString()
+ t.Cleanup(func() {
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios))
+ })
+ ratios := ratio_setting.GetModelRatioCopy()
+ ratios["opaque"] = 1.0
+ ratios["opaque@sha256:deadbeef"] = 7.0
+ ratioJSON, err := common.Marshal(ratios)
+ require.NoError(t, err)
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
+
+ oldSelfUse := operation_setting.SelfUseModeEnabled
+ operation_setting.SelfUseModeEnabled = false
+ t.Cleanup(func() { operation_setting.SelfUseModeEnabled = oldSelfUse })
+
+ ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+ ctx.Set("group", "default")
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "opaque@sha256:deadbeef",
+ UserGroup: "default",
+ UsingGroup: "default",
+ }
+ priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
+ require.NoError(t, err)
+ assert.Empty(t, info.BillingModelName)
+ assert.Equal(t, "opaque@sha256:deadbeef", info.GetBillingModelName())
+ assert.Equal(t, 7.0, priceData.ModelRatio)
+}
+
+func TestModelPriceHelperPreservesGpt51CodexMaxIdentity(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ savedRatios := ratio_setting.ModelRatio2JSONString()
+ t.Cleanup(func() {
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios))
+ })
+ ratios := ratio_setting.GetModelRatioCopy()
+ ratios["gpt-5.1-codex-max"] = 1.75
+ ratios["gpt-5.1-codex"] = 9.9
+ ratioJSON, err := common.Marshal(ratios)
+ require.NoError(t, err)
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
+
+ oldSelfUse := operation_setting.SelfUseModeEnabled
+ operation_setting.SelfUseModeEnabled = false
+ t.Cleanup(func() { operation_setting.SelfUseModeEnabled = oldSelfUse })
+
+ ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+ ctx.Set("group", "default")
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "gpt-5.1-codex-max",
+ UserGroup: "default",
+ UsingGroup: "default",
+ }
+ priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
+ require.NoError(t, err)
+ assert.Empty(t, info.BillingModelName)
+ assert.Equal(t, "gpt-5.1-codex-max", info.GetBillingModelName())
+ assert.Equal(t, 1.75, priceData.ModelRatio)
+}
+
func TestModelPriceHelperNativeGeminiNoThinkingDoesNotAliasBillingModel(t *testing.T) {
gin.SetMode(gin.TestMode)
diff --git a/relay/helper/reasoning_suffix.go b/relay/helper/reasoning_suffix.go
index b5c1ca4329ad..7b542cbc7e98 100644
--- a/relay/helper/reasoning_suffix.go
+++ b/relay/helper/reasoning_suffix.go
@@ -1,30 +1,33 @@
package helper
import (
- "strings"
+ "context"
+ "fmt"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
+ "github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/model_setting"
+ hostreasoning "github.com/QuantumNous/new-api/setting/reasoning"
+ "github.com/gin-gonic/gin"
)
// ApplyReasoningModelSuffix parses host-private reasoning suffixes from the
// origin and mapped model names, attaches the resulting intent to RelayInfo,
// and normalizes UpstreamModelName to the unsuffixed base. Optional outbound
// requests are the DeepCopy the handler will send upstream; they must be
-// synced here because info.Request is the original, not that copy. Conflict
-// between an explicit request field and a suffix is a client error.
-func ApplyReasoningModelSuffix(info *relaycommon.RelayInfo, outbound ...dto.Request) error {
+// synced here because info.Request is the original, not that copy. Explicit
+// model modifiers override request fields; mapped-model modifiers override
+// origin-model modifiers. Pass-through (global or channel) is a no-op so the
+// request body stays byte-identical. c is used only to correlate diagnostics.
+func ApplyReasoningModelSuffix(c *gin.Context, info *relaycommon.RelayInfo, outbound ...dto.Request) error {
if info == nil {
return nil
}
- passThrough := model_setting.GetGlobalSettings().PassThroughRequestEnabled
- if info.ChannelMeta != nil && info.ChannelSetting.PassThroughBodyEnabled {
- passThrough = true
- }
- if passThrough {
+ if model_setting.GetGlobalSettings().PassThroughRequestEnabled ||
+ info.ChannelMeta != nil && info.ChannelSetting.PassThroughBodyEnabled {
return nil
}
@@ -34,99 +37,179 @@ func ApplyReasoningModelSuffix(info *relaycommon.RelayInfo, outbound ...dto.Requ
if info.ChannelMeta != nil {
upstream = info.UpstreamModelName
}
- if opts.ShouldPreserveThinkingSuffix(origin) || opts.ShouldPreserveThinkingSuffix(upstream) {
- return nil
- }
-
- originBase, originIntent, originFound, err := parseHostModelSuffix(origin, opts)
- if err != nil {
- return reasoning.AsClientError(err)
- }
- upstreamBase, upstreamIntent, upstreamFound, err := parseHostModelSuffix(upstream, opts)
+ originParsed, err := parseRequestModelName(origin, opts)
if err != nil {
return reasoning.AsClientError(err)
}
-
- suffix := originIntent
- if originFound && upstreamFound {
- suffix, err = reasoning.MergeExplicitAndSuffix(originIntent, upstreamIntent, origin)
+ upstreamParsed := originParsed
+ if upstream != origin {
+ upstreamParsed, err = parseRequestModelName(upstream, opts)
if err != nil {
return reasoning.AsClientError(err)
}
- } else if upstreamFound {
- suffix = upstreamIntent
+ }
+ diagnostics := append([]types.ConversionDiagnostic(nil), originParsed.diagnostics...)
+ if upstream != origin {
+ diagnostics = append(diagnostics, upstreamParsed.diagnostics...)
}
- explicit, err := explicitIntentFromRequest(info.Request)
- if err != nil {
- return reasoning.AsClientError(err)
+ selected := originParsed
+ if upstream != origin {
+ selected, diagnostics = overlayMappedModelModifiers(selected, upstreamParsed, diagnostics)
}
- conflictModel := upstream
- if conflictModel == "" {
- conflictModel = origin
+
+ if selected.hasThinking {
+ explicit, err := explicitIntentFromRequest(info.Request)
+ if err != nil {
+ return reasoning.AsClientError(err)
+ }
+ diagnostics = append(diagnostics, modifierRequestOverrideDiagnostics(explicit, selected.intent)...)
+ if selected.intent.IncludeThoughts == nil {
+ selected.intent.IncludeThoughts = explicit.IncludeThoughts
+ }
+ info.ReasoningConversion = reasoning.StateFromIntent(selected.intent)
}
- if _, err = reasoning.MergeExplicitAndSuffix(explicit, suffix, conflictModel); err != nil {
- return reasoning.AsClientError(err)
+ if info.ChannelMeta != nil {
+ info.UpstreamModelName = selected.base
}
- if !suffix.IsEmpty() {
- info.ReasoningConversion = reasoning.StateFromIntent(suffix)
+ if selected.hasTemperature {
+ if current, exists := extractTemperature(info.Request); exists && *selected.temperature != current {
+ diagnostics = append(diagnostics, modelModifierDiagnostic(
+ "model_modifier_overrode_request",
+ "temperature",
+ fmt.Sprintf("model temperature modifier overrides request temperature %v", current),
+ ))
+ }
}
-
- if upstreamFound && info.ChannelMeta != nil {
- info.UpstreamModelName = upstreamBase
- } else if !info.IsModelMapped && originFound && info.ChannelMeta != nil {
- info.UpstreamModelName = originBase
+ if selected.hasTopP {
+ if current, exists := extractTopP(info.Request); exists && *selected.topP != current {
+ diagnostics = append(diagnostics, modelModifierDiagnostic(
+ "model_modifier_overrode_request",
+ "topp",
+ fmt.Sprintf("model topp modifier overrides request top_p %v", current),
+ ))
+ }
}
+
// Handlers DeepCopy before this helper; info.Request is the original.
// Sync every outbound copy the caller is about to send upstream.
for _, outbound := range outbound {
- if outbound != nil {
- outbound.SetModelName(info.UpstreamModelName)
+ if outbound == nil {
+ continue
+ }
+ if err := applyModelControls(outbound, selected); err != nil {
+ return reasoning.AsClientError(err)
}
+ outbound.SetModelName(info.UpstreamModelName)
}
if info.Request != nil {
info.Request.SetModelName(info.UpstreamModelName)
}
+ for i := range diagnostics {
+ diagnostics[i].From = info.RelayFormat
+ }
+ diagnosticContext := context.Background()
+ if c != nil {
+ diagnosticContext = c
+ }
+ info.RecordConversionDiagnostics(diagnosticContext, diagnostics)
return nil
}
-func parseHostModelSuffix(name string, opts *convmeta.Options) (string, reasoning.Intent, bool, error) {
- if name == "" {
- return name, reasoning.Intent{}, false, nil
+func parseRequestModelName(name string, opts *convmeta.Options) (parsedModelModifiers, error) {
+ if opts.ShouldPreserveThinkingSuffix(name) {
+ return parsedModelModifiers{base: name}, nil
}
- if strings.HasPrefix(name, "claude-") {
- return reasoning.ParseClaudeModelSuffix(name, opts.Claude.ThinkingAdapterEnabled)
+ parsed, err := parseExplicitModelModifiers(name)
+ if err != nil {
+ return parsedModelModifiers{}, err
}
- if strings.HasPrefix(name, "gemini-") {
- if !opts.Gemini.ThinkingAdapterEnabled {
- return name, reasoning.Intent{}, false, nil
- }
- return reasoning.ParseGeminiModelSuffix(name, true)
+ if opts.ShouldPreserveThinkingSuffix(parsed.base) {
+ return parsed, nil
}
- // deepseek-v4 effort tails are consumed by ParseDeepSeekV4ThinkingSuffix
- // in the DeepSeek adaptor; stripping them here drops THINKING+effort.
- if strings.HasPrefix(name, "deepseek-v4-") {
- return name, reasoning.Intent{}, false, nil
+ legacyBase, legacyIntent, legacyFound, err := parseHostModelSuffix(parsed.base, opts)
+ if err != nil {
+ return parsedModelModifiers{}, err
}
- effort, base := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(name, opts.PreserveEffortTail)
- if effort != "" {
- parsed, err := reasoning.ParseEffort(effort)
- if err != nil {
- return name, reasoning.Intent{}, false, err
+ parsed.base = legacyBase
+ if legacyFound && !parsed.hasThinking {
+ parsed.intent = legacyIntent
+ parsed.hasThinking = true
+ }
+ return parsed, nil
+}
+
+func overlayMappedModelModifiers(origin parsedModelModifiers, mapped parsedModelModifiers, diagnostics []types.ConversionDiagnostic) (parsedModelModifiers, []types.ConversionDiagnostic) {
+ origin.base = mapped.base
+ origin.hasSyntax = origin.hasSyntax || mapped.hasSyntax
+ origin.diagnostics = nil
+ if mapped.hasThinking {
+ if origin.hasThinking && !sameModifierIntent(origin.intent, mapped.intent) {
+ diagnostics = append(diagnostics, modelModifierDiagnostic(
+ "mapped_model_modifier_override",
+ "thinking",
+ "mapped-model thinking modifier overrides the origin-model modifier",
+ ))
+ }
+ origin.intent = mapped.intent
+ origin.hasThinking = true
+ }
+ if mapped.hasTemperature {
+ if origin.hasTemperature && *origin.temperature != *mapped.temperature {
+ diagnostics = append(diagnostics, modelModifierDiagnostic(
+ "mapped_model_modifier_override",
+ "temperature",
+ "mapped-model temperature modifier overrides the origin-model modifier",
+ ))
}
- mode := reasoning.ModeEnabled
- if parsed == reasoning.EffortNone {
- mode = reasoning.ModeDisabled
+ origin.temperature = mapped.temperature
+ origin.hasTemperature = true
+ }
+ if mapped.hasTopP {
+ if origin.hasTopP && *origin.topP != *mapped.topP {
+ diagnostics = append(diagnostics, modelModifierDiagnostic(
+ "mapped_model_modifier_override",
+ "topp",
+ "mapped-model topp modifier overrides the origin-model modifier",
+ ))
}
- return base, reasoning.Intent{Mode: mode, Effort: parsed, Source: reasoning.SourceSuffix}, true, nil
+ origin.topP = mapped.topP
+ origin.hasTopP = true
}
- // Generic -thinking trim is OpenRouter-only. Volcengine/DeepSeek adaptors
- // read the suffix off UpstreamModelName themselves.
- if opts != nil && opts.OpenRouterDialect && strings.HasSuffix(name, "-thinking") {
- return strings.TrimSuffix(name, "-thinking"), reasoning.Intent{Mode: reasoning.ModeEnabled, Source: reasoning.SourceSuffix}, true, nil
+ return origin, diagnostics
+}
+
+func modifierRequestOverrideDiagnostics(explicit reasoning.Intent, modifier reasoning.Intent) []types.ConversionDiagnostic {
+ if !explicit.HasStrength() || sameModifierIntent(explicit, modifier) {
+ return nil
+ }
+ return []types.ConversionDiagnostic{modelModifierDiagnostic(
+ "model_modifier_overrode_request",
+ "thinking",
+ "model thinking modifier overrides structured request reasoning fields",
+ )}
+}
+
+func sameModifierIntent(left reasoning.Intent, right reasoning.Intent) bool {
+ if left.Mode != right.Mode || left.Effort != right.Effort {
+ return false
+ }
+ if left.BudgetTokens == nil || right.BudgetTokens == nil {
+ return left.BudgetTokens == nil && right.BudgetTokens == nil
+ }
+ return *left.BudgetTokens == *right.BudgetTokens
+}
+
+func parseHostModelSuffix(name string, opts *convmeta.Options) (string, reasoning.Intent, bool, error) {
+ if name == "" {
+ return name, reasoning.Intent{}, false, nil
}
- return name, reasoning.Intent{}, false, nil
+ return hostreasoning.ParseLegacyModelSuffix(
+ name,
+ opts.Claude.ThinkingAdapterEnabled,
+ opts.Gemini.ThinkingAdapterEnabled,
+ )
}
func explicitIntentFromRequest(req dto.Request) (reasoning.Intent, error) {
diff --git a/relay/helper/reasoning_suffix_test.go b/relay/helper/reasoning_suffix_test.go
index e07b8873b096..592d26ada644 100644
--- a/relay/helper/reasoning_suffix_test.go
+++ b/relay/helper/reasoning_suffix_test.go
@@ -2,18 +2,28 @@ package helper
import (
"net/http/httptest"
+ "strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
+ kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
+ "github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/model_setting"
+ hostreasoning "github.com/QuantumNous/new-api/setting/reasoning"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
)
+func mustApplyReasoningModelSuffix(t *testing.T, info *relaycommon.RelayInfo, outbound ...dto.Request) {
+ t.Helper()
+ require.NoError(t, ApplyReasoningModelSuffix(nil, info, outbound...))
+}
+
func TestApplyReasoningModelSuffixTrimsUpstreamAndAttachesState(t *testing.T) {
info := &relaycommon.RelayInfo{
OriginModelName: "claude-3-7-sonnet-thinking",
@@ -22,7 +32,7 @@ func TestApplyReasoningModelSuffixTrimsUpstreamAndAttachesState(t *testing.T) {
},
}
- require.NoError(t, ApplyReasoningModelSuffix(info))
+ mustApplyReasoningModelSuffix(t, info)
assert.Equal(t, "claude-3-7-sonnet", info.UpstreamModelName)
require.NotNil(t, info.ReasoningConversion)
assert.Equal(t, "enabled", info.ReasoningConversion.Mode)
@@ -36,13 +46,13 @@ func TestApplyReasoningModelSuffixRetryKeepsEquivalentState(t *testing.T) {
},
}
- require.NoError(t, ApplyReasoningModelSuffix(info))
+ mustApplyReasoningModelSuffix(t, info)
require.NotNil(t, info.ReasoningConversion)
firstMode := info.ReasoningConversion.Mode
firstEffort := info.ReasoningConversion.Effort
info.UpstreamModelName = info.OriginModelName
- require.NoError(t, ApplyReasoningModelSuffix(info))
+ mustApplyReasoningModelSuffix(t, info)
require.NotNil(t, info.ReasoningConversion)
assert.Equal(t, firstMode, info.ReasoningConversion.Mode)
assert.Equal(t, firstEffort, info.ReasoningConversion.Effort)
@@ -60,7 +70,7 @@ func TestApplyReasoningModelSuffixRetryClearsStateWhenNewChannelHasNoSuffix(t *t
IsModelMapped: true,
},
}
- require.NoError(t, ApplyReasoningModelSuffix(info))
+ mustApplyReasoningModelSuffix(t, info)
require.NotNil(t, info.ReasoningState())
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
@@ -70,7 +80,7 @@ func TestApplyReasoningModelSuffixRetryClearsStateWhenNewChannelHasNoSuffix(t *t
info.InitChannelMeta(ctx)
assert.Nil(t, info.ReasoningState())
- require.NoError(t, ApplyReasoningModelSuffix(info))
+ mustApplyReasoningModelSuffix(t, info)
assert.Nil(t, info.ReasoningState())
}
@@ -87,7 +97,7 @@ func TestApplyReasoningModelSuffixPassThroughDoesNotTrim(t *testing.T) {
},
}
- require.NoError(t, ApplyReasoningModelSuffix(info))
+ mustApplyReasoningModelSuffix(t, info)
assert.Equal(t, "claude-3-7-sonnet-thinking", info.UpstreamModelName)
assert.Nil(t, info.ReasoningConversion)
}
@@ -105,12 +115,12 @@ func TestApplyReasoningModelSuffixBlacklistDoesNotTrim(t *testing.T) {
},
}
- require.NoError(t, ApplyReasoningModelSuffix(info))
+ mustApplyReasoningModelSuffix(t, info)
assert.Equal(t, "claude-3-7-sonnet-thinking", info.UpstreamModelName)
assert.Nil(t, info.ReasoningConversion)
}
-func TestApplyReasoningModelSuffixRejectsExplicitSuffixConflict(t *testing.T) {
+func TestApplyReasoningModelSuffixModifierOverridesExplicitConflict(t *testing.T) {
info := &relaycommon.RelayInfo{
OriginModelName: "claude-3-7-sonnet-thinking",
Request: &dto.ClaudeRequest{
@@ -122,8 +132,13 @@ func TestApplyReasoningModelSuffixRejectsExplicitSuffixConflict(t *testing.T) {
},
}
- err := ApplyReasoningModelSuffix(info)
- require.Error(t, err)
+ mustApplyReasoningModelSuffix(t, info, info.Request)
+ require.NotNil(t, info.ReasoningConversion)
+ assert.Equal(t, "enabled", info.ReasoningConversion.Mode)
+ assert.Nil(t, info.Request.(*dto.ClaudeRequest).Thinking)
+ diagnostics := info.ConversionDiagnostics()
+ require.NotEmpty(t, diagnostics)
+ assert.Equal(t, "model_modifier_overrode_request", diagnostics[0].Code)
}
func TestApplyReasoningModelSuffixGeminiNoThinkingWhenAdapterEnabled(t *testing.T) {
@@ -139,7 +154,7 @@ func TestApplyReasoningModelSuffixGeminiNoThinkingWhenAdapterEnabled(t *testing.
},
}
- require.NoError(t, ApplyReasoningModelSuffix(info))
+ mustApplyReasoningModelSuffix(t, info)
assert.Equal(t, "gemini-2.5-flash", info.UpstreamModelName)
require.NotNil(t, info.ReasoningConversion)
assert.Equal(t, "disabled", info.ReasoningConversion.Mode)
@@ -154,7 +169,7 @@ func TestApplyReasoningModelSuffixPreservesEffortTailModelID(t *testing.T) {
},
}
- require.NoError(t, ApplyReasoningModelSuffix(info))
+ mustApplyReasoningModelSuffix(t, info)
assert.Equal(t, "qwen-max", info.UpstreamModelName)
assert.Nil(t, info.ReasoningConversion)
}
@@ -168,7 +183,7 @@ func TestApplyReasoningModelSuffixLeavesDeepSeekV4SuffixForAdaptor(t *testing.T)
},
}
- require.NoError(t, ApplyReasoningModelSuffix(info))
+ mustApplyReasoningModelSuffix(t, info)
assert.Equal(t, "deepseek-v4-chat-max", info.UpstreamModelName)
assert.Nil(t, info.ReasoningConversion)
}
@@ -182,7 +197,7 @@ func TestApplyReasoningModelSuffixLeavesVolcengineDeepSeekThinkingForAdaptor(t *
},
}
- require.NoError(t, ApplyReasoningModelSuffix(info))
+ mustApplyReasoningModelSuffix(t, info)
assert.Equal(t, "deepseek-r1-thinking", info.UpstreamModelName)
assert.Nil(t, info.ReasoningConversion)
}
@@ -196,14 +211,14 @@ func TestApplyReasoningModelSuffixStillParsesOpenAIEffortTail(t *testing.T) {
},
}
- require.NoError(t, ApplyReasoningModelSuffix(info))
+ mustApplyReasoningModelSuffix(t, info)
assert.Equal(t, "gpt-5.1", info.UpstreamModelName)
require.NotNil(t, info.ReasoningConversion)
assert.Equal(t, "enabled", info.ReasoningConversion.Mode)
assert.Equal(t, "high", info.ReasoningConversion.Effort)
}
-func TestApplyReasoningModelSuffixTrimsOpenRouterThinkingOnly(t *testing.T) {
+func TestApplyReasoningModelSuffixLeavesUnknownOpenRouterThinkingModel(t *testing.T) {
openRouter := &relaycommon.RelayInfo{
OriginModelName: "some-model-thinking",
ChannelMeta: &relaycommon.ChannelMeta{
@@ -211,8 +226,363 @@ func TestApplyReasoningModelSuffixTrimsOpenRouterThinkingOnly(t *testing.T) {
UpstreamModelName: "some-model-thinking",
},
}
- require.NoError(t, ApplyReasoningModelSuffix(openRouter))
- assert.Equal(t, "some-model", openRouter.UpstreamModelName)
- require.NotNil(t, openRouter.ReasoningConversion)
- assert.Equal(t, "enabled", openRouter.ReasoningConversion.Mode)
+ mustApplyReasoningModelSuffix(t, openRouter)
+ assert.Equal(t, "some-model-thinking", openRouter.UpstreamModelName)
+ assert.Nil(t, openRouter.ReasoningConversion)
+}
+
+func TestApplyReasoningModelSuffixLeavesVersionedQwenMaxUntouched(t *testing.T) {
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "qwen3.8-max",
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: "qwen3.8-max",
+ },
+ }
+
+ mustApplyReasoningModelSuffix(t, info)
+ assert.Equal(t, "qwen3.8-max", info.UpstreamModelName)
+ assert.Empty(t, info.BillingModelName)
+ assert.Nil(t, info.ReasoningConversion)
+}
+
+func TestApplyReasoningModelSuffixTreatsCloudflareAtAsModelName(t *testing.T) {
+ const model = "@cf/meta/llama-3.1-8b-instruct"
+ info := &relaycommon.RelayInfo{
+ OriginModelName: model,
+ ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: model},
+ }
+
+ mustApplyReasoningModelSuffix(t, info)
+ assert.Equal(t, model, info.UpstreamModelName)
+ assert.Nil(t, info.ReasoningConversion)
+}
+
+func TestApplyReasoningModelSuffixAppliesExplicitModifierChain(t *testing.T) {
+ temperature := 0.9
+ topP := 1.0
+ request := &dto.GeneralOpenAIRequest{
+ Model: "qwen3.8-max@thinking:on@effort:high@temperature:0.2@topp:0.8",
+ ReasoningEffort: "low",
+ Temperature: &temperature,
+ TopP: &topP,
+ }
+ info := &relaycommon.RelayInfo{
+ OriginModelName: request.Model,
+ Request: request,
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: request.Model,
+ },
+ }
+
+ mustApplyReasoningModelSuffix(t, info, request)
+ assert.Equal(t, "qwen3.8-max", info.UpstreamModelName)
+ assert.Empty(t, info.BillingModelName)
+ assert.Equal(t, "qwen3.8-max", request.Model)
+ assert.Equal(t, 0.2, *request.Temperature)
+ assert.Equal(t, 0.8, *request.TopP)
+ assert.Equal(t, "high", request.ReasoningEffort)
+ require.NotNil(t, info.ReasoningConversion)
+ assert.Equal(t, "enabled", info.ReasoningConversion.Mode)
+ assert.Equal(t, "high", info.ReasoningConversion.Effort)
+ assert.Contains(t, info.ConversionDiagnostics(), types.ConversionDiagnostic{
+ Code: "model_modifier_overrode_request",
+ Path: "model.@temperature",
+ Message: "model temperature modifier overrides request temperature 0.9",
+ Severity: types.ConversionDiagnosticWarning,
+ })
+}
+
+func TestApplyReasoningModelSuffixRejectsUnknownModifierKey(t *testing.T) {
+ request := &dto.GeneralOpenAIRequest{Model: "m@thinkin:on"}
+ info := &relaycommon.RelayInfo{
+ OriginModelName: request.Model,
+ Request: request,
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: request.Model,
+ },
+ }
+
+ err := ApplyReasoningModelSuffix(nil, info, request)
+ require.Error(t, err)
+ assert.True(t, kitreasoning.IsClientError(err))
+ assert.Contains(t, err.Error(), `unsupported model modifier "thinkin"`)
+ assert.Contains(t, err.Error(), "Models that skip thinking suffix processing")
+ assert.Contains(t, err.Error(), "re:")
+ assert.Equal(t, "m@thinkin:on", info.UpstreamModelName)
+ assert.Nil(t, info.ReasoningConversion)
+}
+
+func TestApplyReasoningModelSuffixThinkingOnOnly(t *testing.T) {
+ request := &dto.GeneralOpenAIRequest{Model: "qwen3.8-max@thinking:on"}
+ info := &relaycommon.RelayInfo{
+ OriginModelName: request.Model,
+ Request: request,
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: request.Model,
+ },
+ }
+
+ mustApplyReasoningModelSuffix(t, info, request)
+ assert.Equal(t, "qwen3.8-max", info.UpstreamModelName)
+ assert.Equal(t, "", request.ReasoningEffort)
+ require.NotNil(t, info.ReasoningConversion)
+ assert.Equal(t, "enabled", info.ReasoningConversion.Mode)
+ assert.Equal(t, "", info.ReasoningConversion.Effort)
+}
+
+func TestApplyReasoningModelSuffixThinkingOff(t *testing.T) {
+ request := &dto.GeneralOpenAIRequest{Model: "qwen3.8-max@thinking:off"}
+ info := &relaycommon.RelayInfo{
+ OriginModelName: request.Model,
+ Request: request,
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: request.Model,
+ },
+ }
+
+ mustApplyReasoningModelSuffix(t, info, request)
+ assert.Equal(t, "none", request.ReasoningEffort)
+ require.NotNil(t, info.ReasoningConversion)
+ assert.Equal(t, "disabled", info.ReasoningConversion.Mode)
+ assert.Equal(t, "none", info.ReasoningConversion.Effort)
+}
+
+func TestApplyReasoningModelSuffixThinkingLegacyValuesRejected(t *testing.T) {
+ request := &dto.GeneralOpenAIRequest{Model: "qwen3.8-max@thinking:enabled"}
+ info := &relaycommon.RelayInfo{
+ OriginModelName: request.Model,
+ Request: request,
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: request.Model,
+ },
+ }
+
+ err := ApplyReasoningModelSuffix(nil, info, request)
+ require.Error(t, err)
+ assert.True(t, kitreasoning.IsClientError(err))
+ assert.Contains(t, err.Error(), `invalid thinking modifier value "enabled"`)
+ assert.Contains(t, err.Error(), "Models that skip thinking suffix processing")
+ assert.Equal(t, "qwen3.8-max@thinking:enabled", info.UpstreamModelName)
+ assert.Equal(t, "qwen3.8-max@thinking:enabled", request.Model)
+ assert.Nil(t, info.ReasoningConversion)
+ assert.Empty(t, info.ConversionDiagnostics())
+}
+
+func TestApplyReasoningModelSuffixDuplicateModifierLastWins(t *testing.T) {
+ request := &dto.GeneralOpenAIRequest{Model: "qwen3.8-max@thinking:on@thinking:off"}
+ info := &relaycommon.RelayInfo{
+ OriginModelName: request.Model,
+ Request: request,
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: request.Model,
+ },
+ }
+
+ mustApplyReasoningModelSuffix(t, info, request)
+ assert.Equal(t, "qwen3.8-max", info.UpstreamModelName)
+ require.NotNil(t, info.ReasoningConversion)
+ assert.Equal(t, "disabled", info.ReasoningConversion.Mode)
+ assert.Contains(t, info.ConversionDiagnostics(), types.ConversionDiagnostic{
+ Code: "duplicate_model_modifier",
+ Path: "model.@thinking",
+ Message: "model modifier \"thinking\" is repeated; the rightmost value is used",
+ Severity: types.ConversionDiagnosticWarning,
+ })
+}
+
+func TestApplyReasoningModelSuffixExactExemptionKeepsOpaqueName(t *testing.T) {
+ const model = "opaque@sha256:abc"
+ settings := model_setting.GetGlobalSettings()
+ original := append([]string(nil), settings.ThinkingModelBlacklist...)
+ t.Cleanup(func() { settings.ThinkingModelBlacklist = original })
+ settings.ThinkingModelBlacklist = append(original, model)
+
+ request := &dto.GeneralOpenAIRequest{Model: model}
+ info := &relaycommon.RelayInfo{
+ OriginModelName: model,
+ Request: request,
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: model,
+ },
+ }
+
+ mustApplyReasoningModelSuffix(t, info, request)
+ assert.Equal(t, model, info.UpstreamModelName)
+ assert.Equal(t, model, request.Model)
+ assert.Nil(t, info.ReasoningConversion)
+ assert.Empty(t, info.ConversionDiagnostics())
+}
+
+func TestApplyReasoningModelSuffixRegexExemptionKeepsOpaqueName(t *testing.T) {
+ const model = "m@sha256:abc"
+ settings := model_setting.GetGlobalSettings()
+ original := append([]string(nil), settings.ThinkingModelBlacklist...)
+ t.Cleanup(func() { settings.ThinkingModelBlacklist = original })
+ settings.ThinkingModelBlacklist = append(original, "re:.*@sha256:.*")
+
+ request := &dto.GeneralOpenAIRequest{Model: model}
+ info := &relaycommon.RelayInfo{
+ OriginModelName: model,
+ Request: request,
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: model,
+ },
+ }
+
+ mustApplyReasoningModelSuffix(t, info, request)
+ assert.Equal(t, model, info.UpstreamModelName)
+ assert.Equal(t, model, request.Model)
+ assert.Nil(t, info.ReasoningConversion)
+ assert.Empty(t, info.ConversionDiagnostics())
+}
+
+func TestApplyReasoningModelSuffixEffortNoneDisables(t *testing.T) {
+ request := &dto.GeneralOpenAIRequest{Model: "qwen3.8-max@effort:none"}
+ info := &relaycommon.RelayInfo{
+ OriginModelName: request.Model,
+ Request: request,
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: request.Model,
+ },
+ }
+
+ mustApplyReasoningModelSuffix(t, info, request)
+ assert.Equal(t, "none", request.ReasoningEffort)
+ require.NotNil(t, info.ReasoningConversion)
+ assert.Equal(t, "disabled", info.ReasoningConversion.Mode)
+ assert.Equal(t, "none", info.ReasoningConversion.Effort)
+}
+
+func TestApplyReasoningModelSuffixPassThroughKeepsModifierBodyVerbatim(t *testing.T) {
+ settings := model_setting.GetGlobalSettings()
+ original := settings.PassThroughRequestEnabled
+ t.Cleanup(func() { settings.PassThroughRequestEnabled = original })
+ settings.PassThroughRequestEnabled = true
+
+ gin.SetMode(gin.TestMode)
+ const model = "qwen3.8-max@thinking:on@effort:high@temperature:0.2@topp:0.8"
+ body := `{"model":"` + model + `","messages":[],"vendor_extension":{"keep":true}}`
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+ request := &dto.GeneralOpenAIRequest{Model: model}
+ info := &relaycommon.RelayInfo{
+ OriginModelName: model,
+ Request: request,
+ RelayFormat: types.RelayFormatOpenAI,
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: model,
+ },
+ }
+
+ require.NoError(t, ApplyReasoningModelSuffix(c, info, request))
+ assert.Equal(t, model, info.UpstreamModelName)
+ assert.Equal(t, model, request.Model)
+ assert.Nil(t, info.ReasoningConversion)
+ assert.Empty(t, info.ConversionDiagnostics())
+ storage, err := common.GetBodyStorage(c)
+ require.NoError(t, err)
+ got, err := storage.Bytes()
+ require.NoError(t, err)
+ assert.Equal(t, body, string(got))
+}
+
+func TestApplyReasoningModelSuffixChannelPassThroughKeepsModifierBodyVerbatim(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ const model = "qwen3.8-max@thinking:on"
+ body := `{"model":"` + model + `","messages":[]}`
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+ request := &dto.GeneralOpenAIRequest{Model: model}
+ info := &relaycommon.RelayInfo{
+ OriginModelName: model,
+ Request: request,
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: model,
+ ChannelSetting: dto.ChannelSettings{PassThroughBodyEnabled: true},
+ },
+ }
+
+ require.NoError(t, ApplyReasoningModelSuffix(c, info, request))
+ assert.Equal(t, model, info.UpstreamModelName)
+ assert.Nil(t, info.ReasoningConversion)
+ storage, err := common.GetBodyStorage(c)
+ require.NoError(t, err)
+ got, err := storage.Bytes()
+ require.NoError(t, err)
+ assert.Equal(t, body, string(got))
+}
+
+func TestApplyReasoningModelSuffixPassThroughAllowsUnknownModifier(t *testing.T) {
+ settings := model_setting.GetGlobalSettings()
+ original := settings.PassThroughRequestEnabled
+ t.Cleanup(func() { settings.PassThroughRequestEnabled = original })
+ settings.PassThroughRequestEnabled = true
+
+ const model = "m@sha256:abc"
+ request := &dto.GeneralOpenAIRequest{Model: model}
+ info := &relaycommon.RelayInfo{
+ OriginModelName: model,
+ Request: request,
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: model,
+ },
+ }
+
+ mustApplyReasoningModelSuffix(t, info, request)
+ assert.Equal(t, model, info.UpstreamModelName)
+ assert.Nil(t, info.ReasoningConversion)
+}
+
+func TestApplyReasoningModelSuffixAppliesModifiersWhenPassThroughOff(t *testing.T) {
+ request := &dto.GeneralOpenAIRequest{Model: "qwen3.8-max@thinking:on@effort:high"}
+ info := &relaycommon.RelayInfo{
+ OriginModelName: request.Model,
+ Request: request,
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: request.Model,
+ },
+ }
+
+ mustApplyReasoningModelSuffix(t, info, request)
+ assert.Equal(t, "qwen3.8-max", info.UpstreamModelName)
+ assert.Equal(t, "high", request.ReasoningEffort)
+ require.NotNil(t, info.ReasoningConversion)
+ assert.Equal(t, "enabled", info.ReasoningConversion.Mode)
+}
+
+func TestApplyReasoningModelSuffixThinkingMinusOnePassthrough(t *testing.T) {
+ request := &dto.GeneralOpenAIRequest{Model: "m@thinking:-1"}
+ info := &relaycommon.RelayInfo{
+ OriginModelName: request.Model,
+ Request: request,
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: request.Model,
+ },
+ }
+
+ require.NotPanics(t, func() {
+ mustApplyReasoningModelSuffix(t, info, request)
+ })
+ assert.Equal(t, "m", info.UpstreamModelName)
+ require.NotNil(t, info.ReasoningConversion)
+ require.NotNil(t, info.ReasoningConversion.BudgetTokens)
+ assert.Equal(t, -1, *info.ReasoningConversion.BudgetTokens)
+ require.NotEmpty(t, request.Reasoning)
+ assert.Equal(t, float64(-1), gjson.GetBytes(request.Reasoning, "max_tokens").Num)
+}
+
+func TestApplyReasoningModelSuffixPreservesGpt51CodexMax(t *testing.T) {
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "gpt-5.1-codex-max",
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: "gpt-5.1-codex-max",
+ },
+ }
+
+ mustApplyReasoningModelSuffix(t, info)
+ assert.Equal(t, "gpt-5.1-codex-max", info.UpstreamModelName)
+ assert.Nil(t, info.ReasoningConversion)
+ assert.Equal(t, "gpt-5.1-codex-max", hostreasoning.BaseModelName("gpt-5.1-codex-max"))
}
diff --git a/relay/responses_handler.go b/relay/responses_handler.go
index 13eeeb8f000c..698f6eef66d6 100644
--- a/relay/responses_handler.go
+++ b/relay/responses_handler.go
@@ -70,7 +70,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
- if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
+ if err := helper.ApplyReasoningModelSuffix(c, info, request); err != nil {
return newConvertRequestFailedError(c, info, err)
}
diff --git a/relaykit/relayconvert/internal/convdiag/collector.go b/relaykit/relayconvert/internal/convdiag/collector.go
new file mode 100644
index 000000000000..73c29d9850b2
--- /dev/null
+++ b/relaykit/relayconvert/internal/convdiag/collector.go
@@ -0,0 +1,56 @@
+package convdiag
+
+import (
+ "context"
+ "reflect"
+
+ "github.com/QuantumNous/new-api/relaykit/types"
+)
+
+type collectorKey struct{}
+
+type Collector struct {
+ diagnostics []types.ConversionDiagnostic
+}
+
+func WithCollector(ctx context.Context) (context.Context, *Collector) {
+ if isNilContext(ctx) {
+ ctx = context.Background()
+ }
+ if collector, _ := ctx.Value(collectorKey{}).(*Collector); collector != nil {
+ return ctx, collector
+ }
+ collector := &Collector{}
+ return context.WithValue(ctx, collectorKey{}, collector), collector
+}
+
+func Add(ctx context.Context, diagnostics ...types.ConversionDiagnostic) {
+ if isNilContext(ctx) || len(diagnostics) == 0 {
+ return
+ }
+ collector, _ := ctx.Value(collectorKey{}).(*Collector)
+ if collector == nil {
+ return
+ }
+ collector.diagnostics = append(collector.diagnostics, diagnostics...)
+}
+
+func isNilContext(ctx context.Context) bool {
+ if ctx == nil {
+ return true
+ }
+ value := reflect.ValueOf(ctx)
+ switch value.Kind() {
+ case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
+ return value.IsNil()
+ default:
+ return false
+ }
+}
+
+func (c *Collector) Diagnostics() []types.ConversionDiagnostic {
+ if c == nil || len(c.diagnostics) == 0 {
+ return nil
+ }
+ return append([]types.ConversionDiagnostic(nil), c.diagnostics...)
+}
diff --git a/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req.go b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req.go
index f071bc0eba1f..4f0330b29a01 100644
--- a/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req.go
+++ b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req.go
@@ -97,7 +97,7 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
if err != nil {
return nil, reasoning.AsClientError(err)
}
- if err := sharedclaude.ApplyReasoning(&claudeRequest, info, sourceReasoning); err != nil {
+ if err := sharedclaude.ApplyReasoning(c, &claudeRequest, info, sourceReasoning, true); err != nil {
return nil, reasoning.AsClientError(err)
}
if claudeRequest.MaxTokens == nil {
diff --git a/relaykit/relayconvert/internal/oai_responses/to_claude_messages_req.go b/relaykit/relayconvert/internal/oai_responses/to_claude_messages_req.go
index a9116a0f2199..6c041d98774d 100644
--- a/relaykit/relayconvert/internal/oai_responses/to_claude_messages_req.go
+++ b/relaykit/relayconvert/internal/oai_responses/to_claude_messages_req.go
@@ -60,7 +60,7 @@ func OpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Met
if err != nil {
return nil, reasoning.AsClientError(err)
}
- if err := sharedclaude.ApplyReasoning(claudeRequest, info, sourceReasoning); err != nil {
+ if err := sharedclaude.ApplyReasoning(c, claudeRequest, info, sourceReasoning, true); err != nil {
return nil, reasoning.AsClientError(err)
}
if claudeRequest.MaxTokens == nil {
diff --git a/relaykit/relayconvert/internal/shared/claude/reasoning.go b/relaykit/relayconvert/internal/shared/claude/reasoning.go
index 054fda404c0f..e792d43515ef 100644
--- a/relaykit/relayconvert/internal/shared/claude/reasoning.go
+++ b/relaykit/relayconvert/internal/shared/claude/reasoning.go
@@ -1,29 +1,23 @@
package claude
import (
+ "context"
"fmt"
"math"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
+ "github.com/QuantumNous/new-api/relaykit/types"
)
-func ApplyReasoning(req *dto.ClaudeRequest, info convmeta.Meta, source reasoning.Intent) error {
+func ApplyReasoning(ctx context.Context, req *dto.ClaudeRequest, info convmeta.Meta, source reasoning.Intent, crossProtocol bool) error {
if req == nil {
return nil
}
- native, err := reasoning.FromClaude(req)
- if err != nil {
- return err
- }
- explicit, err := reasoning.MergeExplicit(native, source, req.Model)
- if err != nil {
- return err
- }
-
opts := convmeta.OptionsOf(info)
baseModel := req.Model
capabilityModel := baseModel
@@ -35,6 +29,32 @@ func ApplyReasoning(req *dto.ClaudeRequest, info convmeta.Meta, source reasoning
if preserveSuffix {
suffix = reasoning.Intent{}
}
+ // A native Claude request without a host modifier is already in the target
+ // protocol, including Claude-compatible proxies that keep native controls
+ // instead of applying Anthropic model rules. Read portable effort for
+ // accounting metadata, but do not run the capability renderer or rewrite
+ // provider-native controls.
+ if !crossProtocol && source.IsEmpty() && suffix.IsEmpty() {
+ native, err := reasoning.FromClaude(req)
+ if err != nil {
+ return err
+ }
+ if info != nil {
+ if effort := reasoning.EffectiveEffort(native); effort != "" {
+ info.SetReasoningEffort(string(effort))
+ }
+ }
+ return nil
+ }
+
+ native, err := reasoning.FromClaude(req)
+ if err != nil {
+ return err
+ }
+ explicit, err := reasoning.MergeExplicit(native, source, req.Model)
+ if err != nil {
+ return err
+ }
if info != nil && !reasoning.IsKnownClaudeModel(capabilityModel) && reasoning.IsKnownClaudeModel(info.GetOriginModelName()) {
capabilityModel = info.GetOriginModelName()
}
@@ -43,18 +63,6 @@ func ApplyReasoning(req *dto.ClaudeRequest, info convmeta.Meta, source reasoning
return err
}
knownClaudeModel := reasoning.IsKnownClaudeModel(capabilityModel)
- if source.IsEmpty() && suffix.IsEmpty() && !knownClaudeModel {
- // A native Messages request can target a non-Anthropic model through a
- // Claude-compatible proxy. Its capability vocabulary belongs to that
- // upstream, so preserve validated native controls instead of applying
- // Anthropic model rules to an unknown model name.
- if info != nil {
- if effort := reasoning.EffectiveEffort(intent); effort != "" {
- info.SetReasoningEffort(string(effort))
- }
- }
- return nil
- }
if !knownClaudeModel && intent.Mode == reasoning.ModeAdaptive {
// Cross-protocol pivots cannot safely assume that an unknown
// Claude-compatible model implements Anthropic's adaptive mode. Render
@@ -93,6 +101,7 @@ func ApplyReasoning(req *dto.ClaudeRequest, info convmeta.Meta, source reasoning
if err != nil {
return err
}
+ convdiag.Add(ctx, rendered.Diagnostics...)
req.Model = baseModel
if rendered.Thinking != nil {
req.Thinking = rendered.Thinking
@@ -118,15 +127,34 @@ func ApplyReasoning(req *dto.ClaudeRequest, info convmeta.Meta, source reasoning
req.OutputConfig = encoded
}
if rendered.ClearSampling {
+ if req.Temperature != nil || req.TopP != nil || req.TopK != nil {
+ convdiag.Add(ctx, types.ConversionDiagnostic{
+ Code: "claude_sampling_removed",
+ Path: "temperature/top_p/top_k",
+ Message: fmt.Sprintf("model %q does not accept sampling controls with the selected thinking mode", capabilityModel),
+ Severity: types.ConversionDiagnosticWarning,
+ To: types.RelayFormatClaude,
+ })
+ }
req.Temperature = nil
req.TopP = nil
req.TopK = nil
} else if rendered.ConstrainThinkingSampling {
+ removedSampling := req.Temperature != nil || req.TopK != nil || req.TopP != nil && (*req.TopP < 0.95 || *req.TopP > 1)
req.Temperature = nil
req.TopK = nil
if req.TopP != nil && (*req.TopP < 0.95 || *req.TopP > 1) {
req.TopP = nil
}
+ if removedSampling {
+ convdiag.Add(ctx, types.ConversionDiagnostic{
+ Code: "claude_sampling_constrained",
+ Path: "temperature/top_p/top_k",
+ Message: fmt.Sprintf("model %q accepts only top_p between 0.95 and 1 with manual thinking", capabilityModel),
+ Severity: types.ConversionDiagnosticWarning,
+ To: types.RelayFormatClaude,
+ })
+ }
}
if info != nil && rendered.EffectiveEffort != "" {
info.SetReasoningEffort(string(rendered.EffectiveEffort))
diff --git a/relaykit/relayconvert/internal/shared/gemini/request.go b/relaykit/relayconvert/internal/shared/gemini/request.go
index 8845bb242bd5..a270c1ed2dad 100644
--- a/relaykit/relayconvert/internal/shared/gemini/request.go
+++ b/relaykit/relayconvert/internal/shared/gemini/request.go
@@ -81,6 +81,7 @@ func ApplyThinkingConfig(geminiRequest *dto.GeminiChatRequest, info convmeta.Met
modelName := convmeta.UpstreamModelName(info)
var source reasoning.Intent
+ crossProtocol := len(oaiRequest) > 0
if len(oaiRequest) > 0 {
if modelName == "" {
modelName = oaiRequest[0].Model
@@ -101,6 +102,21 @@ func ApplyThinkingConfig(geminiRequest *dto.GeminiChatRequest, info convmeta.Met
if preserveSuffix {
suffix = reasoning.Intent{}
}
+ // Native Gemini requests already use the target protocol. Without a host
+ // modifier, read portable effort metadata without running the capability
+ // renderer or rewriting provider-native controls.
+ if !crossProtocol && suffix.IsEmpty() {
+ native, err := reasoning.FromGemini(geminiRequest)
+ if err != nil {
+ return err
+ }
+ if info != nil {
+ if effort := reasoning.EffectiveEffort(native); effort != "" {
+ info.SetReasoningEffort(string(effort))
+ }
+ }
+ return nil
+ }
native, err := reasoning.FromGemini(geminiRequest)
if err != nil {
return err
diff --git a/relaykit/relayconvert/reasoning/claude.go b/relaykit/relayconvert/reasoning/claude.go
index 878bb1080d07..44565a113c7c 100644
--- a/relaykit/relayconvert/reasoning/claude.go
+++ b/relaykit/relayconvert/reasoning/claude.go
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/relaykit/types"
)
type ClaudeRender struct {
@@ -14,6 +15,7 @@ type ClaudeRender struct {
EffectiveEffort Effort
ClearSampling bool
ConstrainThinkingSampling bool
+ Diagnostics []types.ConversionDiagnostic
}
type claudeCapabilities struct {
@@ -73,7 +75,8 @@ func claudeCapabilitiesFor(model string) claudeCapabilities {
}
func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPercentage float64) (ClaudeRender, error) {
- if intent.Mode == ModeDisabled && intent.Effort != "" && intent.Effort != EffortNone {
+ disabledWithEffort := intent.Mode == ModeDisabled && intent.Effort != "" && intent.Effort != EffortNone
+ if disabledWithEffort {
effort, err := ParseEffort(string(intent.Effort))
if err != nil {
return ClaudeRender{}, err
@@ -87,6 +90,13 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
}
}
capabilities := claudeCapabilitiesFor(model)
+ diagnostics := make([]types.ConversionDiagnostic, 0, 1)
+ if disabledWithEffort {
+ diagnostics = append(diagnostics, claudeReasoningDiagnostic(
+ "claude_disabled_effort_ignored",
+ fmt.Sprintf("model %q cannot apply effort %q while thinking is disabled; the effort was ignored", model, intent.Effort),
+ ))
+ }
if !intent.HasStrength() {
if intent.IncludeThoughts != nil && capabilities.adaptive && capabilities.defaultThinking {
thinking := &dto.Thinking{Type: "adaptive"}
@@ -108,23 +118,50 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
}
if intent.Mode == ModeDisabled || intent.Effort == EffortNone {
- if strings.HasPrefix(strings.ToLower(model), "claude-opus-5") &&
- (intent.Effort == EffortXHigh || intent.Effort == EffortMax) {
- return ClaudeRender{}, fmt.Errorf("model %q does not support effort %q while thinking is disabled", model, intent.Effort)
- }
if !capabilities.supportsDisable {
- return ClaudeRender{}, fmt.Errorf("%w for model %q", ErrThinkingNotDisabled, model)
+ diagnostics = append(diagnostics, claudeReasoningDiagnostic(
+ "claude_thinking_disable_unsupported",
+ fmt.Sprintf("model %q cannot disable thinking; using the lowest representable thinking mode", model),
+ ))
+ if capabilities.adaptive {
+ thinking := &dto.Thinking{Type: "adaptive"}
+ if intent.IncludeThoughts != nil {
+ if *intent.IncludeThoughts {
+ thinking.Display = "summarized"
+ } else {
+ thinking.Display = "omitted"
+ }
+ }
+ outputEffort := Effort("")
+ effectiveEffort := EffortHigh
+ if capabilities.supportsEffort {
+ outputEffort = EffortLow
+ effectiveEffort = EffortLow
+ }
+ return ClaudeRender{
+ Thinking: thinking,
+ OutputEffort: outputEffort,
+ EffectiveEffort: effectiveEffort,
+ ClearSampling: capabilities.strictSampling,
+ Diagnostics: diagnostics,
+ }, nil
+ }
+ return ClaudeRender{Diagnostics: diagnostics}, nil
}
return ClaudeRender{
Thinking: &dto.Thinking{Type: "disabled"},
EffectiveEffort: EffortNone,
ClearSampling: capabilities.strictSampling,
+ Diagnostics: diagnostics,
}, nil
}
preferManual := capabilities.supportsManual && intent.BudgetTokens != nil && intent.Mode != ModeAdaptive
- if !capabilities.supportsManual && intent.BudgetTokens != nil && intent.BudgetSource == SourceNative && intent.Mode == ModeEnabled {
- return ClaudeRender{}, fmt.Errorf("model %q requires adaptive thinking and does not support native budget_tokens", model)
+ if !capabilities.supportsManual && intent.BudgetTokens != nil && intent.Mode == ModeEnabled {
+ diagnostics = append(diagnostics, claudeReasoningDiagnostic(
+ "claude_budget_to_adaptive",
+ fmt.Sprintf("model %q uses adaptive thinking; budget_tokens was converted to an effort level", model),
+ ))
}
if capabilities.adaptive && !preferManual {
effort := intent.Effort
@@ -134,7 +171,14 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
if effort == "" && intent.Mode == ModeEnabled {
effort = EffortHigh
}
- effort = normalizeClaudeEffort(effort, capabilities)
+ normalizedEffort := normalizeClaudeEffort(effort, capabilities)
+ if effort != "" && normalizedEffort != effort {
+ diagnostics = append(diagnostics, claudeReasoningDiagnostic(
+ "claude_effort_adjusted",
+ fmt.Sprintf("model %q does not support effort %q; using %q", model, effort, normalizedEffort),
+ ))
+ }
+ effort = normalizedEffort
effectiveEffort := effort
if effectiveEffort == "" && intent.Mode == ModeAdaptive {
effectiveEffort = EffortHigh
@@ -148,6 +192,7 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
OutputEffort: effort,
EffectiveEffort: effectiveEffort,
ClearSampling: capabilities.strictSampling,
+ Diagnostics: diagnostics,
}, nil
}
@@ -165,11 +210,19 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
EffectiveEffort: effectiveEffort,
ClearSampling: capabilities.strictSampling,
ConstrainThinkingSampling: !capabilities.strictSampling,
+ Diagnostics: diagnostics,
}, nil
}
if intent.Mode == ModeAdaptive {
- return ClaudeRender{}, fmt.Errorf("model %q does not support adaptive thinking", model)
+ diagnostics = append(diagnostics, claudeReasoningDiagnostic(
+ "claude_adaptive_to_manual",
+ fmt.Sprintf("model %q does not support adaptive thinking; using manual thinking", model),
+ ))
+ intent.Mode = ModeEnabled
+ if intent.Effort == "" {
+ intent.Effort = EffortHigh
+ }
}
if intent.Mode == ModeUnset {
return ClaudeRender{OutputEffort: intent.Effort, EffectiveEffort: intent.Effort}, nil
@@ -185,23 +238,28 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
}
budget := 0
- if intent.BudgetTokens != nil && *intent.BudgetTokens == -1 && intent.BudgetSource == SourceNative {
- return ClaudeRender{}, fmt.Errorf("Claude thinking budget_tokens does not support -1")
- }
if intent.BudgetTokens != nil && *intent.BudgetTokens >= 0 {
- budget = *intent.BudgetTokens
- if intent.BudgetSource != SourceNative {
- if budget < 1024 {
- budget = 1024
- }
- if uint(budget) >= *maxTokens {
- budget = int(*maxTokens) - 1
- }
+ requestedBudget := *intent.BudgetTokens
+ budget = requestedBudget
+ if budget < 1024 {
+ budget = 1024
+ }
+ if uint(budget) >= *maxTokens {
+ budget = int(*maxTokens) - 1
}
- if budget < 1024 || uint(budget) >= *maxTokens {
- return ClaudeRender{}, fmt.Errorf("Claude thinking budget must satisfy 1024 <= budget_tokens < max_tokens")
+ if budget != requestedBudget {
+ diagnostics = append(diagnostics, claudeReasoningDiagnostic(
+ "claude_budget_adjusted",
+ fmt.Sprintf("model %q requires 1024 <= budget_tokens < max_tokens; adjusted %d to %d", model, requestedBudget, budget),
+ ))
}
} else {
+ if intent.BudgetTokens != nil {
+ diagnostics = append(diagnostics, claudeReasoningDiagnostic(
+ "claude_dynamic_budget_converted",
+ fmt.Sprintf("model %q does not support a dynamic budget; derived a manual budget from reasoning effort", model),
+ ))
+ }
percentage := effortPercentage(intent.Effort, adapterBudgetPercentage)
budget = int(*maxTokens) * percentage / 100
if budget < 1024 {
@@ -236,9 +294,20 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
OutputEffort: outputEffort,
EffectiveEffort: effectiveEffort,
ConstrainThinkingSampling: true,
+ Diagnostics: diagnostics,
}, nil
}
+func claudeReasoningDiagnostic(code string, message string) types.ConversionDiagnostic {
+ return types.ConversionDiagnostic{
+ Code: code,
+ Path: "thinking",
+ Message: message,
+ Severity: types.ConversionDiagnosticWarning,
+ To: types.RelayFormatClaude,
+ }
+}
+
// ClaudeUsesManualThinking reports whether an exact numeric budget is rendered
// as legacy extended thinking rather than being reduced to adaptive effort.
func ClaudeUsesManualThinking(model string, intent Intent) bool {
diff --git a/relaykit/relayconvert/reasoning/suffix.go b/relaykit/relayconvert/reasoning/suffix.go
index 38b01bef7b46..558e12e4d716 100644
--- a/relaykit/relayconvert/reasoning/suffix.go
+++ b/relaykit/relayconvert/reasoning/suffix.go
@@ -2,6 +2,7 @@ package reasoning
import (
"fmt"
+ "regexp"
"strconv"
"strings"
@@ -14,6 +15,102 @@ var OpenAIEffortSuffixes = []string{"-max", "-xhigh", "-high", "-medium", "-low"
var DeepSeekV4EffortSuffixes = []string{"-none", "-max"}
+var (
+ legacyOpenAIModelPattern = regexp.MustCompile(`^(gpt-[a-z0-9][a-z0-9._-]*|o[1-9][a-z0-9._-]*)$`)
+ legacyClaudeModelPattern = regexp.MustCompile(`^claude-[a-z0-9][a-z0-9._-]*$`)
+ legacyGeminiModelPattern = regexp.MustCompile(`^gemini-[a-z0-9][a-z0-9._-]*$`)
+)
+
+type ModelModifier struct {
+ Key string
+ Value string
+}
+
+type ModelModifierSpec struct {
+ Raw string
+ Base string
+ Modifiers []ModelModifier
+}
+
+func (s ModelModifierSpec) HasModifiers() bool {
+ return len(s.Modifiers) > 0
+}
+
+// ParseModelModifiers removes only a contiguous trailing chain of @key:value
+// segments. Other @ characters remain part of the opaque model name.
+func ParseModelModifiers(modelName string) ModelModifierSpec {
+ spec := ModelModifierSpec{Raw: modelName, Base: modelName}
+ parts := strings.Split(modelName, "@")
+ if len(parts) < 2 {
+ return spec
+ }
+
+ firstModifier := len(parts)
+ for i := len(parts) - 1; i > 0; i-- {
+ key, value, ok := parseModelModifierSegment(parts[i])
+ if !ok {
+ break
+ }
+ firstModifier = i
+ spec.Modifiers = append([]ModelModifier{{Key: key, Value: value}}, spec.Modifiers...)
+ }
+ if firstModifier == len(parts) {
+ return spec
+ }
+
+ base := strings.Join(parts[:firstModifier], "@")
+ if base == "" {
+ return ModelModifierSpec{Raw: modelName, Base: modelName}
+ }
+ spec.Base = base
+ return spec
+}
+
+func parseModelModifierSegment(segment string) (string, string, bool) {
+ colon := strings.IndexByte(segment, ':')
+ if colon <= 0 {
+ return "", "", false
+ }
+ key := segment[:colon]
+ for i, r := range key {
+ letter := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z'
+ if i == 0 && !letter || i > 0 && !letter && (r < '0' || r > '9') && r != '_' && r != '-' {
+ return "", "", false
+ }
+ }
+ return strings.ToLower(key), segment[colon+1:], true
+}
+
+// ParseThinkingModifier maps an explicit @thinking value onto a portable
+// Intent. on/adaptive/off and integer budgets (including -1) are accepted;
+// values below -1 are rejected.
+func ParseThinkingModifier(raw string) (Intent, bool) {
+ value := strings.ToLower(strings.TrimSpace(raw))
+ switch value {
+ case "on":
+ return Intent{Mode: ModeEnabled, Source: SourceSuffix}, true
+ case "adaptive":
+ return Intent{Mode: ModeAdaptive, Source: SourceSuffix}, true
+ case "off":
+ return Intent{Mode: ModeDisabled, Effort: EffortNone, Source: SourceSuffix}, true
+ }
+ if budget, err := strconv.Atoi(value); err == nil {
+ if budget < -1 {
+ return Intent{}, false
+ }
+ if budget == 0 {
+ return Intent{Mode: ModeDisabled, Effort: EffortNone, Source: SourceSuffix}, true
+ }
+ return Intent{
+ Mode: ModeEnabled,
+ BudgetTokens: &budget,
+ Source: SourceSuffix,
+ BudgetSource: SourceSuffix,
+ }, true
+ }
+ return Intent{}, false
+}
+
func TrimEffortSuffixWithSuffixes(modelName string, suffixes []string) (string, string, bool) {
suffix, found := lo.Find(suffixes, func(s string) bool {
return strings.HasSuffix(modelName, s)
@@ -24,37 +121,31 @@ func TrimEffortSuffixWithSuffixes(modelName string, suffixes []string) (string,
return strings.TrimSuffix(modelName, suffix), strings.TrimPrefix(suffix, "-"), true
}
-// ParseOpenAIReasoningEffortFromModelSuffix extracts an OpenAI effort tail
-// such as -high or -none. preserveEffortTail, when non-nil, keeps real model
-// IDs whose names already end in those tokens (for example qwen-max).
+// ParseOpenAIReasoningEffortFromModelSuffix extracts an effort tail only from
+// GPT and o-series model families. preserveEffortTail is consulted on the
+// complete name first so real model IDs that already end in an effort word
+// (for example gpt-5.1-codex-max) stay intact.
func ParseOpenAIReasoningEffortFromModelSuffix(modelName string, preserveEffortTail func(string) bool) (string, string) {
if preserveEffortTail != nil && preserveEffortTail(modelName) {
return "", modelName
}
baseModel, effort, ok := TrimEffortSuffixWithSuffixes(modelName, OpenAIEffortSuffixes)
- if !ok {
+ if !ok || !legacyOpenAIModelPattern.MatchString(lastModelPathSegment(baseModel)) {
return "", modelName
}
return effort, baseModel
}
func ParseClaudeModelSuffix(modelName string, allowThinkingAlias bool) (string, Intent, bool, error) {
- if !strings.HasPrefix(modelName, "claude-") {
+ prefix, bare := splitModelNamespace(modelName)
+ if !strings.HasPrefix(bare, "claude-") {
return modelName, Intent{}, false, nil
}
- if allowThinkingAlias && hasLegacyThinkingAlias(modelName) {
- return parseProviderModelSuffix(modelName, "claude-", true, true)
- }
- if !isKnownClaudeModel(modelName) {
- return modelName, Intent{}, false, nil
+ base, intent, found, err := parseProviderModelSuffix(bare, "claude-", allowThinkingAlias, true)
+ if err != nil || !found || !legacyClaudeModelPattern.MatchString(base) {
+ return modelName, Intent{}, false, err
}
- return parseProviderModelSuffix(modelName, "claude-", allowThinkingAlias, true)
-}
-
-func hasLegacyThinkingAlias(modelName string) bool {
- return strings.HasSuffix(modelName, "-thinking") ||
- strings.HasSuffix(modelName, "-nothinking") ||
- strings.LastIndex(modelName, "-thinking-") >= 0
+ return prefix + base, intent, true, nil
}
func isKnownClaudeModel(modelName string) bool {
@@ -81,36 +172,41 @@ func isKnownClaudeModel(modelName string) bool {
}
func ParseGeminiModelSuffix(modelName string, allowThinkingAlias bool) (string, Intent, bool, error) {
- if !strings.HasPrefix(modelName, "gemini-") {
+ prefix, bare := splitModelNamespace(modelName)
+ if !strings.HasPrefix(bare, "gemini-") {
return modelName, Intent{}, false, nil
}
- if !isKnownGeminiModel(modelName) {
- return modelName, Intent{}, false, nil
+ base, intent, found, err := parseProviderModelSuffix(bare, "gemini-", allowThinkingAlias, true)
+ if err != nil || !found || !legacyGeminiModelPattern.MatchString(base) {
+ return modelName, Intent{}, false, err
}
- return parseProviderModelSuffix(modelName, "gemini-", allowThinkingAlias, true)
+ return prefix + base, intent, true, nil
}
// ParseKnownProviderModelSuffix extracts a canonical intent only when the
// origin identifies a provider family whose suffix vocabulary is defined by
// relaykit. Unknown OpenAI-compatible model names are deliberately untouched.
func ParseKnownProviderModelSuffix(modelName string, allowThinkingAlias bool) (string, Intent, bool, error) {
- if strings.HasPrefix(modelName, "claude-") {
+ bare := lastModelPathSegment(modelName)
+ if strings.HasPrefix(bare, "claude-") {
return ParseClaudeModelSuffix(modelName, allowThinkingAlias)
}
- if strings.HasPrefix(modelName, "gemini-") {
+ if strings.HasPrefix(bare, "gemini-") {
return ParseGeminiModelSuffix(modelName, allowThinkingAlias)
}
return modelName, Intent{}, false, nil
}
-func isKnownGeminiModel(modelName string) bool {
- baseModel, _, _ := TrimEffortSuffixWithSuffixes(modelName, []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal", "-none"})
- if marker := strings.LastIndex(baseModel, "-thinking-"); marker >= 0 {
- baseModel = baseModel[:marker]
- } else {
- baseModel = strings.TrimSuffix(strings.TrimSuffix(baseModel, "-thinking"), "-nothinking")
+func splitModelNamespace(modelName string) (string, string) {
+ if slash := strings.LastIndex(modelName, "/"); slash >= 0 {
+ return modelName[:slash+1], modelName[slash+1:]
}
- return geminiCapabilitiesFor(baseModel).kind != geminiThinkingUnknown
+ return "", modelName
+}
+
+func lastModelPathSegment(modelName string) string {
+ _, bare := splitModelNamespace(modelName)
+ return bare
}
func TrimGeminiThinkingSuffix(modelName string) (string, bool) {
diff --git a/relaykit/relayconvert/reasoning/suffix_test.go b/relaykit/relayconvert/reasoning/suffix_test.go
index 414016fa58ff..ff0f6eba2608 100644
--- a/relaykit/relayconvert/reasoning/suffix_test.go
+++ b/relaykit/relayconvert/reasoning/suffix_test.go
@@ -133,6 +133,58 @@ func TestParseKnownProviderModelSuffix(t *testing.T) {
assert.Empty(t, effort)
assert.Equal(t, "vendor/qwen-max", base)
})
+
+ t.Run("preserve gpt-5.1-codex-max with callback", func(t *testing.T) {
+ t.Parallel()
+ preserve := func(name string) bool { return name == "gpt-5.1-codex-max" }
+ effort, base := ParseOpenAIReasoningEffortFromModelSuffix("gpt-5.1-codex-max", preserve)
+ assert.Empty(t, effort)
+ assert.Equal(t, "gpt-5.1-codex-max", base)
+ })
+
+ t.Run("splits gpt-5.1-codex-max without callback", func(t *testing.T) {
+ t.Parallel()
+ effort, base := ParseOpenAIReasoningEffortFromModelSuffix("gpt-5.1-codex-max", nil)
+ assert.Equal(t, "max", effort)
+ assert.Equal(t, "gpt-5.1-codex", base)
+ })
+}
+
+func TestParseThinkingModifier(t *testing.T) {
+ t.Parallel()
+
+ on, ok := ParseThinkingModifier("on")
+ require.True(t, ok)
+ assert.Equal(t, ModeEnabled, on.Mode)
+
+ adaptive, ok := ParseThinkingModifier("Adaptive")
+ require.True(t, ok)
+ assert.Equal(t, ModeAdaptive, adaptive.Mode)
+
+ off, ok := ParseThinkingModifier("off")
+ require.True(t, ok)
+ assert.Equal(t, ModeDisabled, off.Mode)
+ assert.Equal(t, EffortNone, off.Effort)
+
+ zero, ok := ParseThinkingModifier("0")
+ require.True(t, ok)
+ assert.Equal(t, ModeDisabled, zero.Mode)
+
+ budget, ok := ParseThinkingModifier("8192")
+ require.True(t, ok)
+ require.NotNil(t, budget.BudgetTokens)
+ assert.Equal(t, 8192, *budget.BudgetTokens)
+ assert.Equal(t, ModeEnabled, budget.Mode)
+
+ dynamic, ok := ParseThinkingModifier("-1")
+ require.True(t, ok)
+ require.NotNil(t, dynamic.BudgetTokens)
+ assert.Equal(t, -1, *dynamic.BudgetTokens)
+
+ _, ok = ParseThinkingModifier("-2")
+ assert.False(t, ok)
+ _, ok = ParseThinkingModifier("enabled")
+ assert.False(t, ok)
}
func intPtr(v int) *int {
diff --git a/relaykit/relayconvert/request_compat.go b/relaykit/relayconvert/request_compat.go
index 90b93457ec37..32d023982190 100644
--- a/relaykit/relayconvert/request_compat.go
+++ b/relaykit/relayconvert/request_compat.go
@@ -6,6 +6,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
sharedgemini "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/gemini"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
@@ -33,7 +34,14 @@ func ApplyGeminiThinkingConfigChecked(geminiRequest *dto.GeminiChatRequest, info
}
func ApplyClaudeThinkingModel(claudeRequest *dto.ClaudeRequest, info convmeta.Meta) error {
- return reasoning.AsClientError(sharedclaude.ApplyReasoning(claudeRequest, info, reasoning.Intent{}))
+ ctx, collector := convdiag.WithCollector(context.Background())
+ err := reasoning.AsClientError(sharedclaude.ApplyReasoning(ctx, claudeRequest, info, reasoning.Intent{}, false))
+ if recorder, ok := info.(interface {
+ RecordConversionDiagnostics(context.Context, []types.ConversionDiagnostic)
+ }); ok {
+ recorder.RecordConversionDiagnostics(ctx, collector.Diagnostics())
+ }
+ return err
}
func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) {
diff --git a/relaykit/relayconvert/request_registry.go b/relaykit/relayconvert/request_registry.go
index 6904a820e448..ad219242c45b 100644
--- a/relaykit/relayconvert/request_registry.go
+++ b/relaykit/relayconvert/request_registry.go
@@ -11,6 +11,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
claudemessages "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/claude_messages"
+ "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
geminichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/gemini_chat"
oaichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_chat"
oairesponses "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_responses"
@@ -239,6 +240,7 @@ func executeRequestSpec(c context.Context, info convmeta.Meta, from types.RelayF
}
func executeRequestSteps(c context.Context, info convmeta.Meta, from types.RelayFormat, target types.RelayFormat, request any, converter string, quality RequestConverterQuality, specs []RequestConverterSpec) (*RequestResult, error) {
+ c, diagnosticCollector := convdiag.WithCollector(c)
current, tools, err := toolconv.ExtractRequest(from, request)
if err != nil {
return nil, err
@@ -258,7 +260,16 @@ func executeRequestSteps(c context.Context, info convmeta.Meta, from types.Relay
steps = append(steps, step)
}
- current, diagnostics, err := toolconv.AttachRequest(target, current, tools, convmeta.OptionsOf(info))
+ current, toolDiagnostics, err := toolconv.AttachRequest(target, current, tools, convmeta.OptionsOf(info))
+ diagnostics := append(diagnosticCollector.Diagnostics(), toolDiagnostics...)
+ for i := range diagnostics {
+ if diagnostics[i].From == "" {
+ diagnostics[i].From = from
+ }
+ if diagnostics[i].To == "" {
+ diagnostics[i].To = target
+ }
+ }
if err != nil {
return &RequestResult{
Value: current,
diff --git a/setting/model_setting/global.go b/setting/model_setting/global.go
index ce858f753d20..77127ff5bdbe 100644
--- a/setting/model_setting/global.go
+++ b/setting/model_setting/global.go
@@ -1,9 +1,13 @@
package model_setting
import (
+ "fmt"
+ "regexp"
"slices"
"strings"
+ "sync"
+ "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/config"
)
@@ -33,8 +37,10 @@ func (p ChatCompletionsToResponsesPolicy) IsChannelEnabled(channelID int, channe
}
type GlobalSettings struct {
- PassThroughRequestEnabled bool `json:"pass_through_request_enabled"`
- ThinkingModelBlacklist []string `json:"thinking_model_blacklist"`
+ PassThroughRequestEnabled bool `json:"pass_through_request_enabled"`
+ ThinkingModelBlacklist []string `json:"thinking_model_blacklist"`
+ // EffortTailModelIDs lists real model IDs that sit inside the GPT/o-series
+ // family whitelist but whose names already end in an effort word.
EffortTailModelIDs []string `json:"effort_tail_model_ids"`
ChatCompletionsToResponsesPolicy ChatCompletionsToResponsesPolicy `json:"chat_completions_to_responses_policy"`
}
@@ -71,32 +77,105 @@ func GetGlobalSettings() *GlobalSettings {
return &globalSettings
}
-// ShouldPreserveThinkingSuffix 判断模型是否配置为保留 thinking/-nothinking/-low/-high/-medium 后缀
+const thinkingBlacklistRegexPrefix = "re:"
+
+type thinkingBlacklistCompiled struct {
+ source string
+ exact []string
+ regexes []*regexp.Regexp
+}
+
+var (
+ thinkingBlacklistMu sync.RWMutex
+ thinkingBlacklistCache thinkingBlacklistCompiled
+)
+
+func thinkingBlacklistSourceKey(entries []string) string {
+ return strings.Join(entries, "\x00")
+}
+
+func compiledThinkingBlacklist() ([]string, []*regexp.Regexp) {
+ entries := globalSettings.ThinkingModelBlacklist
+ key := thinkingBlacklistSourceKey(entries)
+
+ thinkingBlacklistMu.RLock()
+ if thinkingBlacklistCache.source == key {
+ exact, regexes := thinkingBlacklistCache.exact, thinkingBlacklistCache.regexes
+ thinkingBlacklistMu.RUnlock()
+ return exact, regexes
+ }
+ thinkingBlacklistMu.RUnlock()
+
+ thinkingBlacklistMu.Lock()
+ defer thinkingBlacklistMu.Unlock()
+ if thinkingBlacklistCache.source == key {
+ return thinkingBlacklistCache.exact, thinkingBlacklistCache.regexes
+ }
+
+ exact := make([]string, 0, len(entries))
+ var regexes []*regexp.Regexp
+ for _, entry := range entries {
+ entry = strings.TrimSpace(entry)
+ if entry == "" {
+ continue
+ }
+ if strings.HasPrefix(entry, thinkingBlacklistRegexPrefix) {
+ pattern := strings.TrimPrefix(entry, thinkingBlacklistRegexPrefix)
+ if pattern == "" {
+ common.SysError(fmt.Sprintf("invalid thinking_model_blacklist regex %q: pattern is empty", entry))
+ continue
+ }
+ re, err := regexp.Compile(pattern)
+ if err != nil {
+ common.SysError(fmt.Sprintf("invalid thinking_model_blacklist regex %q: %v", entry, err))
+ continue
+ }
+ regexes = append(regexes, re)
+ continue
+ }
+ exact = append(exact, entry)
+ }
+ thinkingBlacklistCache = thinkingBlacklistCompiled{source: key, exact: exact, regexes: regexes}
+ return exact, regexes
+}
+
+// ShouldPreserveThinkingSuffix reports whether the full model name is exempt
+// from host thinking-suffix and @-modifier parsing. Exact blacklist entries
+// match the complete name; entries prefixed with re: are Go regular expressions
+// matched with MatchString against the same full name.
func ShouldPreserveThinkingSuffix(modelName string) bool {
target := strings.TrimSpace(modelName)
if target == "" {
return false
}
- for _, entry := range globalSettings.ThinkingModelBlacklist {
- if strings.TrimSpace(entry) == target {
+ exact, regexes := compiledThinkingBlacklist()
+ for _, entry := range exact {
+ if entry == target {
+ return true
+ }
+ }
+ for _, re := range regexes {
+ if re.MatchString(target) {
return true
}
}
return false
}
-// ShouldPreserveEffortTail reports model IDs whose names already end in an
-// effort-like token and must not be treated as reasoning aliases.
+// ShouldPreserveEffortTail reports whether modelName is a real model ID whose
+// name already ends in an effort word. Entries match the complete name and the
+// de-namespaced bare name.
func ShouldPreserveEffortTail(modelName string) bool {
target := strings.TrimSpace(modelName)
if target == "" {
return false
}
bare := target
- if slash := strings.LastIndex(bare, "/"); slash >= 0 {
- bare = bare[slash+1:]
+ if slash := strings.LastIndex(target, "/"); slash >= 0 {
+ bare = target[slash+1:]
}
+
for _, entry := range globalSettings.EffortTailModelIDs {
entry = strings.TrimSpace(entry)
if entry == "" {
diff --git a/setting/model_setting/global_test.go b/setting/model_setting/global_test.go
new file mode 100644
index 000000000000..458d3ebf79f8
--- /dev/null
+++ b/setting/model_setting/global_test.go
@@ -0,0 +1,44 @@
+package model_setting
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestShouldPreserveThinkingSuffixExactAndRegex(t *testing.T) {
+ settings := GetGlobalSettings()
+ original := append([]string(nil), settings.ThinkingModelBlacklist...)
+ t.Cleanup(func() { settings.ThinkingModelBlacklist = original })
+
+ assert.True(t, ShouldPreserveThinkingSuffix("kimi-k2-thinking"))
+ assert.True(t, ShouldPreserveThinkingSuffix("moonshotai/kimi-k2-thinking"))
+ assert.False(t, ShouldPreserveThinkingSuffix("m@sha256:abc"))
+
+ settings.ThinkingModelBlacklist = []string{
+ "kimi-k2-thinking",
+ "re:[",
+ "re:",
+ "re:.*@sha256:.*",
+ }
+
+ var logged bytes.Buffer
+ previous := gin.DefaultErrorWriter
+ gin.DefaultErrorWriter = &logged
+ t.Cleanup(func() { gin.DefaultErrorWriter = previous })
+
+ assert.True(t, ShouldPreserveThinkingSuffix("kimi-k2-thinking"))
+ assert.True(t, ShouldPreserveThinkingSuffix("m@sha256:abc"))
+ assert.False(t, ShouldPreserveThinkingSuffix("m@sha256"))
+ assert.False(t, ShouldPreserveThinkingSuffix("qwen3-max@thinking:on"))
+ require.Contains(t, logged.String(), `invalid thinking_model_blacklist regex "re:["`)
+ require.Contains(t, logged.String(), `invalid thinking_model_blacklist regex "re:"`)
+
+ settings.ThinkingModelBlacklist = []string{"re:^beta@"}
+ assert.False(t, ShouldPreserveThinkingSuffix("m@sha256:abc"))
+ assert.True(t, ShouldPreserveThinkingSuffix("beta@sha256:abc"))
+ assert.False(t, ShouldPreserveThinkingSuffix("alpha@sha256:abc"))
+}
diff --git a/setting/ratio_setting/matching_test.go b/setting/ratio_setting/matching_test.go
new file mode 100644
index 000000000000..9953f5f4ccec
--- /dev/null
+++ b/setting/ratio_setting/matching_test.go
@@ -0,0 +1,38 @@
+package ratio_setting
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/setting/model_setting"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFormatMatchingModelNameDoesNotStripBase(t *testing.T) {
+ assert.Equal(t, "qwen3-max@thinking:on", FormatMatchingModelName("qwen3-max@thinking:on"))
+ assert.Equal(t, "claude-3-7-sonnet-thinking", FormatMatchingModelName("claude-3-7-sonnet-thinking"))
+ assert.Equal(t, "gemini-2.5-flash-thinking-*", FormatMatchingModelName("gemini-2.5-flash-thinking-8192"))
+ assert.Equal(t, "gpt-4-gizmo-*", FormatMatchingModelName("gpt-4-gizmo-abc"))
+}
+
+func TestRoutingMatchModelNameStripsThenWildcards(t *testing.T) {
+ assert.Equal(t, "qwen3-max", RoutingMatchModelName("qwen3-max@thinking:on@temperature:0.2"))
+ assert.Equal(t, "claude-3-7-sonnet", RoutingMatchModelName("claude-3-7-sonnet-thinking"))
+ assert.Equal(t, "gemini-2.5-flash-thinking-*", RoutingMatchModelName("gemini-2.5-flash-thinking-8192"))
+ assert.Equal(t, "gpt-5.1-codex-max", RoutingMatchModelName("gpt-5.1-codex-max"))
+
+ geminiSettings := model_setting.GetGeminiSettings()
+ old := geminiSettings.ThinkingAdapterEnabled
+ geminiSettings.ThinkingAdapterEnabled = true
+ t.Cleanup(func() { geminiSettings.ThinkingAdapterEnabled = old })
+ assert.Equal(t, "gemini-2.5-flash", RoutingMatchModelName("gemini-2.5-flash-thinking-8192"))
+}
+
+func TestRoutingMatchModelNamePreservesExemptAtName(t *testing.T) {
+ settings := model_setting.GetGlobalSettings()
+ original := append([]string(nil), settings.ThinkingModelBlacklist...)
+ t.Cleanup(func() { settings.ThinkingModelBlacklist = original })
+ settings.ThinkingModelBlacklist = append(original, "re:.*@sha256:.*")
+
+ assert.Equal(t, "opaque@sha256:deadbeef", RoutingMatchModelName("opaque@sha256:deadbeef"))
+ assert.Equal(t, "kimi-k2-thinking", RoutingMatchModelName("kimi-k2-thinking"))
+}
diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go
index 53127c9485c0..7dcbf0e7649f 100644
--- a/setting/ratio_setting/model_ratio.go
+++ b/setting/ratio_setting/model_ratio.go
@@ -5,6 +5,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/operation_setting"
+ hostreasoning "github.com/QuantumNous/new-api/setting/reasoning"
"github.com/QuantumNous/new-api/types"
)
@@ -25,196 +26,196 @@ const (
var defaultModelRatio = map[string]float64{
//"midjourney": 50,
- "gpt-4-gizmo-*": 15,
- "gpt-4o-gizmo-*": 2.5,
- "gpt-4-all": 15,
- "gpt-4o-all": 15,
- "gpt-4": 15,
- "gpt-4-0613": 15,
- "gpt-4-32k": 30,
- "gpt-4-32k-0613": 30,
- "gpt-4-1106-preview": 5, // $10 / 1M tokens
- "gpt-4-0125-preview": 5, // $10 / 1M tokens
- "gpt-4-turbo-preview": 5, // $10 / 1M tokens
- "gpt-4-vision-preview": 5, // $10 / 1M tokens
- "gpt-4-1106-vision-preview": 5, // $10 / 1M tokens
- "chatgpt-4o-latest": 2.5, // $5 / 1M tokens
- "gpt-4o": 1.25, // $2.5 / 1M tokens
- "gpt-4o-audio-preview": 1.25, // $2.5 / 1M tokens
- "gpt-4o-audio-preview-2024-10-01": 1.25, // $2.5 / 1M tokens
- "gpt-4o-2024-05-13": 2.5, // $5 / 1M tokens
- "gpt-4o-2024-08-06": 1.25, // $2.5 / 1M tokens
- "gpt-4o-2024-11-20": 1.25, // $2.5 / 1M tokens
- "gpt-4o-realtime-preview": 2.5,
- "gpt-4o-realtime-preview-2024-10-01": 2.5,
- "gpt-4o-realtime-preview-2024-12-17": 2.5,
- "gpt-4o-mini-realtime-preview": 0.3,
- "gpt-4o-mini-realtime-preview-2024-12-17": 0.3,
- "gpt-4.1": 1.0, // $2 / 1M tokens
- "gpt-4.1-2025-04-14": 1.0, // $2 / 1M tokens
- "gpt-4.1-mini": 0.2, // $0.4 / 1M tokens
- "gpt-4.1-mini-2025-04-14": 0.2, // $0.4 / 1M tokens
- "gpt-4.1-nano": 0.05, // $0.1 / 1M tokens
- "gpt-4.1-nano-2025-04-14": 0.05, // $0.1 / 1M tokens
- "gpt-image-1": 2.5, // $5 / 1M tokens
- "o1": 7.5, // $15 / 1M tokens
- "o1-2024-12-17": 7.5, // $15 / 1M tokens
- "o1-preview": 7.5, // $15 / 1M tokens
- "o1-preview-2024-09-12": 7.5, // $15 / 1M tokens
- "o1-mini": 0.55, // $1.1 / 1M tokens
- "o1-mini-2024-09-12": 0.55, // $1.1 / 1M tokens
- "o1-pro": 75.0, // $150 / 1M tokens
- "o1-pro-2025-03-19": 75.0, // $150 / 1M tokens
- "o3-mini": 0.55,
- "o3-mini-2025-01-31": 0.55,
- "o3-mini-high": 0.55,
- "o3-mini-2025-01-31-high": 0.55,
- "o3-mini-low": 0.55,
- "o3-mini-2025-01-31-low": 0.55,
- "o3-mini-medium": 0.55,
- "o3-mini-2025-01-31-medium": 0.55,
- "o3": 1.0, // $2 / 1M tokens
- "o3-2025-04-16": 1.0, // $2 / 1M tokens
- "o3-pro": 10.0, // $20 / 1M tokens
- "o3-pro-2025-06-10": 10.0, // $20 / 1M tokens
- "o3-deep-research": 5.0, // $10 / 1M tokens
- "o3-deep-research-2025-06-26": 5.0, // $10 / 1M tokens
- "o4-mini": 0.55, // $1.1 / 1M tokens
- "o4-mini-2025-04-16": 0.55, // $1.1 / 1M tokens
- "o4-mini-deep-research": 1.0, // $2 / 1M tokens
- "o4-mini-deep-research-2025-06-26": 1.0, // $2 / 1M tokens
- "gpt-4o-mini": 0.075,
- "gpt-4o-mini-2024-07-18": 0.075,
- "gpt-4-turbo": 5, // $0.01 / 1K tokens
- "gpt-4-turbo-2024-04-09": 5, // $0.01 / 1K tokens
- "gpt-4.5-preview": 37.5,
- "gpt-4.5-preview-2025-02-27": 37.5,
- "gpt-5": 0.625,
- "gpt-5-2025-08-07": 0.625,
- "gpt-5-chat-latest": 0.625,
- "gpt-5-mini": 0.125,
- "gpt-5-mini-2025-08-07": 0.125,
- "gpt-5-nano": 0.025,
- "gpt-5-nano-2025-08-07": 0.025,
- "gpt-5.5": 2.5, // $5 / 1M tokens
- "gpt-5.6-sol": 2.5,
- "gpt-5.6-terra": 1.25,
- "gpt-5.6-luna": 0.5,
- "gpt-3.5-turbo": 0.25,
- "gpt-3.5-turbo-0613": 0.75,
- "gpt-3.5-turbo-16k": 1.5, // $0.003 / 1K tokens
- "gpt-3.5-turbo-16k-0613": 1.5,
- "gpt-3.5-turbo-instruct": 0.75, // $0.0015 / 1K tokens
- "gpt-3.5-turbo-1106": 0.5, // $0.001 / 1K tokens
- "gpt-3.5-turbo-0125": 0.25,
- "text-ada-001": 0.2,
- "text-babbage-001": 0.25,
- "text-curie-001": 1,
- "text-davinci-edit-001": 10,
- "code-davinci-edit-001": 10,
- "whisper-1": 15, // $0.006 / minute -> $0.006 / 150 words -> $0.006 / 200 tokens -> $0.03 / 1k tokens
- "tts-1": 7.5, // 1k characters -> $0.015
- "tts-1-1106": 7.5, // 1k characters -> $0.015
- "tts-1-hd": 15, // 1k characters -> $0.03
- "tts-1-hd-1106": 15, // 1k characters -> $0.03
- "davinci": 10,
- "curie": 10,
- "text-embedding-3-small": 0.01,
- "text-embedding-3-large": 0.065,
- "text-embedding-ada-002": 0.05,
- "text-search-ada-doc-001": 10,
- "text-moderation-stable": 0.1,
- "text-moderation-latest": 0.1,
- "claude-3-haiku-20240307": 0.125, // $0.25 / 1M tokens
- "claude-3-5-haiku-20241022": 0.5, // $1 / 1M tokens
- "claude-haiku-4-5-20251001": 0.5, // $1 / 1M tokens
- "claude-3-sonnet-20240229": 1.5, // $3 / 1M tokens
- "claude-3-5-sonnet-20240620": 1.5,
- "claude-3-5-sonnet-20241022": 1.5,
- "claude-3-7-sonnet-20250219": 1.5,
- "claude-3-7-sonnet-20250219-thinking": 1.5,
- "claude-sonnet-4-20250514": 1.5,
- "claude-sonnet-4-5-20250929": 1.5,
- "claude-opus-4-5-20251101": 2.5,
- "claude-opus-4-6": 2.5,
- "claude-opus-4-6-max": 2.5,
- "claude-opus-4-6-high": 2.5,
- "claude-opus-4-6-medium": 2.5,
- "claude-opus-4-6-low": 2.5,
- "claude-opus-4-7": 2.5,
- "claude-opus-4-7-max": 2.5,
- "claude-opus-4-7-xhigh": 2.5,
- "claude-opus-4-7-high": 2.5,
- "claude-opus-4-7-medium": 2.5,
- "claude-opus-4-7-low": 2.5,
- "claude-opus-4-8": 2.5,
- "claude-opus-4-8-max": 2.5,
- "claude-opus-4-8-xhigh": 2.5,
- "claude-opus-4-8-high": 2.5,
- "claude-opus-4-8-medium": 2.5,
- "claude-opus-4-8-low": 2.5,
- "claude-3-opus-20240229": 7.5, // $15 / 1M tokens
- "claude-opus-4-20250514": 7.5,
- "claude-opus-4-1-20250805": 7.5,
- "ERNIE-4.0-8K": 0.120 * RMB,
- "ERNIE-3.5-8K": 0.012 * RMB,
- "ERNIE-3.5-8K-0205": 0.024 * RMB,
- "ERNIE-3.5-8K-1222": 0.012 * RMB,
- "ERNIE-Bot-8K": 0.024 * RMB,
- "ERNIE-3.5-4K-0205": 0.012 * RMB,
- "ERNIE-Speed-8K": 0.004 * RMB,
- "ERNIE-Speed-128K": 0.004 * RMB,
- "ERNIE-Lite-8K-0922": 0.008 * RMB,
- "ERNIE-Lite-8K-0308": 0.003 * RMB,
- "ERNIE-Tiny-8K": 0.001 * RMB,
- "BLOOMZ-7B": 0.004 * RMB,
- "Embedding-V1": 0.002 * RMB,
- "bge-large-zh": 0.002 * RMB,
- "bge-large-en": 0.002 * RMB,
- "tao-8k": 0.002 * RMB,
- "PaLM-2": 1,
- "gemini-1.5-pro-latest": 1.25, // $3.5 / 1M tokens
- "gemini-1.5-flash-latest": 0.075,
- "gemini-2.0-flash": 0.05,
- "gemini-2.5-pro-exp-03-25": 0.625,
- "gemini-2.5-pro-preview-03-25": 0.625,
- "gemini-2.5-pro": 0.625,
- "gemini-2.5-flash-preview-04-17": 0.075,
- "gemini-2.5-flash-preview-04-17-thinking": 0.075,
- "gemini-2.5-flash-preview-05-20": 0.075,
- "gemini-2.5-flash-preview-05-20-thinking": 0.075,
- "gemini-2.5-flash-thinking-*": 0.075, // 用于为后续所有2.5 flash thinking budget 模型设置默认倍率
- "gemini-2.5-pro-thinking-*": 0.625, // 用于为后续所有2.5 pro thinking budget 模型设置默认倍率
- "gemini-2.5-flash-lite-preview-thinking-*": 0.05,
- "gemini-2.5-flash-lite-preview-06-17": 0.05,
- "gemini-2.5-flash": 0.15,
- "gemini-robotics-er-1.5-preview": 0.15,
- "gemini-embedding-001": 0.075,
- "text-embedding-004": 0.001,
- "chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens
- "chatglm_pro": 0.7143, // ¥0.01 / 1k tokens
- "chatglm_std": 0.3572, // ¥0.005 / 1k tokens
- "chatglm_lite": 0.1429, // ¥0.002 / 1k tokens
- "glm-4": 7.143, // ¥0.1 / 1k tokens
- "glm-4v": 0.05 * RMB, // ¥0.05 / 1k tokens
- "glm-4-alltools": 0.1 * RMB, // ¥0.1 / 1k tokens
- "glm-3-turbo": 0.3572,
- "glm-4-plus": 0.05 * RMB,
- "glm-4-0520": 0.1 * RMB,
- "glm-4-air": 0.001 * RMB,
- "glm-4-airx": 0.01 * RMB,
- "glm-4-long": 0.001 * RMB,
- "glm-4-flash": 0,
- "glm-4v-plus": 0.01 * RMB,
- "qwen-turbo": 0.8572, // ¥0.012 / 1k tokens
- "qwen-plus": 10, // ¥0.14 / 1k tokens
- "text-embedding-v1": 0.05, // ¥0.0007 / 1k tokens
- "SparkDesk-v1.1": 1.2858, // ¥0.018 / 1k tokens
- "SparkDesk-v2.1": 1.2858, // ¥0.018 / 1k tokens
- "SparkDesk-v3.1": 1.2858, // ¥0.018 / 1k tokens
- "SparkDesk-v3.5": 1.2858, // ¥0.018 / 1k tokens
- "SparkDesk-v4.0": 1.2858,
- "hunyuan": 7.143, // ¥0.1 / 1k tokens // https://cloud.tencent.com/document/product/1729/97731#e0e6be58-60c8-469f-bdeb-6c264ce3b4d0
+ "gpt-4-gizmo-*": 15,
+ "gpt-4o-gizmo-*": 2.5,
+ "gpt-4-all": 15,
+ "gpt-4o-all": 15,
+ "gpt-4": 15,
+ "gpt-4-0613": 15,
+ "gpt-4-32k": 30,
+ "gpt-4-32k-0613": 30,
+ "gpt-4-1106-preview": 5, // $10 / 1M tokens
+ "gpt-4-0125-preview": 5, // $10 / 1M tokens
+ "gpt-4-turbo-preview": 5, // $10 / 1M tokens
+ "gpt-4-vision-preview": 5, // $10 / 1M tokens
+ "gpt-4-1106-vision-preview": 5, // $10 / 1M tokens
+ "chatgpt-4o-latest": 2.5, // $5 / 1M tokens
+ "gpt-4o": 1.25, // $2.5 / 1M tokens
+ "gpt-4o-audio-preview": 1.25, // $2.5 / 1M tokens
+ "gpt-4o-audio-preview-2024-10-01": 1.25, // $2.5 / 1M tokens
+ "gpt-4o-2024-05-13": 2.5, // $5 / 1M tokens
+ "gpt-4o-2024-08-06": 1.25, // $2.5 / 1M tokens
+ "gpt-4o-2024-11-20": 1.25, // $2.5 / 1M tokens
+ "gpt-4o-realtime-preview": 2.5,
+ "gpt-4o-realtime-preview-2024-10-01": 2.5,
+ "gpt-4o-realtime-preview-2024-12-17": 2.5,
+ "gpt-4o-mini-realtime-preview": 0.3,
+ "gpt-4o-mini-realtime-preview-2024-12-17": 0.3,
+ "gpt-4.1": 1.0, // $2 / 1M tokens
+ "gpt-4.1-2025-04-14": 1.0, // $2 / 1M tokens
+ "gpt-4.1-mini": 0.2, // $0.4 / 1M tokens
+ "gpt-4.1-mini-2025-04-14": 0.2, // $0.4 / 1M tokens
+ "gpt-4.1-nano": 0.05, // $0.1 / 1M tokens
+ "gpt-4.1-nano-2025-04-14": 0.05, // $0.1 / 1M tokens
+ "gpt-image-1": 2.5, // $5 / 1M tokens
+ "o1": 7.5, // $15 / 1M tokens
+ "o1-2024-12-17": 7.5, // $15 / 1M tokens
+ "o1-preview": 7.5, // $15 / 1M tokens
+ "o1-preview-2024-09-12": 7.5, // $15 / 1M tokens
+ "o1-mini": 0.55, // $1.1 / 1M tokens
+ "o1-mini-2024-09-12": 0.55, // $1.1 / 1M tokens
+ "o1-pro": 75.0, // $150 / 1M tokens
+ "o1-pro-2025-03-19": 75.0, // $150 / 1M tokens
+ "o3-mini": 0.55,
+ "o3-mini-2025-01-31": 0.55,
+ "o3-mini-high": 0.55,
+ "o3-mini-2025-01-31-high": 0.55,
+ "o3-mini-low": 0.55,
+ "o3-mini-2025-01-31-low": 0.55,
+ "o3-mini-medium": 0.55,
+ "o3-mini-2025-01-31-medium": 0.55,
+ "o3": 1.0, // $2 / 1M tokens
+ "o3-2025-04-16": 1.0, // $2 / 1M tokens
+ "o3-pro": 10.0, // $20 / 1M tokens
+ "o3-pro-2025-06-10": 10.0, // $20 / 1M tokens
+ "o3-deep-research": 5.0, // $10 / 1M tokens
+ "o3-deep-research-2025-06-26": 5.0, // $10 / 1M tokens
+ "o4-mini": 0.55, // $1.1 / 1M tokens
+ "o4-mini-2025-04-16": 0.55, // $1.1 / 1M tokens
+ "o4-mini-deep-research": 1.0, // $2 / 1M tokens
+ "o4-mini-deep-research-2025-06-26": 1.0, // $2 / 1M tokens
+ "gpt-4o-mini": 0.075,
+ "gpt-4o-mini-2024-07-18": 0.075,
+ "gpt-4-turbo": 5, // $0.01 / 1K tokens
+ "gpt-4-turbo-2024-04-09": 5, // $0.01 / 1K tokens
+ "gpt-4.5-preview": 37.5,
+ "gpt-4.5-preview-2025-02-27": 37.5,
+ "gpt-5": 0.625,
+ "gpt-5-2025-08-07": 0.625,
+ "gpt-5-chat-latest": 0.625,
+ "gpt-5-mini": 0.125,
+ "gpt-5-mini-2025-08-07": 0.125,
+ "gpt-5-nano": 0.025,
+ "gpt-5-nano-2025-08-07": 0.025,
+ "gpt-5.5": 2.5, // $5 / 1M tokens
+ "gpt-5.6-sol": 2.5,
+ "gpt-5.6-terra": 1.25,
+ "gpt-5.6-luna": 0.5,
+ "gpt-3.5-turbo": 0.25,
+ "gpt-3.5-turbo-0613": 0.75,
+ "gpt-3.5-turbo-16k": 1.5, // $0.003 / 1K tokens
+ "gpt-3.5-turbo-16k-0613": 1.5,
+ "gpt-3.5-turbo-instruct": 0.75, // $0.0015 / 1K tokens
+ "gpt-3.5-turbo-1106": 0.5, // $0.001 / 1K tokens
+ "gpt-3.5-turbo-0125": 0.25,
+ "text-ada-001": 0.2,
+ "text-babbage-001": 0.25,
+ "text-curie-001": 1,
+ "text-davinci-edit-001": 10,
+ "code-davinci-edit-001": 10,
+ "whisper-1": 15, // $0.006 / minute -> $0.006 / 150 words -> $0.006 / 200 tokens -> $0.03 / 1k tokens
+ "tts-1": 7.5, // 1k characters -> $0.015
+ "tts-1-1106": 7.5, // 1k characters -> $0.015
+ "tts-1-hd": 15, // 1k characters -> $0.03
+ "tts-1-hd-1106": 15, // 1k characters -> $0.03
+ "davinci": 10,
+ "curie": 10,
+ "text-embedding-3-small": 0.01,
+ "text-embedding-3-large": 0.065,
+ "text-embedding-ada-002": 0.05,
+ "text-search-ada-doc-001": 10,
+ "text-moderation-stable": 0.1,
+ "text-moderation-latest": 0.1,
+ "claude-3-haiku-20240307": 0.125, // $0.25 / 1M tokens
+ "claude-3-5-haiku-20241022": 0.5, // $1 / 1M tokens
+ "claude-haiku-4-5-20251001": 0.5, // $1 / 1M tokens
+ "claude-3-sonnet-20240229": 1.5, // $3 / 1M tokens
+ "claude-3-5-sonnet-20240620": 1.5,
+ "claude-3-5-sonnet-20241022": 1.5,
+ "claude-3-7-sonnet-20250219": 1.5,
+ "claude-3-7-sonnet-20250219-thinking": 1.5,
+ "claude-sonnet-4-20250514": 1.5,
+ "claude-sonnet-4-5-20250929": 1.5,
+ "claude-opus-4-5-20251101": 2.5,
+ "claude-opus-4-6": 2.5,
+ "claude-opus-4-6-max": 2.5,
+ "claude-opus-4-6-high": 2.5,
+ "claude-opus-4-6-medium": 2.5,
+ "claude-opus-4-6-low": 2.5,
+ "claude-opus-4-7": 2.5,
+ "claude-opus-4-7-max": 2.5,
+ "claude-opus-4-7-xhigh": 2.5,
+ "claude-opus-4-7-high": 2.5,
+ "claude-opus-4-7-medium": 2.5,
+ "claude-opus-4-7-low": 2.5,
+ "claude-opus-4-8": 2.5,
+ "claude-opus-4-8-max": 2.5,
+ "claude-opus-4-8-xhigh": 2.5,
+ "claude-opus-4-8-high": 2.5,
+ "claude-opus-4-8-medium": 2.5,
+ "claude-opus-4-8-low": 2.5,
+ "claude-3-opus-20240229": 7.5, // $15 / 1M tokens
+ "claude-opus-4-20250514": 7.5,
+ "claude-opus-4-1-20250805": 7.5,
+ "ERNIE-4.0-8K": 0.120 * RMB,
+ "ERNIE-3.5-8K": 0.012 * RMB,
+ "ERNIE-3.5-8K-0205": 0.024 * RMB,
+ "ERNIE-3.5-8K-1222": 0.012 * RMB,
+ "ERNIE-Bot-8K": 0.024 * RMB,
+ "ERNIE-3.5-4K-0205": 0.012 * RMB,
+ "ERNIE-Speed-8K": 0.004 * RMB,
+ "ERNIE-Speed-128K": 0.004 * RMB,
+ "ERNIE-Lite-8K-0922": 0.008 * RMB,
+ "ERNIE-Lite-8K-0308": 0.003 * RMB,
+ "ERNIE-Tiny-8K": 0.001 * RMB,
+ "BLOOMZ-7B": 0.004 * RMB,
+ "Embedding-V1": 0.002 * RMB,
+ "bge-large-zh": 0.002 * RMB,
+ "bge-large-en": 0.002 * RMB,
+ "tao-8k": 0.002 * RMB,
+ "PaLM-2": 1,
+ "gemini-1.5-pro-latest": 1.25, // $3.5 / 1M tokens
+ "gemini-1.5-flash-latest": 0.075,
+ "gemini-2.0-flash": 0.05,
+ "gemini-2.5-pro-exp-03-25": 0.625,
+ "gemini-2.5-pro-preview-03-25": 0.625,
+ "gemini-2.5-pro": 0.625,
+ "gemini-2.5-flash-preview-04-17": 0.075,
+ "gemini-2.5-flash-preview-04-17-thinking": 0.075,
+ "gemini-2.5-flash-preview-05-20": 0.075,
+ "gemini-2.5-flash-preview-05-20-thinking": 0.075,
+ "gemini-2.5-flash-thinking-*": 0.075, // 用于为后续所有2.5 flash thinking budget 模型设置默认倍率
+ "gemini-2.5-pro-thinking-*": 0.625, // 用于为后续所有2.5 pro thinking budget 模型设置默认倍率
+ "gemini-2.5-flash-lite-preview-thinking-*": 0.05,
+ "gemini-2.5-flash-lite-preview-06-17": 0.05,
+ "gemini-2.5-flash": 0.15,
+ "gemini-robotics-er-1.5-preview": 0.15,
+ "gemini-embedding-001": 0.075,
+ "text-embedding-004": 0.001,
+ "chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens
+ "chatglm_pro": 0.7143, // ¥0.01 / 1k tokens
+ "chatglm_std": 0.3572, // ¥0.005 / 1k tokens
+ "chatglm_lite": 0.1429, // ¥0.002 / 1k tokens
+ "glm-4": 7.143, // ¥0.1 / 1k tokens
+ "glm-4v": 0.05 * RMB, // ¥0.05 / 1k tokens
+ "glm-4-alltools": 0.1 * RMB, // ¥0.1 / 1k tokens
+ "glm-3-turbo": 0.3572,
+ "glm-4-plus": 0.05 * RMB,
+ "glm-4-0520": 0.1 * RMB,
+ "glm-4-air": 0.001 * RMB,
+ "glm-4-airx": 0.01 * RMB,
+ "glm-4-long": 0.001 * RMB,
+ "glm-4-flash": 0,
+ "glm-4v-plus": 0.01 * RMB,
+ "qwen-turbo": 0.8572, // ¥0.012 / 1k tokens
+ "qwen-plus": 10, // ¥0.14 / 1k tokens
+ "text-embedding-v1": 0.05, // ¥0.0007 / 1k tokens
+ "SparkDesk-v1.1": 1.2858, // ¥0.018 / 1k tokens
+ "SparkDesk-v2.1": 1.2858, // ¥0.018 / 1k tokens
+ "SparkDesk-v3.1": 1.2858, // ¥0.018 / 1k tokens
+ "SparkDesk-v3.5": 1.2858, // ¥0.018 / 1k tokens
+ "SparkDesk-v4.0": 1.2858,
+ "hunyuan": 7.143, // ¥0.1 / 1k tokens // https://cloud.tencent.com/document/product/1729/97731#e0e6be58-60c8-469f-bdeb-6c264ce3b4d0
// https://platform.lingyiwanwu.com/docs#-计费单元
// 已经按照 7.2 来换算美元价格
"yi-34b-chat-0205": 0.18,
@@ -693,9 +694,23 @@ func GetAudioCompletionRatioCopy() map[string]float64 {
return audioCompletionRatioMap.ReadAll()
}
+// RoutingMatchModelName returns the name used for channel-ability and token-limit
+// fallback matching: strip @ modifiers and legacy aliases first, then apply
+// wildcard normalization.
+func RoutingMatchModelName(name string) string {
+ return FormatMatchingModelName(hostreasoning.BaseModelName(name))
+}
+
+// HasConfiguredModelRatio reports whether name has an explicit ratio entry
+// after wildcard normalization. Self-use fallback does not count.
+func HasConfiguredModelRatio(name string) bool {
+ name = FormatMatchingModelName(name)
+ _, ok := modelRatioMap.Get(name)
+ return ok
+}
+
// 转换模型名,减少渠道必须配置各种带参数模型
func FormatMatchingModelName(name string) string {
-
if strings.HasPrefix(name, "gemini-2.5-flash-lite") {
name = handleThinkingBudgetModel(name, "gemini-2.5-flash-lite", "gemini-2.5-flash-lite-thinking-*")
} else if strings.HasPrefix(name, "gemini-2.5-flash") {
diff --git a/setting/reasoning/suffix.go b/setting/reasoning/suffix.go
index 255e300430a3..b9defac5c066 100644
--- a/setting/reasoning/suffix.go
+++ b/setting/reasoning/suffix.go
@@ -4,6 +4,8 @@
package reasoning
import (
+ "strings"
+
kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/setting/model_setting"
)
@@ -20,8 +22,212 @@ var (
TrimGeminiThinkingSuffix = kitreasoning.TrimGeminiThinkingSuffix
)
-// ParseOpenAIReasoningEffortFromModelSuffix applies the host effort-tail
-// whitelist so real model IDs such as qwen-max are not treated as aliases.
+// ParseOpenAIReasoningEffortFromModelSuffix applies RelayKit's positive family
+// whitelist and the host EffortTailModelIDs escape hatch so real model IDs
+// such as gpt-5.1-codex-max remain opaque.
func ParseOpenAIReasoningEffortFromModelSuffix(modelName string) (string, string) {
return kitreasoning.ParseOpenAIReasoningEffortFromModelSuffix(modelName, model_setting.ShouldPreserveEffortTail)
}
+
+// ParseLegacyModelSuffix parses the old naked aliases only for positively
+// matched GPT/o-series, Claude, and Gemini model families. The provider prefix
+// before the final path segment is kept opaque.
+func ParseLegacyModelSuffix(modelName string, allowClaudeThinkingAlias bool, allowGeminiThinkingAlias bool) (string, kitreasoning.Intent, bool, error) {
+ prefix, bare := splitModelNamespace(modelName)
+
+ var (
+ base string
+ intent kitreasoning.Intent
+ found bool
+ err error
+ )
+ switch {
+ case strings.HasPrefix(bare, "claude-"):
+ base, intent, found, err = kitreasoning.ParseClaudeModelSuffix(bare, allowClaudeThinkingAlias)
+ case strings.HasPrefix(bare, "gemini-"):
+ base, intent, found, err = kitreasoning.ParseGeminiModelSuffix(bare, allowGeminiThinkingAlias)
+ default:
+ effort, openAIBase := ParseOpenAIReasoningEffortFromModelSuffix(bare)
+ if effort == "" {
+ return modelName, kitreasoning.Intent{}, false, nil
+ }
+ parsedEffort, parseErr := kitreasoning.ParseEffort(effort)
+ if parseErr != nil {
+ return modelName, kitreasoning.Intent{}, false, parseErr
+ }
+ mode := kitreasoning.ModeEnabled
+ if parsedEffort == kitreasoning.EffortNone {
+ mode = kitreasoning.ModeDisabled
+ }
+ base = openAIBase
+ intent = kitreasoning.Intent{Mode: mode, Effort: parsedEffort, Source: kitreasoning.SourceSuffix}
+ found = true
+ }
+ if err != nil || !found {
+ return modelName, kitreasoning.Intent{}, false, err
+ }
+ return prefix + base, intent, true, nil
+}
+
+// BaseModelName strips explicit model modifiers and any enabled legacy alias.
+// Names on the thinking-suffix blacklist stay verbatim, including @ tails.
+// Malformed legacy aliases stay intact so request conversion can report the
+// precise validation error later.
+func BaseModelName(modelName string) string {
+ if model_setting.ShouldPreserveThinkingSuffix(modelName) {
+ return modelName
+ }
+ base := kitreasoning.ParseModelModifiers(modelName).Base
+ if model_setting.ShouldPreserveThinkingSuffix(base) {
+ return base
+ }
+ legacyBase, _, found, err := ParseLegacyModelSuffix(
+ base,
+ model_setting.GetClaudeSettings().ThinkingAdapterEnabled,
+ model_setting.GetGeminiSettings().ThinkingAdapterEnabled,
+ )
+ if err != nil {
+ return base
+ }
+ if found {
+ return legacyBase
+ }
+ return base
+}
+
+func splitModelNamespace(modelName string) (string, string) {
+ if slash := strings.LastIndex(modelName, "/"); slash >= 0 {
+ return modelName[:slash+1], modelName[slash+1:]
+ }
+ return "", modelName
+}
+
+// CanonicalBillingModelNames returns specificity-descending canonical billing
+// name candidates (without the raw request name or the bare base). Explicit
+// @ modifiers and legacy aliases normalize through the same Intent, so order,
+// duplicates, and case do not matter. Temperature and topp never appear.
+func CanonicalBillingModelNames(modelName string) []string {
+ if model_setting.ShouldPreserveThinkingSuffix(modelName) {
+ return nil
+ }
+ spec := kitreasoning.ParseModelModifiers(modelName)
+ base := spec.Base
+ intent, hasThinking := billingIntentFromModifiers(spec)
+
+ if !model_setting.ShouldPreserveThinkingSuffix(base) {
+ legacyBase, legacyIntent, found, err := ParseLegacyModelSuffix(
+ base,
+ model_setting.GetClaudeSettings().ThinkingAdapterEnabled,
+ model_setting.GetGeminiSettings().ThinkingAdapterEnabled,
+ )
+ if err == nil && found {
+ base = legacyBase
+ if !hasThinking {
+ intent = legacyIntent
+ hasThinking = true
+ }
+ }
+ }
+ if !hasThinking {
+ return nil
+ }
+ return canonicalNamesFromIntent(base, intent)
+}
+
+func billingIntentFromModifiers(spec kitreasoning.ModelModifierSpec) (kitreasoning.Intent, bool) {
+ last := make(map[string]int, len(spec.Modifiers))
+ for index, modifier := range spec.Modifiers {
+ last[modifier.Key] = index
+ }
+
+ var (
+ intent kitreasoning.Intent
+ hasThinking bool
+ )
+ for index, modifier := range spec.Modifiers {
+ if last[modifier.Key] != index {
+ continue
+ }
+ switch modifier.Key {
+ case "thinking":
+ parsed, ok := kitreasoning.ParseThinkingModifier(modifier.Value)
+ if !ok {
+ continue
+ }
+ if hasThinking && parsed.Mode != kitreasoning.ModeDisabled && parsed.BudgetTokens == nil {
+ intent.Mode = parsed.Mode
+ intent.Source = kitreasoning.SourceSuffix
+ } else if hasThinking && parsed.Mode != kitreasoning.ModeDisabled {
+ intent.Mode = parsed.Mode
+ intent.BudgetTokens = parsed.BudgetTokens
+ intent.BudgetSource = parsed.BudgetSource
+ intent.Source = kitreasoning.SourceSuffix
+ } else {
+ intent = parsed
+ }
+ hasThinking = true
+ case "effort":
+ effort, err := kitreasoning.ParseEffort(modifier.Value)
+ if err != nil || effort == "" {
+ continue
+ }
+ if effort == kitreasoning.EffortNone {
+ intent = kitreasoning.Intent{Mode: kitreasoning.ModeDisabled, Effort: kitreasoning.EffortNone, Source: kitreasoning.SourceSuffix}
+ } else {
+ if intent.Mode == kitreasoning.ModeUnset || intent.Mode == kitreasoning.ModeDisabled {
+ intent.Mode = kitreasoning.ModeEnabled
+ }
+ intent.Effort = effort
+ intent.Source = kitreasoning.SourceSuffix
+ }
+ hasThinking = true
+ }
+ }
+ return intent, hasThinking
+}
+
+func canonicalNamesFromIntent(base string, intent kitreasoning.Intent) []string {
+ thinking, effort, ok := normalizeBillingThinking(intent)
+ if !ok || base == "" {
+ return nil
+ }
+
+ var names []string
+ if effort != "" {
+ names = append(names, base+"@effort:"+effort+"@thinking:"+thinking)
+ }
+ thinkingForm := base + "@thinking:" + thinking
+ if thinkingForm != base {
+ names = append(names, thinkingForm)
+ }
+
+ seen := make(map[string]struct{}, len(names))
+ out := make([]string, 0, len(names))
+ for _, name := range names {
+ if name == "" || name == base {
+ continue
+ }
+ if _, exists := seen[name]; exists {
+ continue
+ }
+ seen[name] = struct{}{}
+ out = append(out, name)
+ }
+ return out
+}
+
+func normalizeBillingThinking(intent kitreasoning.Intent) (thinking string, effort string, ok bool) {
+ if intent.Effort == kitreasoning.EffortNone || intent.Mode == kitreasoning.ModeDisabled {
+ return "off", "", true
+ }
+ if intent.BudgetTokens != nil && *intent.BudgetTokens == 0 {
+ return "off", "", true
+ }
+ if intent.Effort != "" && intent.Effort != kitreasoning.EffortNone {
+ return "on", strings.ToLower(string(intent.Effort)), true
+ }
+ if intent.Mode == kitreasoning.ModeEnabled || intent.Mode == kitreasoning.ModeAdaptive || intent.BudgetTokens != nil {
+ return "on", "", true
+ }
+ return "", "", false
+}
diff --git a/setting/reasoning/suffix_test.go b/setting/reasoning/suffix_test.go
new file mode 100644
index 000000000000..27440778a8e6
--- /dev/null
+++ b/setting/reasoning/suffix_test.go
@@ -0,0 +1,134 @@
+package reasoning
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/setting/model_setting"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCanonicalBillingModelNames(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ want []string
+ }{
+ {
+ name: "thinking on",
+ in: "qwen3-max@thinking:on",
+ want: []string{"qwen3-max@thinking:on"},
+ },
+ {
+ name: "shuffled temperature and thinking",
+ in: "qwen3-max@temperature:0.2@thinking:on",
+ want: []string{"qwen3-max@thinking:on"},
+ },
+ {
+ name: "thinking first then temperature",
+ in: "qwen3-max@thinking:on@temperature:0.2",
+ want: []string{"qwen3-max@thinking:on"},
+ },
+ {
+ name: "budget normalizes to on",
+ in: "qwen3-max@thinking:8192",
+ want: []string{"qwen3-max@thinking:on"},
+ },
+ {
+ name: "minus one normalizes to on",
+ in: "qwen3-max@thinking:-1",
+ want: []string{"qwen3-max@thinking:on"},
+ },
+ {
+ name: "adaptive normalizes to on",
+ in: "qwen3-max@thinking:adaptive",
+ want: []string{"qwen3-max@thinking:on"},
+ },
+ {
+ name: "thinking off",
+ in: "qwen3-max@thinking:off",
+ want: []string{"qwen3-max@thinking:off"},
+ },
+ {
+ name: "effort none becomes thinking off",
+ in: "qwen3-max@effort:none",
+ want: []string{"qwen3-max@thinking:off"},
+ },
+ {
+ name: "effort high implies thinking on",
+ in: "qwen3-max@effort:high",
+ want: []string{"qwen3-max@effort:high@thinking:on", "qwen3-max@thinking:on"},
+ },
+ {
+ name: "effort and thinking keys sorted",
+ in: "qwen3-max@thinking:on@effort:high@temperature:0.2",
+ want: []string{"qwen3-max@effort:high@thinking:on", "qwen3-max@thinking:on"},
+ },
+ {
+ name: "duplicate last wins then normalize",
+ in: "qwen3-max@thinking:off@thinking:on@effort:low@effort:high",
+ want: []string{"qwen3-max@effort:high@thinking:on", "qwen3-max@thinking:on"},
+ },
+ {
+ name: "legacy thinking alias",
+ in: "claude-3-7-sonnet-thinking",
+ want: []string{"claude-3-7-sonnet@thinking:on"},
+ },
+ {
+ name: "legacy thinking budget matches explicit budget",
+ in: "gemini-2.5-flash-thinking-8192",
+ want: []string{"gemini-2.5-flash@thinking:on"},
+ },
+ {
+ name: "legacy nothinking",
+ in: "claude-3-7-sonnet-nothinking",
+ want: []string{"claude-3-7-sonnet@thinking:off"},
+ },
+ {
+ name: "temperature only has no reasoning state",
+ in: "qwen3-max@temperature:0.7",
+ want: nil,
+ },
+ }
+
+ geminiSettings := model_setting.GetGeminiSettings()
+ oldGemini := geminiSettings.ThinkingAdapterEnabled
+ geminiSettings.ThinkingAdapterEnabled = true
+ t.Cleanup(func() { geminiSettings.ThinkingAdapterEnabled = oldGemini })
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, CanonicalBillingModelNames(tt.in))
+ })
+ }
+
+ assert.Equal(t,
+ CanonicalBillingModelNames("gemini-2.5-flash@thinking:8192"),
+ CanonicalBillingModelNames("gemini-2.5-flash-thinking-8192"),
+ )
+ assert.Equal(t, "gpt-5.1-codex-max", BaseModelName("gpt-5.1-codex-max"))
+ assert.Empty(t, CanonicalBillingModelNames("gpt-5.1-codex-max"))
+}
+
+func TestParseOpenAIReasoningEffortPreservesCodexMax(t *testing.T) {
+ effort, base := ParseOpenAIReasoningEffortFromModelSuffix("gpt-5.1-codex-max")
+ assert.Empty(t, effort)
+ assert.Equal(t, "gpt-5.1-codex-max", base)
+}
+
+func TestBaseModelNameStripsModifiers(t *testing.T) {
+ require.Equal(t, "qwen3-max", BaseModelName("qwen3-max@thinking:on@temperature:0.2"))
+}
+
+func TestExemptAtNameIsOpaqueForBillingIdentity(t *testing.T) {
+ settings := model_setting.GetGlobalSettings()
+ original := append([]string(nil), settings.ThinkingModelBlacklist...)
+ t.Cleanup(func() { settings.ThinkingModelBlacklist = original })
+ settings.ThinkingModelBlacklist = append(original, "re:.*@sha256:.*")
+
+ const model = "opaque@sha256:deadbeef"
+ assert.Equal(t, model, BaseModelName(model))
+ assert.Empty(t, CanonicalBillingModelNames(model))
+ assert.Equal(t, "kimi-k2-thinking", BaseModelName("kimi-k2-thinking"))
+ assert.Empty(t, CanonicalBillingModelNames("kimi-k2-thinking"))
+}
diff --git a/web/src/features/system-settings/models/global-settings-card.tsx b/web/src/features/system-settings/models/global-settings-card.tsx
index 244ebe66518b..ff2980772bb3 100644
--- a/web/src/features/system-settings/models/global-settings-card.tsx
+++ b/web/src/features/system-settings/models/global-settings-card.tsx
@@ -50,7 +50,7 @@ import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
const thinkingBlacklistExample = JSON.stringify(
- ['moonshotai/kimi-k2-thinking', 'kimi-k2-thinking'],
+ ['moonshotai/kimi-k2-thinking', 'kimi-k2-thinking', 're:.*@sha256:.*'],
null,
2
)
@@ -230,7 +230,7 @@ export function GlobalSettingsCard({ defaultValues }: GlobalSettingsCardProps) {
{t(
- 'Models listed here will not automatically append or remove -thinking / -nothinking suffixes.'
+ 'Models listed here skip automatic -thinking / -nothinking suffix handling. Matched names are also exempt from @-modifier parsing and 400 validation. Prefix an entry with re: to match the full model name as a Go regular expression, for example re:.*@sha256:.*'
)}
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 63f6efe3b75d..64b6c97c0f1b 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -672,9 +672,9 @@
"Bind a Pancake store + product": "Bind a Pancake store + product",
"Bind an email address to your account.": "Bind an email address to your account.",
"Bind Email": "Bind Email",
+ "Bind task plugins": "Bind task plugins",
"Bind Telegram Account": "Bind Telegram Account",
"Bind WeChat Account": "Bind WeChat Account",
- "Bind task plugins": "Bind task plugins",
"Binding Information": "Binding Information",
"Binding successful!": "Binding successful!",
"Binding your {{provider}} account": "Binding your {{provider}} account",
@@ -689,6 +689,7 @@
"Blocked keywords": "Blocked keywords",
"Blocks messages when sensitive keywords are detected.": "Blocks messages when sensitive keywords are detected.",
"Body param": "Body param",
+ "Boolean": "Boolean",
"Border radius": "Border radius",
"Bot Name": "Bot Name",
"Bot Protection": "Bot Protection",
@@ -713,9 +714,8 @@
"Built for developers,": "Built for developers,",
"Built-in": "Built-in",
"Built-in Device": "Built-in Device",
- "Built-in v{{factory}} / marketplace v{{market}}": "Built-in v{{factory}} / marketplace v{{market}}",
- "Updates with the system": "Updates with the system",
"Built-in is v{{factory}}; delete the custom version to return to it": "Built-in is v{{factory}}; delete the custom version to return to it",
+ "Built-in v{{factory}} / marketplace v{{market}}": "Built-in v{{factory}} / marketplace v{{market}}",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key",
"by": "by",
"By category": "By category",
@@ -857,11 +857,13 @@
"Choose a username": "Choose a username",
"Choose an amount and payment method": "Choose an amount and payment method",
"Choose and order the groups this API key will try.": "Choose and order the groups this API key will try.",
+ "Choose another file": "Choose another file",
"Choose between default expanded, compact icon-only, or full layout mode": "Choose between default expanded, compact icon-only, or full layout mode",
"Choose between inset, floating, or standard sidebar layout": "Choose between inset, floating, or standard sidebar layout",
"Choose between left-to-right or right-to-left site direction": "Choose between left-to-right or right-to-left site direction",
"Choose between system preference, light mode, or dark mode": "Choose between system preference, light mode, or dark mode",
"Choose channels to sync upstream ratio configurations from": "Choose channels to sync upstream ratio configurations from",
+ "Choose file": "Choose file",
"Choose Group": "Choose Group",
"Choose how flow widths are calculated.": "Choose how flow widths are calculated.",
"Choose how quota values are shown to users": "Choose how quota values are shown to users",
@@ -1448,11 +1450,11 @@
"Disable 2FA": "Disable 2FA",
"Disable All": "Disable All",
"Disable custom task plugins?": "Disable custom task plugins?",
- "Disable task plugins?": "Disable task plugins?",
"Disable on failure": "Disable on failure",
"Disable selected channels": "Disable selected channels",
"Disable selected models": "Disable selected models",
"Disable store passthrough": "Disable store passthrough",
+ "Disable task plugins?": "Disable task plugins?",
"Disable this key?": "Disable this key?",
"Disable threshold (seconds)": "Disable threshold (seconds)",
"Disable Two-Factor Authentication": "Disable Two-Factor Authentication",
@@ -1530,6 +1532,7 @@
"Drawing Logs": "Drawing Logs",
"Drawing task polling": "Drawing task polling",
"Drawing task records": "Drawing task records",
+ "Drop a JavaScript plugin file here": "Drop a JavaScript plugin file here",
"Dry run result": "Dry run result",
"Duplicate": "Duplicate",
"Duplicate group names: {{names}}": "Duplicate group names: {{names}}",
@@ -1652,7 +1655,6 @@
"Enable All": "Enable All",
"Enable check-in feature": "Enable check-in feature",
"Enable custom task plugins": "Enable custom task plugins",
- "Enable task plugins": "Enable task plugins",
"Enable Data Dashboard": "Enable Data Dashboard",
"Enable demo mode with limited functionality": "Enable demo mode with limited functionality",
"Enable Discord OAuth": "Enable Discord OAuth",
@@ -1681,6 +1683,7 @@
"Enable SSRF Protection": "Enable SSRF Protection",
"Enable STARTTLS": "Enable STARTTLS",
"Enable streaming mode for the test request.": "Enable streaming mode for the test request.",
+ "Enable task plugins": "Enable task plugins",
"Enable Telegram OAuth": "Enable Telegram OAuth",
"Enable test mode for Creem payments": "Enable test mode for Creem payments",
"Enable this key?": "Enable this key?",
@@ -1792,7 +1795,6 @@
"Enterprise-grade security with comprehensive permission management": "Enterprise-grade security with comprehensive permission management",
"Entrypoint (space separated)": "Entrypoint (space separated)",
"Enum": "Enum",
- "Boolean": "Boolean",
"Enum values": "Enum values",
"Env (JSON object)": "Env (JSON object)",
"Environment variables": "Environment variables",
@@ -1877,6 +1879,7 @@
"extras": "extras",
"Factory": "Factory",
"Factory and custom plugin behavior": "Factory and custom plugin behavior",
+ "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.": "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.",
"Factory plugins cannot be deleted or disabled individually. A custom version can override them; deleting or disabling that version restores the factory plugin. Third-party-only platforms become unavailable when their plugin is deleted or disabled.": "Factory plugins cannot be deleted or disabled individually. A custom version can override them; deleting or disabling that version restores the factory plugin. Third-party-only platforms become unavailable when their plugin is deleted or disabled.",
"Fail Reason": "Fail Reason",
"Fail Reason Details": "Fail Reason Details",
@@ -2040,8 +2043,8 @@
"Fetch available models for:": "Fetch available models for:",
"Fetch available models from upstream": "Fetch available models from upstream",
"Fetch from Upstream": "Fetch from Upstream",
- "Fetch Models": "Fetch Models",
"Fetch mode": "Fetch mode",
+ "Fetch Models": "Fetch Models",
"Fetched {{count}} model(s) from upstream": "Fetched {{count}} model(s) from upstream",
"Fetched {{count}} models": "Fetched {{count}} models",
"Fetched in your browser and placed in the source field below for review. GitHub and gist page URLs are rewritten to their raw URL automatically.": "Fetched in your browser and placed in the source field below for review. GitHub and gist page URLs are rewritten to their raw URL automatically.",
@@ -2879,6 +2882,7 @@
"Models exposed by this channel": "Models exposed by this channel",
"Models fetched successfully": "Models fetched successfully",
"Models filled to form": "Models filled to form",
+ "Models listed here skip automatic -thinking / -nothinking suffix handling. Matched names are also exempt from @-modifier parsing and 400 validation. Prefix an entry with re: to match the full model name as a Go regular expression, for example re:.*@sha256:.*": "Models listed here skip automatic -thinking / -nothinking suffix handling. Matched names are also exempt from @-modifier parsing and 400 validation. Prefix an entry with re: to match the full model name as a Go regular expression, for example re:.*@sha256:.*",
"Models listed here will not automatically append or remove -thinking / -nothinking suffixes.": "Models listed here will not automatically append or remove -thinking / -nothinking suffixes.",
"Models losing positions": "Models losing positions",
"Models losing the most positions": "Models losing the most positions",
@@ -2964,9 +2968,9 @@
"Native Claude Messages plus OpenAI Chat compatibility forwarding.": "Native Claude Messages plus OpenAI Chat compatibility forwarding.",
"Native format": "Native format",
"Native forwarding": "Native forwarding",
- "Native routes": "Native routes",
"Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.",
"Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "Native OpenAI routes plus optional Claude and Gemini compatibility routes.",
+ "Native routes": "Native routes",
"Need a redemption code?": "Need a redemption code?",
"Needs API key": "Needs API key",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.",
@@ -3334,6 +3338,7 @@
"Optional JSON policy to restrict access based on user info fields": "Optional JSON policy to restrict access based on user info fields",
"Optional minimum recharge amount for this method.": "Optional minimum recharge amount for this method.",
"Optional multiplier per user group used when calculating recharge pricing. Provide a JSON object such as": "Optional multiplier per user group used when calculating recharge pricing. Provide a JSON object such as",
+ "Optional note describing this version": "Optional note describing this version",
"Optional notes about this channel": "Optional notes about this channel",
"Optional notes about when to use this group": "Optional notes about when to use this group",
"Optional ratio used when upstream cache hits occur.": "Optional ratio used when upstream cache hits occur.",
@@ -3614,11 +3619,6 @@
"Plugin key": "Plugin key",
"Plugin metadata": "Plugin metadata",
"Plugin source": "Plugin source",
- "Choose file": "Choose file",
- "Choose another file": "Choose another file",
- "Drop a JavaScript plugin file here": "Drop a JavaScript plugin file here",
- "Single .js file, up to 1 MiB. Its source is shown below before upload.": "Single .js file, up to 1 MiB. Its source is shown below before upload.",
- "Optional note describing this version": "Optional note describing this version",
"Plugin source exceeds the 1 MiB limit.": "Plugin source exceeds the 1 MiB limit.",
"Plugin uploaded successfully": "Plugin uploaded successfully",
"Plugin version activated": "Plugin version activated",
@@ -3639,8 +3639,8 @@
"Pre-Consume for Free Models": "Pre-Consume for Free Models",
"Pre-consumed": "Pre-consumed",
"Pre-Consumed Quota": "Pre-Consumed Quota",
- "Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.": "Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.",
"Preference saved as {{pref}}, but no active subscription. Requests will be rejected.": "Preference saved as {{pref}}, but no active subscription. Requests will be rejected.",
+ "Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.": "Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.",
"Preferences": "Preferences",
"Prefill Group Management": "Prefill Group Management",
"Prefill Groups": "Prefill Groups",
@@ -4452,6 +4452,7 @@
"Simple": "Simple",
"Simple mode only returns message; status code and error type use system defaults.": "Simple mode only returns message; status code and error type use system defaults.",
"Simple mode: prune objects by type, e.g. redacted_thinking.": "Simple mode: prune objects by type, e.g. redacted_thinking.",
+ "Single .js file, up to 1 MiB. Its source is shown below before upload.": "Single .js file, up to 1 MiB. Its source is shown below before upload.",
"Single Key": "Single Key",
"Site & Branding": "Site & Branding",
"Site Key": "Site Key",
@@ -4679,8 +4680,8 @@
"Task logs": "Task logs",
"Task Logs": "Task Logs",
"Task Plugin": "Task Plugin",
- "Task plugin setting updated": "Task plugin setting updated",
"Task plugin *": "Task plugin *",
+ "Task plugin setting updated": "Task plugin setting updated",
"Task Plugins": "Task Plugins",
"Task pricing": "Task pricing",
"Task pricing not configured": "Task pricing not configured",
@@ -4791,7 +4792,6 @@
"Third-party plugin risk": "Third-party plugin risk",
"Third-party source risk": "Third-party source risk",
"Third-party-only plugins become unavailable immediately. In-flight tasks will be handled by timeout cleanup.": "Third-party-only plugins become unavailable immediately. In-flight tasks will be handled by timeout cleanup.",
- "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.": "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.",
"This action cannot be undone.": "This action cannot be undone.",
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "This action cannot be undone. This will permanently delete your account and remove all your data from our servers.",
"This action will permanently remove 2FA protection from your account.": "This action will permanently remove 2FA protection from your account.",
@@ -5137,6 +5137,7 @@
"Updated successfully": "Updated successfully",
"Updated system setting {{key}}": "Updated system setting {{key}}",
"Updated user {{username}} (ID: {{id}})": "Updated user {{username}} (ID: {{id}})",
+ "Updates with the system": "Updates with the system",
"Updating all channel balances. This may take a while. Please refresh to see results.": "Updating all channel balances. This may take a while. Please refresh to see results.",
"Updating...": "Updating...",
"Upgrade {{name}}": "Upgrade {{name}}",
@@ -5539,4 +5540,4 @@
"Zhipu V4": "Zhipu V4",
"Zoom": "Zoom"
}
-}
\ No newline at end of file
+}
diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json
index 176d3100cc4e..b68f621284e2 100644
--- a/web/src/i18n/locales/fr.json
+++ b/web/src/i18n/locales/fr.json
@@ -672,9 +672,9 @@
"Bind a Pancake store + product": "Associer une boutique et un produit Pancake",
"Bind an email address to your account.": "Associez une adresse e-mail à votre compte.",
"Bind Email": "Lier l'e-mail",
+ "Bind task plugins": "Lier des plugins de tâche",
"Bind Telegram Account": "Lier le compte Telegram",
"Bind WeChat Account": "Lier le compte WeChat",
- "Bind task plugins": "Lier des plugins de tâche",
"Binding Information": "Informations de liaison",
"Binding successful!": "Liaison réussie !",
"Binding your {{provider}} account": "Liaison de votre compte {{provider}}",
@@ -689,6 +689,7 @@
"Blocked keywords": "Mots-clés bloqués",
"Blocks messages when sensitive keywords are detected.": "Bloque les messages lorsque des mots-clés sensibles sont détectés.",
"Body param": "Paramètre de corps",
+ "Boolean": "Booléen",
"Border radius": "Rayon de bordure",
"Bot Name": "Nom du bot",
"Bot Protection": "Protection des bots",
@@ -713,9 +714,8 @@
"Built for developers,": "Conçu pour les développeurs,",
"Built-in": "Intégré",
"Built-in Device": "Appareil intégré",
- "Built-in v{{factory}} / marketplace v{{market}}": "Intégré v{{factory}} / marché v{{market}}",
- "Updates with the system": "Mise à jour système",
"Built-in is v{{factory}}; delete the custom version to return to it": "La version intégrée est v{{factory}} ; supprimez la version personnalisée pour y revenir",
+ "Built-in v{{factory}} / marketplace v{{market}}": "Intégré v{{factory}} / marché v{{market}}",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Intégré : empreinte digitale/visage du téléphone, ou Windows Hello ; Externe : clé de sécurité USB",
"by": "par",
"By category": "Par catégorie",
@@ -857,11 +857,13 @@
"Choose a username": "Choisir un nom d'utilisateur",
"Choose an amount and payment method": "Choisir un montant et un mode de paiement",
"Choose and order the groups this API key will try.": "Sélectionnez et ordonnez les groupes que cette clé API essaiera.",
+ "Choose another file": "Choisir un autre fichier",
"Choose between default expanded, compact icon-only, or full layout mode": "Choisissez entre le mode d'affichage étendu par défaut, compact (icône uniquement) ou complet",
"Choose between inset, floating, or standard sidebar layout": "Choisissez entre la disposition de la barre latérale intégrée, flottante ou standard",
"Choose between left-to-right or right-to-left site direction": "Choisissez entre la direction du site de gauche à droite ou de droite à gauche",
"Choose between system preference, light mode, or dark mode": "Choisissez entre la préférence système, le mode clair ou le mode sombre",
"Choose channels to sync upstream ratio configurations from": "Choisissez les canaux à partir desquels synchroniser les configurations de ratio amont",
+ "Choose file": "Choisir un fichier",
"Choose Group": "Choisir un groupe",
"Choose how flow widths are calculated.": "Choisissez comment l'épaisseur des flux est calculée.",
"Choose how quota values are shown to users": "Choisissez comment les valeurs de quota sont affichées aux utilisateurs",
@@ -1448,11 +1450,11 @@
"Disable 2FA": "Désactiver la 2FA",
"Disable All": "Désactiver tout",
"Disable custom task plugins?": "Désactiver les plugins personnalisés ?",
- "Disable task plugins?": "Désactiver les plugins de tâche ?",
"Disable on failure": "Désactiver en cas d'échec",
"Disable selected channels": "Désactiver les canaux sélectionnés",
"Disable selected models": "Désactiver les modèles sélectionnés",
"Disable store passthrough": "Désactiver la transmission du champ store",
+ "Disable task plugins?": "Désactiver les plugins de tâche ?",
"Disable this key?": "Désactiver cette clé ?",
"Disable threshold (seconds)": "Seuil de désactivation (secondes)",
"Disable Two-Factor Authentication": "Désactiver l'authentification à deux facteurs",
@@ -1530,6 +1532,7 @@
"Drawing Logs": "Journaux de dessin",
"Drawing task polling": "Interrogation des tâches de dessin",
"Drawing task records": "Historique des tâches de dessin",
+ "Drop a JavaScript plugin file here": "Déposez ici un fichier de plugin JavaScript",
"Dry run result": "Résultat de la simulation",
"Duplicate": "Dupliquer",
"Duplicate group names: {{names}}": "Noms de groupe en double : {{names}}",
@@ -1652,7 +1655,6 @@
"Enable All": "Tout activer",
"Enable check-in feature": "Activer la fonction de connexion",
"Enable custom task plugins": "Activer les plugins de tâches personnalisés",
- "Enable task plugins": "Activer les plugins de tâche",
"Enable Data Dashboard": "Activer le tableau de bord des données",
"Enable demo mode with limited functionality": "Activer le mode démo avec des fonctionnalités limitées",
"Enable Discord OAuth": "Activer OAuth Discord",
@@ -1681,6 +1683,7 @@
"Enable SSRF Protection": "Activer la protection SSRF",
"Enable STARTTLS": "Activer STARTTLS",
"Enable streaming mode for the test request.": "Activer le mode streaming pour la requête de test.",
+ "Enable task plugins": "Activer les plugins de tâche",
"Enable Telegram OAuth": "Activer Telegram OAuth",
"Enable test mode for Creem payments": "Activer le mode test pour les paiements Creem",
"Enable this key?": "Activer cette clé ?",
@@ -1792,7 +1795,6 @@
"Enterprise-grade security with comprehensive permission management": "Sécurité de niveau entreprise avec gestion complète des autorisations",
"Entrypoint (space separated)": "Point d'entrée (séparés par des espaces)",
"Enum": "Énumération",
- "Boolean": "Booléen",
"Enum values": "Valeurs de l'énumération",
"Env (JSON object)": "Env (objet JSON)",
"Environment variables": "Variables d'environnement",
@@ -1877,6 +1879,7 @@
"extras": "suppléments",
"Factory": "Intégré",
"Factory and custom plugin behavior": "Comportement des plugins intégrés et personnalisés",
+ "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.": "Les plugins d’usine et personnalisés cessent immédiatement de servir. Les tâches en cours seront traitées par le nettoyage des délais d’attente.",
"Factory plugins cannot be deleted or disabled individually. A custom version can override them; deleting or disabling that version restores the factory plugin. Third-party-only platforms become unavailable when their plugin is deleted or disabled.": "Les plugins intégrés ne peuvent pas être supprimés ni désactivés individuellement. Une version personnalisée peut les remplacer ; sa suppression ou désactivation restaure le plugin intégré. Une plateforme tierce devient indisponible si son plugin est supprimé ou désactivé.",
"Fail Reason": "Raison de l'échec",
"Fail Reason Details": "Détails de la raison de l'échec",
@@ -2040,8 +2043,8 @@
"Fetch available models for:": "Récupérer les modèles disponibles pour :",
"Fetch available models from upstream": "Récupérer les modèles disponibles en amont",
"Fetch from Upstream": "Récupérer depuis l'amont",
- "Fetch Models": "Récupérer les modèles",
"Fetch mode": "Mode de récupération",
+ "Fetch Models": "Récupérer les modèles",
"Fetched {{count}} model(s) from upstream": "{{count}} modèle(s) récupéré(s) depuis l'amont",
"Fetched {{count}} models": "{{count}} modèles récupérés",
"Fetched in your browser and placed in the source field below for review. GitHub and gist page URLs are rewritten to their raw URL automatically.": "Récupéré par votre navigateur et placé dans le champ de code ci-dessous pour examen. Les URL de pages GitHub et gist sont automatiquement réécrites en URL raw.",
@@ -2879,6 +2882,7 @@
"Models exposed by this channel": "Modeles exposes par ce canal",
"Models fetched successfully": "Modèles récupérés avec succès",
"Models filled to form": "Modèles remplis pour le formulaire",
+ "Models listed here skip automatic -thinking / -nothinking suffix handling. Matched names are also exempt from @-modifier parsing and 400 validation. Prefix an entry with re: to match the full model name as a Go regular expression, for example re:.*@sha256:.*": "Les modèles listés ici n'ajoutent ni ne retirent automatiquement les suffixes -thinking / -nothinking. Les noms correspondants sont aussi exemptés de l'analyse et de la validation des modificateurs @ (erreur 400). Un préfixe re: interprète l'entrée comme une expression régulière Go sur le nom complet, par exemple re:.*@sha256:.*",
"Models listed here will not automatically append or remove -thinking / -nothinking suffixes.": "Les modèles listés ici n'ajouteront ni ne supprimeront automatiquement les suffixes -thinking / -nothinking.",
"Models losing positions": "Modèles perdant des positions",
"Models losing the most positions": "Modèles qui perdent le plus de places",
@@ -2964,9 +2968,9 @@
"Native Claude Messages plus OpenAI Chat compatibility forwarding.": "Claude Messages natif avec transfert compatible OpenAI Chat.",
"Native format": "Format natif",
"Native forwarding": "Transfert natif",
- "Native routes": "Routes natives",
"Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Routes Gemini natives avec transfert compatible OpenAI Chat et Responses.",
"Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "Routes OpenAI natives avec compatibilité Claude et Gemini en option.",
+ "Native routes": "Routes natives",
"Need a redemption code?": "Besoin d'un code d'échange ?",
"Needs API key": "Clé API requise",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "JSON imbriqué définissant des règles par groupe pour ajouter (+:), supprimer (-:), ou ajouter des groupes utilisables.",
@@ -3334,6 +3338,7 @@
"Optional JSON policy to restrict access based on user info fields": "Politique JSON optionnelle pour restreindre l'accès en fonction des champs d'informations utilisateur",
"Optional minimum recharge amount for this method.": "Montant minimum de recharge optionnel pour cette méthode.",
"Optional multiplier per user group used when calculating recharge pricing. Provide a JSON object such as": "Multiplicateur optionnel par groupe d'utilisateurs utilisé lors du calcul des prix de recharge. Fournissez un objet JSON tel que",
+ "Optional note describing this version": "Note facultative décrivant cette version",
"Optional notes about this channel": "Notes optionnelles sur ce canal",
"Optional notes about when to use this group": "Notes optionnelles sur le moment d'utiliser ce groupe",
"Optional ratio used when upstream cache hits occur.": "Ratio optionnel utilisé en cas de succès du cache en amont.",
@@ -3614,11 +3619,6 @@
"Plugin key": "Clé du plugin",
"Plugin metadata": "Métadonnées du plugin",
"Plugin source": "Source du plugin",
- "Choose file": "Choisir un fichier",
- "Choose another file": "Choisir un autre fichier",
- "Drop a JavaScript plugin file here": "Déposez ici un fichier de plugin JavaScript",
- "Single .js file, up to 1 MiB. Its source is shown below before upload.": "Un seul fichier .js, jusqu’à 1 Mio. Sa source est affichée ci-dessous avant l’envoi.",
- "Optional note describing this version": "Note facultative décrivant cette version",
"Plugin source exceeds the 1 MiB limit.": "Le code du plugin dépasse la limite de 1 Mio.",
"Plugin uploaded successfully": "Plugin importé avec succès",
"Plugin version activated": "Version du plugin activée",
@@ -3639,8 +3639,8 @@
"Pre-Consume for Free Models": "Pré-consommation pour les modèles gratuits",
"Pre-consumed": "Pré-consommé",
"Pre-Consumed Quota": "Quota pré-consommé",
- "Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.": "Préférence enregistrée comme {{pref}}, mais aucun abonnement actif. Le portefeuille sera utilisé automatiquement.",
"Preference saved as {{pref}}, but no active subscription. Requests will be rejected.": "Préférence enregistrée comme {{pref}}, mais aucun abonnement actif. Les demandes seront rejetées.",
+ "Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.": "Préférence enregistrée comme {{pref}}, mais aucun abonnement actif. Le portefeuille sera utilisé automatiquement.",
"Preferences": "Préférences",
"Prefill Group Management": "Gestion des groupes de préremplissage",
"Prefill Groups": "Groupes de préremplissage",
@@ -4452,6 +4452,7 @@
"Simple": "Simple",
"Simple mode only returns message; status code and error type use system defaults.": "Le mode simple ne retourne que le message ; le code de statut et le type d'erreur utilisent les valeurs par défaut.",
"Simple mode: prune objects by type, e.g. redacted_thinking.": "Mode simple : nettoyer les objets par type, ex. redacted_thinking.",
+ "Single .js file, up to 1 MiB. Its source is shown below before upload.": "Un seul fichier .js, jusqu’à 1 Mio. Sa source est affichée ci-dessous avant l’envoi.",
"Single Key": "Clé unique",
"Site & Branding": "Site et marque",
"Site Key": "Clé du site",
@@ -4679,8 +4680,8 @@
"Task logs": "Journaux des tâches",
"Task Logs": "Journaux de tâches",
"Task Plugin": "Plugin de tâche",
- "Task plugin setting updated": "Paramètre des plugins de tâche mis à jour",
"Task plugin *": "Plugin de tâche *",
+ "Task plugin setting updated": "Paramètre des plugins de tâche mis à jour",
"Task Plugins": "Plugins de tâches",
"Task pricing": "Tarification des tâches",
"Task pricing not configured": "Tarification des tâches non configurée",
@@ -4791,7 +4792,6 @@
"Third-party plugin risk": "Risque des plugins tiers",
"Third-party source risk": "Risque des sources tierces",
"Third-party-only plugins become unavailable immediately. In-flight tasks will be handled by timeout cleanup.": "Les plugins tiers deviennent immédiatement indisponibles. Les tâches en cours seront gérées par le nettoyage après expiration.",
- "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.": "Les plugins d’usine et personnalisés cessent immédiatement de servir. Les tâches en cours seront traitées par le nettoyage des délais d’attente.",
"This action cannot be undone.": "Cette action est irréversible.",
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "Cette action est irréversible. Cela supprimera définitivement votre compte et toutes vos données de nos serveurs.",
"This action will permanently remove 2FA protection from your account.": "Cette action supprimera définitivement la protection 2FA de votre compte.",
@@ -5137,6 +5137,7 @@
"Updated successfully": "Mise à jour réussie",
"Updated system setting {{key}}": "Paramètre système {{key}} mis à jour",
"Updated user {{username}} (ID: {{id}})": "Utilisateur {{username}} mis à jour (ID : {{id}})",
+ "Updates with the system": "Mise à jour système",
"Updating all channel balances. This may take a while. Please refresh to see results.": "Mise à jour de tous les soldes des canaux. Cela peut prendre un certain temps. Veuillez actualiser pour voir les résultats.",
"Updating...": "Mise à jour...",
"Upgrade {{name}}": "Mettre à jour {{name}}",
@@ -5539,4 +5540,4 @@
"Zhipu V4": "Zhipu V4",
"Zoom": "Zoom"
}
-}
\ No newline at end of file
+}
diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json
index e07847df1b16..631c54d5d6dd 100644
--- a/web/src/i18n/locales/ja.json
+++ b/web/src/i18n/locales/ja.json
@@ -672,9 +672,9 @@
"Bind a Pancake store + product": "Pancake のストアと商品を紐付ける",
"Bind an email address to your account.": "アカウントにメールアドレスを紐付けます。",
"Bind Email": "メールアドレス連携",
+ "Bind task plugins": "タスクプラグインを紐付け",
"Bind Telegram Account": "Telegram連携",
"Bind WeChat Account": "WeChatアカウント連携",
- "Bind task plugins": "タスクプラグインを紐付け",
"Binding Information": "連携情報",
"Binding successful!": "紐付けが成功しました!",
"Binding your {{provider}} account": "{{provider}} アカウントをバインド中",
@@ -689,6 +689,7 @@
"Blocked keywords": "ブロックされたキーワード",
"Blocks messages when sensitive keywords are detected.": "機密性の高いキーワードが検出された場合にメッセージをブロックします。",
"Body param": "ボディパラメータ",
+ "Boolean": "真偽値",
"Border radius": "角丸",
"Bot Name": "ボット名",
"Bot Protection": "ボット保護",
@@ -713,9 +714,8 @@
"Built for developers,": "開発者のために構築、",
"Built-in": "組み込み",
"Built-in Device": "内蔵デバイス",
- "Built-in v{{factory}} / marketplace v{{market}}": "組み込み v{{factory}} / マーケット v{{market}}",
- "Updates with the system": "システムとともに更新",
"Built-in is v{{factory}}; delete the custom version to return to it": "組み込みは v{{factory}} です。カスタム版を削除すると戻ります",
+ "Built-in v{{factory}} / marketplace v{{market}}": "組み込み v{{factory}} / マーケット v{{market}}",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "内蔵: 電話の指紋/顔認証、またはWindows Hello。外部: USBセキュリティキー",
"by": "によって",
"By category": "カテゴリ別",
@@ -857,11 +857,13 @@
"Choose a username": "ユーザー名を選択",
"Choose an amount and payment method": "金額と支払い方法を選択してください",
"Choose and order the groups this API key will try.": "この API キーが試行するグループを選択して並べ替えます。",
+ "Choose another file": "別のファイルを選択",
"Choose between default expanded, compact icon-only, or full layout mode": "デフォルトの展開表示、コンパクトなアイコンのみ、またはフルレイアウトモードから選択します",
"Choose between inset, floating, or standard sidebar layout": "インセット、フローティング、または標準のサイドバーレイアウトから選択します",
"Choose between left-to-right or right-to-left site direction": "左から右、または右から左のサイトの方向を選択します",
"Choose between system preference, light mode, or dark mode": "システム設定、ライトモード、またはダークモードから選択します",
"Choose channels to sync upstream ratio configurations from": "アップストリームの比率設定を同期するチャネルを選択してください",
+ "Choose file": "ファイルを選択",
"Choose Group": "グループを選択",
"Choose how flow widths are calculated.": "フローの線幅をどの指標で計算するかを選択します。",
"Choose how quota values are shown to users": "クォータ値がユーザーにどのように表示されるかを選択してください",
@@ -1448,11 +1450,11 @@
"Disable 2FA": "2FAを無効にする",
"Disable All": "すべて無効にする",
"Disable custom task plugins?": "カスタムタスクプラグインを無効化しますか?",
- "Disable task plugins?": "タスクプラグインを無効化しますか?",
"Disable on failure": "失敗時に無効にする",
"Disable selected channels": "選択したチャネルを無効にする",
"Disable selected models": "選択したモデルを無効にする",
"Disable store passthrough": "ストアパススルーを無効にする",
+ "Disable task plugins?": "タスクプラグインを無効化しますか?",
"Disable this key?": "このキーを無効にしますか?",
"Disable threshold (seconds)": "無効化しきい値(秒)",
"Disable Two-Factor Authentication": "二要素認証を無効にする",
@@ -1530,6 +1532,7 @@
"Drawing Logs": "画像生成履歴",
"Drawing task polling": "描画タスクのポーリング",
"Drawing task records": "描画タスク記録",
+ "Drop a JavaScript plugin file here": "JavaScript プラグインファイルをここにドロップ",
"Dry run result": "ドライラン結果",
"Duplicate": "複製",
"Duplicate group names: {{names}}": "重複するグループ名: {{names}}",
@@ -1652,7 +1655,6 @@
"Enable All": "すべて有効にする",
"Enable check-in feature": "チェックイン機能を有効にする",
"Enable custom task plugins": "カスタムタスクプラグインを有効化",
- "Enable task plugins": "タスクプラグインを有効化",
"Enable Data Dashboard": "データダッシュボードを有効にする",
"Enable demo mode with limited functionality": "機能が制限されたデモモードを有効にする",
"Enable Discord OAuth": "Discord OAuthを有効にする",
@@ -1681,6 +1683,7 @@
"Enable SSRF Protection": "SSRF保護を有効にする",
"Enable STARTTLS": "STARTTLSを有効にする",
"Enable streaming mode for the test request.": "テストリクエストのストリーミングモードを有効にします。",
+ "Enable task plugins": "タスクプラグインを有効化",
"Enable Telegram OAuth": "Telegram OAuthを有効にする",
"Enable test mode for Creem payments": "Creem 決済のテストモードを有効にする",
"Enable this key?": "このキーを有効にしますか?",
@@ -1792,7 +1795,6 @@
"Enterprise-grade security with comprehensive permission management": "包括的な権限管理を備えたエンタープライズグレードのセキュリティ",
"Entrypoint (space separated)": "Entrypoint (スペース区切り)",
"Enum": "列挙",
- "Boolean": "真偽値",
"Enum values": "列挙値",
"Env (JSON object)": "Env (JSON オブジェクト)",
"Environment variables": "環境変数",
@@ -1877,6 +1879,7 @@
"extras": "追加項目",
"Factory": "組み込み",
"Factory and custom plugin behavior": "組み込み版とカスタム版の動作",
+ "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.": "ファクトリープラグインとカスタムプラグインは直ちに停止します。実行中のタスクはタイムアウトクリーンアップで処理されます。",
"Factory plugins cannot be deleted or disabled individually. A custom version can override them; deleting or disabling that version restores the factory plugin. Third-party-only platforms become unavailable when their plugin is deleted or disabled.": "組み込みプラグインは個別に削除・無効化できません。カスタム版で上書きでき、その版を削除または無効化すると組み込み版に戻ります。サードパーティ専用プラットフォームはプラグインを削除または無効化すると利用できなくなります。",
"Fail Reason": "失敗理由",
"Fail Reason Details": "失敗理由の詳細",
@@ -2040,8 +2043,8 @@
"Fetch available models for:": "利用可能なモデルを取得:",
"Fetch available models from upstream": "アップストリームから利用可能なモデルを取得する",
"Fetch from Upstream": "Upstreamからフェッチ",
- "Fetch Models": "モデルを取得",
"Fetch mode": "取得モード",
+ "Fetch Models": "モデルを取得",
"Fetched {{count}} model(s) from upstream": "上流から {{count}} 個のモデルを取得しました",
"Fetched {{count}} models": "{{count}} 個のモデルを取得しました",
"Fetched in your browser and placed in the source field below for review. GitHub and gist page URLs are rewritten to their raw URL automatically.": "ブラウザで取得し、確認用に下のソース欄へ挿入します。GitHub と gist のページ URL は自動的に raw URL へ書き換えられます。",
@@ -2879,6 +2882,7 @@
"Models exposed by this channel": "このチャンネルで公開するモデル",
"Models fetched successfully": "モデルが正常に取得されました",
"Models filled to form": "フォームにモデルが記入されました",
+ "Models listed here skip automatic -thinking / -nothinking suffix handling. Matched names are also exempt from @-modifier parsing and 400 validation. Prefix an entry with re: to match the full model name as a Go regular expression, for example re:.*@sha256:.*": "ここに記載されたモデルは、-thinking / -nothinking サフィックスの自動付与・削除を行いません。一致した名前は @ 修飾子の解析および 400 バリデーションからも除外されます。re: で始まる項目は完全なモデル名に対する Go 正規表現として扱われます(例: re:.*@sha256:.*)。",
"Models listed here will not automatically append or remove -thinking / -nothinking suffixes.": "ここに記載されたモデルは、-thinking / -nothinking サフィックスの自動付与・削除を行いません。",
"Models losing positions": "順位を下げているモデル",
"Models losing the most positions": "順位を最も落としているモデル",
@@ -2964,9 +2968,9 @@
"Native Claude Messages plus OpenAI Chat compatibility forwarding.": "Claude Messages ネイティブ転送と OpenAI Chat 互換転送。",
"Native format": "ネイティブ形式",
"Native forwarding": "ネイティブ転送",
- "Native routes": "ネイティブルート",
"Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Gemini ネイティブルートと OpenAI Chat / Responses 互換転送。",
"Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "OpenAI ネイティブルートと、任意の Claude / Gemini 互換ルート。",
+ "Native routes": "ネイティブルート",
"Need a redemption code?": "引き換えコードが必要ですか?",
"Needs API key": "API キーが必要",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "追加 (+:)、削除 (-:)、または使用可能なグループの追加を行うグループごとのルールを定義するネストされたJSON。",
@@ -3334,6 +3338,7 @@
"Optional JSON policy to restrict access based on user info fields": "ユーザー情報フィールドに基づいてアクセスを制限するためのオプションのJSONポリシー",
"Optional minimum recharge amount for this method.": "この方法のオプションの最小チャージ額。",
"Optional multiplier per user group used when calculating recharge pricing. Provide a JSON object such as": "チャージ料金を計算する際に使用されるユーザーグループごとのオプションの乗数。次のようなJSONオブジェクトを提供してください",
+ "Optional note describing this version": "このバージョンを説明する任意のメモ",
"Optional notes about this channel": "このチャネルに関するオプションのノート",
"Optional notes about when to use this group": "このグループを使用する時期に関するオプションのメモ",
"Optional ratio used when upstream cache hits occur.": "アップストリームキャッシュヒットが発生したときに使用されるオプションの比率。",
@@ -3614,11 +3619,6 @@
"Plugin key": "プラグインキー",
"Plugin metadata": "プラグインメタデータ",
"Plugin source": "プラグインソース",
- "Choose file": "ファイルを選択",
- "Choose another file": "別のファイルを選択",
- "Drop a JavaScript plugin file here": "JavaScript プラグインファイルをここにドロップ",
- "Single .js file, up to 1 MiB. Its source is shown below before upload.": "単一の .js ファイル、最大 1 MiB。アップロード前に下部でソースを確認できます。",
- "Optional note describing this version": "このバージョンを説明する任意のメモ",
"Plugin source exceeds the 1 MiB limit.": "プラグインのソースが 1 MiB の上限を超えています。",
"Plugin uploaded successfully": "プラグインをアップロードしました",
"Plugin version activated": "プラグインバージョンを有効化しました",
@@ -3639,8 +3639,8 @@
"Pre-Consume for Free Models": "無料モデルの事前消費",
"Pre-consumed": "事前消費",
"Pre-Consumed Quota": "事前消費クォータ",
- "Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.": "設定は{{pref}}として保存されましたが、アクティブなサブスクリプションがありません。ウォレットが自動的に使用されます。",
"Preference saved as {{pref}}, but no active subscription. Requests will be rejected.": "設定は{{pref}}として保存されましたが、アクティブなサブスクリプションがありません。リクエストは拒否されます。",
+ "Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.": "設定は{{pref}}として保存されましたが、アクティブなサブスクリプションがありません。ウォレットが自動的に使用されます。",
"Preferences": "環境設定",
"Prefill Group Management": "プリフィルグループ管理",
"Prefill Groups": "プリフィルグループ",
@@ -4452,6 +4452,7 @@
"Simple": "シンプル",
"Simple mode only returns message; status code and error type use system defaults.": "シンプルモードはメッセージのみ返します。ステータスコードとエラータイプはシステムデフォルトを使用します。",
"Simple mode: prune objects by type, e.g. redacted_thinking.": "シンプルモード:typeでオブジェクトを削除(例:redacted_thinking)。",
+ "Single .js file, up to 1 MiB. Its source is shown below before upload.": "単一の .js ファイル、最大 1 MiB。アップロード前に下部でソースを確認できます。",
"Single Key": "単一キー",
"Site & Branding": "サイトとブランド",
"Site Key": "サイトキー",
@@ -4679,8 +4680,8 @@
"Task logs": "タスクログ",
"Task Logs": "タスクログ",
"Task Plugin": "タスクプラグイン",
- "Task plugin setting updated": "タスクプラグイン設定を更新しました",
"Task plugin *": "タスクプラグイン *",
+ "Task plugin setting updated": "タスクプラグイン設定を更新しました",
"Task Plugins": "タスクプラグイン",
"Task pricing": "タスク料金",
"Task pricing not configured": "タスク料金が未設定",
@@ -4791,7 +4792,6 @@
"Third-party plugin risk": "サードパーティプラグインのリスク",
"Third-party source risk": "サードパーティソースのリスク",
"Third-party-only plugins become unavailable immediately. In-flight tasks will be handled by timeout cleanup.": "サードパーティ専用プラグインは直ちに利用不可になります。処理中タスクはタイムアウト清掃で処理されます。",
- "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.": "ファクトリープラグインとカスタムプラグインは直ちに停止します。実行中のタスクはタイムアウトクリーンアップで処理されます。",
"This action cannot be undone.": "この操作は元に戻せません。",
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "この操作は元に戻せません。これにより、あなたのアカウントは完全に削除され、すべてのデータがサーバーから削除されます。",
"This action will permanently remove 2FA protection from your account.": "この操作により、アカウントから2FA保護が完全に削除されます。",
@@ -5137,6 +5137,7 @@
"Updated successfully": "正常に更新されました",
"Updated system setting {{key}}": "システム設定 {{key}} を更新しました",
"Updated user {{username}} (ID: {{id}})": "ユーザー {{username}} を更新しました(ID: {{id}})",
+ "Updates with the system": "システムとともに更新",
"Updating all channel balances. This may take a while. Please refresh to see results.": "すべてのチャネル残高を更新中です。これには少し時間がかかる場合があります。結果を確認するには更新してください。",
"Updating...": "更新中...",
"Upgrade {{name}}": "{{name}} をアップグレード",
@@ -5539,4 +5540,4 @@
"Zhipu V4": "Zhipu V 4",
"Zoom": "ズーム"
}
-}
\ No newline at end of file
+}
diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json
index 51e85754ec5a..aeabf44fd404 100644
--- a/web/src/i18n/locales/ru.json
+++ b/web/src/i18n/locales/ru.json
@@ -672,9 +672,9 @@
"Bind a Pancake store + product": "Привязать магазин и продукт Pancake",
"Bind an email address to your account.": "Привяжите адрес электронной почты к вашему аккаунту.",
"Bind Email": "Привязать Email",
+ "Bind task plugins": "Привязать плагины задач",
"Bind Telegram Account": "Привязать аккаунт Telegram",
"Bind WeChat Account": "Привязка аккаунта WeChat",
- "Bind task plugins": "Привязать плагины задач",
"Binding Information": "Информация о привязке",
"Binding successful!": "Привязка успешна!",
"Binding your {{provider}} account": "Привязка вашего аккаунта {{provider}}",
@@ -689,6 +689,7 @@
"Blocked keywords": "Заблокированные ключевые слова",
"Blocks messages when sensitive keywords are detected.": "Блокирует сообщения при обнаружении конфиденциальных ключевых слов.",
"Body param": "Параметр тела запроса",
+ "Boolean": "Логический",
"Border radius": "Радиус скругления",
"Bot Name": "Имя бота",
"Bot Protection": "Защита от ботов",
@@ -713,9 +714,8 @@
"Built for developers,": "Создано для разработчиков,",
"Built-in": "Встроенный",
"Built-in Device": "Встроенное устройство",
- "Built-in v{{factory}} / marketplace v{{market}}": "Встроенный v{{factory}} / магазин v{{market}}",
- "Updates with the system": "Обновляется с системой",
"Built-in is v{{factory}}; delete the custom version to return to it": "Встроенная версия — v{{factory}}; удалите свою, чтобы вернуться к ней",
+ "Built-in v{{factory}} / marketplace v{{market}}": "Встроенный v{{factory}} / магазин v{{market}}",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Встроенное: отпечаток пальца/лицо телефона или Windows Hello; Внешнее: USB-ключ безопасности",
"by": "от",
"By category": "По категориям",
@@ -857,11 +857,13 @@
"Choose a username": "Выберите имя пользователя",
"Choose an amount and payment method": "Выберите сумму и способ оплаты",
"Choose and order the groups this API key will try.": "Выберите и упорядочьте группы, которые будет использовать этот API-ключ.",
+ "Choose another file": "Выбрать другой файл",
"Choose between default expanded, compact icon-only, or full layout mode": "Выберите между развернутым по умолчанию, компактным (только иконки) или полным режимом макета",
"Choose between inset, floating, or standard sidebar layout": "Выберите между встроенным, плавающим или стандартным макетом боковой панели",
"Choose between left-to-right or right-to-left site direction": "Выберите между направлением сайта слева направо или справа налево",
"Choose between system preference, light mode, or dark mode": "Выберите между системными настройками, светлым режимом или темным режимом",
"Choose channels to sync upstream ratio configurations from": "Выберите каналы для синхронизации конфигураций соотношений из вышестоящих источников",
+ "Choose file": "Выбрать файл",
"Choose Group": "Выбрать группу",
"Choose how flow widths are calculated.": "Выберите, как рассчитывается ширина потоков.",
"Choose how quota values are shown to users": "Выберите, как значения квоты отображаются пользователям",
@@ -1448,11 +1450,11 @@
"Disable 2FA": "Отключить 2FA",
"Disable All": "Отключить все",
"Disable custom task plugins?": "Отключить пользовательские плагины задач?",
- "Disable task plugins?": "Отключить плагины задач?",
"Disable on failure": "Отключить при сбое",
"Disable selected channels": "Отключить выбранные каналы",
"Disable selected models": "Отключить выбранные модели",
"Disable store passthrough": "Отключить сквозной переход магазина",
+ "Disable task plugins?": "Отключить плагины задач?",
"Disable this key?": "Отключить этот ключ?",
"Disable threshold (seconds)": "Порог отключения (секунды)",
"Disable Two-Factor Authentication": "Отключить двухфакторную аутентификацию",
@@ -1530,6 +1532,7 @@
"Drawing Logs": "Журнал рисования",
"Drawing task polling": "Опрос задач рисования",
"Drawing task records": "Записи задач рисования",
+ "Drop a JavaScript plugin file here": "Перетащите сюда файл плагина JavaScript",
"Dry run result": "Результат пробного запуска",
"Duplicate": "Дублировать",
"Duplicate group names: {{names}}": "Повторяющиеся имена групп: {{names}}",
@@ -1652,7 +1655,6 @@
"Enable All": "Включить все",
"Enable check-in feature": "Включить функцию прибытия",
"Enable custom task plugins": "Включить пользовательские плагины задач",
- "Enable task plugins": "Включить плагины задач",
"Enable Data Dashboard": "Включить панель данных",
"Enable demo mode with limited functionality": "Включить демонстрационный режим с ограниченной функциональностью",
"Enable Discord OAuth": "Включить Discord OAuth",
@@ -1681,6 +1683,7 @@
"Enable SSRF Protection": "Включить защиту от SSRF",
"Enable STARTTLS": "Включить STARTTLS",
"Enable streaming mode for the test request.": "Включить потоковый режим для тестового запроса.",
+ "Enable task plugins": "Включить плагины задач",
"Enable Telegram OAuth": "Включить Telegram OAuth",
"Enable test mode for Creem payments": "Включить тестовый режим для платежей Creem",
"Enable this key?": "Включить этот ключ?",
@@ -1792,7 +1795,6 @@
"Enterprise-grade security with comprehensive permission management": "Безопасность корпоративного уровня с комплексным управлением разрешениями",
"Entrypoint (space separated)": "Точка входа (через пробелы)",
"Enum": "Перечисление",
- "Boolean": "Логический",
"Enum values": "Значения перечисления",
"Env (JSON object)": "Env (объект JSON)",
"Environment variables": "Переменные окружения",
@@ -1877,6 +1879,7 @@
"extras": "доп. пункты",
"Factory": "Встроенный",
"Factory and custom plugin behavior": "Поведение встроенных и пользовательских плагинов",
+ "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.": "Заводские и пользовательские плагины сразу перестают обслуживать запросы. Текущие задачи обработает очистка по таймауту.",
"Factory plugins cannot be deleted or disabled individually. A custom version can override them; deleting or disabling that version restores the factory plugin. Third-party-only platforms become unavailable when their plugin is deleted or disabled.": "Встроенные плагины нельзя удалять или отключать отдельно. Пользовательская версия может заменить их; её удаление или отключение восстанавливает встроенный плагин. Сторонняя платформа становится недоступной при удалении или отключении её плагина.",
"Fail Reason": "Причина сбоя",
"Fail Reason Details": "Детали причины сбоя",
@@ -2040,8 +2043,8 @@
"Fetch available models for:": "Получить доступные модели для:",
"Fetch available models from upstream": "Получить доступные модели от вышестоящего поставщика",
"Fetch from Upstream": "Получить из Upstream",
- "Fetch Models": "Получить модели",
"Fetch mode": "Режим получения",
+ "Fetch Models": "Получить модели",
"Fetched {{count}} model(s) from upstream": "Получено {{count}} моделей из upstream",
"Fetched {{count}} models": "Получено {{count}} моделей",
"Fetched in your browser and placed in the source field below for review. GitHub and gist page URLs are rewritten to their raw URL automatically.": "Загружается браузером и помещается в поле кода ниже для проверки. URL страниц GitHub и gist автоматически преобразуются в raw URL.",
@@ -2879,6 +2882,7 @@
"Models exposed by this channel": "Модели, доступные через этот канал",
"Models fetched successfully": "Модели успешно получены",
"Models filled to form": "Модели заполнены в форму",
+ "Models listed here skip automatic -thinking / -nothinking suffix handling. Matched names are also exempt from @-modifier parsing and 400 validation. Prefix an entry with re: to match the full model name as a Go regular expression, for example re:.*@sha256:.*": "Модели из этого списка не получают и не теряют автоматически суффиксы -thinking / -nothinking. Совпавшие имена также освобождаются от разбора модификаторов @ и проверки с ответом 400. Запись с префиксом re: — регулярное выражение Go по полному имени модели, например re:.*@sha256:.*",
"Models listed here will not automatically append or remove -thinking / -nothinking suffixes.": "Модели из этого списка не будут автоматически добавлять или удалять суффиксы -thinking / -nothinking.",
"Models losing positions": "Модели, теряющие позиции",
"Models losing the most positions": "Модели, потерявшие больше всего позиций",
@@ -2964,9 +2968,9 @@
"Native Claude Messages plus OpenAI Chat compatibility forwarding.": "Нативный Claude Messages и совместимая пересылка OpenAI Chat.",
"Native format": "Собственный формат",
"Native forwarding": "Нативная пересылка",
- "Native routes": "Нативные маршруты",
"Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Нативные маршруты Gemini и совместимая пересылка OpenAI Chat и Responses.",
"Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "Нативные маршруты OpenAI и дополнительные маршруты совместимости Claude и Gemini.",
+ "Native routes": "Нативные маршруты",
"Need a redemption code?": "Нужен код активации?",
"Needs API key": "Нужен API-ключ",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "Вложенный JSON, определяющий правила для каждой группы для добавления (+:), удаления (-:) или добавления используемых групп.",
@@ -3334,6 +3338,7 @@
"Optional JSON policy to restrict access based on user info fields": "Необязательная политика JSON для ограничения доступа на основе полей информации о пользователе",
"Optional minimum recharge amount for this method.": "Необязательная минимальная сумма пополнения для этого метода.",
"Optional multiplier per user group used when calculating recharge pricing. Provide a JSON object such as": "Необязательный множитель для группы пользователей, используемый при расчете цен на пополнение. Предоставьте JSON-объект, например",
+ "Optional note describing this version": "Необязательное примечание к этой версии",
"Optional notes about this channel": "Необязательные заметки об этом канале",
"Optional notes about when to use this group": "Необязательные примечания о том, когда использовать эту группу",
"Optional ratio used when upstream cache hits occur.": "Необязательное соотношение, используемое при попаданиях в вышестоящий кэш.",
@@ -3614,11 +3619,6 @@
"Plugin key": "Ключ плагина",
"Plugin metadata": "Метаданные плагина",
"Plugin source": "Исходный код плагина",
- "Choose file": "Выбрать файл",
- "Choose another file": "Выбрать другой файл",
- "Drop a JavaScript plugin file here": "Перетащите сюда файл плагина JavaScript",
- "Single .js file, up to 1 MiB. Its source is shown below before upload.": "Один файл .js размером до 1 МиБ. Его исходный код показан ниже перед загрузкой.",
- "Optional note describing this version": "Необязательное примечание к этой версии",
"Plugin source exceeds the 1 MiB limit.": "Код плагина превышает лимит 1 МиБ.",
"Plugin uploaded successfully": "Плагин успешно загружен",
"Plugin version activated": "Версия плагина активирована",
@@ -3639,8 +3639,8 @@
"Pre-Consume for Free Models": "Предварительное потребление для бесплатных моделей",
"Pre-consumed": "Предоплата",
"Pre-Consumed Quota": "Предварительно потребленная квота",
- "Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.": "Настройка сохранена как {{pref}}, но нет активной подписки. Кошелёк будет использоваться автоматически.",
"Preference saved as {{pref}}, but no active subscription. Requests will be rejected.": "Настройка сохранена как {{pref}}, но нет активной подписки. Запросы будут отклонены.",
+ "Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.": "Настройка сохранена как {{pref}}, но нет активной подписки. Кошелёк будет использоваться автоматически.",
"Preferences": "Настройки",
"Prefill Group Management": "Управление группами автозаполнения",
"Prefill Groups": "Группы автозаполнения",
@@ -4452,6 +4452,7 @@
"Simple": "Простой",
"Simple mode only returns message; status code and error type use system defaults.": "Простой режим возвращает только сообщение; код статуса и тип ошибки используют системные значения по умолчанию.",
"Simple mode: prune objects by type, e.g. redacted_thinking.": "Простой режим: очистка объектов по типу, например redacted_thinking.",
+ "Single .js file, up to 1 MiB. Its source is shown below before upload.": "Один файл .js размером до 1 МиБ. Его исходный код показан ниже перед загрузкой.",
"Single Key": "Одиночный ключ",
"Site & Branding": "Сайт и брендинг",
"Site Key": "Ключ сайта",
@@ -4679,8 +4680,8 @@
"Task logs": "Журналы задач",
"Task Logs": "Журнал задач",
"Task Plugin": "Плагин задач",
- "Task plugin setting updated": "Настройка плагинов задач обновлена",
"Task plugin *": "Плагин задач *",
+ "Task plugin setting updated": "Настройка плагинов задач обновлена",
"Task Plugins": "Плагины задач",
"Task pricing": "Тарификация задач",
"Task pricing not configured": "Тарификация задач не настроена",
@@ -4791,7 +4792,6 @@
"Third-party plugin risk": "Риск стороннего плагина",
"Third-party source risk": "Риск сторонних источников",
"Third-party-only plugins become unavailable immediately. In-flight tasks will be handled by timeout cleanup.": "Сторонние плагины сразу станут недоступны. Активные задачи обработает очистка по тайм-ауту.",
- "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.": "Заводские и пользовательские плагины сразу перестают обслуживать запросы. Текущие задачи обработает очистка по таймауту.",
"This action cannot be undone.": "Это действие невозможно отменить.",
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "Это действие невозможно отменить. Это безвозвратно удалит вашу учетную запись и все ваши данные с наших серверов.",
"This action will permanently remove 2FA protection from your account.": "Это действие безвозвратно удалит защиту 2FA из вашей учетной записи.",
@@ -5137,6 +5137,7 @@
"Updated successfully": "Обновлено успешно",
"Updated system setting {{key}}": "Обновлён системный параметр {{key}}",
"Updated user {{username}} (ID: {{id}})": "Обновлён пользователь {{username}} (ID: {{id}})",
+ "Updates with the system": "Обновляется с системой",
"Updating all channel balances. This may take a while. Please refresh to see results.": "Обновление балансов всех каналов. Это может занять некоторое время. Пожалуйста, обновите страницу, чтобы увидеть результаты.",
"Updating...": "Обновление...",
"Upgrade {{name}}": "Обновление {{name}}",
@@ -5539,4 +5540,4 @@
"Zhipu V4": "Zhipu V4",
"Zoom": "Zoom"
}
-}
\ No newline at end of file
+}
diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json
index 1a1ab235e659..40fc633bee5d 100644
--- a/web/src/i18n/locales/vi.json
+++ b/web/src/i18n/locales/vi.json
@@ -672,9 +672,9 @@
"Bind a Pancake store + product": "Liên kết cửa hàng + sản phẩm Pancake",
"Bind an email address to your account.": "Liên kết địa chỉ email với tài khoản của bạn.",
"Bind Email": "Liên kết Email",
+ "Bind task plugins": "Gắn plugin tác vụ",
"Bind Telegram Account": "Liên kết tài khoản Telegram",
"Bind WeChat Account": "Liên kết tài khoản WeChat",
- "Bind task plugins": "Gắn plugin tác vụ",
"Binding Information": "Thông tin Ràng buộc",
"Binding successful!": "Liên kết thành công!",
"Binding your {{provider}} account": "Đang liên kết tài khoản {{provider}} của bạn",
@@ -689,6 +689,7 @@
"Blocked keywords": "Blocked keyword",
"Blocks messages when sensitive keywords are detected.": "Chặn tin nhắn khi phát hiện từ khóa nhạy cảm.",
"Body param": "Tham số body",
+ "Boolean": "Boolean",
"Border radius": "Độ bo góc",
"Bot Name": "Tên Bot",
"Bot Protection": "Bảo vệ Bot",
@@ -713,9 +714,8 @@
"Built for developers,": "Được xây dựng cho nhà phát triển,",
"Built-in": "Tích hợp sẵn",
"Built-in Device": "Thiết bị tích hợp",
- "Built-in v{{factory}} / marketplace v{{market}}": "Tích hợp v{{factory}} / chợ v{{market}}",
- "Updates with the system": "Cập nhật cùng hệ thống",
"Built-in is v{{factory}}; delete the custom version to return to it": "Bản tích hợp là v{{factory}}; xóa bản tùy chỉnh để trở lại",
+ "Built-in v{{factory}} / marketplace v{{market}}": "Tích hợp v{{factory}} / chợ v{{market}}",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Tích hợp sẵn: vân tay/khuôn mặt điện thoại, hoặc Windows Hello; Bên ngoài: khóa bảo mật USB",
"by": "by",
"By category": "Theo danh mục",
@@ -857,11 +857,13 @@
"Choose a username": "Chọn tên người dùng",
"Choose an amount and payment method": "Chọn số tiền và phương thức thanh toán",
"Choose and order the groups this API key will try.": "Chọn và sắp xếp các nhóm mà khóa API này sẽ thử.",
+ "Choose another file": "Chọn tệp khác",
"Choose between default expanded, compact icon-only, or full layout mode": "Chọn giữa chế độ mở rộng mặc định, chế độ chỉ biểu tượng thu gọn, hoặc chế độ bố cục đầy đủ",
"Choose between inset, floating, or standard sidebar layout": "Chọn giữa bố cục thanh bên chìm, nổi hoặc tiêu chuẩn",
"Choose between left-to-right or right-to-left site direction": "Chọn giữa hướng trang từ trái sang phải hoặc từ phải sang trái",
"Choose between system preference, light mode, or dark mode": "Lựa chọn giữa tùy chọn hệ thống, chế độ sáng hoặc chế độ tối",
"Choose channels to sync upstream ratio configurations from": "Chọn các kênh để đồng bộ cấu hình tỷ lệ đường lên từ",
+ "Choose file": "Chọn tệp",
"Choose Group": "Chọn Nhóm",
"Choose how flow widths are calculated.": "Chọn cách tính độ rộng của các luồng.",
"Choose how quota values are shown to users": "Chọn cách hiển thị giá trị hạn ngạch cho người dùng",
@@ -1448,11 +1450,11 @@
"Disable 2FA": "Tắt 2FA",
"Disable All": "Vô hiệu hóa tất cả",
"Disable custom task plugins?": "Tắt plugin tác vụ tùy chỉnh?",
- "Disable task plugins?": "Tắt plugin tác vụ?",
"Disable on failure": "Vô hiệu hóa khi lỗi",
"Disable selected channels": "Vô hiệu hóa các kênh đã chọn",
"Disable selected models": "Vô hiệu hóa các mô hình đã chọn",
"Disable store passthrough": "Vô hiệu hóa chuyển tiếp store",
+ "Disable task plugins?": "Tắt plugin tác vụ?",
"Disable this key?": "Vô hiệu hóa khóa này?",
"Disable threshold (seconds)": "Vô hiệu hóa ngưỡng (giây)",
"Disable Two-Factor Authentication": "Vô hiệu hóa Xác thực hai yếu tố",
@@ -1530,6 +1532,7 @@
"Drawing Logs": "Nhật ký bản vẽ",
"Drawing task polling": "Thăm dò tác vụ vẽ",
"Drawing task records": "Lịch sử tác vụ vẽ",
+ "Drop a JavaScript plugin file here": "Kéo tệp plugin JavaScript vào đây",
"Dry run result": "Kết quả chạy thử",
"Duplicate": "Nhân bản",
"Duplicate group names: {{names}}": "Tên nhóm bị trùng: {{names}}",
@@ -1652,7 +1655,6 @@
"Enable All": "Bật tất cả",
"Enable check-in feature": "Bật tính năng điểm danh",
"Enable custom task plugins": "Bật plugin tác vụ tùy chỉnh",
- "Enable task plugins": "Bật plugin tác vụ",
"Enable Data Dashboard": "Kích hoạt Trang tổng quan Dữ liệu",
"Enable demo mode with limited functionality": "Bật chế độ demo với chức năng hạn chế",
"Enable Discord OAuth": "Bật Discord OAuth",
@@ -1681,6 +1683,7 @@
"Enable SSRF Protection": "Kích hoạt Bảo vệ SSRF",
"Enable STARTTLS": "Bật STARTTLS",
"Enable streaming mode for the test request.": "Bật chế độ streaming cho yêu cầu thử nghiệm.",
+ "Enable task plugins": "Bật plugin tác vụ",
"Enable Telegram OAuth": "Bật Telegram OAuth",
"Enable test mode for Creem payments": "Bật chế độ thử nghiệm cho thanh toán Creem",
"Enable this key?": "Kích hoạt khóa này?",
@@ -1792,7 +1795,6 @@
"Enterprise-grade security with comprehensive permission management": "Bảo mật cấp doanh nghiệp với quản lý quyền toàn diện",
"Entrypoint (space separated)": "Entrypoint (cách nhau bằng dấu cách)",
"Enum": "Liệt kê",
- "Boolean": "Boolean",
"Enum values": "Các giá trị liệt kê",
"Env (JSON object)": "Env (đối tượng JSON)",
"Environment variables": "Biến môi trường",
@@ -1877,6 +1879,7 @@
"extras": "mục bổ sung",
"Factory": "Tích hợp",
"Factory and custom plugin behavior": "Cách hoạt động của plugin tích hợp và tùy chỉnh",
+ "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.": "Plugin gốc và plugin tùy chỉnh sẽ ngay lập tức ngừng phục vụ. Các tác vụ đang chạy sẽ được xử lý bằng dọn dẹp hết hạn.",
"Factory plugins cannot be deleted or disabled individually. A custom version can override them; deleting or disabling that version restores the factory plugin. Third-party-only platforms become unavailable when their plugin is deleted or disabled.": "Không thể xóa hoặc tắt riêng plugin tích hợp. Phiên bản tùy chỉnh có thể ghi đè; xóa hoặc tắt phiên bản đó sẽ khôi phục plugin tích hợp. Nền tảng chỉ có plugin bên thứ ba sẽ không khả dụng khi plugin bị xóa hoặc tắt.",
"Fail Reason": "Lý do thất bại",
"Fail Reason Details": "Chi tiết lý do thất bại",
@@ -2040,8 +2043,8 @@
"Fetch available models for:": "Tìm nạp các mô hình khả dụng cho:",
"Fetch available models from upstream": "Lấy các mô hình khả dụng từ nguồn trên",
"Fetch from Upstream": "Lấy từ nguồn",
- "Fetch Models": "Tìm nạp Mô hình",
"Fetch mode": "Chế độ lấy dữ liệu",
+ "Fetch Models": "Tìm nạp Mô hình",
"Fetched {{count}} model(s) from upstream": "Đã lấy {{count}} mô hình từ upstream",
"Fetched {{count}} models": "Đã lấy {{count}} mô hình",
"Fetched in your browser and placed in the source field below for review. GitHub and gist page URLs are rewritten to their raw URL automatically.": "Được trình duyệt tải về và đưa vào ô mã nguồn bên dưới để bạn xem lại. URL trang GitHub và gist sẽ tự động được đổi thành URL raw.",
@@ -2879,6 +2882,7 @@
"Models exposed by this channel": "Model được kênh này công bố",
"Models fetched successfully": "Các mô hình đã được tải thành công",
"Models filled to form": "Mô hình đã được điền vào biểu mẫu",
+ "Models listed here skip automatic -thinking / -nothinking suffix handling. Matched names are also exempt from @-modifier parsing and 400 validation. Prefix an entry with re: to match the full model name as a Go regular expression, for example re:.*@sha256:.*": "Các mô hình được liệt kê ở đây sẽ không tự động thêm hoặc xóa hậu tố -thinking / -nothinking. Tên khớp cũng được miễn phân tích và kiểm tra (400) cho bộ sửa đổi @. Mục bắt đầu bằng re: được coi là biểu thức chính quy Go khớp toàn bộ tên mô hình, ví dụ re:.*@sha256:.*",
"Models listed here will not automatically append or remove -thinking / -nothinking suffixes.": "Các mô hình được liệt kê ở đây sẽ không tự động thêm hoặc xóa hậu tố -thinking / -nothinking.",
"Models losing positions": "Các mô hình đang mất vị trí",
"Models losing the most positions": "Mô hình tụt hạng nhiều nhất",
@@ -2964,9 +2968,9 @@
"Native Claude Messages plus OpenAI Chat compatibility forwarding.": "Chuyển tiếp Claude Messages nguyên bản và tương thích OpenAI Chat.",
"Native format": "Định dạng gốc",
"Native forwarding": "Chuyển tiếp nguyên bản",
- "Native routes": "Tuyến nguyên bản",
"Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Route Gemini nguyên bản cùng chuyển tiếp tương thích OpenAI Chat và Responses.",
"Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "Route OpenAI nguyên bản cùng các route tương thích Claude và Gemini tùy chọn.",
+ "Native routes": "Tuyến nguyên bản",
"Need a redemption code?": "Cần mã đổi thưởng?",
"Needs API key": "Cần khóa API",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "JSON lồng nhau xác định quy tắc theo nhóm để thêm (+:), xóa (-:), hoặc nối các nhóm có thể sử dụng.",
@@ -3334,6 +3338,7 @@
"Optional JSON policy to restrict access based on user info fields": "Chính sách JSON tùy chọn để hạn chế truy cập dựa trên các trường thông tin người dùng",
"Optional minimum recharge amount for this method.": "Số tiền nạp tối thiểu tùy chọn cho phương thức này.",
"Optional multiplier per user group used when calculating recharge pricing. Provide a JSON object such as": "Hệ số nhân tùy chọn cho mỗi nhóm người dùng, được sử dụng khi tính toán giá nạp tiền. Cung cấp một đối tượng JSON như",
+ "Optional note describing this version": "Ghi chú tùy chọn mô tả phiên bản này",
"Optional notes about this channel": "Ghi chú tùy chọn về kênh này",
"Optional notes about when to use this group": "Các ghi chú tùy chọn về thời điểm sử dụng nhóm này",
"Optional ratio used when upstream cache hits occur.": "Tỷ lệ tùy chọn được sử dụng khi xảy ra các lượt truy cập bộ nhớ đệm ngược dòng.",
@@ -3614,11 +3619,6 @@
"Plugin key": "Khóa plugin",
"Plugin metadata": "Siêu dữ liệu plugin",
"Plugin source": "Mã nguồn plugin",
- "Choose file": "Chọn tệp",
- "Choose another file": "Chọn tệp khác",
- "Drop a JavaScript plugin file here": "Kéo tệp plugin JavaScript vào đây",
- "Single .js file, up to 1 MiB. Its source is shown below before upload.": "Một tệp .js duy nhất, tối đa 1 MiB. Mã nguồn được hiển thị bên dưới trước khi tải lên.",
- "Optional note describing this version": "Ghi chú tùy chọn mô tả phiên bản này",
"Plugin source exceeds the 1 MiB limit.": "Mã nguồn plugin vượt giới hạn 1 MiB.",
"Plugin uploaded successfully": "Đã tải plugin lên",
"Plugin version activated": "Đã kích hoạt phiên bản plugin",
@@ -3639,8 +3639,8 @@
"Pre-Consume for Free Models": "Dùng trước các mô hình miễn phí",
"Pre-consumed": "Khấu trừ trước",
"Pre-Consumed Quota": "Hạn mức đã tiêu thụ trước",
- "Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.": "Tùy chọn đã lưu là {{pref}}, nhưng không có gói đăng ký đang hoạt động. Ví sẽ được sử dụng tự động.",
"Preference saved as {{pref}}, but no active subscription. Requests will be rejected.": "Tùy chọn đã lưu là {{pref}}, nhưng không có gói đăng ký đang hoạt động. Các yêu cầu sẽ bị từ chối.",
+ "Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.": "Tùy chọn đã lưu là {{pref}}, nhưng không có gói đăng ký đang hoạt động. Ví sẽ được sử dụng tự động.",
"Preferences": "Tùy chọn",
"Prefill Group Management": "Quản lý Nhóm Điền sẵn",
"Prefill Groups": "Điền sẵn các nhóm",
@@ -4452,6 +4452,7 @@
"Simple": "Đơn giản",
"Simple mode only returns message; status code and error type use system defaults.": "Chế độ đơn giản chỉ trả về message; mã trạng thái và loại lỗi sử dụng giá trị mặc định.",
"Simple mode: prune objects by type, e.g. redacted_thinking.": "Chế độ đơn giản: dọn dẹp đối tượng theo type, ví dụ redacted_thinking.",
+ "Single .js file, up to 1 MiB. Its source is shown below before upload.": "Một tệp .js duy nhất, tối đa 1 MiB. Mã nguồn được hiển thị bên dưới trước khi tải lên.",
"Single Key": "Khóa đơn",
"Site & Branding": "Trang web & thương hiệu",
"Site Key": "Khóa trang web",
@@ -4679,8 +4680,8 @@
"Task logs": "Nhật ký tác vụ",
"Task Logs": "Nhật ký tác vụ",
"Task Plugin": "Plugin tác vụ",
- "Task plugin setting updated": "Đã cập nhật cài đặt plugin tác vụ",
"Task plugin *": "Plugin tác vụ *",
+ "Task plugin setting updated": "Đã cập nhật cài đặt plugin tác vụ",
"Task Plugins": "Plugin tác vụ",
"Task pricing": "Giá tác vụ",
"Task pricing not configured": "Chưa cấu hình giá tác vụ",
@@ -4791,7 +4792,6 @@
"Third-party plugin risk": "Rủi ro plugin bên thứ ba",
"Third-party source risk": "Rủi ro nguồn bên thứ ba",
"Third-party-only plugins become unavailable immediately. In-flight tasks will be handled by timeout cleanup.": "Plugin chỉ có từ bên thứ ba sẽ ngừng hoạt động ngay. Tác vụ đang chạy sẽ được xử lý khi dọn dẹp quá hạn.",
- "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.": "Plugin gốc và plugin tùy chỉnh sẽ ngay lập tức ngừng phục vụ. Các tác vụ đang chạy sẽ được xử lý bằng dọn dẹp hết hạn.",
"This action cannot be undone.": "Hành động này không thể hoàn tác.",
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "Hành động này không thể hoàn tác. Việc này sẽ xóa vĩnh viễn tài khoản của bạn và loại bỏ tất cả dữ liệu của bạn khỏi máy chủ của chúng tôi.",
"This action will permanently remove 2FA protection from your account.": "Hành động này sẽ vĩnh viễn gỡ bỏ tính năng bảo vệ",
@@ -5137,6 +5137,7 @@
"Updated successfully": "Cập nhật thành công",
"Updated system setting {{key}}": "Đã cập nhật cài đặt hệ thống {{key}}",
"Updated user {{username}} (ID: {{id}})": "Đã cập nhật người dùng {{username}} (ID: {{id}})",
+ "Updates with the system": "Cập nhật cùng hệ thống",
"Updating all channel balances. This may take a while. Please refresh to see results.": "Đang cập nhật tất cả số dư kênh. Quá trình này có thể mất một chút thời gian. Vui lòng làm mới để xem kết quả.",
"Updating...": "Đang cập nhật...",
"Upgrade {{name}}": "Nâng cấp {{name}}",
@@ -5539,4 +5540,4 @@
"Zhipu V4": "Zhipu V4",
"Zoom": "Zoom"
}
-}
\ No newline at end of file
+}
diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json
index cdb42a32eb78..1ac31aed2cd3 100644
--- a/web/src/i18n/locales/zh-TW.json
+++ b/web/src/i18n/locales/zh-TW.json
@@ -672,9 +672,9 @@
"Bind a Pancake store + product": "連結 Pancake 店鋪和產品",
"Bind an email address to your account.": "將電郵地址連結到您的用戶。",
"Bind Email": "連結電郵",
+ "Bind task plugins": "綁定任務外掛",
"Bind Telegram Account": "連結 Telegram 用戶",
"Bind WeChat Account": "連結微信用戶",
- "Bind task plugins": "綁定任務外掛",
"Binding Information": "連結資訊",
"Binding successful!": "連結成功!",
"Binding your {{provider}} account": "正在連結您的 {{provider}} 賬號",
@@ -689,6 +689,7 @@
"Blocked keywords": "已阻止的關鍵詞",
"Blocks messages when sensitive keywords are detected.": "偵測到敏感關鍵詞時阻止訊息。",
"Body param": "請求體參數",
+ "Boolean": "布林",
"Border radius": "圓角",
"Bot Name": "機械人名稱",
"Bot Protection": "機械人保護",
@@ -713,9 +714,8 @@
"Built for developers,": "為開發者打造,",
"Built-in": "內置",
"Built-in Device": "內置設備",
- "Built-in v{{factory}} / marketplace v{{market}}": "內建 v{{factory}} / 市集 v{{market}}",
- "Updates with the system": "隨系統更新",
"Built-in is v{{factory}}; delete the custom version to return to it": "內建版本為 v{{factory}};刪除自訂版本即可恢復",
+ "Built-in v{{factory}} / marketplace v{{market}}": "內建 v{{factory}} / 市集 v{{market}}",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "內置:手機指紋/面部,或 Windows Hello;外部:USB 安全金鑰",
"by": "由",
"By category": "按行業",
@@ -857,11 +857,13 @@
"Choose a username": "選擇一個用戶名",
"Choose an amount and payment method": "選擇金額和支付方式",
"Choose and order the groups this API key will try.": "選擇此 API 金鑰要依序嘗試的分組並排序。",
+ "Choose another file": "重新選擇檔案",
"Choose between default expanded, compact icon-only, or full layout mode": "選擇預設展開、緊湊圖標模式或完整佈局模式",
"Choose between inset, floating, or standard sidebar layout": "選擇嵌入式、浮動式或標準側邊欄佈局",
"Choose between left-to-right or right-to-left site direction": "選擇從左到右或從右到左的站點方向",
"Choose between system preference, light mode, or dark mode": "選擇系統偏好、淺色模式或深色模式",
"Choose channels to sync upstream ratio configurations from": "選擇要同步上游比例設定的渠道",
+ "Choose file": "選擇檔案",
"Choose Group": "選擇分組",
"Choose how flow widths are calculated.": "選擇分流圖連線寬度的計算方式。",
"Choose how quota values are shown to users": "選擇如何向用戶展示配額值",
@@ -1448,11 +1450,11 @@
"Disable 2FA": "停用 2FA",
"Disable All": "停用全部",
"Disable custom task plugins?": "停用自訂任務外掛?",
- "Disable task plugins?": "停用任務外掛?",
"Disable on failure": "失敗時停用",
"Disable selected channels": "停用選定的渠道",
"Disable selected models": "停用選定的模型",
"Disable store passthrough": "禁止透傳 store",
+ "Disable task plugins?": "停用任務外掛?",
"Disable this key?": "停用此金鑰?",
"Disable threshold (seconds)": "停用閾值(秒)",
"Disable Two-Factor Authentication": "停用雙重身份驗證",
@@ -1530,6 +1532,7 @@
"Drawing Logs": "繪圖日誌",
"Drawing task polling": "繪圖任務輪詢",
"Drawing task records": "繪圖任務記錄",
+ "Drop a JavaScript plugin file here": "將 JavaScript 外掛檔案拖放到此處",
"Dry run result": "試跑結果",
"Duplicate": "重複",
"Duplicate group names: {{names}}": "存在重複的分組名稱:{{names}}",
@@ -1652,7 +1655,6 @@
"Enable All": "啟用全部",
"Enable check-in feature": "啟用簽到功能",
"Enable custom task plugins": "啟用自訂任務外掛",
- "Enable task plugins": "啟用任務外掛",
"Enable Data Dashboard": "啟用數據儀表板",
"Enable demo mode with limited functionality": "啟用功能受限的演示模式",
"Enable Discord OAuth": "啟用 Discord OAuth",
@@ -1681,6 +1683,7 @@
"Enable SSRF Protection": "啟用 SSRF 保護",
"Enable STARTTLS": "啟用 STARTTLS",
"Enable streaming mode for the test request.": "為測試請求啟用串流模式。",
+ "Enable task plugins": "啟用任務外掛",
"Enable Telegram OAuth": "啟用 Telegram OAuth",
"Enable test mode for Creem payments": "啟用 Creem 支付測試模式",
"Enable this key?": "啟用此金鑰?",
@@ -1792,7 +1795,6 @@
"Enterprise-grade security with comprehensive permission management": "企業級安全性,提供全面的權限管理",
"Entrypoint (space separated)": "入口點 (空格分隔)",
"Enum": "列舉",
- "Boolean": "布林",
"Enum values": "列舉值",
"Env (JSON object)": "環境變數 (JSON 物件)",
"Environment variables": "環境變數",
@@ -1877,6 +1879,7 @@
"extras": "額外項",
"Factory": "內建",
"Factory and custom plugin behavior": "內建与自定义外掛行为",
+ "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.": "出廠外掛與自訂外掛將立即停止服務。進行中的任務將由逾時清理處理。",
"Factory plugins cannot be deleted or disabled individually. A custom version can override them; deleting or disabling that version restores the factory plugin. Third-party-only platforms become unavailable when their plugin is deleted or disabled.": "內建外掛不可刪除或单独禁用。自定义版本可覆盖內建版;刪除或禁用自定义版会恢复內建版。纯第三方平台的外掛被刪除或禁用后将不可用。",
"Fail Reason": "失敗原因",
"Fail Reason Details": "失敗原因詳情",
@@ -2040,8 +2043,8 @@
"Fetch available models for:": "獲取可用模型:",
"Fetch available models from upstream": "從上游獲取可用模型",
"Fetch from Upstream": "從上游獲取",
- "Fetch Models": "獲取模型",
"Fetch mode": "拉取模式",
+ "Fetch Models": "獲取模型",
"Fetched {{count}} model(s) from upstream": "從上游獲取了 {{count}} 個模型",
"Fetched {{count}} models": "已獲取 {{count}} 個模型",
"Fetched in your browser and placed in the source field below for review. GitHub and gist page URLs are rewritten to their raw URL automatically.": "由瀏覽器取得並填入下方原始碼欄位供你審查。GitHub 與 gist 頁面 URL 會自動改寫為 raw URL。",
@@ -2879,6 +2882,7 @@
"Models exposed by this channel": "此渠道暴露的模型",
"Models fetched successfully": "模型獲取成功",
"Models filled to form": "模型已填充到表單",
+ "Models listed here skip automatic -thinking / -nothinking suffix handling. Matched names are also exempt from @-modifier parsing and 400 validation. Prefix an entry with re: to match the full model name as a Go regular expression, for example re:.*@sha256:.*": "此處列出的模型不會自動新增或移除 -thinking / -nothinking 後綴。命中的模型同時豁免 @ 修飾符解析與 400 校驗。以 re: 開頭的條目會以 Go 正規表示式匹配完整模型名稱,例如 re:.*@sha256:.*。",
"Models listed here will not automatically append or remove -thinking / -nothinking suffixes.": "此處列出的模型不會自動新增或移除 -thinking / -nothinking 後綴。",
"Models losing positions": "排名下滑的模型",
"Models losing the most positions": "名次下滑最多的模型",
@@ -2964,9 +2968,9 @@
"Native Claude Messages plus OpenAI Chat compatibility forwarding.": "Claude Messages 原生轉發,並相容 OpenAI Chat 轉換。",
"Native format": "原生格式",
"Native forwarding": "原生轉發",
- "Native routes": "原生路由",
"Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Gemini 原生轉發,並相容 OpenAI Chat 和 Responses 轉換。",
"Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "OpenAI 原生轉發,並提供可選的 Claude 和 Gemini 相容轉換。",
+ "Native routes": "原生路由",
"Need a redemption code?": "需要兌換碼?",
"Needs API key": "需要 API 金鑰",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "嵌套 JSON,定義按分組新增(+:)、移除(-:)或追加可用分組的規則。",
@@ -3334,6 +3338,7 @@
"Optional JSON policy to restrict access based on user info fields": "可選的 JSON 政策,用於基於用戶資訊欄位限制存取",
"Optional minimum recharge amount for this method.": "此方法的可選最低儲值金額。",
"Optional multiplier per user group used when calculating recharge pricing. Provide a JSON object such as": "計算儲值定價時使用的每個用戶分組可選乘數。請提供一個 JSON 物件,例如",
+ "Optional note describing this version": "選填,用於描述該版本的備註",
"Optional notes about this channel": "關於此渠道的可選備註",
"Optional notes about when to use this group": "關於何時使用此分組的可選說明",
"Optional ratio used when upstream cache hits occur.": "上游緩存命中時使用的可選比率。",
@@ -3614,11 +3619,6 @@
"Plugin key": "外掛键",
"Plugin metadata": "外掛元数据",
"Plugin source": "外掛原始碼",
- "Choose file": "選擇檔案",
- "Choose another file": "重新選擇檔案",
- "Drop a JavaScript plugin file here": "將 JavaScript 外掛檔案拖放到此處",
- "Single .js file, up to 1 MiB. Its source is shown below before upload.": "單一 .js 檔案,最大 1 MiB。上傳前會在下方顯示其原始碼。",
- "Optional note describing this version": "選填,用於描述該版本的備註",
"Plugin source exceeds the 1 MiB limit.": "外掛原始碼超過 1 MiB 上限。",
"Plugin uploaded successfully": "外掛上傳成功",
"Plugin version activated": "外掛版本已激活",
@@ -3639,8 +3639,8 @@
"Pre-Consume for Free Models": "免費模型預消耗",
"Pre-consumed": "預扣費",
"Pre-Consumed Quota": "預消耗配額",
- "Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.": "已儲存偏好為{{pref}},目前無生效訂閱,將自動使用錢包",
"Preference saved as {{pref}}, but no active subscription. Requests will be rejected.": "已儲存偏好為{{pref}},目前無生效訂閱,請求將被拒絕",
+ "Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.": "已儲存偏好為{{pref}},目前無生效訂閱,將自動使用錢包",
"Preferences": "偏好設定",
"Prefill Group Management": "預填充分組管理",
"Prefill Groups": "預填充分組",
@@ -4452,6 +4452,7 @@
"Simple": "簡潔",
"Simple mode only returns message; status code and error type use system defaults.": "簡潔模式僅回傳 message;狀態碼和錯誤類型將使用系統預設值。",
"Simple mode: prune objects by type, e.g. redacted_thinking.": "簡潔模式:按 type 全量清理物件,例如 redacted_thinking。",
+ "Single .js file, up to 1 MiB. Its source is shown below before upload.": "單一 .js 檔案,最大 1 MiB。上傳前會在下方顯示其原始碼。",
"Single Key": "單金鑰",
"Site & Branding": "站點與品牌",
"Site Key": "站點金鑰",
@@ -4679,8 +4680,8 @@
"Task logs": "任務日誌",
"Task Logs": "任務日誌",
"Task Plugin": "任務外掛",
- "Task plugin setting updated": "任務外掛設定已更新",
"Task plugin *": "任務外掛 *",
+ "Task plugin setting updated": "任務外掛設定已更新",
"Task Plugins": "任务外掛",
"Task pricing": "任務定價",
"Task pricing not configured": "尚未設定任務定價",
@@ -4791,7 +4792,6 @@
"Third-party plugin risk": "第三方外掛风险",
"Third-party source risk": "第三方來源風險",
"Third-party-only plugins become unavailable immediately. In-flight tasks will be handled by timeout cleanup.": "純第三方外掛將立即不可用,進行中的任務將由逾時清理處理。",
- "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.": "出廠外掛與自訂外掛將立即停止服務。進行中的任務將由逾時清理處理。",
"This action cannot be undone.": "此操作無法撤銷。",
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "此操作無法撤銷。這將永久刪除您的用戶並從我們的伺服器中移除您的所有數據。",
"This action will permanently remove 2FA protection from your account.": "此操作將永久移除您用戶的 2FA 保護。",
@@ -5137,6 +5137,7 @@
"Updated successfully": "更新成功",
"Updated system setting {{key}}": "修改系統設定 {{key}}",
"Updated user {{username}} (ID: {{id}})": "更新用戶 {{username}}(ID: {{id}})",
+ "Updates with the system": "隨系統更新",
"Updating all channel balances. This may take a while. Please refresh to see results.": "正在更新所有渠道餘額。這可能需要一段時間。請重新整理以查看結果。",
"Updating...": "正在更新...",
"Upgrade {{name}}": "升級 {{name}}",
@@ -5539,4 +5540,4 @@
"Zhipu V4": "智譜 V4",
"Zoom": "縮放"
}
-}
\ No newline at end of file
+}
diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json
index 183e97661c9e..c963eb8e55e7 100644
--- a/web/src/i18n/locales/zh.json
+++ b/web/src/i18n/locales/zh.json
@@ -672,9 +672,9 @@
"Bind a Pancake store + product": "绑定 Pancake 店铺和产品",
"Bind an email address to your account.": "将邮箱地址绑定到您的账户。",
"Bind Email": "绑定邮箱",
+ "Bind task plugins": "绑定任务插件",
"Bind Telegram Account": "绑定 Telegram 账户",
"Bind WeChat Account": "绑定微信账户",
- "Bind task plugins": "绑定任务插件",
"Binding Information": "绑定信息",
"Binding successful!": "绑定成功!",
"Binding your {{provider}} account": "正在绑定您的 {{provider}} 账号",
@@ -689,6 +689,7 @@
"Blocked keywords": "已阻止的关键词",
"Blocks messages when sensitive keywords are detected.": "检测到敏感关键词时阻止消息。",
"Body param": "请求体参数",
+ "Boolean": "布尔",
"Border radius": "圆角",
"Bot Name": "机器人名称",
"Bot Protection": "机器人保护",
@@ -713,9 +714,8 @@
"Built for developers,": "为开发者打造,",
"Built-in": "内置",
"Built-in Device": "内置设备",
- "Built-in v{{factory}} / marketplace v{{market}}": "内置 v{{factory}} / 市场 v{{market}}",
- "Updates with the system": "随系统更新",
"Built-in is v{{factory}}; delete the custom version to return to it": "内置版本为 v{{factory}};删除自定义版本即可恢复",
+ "Built-in v{{factory}} / marketplace v{{market}}": "内置 v{{factory}} / 市场 v{{market}}",
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "内置:手机指纹/面部,或 Windows Hello;外部:USB 安全密钥",
"by": "由",
"By category": "按行业",
@@ -857,11 +857,13 @@
"Choose a username": "选择一个用户名",
"Choose an amount and payment method": "选择金额和支付方式",
"Choose and order the groups this API key will try.": "选择并排列此 API 密钥将依次尝试的分组。",
+ "Choose another file": "重新选择文件",
"Choose between default expanded, compact icon-only, or full layout mode": "选择默认展开、紧凑图标模式或完整布局模式",
"Choose between inset, floating, or standard sidebar layout": "选择嵌入式、浮动式或标准侧边栏布局",
"Choose between left-to-right or right-to-left site direction": "选择从左到右或从右到左的站点方向",
"Choose between system preference, light mode, or dark mode": "选择系统偏好、浅色模式或深色模式",
"Choose channels to sync upstream ratio configurations from": "选择要同步上游比例配置的渠道",
+ "Choose file": "选择文件",
"Choose Group": "选择分组",
"Choose how flow widths are calculated.": "选择分流图连线宽度的计算方式。",
"Choose how quota values are shown to users": "选择如何向用户展示配额值",
@@ -1448,11 +1450,11 @@
"Disable 2FA": "禁用 2FA",
"Disable All": "禁用全部",
"Disable custom task plugins?": "禁用自定义任务插件?",
- "Disable task plugins?": "禁用任务插件?",
"Disable on failure": "失败时禁用",
"Disable selected channels": "禁用选定的渠道",
"Disable selected models": "禁用选定的模型",
"Disable store passthrough": "禁止透传 store",
+ "Disable task plugins?": "禁用任务插件?",
"Disable this key?": "禁用此密钥?",
"Disable threshold (seconds)": "禁用阈值(秒)",
"Disable Two-Factor Authentication": "禁用双重身份验证",
@@ -1530,6 +1532,7 @@
"Drawing Logs": "绘图日志",
"Drawing task polling": "绘图任务轮询",
"Drawing task records": "绘图任务记录",
+ "Drop a JavaScript plugin file here": "将 JavaScript 插件文件拖放到此处",
"Dry run result": "干跑结果",
"Duplicate": "重复",
"Duplicate group names: {{names}}": "存在重复的分组名称:{{names}}",
@@ -1652,7 +1655,6 @@
"Enable All": "启用全部",
"Enable check-in feature": "启用签到功能",
"Enable custom task plugins": "启用自定义任务插件",
- "Enable task plugins": "启用任务插件",
"Enable Data Dashboard": "启用数据仪表板",
"Enable demo mode with limited functionality": "启用功能受限的演示模式",
"Enable Discord OAuth": "启用 Discord OAuth",
@@ -1681,6 +1683,7 @@
"Enable SSRF Protection": "启用 SSRF 保护",
"Enable STARTTLS": "启用 STARTTLS",
"Enable streaming mode for the test request.": "为测试请求启用流式模式。",
+ "Enable task plugins": "启用任务插件",
"Enable Telegram OAuth": "启用 Telegram OAuth",
"Enable test mode for Creem payments": "启用 Creem 支付测试模式",
"Enable this key?": "启用此密钥?",
@@ -1792,7 +1795,6 @@
"Enterprise-grade security with comprehensive permission management": "企业级安全性,提供全面的权限管理",
"Entrypoint (space separated)": "入口点 (空格分隔)",
"Enum": "枚举",
- "Boolean": "布尔",
"Enum values": "枚举值",
"Env (JSON object)": "环境变量 (JSON 对象)",
"Environment variables": "环境变量",
@@ -1877,6 +1879,7 @@
"extras": "额外项",
"Factory": "出厂",
"Factory and custom plugin behavior": "出厂与自定义插件行为",
+ "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.": "出厂插件和自定义插件将立即停止服务。进行中的任务将由超时清理处理。",
"Factory plugins cannot be deleted or disabled individually. A custom version can override them; deleting or disabling that version restores the factory plugin. Third-party-only platforms become unavailable when their plugin is deleted or disabled.": "出厂插件不可删除或单独禁用。自定义版本可覆盖出厂版;删除或禁用自定义版会恢复出厂版。纯第三方平台的插件被删除或禁用后将不可用。",
"Fail Reason": "失败原因",
"Fail Reason Details": "失败原因详情",
@@ -2040,8 +2043,8 @@
"Fetch available models for:": "获取可用模型:",
"Fetch available models from upstream": "从上游获取可用模型",
"Fetch from Upstream": "从上游获取",
- "Fetch Models": "获取模型",
"Fetch mode": "拉取模式",
+ "Fetch Models": "获取模型",
"Fetched {{count}} model(s) from upstream": "从上游获取了 {{count}} 个模型",
"Fetched {{count}} models": "已获取 {{count}} 个模型",
"Fetched in your browser and placed in the source field below for review. GitHub and gist page URLs are rewritten to their raw URL automatically.": "由浏览器拉取并填入下方源码框供你审查。GitHub 与 gist 页面 URL 会自动改写为 raw URL。",
@@ -2879,6 +2882,7 @@
"Models exposed by this channel": "此渠道暴露的模型",
"Models fetched successfully": "模型获取成功",
"Models filled to form": "模型已填充到表单",
+ "Models listed here skip automatic -thinking / -nothinking suffix handling. Matched names are also exempt from @-modifier parsing and 400 validation. Prefix an entry with re: to match the full model name as a Go regular expression, for example re:.*@sha256:.*": "此处列出的模型不会自动添加或移除 -thinking / -nothinking 后缀。命中的模型同时豁免 @ 修饰符解析与 400 校验。以 re: 开头的条目会按 Go 正则匹配完整模型名,例如 re:.*@sha256:.*。",
"Models listed here will not automatically append or remove -thinking / -nothinking suffixes.": "此处列出的模型不会自动添加或移除 -thinking / -nothinking 后缀。",
"Models losing positions": "排名下滑的模型",
"Models losing the most positions": "名次下滑最多的模型",
@@ -2964,9 +2968,9 @@
"Native Claude Messages plus OpenAI Chat compatibility forwarding.": "Claude Messages 原生转发,并兼容 OpenAI Chat 转换。",
"Native format": "原生格式",
"Native forwarding": "原生转发",
- "Native routes": "原生路由",
"Native Gemini routes plus OpenAI Chat and Responses compatibility forwarding.": "Gemini 原生转发,并兼容 OpenAI Chat 和 Responses 转换。",
"Native OpenAI routes plus optional Claude and Gemini compatibility routes.": "OpenAI 原生转发,并提供可选的 Claude 和 Gemini 兼容转换。",
+ "Native routes": "原生路由",
"Need a redemption code?": "需要兑换码?",
"Needs API key": "需要 API 密钥",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "嵌套 JSON,定义按分组添加(+:)、移除(-:)或追加可用分组的规则。",
@@ -3334,6 +3338,7 @@
"Optional JSON policy to restrict access based on user info fields": "可选的 JSON 策略,用于基于用户信息字段限制访问",
"Optional minimum recharge amount for this method.": "此方法的可选最低充值金额。",
"Optional multiplier per user group used when calculating recharge pricing. Provide a JSON object such as": "计算充值定价时使用的每个用户分组可选乘数。请提供一个 JSON 对象,例如",
+ "Optional note describing this version": "可选,用于描述该版本的备注",
"Optional notes about this channel": "关于此渠道的可选备注",
"Optional notes about when to use this group": "关于何时使用此分组的可选说明",
"Optional ratio used when upstream cache hits occur.": "上游缓存命中时使用的可选比率。",
@@ -3614,11 +3619,6 @@
"Plugin key": "插件键",
"Plugin metadata": "插件元数据",
"Plugin source": "插件源码",
- "Choose file": "选择文件",
- "Choose another file": "重新选择文件",
- "Drop a JavaScript plugin file here": "将 JavaScript 插件文件拖放到此处",
- "Single .js file, up to 1 MiB. Its source is shown below before upload.": "单个 .js 文件,最大 1 MiB。上传前会在下方显示其源码。",
- "Optional note describing this version": "可选,用于描述该版本的备注",
"Plugin source exceeds the 1 MiB limit.": "插件源码超过 1 MiB 上限。",
"Plugin uploaded successfully": "插件上传成功",
"Plugin version activated": "插件版本已激活",
@@ -3639,8 +3639,8 @@
"Pre-Consume for Free Models": "免费模型预消耗",
"Pre-consumed": "预扣费",
"Pre-Consumed Quota": "预消耗配额",
- "Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.": "已保存偏好为{{pref}},当前无生效订阅,将自动使用钱包",
"Preference saved as {{pref}}, but no active subscription. Requests will be rejected.": "已保存偏好为{{pref}},当前无生效订阅,请求将被拒绝",
+ "Preference saved as {{pref}}, but no active subscription. Wallet will be used automatically.": "已保存偏好为{{pref}},当前无生效订阅,将自动使用钱包",
"Preferences": "偏好设置",
"Prefill Group Management": "预填充分组管理",
"Prefill Groups": "预填充分组",
@@ -4452,6 +4452,7 @@
"Simple": "简洁",
"Simple mode only returns message; status code and error type use system defaults.": "简洁模式仅返回 message;状态码和错误类型将使用系统默认值。",
"Simple mode: prune objects by type, e.g. redacted_thinking.": "简洁模式:按 type 全量清理对象,例如 redacted_thinking。",
+ "Single .js file, up to 1 MiB. Its source is shown below before upload.": "单个 .js 文件,最大 1 MiB。上传前会在下方显示其源码。",
"Single Key": "单密钥",
"Site & Branding": "站点与品牌",
"Site Key": "站点密钥",
@@ -4679,8 +4680,8 @@
"Task logs": "任务日志",
"Task Logs": "任务日志",
"Task Plugin": "任务插件",
- "Task plugin setting updated": "任务插件设置已更新",
"Task plugin *": "任务插件 *",
+ "Task plugin setting updated": "任务插件设置已更新",
"Task Plugins": "任务插件",
"Task pricing": "任务定价",
"Task pricing not configured": "尚未配置任务定价",
@@ -4791,7 +4792,6 @@
"Third-party plugin risk": "第三方插件风险",
"Third-party source risk": "第三方源风险",
"Third-party-only plugins become unavailable immediately. In-flight tasks will be handled by timeout cleanup.": "纯第三方插件将立即不可用,在途任务将由超时清理处理。",
- "Factory and custom plugins all stop serving immediately. In-flight tasks will be handled by timeout cleanup.": "出厂插件和自定义插件将立即停止服务。进行中的任务将由超时清理处理。",
"This action cannot be undone.": "此操作无法撤消。",
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "此操作无法撤消。这将永久删除您的账户并从我们的服务器中移除您的所有数据。",
"This action will permanently remove 2FA protection from your account.": "此操作将永久移除您账户的 2FA 保护。",
@@ -5137,6 +5137,7 @@
"Updated successfully": "更新成功",
"Updated system setting {{key}}": "修改系统设置 {{key}}",
"Updated user {{username}} (ID: {{id}})": "更新用户 {{username}}(ID: {{id}})",
+ "Updates with the system": "随系统更新",
"Updating all channel balances. This may take a while. Please refresh to see results.": "正在更新所有渠道余额。这可能需要一段时间。请刷新以查看结果。",
"Updating...": "正在更新...",
"Upgrade {{name}}": "升级 {{name}}",
@@ -5539,4 +5540,4 @@
"Zhipu V4": "智谱 V4",
"Zoom": "缩放"
}
-}
\ No newline at end of file
+}
From 6b659fd61c50e35d559c41520a0fff7b8aea56a4 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Sat, 5 Sep 2026 11:37:41 +0800
Subject: [PATCH 97/99] fix(relay): preserve reasoning effort without implicit
remapping
---
relay/channel/deepseek/adaptor.go | 10 ++++
relay/channel/openai/adaptor.go | 35 ++++++++++++--
relay/channel/volcengine/adaptor.go | 1 +
relay/channel/xai/adaptor.go | 6 ++-
relay/common/relay_info.go | 7 +--
relay/helper/model_modifier.go | 4 +-
relay/helper/reasoning_suffix.go | 3 ++
.../internal/shared/claude/reasoning.go | 17 ++++---
.../internal/shared/gemini/request.go | 13 ++---
relaykit/relayconvert/reasoning/intent.go | 22 +++------
.../relayconvert/reasoning/intent_test.go | 47 +++++++++++++++++++
11 files changed, 125 insertions(+), 40 deletions(-)
diff --git a/relay/channel/deepseek/adaptor.go b/relay/channel/deepseek/adaptor.go
index 72805c8c8654..ea8cbb76172a 100644
--- a/relay/channel/deepseek/adaptor.go
+++ b/relay/channel/deepseek/adaptor.go
@@ -15,6 +15,7 @@ import (
"github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
+ "github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/setting/reasoning"
"github.com/gin-gonic/gin"
)
@@ -98,6 +99,9 @@ func applyDeepSeekV4OpenAIThinkingSuffix(info *relaycommon.RelayInfo, request *d
if info != nil && info.ChannelMeta != nil && info.UpstreamModelName != "" {
modelName = info.UpstreamModelName
}
+ if model_setting.ShouldPreserveThinkingSuffix(modelName) || info != nil && model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
+ return nil
+ }
baseModel, thinkingType, effort, ok := reasoning.ParseDeepSeekV4ThinkingSuffix(modelName)
if !ok {
return nil
@@ -125,6 +129,9 @@ func applyDeepSeekV4ClaudeThinkingSuffix(info *relaycommon.RelayInfo, request *d
if info != nil && info.ChannelMeta != nil && info.UpstreamModelName != "" {
modelName = info.UpstreamModelName
}
+ if model_setting.ShouldPreserveThinkingSuffix(modelName) || info != nil && model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
+ return nil
+ }
baseModel, thinkingType, effort, ok := reasoning.ParseDeepSeekV4ThinkingSuffix(modelName)
if !ok {
return nil
@@ -170,6 +177,9 @@ func applyDeepSeekV4ResponsesThinkingSuffix(info *relaycommon.RelayInfo, request
if info != nil && info.ChannelMeta != nil && info.UpstreamModelName != "" {
modelName = info.UpstreamModelName
}
+ if model_setting.ShouldPreserveThinkingSuffix(modelName) || info != nil && model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
+ return
+ }
baseModel, thinkingType, effort, ok := reasoning.ParseDeepSeekV4ThinkingSuffix(modelName)
if ok {
if thinkingType == "disabled" {
diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go
index 5543eeca0626..f4dce3ad49b7 100644
--- a/relay/channel/openai/adaptor.go
+++ b/relay/channel/openai/adaptor.go
@@ -249,6 +249,18 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if info.ChannelType != constant.ChannelTypeOpenAI && info.ChannelType != constant.ChannelTypeAzure {
request.StreamOptions = nil
}
+ // Nested reasoning is an OpenRouter-compatible input dialect and needs
+ // projection even without a protocol conversion hop. Native top-level
+ // reasoning_effort stays untouched unless a modifier or conversion applies.
+ // OpenRouter retains its own dialect normalization below.
+ preserveSuffix := model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) || model_setting.ShouldPreserveThinkingSuffix(info.UpstreamModelName)
+ upstreamEffort, _ := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.UpstreamModelName)
+ originEffort, _ := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.OriginModelName)
+ renderReasoning := len(request.Reasoning) > 0 || len(info.RequestConversionChain) > 1 || request.ReasoningConversion != nil || info.ReasoningState() != nil ||
+ !preserveSuffix && (upstreamEffort != "" || originEffort != "")
+ if info.ChannelType != constant.ChannelTypeOpenRouter && !renderReasoning {
+ info.SetReasoningEffort(request.ReasoningEffort)
+ }
if info.ChannelType == constant.ChannelTypeOpenRouter {
initialIntent, err := kitreasoning.FromOpenAIChat(request)
if err != nil {
@@ -273,7 +285,6 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
request.Usage = json.RawMessage(`{"include":true}`)
}
// 合并 effort 尾巴产生的意图
- preserveSuffix := model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) || model_setting.ShouldPreserveThinkingSuffix(info.UpstreamModelName)
mergeEffortSuffix := func(modelName string) error {
rawEffort, _ := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(modelName)
if rawEffort == "" {
@@ -374,8 +385,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
}
}
- if info.ChannelType != constant.ChannelTypeOpenRouter {
- preserveSuffix := model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) || model_setting.ShouldPreserveThinkingSuffix(info.UpstreamModelName)
+ if info.ChannelType != constant.ChannelTypeOpenRouter && renderReasoning {
effort, baseModel := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.UpstreamModelName)
if preserveSuffix {
effort = ""
@@ -412,7 +422,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
info.UpstreamModelName = baseModel
request.Model = baseModel
}
- if canonicalEffort := kitreasoning.OpenAIEffort(kitreasoning.EffectiveEffort(currentIntent)); canonicalEffort != "" {
+ if canonicalEffort := kitreasoning.EffectiveEffort(currentIntent); canonicalEffort != "" {
request.ReasoningEffort = string(canonicalEffort)
info.SetReasoningEffort(string(canonicalEffort))
}
@@ -666,6 +676,21 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo
if preserveSuffix {
effort = ""
}
+ originEffort := ""
+ if info != nil && !preserveSuffix {
+ originEffort, _ = reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.OriginModelName)
+ }
+ crossProtocol := info != nil && len(info.RequestConversionChain) > 1
+ if (info == nil || info.ChannelType != constant.ChannelTypeOpenRouter) && !crossProtocol && effort == "" && originEffort == "" && request.ReasoningConversion == nil && info.ReasoningState() == nil {
+ if info != nil {
+ rawEffort := ""
+ if request.Reasoning != nil {
+ rawEffort = request.Reasoning.Effort
+ }
+ info.SetReasoningEffort(rawEffort)
+ }
+ return request, nil
+ }
currentIntent, err := kitreasoning.FromOpenAIResponses(&request)
if err != nil {
return nil, kitreasoning.AsClientError(err)
@@ -700,7 +725,7 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo
info.UpstreamModelName = originModel
}
}
- if canonicalEffort := kitreasoning.OpenAIEffort(kitreasoning.EffectiveEffort(currentIntent)); canonicalEffort != "" {
+ if canonicalEffort := kitreasoning.EffectiveEffort(currentIntent); canonicalEffort != "" {
if request.Reasoning == nil {
request.Reasoning = &dto.Reasoning{}
}
diff --git a/relay/channel/volcengine/adaptor.go b/relay/channel/volcengine/adaptor.go
index ce8fd5901b78..7112debed6c7 100644
--- a/relay/channel/volcengine/adaptor.go
+++ b/relay/channel/volcengine/adaptor.go
@@ -308,6 +308,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
}
if !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) &&
+ !model_setting.ShouldPreserveThinkingSuffix(info.UpstreamModelName) &&
strings.HasSuffix(info.UpstreamModelName, "-thinking") &&
strings.HasPrefix(info.UpstreamModelName, "deepseek") {
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
diff --git a/relay/channel/xai/adaptor.go b/relay/channel/xai/adaptor.go
index 62b41b33987c..36150c4acbf3 100644
--- a/relay/channel/xai/adaptor.go
+++ b/relay/channel/xai/adaptor.go
@@ -11,6 +11,7 @@ import (
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
+ "github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/relay/constant"
@@ -78,10 +79,11 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
request.MaxCompletionTokens = request.MaxTokens
request.MaxTokens = nil
}
- if strings.HasSuffix(request.Model, "-high") {
+ preserveSuffix := model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) || model_setting.ShouldPreserveThinkingSuffix(request.Model)
+ if !preserveSuffix && strings.HasSuffix(request.Model, "-high") {
request.ReasoningEffort = "high"
request.Model = strings.TrimSuffix(request.Model, "-high")
- } else if strings.HasSuffix(request.Model, "-low") {
+ } else if !preserveSuffix && strings.HasSuffix(request.Model, "-low") {
request.ReasoningEffort = "low"
request.Model = strings.TrimSuffix(request.Model, "-low")
}
diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go
index b1bd4f1bcad5..e02559754c0c 100644
--- a/relay/common/relay_info.go
+++ b/relay/common/relay_info.go
@@ -496,9 +496,10 @@ func reasoningEffortFromRequest(request dto.Request) string {
}
case *dto.GeminiChatRequest:
if req != nil && req.GenerationConfig.ThinkingConfig != nil {
- intent, err := kitreasoning.FromGemini(req)
- if err == nil {
- effort = string(kitreasoning.EffectiveEffort(intent))
+ config := req.GenerationConfig.ThinkingConfig
+ effort = config.ThinkingLevel
+ if effort == "" && config.ThinkingBudget != nil {
+ effort = string(kitreasoning.EffortFromBudget(*config.ThinkingBudget))
}
}
}
diff --git a/relay/helper/model_modifier.go b/relay/helper/model_modifier.go
index 848f45709a69..175394532880 100644
--- a/relay/helper/model_modifier.go
+++ b/relay/helper/model_modifier.go
@@ -211,7 +211,7 @@ func applyModelControls(req dto.Request, parsed parsedModelModifiers) error {
delete(reasoningConfig, "max_tokens")
request.ReasoningEffort = ""
if parsed.intent.Effort != "" {
- request.ReasoningEffort = string(reasoning.OpenAIEffort(parsed.intent.Effort))
+ request.ReasoningEffort = string(parsed.intent.Effort)
}
}
if len(reasoningConfig) == 0 {
@@ -237,7 +237,7 @@ func applyModelControls(req dto.Request, parsed parsedModelModifiers) error {
if request.Reasoning == nil {
request.Reasoning = &dto.Reasoning{}
}
- request.Reasoning.Effort = string(reasoning.OpenAIEffort(parsed.intent.Effort))
+ request.Reasoning.Effort = string(parsed.intent.Effort)
} else if request.Reasoning != nil && parsed.intent.BudgetTokens == nil {
request.Reasoning.Effort = ""
}
diff --git a/relay/helper/reasoning_suffix.go b/relay/helper/reasoning_suffix.go
index 7b542cbc7e98..5a85ab609437 100644
--- a/relay/helper/reasoning_suffix.go
+++ b/relay/helper/reasoning_suffix.go
@@ -106,6 +106,9 @@ func ApplyReasoningModelSuffix(c *gin.Context, info *relaycommon.RelayInfo, outb
if info.Request != nil {
info.Request.SetModelName(info.UpstreamModelName)
}
+ if selected.hasThinking {
+ info.SetReasoningEffort(string(reasoning.EffectiveEffort(selected.intent)))
+ }
for i := range diagnostics {
diagnostics[i].From = info.RelayFormat
}
diff --git a/relaykit/relayconvert/internal/shared/claude/reasoning.go b/relaykit/relayconvert/internal/shared/claude/reasoning.go
index e792d43515ef..59a96a52168e 100644
--- a/relaykit/relayconvert/internal/shared/claude/reasoning.go
+++ b/relaykit/relayconvert/internal/shared/claude/reasoning.go
@@ -35,14 +35,19 @@ func ApplyReasoning(ctx context.Context, req *dto.ClaudeRequest, info convmeta.M
// accounting metadata, but do not run the capability renderer or rewrite
// provider-native controls.
if !crossProtocol && source.IsEmpty() && suffix.IsEmpty() {
- native, err := reasoning.FromClaude(req)
- if err != nil {
- return err
- }
if info != nil {
- if effort := reasoning.EffectiveEffort(native); effort != "" {
- info.SetReasoningEffort(string(effort))
+ effort := req.GetEfforts()
+ if effort == "" && req.Thinking != nil {
+ switch {
+ case req.Thinking.Type == "disabled":
+ effort = string(reasoning.EffortNone)
+ case req.Thinking.BudgetTokens != nil:
+ effort = string(reasoning.EffortFromBudget(*req.Thinking.BudgetTokens))
+ case req.Thinking.Type == "enabled" || req.Thinking.Type == "adaptive":
+ effort = string(reasoning.EffortHigh)
+ }
}
+ info.SetReasoningEffort(effort)
}
return nil
}
diff --git a/relaykit/relayconvert/internal/shared/gemini/request.go b/relaykit/relayconvert/internal/shared/gemini/request.go
index a270c1ed2dad..aacd0f9808f0 100644
--- a/relaykit/relayconvert/internal/shared/gemini/request.go
+++ b/relaykit/relayconvert/internal/shared/gemini/request.go
@@ -106,14 +106,15 @@ func ApplyThinkingConfig(geminiRequest *dto.GeminiChatRequest, info convmeta.Met
// modifier, read portable effort metadata without running the capability
// renderer or rewriting provider-native controls.
if !crossProtocol && suffix.IsEmpty() {
- native, err := reasoning.FromGemini(geminiRequest)
- if err != nil {
- return err
- }
if info != nil {
- if effort := reasoning.EffectiveEffort(native); effort != "" {
- info.SetReasoningEffort(string(effort))
+ effort := ""
+ if config := geminiRequest.GenerationConfig.ThinkingConfig; config != nil {
+ effort = config.ThinkingLevel
+ if effort == "" && config.ThinkingBudget != nil {
+ effort = string(reasoning.EffortFromBudget(*config.ThinkingBudget))
+ }
}
+ info.SetReasoningEffort(effort)
}
return nil
}
diff --git a/relaykit/relayconvert/reasoning/intent.go b/relaykit/relayconvert/reasoning/intent.go
index 061006950e1a..f404a974f64a 100644
--- a/relaykit/relayconvert/reasoning/intent.go
+++ b/relaykit/relayconvert/reasoning/intent.go
@@ -363,8 +363,8 @@ func FromOpenAIChat(req *dto.GeneralOpenAIRequest) (Intent, error) {
BudgetSource: SourcePivot,
}
if req.ReasoningEffort != "" {
- projectedEffort := OpenAIEffort(EffectiveEffort(pivot))
- if Effort(req.ReasoningEffort) == projectedEffort {
+ pivotEffort := EffectiveEffort(pivot)
+ if Effort(req.ReasoningEffort) == pivotEffort {
intent.Effort = ""
intent.Mode = ModeUnset
}
@@ -384,7 +384,7 @@ func ApplyToOpenAIChat(req *dto.GeneralOpenAIRequest, intent Intent) error {
return err
}
- if effort := OpenAIEffort(EffectiveEffort(intent)); effort != "" {
+ if effort := EffectiveEffort(intent); effort != "" {
req.ReasoningEffort = string(effort)
}
@@ -412,7 +412,7 @@ func ApplyToOpenAIResponses(req *dto.OpenAIResponsesRequest, intent Intent) erro
return err
}
- if effort := OpenAIEffort(EffectiveEffort(intent)); effort != "" {
+ if effort := EffectiveEffort(intent); effort != "" {
summary := "detailed"
if effort == EffortNone || (intent.IncludeThoughts != nil && !*intent.IncludeThoughts) {
summary = ""
@@ -436,16 +436,6 @@ func ApplyToOpenAIResponses(req *dto.OpenAIResponsesRequest, intent Intent) erro
return nil
}
-// OpenAIEffort maps the canonical cross-provider vocabulary to the public
-// OpenAI reasoning_effort vocabulary. Claude/OpenRouter "max" has no direct
-// OpenAI equivalent and is represented by xhigh at that wire boundary.
-func OpenAIEffort(effort Effort) Effort {
- if effort == EffortMax {
- return EffortXHigh
- }
- return effort
-}
-
func FromOpenAIResponses(req *dto.OpenAIResponsesRequest) (Intent, error) {
if req == nil {
return Intent{}, nil
@@ -481,8 +471,8 @@ func FromOpenAIResponses(req *dto.OpenAIResponsesRequest) (Intent, error) {
BudgetSource: SourcePivot,
}
if req.Reasoning != nil && req.Reasoning.Effort != "" {
- projectedEffort := OpenAIEffort(EffectiveEffort(pivot))
- if Effort(req.Reasoning.Effort) == projectedEffort {
+ pivotEffort := EffectiveEffort(pivot)
+ if Effort(req.Reasoning.Effort) == pivotEffort {
intent.Effort = ""
intent.Mode = ModeUnset
}
diff --git a/relaykit/relayconvert/reasoning/intent_test.go b/relaykit/relayconvert/reasoning/intent_test.go
index cc7c85f41ae4..ccfeb4422974 100644
--- a/relaykit/relayconvert/reasoning/intent_test.go
+++ b/relaykit/relayconvert/reasoning/intent_test.go
@@ -3,6 +3,7 @@ package reasoning
import (
"testing"
+ "github.com/QuantumNous/new-api/relaykit/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -123,3 +124,49 @@ func TestIntentStateRoundTrip(t *testing.T) {
func boolPtr(v bool) *bool {
return &v
}
+
+func TestOpenAIPivotRetainsExactStrengthAndBudget(t *testing.T) {
+ budget, include := 16384, false
+ for _, effort := range []Effort{EffortMax, EffortXHigh} {
+ t.Run(string(effort), func(t *testing.T) {
+ intent := Intent{Mode: ModeEnabled, Effort: effort, BudgetTokens: &budget, IncludeThoughts: &include}
+ chat := &dto.GeneralOpenAIRequest{}
+ require.NoError(t, ApplyToOpenAIChat(chat, intent))
+ assert.Equal(t, string(effort), chat.ReasoningEffort)
+ restored, err := FromOpenAIChat(chat)
+ require.NoError(t, err)
+ assert.Equal(t, effort, restored.Effort)
+ require.NotNil(t, restored.BudgetTokens)
+ assert.Equal(t, budget, *restored.BudgetTokens)
+ require.NotNil(t, restored.IncludeThoughts)
+ assert.False(t, *restored.IncludeThoughts)
+
+ responses := &dto.OpenAIResponsesRequest{}
+ require.NoError(t, ApplyToOpenAIResponses(responses, restored))
+ require.NotNil(t, responses.Reasoning)
+ assert.Equal(t, string(effort), responses.Reasoning.Effort)
+ restored, err = FromOpenAIResponses(responses)
+ require.NoError(t, err)
+ assert.Equal(t, effort, restored.Effort)
+ require.NotNil(t, restored.BudgetTokens)
+ assert.Equal(t, budget, *restored.BudgetTokens)
+ require.NotNil(t, restored.IncludeThoughts)
+ assert.False(t, *restored.IncludeThoughts)
+ })
+ }
+}
+
+func TestOpenAIPivotDoesNotTreatMaxAndXHighAsEquivalent(t *testing.T) {
+ intent := Intent{Mode: ModeEnabled, Effort: EffortMax}
+ chat := &dto.GeneralOpenAIRequest{}
+ require.NoError(t, ApplyToOpenAIChat(chat, intent))
+ chat.ReasoningEffort = "xhigh"
+ _, err := FromOpenAIChat(chat)
+ require.ErrorIs(t, err, ErrEffortConflict)
+
+ responses := &dto.OpenAIResponsesRequest{}
+ require.NoError(t, ApplyToOpenAIResponses(responses, intent))
+ responses.Reasoning.Effort = "xhigh"
+ _, err = FromOpenAIResponses(responses)
+ require.ErrorIs(t, err, ErrEffortConflict)
+}
From d5803532bdccde3a2b1583291f51e92d3519b1c6 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Sat, 5 Sep 2026 11:51:05 +0800
Subject: [PATCH 98/99] docs: require expression pricing and consolidated tests
---
AGENTS.md | 3 +++
1 file changed, 3 insertions(+)
diff --git a/AGENTS.md b/AGENTS.md
index 6b518e817ede..2d572112fd17 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -107,6 +107,8 @@ Do NOT directly import or call `encoding/json` in business code. `json.RawMessag
**Billing expression system:** When working on tiered/dynamic billing (expression-based pricing), MUST read `pkg/billingexpr/expr.md` first. It documents the design philosophy, expression language, full architecture, token normalization rules, quota conversion, and expression versioning. All billing expression changes must follow that document.
+**Built-in model pricing:** New built-in model prices MUST be defined as self-contained billing expressions in `setting/billing_setting/builtin_billing.go`, using real USD per million tokens. Do not add new built-in prices to the legacy model/completion/cache ratio tables. Preserve explicit administrator pricing overrides. Existing legacy prices are migrated only when explicitly requested. Verify published prices and cover applicable context-length thresholds and cache categories.
+
**Billing safety invariants:** Quota/billing code MUST never produce a negative charge (a credit) from arithmetic overflow or unvalidated input. Apply defense in depth:
- Every user-controlled quantity that becomes a billing multiplier (image `n`, video `seconds`/`duration`, resolution/quality ratios, batch counts) MUST be bounded before it reaches quota calculation. Reject out-of-range values at request validation with a 400. Existing bounds: `dto.MaxImageN` for image generation count, `relaycommon.MaxTaskDurationSeconds` for task video duration, `maxTokensLimit` (`relay/helper/valid_request.go`) for `max_tokens`-family fields on every relay format (OpenAI, Claude, Gemini, Responses). Reuse these constants instead of introducing new ad hoc limits for the same concepts. When adding a new relay format or request DTO, bound its max-tokens and count fields in its validator from day one.
@@ -121,6 +123,7 @@ Do NOT directly import or call `encoding/json` in business code. `json.RawMessag
**Backend test quality:** Backend tests must protect real behavior, API contracts, billing/accounting invariants, data compatibility, or regression paths.
+- **Do not scatter tests for a small change:** For a focused feature or fix, extend an existing suitable test file first. If a new test file is necessary, add at most one and consolidate the key regression cases there. MUST NOT create separate test files for the same small feature across `controller/`, `service/`, `setting/`, or other layers merely because its call chain crosses those layers. Do not repeat fixtures and assertions at each layer. Keep the cases compact and focused on observable behavior; the number of production files touched is not a reason to add more test files.
- Do not add tests that only improve coverage numbers, prove that code happens to run, or lock in implementation details without a user-visible or cross-module contract.
- Avoid fake fuzz/stress/smoke/performance tests built from random inputs, large loop counts, sleeps, timing comparisons, or log-only assertions.
- Avoid duplicate tests that exercise the same branch with different names but no new invariant.
From eb99ab1b40343c3317bb47981cccdbb2b159a5fa Mon Sep 17 00:00:00 2001
From: CaIon
Date: Sat, 5 Sep 2026 11:51:18 +0800
Subject: [PATCH 99/99] feat(billing): add built-in expression pricing for
gpt-6-astra
---
controller/option.go | 23 +++-
relay/channel/openai/constant.go | 1 +
setting/billing_setting/builtin_billing.go | 11 ++
.../billing_setting/builtin_billing_test.go | 111 ++++++++++++++++++
setting/billing_setting/tiered_billing.go | 43 ++++++-
5 files changed, 178 insertions(+), 11 deletions(-)
create mode 100644 setting/billing_setting/builtin_billing.go
create mode 100644 setting/billing_setting/builtin_billing_test.go
diff --git a/controller/option.go b/controller/option.go
index 1feb4a818d37..20ce18b2f841 100644
--- a/controller/option.go
+++ b/controller/option.go
@@ -3,6 +3,7 @@ package controller
import (
"fmt"
"net/http"
+ "slices"
"sort"
"strconv"
"strings"
@@ -85,7 +86,7 @@ func GetOptions(c *gin.Context) {
optionValues := make(map[string]string)
common.OptionMapRWMutex.Lock()
for k, v := range common.OptionMap {
- if k == "theme.frontend" {
+ if k == "theme.frontend" || k == "billing_setting.billing_mode" || k == "billing_setting.billing_expr" {
continue
}
value := common.Interface2String(v)
@@ -101,14 +102,24 @@ func GetOptions(c *gin.Context) {
Key: k,
Value: value,
})
- for _, optionKey := range completionRatioMetaOptionKeys {
- if optionKey == k {
- optionValues[k] = value
- break
- }
+ if slices.Contains(completionRatioMetaOptionKeys, k) {
+ optionValues[k] = value
}
}
common.OptionMapRWMutex.Unlock()
+ // Display the same effective expressions used by pricing and settlement,
+ // including built-in defaults absent from persisted administrator options.
+ for key, values := range map[string]map[string]string{
+ "billing_setting.billing_mode": billing_setting.GetBillingModeCopy(),
+ "billing_setting.billing_expr": billing_setting.GetBillingExprCopy(),
+ } {
+ encoded, err := common.Marshal(values)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+ options = append(options, &model.Option{Key: key, Value: string(encoded)})
+ }
options = append(options, &model.Option{
Key: "CompletionRatioMeta",
Value: buildCompletionRatioMetaValue(optionValues),
diff --git a/relay/channel/openai/constant.go b/relay/channel/openai/constant.go
index 6bf2cac1c652..7afaefdfd9de 100644
--- a/relay/channel/openai/constant.go
+++ b/relay/channel/openai/constant.go
@@ -1,6 +1,7 @@
package openai
var ModelList = []string{
+ "gpt-6-astra",
"gpt-3.5-turbo", "gpt-3.5-turbo-0613", "gpt-3.5-turbo-1106", "gpt-3.5-turbo-0125",
"gpt-3.5-turbo-16k", "gpt-3.5-turbo-16k-0613",
"gpt-3.5-turbo-instruct", "gpt-3.5-turbo-instruct-0914",
diff --git a/setting/billing_setting/builtin_billing.go b/setting/billing_setting/builtin_billing.go
new file mode 100644
index 000000000000..9ee188c68ce7
--- /dev/null
+++ b/setting/billing_setting/builtin_billing.go
@@ -0,0 +1,11 @@
+package billing_setting
+
+// Built-in token prices use actual USD per million tokens. Keep new model
+// defaults here instead of splitting them across the legacy ratio tables.
+var builtinBillingExpr = map[string]string{
+ // https://developers.openai.com/api/docs/models/gpt-6-astra
+ // Standard pricing; the long-context rates apply to the whole request.
+ // Do not infer service-tier discounts from incoming request parameters:
+ // channels filter service_tier by default, so it may not reach the upstream.
+ "gpt-6-astra": `len <= 272000 ? tier("standard", p * 10 + c * 50 + cr * 1 + cc * 12.5) : tier("long_context", p * 20 + c * 75 + cr * 2 + cc * 25)`,
+}
diff --git a/setting/billing_setting/builtin_billing_test.go b/setting/billing_setting/builtin_billing_test.go
new file mode 100644
index 000000000000..4a2b9e4ae734
--- /dev/null
+++ b/setting/billing_setting/builtin_billing_test.go
@@ -0,0 +1,111 @@
+package billing_setting_test
+
+import (
+ "net/http/httptest"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/controller"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/pkg/billingexpr"
+ "github.com/QuantumNous/new-api/relaykit/dto"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/setting/billing_setting"
+ "github.com/QuantumNous/new-api/setting/config"
+ "github.com/QuantumNous/new-api/setting/ratio_setting"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGPT6AstraBuiltinBilling(t *testing.T) {
+ settings := config.GlobalConfig.Get("billing_setting").(*billing_setting.BillingSetting)
+ saved := *settings
+ savedRatios, savedPrices := ratio_setting.ModelRatio2JSONString(), ratio_setting.ModelPrice2JSONString()
+ savedOptions := common.OptionMap
+ t.Cleanup(func() {
+ *settings, common.OptionMap = saved, savedOptions
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios))
+ require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(savedPrices))
+ })
+ common.OptionMap = map[string]string{"billing_setting.billing_mode": `{}`, "billing_setting.billing_expr": `{}`}
+ require.NoError(t, config.GlobalConfig.LoadFromDB(common.OptionMap))
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{}`))
+ require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{}`))
+ assert.Equal(t, billing_setting.BillingModeTieredExpr, billing_setting.GetBillingMode("gpt-6-astra"))
+ expression, ok := billing_setting.GetBillingExpr("gpt-6-astra")
+ require.True(t, ok)
+
+ for _, tc := range []struct {
+ name string
+ input, output, cached, written int
+ request string
+ quota int
+ }{
+ {"standard", 1000, 100, 0, 0, `{}`, 7500},
+ {"client flex cannot discount standard pricing", 1000, 100, 0, 0, `{"service_tier":"flex"}`, 7500},
+ {"cache at context boundary", 272000, 1000, 200000, 20000, `{}`, 510000},
+ {"whole request above boundary", 272001, 1000, 200000, 20000, `{}`, 1007510},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ usage := &dto.Usage{
+ PromptTokens: tc.input, CompletionTokens: tc.output,
+ PromptTokensDetails: dto.InputTokenDetails{CachedTokens: tc.cached, CacheWriteTokens: tc.written},
+ }
+ params := service.BuildTieredTokenParams(usage, false, billingexpr.UsedVars(expression))
+ result, err := billingexpr.ComputeTieredQuotaWithRequest(&billingexpr.BillingSnapshot{
+ ExprString: expression, GroupRatio: 1, QuotaPerUnit: 500000,
+ }, params, billingexpr.RequestInput{Body: []byte(tc.request)})
+ require.NoError(t, err)
+ assert.Equal(t, tc.quota, result.ActualQuotaAfterGroup)
+ })
+ }
+
+ t.Run("admin options expose defaults without persisting them", func(t *testing.T) {
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ controller.GetOptions(ctx)
+ var response struct {
+ Success bool
+ Data []model.Option
+ }
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success)
+ found := map[string]string{}
+ for _, option := range response.Data {
+ if _, ok := common.OptionMap[option.Key]; ok {
+ assert.NotContains(t, found, option.Key)
+ var values map[string]string
+ require.NoError(t, common.UnmarshalJsonStr(option.Value, &values))
+ found[option.Key] = values["gpt-6-astra"]
+ assert.Equal(t, `{}`, common.OptionMap[option.Key])
+ }
+ }
+ assert.Equal(t, map[string]string{"billing_setting.billing_mode": "tiered_expr", "billing_setting.billing_expr": expression}, found)
+ })
+
+ for _, tc := range []struct {
+ name, mode, expr, ratios, prices, wantMode string
+ }{
+ {"custom expression overrides legacy price", "tiered_expr", "p * 7", `{"gpt-6-astra":8}`, `{}`, "tiered_expr"},
+ {"explicit ratio mode", "ratio", "", `{}`, `{}`, "ratio"},
+ {"existing free token price", "", "", `{"gpt-6-astra":0}`, `{}`, "ratio"},
+ {"existing per-call price", "", "", `{}`, `{"gpt-6-astra":0.1}`, "ratio"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ *settings = billing_setting.BillingSetting{BillingMode: map[string]string{}, BillingExpr: map[string]string{}}
+ if tc.mode != "" {
+ settings.BillingMode["gpt-6-astra"] = tc.mode
+ }
+ if tc.expr != "" {
+ settings.BillingExpr["gpt-6-astra"] = tc.expr
+ }
+ require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(tc.ratios))
+ require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(tc.prices))
+ assert.Equal(t, tc.wantMode, billing_setting.GetBillingMode("gpt-6-astra"))
+ actual, ok := billing_setting.GetBillingExpr("gpt-6-astra")
+ assert.Equal(t, tc.expr, actual)
+ assert.Equal(t, tc.expr != "", ok)
+ })
+ }
+}
diff --git a/setting/billing_setting/tiered_billing.go b/setting/billing_setting/tiered_billing.go
index a7e32b29aad6..ab618276b3ed 100644
--- a/setting/billing_setting/tiered_billing.go
+++ b/setting/billing_setting/tiered_billing.go
@@ -6,11 +6,12 @@ import (
"sort"
"github.com/QuantumNous/new-api/common"
- "github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/pkg/billingexpr"
"github.com/QuantumNous/new-api/pkg/jsplugin"
relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/config"
+ "github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/samber/lo"
)
@@ -46,20 +47,52 @@ func GetBillingMode(model string) string {
if mode, ok := billingSetting.BillingMode[model]; ok {
return mode
}
+ if _, ok := builtinBillingExpr[model]; ok {
+ // Existing administrator-configured legacy prices take precedence over
+ // a newly introduced built-in expression unless a mode was explicit.
+ if ratio_setting.HasConfiguredModelRatio(model) {
+ return BillingModeRatio
+ }
+ if _, configured := ratio_setting.GetModelPrice(model, false); configured {
+ return BillingModeRatio
+ }
+ return BillingModeTieredExpr
+ }
return BillingModeRatio
}
func GetBillingExpr(model string) (string, bool) {
- expr, ok := billingSetting.BillingExpr[model]
- return expr, ok
+ if expr, ok := billingSetting.BillingExpr[model]; ok {
+ return expr, true
+ }
+ if GetBillingMode(model) == BillingModeTieredExpr {
+ expr, ok := builtinBillingExpr[model]
+ return expr, ok
+ }
+ return "", false
}
func GetBillingModeCopy() map[string]string {
- return lo.Assign(billingSetting.BillingMode)
+ modes := lo.Assign(billingSetting.BillingMode)
+ for model := range builtinBillingExpr {
+ if _, configured := modes[model]; !configured && GetBillingMode(model) == BillingModeTieredExpr {
+ modes[model] = BillingModeTieredExpr
+ }
+ }
+ return modes
}
func GetBillingExprCopy() map[string]string {
- return lo.Assign(billingSetting.BillingExpr)
+ expressions := lo.Assign(billingSetting.BillingExpr)
+ for model := range builtinBillingExpr {
+ if _, configured := expressions[model]; configured {
+ continue
+ }
+ if expression, ok := GetBillingExpr(model); ok {
+ expressions[model] = expression
+ }
+ }
+ return expressions
}
func GetPricingSyncData(base map[string]any) map[string]any {