From 8802113263b8a94bf8957a1231b4ec04705d7dd9 Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 22 Oct 2026 15:59:10 +0800 Subject: [PATCH] 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. --- controller/model_management_test.go | 21 ++ docs/plugin-api/v1.md | 22 +- dto/channel_constraints.go | 4 + i18n/keys.go | 2 + i18n/locales/en.yaml | 2 + i18n/locales/zh-CN.yaml | 2 + i18n/locales/zh-TW.yaml | 2 + middleware/distributor.go | 23 +- middleware/task_plugin.go | 143 ++++++--- middleware/task_plugin_test.go | 272 ++++++++++++++++++ model/channel_constraint.go | 19 +- model/model_pricing_config.go | 2 +- pkg/jsplugin/routing.go | 3 + relay/helper/price.go | 31 +- relay/relay_task_test.go | 118 ++++++++ router/task-plugin-protocol-router.go | 6 +- .../model-pricing/model-pricing-panel.tsx | 6 +- web/src/features/model-pricing/pricing.ts | 20 +- .../lib/__tests__/billing-expression.test.ts | 66 +++++ .../pricing/lib/billing-expression/visual.ts | 50 +++- .../models/__tests__/plugin-pricing.test.tsx | 154 +++++++++- .../models/model-pricing-core.ts | 31 ++ .../models/model-pricing-sheet.tsx | 90 ++++-- .../models/task-plugin-pricing-editor.tsx | 38 ++- .../models/tier-price-fields.tsx | 16 +- .../models/visual-billing-document-editor.tsx | 10 +- web/src/i18n/locales/en.json | 4 + web/src/i18n/locales/fr.json | 4 + web/src/i18n/locales/ja.json | 4 + web/src/i18n/locales/ru.json | 4 + web/src/i18n/locales/vi.json | 4 + web/src/i18n/locales/zh-TW.json | 4 + web/src/i18n/locales/zh.json | 4 + 33 files changed, 1078 insertions(+), 103 deletions(-) diff --git a/controller/model_management_test.go b/controller/model_management_test.go index fd760b98b4..2e0d708c0e 100644 --- a/controller/model_management_test.go +++ b/controller/model_management_test.go @@ -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) diff --git a/docs/plugin-api/v1.md b/docs/plugin-api/v1.md index a0f4812dcb..abff902134 100644 --- a/docs/plugin-api/v1.md +++ b/docs/plugin-api/v1.md @@ -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 diff --git a/dto/channel_constraints.go b/dto/channel_constraints.go index a1848bc101..1936378f00 100644 --- a/dto/channel_constraints.go +++ b/dto/channel_constraints.go @@ -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 { diff --git a/i18n/keys.go b/i18n/keys.go index d2e86b3eca..2da7668def 100644 --- a/i18n/keys.go +++ b/i18n/keys.go @@ -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" diff --git a/i18n/locales/en.yaml b/i18n/locales/en.yaml index f9cbb0a3b7..d133ffa6fb 100644 --- a/i18n/locales/en.yaml +++ b/i18n/locales/en.yaml @@ -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" diff --git a/i18n/locales/zh-CN.yaml b/i18n/locales/zh-CN.yaml index eceee81e79..7b5d8ff15c 100644 --- a/i18n/locales/zh-CN.yaml +++ b/i18n/locales/zh-CN.yaml @@ -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: "当前分组上游负载已饱和,请稍后再试" diff --git a/i18n/locales/zh-TW.yaml b/i18n/locales/zh-TW.yaml index d2349d5ef3..0cf8dad650 100644 --- a/i18n/locales/zh-TW.yaml +++ b/i18n/locales/zh-TW.yaml @@ -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: "目前分組上游負載已飽和,請稍後再試" diff --git a/middleware/distributor.go b/middleware/distributor.go index 88c4cb2818..2397d9f149 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -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") diff --git a/middleware/task_plugin.go b/middleware/task_plugin.go index 191dc6abc3..bfb296abe5 100644 --- a/middleware/task_plugin.go +++ b/middleware/task_plugin.go @@ -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() } diff --git a/middleware/task_plugin_test.go b/middleware/task_plugin_test.go index d999cf0497..487a28c9f6 100644 --- a/middleware/task_plugin_test.go +++ b/middleware/task_plugin_test.go @@ -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") + } + }) + } + }) + } + }) + } +} diff --git a/model/channel_constraint.go b/model/channel_constraint.go index c3c3e9b63a..81c15413f7 100644 --- a/model/channel_constraint.go +++ b/model/channel_constraint.go @@ -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 diff --git a/model/model_pricing_config.go b/model/model_pricing_config.go index 01e33bb005..d501598716 100644 --- a/model/model_pricing_config.go +++ b/model/model_pricing_config.go @@ -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 diff --git a/pkg/jsplugin/routing.go b/pkg/jsplugin/routing.go index 648da85b9c..c07ce0bfc0 100644 --- a/pkg/jsplugin/routing.go +++ b/pkg/jsplugin/routing.go @@ -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 { diff --git a/relay/helper/price.go b/relay/helper/price.go index 3cd2963f17..adebd8783c 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -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 diff --git a/relay/relay_task_test.go b/relay/relay_task_test.go index cab325c41a..c785388888 100644 --- a/relay/relay_task_test.go +++ b/relay/relay_task_test.go @@ -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. diff --git a/router/task-plugin-protocol-router.go b/router/task-plugin-protocol-router.go index 3f7f96b378..425aae7e0b 100644 --- a/router/task-plugin-protocol-router.go +++ b/router/task-plugin-protocol-router.go @@ -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": diff --git a/web/src/features/model-pricing/model-pricing-panel.tsx b/web/src/features/model-pricing/model-pricing-panel.tsx index 251a3becc3..37a6e464b9 100644 --- a/web/src/features/model-pricing/model-pricing-panel.tsx +++ b/web/src/features/model-pricing/model-pricing-panel.tsx @@ -193,6 +193,10 @@ export function ModelPricingPanel(props: { className='space-y-3 border-b pb-3' >

+ {!effectivePricing.billing_usage_schema && + entry.plugin_variants?.length ? ( + <>{t('Ordinary OpenAI')} · + ) : null} {t('Current Billing')}

@@ -206,7 +210,7 @@ export function ModelPricingPanel(props: { ) : ( effectivePricing.quota_type === 0 && diff --git a/web/src/features/model-pricing/pricing.ts b/web/src/features/model-pricing/pricing.ts index 7e3e508eac..8be6a9bdb7 100644 --- a/web/src/features/model-pricing/pricing.ts +++ b/web/src/features/model-pricing/pricing.ts @@ -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 & - Partial> + 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, } } diff --git a/web/src/features/pricing/lib/__tests__/billing-expression.test.ts b/web/src/features/pricing/lib/__tests__/billing-expression.test.ts index 3744ed438b..36e35fa120 100644 --- a/web/src/features/pricing/lib/__tests__/billing-expression.test.ts +++ b/web/src/features/pricing/lib/__tests__/billing-expression.test.ts @@ -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)) `, diff --git a/web/src/features/pricing/lib/billing-expression/visual.ts b/web/src/features/pricing/lib/billing-expression/visual.ts index 5530432aae..de65d6083c 100644 --- a/web/src/features/pricing/lib/billing-expression/visual.ts +++ b/web/src/features/pricing/lib/billing-expression/visual.ts @@ -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, diff --git a/web/src/features/system-settings/models/__tests__/plugin-pricing.test.tsx b/web/src/features/system-settings/models/__tests__/plugin-pricing.test.tsx index d88ac77c4e..30ef5a1771 100644 --- a/web/src/features/system-settings/models/__tests__/plugin-pricing.test.tsx +++ b/web/src/features/system-settings/models/__tests__/plugin-pricing.test.tsx @@ -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)' }, + }) + } +) diff --git a/web/src/features/system-settings/models/model-pricing-core.ts b/web/src/features/system-settings/models/model-pricing-core.ts index b0b1612c27..c9f94778b7 100644 --- a/web/src/features/system-settings/models/model-pricing-core.ts +++ b/web/src/features/system-settings/models/model-pricing-core.ts @@ -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 diff --git a/web/src/features/system-settings/models/model-pricing-sheet.tsx b/web/src/features/system-settings/models/model-pricing-sheet.tsx index e9b16d2666..e1b663da0c 100644 --- a/web/src/features/system-settings/models/model-pricing-sheet.tsx +++ b/web/src/features/system-settings/models/model-pricing-sheet.tsx @@ -102,6 +102,7 @@ import { laneConfigs, ratioFieldByLane, toNumberOrNull, + usesTaskModelPricing, type LaneKey, type ModelPricingFormValues, type ModelRatioData, @@ -239,6 +240,9 @@ export const ModelPricingEditorPanel = forwardRef< Record >({}) const [editorReloadToken, setEditorReloadToken] = useState(0) + const [ordinaryPricingModel, setOrdinaryPricingModel] = useState< + string | null + >(null) const autoSwitchedForRef = useRef(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 ? ( - - ) : ( - - ) + 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 ? ( + + ) : ( + + ) const showActions = Boolean(onSave) @@ -922,6 +957,8 @@ export const ModelPricingEditorPanel = forwardRef< pricingMode === 'tiered_expr' ? 'tiered_expr' : 'ratio' } currency={currency} + ordinaryPricing={!usesTaskPricing} + onConfigureOrdinaryPricing={configureOrdinaryPricing} > - + {t('Expression')} @@ -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' >
-
{t('Preview')}
+
+ {!usesTaskPricing && pluginVariants?.length ? ( + <>{t('Ordinary OpenAI')} · + ) : null} + {t('Preview')} +
{pricingMode === 'per-token' && !effectivePreview && ( diff --git a/web/src/features/system-settings/models/task-plugin-pricing-editor.tsx b/web/src/features/system-settings/models/task-plugin-pricing-editor.tsx index 95348dd6df..af8123193d 100644 --- a/web/src/features/system-settings/models/task-plugin-pricing-editor.tsx +++ b/web/src/features/system-settings/models/task-plugin-pricing-editor.tsx @@ -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' > - {t('Default')} + + {props.ordinaryPricing ? t('Ordinary OpenAI') : t('Default')} + + {!props.ordinaryPricing && props.onConfigureOrdinaryPricing && ( + {t('Ordinary OpenAI')} + )} {props.variants.map((variant) => ( + {!props.ordinaryPricing && props.onConfigureOrdinaryPricing && ( + + + + {t( + 'Set ordinary OpenAI prices without changing plugin prices. Changes take effect when you save.' + )} + + + + + )} {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 ( props.onChange((current) => { const next = { ...current } diff --git a/web/src/features/system-settings/models/tier-price-fields.tsx b/web/src/features/system-settings/models/tier-price-fields.tsx index 6a56855c28..94a63a07d0 100644 --- a/web/src/features/system-settings/models/tier-price-fields.tsx +++ b/web/src/features/system-settings/models/tier-price-fields.tsx @@ -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) { @@ -192,10 +198,12 @@ export function TierPriceFields(props: TierPriceFieldsProps) { {billingControl} props.onFixedPriceChange?.(value)} - hint={`${props.currency.symbol}/${t('request')}`} + hint={`${props.currency.symbol}/${props.imageCount ? t('image') : t('request')}`} invalid={props.invalidVariables?.includes('fixed')} /> diff --git a/web/src/features/system-settings/models/visual-billing-document-editor.tsx b/web/src/features/system-settings/models/visual-billing-document-editor.tsx index e60077f02c..04bc505a49 100644 --- a/web/src/features/system-settings/models/visual-billing-document-editor.tsx +++ b/web/src/features/system-settings/models/visual-billing-document-editor.tsx @@ -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(
{tier.billingUnit === 'request' ? ( - {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')} ) : ( <> @@ -390,6 +395,7 @@ export function VisualBillingDocumentEditor(props: {

)}