mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 08:35:33 +08:00
feat(task): let ordinary channels compete on shared plugin endpoints
When a task plugin claimed a model on a shared host endpoint (/v1/images/generations, /v1/images/edits, /v1/responses, /v1/videos), every request for that model went to the plugin, even when an ordinary OpenAI-compatible channel served the model too. Ordinary channels and the matching plugin channels now compete for the request under the usual group, priority, weight, affinity and channel-pin rules. When an ordinary channel wins, the original request reaches the ordinary relay unchanged: no plugin decoder, mode check or model rewrite runs, and retries stay on ordinary channels. When a plugin channel wins, the plugin handles the request as before and retries stay on its accepted plugin candidates. Native plugin routes still belong to their plugins. Ordinary and plugin prices are kept apart. Task expressions return USD, while ordinary expressions keep their USD-per-million result, so an ordinary request now fails with 400 model_price_error before any quota is reserved when the model's expression reads task usage (u()) or is still inherited by a task plugin on that endpoint. The model pricing editor can set ordinary OpenAI prices, per returned image with fixed(...) * image_count, while each plugin keeps its price in its own override. A model served by a single plugin now shows that plugin's pricing editor as well.
This commit is contained in:
33 files changed
+1078
-103
No files matched your search
@@ -1525,6 +1525,12 @@ func TestSharedModelPluginPricingDatabaseMatrix(t *testing.T) {
|
||||
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(spec.key) })
|
||||
if spec.key == "matrix-alpha" {
|
||||
single, err := model.GetModelPricingSnapshot([]string{name})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, single.Entries[0].PluginVariants, 1, "a sole provider must expose its separate pricing editor")
|
||||
assert.Equal(t, "matrix-alpha", single.Entries[0].PluginVariants[0].PluginKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Representative existing options have no plugin-expression row.
|
||||
@@ -1560,6 +1566,21 @@ func TestSharedModelPluginPricingDatabaseMatrix(t *testing.T) {
|
||||
assert.Equal(t, base, loaded.Entries[0].PluginVariants[0].Effective)
|
||||
assert.True(t, loaded.Entries[0].PluginVariants[1].Compatible)
|
||||
assert.Equal(t, variant, loaded.Entries[0].PluginVariants[1].Configured)
|
||||
// Ordinary relay pricing and the existing plugin expressions are
|
||||
// saved together without changing either plugin's billing contract.
|
||||
ordinary := model.ModelPricingChange{ModelName: name, ExpectedVersion: loaded.Entries[0].Version, Pricing: model.PricingValues{
|
||||
"billing_setting.billing_mode": "tiered_expr", "billing_setting.billing_expr": `tier("image", fixed(0.04)) * image_count`,
|
||||
billing_setting.PluginBillingExprOption: map[string]any{"matrix-alpha": base, "matrix-beta": variant},
|
||||
}}
|
||||
require.NoError(t, model.UpdateModelPricing([]model.ModelPricingChange{ordinary}))
|
||||
separate, err := model.GetModelPricingSnapshot([]string{name})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ordinary.Pricing, separate.Entries[0].Configured)
|
||||
assert.Equal(t, base, separate.Entries[0].PluginVariants[0].Effective)
|
||||
assert.Equal(t, variant, separate.Entries[0].PluginVariants[1].Effective)
|
||||
// Keep exercising the pre-existing plugin-default configuration below.
|
||||
change.ExpectedVersion = separate.Entries[0].Version
|
||||
require.NoError(t, model.UpdateModelPricing([]model.ModelPricingChange{change}))
|
||||
var flat map[string]string
|
||||
require.NoError(t, common.UnmarshalJsonStr(loaded.Options[billing_setting.PluginBillingExprOption], &flat))
|
||||
assert.Equal(t, map[string]string{"matrix-beta::" + name: variant}, flat)
|
||||
|
||||
+21
-1
@@ -62,7 +62,16 @@ A plugin whose vendor has no LobeHub icon ships its logo as a sidecar file, `ico
|
||||
|
||||
`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, and neither the Task Plugin (61) nor the New API (60) type may be declared. Third-party plugins normally omit `channelTypes` and live on type-61 "Task Plugin" channels bound by `task_plugin_key`, or are listed in a type-60 New API channel's `task_extend_plugin_keys` without claiming a channel type. The previous split identity/compatibility field names are rejected.
|
||||
|
||||
Before channel distribution, the host calls every mode-compatible candidate's
|
||||
On shared host endpoints, ordinary relay channels and matching plugin channels
|
||||
compete under the usual group, priority, weight, affinity, and channel-pin rules.
|
||||
A plugin's model declaration does not exclude an ordinary OpenAI-compatible
|
||||
channel serving the same model. When an ordinary channel wins, the host keeps
|
||||
the original request body and skips plugin mode checks, decoders, and model
|
||||
rewrites. Retries remain on ordinary channels for that endpoint; plugin retries
|
||||
remain on its accepted plugin candidates. Plugin-owned native routes keep their
|
||||
exclusive routing.
|
||||
|
||||
When a plugin channel wins initial selection, before final distribution the host calls every mode-compatible candidate's
|
||||
`decodeRequest`. Only candidates returning a valid submit intent for the pinned
|
||||
model remain eligible. If all reject the request, the host returns the candidate
|
||||
failures, grouping identical messages with their plugin keys. A single candidate
|
||||
@@ -82,6 +91,17 @@ labels continue to come from the first plugin in ascending key order. Saving a
|
||||
model expression checks every candidate without its own override in the same
|
||||
draft. Provider overrides are not included in model-price synchronization.
|
||||
|
||||
When the same model also uses ordinary relay channels, keep ordinary pricing at
|
||||
the model level and task pricing in plugin overrides. Ordinary relay expressions
|
||||
cannot call `u()`; use `fixed(...) * image_count` for per-image USD prices.
|
||||
Task expressions return USD directly, while ordinary expressions retain their
|
||||
USD-times-one-million result contract. An ordinary request fails with 400
|
||||
`model_price_error` before reservation or upstream submission if it encounters a
|
||||
usage expression, or an expression still inherited by an endpoint's task plugin.
|
||||
Expressions containing `fixed()` are unambiguously ordinary pricing, since task
|
||||
usage billing already rejects them. Existing task expressions are never rewritten
|
||||
or converted automatically.
|
||||
|
||||
Saved overrides remain visible and effective when only one provider is left.
|
||||
An override whose plugin is unavailable or no longer declares the model is
|
||||
marked `stale` in the administration snapshot. Its unchanged value can be kept
|
||||
|
||||
@@ -37,6 +37,7 @@ type ChannelFilterKind string
|
||||
|
||||
const (
|
||||
FilterRequestPath ChannelFilterKind = "request_path"
|
||||
FilterTaskPluginEndpoint ChannelFilterKind = "task_plugin_endpoint"
|
||||
FilterTaskPluginIdentity ChannelFilterKind = "task_plugin_identity"
|
||||
FilterResponsesWebSocket ChannelFilterKind = "responses_websocket"
|
||||
)
|
||||
@@ -47,6 +48,9 @@ type ChannelFilter struct {
|
||||
TaskPluginKey string
|
||||
TaskPluginKeys []string
|
||||
TaskPluginChannelTypes []int
|
||||
// TaskPluginRelayOnly excludes endpoint candidates after an ordinary relay
|
||||
// channel wins selection, so retries keep using the same protocol handler.
|
||||
TaskPluginRelayOnly bool
|
||||
}
|
||||
|
||||
type ChannelConstraints struct {
|
||||
|
||||
@@ -40,6 +40,8 @@ const (
|
||||
MsgQuotaSubscriptionInsufficient = "quota.subscription_insufficient"
|
||||
MsgRelayModelPriceNotConfigured = "relay.model_price_not_configured"
|
||||
MsgRelayModelPriceNotConfiguredAdmin = "relay.model_price_not_configured_admin"
|
||||
MsgRelayTaskUsagePriceUnsupported = "relay.task_usage_price_unsupported"
|
||||
MsgRelaySharedTaskPriceNeedsOverride = "relay.shared_task_price_needs_override"
|
||||
MsgRelayTaskChannelDisabled = "relay.task_channel_disabled"
|
||||
MsgRelayGroupSaturated = "relay.group_saturated"
|
||||
MsgRelayGroupUpstreamSaturated = "relay.group_upstream_saturated"
|
||||
|
||||
@@ -31,6 +31,8 @@ quota.pre_consume_insufficient: "Not enough quota to pre-consume for this reques
|
||||
quota.subscription_insufficient: "Subscription quota is insufficient or no subscription is configured: {{.Error}}"
|
||||
relay.model_price_not_configured: "Model {{.Model}} has not been priced by the administrator yet. Please contact the site administrator to enable this model."
|
||||
relay.model_price_not_configured_admin: "Model {{.Model}} price not configured. Go to System Settings → Operation Settings to enable self-use mode, or configure the model price in System Settings → Group & Model Pricing."
|
||||
relay.task_usage_price_unsupported: "Model {{.Model}} uses a task usage expression; configure ordinary relay pricing and separate plugin billing expressions"
|
||||
relay.shared_task_price_needs_override: "Model {{.Model}} shares its billing expression with task plugin {{.Plugin}}; configure a separate plugin billing expression before using ordinary relay pricing"
|
||||
relay.task_channel_disabled: "The channel of this task has been disabled"
|
||||
relay.group_saturated: "The current group is at capacity. Please try again later, or upgrade your account for better service."
|
||||
relay.group_upstream_saturated: "The upstream of the current group is at capacity. Please try again later"
|
||||
|
||||
@@ -32,6 +32,8 @@ quota.pre_consume_insufficient: "预扣费额度失败, 用户剩余额度: {{.R
|
||||
quota.subscription_insufficient: "订阅额度不足或未配置订阅: {{.Error}}"
|
||||
relay.model_price_not_configured: "模型 {{.Model}} 的价格尚未由管理员配置,暂时无法使用,请联系站点管理员开启该模型;Model {{.Model}} has not been priced by the administrator yet. Please contact the site administrator to enable this model."
|
||||
relay.model_price_not_configured_admin: "模型 {{.Model}} 的价格未配置。请前往「系统设置 → 运营设置」开启自用模式,或在「系统设置 → 分组与模型定价设置」中为该模型配置价格;Model {{.Model}} price not configured. Go to System Settings → Operation Settings to enable self-use mode, or configure the model price in System Settings → Group & Model Pricing."
|
||||
relay.task_usage_price_unsupported: "模型 {{.Model}} 使用了任务用量表达式;请为普通转发配置常规定价,并为插件单独配置计费表达式"
|
||||
relay.shared_task_price_needs_override: "模型 {{.Model}} 与任务插件 {{.Plugin}} 共用了计费表达式;请先为插件配置独立的计费表达式,再使用普通转发定价"
|
||||
relay.task_channel_disabled: "该任务所属渠道已被禁用"
|
||||
relay.group_saturated: "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。"
|
||||
relay.group_upstream_saturated: "当前分组上游负载已饱和,请稍后再试"
|
||||
|
||||
@@ -32,6 +32,8 @@ quota.pre_consume_insufficient: "預扣費額度失敗,使用者剩餘額度
|
||||
quota.subscription_insufficient: "訂閱額度不足或未設定訂閱:{{.Error}}"
|
||||
relay.model_price_not_configured: "模型 {{.Model}} 的價格尚未由管理員設定,暫時無法使用,請聯絡站點管理員開啟該模型"
|
||||
relay.model_price_not_configured_admin: "模型 {{.Model}} 的價格未設定。請前往「系統設定 → 營運設定」開啟自用模式,或在「系統設定 → 分組與模型定價設定」中為該模型設定價格"
|
||||
relay.task_usage_price_unsupported: "模型 {{.Model}} 使用了任務用量表達式;請為一般轉發設定常規定價,並為插件單獨設定計費表達式"
|
||||
relay.shared_task_price_needs_override: "模型 {{.Model}} 與任務插件 {{.Plugin}} 共用了計費表達式;請先為插件設定獨立的計費表達式,再使用一般轉發定價"
|
||||
relay.task_channel_disabled: "該任務所屬管道已被停用"
|
||||
relay.group_saturated: "目前分組負載已飽和,請稍後再試,或升級帳戶以提升服務品質。"
|
||||
relay.group_upstream_saturated: "目前分組上游負載已飽和,請稍後再試"
|
||||
|
||||
@@ -100,13 +100,22 @@ func Distribute() func(c *gin.Context) {
|
||||
if pinned || shouldSelectChannel {
|
||||
usingGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
|
||||
var selectErr *service.ChannelSelectError
|
||||
channel, _, selectErr = service.SelectChannelForRequest(c, modelRequest.Model, &service.RetryParam{
|
||||
Ctx: c,
|
||||
ModelName: modelRequest.Model,
|
||||
TokenGroup: usingGroup,
|
||||
RequestPath: c.Request.URL.Path,
|
||||
Retry: common.GetPointer(0),
|
||||
})
|
||||
if value, exists := c.Get(contextKeyTaskPluginEndpointSelection); exists && !pinned {
|
||||
if selected, ok := value.(taskPluginEndpointSelection); ok && selected.Model == modelRequest.Model && selected.Channel != nil && selected.Channel.Status == common.ChannelStatusEnabled {
|
||||
if matches, _ := model.ChannelSatisfiesFilters(selected.Channel, modelRequest.Model, constraints.Filters); matches {
|
||||
channel = selected.Channel
|
||||
}
|
||||
}
|
||||
}
|
||||
if channel == nil {
|
||||
channel, _, selectErr = service.SelectChannelForRequest(c, modelRequest.Model, &service.RetryParam{
|
||||
Ctx: c,
|
||||
ModelName: modelRequest.Model,
|
||||
TokenGroup: usingGroup,
|
||||
RequestPath: c.Request.URL.Path,
|
||||
Retry: common.GetPointer(0),
|
||||
})
|
||||
}
|
||||
if selectErr != nil {
|
||||
if selectErr.FilterKind == taskdto.FilterTaskPluginIdentity {
|
||||
logTaskPluginChannelDecision(c, selectErr.Channel, modelRequest.Model, "channel_rejected", "identity_mismatch")
|
||||
|
||||
+101
-42
@@ -33,6 +33,14 @@ import (
|
||||
|
||||
const contextKeyTaskPluginEndpointModel = "task_plugin_endpoint_model_request"
|
||||
|
||||
const contextKeyTaskPluginEndpointSelection = "task_plugin_endpoint_channel_selection"
|
||||
|
||||
type taskPluginEndpointSelection struct {
|
||||
Generation *pluginruntime.RoutingGeneration
|
||||
Channel *model.Channel
|
||||
Model string
|
||||
}
|
||||
|
||||
var errTaskPluginUnsupportedMediaType = errors.New("unsupported task plugin media type")
|
||||
|
||||
const taskPluginInvalidRouteResult = "plugin returned an invalid route result"
|
||||
@@ -330,12 +338,93 @@ func PrepareTaskPluginRoute() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// RouteTaskPluginEndpoint selects between ordinary relay and plugin channels
|
||||
// before a plugin can validate or rewrite the client's request. Plugin calls
|
||||
// still prepare their candidates and origin-task pins before final distribution.
|
||||
func RouteTaskPluginEndpoint() gin.HandlerFunc {
|
||||
pinEndpoint := PinTaskPluginEndpoint()
|
||||
return func(c *gin.Context) {
|
||||
generation := pluginruntime.DefaultRegistry.Generation()
|
||||
request, err := getModelFromRequest(c)
|
||||
if err != nil || generation == nil {
|
||||
pinEndpoint(c)
|
||||
return
|
||||
}
|
||||
endpoint, found := lookupTaskPluginEndpoint(generation, c.Request.Method, c.Request.URL.Path, request.Model)
|
||||
if !found {
|
||||
c.Set(contextKeyTaskPluginEndpointModel, *request)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
filter := dto.ChannelFilter{Kind: dto.FilterTaskPluginEndpoint}
|
||||
for _, candidate := range endpoint.Candidates {
|
||||
filter.TaskPluginKeys = append(filter.TaskPluginKeys, candidate.Plugin.Meta.Key)
|
||||
filter.TaskPluginChannelTypes = append(filter.TaskPluginChannelTypes, candidate.Plugin.Meta.ChannelTypes...)
|
||||
}
|
||||
constraints := service.GetChannelConstraints(c)
|
||||
filterIndex := len(constraints.Filters)
|
||||
constraints.AddFilter(filter)
|
||||
constraints.AddFilter(dto.ChannelFilter{Kind: dto.FilterRequestPath, RequestPath: c.Request.URL.Path})
|
||||
group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
|
||||
channel, _, selectErr := service.SelectChannelForRequest(c, request.Model, &service.RetryParam{
|
||||
Ctx: c, ModelName: request.Model, TokenGroup: group,
|
||||
RequestPath: c.Request.URL.Path, Retry: common.GetPointer(0),
|
||||
})
|
||||
c.Set(contextKeyTaskPluginEndpointSelection, taskPluginEndpointSelection{
|
||||
Generation: generation, Channel: channel, Model: request.Model,
|
||||
})
|
||||
if selectErr != nil {
|
||||
// Keep the existing plugin diagnostics and canonical-model lookup
|
||||
// when selection cannot resolve the client's original spelling.
|
||||
pinEndpoint(c)
|
||||
return
|
||||
}
|
||||
filter.Kind = dto.FilterTaskPluginIdentity
|
||||
filter.TaskPluginKey = endpoint.Plugin.Meta.Key
|
||||
if pluginChannel, _ := model.ChannelSatisfiesFilters(channel, request.Model, []dto.ChannelFilter{filter}); !pluginChannel {
|
||||
constraints.Filters[filterIndex].TaskPluginRelayOnly = true
|
||||
c.Set(pluginruntime.ContextKeyRelayEndpoint, endpoint)
|
||||
c.Set(contextKeyTaskPluginEndpointModel, *request)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
pinEndpoint(c)
|
||||
}
|
||||
}
|
||||
|
||||
func lookupTaskPluginEndpoint(generation *pluginruntime.RoutingGeneration, method, path, modelName string) (pluginruntime.PinnedEndpoint, bool) {
|
||||
lookupModel, pinModel, mappedModel := modelName, modelName, ""
|
||||
if declared, ok := generation.CanonicalModel(modelName); ok {
|
||||
lookupModel, pinModel = declared, declared
|
||||
} else if target, ok := model.ResolveTaskModelAlias(generation, modelName); ok {
|
||||
if target.Declared == "" {
|
||||
return pluginruntime.PinnedEndpoint{}, false
|
||||
}
|
||||
lookupModel, pinModel, mappedModel = target.Declared, target.Alias, target.Declared
|
||||
}
|
||||
candidates := generation.LookupEndpointCandidates(method, path, lookupModel)
|
||||
if len(candidates) == 0 {
|
||||
return pluginruntime.PinnedEndpoint{}, false
|
||||
}
|
||||
binding := candidates[0]
|
||||
return pluginruntime.PinnedEndpoint{
|
||||
Generation: generation, Plugin: binding.Plugin, Protocol: binding.Protocol,
|
||||
Operation: binding.Operation, Model: pinModel, MappedModel: mappedModel, Candidates: candidates,
|
||||
}, true
|
||||
}
|
||||
|
||||
// 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 value, exists := c.Get(contextKeyTaskPluginEndpointSelection); exists {
|
||||
if selected, ok := value.(taskPluginEndpointSelection); ok {
|
||||
generation = selected.Generation
|
||||
}
|
||||
}
|
||||
if generation == nil {
|
||||
c.Next()
|
||||
return
|
||||
@@ -356,47 +445,22 @@ func PinTaskPluginEndpoint() gin.HandlerFunc {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
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 {
|
||||
pinnedEndpoint, found := lookupTaskPluginEndpoint(generation, c.Request.Method, c.Request.URL.Path, claimedModel)
|
||||
if !found {
|
||||
c.Set(contextKeyTaskPluginEndpointModel, *modelRequest)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if rewriteTo != "" {
|
||||
if rewriteErr := rewriteTaskPluginJSONModel(c, rewriteTo); rewriteErr != nil {
|
||||
if claimedModel != pinnedEndpoint.Model {
|
||||
if rewriteErr := rewriteTaskPluginJSONModel(c, pinnedEndpoint.Model); rewriteErr != nil {
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, "Invalid task protocol request")
|
||||
return
|
||||
}
|
||||
}
|
||||
modelRequest.Model = pinModel
|
||||
modelRequest.Model = pinnedEndpoint.Model
|
||||
c.Set(contextKeyTaskPluginEndpointModel, *modelRequest)
|
||||
candidates := generation.LookupEndpointCandidates(c.Request.Method, c.Request.URL.Path, lookupModel)
|
||||
if len(candidates) == 0 {
|
||||
candidates = []pluginruntime.ProtocolBinding{binding}
|
||||
}
|
||||
candidates := pinnedEndpoint.Candidates
|
||||
binding := candidates[0]
|
||||
if definition, known := pluginruntime.HostProtocol(binding.Protocol); known && len(definition.DefinedModes()) > 0 {
|
||||
stream, background := jsonBodyBoolFlags(c)
|
||||
required := make([]string, 0, 2)
|
||||
@@ -435,15 +499,10 @@ 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: pinModel,
|
||||
MappedModel: mappedModel,
|
||||
Candidates: candidates,
|
||||
}
|
||||
pinnedEndpoint.Plugin = binding.Plugin
|
||||
pinnedEndpoint.Protocol = binding.Protocol
|
||||
pinnedEndpoint.Operation = binding.Operation
|
||||
pinnedEndpoint.Candidates = candidates
|
||||
c.Set(pluginruntime.ContextKeyPinnedPlugin, pin)
|
||||
c.Set(pluginruntime.ContextKeyPinnedEndpoint, pinnedEndpoint)
|
||||
logger.LogDebug(
|
||||
@@ -454,7 +513,7 @@ func PinTaskPluginEndpoint() gin.HandlerFunc {
|
||||
binding.Plugin.Meta.Version,
|
||||
binding.Operation.Methods[0],
|
||||
binding.Protocol,
|
||||
pinModel,
|
||||
pinnedEndpoint.Model,
|
||||
)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/iotest"
|
||||
@@ -21,12 +22,14 @@ import (
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/pkg/jsplugin"
|
||||
builtinplugins "github.com/QuantumNous/new-api/plugins"
|
||||
kitdto "github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"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"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
const genericTaskPluginSource = `
|
||||
@@ -2051,3 +2054,272 @@ func TestPrepareTaskPluginEndpointFiltersEachSharedCandidate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteTaskPluginEndpointUsesSelectedChannel(t *testing.T) {
|
||||
require.NoError(t, appI18n.Init())
|
||||
for _, dialect := range []string{"sqlite", "mysql", "postgres"} {
|
||||
t.Run(dialect, func(t *testing.T) {
|
||||
dsn := ""
|
||||
if dialect != "sqlite" {
|
||||
dsn = os.Getenv("TEST_" + strings.ToUpper(dialect) + "_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("database test DSN not configured")
|
||||
}
|
||||
}
|
||||
previousDB, previousMain, previousLog := model.DB, common.MainDatabaseType(), common.LogDatabaseType()
|
||||
previousPath, previousMaster := common.SQLitePath, common.IsMasterNode
|
||||
previousCache, previousRedis := common.MemoryCacheEnabled, common.RedisEnabled
|
||||
common.SQLitePath, common.IsMasterNode = t.TempDir()+"/routing.db", false
|
||||
common.RedisEnabled = false
|
||||
t.Setenv("SQL_DSN", dsn)
|
||||
require.NoError(t, model.InitDB())
|
||||
database := model.DB
|
||||
// Every table belongs to this run, including when a developer points
|
||||
// a test DSN at a database shared with another test suite.
|
||||
database.Config.NamingStrategy = schema.NamingStrategy{TablePrefix: "endpoint_route_" + common.GetUUID() + "_"}
|
||||
require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Ability{}, &model.Task{}))
|
||||
t.Cleanup(func() {
|
||||
for _, table := range []any{&model.Ability{}, &model.Channel{}, &model.Task{}} {
|
||||
require.NoError(t, database.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(table).Error)
|
||||
}
|
||||
common.MemoryCacheEnabled = true
|
||||
model.InitChannelCache()
|
||||
require.NoError(t, database.Migrator().DropTable(&model.Ability{}, &model.Channel{}, &model.Task{}))
|
||||
model.DB = previousDB
|
||||
common.SetDatabaseTypes(previousMain, previousLog)
|
||||
common.SQLitePath, common.IsMasterNode = previousPath, previousMaster
|
||||
common.MemoryCacheEnabled, common.RedisEnabled = previousCache, previousRedis
|
||||
if previousDB != nil {
|
||||
model.InitChannelCache()
|
||||
}
|
||||
sqlDB, err := database.DB()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, sqlDB.Close())
|
||||
})
|
||||
query := "SELECT version()"
|
||||
if dialect == "sqlite" {
|
||||
query = "SELECT sqlite_version()"
|
||||
}
|
||||
var version string
|
||||
require.NoError(t, database.Raw(query).Scan(&version).Error)
|
||||
t.Logf("database version: %s", version)
|
||||
|
||||
for _, memoryCache := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("memory_cache=%t", memoryCache), func(t *testing.T) {
|
||||
common.MemoryCacheEnabled = memoryCache
|
||||
for _, tc := range []struct {
|
||||
name, path, model string
|
||||
pluginType int
|
||||
ordinaryType int
|
||||
ordinaryPriority int64
|
||||
pluginPriority int64
|
||||
pluginOnly, wantPlugin bool
|
||||
multipart, stream bool
|
||||
pinOrdinary, pinPlugin bool
|
||||
wrongGroup, wrongModel bool
|
||||
disabled, unrelated bool
|
||||
decoderRejects, syncOnly bool
|
||||
originPin, retry bool
|
||||
legacy, mapped, shared bool
|
||||
wantStatus int
|
||||
}{
|
||||
{name: "ordinary images bypass plugin decoder", decoderRejects: true},
|
||||
{name: "ordinary images win by priority", ordinaryPriority: 20},
|
||||
{name: "plugin images win by priority", pluginPriority: 20, wantPlugin: true},
|
||||
{name: "type 61 plugin remains usable", pluginOnly: true, wantPlugin: true},
|
||||
{name: "Alibaba legacy channel remains usable", legacy: true, pluginOnly: true, wantPlugin: true},
|
||||
{name: "OpenAI can win against Alibaba for qwen-image-3.0", legacy: true, ordinaryPriority: 20},
|
||||
{name: "Alibaba can win against OpenAI for qwen-image-3.0", legacy: true, pluginPriority: 20, wantPlugin: true},
|
||||
{name: "bound New API uses plugin", pluginType: constant.ChannelTypeNewAPI, pluginPriority: 20, wantPlugin: true},
|
||||
{name: "unbound New API uses ordinary relay", ordinaryType: constant.ChannelTypeNewAPI, decoderRejects: true},
|
||||
{name: "unrelated type 61 cannot steal request", unrelated: true, pluginPriority: 20},
|
||||
{name: "disabled plugin channel is ignored", disabled: true, pluginPriority: 20},
|
||||
{name: "plugin in another group is ignored", wrongGroup: true, pluginPriority: 20},
|
||||
{name: "plugin without requested model is ignored", wrongModel: true, pluginPriority: 20},
|
||||
{name: "ordinary channel pin wins", pinOrdinary: true, pluginPriority: 20},
|
||||
{name: "plugin channel pin wins", pinPlugin: true, wantPlugin: true},
|
||||
{name: "ordinary model spelling is preserved", model: "CHANNEL-ROUTE-MODEL", decoderRejects: true},
|
||||
{name: "mapped plugin alias remains usable", model: "friendly-image", mapped: true, pluginOnly: true, wantPlugin: true},
|
||||
{name: "ordinary alias is not rewritten by plugin", model: "friendly-image", mapped: true, decoderRejects: true},
|
||||
{name: "ordinary multipart edits are preserved", path: "/v1/images/edits", multipart: true, decoderRejects: true},
|
||||
{name: "plugin multipart edits remain usable", path: "/v1/images/edits", multipart: true, pluginOnly: true, wantPlugin: true},
|
||||
{name: "ordinary Responses bypass unsupported plugin mode", path: "/v1/responses", stream: true, syncOnly: true},
|
||||
{name: "plugin Responses remain usable", path: "/v1/responses", pluginOnly: true, wantPlugin: true},
|
||||
{name: "ordinary videos bypass plugin decoder", path: "/v1/videos", multipart: true, decoderRejects: true},
|
||||
{name: "plugin videos remain usable", path: "/v1/videos", multipart: true, pluginOnly: true, wantPlugin: true},
|
||||
{name: "selected plugin retains validation errors", pluginOnly: true, decoderRejects: true, wantStatus: http.StatusBadRequest},
|
||||
{name: "selected plugin retains mode errors", path: "/v1/responses", pluginOnly: true, stream: true, syncOnly: true, wantStatus: http.StatusBadRequest},
|
||||
{name: "origin task still pins its channel", pluginOnly: true, originPin: true, wantPlugin: true},
|
||||
{name: "ordinary retries stay on ordinary channels", ordinaryPriority: 20, pluginPriority: 10, retry: true},
|
||||
{name: "plugin retries stay on plugin channels", pluginPriority: 20, retry: true, wantPlugin: true},
|
||||
{name: "rejected shared candidate still allows another plugin", pluginOnly: true, shared: true, decoderRejects: true, pluginPriority: 20, wantPlugin: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, table := range []any{&model.Ability{}, &model.Channel{}, &model.Task{}} {
|
||||
require.NoError(t, database.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(table).Error)
|
||||
}
|
||||
key := "channel-route-test"
|
||||
path := common.GetStringIfEmpty(tc.path, "/v1/images/generations")
|
||||
modelName := common.GetStringIfEmpty(tc.model, "channel-route-model")
|
||||
if tc.legacy {
|
||||
key, modelName = "alibaba", "qwen-image-3.0"
|
||||
}
|
||||
decode := `return {model: ctx.model, action: "generate", requestBody: {normalized: true}};`
|
||||
if tc.decoderRejects {
|
||||
decode = `throw new Error("plugin rejected this request");`
|
||||
} else if tc.originPin {
|
||||
decode = `return {model: ctx.model, originTaskIds: ["task-route-origin"], requestBody: {normalized: true}};`
|
||||
}
|
||||
source := taskProtocolPluginSource(key, "1.0.0", `["channel-route-model"]`, path, decode)
|
||||
if tc.syncOnly {
|
||||
source = taskResponsesPluginSource(key, 0, `["channel-route-model"]`, `["sync"]`, `renderFinal: function() { return {}; },`, decode)
|
||||
}
|
||||
if !tc.legacy {
|
||||
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, jsplugin.DefaultRegistry.Unregister(key)) })
|
||||
}
|
||||
ordinary := &model.Channel{Type: constant.ChannelTypeOpenAI, Status: common.ChannelStatusEnabled, Name: "ordinary", Key: "ordinary-key", Group: "route-test", Models: modelName, Priority: common.GetPointer(tc.ordinaryPriority + 1)}
|
||||
if tc.ordinaryType != 0 {
|
||||
ordinary.Type = tc.ordinaryType
|
||||
}
|
||||
plugin := &model.Channel{Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Name: "plugin", Key: "plugin-key", Group: "route-test", Models: modelName, Priority: &tc.pluginPriority}
|
||||
if tc.pluginType != 0 {
|
||||
plugin.Type = tc.pluginType
|
||||
}
|
||||
plugin.SetSetting(kitdto.ChannelSettings{TaskPluginKey: key})
|
||||
if tc.legacy {
|
||||
plugin.Type = constant.ChannelTypeAli
|
||||
}
|
||||
if tc.mapped {
|
||||
plugin.ModelMapping = common.GetPointer(`{"friendly-image":"channel-route-model"}`)
|
||||
}
|
||||
if tc.unrelated {
|
||||
plugin.SetSetting(kitdto.ChannelSettings{TaskPluginKey: "unrelated"})
|
||||
}
|
||||
if tc.wrongGroup {
|
||||
plugin.Group = "another-group"
|
||||
}
|
||||
if tc.wrongModel {
|
||||
plugin.Models = "another-model"
|
||||
}
|
||||
if tc.disabled {
|
||||
plugin.Status = common.ChannelStatusManuallyDisabled
|
||||
}
|
||||
channels := []*model.Channel{plugin}
|
||||
if !tc.pluginOnly {
|
||||
channels = append(channels, ordinary)
|
||||
}
|
||||
if tc.originPin || tc.retry || tc.shared {
|
||||
alternate := &model.Channel{Type: ordinary.Type, Status: common.ChannelStatusEnabled, Name: "alternate", Key: "alternate-key", Group: "route-test", Models: modelName}
|
||||
if tc.wantPlugin {
|
||||
alternate.Type = plugin.Type
|
||||
alternate.SetSetting(plugin.GetSetting())
|
||||
}
|
||||
if tc.shared {
|
||||
const alternateKey = "channel-route-ztest"
|
||||
alternateSource := taskProtocolPluginSource(alternateKey, "1.0.0", `["channel-route-model"]`, path, `return {model: ctx.model, requestBody: {normalized: true}};`)
|
||||
_, err := jsplugin.DefaultRegistry.Register(alternateSource, jsplugin.Options{})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, jsplugin.DefaultRegistry.Unregister(alternateKey)) })
|
||||
alternate.SetSetting(kitdto.ChannelSettings{TaskPluginKey: alternateKey})
|
||||
}
|
||||
channels = append(channels, alternate)
|
||||
}
|
||||
for _, channel := range channels {
|
||||
require.NoError(t, channel.Insert())
|
||||
}
|
||||
if tc.originPin {
|
||||
require.NoError(t, database.Create(&model.Task{TaskID: "task-route-origin", UserId: 7, ChannelId: channels[len(channels)-1].Id, Platform: constant.TaskPlatform(key), Status: model.TaskStatusSuccess}).Error)
|
||||
}
|
||||
model.InitChannelCache()
|
||||
body := []byte(fmt.Sprintf(`{"model":%q,"prompt":"a cat","size":"1024x1024","stream":%t}`, modelName, tc.stream))
|
||||
contentType := "application/json"
|
||||
if tc.multipart {
|
||||
var form bytes.Buffer
|
||||
writer := multipart.NewWriter(&form)
|
||||
require.NoError(t, writer.WriteField("model", modelName))
|
||||
require.NoError(t, writer.WriteField("prompt", "a cat"))
|
||||
file, err := writer.CreateFormFile("image", "reference.png")
|
||||
require.NoError(t, err)
|
||||
_, err = file.Write([]byte("image bytes"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
body, contentType = form.Bytes(), writer.FormDataContentType()
|
||||
}
|
||||
reached := false
|
||||
router := gin.New()
|
||||
router.POST(path, func(c *gin.Context) {
|
||||
common.SetContextKey(c, constant.ContextKeyUsingGroup, "route-test")
|
||||
common.SetContextKey(c, constant.ContextKeyUserId, 7)
|
||||
if tc.pinOrdinary || tc.pinPlugin {
|
||||
id := ordinary.Id
|
||||
if tc.pinPlugin {
|
||||
id = plugin.Id
|
||||
}
|
||||
service.GetChannelConstraints(c).AddPin(dto.ChannelPin{ChannelId: id, Source: dto.PinSourceToken, Rank: dto.PinRankToken})
|
||||
}
|
||||
}, RouteTaskPluginEndpoint(), PrepareTaskPluginEndpoint(), Distribute(), func(c *gin.Context) {
|
||||
reached = true
|
||||
_, pinned := c.Get(jsplugin.ContextKeyPinnedEndpoint)
|
||||
assert.Equal(t, tc.wantPlugin, pinned)
|
||||
_, relayEndpoint := c.Get(jsplugin.ContextKeyRelayEndpoint)
|
||||
assert.Equal(t, !tc.wantPlugin, relayEndpoint)
|
||||
expected := ordinary
|
||||
if tc.wantPlugin {
|
||||
expected = plugin
|
||||
if tc.shared {
|
||||
assert.Equal(t, "channel-route-ztest", c.GetString("task_plugin_key"))
|
||||
} else {
|
||||
assert.Equal(t, key, c.GetString("task_plugin_key"))
|
||||
}
|
||||
if tc.legacy {
|
||||
assert.NotNil(t, c.MustGet("task_request"))
|
||||
} else {
|
||||
assert.Equal(t, map[string]any{"normalized": true}, c.MustGet("task_request"))
|
||||
}
|
||||
if tc.mapped {
|
||||
assert.Equal(t, "channel-route-model", c.MustGet(jsplugin.ContextKeyPinnedEndpoint).(jsplugin.PinnedEndpoint).MappedModel)
|
||||
}
|
||||
if tc.originPin || tc.shared {
|
||||
expected = channels[len(channels)-1]
|
||||
}
|
||||
} else {
|
||||
assert.Empty(t, c.GetString("expected_task_plugin_key"))
|
||||
assert.Empty(t, c.GetString("resolved_task_model"))
|
||||
storage, err := common.GetBodyStorage(c)
|
||||
require.NoError(t, err)
|
||||
got, err := storage.Bytes()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, body, got, "ordinary relay must receive the original body")
|
||||
}
|
||||
assert.Equal(t, expected.Id, common.GetContextKeyInt(c, constant.ContextKeyChannelId))
|
||||
assert.Equal(t, expected.Key, common.GetContextKeyString(c, constant.ContextKeyChannelKey))
|
||||
if tc.retry {
|
||||
next, _, err := service.CacheGetRandomSatisfiedChannel(&service.RetryParam{Ctx: c, TokenGroup: "route-test", ModelName: modelName, Retry: common.GetPointer(1)})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, next)
|
||||
assert.Equal(t, channels[len(channels)-1].Id, next.Id)
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
status := tc.wantStatus
|
||||
if status == 0 {
|
||||
status = http.StatusNoContent
|
||||
}
|
||||
assert.Equal(t, status, recorder.Code, recorder.Body.String())
|
||||
assert.Equal(t, status == http.StatusNoContent, reached)
|
||||
if tc.decoderRejects && status == http.StatusBadRequest {
|
||||
assert.Contains(t, recorder.Body.String(), "plugin rejected this request")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,14 @@ import (
|
||||
|
||||
var filterEvalOrder = []dto.ChannelFilterKind{
|
||||
dto.FilterRequestPath,
|
||||
dto.FilterTaskPluginEndpoint,
|
||||
dto.FilterTaskPluginIdentity,
|
||||
dto.FilterResponsesWebSocket,
|
||||
}
|
||||
|
||||
// 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.
|
||||
// On false, it returns the first violated filter in filterEvalOrder for error
|
||||
// attribution.
|
||||
func ChannelSatisfiesFilters(ch *Channel, modelName string, filters []dto.ChannelFilter) (bool, dto.ChannelFilterKind) {
|
||||
if ch == nil {
|
||||
return false, ""
|
||||
@@ -36,7 +37,7 @@ func ChannelSatisfiesFilters(ch *Channel, modelName string, filters []dto.Channe
|
||||
// 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.
|
||||
// error) and dropped for the other filters.
|
||||
func filterCandidateIDs(ids []int, modelName string, filters []dto.ChannelFilter) (kept []int, emptiedBy dto.ChannelFilterKind) {
|
||||
if len(ids) == 0 {
|
||||
return ids, ""
|
||||
@@ -109,6 +110,18 @@ func channelMatchesFilter(ch *Channel, modelName string, filter dto.ChannelFilte
|
||||
return setting.BindsTaskPlugin(filter.TaskPluginKey) || slices.ContainsFunc(filter.TaskPluginKeys, setting.BindsTaskPlugin)
|
||||
}
|
||||
return slices.Contains(filter.TaskPluginChannelTypes, ch.Type)
|
||||
case dto.FilterTaskPluginEndpoint:
|
||||
pluginChannel := slices.Contains(filter.TaskPluginChannelTypes, ch.Type)
|
||||
if ch.Type == constant.ChannelTypeTaskPlugin || ch.Type == constant.ChannelTypeNewAPI {
|
||||
pluginChannel = slices.ContainsFunc(filter.TaskPluginKeys, ch.GetSetting().BindsTaskPlugin)
|
||||
}
|
||||
if filter.TaskPluginRelayOnly {
|
||||
return ch.Type != constant.ChannelTypeTaskPlugin && !pluginChannel
|
||||
}
|
||||
// Ordinary channels and matching plugin channels compete using the same
|
||||
// group, priority, weight, affinity, and pin rules. Unrelated type-61
|
||||
// plugins cannot execute this endpoint.
|
||||
return ch.Type != constant.ChannelTypeTaskPlugin || pluginChannel
|
||||
case dto.FilterResponsesWebSocket:
|
||||
if !ch.GetSetting().ResponsesWebSocketEnabled {
|
||||
return false
|
||||
|
||||
@@ -280,7 +280,7 @@ func GetModelPricingSnapshot(names []string) (*ModelPricingSnapshot, error) {
|
||||
}
|
||||
plugins := generation.PluginsByModel(name)
|
||||
configuredVariants, _ := configured[billing_setting.PluginBillingExprOption].(map[string]any)
|
||||
if len(plugins) >= 2 || len(configuredVariants) > 0 {
|
||||
if len(plugins) > 0 || len(configuredVariants) > 0 {
|
||||
keys := make(map[string]bool, len(plugins)+len(configuredVariants))
|
||||
for _, plugin := range plugins {
|
||||
keys[plugin.Meta.Key] = true
|
||||
|
||||
@@ -223,6 +223,9 @@ const (
|
||||
// order, the decoded requestBody as JSON text (json.RawMessage) beside
|
||||
// the Go value in task_request.
|
||||
ContextKeyRequestBodyText = "task_plugin_request_body_text"
|
||||
// ContextKeyRelayEndpoint retains the shared endpoint candidates when an
|
||||
// ordinary channel wins, so task USD expressions cannot become relay prices.
|
||||
ContextKeyRelayEndpoint = "task_plugin_relay_endpoint"
|
||||
)
|
||||
|
||||
type PinnedPlugin struct {
|
||||
|
||||
+29
-2
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"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/relaykit/relayconvert/reasoning"
|
||||
@@ -346,7 +347,33 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, billing
|
||||
return hosttypes.PriceData{}, fmt.Errorf("model %s is configured as tiered_expr but has no billing expression", billingModelName)
|
||||
}
|
||||
exprHash := billingexpr.ExprHashString(exprStr)
|
||||
if info.RelayFormat == types.RelayFormatOpenAIRealtime && billingexpr.UsesFixedPricingByHash(exprStr, exprHash) {
|
||||
usedVars := billingexpr.UsedVarsByHash(exprStr, exprHash)
|
||||
if usedVars["u"] {
|
||||
return hosttypes.PriceData{}, errors.New(i18n.T(c, i18n.MsgRelayTaskUsagePriceUnsupported, map[string]any{"Model": billingModelName}))
|
||||
}
|
||||
fixedPricing := billingexpr.UsesFixedPricingByHash(exprStr, exprHash)
|
||||
value, _ := c.Get(jsplugin.ContextKeyRelayEndpoint)
|
||||
if endpoint, ok := value.(jsplugin.PinnedEndpoint); ok && !fixedPricing {
|
||||
for _, candidate := range endpoint.Candidates {
|
||||
pluginKey := candidate.Plugin.Meta.Key
|
||||
if _, overridden := billing_setting.GetPluginBillingExpr(pluginKey, endpoint.Model); overridden {
|
||||
continue
|
||||
}
|
||||
if _, overridden := billing_setting.GetPluginBillingExpr(pluginKey, endpoint.MappedModel); endpoint.MappedModel != "" && overridden {
|
||||
continue
|
||||
}
|
||||
taskBillingModel := endpoint.Model
|
||||
if billing_setting.GetBillingMode(taskBillingModel) != billing_setting.BillingModeTieredExpr {
|
||||
taskBillingModel = endpoint.MappedModel
|
||||
}
|
||||
// Task expressions return USD; ordinary expressions return USD *
|
||||
// 1M. Even a constant without u() cannot safely serve both contracts.
|
||||
if taskBillingModel == billingModelName {
|
||||
return hosttypes.PriceData{}, errors.New(i18n.T(c, i18n.MsgRelaySharedTaskPriceNeedsOverride, map[string]any{"Model": billingModelName, "Plugin": pluginKey}))
|
||||
}
|
||||
}
|
||||
}
|
||||
if info.RelayFormat == types.RelayFormatOpenAIRealtime && fixedPricing {
|
||||
return hosttypes.PriceData{}, fmt.Errorf("fixed pricing is not supported for Realtime requests")
|
||||
}
|
||||
|
||||
@@ -359,7 +386,7 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, billing
|
||||
if err != nil {
|
||||
return hosttypes.PriceData{}, err
|
||||
}
|
||||
if billingexpr.UsedVarsByHash(exprStr, exprHash)["image_count"] {
|
||||
if usedVars["image_count"] {
|
||||
requestInput, err = ResolveImageBillingRequestInput(c, info, requestInput)
|
||||
if err != nil {
|
||||
return hosttypes.PriceData{}, err
|
||||
|
||||
@@ -16,6 +16,9 @@ import (
|
||||
"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/relay/helper"
|
||||
kitdto "github.com/QuantumNous/new-api/relaykit/dto"
|
||||
kittypes "github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/billing_setting"
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
@@ -492,6 +495,121 @@ func TestSharedTaskBillingExpressionSelectionAndFrozenSettlement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedEndpointBillingKeepsOrdinaryAndPluginPricesSeparate(t *testing.T) {
|
||||
require.NoError(t, i18n.Init())
|
||||
const taskExpr = `tier("images", u("image_count") * 0.05)`
|
||||
const ordinaryExpr = `tier("images", fixed(0.04)) * image_count`
|
||||
savedPrices := ratio_setting.ModelPrice2JSONString()
|
||||
t.Cleanup(func() { require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(savedPrices)) })
|
||||
for _, tc := range []struct {
|
||||
name, expression, override string
|
||||
ordinaryQuota, taskQuota, taskFinal int
|
||||
wantError string
|
||||
alias, shared, legacy bool
|
||||
}{
|
||||
{name: "task usage price is rejected by ordinary relay", expression: taskExpr, taskQuota: 50000, taskFinal: 75000, wantError: "task usage expression"},
|
||||
{name: "enum fallback cannot silently bill zero", expression: `u("output_image_type") == "2k" ? tier("2k", u("image_count") * 0.08) : tier("1k", 0.04)`, taskQuota: 20000, taskFinal: 20000, wantError: "task usage expression"},
|
||||
{name: "unselected usage branch is rejected", expression: `true ? tier("flat", 0.04) : tier("images", u("image_count"))`, taskQuota: 20000, taskFinal: 20000, wantError: "task usage expression"},
|
||||
{name: "dynamic usage key is rejected", expression: `tier("images", u(u("unit")) * 0.05)`, taskQuota: 50000, taskFinal: 75000, wantError: "task usage expression"},
|
||||
{name: "plain task USD price is ambiguous", expression: `tier("flat", 0.04)`, taskQuota: 20000, taskFinal: 20000, wantError: "shares its billing expression"},
|
||||
{name: "alias retains task USD price", expression: `tier("flat", 0.04)`, taskQuota: 20000, taskFinal: 20000, wantError: "shares its billing expression", alias: true},
|
||||
{name: "per image relay and plugin overrides", expression: ordinaryExpr, override: taskExpr, ordinaryQuota: 40000, taskQuota: 50000, taskFinal: 75000},
|
||||
{name: "mapped plugin override", expression: ordinaryExpr, override: taskExpr, ordinaryQuota: 40000, taskQuota: 50000, taskFinal: 75000, alias: true},
|
||||
{name: "token relay and plugin overrides", expression: `tier("tokens", p * 2)`, override: taskExpr, ordinaryQuota: 1000, taskQuota: 50000, taskFinal: 75000},
|
||||
{name: "ordinary constant has ordinary units with plugin override", expression: `tier("flat", 80000)`, override: taskExpr, ordinaryQuota: 40000, taskQuota: 50000, taskFinal: 75000},
|
||||
{name: "another plugin still inherits task USD units", expression: `tier("flat", 0.04)`, override: taskExpr, taskQuota: 50000, taskFinal: 75000, wantError: "shares its billing expression", shared: true},
|
||||
{name: "legacy per image pricing stays shared", ordinaryQuota: 40000, taskQuota: 40000, legacy: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
saveBillingConfig(t)
|
||||
name, mapping, mappedModel := "declared-model", "", ""
|
||||
if tc.alias {
|
||||
name, mapping, mappedModel = "alias-model", `{"alias-model":"declared-model"}`, "declared-model"
|
||||
}
|
||||
mode := billing_setting.BillingModeTieredExpr
|
||||
if tc.legacy {
|
||||
mode = billing_setting.BillingModeRatio
|
||||
}
|
||||
variants := map[string]string{}
|
||||
if tc.override != "" {
|
||||
variants["bill-fallback::declared-model"] = tc.override
|
||||
}
|
||||
modes, err := common.Marshal(map[string]string{name: mode})
|
||||
require.NoError(t, err)
|
||||
expressions, err := common.Marshal(map[string]string{name: tc.expression})
|
||||
require.NoError(t, err)
|
||||
overrides, err := common.Marshal(variants)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
|
||||
"billing_setting.billing_mode": string(modes), "billing_setting.billing_expr": string(expressions),
|
||||
billing_setting.PluginBillingExprOption: string(overrides), "group_ratio_setting.group_ratio": `{"default":1}`,
|
||||
}))
|
||||
prices, err := common.Marshal(map[string]float64{name: 0.04})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(string(prices)))
|
||||
registry := pluginruntime.NewRegistry()
|
||||
source := strings.Replace(billingFallbackPlugin, `fetchMode:"per_task"`, `fetchMode:"per_task",usageSchema:{image_count:{type:"number",unit:"count",description:"Image generation unit price"},output_image_type:{enum:["1k","2k"]},unit:{enum:["image_count"]}}`, 1)
|
||||
source += `export function extractUsage(){return {image_count:2,output_image_type:"1k",unit:"image_count"};}`
|
||||
plugin, err := registry.Register(source, pluginruntime.Options{})
|
||||
require.NoError(t, err)
|
||||
candidates := []pluginruntime.ProtocolBinding{{Plugin: plugin}}
|
||||
if tc.shared {
|
||||
other, err := registry.Register(strings.ReplaceAll(source, "bill-fallback", "other-images"), pluginruntime.Options{})
|
||||
require.NoError(t, err)
|
||||
candidates = append(candidates, pluginruntime.ProtocolBinding{Plugin: other})
|
||||
}
|
||||
ordinaryContext, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ordinaryContext.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
|
||||
ordinaryContext.Request.Header.Set("Accept-Language", "en")
|
||||
ordinaryContext.Set(pluginruntime.ContextKeyRelayEndpoint, pluginruntime.PinnedEndpoint{Generation: registry.Generation(), Model: name, MappedModel: mappedModel, Candidates: candidates})
|
||||
ordinary := &relaycommon.RelayInfo{
|
||||
OriginModelName: name, UserGroup: "default", UsingGroup: "default", RelayFormat: kittypes.RelayFormatOpenAIImage,
|
||||
Request: &kitdto.ImageRequest{Model: name, N: common.GetPointer(uint(2))}, BillingRequestInput: &billingexpr.RequestInput{},
|
||||
}
|
||||
ordinaryPrice, err := helper.ModelPriceHelper(ordinaryContext, ordinary, 1000, &kittypes.TokenCountMeta{})
|
||||
if tc.wantError != "" {
|
||||
require.ErrorContains(t, err, tc.wantError)
|
||||
assert.Nil(t, ordinary.TieredBillingSnapshot)
|
||||
assert.Nil(t, ordinary.Billing, "pricing must fail before reservation")
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.ordinaryQuota, ordinaryPrice.QuotaToPreConsume)
|
||||
}
|
||||
|
||||
pluginContext, task := newTaskSubmitContext(t, name, mapping)
|
||||
pluginContext.Set("group", "default")
|
||||
pluginContext.Set(pluginruntime.ContextKeyPinnedPlugin, pluginruntime.PinnedPlugin{Generation: registry.Generation(), Plugin: plugin})
|
||||
task.OriginModelName, task.UserGroup, task.UsingGroup = name, "default", "default"
|
||||
_, taskErr := RelayTaskSubmit(pluginContext, task)
|
||||
require.NotNil(t, taskErr) // This fixture stops at reservation, before upstream submission.
|
||||
assert.NotEqual(t, "model_price_error", taskErr.Code)
|
||||
assert.Equal(t, tc.taskQuota, task.PriceData.Quota)
|
||||
if tc.legacy {
|
||||
assert.Nil(t, task.TieredBillingSnapshot)
|
||||
assert.Nil(t, ordinary.TieredBillingSnapshot)
|
||||
return
|
||||
}
|
||||
require.NotNil(t, task.TieredBillingSnapshot)
|
||||
assert.True(t, task.TieredBillingSnapshot.TaskUsageBilling)
|
||||
// Updating live prices must not change either in-flight meter.
|
||||
require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
|
||||
"billing_setting.billing_expr": `{}`, billing_setting.PluginBillingExprOption: `{}`,
|
||||
}))
|
||||
settlement, _, err := service.EvaluateTaskCompletionUsage(task.TieredBillingSnapshot, map[string]any{"image_count": float64(3)})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.taskFinal, settlement.ActualQuotaAfterGroup)
|
||||
if tc.wantError != "" {
|
||||
return
|
||||
}
|
||||
require.NotNil(t, ordinary.TieredBillingSnapshot)
|
||||
assert.False(t, ordinary.TieredBillingSnapshot.TaskUsageBilling)
|
||||
settlement, err = billingexpr.ComputeTieredQuotaWithRequest(ordinary.TieredBillingSnapshot, billingexpr.TokenParams{P: 1000, Len: 1000}, *ordinary.BillingRequestInput)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.ordinaryQuota, settlement.ActualQuotaAfterGroup)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Issue #7478: task APIs answer 201 Created or 202 Accepted on submission.
|
||||
// Every 2xx must reach parseSubmitResponse; only other statuses are upstream
|
||||
// failures that keep the upstream status and body.
|
||||
|
||||
@@ -29,7 +29,7 @@ func taskPluginProtocolHandlers(protocol, operation string) ([]gin.HandlerFunc,
|
||||
case "openai_responses.create":
|
||||
return []gin.HandlerFunc{
|
||||
middleware.RouteTag("relay"), middleware.SystemPerformanceCheck(), middleware.TokenAuth(),
|
||||
middleware.ModelRequestRateLimit(), middleware.PinTaskPluginEndpoint(), middleware.PrepareTaskPluginEndpoint(), middleware.Distribute(),
|
||||
middleware.ModelRequestRateLimit(), middleware.RouteTaskPluginEndpoint(), middleware.PrepareTaskPluginEndpoint(), middleware.Distribute(),
|
||||
func(c *gin.Context) {
|
||||
controller.RelayTaskPluginEndpoint(c, func(c *gin.Context) { controller.Relay(c, types.RelayFormatOpenAIResponses) })
|
||||
},
|
||||
@@ -37,7 +37,7 @@ func taskPluginProtocolHandlers(protocol, operation string) ([]gin.HandlerFunc,
|
||||
case "openai_image.generate", "openai_image.edit":
|
||||
return []gin.HandlerFunc{
|
||||
middleware.RouteTag("relay"), middleware.SystemPerformanceCheck(), middleware.TokenAuth(),
|
||||
middleware.ModelRequestRateLimit(), middleware.PinTaskPluginEndpoint(), middleware.PrepareTaskPluginEndpoint(), middleware.Distribute(),
|
||||
middleware.ModelRequestRateLimit(), middleware.RouteTaskPluginEndpoint(), middleware.PrepareTaskPluginEndpoint(), middleware.Distribute(),
|
||||
func(c *gin.Context) {
|
||||
controller.RelayTaskPluginEndpoint(c, func(c *gin.Context) { controller.Relay(c, types.RelayFormatOpenAIImage) })
|
||||
},
|
||||
@@ -45,7 +45,7 @@ func taskPluginProtocolHandlers(protocol, operation string) ([]gin.HandlerFunc,
|
||||
case "openai_video.create":
|
||||
return []gin.HandlerFunc{
|
||||
middleware.RouteTag("relay"), middleware.TokenAuth(), middleware.SystemPerformanceCheck(),
|
||||
middleware.PinTaskPluginEndpoint(), middleware.TaskPluginEndpointOnly(middleware.ModelRequestRateLimit()), middleware.PrepareTaskPluginEndpoint(), middleware.Distribute(),
|
||||
middleware.RouteTaskPluginEndpoint(), middleware.TaskPluginEndpointOnly(middleware.ModelRequestRateLimit()), middleware.PrepareTaskPluginEndpoint(), middleware.Distribute(),
|
||||
func(c *gin.Context) { controller.RelayTaskPluginEndpoint(c, controller.RelayTask) },
|
||||
}, nil
|
||||
case "openai_responses.retrieve":
|
||||
|
||||
@@ -193,6 +193,10 @@ export function ModelPricingPanel(props: {
|
||||
className='space-y-3 border-b pb-3'
|
||||
>
|
||||
<h3 className='text-muted-foreground text-xs'>
|
||||
{!effectivePricing.billing_usage_schema &&
|
||||
entry.plugin_variants?.length ? (
|
||||
<>{t('Ordinary OpenAI')} · </>
|
||||
) : null}
|
||||
{t('Current Billing')}
|
||||
</h3>
|
||||
<div className='max-w-xs'>
|
||||
@@ -206,7 +210,7 @@ export function ModelPricingPanel(props: {
|
||||
<DynamicPricingBreakdown
|
||||
compact
|
||||
billingExpr={effectivePricing.billing_expr}
|
||||
usageSchema={entry.usage_schema}
|
||||
usageSchema={effectivePricing.billing_usage_schema}
|
||||
/>
|
||||
) : (
|
||||
effectivePricing.quota_type === 0 &&
|
||||
|
||||
@@ -21,7 +21,10 @@ import { t } from 'i18next'
|
||||
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
|
||||
import { splitPluginBillingExprKey } from '@/features/pricing/lib/plugin-pricing'
|
||||
import type { PricingModel } from '@/features/pricing/types'
|
||||
import type { ModelRatioData } from '@/features/system-settings/models/model-pricing-core'
|
||||
import {
|
||||
usesTaskModelPricing,
|
||||
type ModelRatioData,
|
||||
} from '@/features/system-settings/models/model-pricing-core'
|
||||
import {
|
||||
buildModelSnapshots,
|
||||
type ModelPricingSnapshot,
|
||||
@@ -65,7 +68,12 @@ export type LegacyBillingDetails = {
|
||||
|
||||
export function modelPricingDisplay(
|
||||
entry: Pick<ModelPricingEntry, 'model_name' | 'effective' | 'usage_schema'> &
|
||||
Partial<Pick<ModelPricingEntry, 'configured' | 'cache_write_mode'>>
|
||||
Partial<
|
||||
Pick<
|
||||
ModelPricingEntry,
|
||||
'configured' | 'cache_write_mode' | 'plugin_variants'
|
||||
>
|
||||
>
|
||||
): PricingModel {
|
||||
const values = entry.effective
|
||||
return {
|
||||
@@ -112,7 +120,13 @@ export function modelPricingDisplay(
|
||||
typeof values['billing_setting.billing_expr'] === 'string'
|
||||
? values['billing_setting.billing_expr']
|
||||
: undefined,
|
||||
billing_usage_schema: entry.usage_schema,
|
||||
billing_usage_schema: usesTaskModelPricing(
|
||||
pricingRow(entry.model_name, values),
|
||||
entry.usage_schema,
|
||||
entry.plugin_variants
|
||||
)
|
||||
? entry.usage_schema
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -320,6 +320,72 @@ describe('local billing expression evaluation', () => {
|
||||
})
|
||||
|
||||
describe('visual billing document', () => {
|
||||
test('adding a condition to per-image pricing keeps image quantity outside the whole pricing tree', () => {
|
||||
const document = parseVisualBillingDocument(
|
||||
'image_count * tier("image", fixed(0.04))'
|
||||
)
|
||||
assert(document && document.root.kind === 'tier')
|
||||
const original = document.root
|
||||
document.root = {
|
||||
id: 'priced-condition',
|
||||
kind: 'branch',
|
||||
condition: {
|
||||
id: 'length',
|
||||
kind: 'comparison',
|
||||
probe: 'len',
|
||||
timezone: '',
|
||||
operator: '>=',
|
||||
value: '100',
|
||||
},
|
||||
yes: {
|
||||
...original,
|
||||
id: 'long',
|
||||
origin: undefined,
|
||||
label: 'long',
|
||||
fixedPrice: '0.1',
|
||||
},
|
||||
no: original,
|
||||
}
|
||||
const result = serializeVisualBillingDocument(document)
|
||||
assert(result.ok)
|
||||
expect(
|
||||
evaluateBillingExpression(result.source, {
|
||||
imageCount: 3,
|
||||
tokens: { len: 100 },
|
||||
})
|
||||
).toMatchObject({ status: 'success', cost: 300000 })
|
||||
expect(
|
||||
evaluateBillingExpression(result.source, {
|
||||
imageCount: 3,
|
||||
tokens: { len: 0 },
|
||||
})
|
||||
).toMatchObject({ status: 'success', cost: 120000 })
|
||||
})
|
||||
test.each([
|
||||
'v1: (tier("image", fixed(0.04))) * image_count',
|
||||
'image_count * tier("image", fixed(0.04))',
|
||||
])(
|
||||
'editing a per-image price retains the quantity multiplier: %s',
|
||||
(source) => {
|
||||
const document = parseVisualBillingDocument(source)
|
||||
assert(document)
|
||||
expect(serializeVisualBillingDocument(document)).toEqual({
|
||||
ok: true,
|
||||
source,
|
||||
})
|
||||
assert(document.root.kind === 'tier')
|
||||
document.root.fixedPrice = '0.05'
|
||||
const updated = serializeVisualBillingDocument(document)
|
||||
expect(updated).toEqual({
|
||||
ok: true,
|
||||
source: source.replace('0.04', '0.05'),
|
||||
})
|
||||
if (!updated.ok) return
|
||||
expect(
|
||||
evaluateBillingExpression(updated.source, { imageCount: 3 })
|
||||
).toMatchObject({ status: 'success', cost: 150000 })
|
||||
}
|
||||
)
|
||||
test.each([
|
||||
deepSeekExpression,
|
||||
`v1: (hour('Asia/Shanghai') >= 9 ? tier('peak', cr * 0 + p * 3e0 + c * 9) : tier('off', c * 4.5 + p * 1.5)) `,
|
||||
|
||||
@@ -57,7 +57,11 @@ export type VisualPricingNode =
|
||||
yes: VisualPricingNode
|
||||
no: VisualPricingNode
|
||||
})
|
||||
export type VisualBillingDocument = { source: string; root: VisualPricingNode }
|
||||
export type VisualBillingDocument = {
|
||||
source: string
|
||||
root: VisualPricingNode
|
||||
imageCount?: boolean
|
||||
}
|
||||
export type VisualBillingIssue = { id: string; message: string }
|
||||
export type VisualBillingSerialization =
|
||||
| { ok: true; source: string }
|
||||
@@ -201,14 +205,26 @@ function readVisualPricing(node: ExpressionNode): VisualPricingNode | null {
|
||||
}
|
||||
}
|
||||
|
||||
function imageCountPricingRoot(node: ExpressionNode): ExpressionNode | null {
|
||||
if (node.kind !== 'binary' || node.operator !== '*') return null
|
||||
if (node.right.kind === 'variable' && node.right.name === 'image_count') {
|
||||
return node.left
|
||||
}
|
||||
if (node.left.kind === 'variable' && node.left.name === 'image_count') {
|
||||
return node.right
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function parseVisualBillingDocument(
|
||||
source: string
|
||||
): VisualBillingDocument | null {
|
||||
const compiled = compileBillingExpression(source)
|
||||
if (compiled.status !== 'ready') return null
|
||||
const root = readVisualPricing(compiled.ast)
|
||||
const pricing = imageCountPricingRoot(compiled.ast) ?? compiled.ast
|
||||
const root = readVisualPricing(pricing)
|
||||
if (!root) return null
|
||||
const document = { source, root }
|
||||
const document = { source, root, imageCount: pricing !== compiled.ast }
|
||||
return serializeVisualBillingDocument(document).ok ? document : null
|
||||
}
|
||||
|
||||
@@ -465,7 +481,14 @@ export function serializeVisualBillingDocument(
|
||||
document: VisualBillingDocument
|
||||
): VisualBillingSerialization {
|
||||
const issues: VisualBillingIssue[] = []
|
||||
const body = writeVisualPricing(document.root, document.source, issues)
|
||||
let body = writeVisualPricing(document.root, document.source, issues)
|
||||
if (
|
||||
document.imageCount &&
|
||||
document.root.kind === 'branch' &&
|
||||
document.root.origin?.kind !== 'conditional'
|
||||
) {
|
||||
body = `(${body})`
|
||||
}
|
||||
if (issues.length > 0) return { ok: false, issues }
|
||||
const original = compileBillingExpression(document.source)
|
||||
if (original.status !== 'ready') {
|
||||
@@ -480,10 +503,25 @@ export function serializeVisualBillingDocument(
|
||||
],
|
||||
}
|
||||
}
|
||||
const pricingOrigin = document.imageCount
|
||||
? imageCountPricingRoot(original.ast)
|
||||
: original.ast
|
||||
if (!pricingOrigin) {
|
||||
return {
|
||||
ok: false,
|
||||
issues: [
|
||||
{
|
||||
id: document.root.id,
|
||||
message:
|
||||
'This expression cannot be edited visually without losing information.',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
const source =
|
||||
document.source.slice(0, original.ast.start) +
|
||||
document.source.slice(0, pricingOrigin.start) +
|
||||
body +
|
||||
document.source.slice(original.ast.end)
|
||||
document.source.slice(pricingOrigin.end)
|
||||
if (compileBillingExpression(source).status !== 'ready') {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -173,6 +173,13 @@ it('keeps provider drafts across tabs and saves nested expressions with the mode
|
||||
).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('tab', { name: 'Beta' }))
|
||||
expect(price).toHaveValue('1.5')
|
||||
await user.click(screen.getByRole('tab', { name: 'Ordinary OpenAI' }))
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Configure ordinary OpenAI pricing' })
|
||||
)
|
||||
const ordinaryPrice = screen.getByRole('textbox', { name: 'Input price' })
|
||||
await user.clear(ordinaryPrice)
|
||||
await user.type(ordinaryPrice, '2')
|
||||
const saveButton = screen.getByRole('button', { name: 'Save model prices' })
|
||||
await user.click(saveButton)
|
||||
await waitFor(() => expect(save).toHaveBeenCalledOnce())
|
||||
@@ -182,11 +189,11 @@ it('keeps provider drafts across tabs and saves nested expressions with the mode
|
||||
}
|
||||
expect(changes.changes[0].expected_version).toBe('version-with-providers')
|
||||
expect(changes.changes[0].pricing['billing_setting.billing_expr']).toBe(
|
||||
expression
|
||||
'tier("base", p * 2 + c * 0)'
|
||||
)
|
||||
expect(
|
||||
changes.changes[0].pricing['billing_setting.plugin_billing_expr']
|
||||
).toEqual({ beta: 'tier("base", u("credits") * 1.5)' })
|
||||
).toEqual({ alpha: expression, beta: 'tier("base", u("credits") * 1.5)' })
|
||||
await act(async () => {
|
||||
finishSave?.()
|
||||
})
|
||||
@@ -411,3 +418,146 @@ it('opens task expression pricing when only provider metadata supplies a schema'
|
||||
)
|
||||
expect(screen.getByRole('textbox', { name: 'seconds' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('configures ordinary OpenAI pricing while preserving inherited and saved plugin prices', async () => {
|
||||
const expression = 'tier("base", u("seconds") * 0.4)'
|
||||
const savedOverride = 'tier("base", u("seconds") * 0.7)'
|
||||
const { ref, dirty } = renderEditor(
|
||||
{
|
||||
name: 'shared',
|
||||
billingMode: 'tiered_expr',
|
||||
billingExpr: expression,
|
||||
pluginBillingExpr: { beta: savedOverride },
|
||||
},
|
||||
['alpha', 'beta'].map((key) => ({
|
||||
plugin_key: key,
|
||||
plugin_name: key,
|
||||
usage_schema: { seconds: { type: 'number', unit: 'second' } },
|
||||
configured: key === 'beta' ? savedOverride : '',
|
||||
effective: key === 'beta' ? savedOverride : expression,
|
||||
compatible: true,
|
||||
}))
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('tab', { name: 'Ordinary OpenAI' }))
|
||||
expect(dirty).toHaveBeenLastCalledWith(false)
|
||||
expect(await ref.current?.commitDraft()).toMatchObject({
|
||||
billingExpr: expression,
|
||||
pluginBillingExpr: { beta: savedOverride },
|
||||
})
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Configure ordinary OpenAI pricing' })
|
||||
)
|
||||
const price = screen.getByRole('textbox', { name: 'Input price' })
|
||||
await user.clear(price)
|
||||
await user.type(price, '2.5')
|
||||
expect(dirty).toHaveBeenLastCalledWith(true)
|
||||
await user.click(screen.getByRole('tab', { name: 'alpha' }))
|
||||
expect(screen.getByRole('textbox', { name: 'seconds' })).toHaveValue('0.4')
|
||||
await user.click(screen.getByRole('tab', { name: 'beta' }))
|
||||
expect(screen.getByRole('textbox', { name: 'seconds' })).toHaveValue('0.7')
|
||||
const draft = await ref.current?.commitDraft()
|
||||
expect(draft).toMatchObject({
|
||||
billingMode: 'tiered_expr',
|
||||
billingExpr: 'tier("base", p * 2.5 + c * 0)',
|
||||
pluginBillingExpr: { alpha: expression, beta: savedOverride },
|
||||
})
|
||||
})
|
||||
|
||||
it('starts ordinary image pricing per returned image and keeps the plugin image price', async () => {
|
||||
const expression = 'tier("base", u("image_count") * 0.04)'
|
||||
const { ref } = renderEditor(
|
||||
{
|
||||
name: 'image-model',
|
||||
billingMode: 'tiered_expr',
|
||||
billingExpr: expression,
|
||||
},
|
||||
[
|
||||
{
|
||||
plugin_key: 'alibaba',
|
||||
plugin_name: 'Alibaba',
|
||||
usage_schema: { image_count: { type: 'number', unit: 'count' } },
|
||||
configured: '',
|
||||
effective: expression,
|
||||
compatible: true,
|
||||
},
|
||||
]
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('tab', { name: 'Ordinary OpenAI' }))
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Configure ordinary OpenAI pricing' })
|
||||
)
|
||||
const price = screen.getByRole('textbox', { name: 'Price per image' })
|
||||
await user.clear(price)
|
||||
await user.type(price, '0.02')
|
||||
expect(
|
||||
within(screen.getByRole('tabpanel', { name: 'Ordinary OpenAI' })).getByRole(
|
||||
'tablist'
|
||||
)
|
||||
).toHaveClass('flex-wrap')
|
||||
const ordinary = screen.getByRole('tab', { name: 'Ordinary OpenAI' })
|
||||
await user.click(ordinary)
|
||||
await user.keyboard('{ArrowRight}{Enter}')
|
||||
expect(screen.getByRole('tab', { name: 'Alibaba' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await user.keyboard('{ArrowLeft}{Enter}')
|
||||
expect(ordinary).toHaveAttribute('aria-selected', 'true')
|
||||
expect(price).toHaveValue('0.02')
|
||||
expect(await ref.current?.commitDraft()).toMatchObject({
|
||||
billingExpr: 'tier("image", fixed(0.02)) * image_count',
|
||||
pluginBillingExpr: { alibaba: expression },
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['token', 'tier("base", p * 2 + c * 4)', 'Input price', '2'],
|
||||
[
|
||||
'per-image',
|
||||
'tier("image", fixed(0.04)) * image_count',
|
||||
'Price per image',
|
||||
'0.04',
|
||||
],
|
||||
['explicit zero', 'tier("free", fixed(0))', 'Price per request', '0'],
|
||||
])(
|
||||
'loads ordinary %s pricing independently of plugin usage metadata',
|
||||
async (_kind, expression, field, value) => {
|
||||
const override = 'tier("base", u("seconds") * 0.4)'
|
||||
const { ref } = renderEditor(
|
||||
{
|
||||
name: 'shared',
|
||||
billingMode: 'tiered_expr',
|
||||
billingExpr: expression,
|
||||
pluginBillingExpr: { alpha: override },
|
||||
},
|
||||
[
|
||||
{
|
||||
plugin_key: 'alpha',
|
||||
plugin_name: 'Alpha',
|
||||
usage_schema: { seconds: { type: 'number', unit: 'second' } },
|
||||
configured: override,
|
||||
effective: override,
|
||||
compatible: true,
|
||||
},
|
||||
]
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'Ordinary OpenAI' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByRole('textbox', { name: field })).toHaveValue(value)
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('tab', { name: 'Alpha' }))
|
||||
const price = screen.getByRole('textbox', { name: 'seconds' })
|
||||
expect(
|
||||
screen.getByRole('switch', { name: 'Set separately for this provider' })
|
||||
).toHaveAttribute('aria-disabled', 'true')
|
||||
await user.clear(price)
|
||||
await user.type(price, '0.6')
|
||||
expect(await ref.current?.commitDraft()).toMatchObject({
|
||||
billingExpr: expression,
|
||||
pluginBillingExpr: { alpha: 'tier("base", u("seconds") * 0.6)' },
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import * as z from 'zod'
|
||||
|
||||
import type { ModelPricingPluginVariant } from '@/features/model-pricing/api'
|
||||
import {
|
||||
formatPricingAmount,
|
||||
USD_PRICING_CURRENCY,
|
||||
@@ -29,6 +30,8 @@ import type {
|
||||
} from '@/features/model-pricing/pricing'
|
||||
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
|
||||
import { formatBillingCondition } from '@/features/pricing/lib/billing-expression/condition-display'
|
||||
import { compileBillingExpression } from '@/features/pricing/lib/billing-expression/parser'
|
||||
import type { BillingUsageSchema } from '@/features/pricing/types'
|
||||
|
||||
import { formatPricingNumber } from './pricing-format'
|
||||
|
||||
@@ -75,6 +78,34 @@ export type ModelRatioData = {
|
||||
requestRuleExpr?: string
|
||||
}
|
||||
|
||||
export function usesTaskModelPricing(
|
||||
data: ModelRatioData | null | undefined,
|
||||
schema: BillingUsageSchema | undefined,
|
||||
variants: ModelPricingPluginVariant[] = []
|
||||
): boolean {
|
||||
if (
|
||||
data?.billingMode !== 'tiered_expr' &&
|
||||
(hasValue(data?.price) || hasValue(data?.ratio))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const compiled = compileBillingExpression(data?.billingExpr ?? '')
|
||||
if (compiled.status === 'ready' && compiled.functions.has('u')) return true
|
||||
if (!schema) return false
|
||||
if (!data?.billingExpr?.trim()) return true
|
||||
if (compiled.status === 'ready' && compiled.functions.has('fixed')) {
|
||||
return false
|
||||
}
|
||||
const active = variants.filter((variant) => !variant.stale)
|
||||
return (
|
||||
active.length === 0 ||
|
||||
active.some(
|
||||
(variant) =>
|
||||
!Object.hasOwn(data.pluginBillingExpr ?? {}, variant.plugin_key)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export type PreviewRow = {
|
||||
unit?: 'image' | 'none'
|
||||
key: string
|
||||
|
||||
@@ -102,6 +102,7 @@ import {
|
||||
laneConfigs,
|
||||
ratioFieldByLane,
|
||||
toNumberOrNull,
|
||||
usesTaskModelPricing,
|
||||
type LaneKey,
|
||||
type ModelPricingFormValues,
|
||||
type ModelRatioData,
|
||||
@@ -239,6 +240,9 @@ export const ModelPricingEditorPanel = forwardRef<
|
||||
Record<string, string>
|
||||
>({})
|
||||
const [editorReloadToken, setEditorReloadToken] = useState(0)
|
||||
const [ordinaryPricingModel, setOrdinaryPricingModel] = useState<
|
||||
string | null
|
||||
>(null)
|
||||
const autoSwitchedForRef = useRef<string | null>(null)
|
||||
const isEditMode = !!editData
|
||||
const hasLegacyPricing =
|
||||
@@ -331,6 +335,9 @@ export const ModelPricingEditorPanel = forwardRef<
|
||||
const taskUsageExamples =
|
||||
usageExamplesByModel.get(watchedValues.name.trim()) ??
|
||||
pluginVariants?.find((variant) => !variant.stale)?.usage_examples
|
||||
const usesTaskPricing =
|
||||
ordinaryPricingModel !== watchedValues.name &&
|
||||
usesTaskModelPricing(editData, taskUsageSchema, pluginVariants)
|
||||
const defaultTaskBillingExpr = useMemo(
|
||||
() =>
|
||||
taskUsageSchema
|
||||
@@ -342,6 +349,7 @@ export const ModelPricingEditorPanel = forwardRef<
|
||||
[taskUsageSchema]
|
||||
)
|
||||
const resolvedBillingExpr =
|
||||
usesTaskPricing &&
|
||||
taskUsageSchema &&
|
||||
(!billingExpr || billingExpr === DEFAULT_TOKEN_BILLING_EXPR)
|
||||
? defaultTaskBillingExpr
|
||||
@@ -351,6 +359,7 @@ export const ModelPricingEditorPanel = forwardRef<
|
||||
conversionGeneration.current += 1
|
||||
setConversionReason('')
|
||||
setPluginExpressions(editData?.pluginBillingExpr ?? {})
|
||||
setOrdinaryPricingModel(null)
|
||||
setWasConverted(false)
|
||||
setConversionPreview(null)
|
||||
const nextLaneState = createInitialLaneState(editData)
|
||||
@@ -806,28 +815,54 @@ export const ModelPricingEditorPanel = forwardRef<
|
||||
[form, validatePricingValues, buildSubmitData]
|
||||
)
|
||||
|
||||
const expressionEditor = taskUsageSchema ? (
|
||||
<TaskUsagePricingEditor
|
||||
currency={currency}
|
||||
key={`${editorReloadToken}:${watchedValues.name}`}
|
||||
billingExpr={resolvedBillingExpr}
|
||||
requestRuleExpr={requestRuleExpr}
|
||||
usageSchema={taskUsageSchema}
|
||||
usageExamples={taskUsageExamples}
|
||||
onBillingExprChange={setBillingExpr}
|
||||
onRequestRuleExprChange={setRequestRuleExpr}
|
||||
/>
|
||||
) : (
|
||||
<TieredPricingEditor
|
||||
currency={currency}
|
||||
key={editorReloadToken}
|
||||
modelName={watchedValues.name}
|
||||
billingExpr={billingExpr}
|
||||
requestRuleExpr={requestRuleExpr}
|
||||
onBillingExprChange={setBillingExpr}
|
||||
onRequestRuleExprChange={setRequestRuleExpr}
|
||||
/>
|
||||
)
|
||||
const configureOrdinaryPricing = () => {
|
||||
const inheritedExpression = combineBillingExpr(
|
||||
resolvedBillingExpr,
|
||||
requestRuleExpr
|
||||
)
|
||||
setPluginExpressions((current) => {
|
||||
const next = { ...current }
|
||||
for (const variant of pluginVariants ?? []) {
|
||||
if (!variant.stale && !Object.hasOwn(next, variant.plugin_key)) {
|
||||
next[variant.plugin_key] = inheritedExpression
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
setOrdinaryPricingModel(watchedValues.name)
|
||||
setPricingMode('tiered_expr')
|
||||
setBillingExpr(
|
||||
taskUsageSchema?.image_count?.unit === 'count'
|
||||
? 'tier("image", fixed(0)) * image_count'
|
||||
: DEFAULT_TOKEN_BILLING_EXPR
|
||||
)
|
||||
setRequestRuleExpr('')
|
||||
setEditorReloadToken((token) => token + 1)
|
||||
}
|
||||
|
||||
const expressionEditor =
|
||||
usesTaskPricing && taskUsageSchema ? (
|
||||
<TaskUsagePricingEditor
|
||||
currency={currency}
|
||||
key={`${editorReloadToken}:${watchedValues.name}`}
|
||||
billingExpr={resolvedBillingExpr}
|
||||
requestRuleExpr={requestRuleExpr}
|
||||
usageSchema={taskUsageSchema}
|
||||
usageExamples={taskUsageExamples}
|
||||
onBillingExprChange={setBillingExpr}
|
||||
onRequestRuleExprChange={setRequestRuleExpr}
|
||||
/>
|
||||
) : (
|
||||
<TieredPricingEditor
|
||||
currency={currency}
|
||||
key={editorReloadToken}
|
||||
modelName={watchedValues.name}
|
||||
billingExpr={billingExpr}
|
||||
requestRuleExpr={requestRuleExpr}
|
||||
onBillingExprChange={setBillingExpr}
|
||||
onRequestRuleExprChange={setRequestRuleExpr}
|
||||
/>
|
||||
)
|
||||
|
||||
const showActions = Boolean(onSave)
|
||||
|
||||
@@ -922,6 +957,8 @@ export const ModelPricingEditorPanel = forwardRef<
|
||||
pricingMode === 'tiered_expr' ? 'tiered_expr' : 'ratio'
|
||||
}
|
||||
currency={currency}
|
||||
ordinaryPricing={!usesTaskPricing}
|
||||
onConfigureOrdinaryPricing={configureOrdinaryPricing}
|
||||
>
|
||||
<Tabs
|
||||
key={editorReloadToken}
|
||||
@@ -929,7 +966,7 @@ export const ModelPricingEditorPanel = forwardRef<
|
||||
onValueChange={handleModeChange}
|
||||
className='gap-4'
|
||||
>
|
||||
<TabsList className='grid w-full grid-cols-3'>
|
||||
<TabsList className='w-full flex-wrap group-data-horizontal/tabs:h-auto'>
|
||||
<TabsTrigger value='tiered_expr'>
|
||||
{t('Expression')}
|
||||
</TabsTrigger>
|
||||
@@ -1149,7 +1186,12 @@ export const ModelPricingEditorPanel = forwardRef<
|
||||
className='bg-muted/20 min-w-0 rounded-lg border @min-[960px]/pricing-editor:sticky @min-[960px]/pricing-editor:top-0'
|
||||
>
|
||||
<div className='border-b px-3 py-2'>
|
||||
<div className='text-sm font-medium'>{t('Preview')}</div>
|
||||
<div className='text-sm font-medium'>
|
||||
{!usesTaskPricing && pluginVariants?.length ? (
|
||||
<>{t('Ordinary OpenAI')} · </>
|
||||
) : null}
|
||||
{t('Preview')}
|
||||
</div>
|
||||
</div>
|
||||
<div className='divide-y'>
|
||||
{pricingMode === 'per-token' && !effectivePreview && (
|
||||
|
||||
@@ -52,6 +52,8 @@ type TaskPluginPricingEditorProps = {
|
||||
modelBillingMode?: 'ratio' | 'tiered_expr'
|
||||
currency: PricingCurrency
|
||||
children: ReactNode
|
||||
ordinaryPricing?: boolean
|
||||
onConfigureOrdinaryPricing?: () => void
|
||||
}
|
||||
|
||||
export function TaskPluginPricingEditor(props: TaskPluginPricingEditorProps) {
|
||||
@@ -86,7 +88,12 @@ export function TaskPluginPricingEditor(props: TaskPluginPricingEditorProps) {
|
||||
aria-label={t('Provider')}
|
||||
className='max-w-full flex-wrap justify-start group-data-horizontal/tabs:h-auto'
|
||||
>
|
||||
<TabsTrigger value='__model__'>{t('Default')}</TabsTrigger>
|
||||
<TabsTrigger value='__model__'>
|
||||
{props.ordinaryPricing ? t('Ordinary OpenAI') : t('Default')}
|
||||
</TabsTrigger>
|
||||
{!props.ordinaryPricing && props.onConfigureOrdinaryPricing && (
|
||||
<TabsTrigger value='__ordinary__'>{t('Ordinary OpenAI')}</TabsTrigger>
|
||||
)}
|
||||
{props.variants.map((variant) => (
|
||||
<TabsTrigger
|
||||
key={variant.plugin_key}
|
||||
@@ -139,6 +146,24 @@ export function TaskPluginPricingEditor(props: TaskPluginPricingEditorProps) {
|
||||
)}
|
||||
{props.children}
|
||||
</TabsContent>
|
||||
{!props.ordinaryPricing && props.onConfigureOrdinaryPricing && (
|
||||
<TabsContent value='__ordinary__' className='space-y-3'>
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'Set ordinary OpenAI prices without changing plugin prices. Changes take effect when you save.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={props.onConfigureOrdinaryPricing}
|
||||
>
|
||||
{t('Configure ordinary OpenAI pricing')}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
)}
|
||||
{props.variants.map((variant) => {
|
||||
const key = variant.plugin_key
|
||||
const separate = Object.hasOwn(props.expressions, key)
|
||||
@@ -203,13 +228,22 @@ export function TaskPluginPricingEditor(props: TaskPluginPricingEditorProps) {
|
||||
}
|
||||
let inheritDescription = t('Use model-level expression')
|
||||
if (!usesExpression) inheritDescription = t('Use model-level pricing')
|
||||
let description = !separate ? inheritDescription : undefined
|
||||
if (props.ordinaryPricing && usesExpression) {
|
||||
description = t(
|
||||
'Plugin expressions are independent from ordinary OpenAI pricing.'
|
||||
)
|
||||
}
|
||||
return (
|
||||
<TabsContent key={key} value={key} keepMounted className='space-y-3'>
|
||||
<SettingsSwitchField
|
||||
controlId={`${id}-${key}`}
|
||||
checked={separate}
|
||||
disabled={Boolean(
|
||||
separate && props.ordinaryPricing && usesExpression
|
||||
)}
|
||||
label={t('Set separately for this provider')}
|
||||
description={!separate ? inheritDescription : undefined}
|
||||
description={description}
|
||||
onCheckedChange={(checked) =>
|
||||
props.onChange((current) => {
|
||||
const next = { ...current }
|
||||
|
||||
@@ -110,6 +110,7 @@ function PriceField({
|
||||
}
|
||||
|
||||
type TierPriceFieldsProps = {
|
||||
imageCount?: boolean
|
||||
billingUnit?: 'token' | 'request'
|
||||
fixedPrice?: string
|
||||
onBillingUnitChange?: (unit: 'token' | 'request') => void
|
||||
@@ -162,7 +163,10 @@ export function TierPriceFields(props: TierPriceFieldsProps) {
|
||||
<Select
|
||||
items={[
|
||||
{ value: 'token', label: t('Per token') },
|
||||
{ value: 'request', label: t('Per-call') },
|
||||
{
|
||||
value: 'request',
|
||||
label: props.imageCount ? t('Price per image') : t('Per-call'),
|
||||
},
|
||||
]}
|
||||
value={props.billingUnit ?? 'token'}
|
||||
onValueChange={(value) => {
|
||||
@@ -181,7 +185,9 @@ export function TierPriceFields(props: TierPriceFieldsProps) {
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
<SelectItem value='token'>{t('Per token')}</SelectItem>
|
||||
<SelectItem value='request'>{t('Per-call')}</SelectItem>
|
||||
<SelectItem value='request'>
|
||||
{props.imageCount ? t('Price per image') : t('Per-call')}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -192,10 +198,12 @@ export function TierPriceFields(props: TierPriceFieldsProps) {
|
||||
{billingControl}
|
||||
<PriceField
|
||||
currency={props.currency}
|
||||
label={t('Price per request')}
|
||||
label={
|
||||
props.imageCount ? t('Price per image') : t('Price per request')
|
||||
}
|
||||
value={props.fixedPrice ?? ''}
|
||||
onChange={(value) => props.onFixedPriceChange?.(value)}
|
||||
hint={`${props.currency.symbol}/${t('request')}`}
|
||||
hint={`${props.currency.symbol}/${props.imageCount ? t('image') : t('request')}`}
|
||||
invalid={props.invalidVariables?.includes('fixed')}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -50,6 +50,7 @@ import { TierPriceFields } from './tier-price-fields'
|
||||
import { VisualConditionTree } from './visual-condition-tree'
|
||||
|
||||
type PricingNodeProps = {
|
||||
imageCount?: boolean
|
||||
node: VisualPricingNode
|
||||
source: string
|
||||
currency: PricingCurrency
|
||||
@@ -107,6 +108,7 @@ function PricingTierFields(
|
||||
</Button>
|
||||
</div>
|
||||
<TierPriceFields
|
||||
imageCount={props.imageCount}
|
||||
currency={props.currency}
|
||||
billingUnit={node.billingUnit}
|
||||
fixedPrice={node.fixedPrice}
|
||||
@@ -250,10 +252,13 @@ function PricingRuleCard(
|
||||
<span className='flex flex-wrap gap-x-4 gap-y-1 text-xs tabular-nums'>
|
||||
{tier.billingUnit === 'request' ? (
|
||||
<span>
|
||||
{t('Price per request')}:{' '}
|
||||
{props.imageCount
|
||||
? t('Price per image')
|
||||
: t('Price per request')}
|
||||
:{' '}
|
||||
{formatPricingAmount(tier.fixedPrice, props.currency) ||
|
||||
'—'}
|
||||
/{t('request')}
|
||||
/{props.imageCount ? t('image') : t('request')}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
@@ -390,6 +395,7 @@ export function VisualBillingDocumentEditor(props: {
|
||||
</p>
|
||||
)}
|
||||
<PricingRuleList
|
||||
imageCount={props.document.imageCount}
|
||||
node={props.document.root}
|
||||
prefix=''
|
||||
source={props.document.source}
|
||||
|
||||
@@ -1375,6 +1375,7 @@
|
||||
"Configure Models": "Configure Models",
|
||||
"Configure monitoring status page groups for the dashboard": "Configure monitoring status page groups for the dashboard",
|
||||
"Configure NODE_NAME": "Configure NODE_NAME",
|
||||
"Configure ordinary OpenAI pricing": "Configure ordinary OpenAI pricing",
|
||||
"Configure per-model ratio for image inputs or outputs.": "Configure per-model ratio for image inputs or outputs.",
|
||||
"Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.": "Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.",
|
||||
"Configure pricing ratios for a specific model.": "Configure pricing ratios for a specific model.",
|
||||
@@ -4398,6 +4399,7 @@
|
||||
"Order is not pending payment and cannot be completed": "Order is not pending payment and cannot be completed",
|
||||
"Order number not provided": "Order number not provided",
|
||||
"Order Payment Method": "Order Payment Method",
|
||||
"Ordinary OpenAI": "Ordinary OpenAI",
|
||||
"org-...": "org-...",
|
||||
"Original Model": "Original Model",
|
||||
"Original value": "Original value",
|
||||
@@ -4723,6 +4725,7 @@
|
||||
"Plugin author": "Plugin author",
|
||||
"Plugin default": "Plugin default",
|
||||
"Plugin details": "Plugin details",
|
||||
"Plugin expressions are independent from ordinary OpenAI pricing.": "Plugin expressions are independent from ordinary OpenAI pricing.",
|
||||
"Plugin extensions": "Plugin extensions",
|
||||
"Plugin Generation": "Plugin Generation",
|
||||
"Plugin icon": "Plugin icon",
|
||||
@@ -5740,6 +5743,7 @@
|
||||
"Set filters to customize your dashboard statistics and charts.": "Set filters to customize your dashboard statistics and charts.",
|
||||
"Set filters to narrow down your log search results.": "Set filters to narrow down your log search results.",
|
||||
"Set Header": "Set Header",
|
||||
"Set ordinary OpenAI prices without changing plugin prices. Changes take effect when you save.": "Set ordinary OpenAI prices without changing plugin prices. Changes take effect when you save.",
|
||||
"Set Password": "Set Password",
|
||||
"Set prices for each set of conditions below. Cost = usage × unit price + additional charge. Token prices are per million tokens.": "Set prices for each set of conditions below. Cost = usage × unit price + additional charge. Token prices are per million tokens.",
|
||||
"Set prices for each set of conditions below. Cost = usage × unit price + fixed fee. Token prices are per million tokens.": "Set prices for each set of conditions below. Cost = usage × unit price + fixed fee. Token prices are per million tokens.",
|
||||
|
||||
@@ -1375,6 +1375,7 @@
|
||||
"Configure Models": "Configurer les modèles",
|
||||
"Configure monitoring status page groups for the dashboard": "Configurer les groupes de pages d'état de surveillance pour le tableau de bord",
|
||||
"Configure NODE_NAME": "Configurer NODE_NAME",
|
||||
"Configure ordinary OpenAI pricing": "Définir les tarifs OpenAI standard",
|
||||
"Configure per-model ratio for image inputs or outputs.": "Configurer le ratio par modèle pour les entrées ou sorties d'images.",
|
||||
"Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.": "Définissez le prix unitaire de chaque outil ($/1K appels). Les modèles facturés à la requête n'entraînent pas de frais d'outils supplémentaires.",
|
||||
"Configure pricing ratios for a specific model.": "Configurer les ratios de tarification pour un modèle spécifique.",
|
||||
@@ -4398,6 +4399,7 @@
|
||||
"Order is not pending payment and cannot be completed": "La commande n'est pas en attente de paiement et ne peut pas être finalisée",
|
||||
"Order number not provided": "Numéro de commande non fourni",
|
||||
"Order Payment Method": "Moyen de paiement (commande)",
|
||||
"Ordinary OpenAI": "OpenAI standard",
|
||||
"org-...": "org-...",
|
||||
"Original Model": "Modèle Original",
|
||||
"Original value": "Valeur d'origine",
|
||||
@@ -4723,6 +4725,7 @@
|
||||
"Plugin author": "Auteur du plugin",
|
||||
"Plugin default": "Valeur par défaut du plugin",
|
||||
"Plugin details": "Détails du plugin",
|
||||
"Plugin expressions are independent from ordinary OpenAI pricing.": "Les expressions des plugins sont indépendantes des tarifs OpenAI standard.",
|
||||
"Plugin extensions": "Extensions par plugin",
|
||||
"Plugin Generation": "Génération du plugin",
|
||||
"Plugin icon": "Icône du plugin",
|
||||
@@ -5740,6 +5743,7 @@
|
||||
"Set filters to customize your dashboard statistics and charts.": "Définir des filtres pour personnaliser les statistiques et les graphiques de votre tableau de bord.",
|
||||
"Set filters to narrow down your log search results.": "Définir des filtres pour affiner vos résultats de recherche de journaux.",
|
||||
"Set Header": "Définir l'en-tête",
|
||||
"Set ordinary OpenAI prices without changing plugin prices. Changes take effect when you save.": "Définissez les tarifs OpenAI standard sans modifier ceux des plugins. Les changements prennent effet après enregistrement.",
|
||||
"Set Password": "Définir un mot de passe",
|
||||
"Set prices for each set of conditions below. Cost = usage × unit price + additional charge. Token prices are per million tokens.": "Définissez les prix selon les conditions ci-dessous. Coût = usage × prix unitaire + supplément. Les prix des tokens sont par million.",
|
||||
"Set prices for each set of conditions below. Cost = usage × unit price + fixed fee. Token prices are per million tokens.": "Définissez les prix selon les conditions ci-dessous. Coût = usage × prix unitaire + frais fixes. Les prix des tokens sont par million.",
|
||||
|
||||
@@ -1375,6 +1375,7 @@
|
||||
"Configure Models": "モデルを設定",
|
||||
"Configure monitoring status page groups for the dashboard": "ダッシュボードの監視ステータスページグループを設定します。",
|
||||
"Configure NODE_NAME": "NODE_NAME を設定",
|
||||
"Configure ordinary OpenAI pricing": "通常の OpenAI 料金を設定",
|
||||
"Configure per-model ratio for image inputs or outputs.": "画像の入力または出力のモデルごとの比率を設定します。",
|
||||
"Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.": "ツールごとの単価($/1K 回)を設定します。リクエスト課金モデルでは追加工具料金はかかりません。",
|
||||
"Configure pricing ratios for a specific model.": "特定のモデルの料金比率を設定します。",
|
||||
@@ -4398,6 +4399,7 @@
|
||||
"Order is not pending payment and cannot be completed": "注文が支払い待ちではないため、完了できません",
|
||||
"Order number not provided": "注文番号が指定されていません",
|
||||
"Order Payment Method": "注文の支払い方法",
|
||||
"Ordinary OpenAI": "通常の OpenAI",
|
||||
"org-...": "org-...",
|
||||
"Original Model": "オリジナルモデル",
|
||||
"Original value": "元の値",
|
||||
@@ -4723,6 +4725,7 @@
|
||||
"Plugin author": "プラグイン作成者",
|
||||
"Plugin default": "プラグインの既定値",
|
||||
"Plugin details": "プラグインの詳細",
|
||||
"Plugin expressions are independent from ordinary OpenAI pricing.": "プラグインの課金式は通常の OpenAI の料金設定から独立しています。",
|
||||
"Plugin extensions": "プラグイン拡張",
|
||||
"Plugin Generation": "プラグイン世代",
|
||||
"Plugin icon": "プラグインアイコン",
|
||||
@@ -5740,6 +5743,7 @@
|
||||
"Set filters to customize your dashboard statistics and charts.": "ダッシュボードの統計とグラフをカスタマイズするためにフィルターを設定します。",
|
||||
"Set filters to narrow down your log search results.": "ログ検索結果を絞り込むためにフィルターを設定します。",
|
||||
"Set Header": "ヘッダーを設定",
|
||||
"Set ordinary OpenAI prices without changing plugin prices. Changes take effect when you save.": "プラグインの料金を変更せずに、通常の OpenAI の料金を設定します。保存後に適用されます。",
|
||||
"Set Password": "パスワードを設定",
|
||||
"Set prices for each set of conditions below. Cost = usage × unit price + additional charge. Token prices are per million tokens.": "以下の条件ごとに料金を設定してください。料金 = 使用量 × 単価 + 追加料金。トークン単価は100万トークンあたりで入力します。",
|
||||
"Set prices for each set of conditions below. Cost = usage × unit price + fixed fee. Token prices are per million tokens.": "以下の条件ごとに料金を設定してください。料金 = 使用量 × 単価 + 固定料金。トークン単価は100万トークンあたりで入力します。",
|
||||
|
||||
@@ -1375,6 +1375,7 @@
|
||||
"Configure Models": "Настроить модели",
|
||||
"Configure monitoring status page groups for the dashboard": "Настроить группы страниц состояния мониторинга для панели управления",
|
||||
"Configure NODE_NAME": "Настроить NODE_NAME",
|
||||
"Configure ordinary OpenAI pricing": "Настроить цены обычного OpenAI",
|
||||
"Configure per-model ratio for image inputs or outputs.": "Настроить коэффициент для каждой модели для ввода или вывода изображений.",
|
||||
"Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.": "Настройте стоимость единицы на инструмент ($/1K вызовов). Для моделей с оплатой за запрос доп. плата за инструменты не взимается.",
|
||||
"Configure pricing ratios for a specific model.": "Настроить коэффициенты ценообразования для конкретной модели.",
|
||||
@@ -4398,6 +4399,7 @@
|
||||
"Order is not pending payment and cannot be completed": "Заказ не ожидает оплаты, его нельзя завершить",
|
||||
"Order number not provided": "Номер заказа не указан",
|
||||
"Order Payment Method": "Способ оплаты (заказа)",
|
||||
"Ordinary OpenAI": "Обычный OpenAI",
|
||||
"org-...": "орг-...",
|
||||
"Original Model": "Оригинальная модель",
|
||||
"Original value": "Исходное значение",
|
||||
@@ -4723,6 +4725,7 @@
|
||||
"Plugin author": "Автор плагина",
|
||||
"Plugin default": "Значение плагина по умолчанию",
|
||||
"Plugin details": "Сведения о плагине",
|
||||
"Plugin expressions are independent from ordinary OpenAI pricing.": "Формулы плагинов не зависят от цен обычного OpenAI.",
|
||||
"Plugin extensions": "Расширения через плагины",
|
||||
"Plugin Generation": "Поколение плагина",
|
||||
"Plugin icon": "Иконка плагина",
|
||||
@@ -5740,6 +5743,7 @@
|
||||
"Set filters to customize your dashboard statistics and charts.": "Установите фильтры, чтобы настроить статистику и диаграммы вашей панели управления.",
|
||||
"Set filters to narrow down your log search results.": "Установите фильтры, чтобы сузить результаты поиска по журналам.",
|
||||
"Set Header": "Установить заголовок",
|
||||
"Set ordinary OpenAI prices without changing plugin prices. Changes take effect when you save.": "Настройте цены обычного OpenAI, сохранив цены плагинов. Изменения вступят в силу после сохранения.",
|
||||
"Set Password": "Установить пароль",
|
||||
"Set prices for each set of conditions below. Cost = usage × unit price + additional charge. Token prices are per million tokens.": "Задайте цены для условий ниже. Стоимость = объём × цена за единицу + доплата. Цены токенов указываются за миллион.",
|
||||
"Set prices for each set of conditions below. Cost = usage × unit price + fixed fee. Token prices are per million tokens.": "Задайте цены для условий ниже. Стоимость = объём × цена за единицу + фиксированная плата. Цены токенов указываются за миллион.",
|
||||
|
||||
@@ -1375,6 +1375,7 @@
|
||||
"Configure Models": "Cấu hình mô hình",
|
||||
"Configure monitoring status page groups for the dashboard": "Cấu hình các nhóm trang trạng thái giám sát cho bảng điều khiển",
|
||||
"Configure NODE_NAME": "Cấu hình NODE_NAME",
|
||||
"Configure ordinary OpenAI pricing": "Đặt giá OpenAI thông thường",
|
||||
"Configure per-model ratio for image inputs or outputs.": "Cấu hình tỷ lệ theo mô hình cho đầu vào hoặc đầu ra hình ảnh.",
|
||||
"Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.": "Cấu hình giá theo từng công cụ ($/1K lần gọi). Mô hình tính phí theo request không phát sinh thêm phí công cụ.",
|
||||
"Configure pricing ratios for a specific model.": "Cấu hình tỷ lệ định giá cho một mô hình cụ thể.",
|
||||
@@ -4398,6 +4399,7 @@
|
||||
"Order is not pending payment and cannot be completed": "Đơn hàng không ở trạng thái chờ thanh toán nên không thể hoàn tất",
|
||||
"Order number not provided": "Chưa cung cấp mã đơn hàng",
|
||||
"Order Payment Method": "Phương thức thanh toán đơn hàng",
|
||||
"Ordinary OpenAI": "OpenAI thông thường",
|
||||
"org-...": "org-...",
|
||||
"Original Model": "Nguyên mẫu",
|
||||
"Original value": "Giá trị gốc",
|
||||
@@ -4723,6 +4725,7 @@
|
||||
"Plugin author": "Tác giả plugin",
|
||||
"Plugin default": "Mặc định của plugin",
|
||||
"Plugin details": "Chi tiết plugin",
|
||||
"Plugin expressions are independent from ordinary OpenAI pricing.": "Biểu thức tính phí plugin độc lập với giá OpenAI thông thường.",
|
||||
"Plugin extensions": "Phần mở rộng bằng plugin",
|
||||
"Plugin Generation": "Thế hệ plugin",
|
||||
"Plugin icon": "Biểu tượng plugin",
|
||||
@@ -5740,6 +5743,7 @@
|
||||
"Set filters to customize your dashboard statistics and charts.": "Đặt bộ lọc để tùy chỉnh số liệu thống kê và biểu đồ trên bảng điều khiển của bạn.",
|
||||
"Set filters to narrow down your log search results.": "Đặt bộ lọc để thu hẹp kết quả tìm kiếm nhật ký của bạn.",
|
||||
"Set Header": "Đặt tiêu đề",
|
||||
"Set ordinary OpenAI prices without changing plugin prices. Changes take effect when you save.": "Đặt giá OpenAI thông thường mà không thay đổi giá plugin. Thay đổi có hiệu lực sau khi lưu.",
|
||||
"Set Password": "Đặt mật khẩu",
|
||||
"Set prices for each set of conditions below. Cost = usage × unit price + additional charge. Token prices are per million tokens.": "Đặt giá cho từng điều kiện bên dưới. Chi phí = mức sử dụng × đơn giá + phí bổ sung. Đơn giá token tính trên mỗi triệu token.",
|
||||
"Set prices for each set of conditions below. Cost = usage × unit price + fixed fee. Token prices are per million tokens.": "Đặt giá cho từng điều kiện bên dưới. Chi phí = mức sử dụng × đơn giá + phí cố định. Đơn giá token tính trên mỗi triệu token.",
|
||||
|
||||
@@ -1375,6 +1375,7 @@
|
||||
"Configure Models": "設定模型",
|
||||
"Configure monitoring status page groups for the dashboard": "設定用於儀表板的監控狀態頁面分組",
|
||||
"Configure NODE_NAME": "設定 NODE_NAME",
|
||||
"Configure ordinary OpenAI pricing": "設定一般 OpenAI 價格",
|
||||
"Configure per-model ratio for image inputs or outputs.": "設定圖像輸入或輸出的每模型比例。",
|
||||
"Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.": "為每個工具設定單價($/1K 次呼叫)。按請求收費的模型不額外收取工具費用。",
|
||||
"Configure pricing ratios for a specific model.": "設定特定模型的定價比例。",
|
||||
@@ -4398,6 +4399,7 @@
|
||||
"Order is not pending payment and cannot be completed": "訂單狀態不是待支付,無法補單",
|
||||
"Order number not provided": "未提供訂單號",
|
||||
"Order Payment Method": "訂單支付方式",
|
||||
"Ordinary OpenAI": "一般 OpenAI",
|
||||
"org-...": "org-...",
|
||||
"Original Model": "原始模型",
|
||||
"Original value": "原始值",
|
||||
@@ -4723,6 +4725,7 @@
|
||||
"Plugin author": "外掛作者",
|
||||
"Plugin default": "外掛預設值",
|
||||
"Plugin details": "外掛詳情",
|
||||
"Plugin expressions are independent from ordinary OpenAI pricing.": "插件計費表達式獨立於一般 OpenAI 定價。",
|
||||
"Plugin extensions": "外掛擴充",
|
||||
"Plugin Generation": "外掛執行代次",
|
||||
"Plugin icon": "外掛圖示",
|
||||
@@ -5740,6 +5743,7 @@
|
||||
"Set filters to customize your dashboard statistics and charts.": "設定篩選器以自訂您的儀表板統計數據和圖表。",
|
||||
"Set filters to narrow down your log search results.": "設定篩選器以縮小日誌搜尋結果範圍。",
|
||||
"Set Header": "設請求頭",
|
||||
"Set ordinary OpenAI prices without changing plugin prices. Changes take effect when you save.": "單獨設定一般 OpenAI 價格,保留插件現有價格。儲存後生效。",
|
||||
"Set Password": "設定密碼",
|
||||
"Set prices for each set of conditions below. Cost = usage × unit price + additional charge. Token prices are per million tokens.": "為下方不同條件分別填寫價格。費用 = 用量 × 單價 + 加收費用;Token 單價按每百萬 Token 填寫。",
|
||||
"Set prices for each set of conditions below. Cost = usage × unit price + fixed fee. Token prices are per million tokens.": "為下方不同條件分別填寫價格。費用 = 用量 × 單價 + 固定費用;Token 單價按每百萬 Token 填寫。",
|
||||
|
||||
@@ -1375,6 +1375,7 @@
|
||||
"Configure Models": "配置模型",
|
||||
"Configure monitoring status page groups for the dashboard": "配置用于仪表板的监控状态页面分组",
|
||||
"Configure NODE_NAME": "配置 NODE_NAME",
|
||||
"Configure ordinary OpenAI pricing": "配置普通 OpenAI 价格",
|
||||
"Configure per-model ratio for image inputs or outputs.": "配置图像输入或输出的每模型比例。",
|
||||
"Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.": "为每个工具配置单价($/1K 次调用)。按请求计费的模型不额外收取工具费用。",
|
||||
"Configure pricing ratios for a specific model.": "配置特定模型的定价比例。",
|
||||
@@ -4398,6 +4399,7 @@
|
||||
"Order is not pending payment and cannot be completed": "订单状态不是待支付,无法补单",
|
||||
"Order number not provided": "未提供订单号",
|
||||
"Order Payment Method": "订单支付方式",
|
||||
"Ordinary OpenAI": "普通 OpenAI",
|
||||
"org-...": "org-...",
|
||||
"Original Model": "原始模型",
|
||||
"Original value": "原始值",
|
||||
@@ -4723,6 +4725,7 @@
|
||||
"Plugin author": "插件作者",
|
||||
"Plugin default": "插件默认值",
|
||||
"Plugin details": "插件详情",
|
||||
"Plugin expressions are independent from ordinary OpenAI pricing.": "插件计费表达式独立于普通 OpenAI 定价。",
|
||||
"Plugin extensions": "插件扩展",
|
||||
"Plugin Generation": "插件运行代次",
|
||||
"Plugin icon": "插件图标",
|
||||
@@ -5740,6 +5743,7 @@
|
||||
"Set filters to customize your dashboard statistics and charts.": "设置筛选器以自定义您的仪表板统计数据和图表。",
|
||||
"Set filters to narrow down your log search results.": "设置筛选器以缩小日志搜索结果范围。",
|
||||
"Set Header": "设请求头",
|
||||
"Set ordinary OpenAI prices without changing plugin prices. Changes take effect when you save.": "单独设置普通 OpenAI 价格,保留插件现有价格。保存后生效。",
|
||||
"Set Password": "设置密码",
|
||||
"Set prices for each set of conditions below. Cost = usage × unit price + additional charge. Token prices are per million tokens.": "为下方不同条件分别填写价格。费用 = 用量 × 单价 + 加收费用;Token 单价按每百万 Token 填写。",
|
||||
"Set prices for each set of conditions below. Cost = usage × unit price + fixed fee. Token prices are per million tokens.": "为下方不同条件分别填写价格。费用 = 用量 × 单价 + 固定费用;Token 单价按每百万 Token 填写。",
|
||||
|
||||
Reference in new issue
Block a user