diff --git a/.gitignore b/.gitignore index 5747563f..91fd4e39 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,4 @@ token_estimator_test.go skills-lock.json .playwright-mcp Operation.md -home +/home diff --git a/controller/relay.go b/controller/relay.go index ba467b3d..390f2e52 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -582,6 +582,10 @@ func RelayTask(c *gin.Context) { } service.LogTaskConsumption(c, relayInfo) + deltaSettlementDisabled := false + if relayInfo.ChannelMeta != nil && relayInfo.ChannelType == constant.ChannelTypeDoubaoVideo { + deltaSettlementDisabled = relayInfo.ChannelOtherSettings.DisableTaskDeltaSettlement + } task := model.InitTask(result.Platform, relayInfo) task.PrivateData.UpstreamTaskID = result.UpstreamTaskID task.PrivateData.BillingSource = relayInfo.BillingSource @@ -589,13 +593,14 @@ func RelayTask(c *gin.Context) { task.PrivateData.TokenId = relayInfo.TokenId task.PrivateData.NodeName = common.NodeName task.PrivateData.BillingContext = &model.TaskBillingContext{ - ModelPrice: relayInfo.PriceData.ModelPrice, - GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio, - ModelRatio: relayInfo.PriceData.ModelRatio, - OtherRatios: relayInfo.PriceData.OtherRatios, - TaskBilling: relayInfo.TaskBilling, - OriginModelName: relayInfo.OriginModelName, - PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice || relayInfo.TaskBilling != nil, + ModelPrice: relayInfo.PriceData.ModelPrice, + GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio, + ModelRatio: relayInfo.PriceData.ModelRatio, + OtherRatios: relayInfo.PriceData.OtherRatios, + TaskBilling: relayInfo.TaskBilling, + OriginModelName: relayInfo.OriginModelName, + PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice || relayInfo.TaskBilling != nil, + DeltaSettlementDisabled: common.GetPointer(deltaSettlementDisabled), } task.Quota = result.Quota task.Data = result.TaskData diff --git a/controller/task_video.go b/controller/task_video.go index de84b49e..19ce0639 100644 --- a/controller/task_video.go +++ b/controller/task_video.go @@ -160,7 +160,9 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha } // 如果返回了 total_tokens 并且配置了模型倍率(非固定价格),则重新计费 - if taskResult.TotalTokens > 0 { + if task.DeltaSettlementDisabledForChannel(channel.Type, channel.GetOtherSettings()) { + logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 已按渠道设置跳过完成态差额结算", task.TaskID)) + } else if taskResult.TotalTokens > 0 { // 获取模型名称 var taskData map[string]interface{} if err := common.Unmarshal(task.Data, &taskData); err == nil { diff --git a/dto/channel_settings.go b/dto/channel_settings.go index e549d7a2..35f0c0e3 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -49,6 +49,7 @@ type ChannelOtherSettings struct { UpstreamModelUpdateIgnoredModels []string `json:"upstream_model_update_ignored_models,omitempty"` TaskProtocol string `json:"task_protocol,omitempty"` TaskProtocolConfig *TaskProtocolConfig `json:"task_protocol_config,omitempty"` + DisableTaskDeltaSettlement bool `json:"disable_task_delta_settlement,omitempty"` AdvancedCustom *AdvancedCustomConfig `json:"advanced_custom,omitempty"` } diff --git a/model/task.go b/model/task.go index 978b8b5d..905632fd 100644 --- a/model/task.go +++ b/model/task.go @@ -111,13 +111,31 @@ type TaskPrivateData struct { // TaskBillingContext 记录任务提交时的计费参数,以便轮询阶段可以重新计算额度。 type TaskBillingContext struct { - ModelPrice float64 `json:"model_price,omitempty"` // 模型单价 - GroupRatio float64 `json:"group_ratio,omitempty"` // 分组倍率 - ModelRatio float64 `json:"model_ratio,omitempty"` // 模型倍率 - OtherRatios map[string]float64 `json:"other_ratios,omitempty"` // 附加倍率(时长、分辨率等) - TaskBilling *types.TaskBillingResult `json:"task_billing,omitempty"` - OriginModelName string `json:"origin_model_name,omitempty"` // 模型名称,必须为OriginModelName - PerCallBilling bool `json:"per_call_billing,omitempty"` // 按次计费:跳过轮询阶段的差额结算 + ModelPrice float64 `json:"model_price,omitempty"` // 模型单价 + GroupRatio float64 `json:"group_ratio,omitempty"` // 分组倍率 + ModelRatio float64 `json:"model_ratio,omitempty"` // 模型倍率 + OtherRatios map[string]float64 `json:"other_ratios,omitempty"` // 附加倍率(时长、分辨率等) + TaskBilling *types.TaskBillingResult `json:"task_billing,omitempty"` + OriginModelName string `json:"origin_model_name,omitempty"` // 模型名称,必须为OriginModelName + PerCallBilling bool `json:"per_call_billing,omitempty"` // 按次计费:跳过轮询阶段的差额结算 + DeltaSettlementDisabled *bool `json:"delta_settlement_disabled,omitempty"` // 渠道关闭完成态差额结算时按提交快照跳过 +} + +func (t *Task) DeltaSettlementDisabledForChannel(channelType int, channelSettings ...dto.ChannelOtherSettings) bool { + if channelType != constant.ChannelTypeDoubaoVideo { + return false + } + if t != nil { + if bc := t.PrivateData.BillingContext; bc != nil && bc.DeltaSettlementDisabled != nil { + return *bc.DeltaSettlementDisabled + } + } + for _, settings := range channelSettings { + if settings.DisableTaskDeltaSettlement { + return true + } + } + return false } // GetUpstreamTaskID 获取上游真实 task ID(用于与 provider 通信) diff --git a/relay/channel/task/taskcommon/path_config_test.go b/relay/channel/task/taskcommon/path_config_test.go index 239a53bd..18e3c4e0 100644 --- a/relay/channel/task/taskcommon/path_config_test.go +++ b/relay/channel/task/taskcommon/path_config_test.go @@ -78,6 +78,28 @@ func TestBuildTaskQueryURLKeepsFallbackWithoutConfig(t *testing.T) { assert.Equal(t, "https://fallback.example.com/task_abc", got) } +func TestParseConfiguredTaskResultReadsUsageTokens(t *testing.T) { + taskInfo, ok, err := ParseConfiguredTaskResult([]byte(`{ + "object": "media.task", + "task_id": "task_123", + "status": "succeeded", + "progress": 100, + "result": { + "url": "https://example.com/result.mp4" + }, + "usage": { + "completion_tokens": 40594, + "total_tokens": 40594 + } + }`), dto.ChannelOtherSettings{TaskProtocol: TaskProtocolGenericVideo}) + + require.NoError(t, err) + require.True(t, ok) + require.NotNil(t, taskInfo) + assert.Equal(t, 40594, taskInfo.CompletionTokens) + assert.Equal(t, 40594, taskInfo.TotalTokens) +} + func TestBuildConfiguredTaskPassThroughBodyUsesChannelPassThrough(t *testing.T) { c := newJSONTaskContext(`{ "model": "local-model", diff --git a/relay/channel/task/taskcommon/protocol_config.go b/relay/channel/task/taskcommon/protocol_config.go index 7e4fb5a4..e9c971e1 100644 --- a/relay/channel/task/taskcommon/protocol_config.go +++ b/relay/channel/task/taskcommon/protocol_config.go @@ -203,14 +203,18 @@ func ParseConfiguredTaskResult(respBody []byte, settings dto.ChannelOtherSetting status = string(model.TaskStatusSuccess) } progress := NormalizeConfiguredProgress(progressRaw, status) + completionTokens := int(gjson.GetBytes(respBody, "usage.completion_tokens").Int()) + totalTokens := int(gjson.GetBytes(respBody, "usage.total_tokens").Int()) return &relaycommon.TaskInfo{ - Code: 0, - TaskID: taskID, - Status: status, - Progress: progress, - Url: resultURL, - Reason: reason, + Code: 0, + TaskID: taskID, + Status: status, + Progress: progress, + Url: resultURL, + Reason: reason, + CompletionTokens: completionTokens, + TotalTokens: totalTokens, }, true, nil } diff --git a/service/task_billing_test.go b/service/task_billing_test.go index ef0e768e..9d309999 100644 --- a/service/task_billing_test.go +++ b/service/task_billing_test.go @@ -9,6 +9,8 @@ import ( "time" "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/dto" "github.com/MAX-API-Next/MAX-API/model" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" "github.com/glebarez/sqlite" @@ -716,7 +718,7 @@ func TestSettle_PerCallBilling_SkipsAdaptorAdjust(t *testing.T) { adaptor := &mockAdaptor{adjustReturn: 2000} taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess} - settleTaskBillingOnComplete(ctx, adaptor, task, taskResult) + settleTaskBillingOnComplete(ctx, adaptor, task, taskResult, 0) // Per-call: no adjustment despite adaptor returning 2000 assert.EqualValues(t, initQuota, getUserQuota(t, userID)) @@ -743,7 +745,7 @@ func TestSettle_PerCallBilling_SkipsTotalTokens(t *testing.T) { adaptor := &mockAdaptor{adjustReturn: 0} taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess, TotalTokens: 9999} - settleTaskBillingOnComplete(ctx, adaptor, task, taskResult) + settleTaskBillingOnComplete(ctx, adaptor, task, taskResult, 0) // Per-call: no recalculation by tokens assert.EqualValues(t, initQuota, getUserQuota(t, userID)) @@ -752,6 +754,118 @@ func TestSettle_PerCallBilling_SkipsTotalTokens(t *testing.T) { assert.Equal(t, int64(0), countLogs(t)) } +func TestSettle_DeltaSettlementDisabledSnapshotSkipsAdjustments(t *testing.T) { + truncate(t) + ctx := context.Background() + + const userID, tokenID, channelID = 34, 34, 34 + const initQuota, preConsumed = 10000, 5000 + const tokenRemain = 8000 + + seedUser(t, userID, initQuota) + seedToken(t, tokenID, userID, "sk-disabled-snapshot", tokenRemain) + seedChannel(t, channelID) + + task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0) + task.PrivateData.BillingContext.DeltaSettlementDisabled = common.GetPointer(true) + + adaptor := &mockAdaptor{adjustReturn: 3000} + taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess, TotalTokens: 9999} + + settleTaskBillingOnComplete(ctx, adaptor, task, taskResult, constant.ChannelTypeDoubaoVideo, dto.ChannelOtherSettings{}) + + assert.EqualValues(t, initQuota, getUserQuota(t, userID)) + assert.Equal(t, tokenRemain, getTokenRemainQuota(t, tokenID)) + assert.Equal(t, preConsumed, task.Quota) + assert.Equal(t, int64(0), countLogs(t)) +} + +func TestSettle_DeltaSettlementDisabledChannelFallbackForLegacyTask(t *testing.T) { + truncate(t) + ctx := context.Background() + + const userID, tokenID, channelID = 35, 35, 35 + const initQuota, preConsumed = 10000, 5000 + const tokenRemain = 8000 + + seedUser(t, userID, initQuota) + seedToken(t, tokenID, userID, "sk-disabled-channel", tokenRemain) + seedChannel(t, channelID) + + task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0) + task.PrivateData.BillingContext.DeltaSettlementDisabled = nil + + adaptor := &mockAdaptor{adjustReturn: 3000} + taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess, TotalTokens: 9999} + + settleTaskBillingOnComplete(ctx, adaptor, task, taskResult, constant.ChannelTypeDoubaoVideo, dto.ChannelOtherSettings{ + DisableTaskDeltaSettlement: true, + }) + + assert.EqualValues(t, initQuota, getUserQuota(t, userID)) + assert.Equal(t, tokenRemain, getTokenRemainQuota(t, tokenID)) + assert.Equal(t, preConsumed, task.Quota) + assert.Equal(t, int64(0), countLogs(t)) +} + +func TestSettle_DeltaSettlementSnapshotOverridesCurrentChannelSetting(t *testing.T) { + truncate(t) + ctx := context.Background() + + const userID, tokenID, channelID = 36, 36, 36 + const initQuota, preConsumed = 10000, 5000 + const adaptorQuota = 3000 + const tokenRemain = 8000 + + seedUser(t, userID, initQuota) + seedToken(t, tokenID, userID, "sk-enabled-snapshot", tokenRemain) + seedChannel(t, channelID) + + task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0) + task.PrivateData.BillingContext.DeltaSettlementDisabled = common.GetPointer(false) + + adaptor := &mockAdaptor{adjustReturn: adaptorQuota} + taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess} + + settleTaskBillingOnComplete(ctx, adaptor, task, taskResult, constant.ChannelTypeDoubaoVideo, dto.ChannelOtherSettings{ + DisableTaskDeltaSettlement: true, + }) + + assert.EqualValues(t, initQuota+(preConsumed-adaptorQuota), getUserQuota(t, userID)) + assert.Equal(t, tokenRemain+(preConsumed-adaptorQuota), getTokenRemainQuota(t, tokenID)) + assert.Equal(t, adaptorQuota, task.Quota) + assert.Equal(t, int64(1), countLogs(t)) +} + +func TestSettle_DeltaSettlementDisabledIgnoredForNonDoubaoVideo(t *testing.T) { + truncate(t) + ctx := context.Background() + + const userID, tokenID, channelID = 37, 37, 37 + const initQuota, preConsumed = 10000, 5000 + const adaptorQuota = 3000 + const tokenRemain = 8000 + + seedUser(t, userID, initQuota) + seedToken(t, tokenID, userID, "sk-disabled-non-doubao", tokenRemain) + seedChannel(t, channelID) + + task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0) + task.PrivateData.BillingContext.DeltaSettlementDisabled = common.GetPointer(true) + + adaptor := &mockAdaptor{adjustReturn: adaptorQuota} + taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess} + + settleTaskBillingOnComplete(ctx, adaptor, task, taskResult, constant.ChannelTypeKling, dto.ChannelOtherSettings{ + DisableTaskDeltaSettlement: true, + }) + + assert.EqualValues(t, initQuota+(preConsumed-adaptorQuota), getUserQuota(t, userID)) + assert.Equal(t, tokenRemain+(preConsumed-adaptorQuota), getTokenRemainQuota(t, tokenID)) + assert.Equal(t, adaptorQuota, task.Quota) + assert.Equal(t, int64(1), countLogs(t)) +} + func TestSettle_NonPerCall_AdaptorAdjustWorks(t *testing.T) { truncate(t) ctx := context.Background() @@ -771,7 +885,7 @@ func TestSettle_NonPerCall_AdaptorAdjustWorks(t *testing.T) { adaptor := &mockAdaptor{adjustReturn: adaptorQuota} taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess} - settleTaskBillingOnComplete(ctx, adaptor, task, taskResult) + settleTaskBillingOnComplete(ctx, adaptor, task, taskResult, 0) // Non-per-call: adaptor adjustment applies (refund 2000) assert.EqualValues(t, initQuota+(preConsumed-adaptorQuota), getUserQuota(t, userID)) diff --git a/service/task_polling.go b/service/task_polling.go index 2745f7be..89a83546 100644 --- a/service/task_polling.go +++ b/service/task_polling.go @@ -501,7 +501,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * } if shouldSettle { - settleTaskBillingOnComplete(ctx, adaptor, task, taskResult) + settleTaskBillingOnComplete(ctx, adaptor, task, taskResult, ch.Type, ch.GetOtherSettings()) } if shouldRefund { RefundTaskQuota(ctx, task, task.FailReason) @@ -563,12 +563,16 @@ func truncateBase64(s string) string { // // 2. taskResult.TotalTokens > 0 → 按 token 重算 // 3. 都不满足 → 保持预扣额度不变 -func settleTaskBillingOnComplete(ctx context.Context, adaptor TaskPollingAdaptor, task *model.Task, taskResult *relaycommon.TaskInfo) { +func settleTaskBillingOnComplete(ctx context.Context, adaptor TaskPollingAdaptor, task *model.Task, taskResult *relaycommon.TaskInfo, channelType int, channelSettings ...dto.ChannelOtherSettings) { // 0. 按次计费的任务不做差额结算 if bc := task.PrivateData.BillingContext; bc != nil && bc.PerCallBilling { logger.LogInfo(ctx, fmt.Sprintf("任务 %s 按次计费,跳过差额结算", task.TaskID)) return } + if task.DeltaSettlementDisabledForChannel(channelType, channelSettings...) { + logger.LogInfo(ctx, fmt.Sprintf("任务 %s 已按渠道设置跳过完成态差额结算", task.TaskID)) + return + } // 1. 优先让 adaptor 决定最终额度 if actualQuota := adaptor.AdjustBillingOnComplete(task, taskResult); actualQuota > 0 { RecalculateTaskQuota(ctx, task, actualQuota, "adaptor计费调整") diff --git a/web/default/package.json b/web/default/package.json index dee04d39..60b7488d 100644 --- a/web/default/package.json +++ b/web/default/package.json @@ -5,8 +5,8 @@ "type": "module", "scripts": { "dev": "rsbuild dev", - "build": "rsbuild build", - "build:check": "tsc -b && rsbuild build", + "build": "rsbuild build && node scripts/verify-tailwind-build.mjs", + "build:check": "tsc -b && rsbuild build && node scripts/verify-tailwind-build.mjs", "typecheck": "tsc -b", "lint": "eslint .", "preview": "rsbuild preview", diff --git a/web/default/scripts/verify-tailwind-build.mjs b/web/default/scripts/verify-tailwind-build.mjs new file mode 100644 index 00000000..c65a7454 --- /dev/null +++ b/web/default/scripts/verify-tailwind-build.mjs @@ -0,0 +1,64 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { readdir, readFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)) +const cssDirectory = path.resolve(scriptDirectory, '../dist/static/css') + +async function findCssFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }) + const files = await Promise.all( + entries.map((entry) => { + const entryPath = path.join(directory, entry.name) + return entry.isDirectory() + ? findCssFiles(entryPath) + : Promise.resolve(entry.name.endsWith('.css') ? [entryPath] : []) + }) + ) + + return files.flat() +} + +const cssFiles = await findCssFiles(cssDirectory) +const css = ( + await Promise.all(cssFiles.map((file) => readFile(file, 'utf8'))) +).join('\n') + +const requiredRules = [ + { + name: 'desktop homepage hero grid', + pattern: + /grid-template-columns\s*:\s*minmax\(0,\s*0?\.94fr\)\s+minmax\(440px,\s*1\.06fr\)/, + }, +] + +const missingRules = requiredRules.filter(({ pattern }) => !pattern.test(css)) + +if (missingRules.length > 0) { + console.error( + `Tailwind build verification failed: missing ${missingRules + .map(({ name }) => name) + .join(', ')}` + ) + process.exit(1) +} + +console.log(`Tailwind build verification passed (${cssFiles.length} CSS files)`) diff --git a/web/default/src/components/data-table/column-header.tsx b/web/default/src/components/data-table/column-header.tsx index 09bd6bfe..a45b14f2 100644 --- a/web/default/src/components/data-table/column-header.tsx +++ b/web/default/src/components/data-table/column-header.tsx @@ -51,7 +51,7 @@ export function DataTableColumnHeader({ } return ( -
+
({ > {title} {column.getIsSorted() === 'desc' ? ( - + ) : column.getIsSorted() === 'asc' ? ( - + ) : ( - + )} diff --git a/web/default/src/components/data-table/data-table-page.tsx b/web/default/src/components/data-table/data-table-page.tsx index 075f16a4..65327117 100644 --- a/web/default/src/components/data-table/data-table-page.tsx +++ b/web/default/src/components/data-table/data-table-page.tsx @@ -342,7 +342,10 @@ function renderDesktop( ({ {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} diff --git a/web/default/src/components/data-table/mobile-card-list.tsx b/web/default/src/components/data-table/mobile-card-list.tsx index d856f7be..cc0cf65d 100644 --- a/web/default/src/components/data-table/mobile-card-list.tsx +++ b/web/default/src/components/data-table/mobile-card-list.tsx @@ -48,6 +48,7 @@ interface MobileColumnMeta { mobileTitle?: boolean mobileBadge?: boolean mobileHidden?: boolean + mobileClassName?: string } function getCellMeta( @@ -150,7 +151,12 @@ function CompactRow({ row }: { row: Row }) {
{renderCellContent(selectCell)}
)} {titleCell && ( -
+
{renderCellContent(titleCell)}
)} @@ -176,7 +182,12 @@ function CompactRow({ row }: { row: Row }) { {label}
)} -
+
{renderCellContent(cell) ?? '-'}
@@ -220,7 +231,13 @@ function FallbackRow({ row }: { row: Row }) { if (!label) { return ( -
+
{content}
) @@ -234,7 +251,12 @@ function FallbackRow({ row }: { row: Row }) { {label} -
+
{content ?? '-'}
@@ -254,6 +276,7 @@ function FallbackRow({ row }: { row: Row }) { * - `mobileTitle` — card header (left, larger text) * - `mobileBadge` — inline with title (right, e.g. status badge) * - `mobileHidden` — hidden on mobile + * - `mobileClassName` — value-container layout for long mobile content * * When mobileTitle or mobileBadge is set on any column, uses a structured * two-tier layout: title+badge header, then 2 key fields side-by-side. diff --git a/web/default/src/components/data-table/table-skeleton.tsx b/web/default/src/components/data-table/table-skeleton.tsx index 8a92b70f..31eed011 100644 --- a/web/default/src/components/data-table/table-skeleton.tsx +++ b/web/default/src/components/data-table/table-skeleton.tsx @@ -66,7 +66,14 @@ export function TableSkeleton({ SKELETON_WIDTHS.length return ( - +
{hasWarnings && ( - +
{rows.map((row) => (
-
{row.label}
+
{t(row.label)}
- {row.description} + {t(row.description)}
-

配置校验

+

+ {t('Configuration validation')} +

{props.issues.length === 0 ? ( - 当前渠道类型未检测到阻断性配置问题。 + {t( + 'No blocking configuration issues were detected for this channel type.' + )} ) : ( @@ -193,12 +214,12 @@ export function ChannelCapabilityMatrix(props: ChannelCapabilityMatrixProps) {
- {issue.message} + {t(issue.message)}
diff --git a/web/default/src/features/channels/components/channels-columns.tsx b/web/default/src/features/channels/components/channels-columns.tsx index 079494e9..f0a8ad81 100644 --- a/web/default/src/features/channels/components/channels-columns.tsx +++ b/web/default/src/features/channels/components/channels-columns.tsx @@ -473,7 +473,12 @@ export function useChannelsColumns(): ColumnDef[] { // Name column { accessorKey: 'name', - meta: { label: t('Name'), mobileTitle: true }, + meta: { + label: t('Name'), + mobileTitle: true, + cellClassName: 'max-w-[28rem] whitespace-normal', + mobileClassName: '[overflow-wrap:anywhere]', + }, header: ({ column }) => ( ), diff --git a/web/default/src/features/channels/components/drawers/channel-editor-state.test.ts b/web/default/src/features/channels/components/drawers/channel-editor-state.test.ts new file mode 100644 index 00000000..92c9fd86 --- /dev/null +++ b/web/default/src/features/channels/components/drawers/channel-editor-state.test.ts @@ -0,0 +1,48 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' +import { getModelMappingGuardrail } from './channel-editor-state' + +describe('model mapping guardrail', () => { + test('reports missing source models and exposed upstream targets once', () => { + const guardrail = getModelMappingGuardrail( + JSON.stringify({ + 'client-model': 'vendor-model', + 'second-client-model': 'vendor-model', + incomplete: '', + }), + ['client-model', 'vendor-model'] + ) + + assert.equal(guardrail.invalidJson, false) + assert.deepEqual(guardrail.entries, [ + { source: 'client-model', target: 'vendor-model' }, + { source: 'second-client-model', target: 'vendor-model' }, + ]) + assert.deepEqual(guardrail.missingSourceModels, ['second-client-model']) + assert.deepEqual(guardrail.exposedTargetModels, ['vendor-model']) + }) + + test('rejects invalid JSON and non-object mappings', () => { + assert.equal(getModelMappingGuardrail('{', []).invalidJson, true) + assert.equal(getModelMappingGuardrail('[]', []).invalidJson, true) + assert.equal(getModelMappingGuardrail('', []).invalidJson, false) + }) +}) diff --git a/web/default/src/features/channels/components/drawers/channel-editor-state.ts b/web/default/src/features/channels/components/drawers/channel-editor-state.ts index 5b4315db..89f6f738 100644 --- a/web/default/src/features/channels/components/drawers/channel-editor-state.ts +++ b/web/default/src/features/channels/components/drawers/channel-editor-state.ts @@ -34,6 +34,59 @@ export const createEmptyModelMappingGuardrail = (): ModelMappingGuardrail => ({ exposedTargetModels: [], }) +export function getModelMappingGuardrail( + modelMapping: string | undefined, + publishedModels: string[] +): ModelMappingGuardrail { + if (!modelMapping?.trim()) { + return createEmptyModelMappingGuardrail() + } + + try { + const parsed: unknown = JSON.parse(modelMapping) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ...createEmptyModelMappingGuardrail(), invalidJson: true } + } + + const entries = Object.entries(parsed).reduce< + Array<{ source: string; target: string }> + >((acc, [rawSource, rawTarget]) => { + const source = String(rawSource).trim() + const target = String(rawTarget ?? '').trim() + + if (source && target) { + acc.push({ source, target }) + } + return acc + }, []) + + const publishedModelSet = new Set(publishedModels) + const missingSourceModels = Array.from( + new Set( + entries + .filter((entry) => !publishedModelSet.has(entry.source)) + .map((entry) => entry.source) + ) + ) + const exposedTargetModels = Array.from( + new Set( + entries + .filter((entry) => publishedModelSet.has(entry.target)) + .map((entry) => entry.target) + ) + ) + + return { + invalidJson: false, + entries, + missingSourceModels, + exposedTargetModels, + } + } catch { + return { ...createEmptyModelMappingGuardrail(), invalidJson: true } + } +} + export const formatModelNames = (models: string[]): string => models.map((model) => `"${model}"`).join(', ') @@ -61,6 +114,7 @@ export const ADVANCED_SETTINGS_SECTION_IDS = { internalNotes: 'channel-section-advanced-internal-notes', overrideRules: 'channel-section-advanced-override-rules', videoTaskProtocol: 'channel-section-advanced-video-task-protocol', + videoTaskBilling: 'channel-section-advanced-video-task-billing', responseMapping: 'channel-section-advanced-response-mapping', fieldPassthrough: 'channel-section-advanced-field-passthrough', extraSettings: 'channel-section-advanced-extra-settings', @@ -95,6 +149,7 @@ export const ADVANCED_ERROR_FIELDS = [ 'upstream_model_update_check_enabled', 'upstream_model_update_auto_sync_enabled', 'upstream_model_update_ignored_models', + 'video_task_delta_settlement_enabled', 'video_task_path_override_enabled', 'video_task_protocol_enabled', 'video_task_submit_path', @@ -153,6 +208,7 @@ export function hasAdvancedSettingsValues(values: ChannelFormValues): boolean { values.pass_through_body_enabled || values.system_prompt_override || values.claude_beta_query || + values.video_task_delta_settlement_enabled === false || values.video_task_path_override_enabled || values.video_task_protocol_enabled || values.upstream_model_update_check_enabled || diff --git a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx index da8d288a..055273db 100644 --- a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -21,18 +21,13 @@ import { type SubmitErrorHandler, useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { useQuery, useQueryClient } from '@tanstack/react-query' import { - ArrowRight, Boxes, ClipboardPaste, - HelpCircle, Loader2, Server, - Sparkles, Trash2, Copy, FileText, - Eraser, - Plus, Eye, Link2, RefreshCw, @@ -55,7 +50,6 @@ import { useHiddenClickUnlock } from '@/hooks/use-hidden-click-unlock' import { Alert, AlertDescription } from '@/components/ui/alert' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' -import { Combobox } from '@/components/ui/combobox' import { Form, FormControl, @@ -74,7 +68,6 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { Separator } from '@/components/ui/separator' import { Sheet, SheetClose, @@ -84,24 +77,16 @@ import { SheetHeader, SheetTitle, } from '@/components/ui/sheet' -import { Skeleton } from '@/components/ui/skeleton' import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/components/ui/tooltip' import { sideDrawerContentClassName, sideDrawerFooterClassName, sideDrawerFormClassName, sideDrawerHeaderClassName, sideDrawerSectionClassName, - sideDrawerSwitchItemClassName, } from '@/components/drawer-layout' import { JsonEditor } from '@/components/json-editor' -import { MultiSelect } from '@/components/multi-select' import { SecureVerificationDialog, useSecureVerification, @@ -118,6 +103,7 @@ import { import { ADD_MODE_OPTIONS, CHANNEL_STATUS_LABELS, + CHANNEL_TYPE_DOUBAO_VIDEO, CHANNEL_TYPE_OPTIONS, CHANNEL_TYPE_WARNINGS, ERROR_MESSAGES, @@ -166,7 +152,6 @@ import { } from '../dialogs/missing-models-confirmation-dialog' import { ParamOverrideEditorDialog } from '../dialogs/param-override-editor-dialog' import { StatusCodeRiskDialog } from '../dialogs/status-code-risk-dialog' -import { ModelMappingEditor } from '../model-mapping-editor' import { CardHeading, ChannelEditorNav, @@ -182,10 +167,7 @@ import { ADVANCED_SETTINGS_SECTION_IDS, CHANNEL_EDITOR_MAIN_SECTION_IDS, CHANNEL_EDITOR_SECTION_IDS, - MODEL_MAPPING_PREVIEW_FALLBACK, UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT, - createEmptyModelMappingGuardrail, - formatModelNames, formatUnixTime, getCompletionStatus, getSectionStatusLabel, @@ -193,7 +175,6 @@ import { hasConfiguredOverrideValue, parseSettingsRecord, readAdvancedSettingsPreference, - type ModelMappingGuardrail, } from './channel-editor-state' import { ChannelAdvancedSection, @@ -241,6 +222,7 @@ export function ChannelMutateDrawer({ >(null) const channelFormRef = useRef(null) const advancedNavScrollPendingRef = useRef(false) + const doubaoVideoAdvancedAutoOpenedRef = useRef(false) const [activeEditorSectionId, setActiveEditorSectionId] = useState( CHANNEL_EDITOR_SECTION_IDS.identity ) @@ -319,7 +301,7 @@ export function ChannelMutateDrawer({ const multiKeyType = form.watch('multi_key_type') const keyMode = form.watch('key_mode') const currentGroups = form.watch('group') - const currentType = form.watch('type') + const currentType = Number(form.watch('type') || 0) const currentBaseUrl = form.watch('base_url') const currentModels = form.watch('models') const currentName = form.watch('name') @@ -333,6 +315,9 @@ export function ChannelMutateDrawer({ 'video_task_path_override_enabled' ) const videoTaskProtocolEnabled = form.watch('video_task_protocol_enabled') + const videoTaskDeltaSettlementEnabled = form.watch( + 'video_task_delta_settlement_enabled' + ) const currentAdvancedCustom = form.watch('advanced_custom') const formSnapshot = form.watch() as ChannelFormValues const currentStatus = formSnapshot.status @@ -367,6 +352,7 @@ export function ChannelMutateDrawer({ const showVideoTaskPathFields = videoTaskPathOverrideEnabled || videoTaskProtocolEnabled const isVideoTaskChannel = VIDEO_TASK_CHANNEL_TYPES.has(currentType) + const isDoubaoVideoChannel = currentType === CHANNEL_TYPE_DOUBAO_VIDEO const { unlocked: doubaoApiEditUnlocked, handleClick: handleApiConfigSecretClick, @@ -597,6 +583,9 @@ export function ChannelMutateDrawer({ const responseMappingConfigured = Boolean( isVideoTaskChannel && videoTaskProtocolEnabled ) + const videoTaskBillingConfigured = Boolean( + isDoubaoVideoChannel && videoTaskDeltaSettlementEnabled === false + ) const extraSettingsConfigured = Boolean( currentForceFormat || currentThinkingToContent || @@ -633,6 +622,7 @@ export function ChannelMutateDrawer({ overrideRulesConfigured || videoTaskProtocolConfigured || responseMappingConfigured || + videoTaskBillingConfigured || fieldPassthroughConfigured || extraSettingsConfigured || upstreamModelDetectionConfigured @@ -660,6 +650,13 @@ export function ChannelMutateDrawer({ title: t('Video Task Protocol'), configured: videoTaskProtocolConfigured, }) + if (isDoubaoVideoChannel) { + advancedNavChildren.push({ + id: ADVANCED_SETTINGS_SECTION_IDS.videoTaskBilling, + title: t('Video Task Billing'), + configured: videoTaskBillingConfigured, + }) + } if (videoTaskProtocolEnabled) { advancedNavChildren.push({ id: ADVANCED_SETTINGS_SECTION_IDS.responseMapping, @@ -736,84 +733,6 @@ export function ChannelMutateDrawer({ [currentModelMapping] ) - // Transform models to multi-select options - const modelOptions = useMemo(() => { - const allModels = new Set([...allModelsList, ...currentModelsArray]) - return Array.from(allModels).map((model) => ({ - value: model, - label: model, - })) - }, [allModelsList, currentModelsArray]) - - const modelMappingGuardrail = useMemo(() => { - if (!currentModelMapping?.trim()) { - return createEmptyModelMappingGuardrail() - } - - try { - const parsed = JSON.parse(currentModelMapping) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return { ...createEmptyModelMappingGuardrail(), invalidJson: true } - } - - const entries = Object.entries(parsed).reduce< - Array<{ source: string; target: string }> - >((acc, [rawSource, rawTarget]) => { - const source = String(rawSource).trim() - const target = String(rawTarget ?? '').trim() - - if (!source || !target) { - return acc - } - - acc.push({ source, target }) - return acc - }, []) - - const missingSourceModels = Array.from( - new Set( - entries - .filter( - (entry) => - Boolean(entry.source) && - !currentModelsArray.includes(entry.source) - ) - .map((entry) => entry.source) - ) - ) - - const exposedTargetModels = Array.from( - new Set( - entries - .filter( - (entry) => - Boolean(entry.target) && - currentModelsArray.includes(entry.target) - ) - .map((entry) => entry.target) - ) - ) - - return { - invalidJson: false, - entries, - missingSourceModels, - exposedTargetModels, - } - } catch { - return { ...createEmptyModelMappingGuardrail(), invalidJson: true } - } - }, [currentModelMapping, currentModelsArray]) - - const mappingPreviewPairs = - modelMappingGuardrail.entries.length > 0 - ? modelMappingGuardrail.entries.slice(0, 3) - : MODEL_MAPPING_PREVIEW_FALLBACK - const remainingMappingCount = - modelMappingGuardrail.entries.length > 3 - ? modelMappingGuardrail.entries.length - 3 - : 0 - const upstreamUpdateMeta = useMemo(() => { const settings = parseSettingsRecord(currentSettings) const detectedModels = Array.isArray( @@ -846,9 +765,16 @@ export function ChannelMutateDrawer({ useEffect(() => { if (isEditing && channelData?.data) { const defaults = transformChannelToFormDefaults(channelData.data) + const shouldOpenAdvancedSettings = + readAdvancedSettingsPreference() || + hasAdvancedSettingsValues(defaults) || + defaults.type === CHANNEL_TYPE_DOUBAO_VIDEO form.reset(defaults) - setAdvancedSettingsOpen( - readAdvancedSettingsPreference() || hasAdvancedSettingsValues(defaults) + setAdvancedSettingsOpen(shouldOpenAdvancedSettings) + setExpandedEditorNavItemId( + shouldOpenAdvancedSettings + ? CHANNEL_EDITOR_SECTION_IDS.advanced + : undefined ) // Store initial values for comparison initialModelsRef.current = parseModelsString( @@ -860,12 +786,27 @@ export function ChannelMutateDrawer({ } else if (!isEditing) { form.reset(CHANNEL_FORM_DEFAULT_VALUES) setAdvancedSettingsOpen(false) + setExpandedEditorNavItemId(undefined) + doubaoVideoAdvancedAutoOpenedRef.current = false initialModelsRef.current = [] initialModelMappingRef.current = '' initialStatusCodeMappingRef.current = '' } }, [isEditing, channelData, form]) + useEffect(() => { + if (currentType !== CHANNEL_TYPE_DOUBAO_VIDEO) { + doubaoVideoAdvancedAutoOpenedRef.current = false + return + } + + if (doubaoVideoAdvancedAutoOpenedRef.current) return + + doubaoVideoAdvancedAutoOpenedRef.current = true + setAdvancedSettingsOpen(true) + setExpandedEditorNavItemId(CHANNEL_EDITOR_SECTION_IDS.advanced) + }, [currentType]) + // Handle type change - set default values for specific types useEffect(() => { if (isEditing) return // Don't auto-set defaults when editing @@ -990,18 +931,6 @@ export function ChannelMutateDrawer({ } }, [channelId, queryClient, t]) - // Unified function to update models - const updateModels = useCallback( - (newModels: string[], merge: boolean = false) => { - const finalModels = merge - ? formatModelsArray([...currentModelsArray, ...newModels]) - : formatModelsArray(newModels) - form.setValue('models', finalModels) - return newModels.length - }, - [currentModelsArray, form] - ) - // Handle fetching models from upstream const handleFetchModels = useCallback(async () => { const type = form.getValues('type') @@ -1035,77 +964,6 @@ export function ChannelMutateDrawer({ throw new Error(response.message || 'No models fetched from upstream') }, [form]) - // Handle model operations - const handleFillRelatedModels = useCallback(() => { - if (!basicModels.length) { - toast.info(t('No related models available for this channel type')) - return - } - updateModels(basicModels) - toast.success( - t('Filled {{count}} related model(s)', { count: basicModels.length }) - ) - }, [basicModels, updateModels, t]) - - const handleFillAllModels = useCallback(() => { - if (!allModelsList.length) { - toast.info(t('No models available')) - return - } - updateModels(allModelsList) - toast.success( - t('Filled {{count}} model(s)', { count: allModelsList.length }) - ) - }, [allModelsList, updateModels, t]) - - const handleClearModels = useCallback(() => { - form.setValue('models', '') - toast.success(t('Cleared all models')) - }, [form, t]) - - const handleCopyModels = useCallback(async () => { - const models = form.getValues('models') - if (!models?.trim()) { - toast.info(t('No models to copy')) - return - } - await copyToClipboard(models) - }, [form, copyToClipboard, t]) - - // Handle adding prefill group models - const handleAddPrefillGroup = useCallback( - (group: { id: number; name: string; items: string | string[] }) => { - try { - const items = Array.isArray(group.items) - ? group.items - : JSON.parse(group.items) - - if (!Array.isArray(items)) { - throw new Error('Invalid items format') - } - - const count = updateModels(items, true) - toast.success( - t('Added {{count}} models from "{{name}}"', { - count, - name: group.name, - }) - ) - } catch { - toast.error(t('Failed to parse group items')) - } - }, - [updateModels, t] - ) - - // Handle model selection change from MultiSelect - const handleModelsChange = useCallback( - (selected: string[]) => { - form.setValue('models', selected.join(',')) - }, - [form] - ) - // Handle successful submission const handleSuccess = useCallback(() => { queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) @@ -1279,6 +1137,8 @@ export function ChannelMutateDrawer({ if (!nextOpen) { advancedNavScrollPendingRef.current = false setExpandedEditorNavItemId(undefined) + } else { + setExpandedEditorNavItemId(CHANNEL_EDITOR_SECTION_IDS.advanced) } setAdvancedSettingsOpen(nextOpen) if (persist && typeof window !== 'undefined') { @@ -1353,7 +1213,9 @@ export function ChannelMutateDrawer({ advancedSettingsOpen ? CHANNEL_EDITOR_SECTION_IDS.advanced : undefined ) } else if (!advancedNavScrollPendingRef.current) { - setExpandedEditorNavItemId(undefined) + setExpandedEditorNavItemId( + advancedSettingsOpen ? CHANNEL_EDITOR_SECTION_IDS.advanced : undefined + ) } }, [advancedSettingsOpen]) @@ -1502,134 +1364,10 @@ export function ChannelMutateDrawer({ id={CHANNEL_EDITOR_SECTION_IDS.identity} className='scroll-mt-4' > - -
- ( - - {t('Name *')} - - - - - - )} - /> - - ( - - {t('Type *')} - - { - const nextType = Number(value) - if ( - Number.isInteger(nextType) && - nextType > 0 - ) { - field.onChange(nextType) - if ( - !isEditing && - nextType !== field.value - ) { - const scopedDefaults = - getChannelTypeScopedFieldDefaults( - nextType - ) - form.setValue( - 'base_url', - scopedDefaults.base_url, - { - shouldDirty: true, - shouldTouch: true, - shouldValidate: true, - } - ) - form.setValue( - 'other', - scopedDefaults.other, - { - shouldDirty: true, - shouldTouch: true, - shouldValidate: true, - } - ) - } - } - }} - placeholder={t('Select channel type')} - searchPlaceholder={t( - 'Search channel type...' - )} - emptyText={t('No channel type found.')} - allowCustomValue - /> - - - - )} - /> -
- - ( - -
- {t('Enabled')} - - {t('Enable or disable this channel')} - -
- - - field.onChange(checked ? 1 : 2) - } - /> - -
- )} - /> - - {currentType === 1 && ( - ( - - - {t('OpenAI Organization')} - - - - - - {t(FIELD_DESCRIPTIONS.OPENAI_ORG)} - - - - )} - /> - )} -
+ - -
-
- ( - -
-
- {t('Models *')} - - {t(FIELD_DESCRIPTIONS.MODELS)} - -
- - {t('Selected {{count}}', { - count: currentModelsArray.length, - })} - -
- - - - {modelMappingGuardrail.exposedTargetModels - .length > 0 && ( - - - - {t('The mapped upstream model(s)')}{' '} - {formatModelNames( - modelMappingGuardrail.exposedTargetModels - )}{' '} - {t( - 'are also listed here. Remove them from Models to keep the `/v1/models` response user-friendly and hide vendor-specific names.' - )} - - - - - )} - -
- )} - /> - - - -
-
-

- {t('Quick actions')} -

-

- {t( - 'Use presets or upstream discovery to populate the model list faster.' - )} -

-
-
- - - {MODEL_FETCHABLE_TYPES.has(currentType) && ( - - )} - - -
- {prefillGroups.length > 0 && ( -
- - {t('Preset groups')}: - - {prefillGroups.map((group) => ( - - ))} -
- )} -
-
- -
- ( - -
-
-
- - {t('Model Mapping')} - - - - } - > - - -

- {t('Request flow')} -

-
- {mappingPreviewPairs.map( - (pair) => ( -
- {pair.source} -
- ) - )} - {remainingMappingCount > 0 && ( -
- +{remainingMappingCount}{' '} - {t('more mapping')} - {remainingMappingCount > 1 - ? 's' - : ''} -
- )} -
-

- {t( - 'Users call the model on the left. The platform forwards the request to the upstream model on the right.' - )} -

-
-
-
- - {t(FIELD_DESCRIPTIONS.MODEL_MAPPING)} - -
-
- - option.value - )} - /> - - {modelMappingGuardrail.invalidJson && ( - - - {t( - 'Model Mapping must be a JSON object like' - )}{' '} - - {'{"gpt-4":"Azure-GPT4"}'} - - {t( - '. Please fix the JSON before saving.' - )} - - - )} - {modelMappingGuardrail.missingSourceModels - .length > 0 && ( - - - - {t('Add')}{' '} - {formatModelNames( - modelMappingGuardrail.missingSourceModels - )}{' '} - {t( - 'to the Models list so users can use them before the mapping sends traffic upstream.' - )} - - - - - )} - -
- )} - /> -
- -
- ( - -
- {t('Groups *')} - - {t(FIELD_DESCRIPTIONS.GROUP)} - -
- - {isLoadingGroups ? ( - - ) : ( - - )} - - -
- )} - /> -
-
-
+
)} + {isDoubaoVideoChannel && ( +
+ } + /> +
+ ( + +
+ + {t('Completion delta settlement')} + + + {t( + 'Recalculate successful DoubaoVideo tasks from upstream usage tokens when polling completes; turn this off only for emergency containment because successful tasks will keep the pre-consumed quota.' + )} + +
+ + + +
+ )} + /> +
+
+ )} {isVideoTaskChannel && videoTaskProtocolEnabled && (
{ - form.setValue('models', formatModelsArray(models)) + form.setValue('models', formatModelsArray(models), { + shouldDirty: true, + shouldValidate: true, + }) }} redirectModels={redirectModelList} redirectSourceModels={redirectModelKeyList} diff --git a/web/default/src/features/channels/components/drawers/sections/channel-auth-section.tsx b/web/default/src/features/channels/components/drawers/sections/channel-auth-section.tsx index ab056593..6af709d3 100644 --- a/web/default/src/features/channels/components/drawers/sections/channel-auth-section.tsx +++ b/web/default/src/features/channels/components/drawers/sections/channel-auth-section.tsx @@ -34,7 +34,7 @@ export function ChannelAuthSection(props: ChannelAuthSectionProps) { className='text-muted-foreground h-3.5 w-3.5' aria-hidden='true' /> -

+

{t('Authentication')}

diff --git a/web/default/src/features/channels/components/drawers/sections/channel-basic-section.tsx b/web/default/src/features/channels/components/drawers/sections/channel-basic-section.tsx index 63af16de..eb126712 100644 --- a/web/default/src/features/channels/components/drawers/sections/channel-basic-section.tsx +++ b/web/default/src/features/channels/components/drawers/sections/channel-basic-section.tsx @@ -16,20 +16,41 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import type { ReactNode } from 'react' +import { useFormContext, useWatch } from 'react-hook-form' import { Server } from 'lucide-react' import { useTranslation } from 'react-i18next' +import { Combobox } from '@/components/ui/combobox' +import type { ComboboxInputOption } from '@/components/ui/combobox-input' +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' import { SideDrawerSection, SideDrawerSectionHeader, + sideDrawerSwitchItemClassName, } from '@/components/drawer-layout' +import { FIELD_DESCRIPTIONS, FIELD_PLACEHOLDERS } from '../../../constants' +import { + getChannelTypeScopedFieldDefaults, + type ChannelFormValues, +} from '../../../lib' type ChannelBasicSectionProps = { - children: ReactNode + channelTypeOptions: ComboboxInputOption[] + isEditing: boolean } export function ChannelBasicSection(props: ChannelBasicSectionProps) { const { t } = useTranslation() + const form = useFormContext() + const currentType = useWatch({ control: form.control, name: 'type' }) return ( @@ -38,7 +59,103 @@ export function ChannelBasicSection(props: ChannelBasicSectionProps) { description={t('Name, provider type, and availability.')} icon={ ) } diff --git a/web/default/src/features/channels/components/drawers/sections/channel-models-section.test.tsx b/web/default/src/features/channels/components/drawers/sections/channel-models-section.test.tsx new file mode 100644 index 00000000..05abd5b4 --- /dev/null +++ b/web/default/src/features/channels/components/drawers/sections/channel-models-section.test.tsx @@ -0,0 +1,94 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { useForm } from 'react-hook-form' +import i18n from 'i18next' +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' +import { renderToStaticMarkup } from 'react-dom/server' +import { I18nextProvider } from 'react-i18next' +import { Form } from '@/components/ui/form' +import { + CHANNEL_FORM_DEFAULT_VALUES, + type ChannelFormValues, +} from '../../../lib' +import { ChannelModelsSection } from './channel-models-section' + +i18n.init({ + lng: 'en', + fallbackLng: 'en', + resources: { en: { translation: {} } }, + interpolation: { escapeValue: false }, +}) + +const selectedModels = Array.from( + { length: 8 }, + (_, index) => `long-vendor-model-${index + 1}` +) + +function ChannelModelsSectionFixture() { + const form = useForm({ + defaultValues: { + ...CHANNEL_FORM_DEFAULT_VALUES, + models: selectedModels.join(','), + model_mapping: JSON.stringify({ + 'public-model': selectedModels[0], + }), + }, + }) + + return ( +
+ undefined} + prefillGroups={[]} + /> + + ) +} + +describe('ChannelModelsSection', () => { + test('caps selected chips while preserving the complete model count', () => { + const markup = renderToStaticMarkup( + + + + ) + + assert.match(markup, /Selected 8/) + assert.match(markup, /\+2 more/) + assert.doesNotMatch(markup, />long-vendor-model-7long-vendor-model-8 { + const markup = renderToStaticMarkup( + + + + ) + + assert.match(markup, /\[overflow-wrap:anywhere\]/) + assert.match(markup, /aria-describedby="[^"]+-form-item-description"/) + }) +}) diff --git a/web/default/src/features/channels/components/drawers/sections/channel-models-section.tsx b/web/default/src/features/channels/components/drawers/sections/channel-models-section.tsx index f68388e7..88a0085f 100644 --- a/web/default/src/features/channels/components/drawers/sections/channel-models-section.tsx +++ b/web/default/src/features/channels/components/drawers/sections/channel-models-section.tsx @@ -16,20 +16,141 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import type { ReactNode } from 'react' -import { Boxes } from 'lucide-react' +import { useCallback, useMemo } from 'react' +import { useFormContext, useWatch } from 'react-hook-form' +import { + ArrowRight, + Boxes, + Copy, + Eraser, + FileText, + HelpCircle, + Plus, + Sparkles, +} from 'lucide-react' import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Separator } from '@/components/ui/separator' +import { Skeleton } from '@/components/ui/skeleton' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' import { SideDrawerSection, SideDrawerSectionHeader, } from '@/components/drawer-layout' +import { MultiSelect, type Option } from '@/components/multi-select' +import { + FIELD_DESCRIPTIONS, + FIELD_PLACEHOLDERS, + MODEL_FETCHABLE_TYPES, +} from '../../../constants' +import { + formatModelsArray, + parseModelsString, + type ChannelFormValues, +} from '../../../lib' +import { ModelMappingEditor } from '../../model-mapping-editor' +import { + MODEL_MAPPING_PREVIEW_FALLBACK, + formatModelNames, + getModelMappingGuardrail, +} from '../channel-editor-state' + +export type ChannelModelPrefillGroup = { + id: number + name: string + items: string | string[] +} type ChannelModelsSectionProps = { - children: ReactNode + allModels: string[] + basicModels: string[] + groupOptions: Option[] + isLoadingGroups: boolean + isSubmitting: boolean + onFetchModels: () => void + prefillGroups: ChannelModelPrefillGroup[] } export function ChannelModelsSection(props: ChannelModelsSectionProps) { const { t } = useTranslation() + const form = useFormContext() + const { copyToClipboard } = useCopyToClipboard() + const currentType = useWatch({ control: form.control, name: 'type' }) + const currentModels = useWatch({ control: form.control, name: 'models' }) + const currentModelMapping = useWatch({ + control: form.control, + name: 'model_mapping', + }) + + const currentModelsArray = useMemo( + () => parseModelsString(currentModels), + [currentModels] + ) + const modelOptions = useMemo(() => { + const models = new Set([...props.allModels, ...currentModelsArray]) + return Array.from(models).map((model) => ({ value: model, label: model })) + }, [props.allModels, currentModelsArray]) + const modelMappingGuardrail = useMemo( + () => getModelMappingGuardrail(currentModelMapping, currentModelsArray), + [currentModelMapping, currentModelsArray] + ) + const mappingPreviewPairs = + modelMappingGuardrail.entries.length > 0 + ? modelMappingGuardrail.entries.slice(0, 3) + : MODEL_MAPPING_PREVIEW_FALLBACK + const remainingMappingCount = Math.max( + modelMappingGuardrail.entries.length - mappingPreviewPairs.length, + 0 + ) + + const updateModels = useCallback( + (newModels: string[], merge = false) => { + const finalModels = merge + ? formatModelsArray([...currentModelsArray, ...newModels]) + : formatModelsArray(newModels) + form.setValue('models', finalModels, { + shouldDirty: true, + shouldValidate: true, + }) + return newModels.length + }, + [currentModelsArray, form] + ) + + const handleAddPrefillGroup = (group: ChannelModelPrefillGroup) => { + try { + const items = Array.isArray(group.items) + ? group.items + : JSON.parse(group.items) + if (!Array.isArray(items)) throw new Error('Invalid items format') + + const count = updateModels(items, true) + toast.success( + t('Added {{count}} models from "{{name}}"', { + count, + name: group.name, + }) + ) + } catch { + toast.error(t('Failed to parse group items')) + } + } return ( @@ -38,7 +159,338 @@ export function ChannelModelsSection(props: ChannelModelsSectionProps) { description={t('Published models, groups, and model remapping rules.')} icon={ ) } diff --git a/web/default/src/features/channels/components/model-mapping-editor.tsx b/web/default/src/features/channels/components/model-mapping-editor.tsx index b785d5ae..0f80d823 100644 --- a/web/default/src/features/channels/components/model-mapping-editor.tsx +++ b/web/default/src/features/channels/components/model-mapping-editor.tsx @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useId, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { Code, Plus, Table, Trash2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { cn } from '@/lib/utils' @@ -70,68 +70,71 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) { const nextRowIdRef = useRef(0) const duplicateSources = useMemo(() => getDuplicateSources(rows), [rows]) - const createRowId = () => { + const createRowId = useCallback(() => { nextRowIdRef.current += 1 return `mapping-${nextRowIdRef.current}` - } + }, []) - const parseJsonToRows = (json: string): boolean => { - try { - if (!json.trim()) { - setRows([]) - setJsonError(null) - return true - } - const parsed = JSON.parse(json) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - setJsonError(t('Model mapping must be a valid JSON object')) - return false - } - const entries = Object.entries(parsed) - const invalidValue = entries.find(([, to]) => typeof to !== 'string') - if (invalidValue) { - setJsonError(t('Model mapping values must be strings')) - return false - } - setRows((previousRows) => { - const remainingRows = [...previousRows] - return entries.map(([from, to], index) => { - const toString = String(to) - const existingIndex = remainingRows.findIndex( - (row) => - row.from === from || - (row.from === from && row.to === toString) || - previousRows[index]?.id === row.id - ) - if (existingIndex >= 0) { - const [existing] = remainingRows.splice(existingIndex, 1) + const parseJsonToRows = useCallback( + (json: string): boolean => { + try { + if (!json.trim()) { + setRows([]) + setJsonError(null) + return true + } + const parsed = JSON.parse(json) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + setJsonError(t('Model mapping must be a valid JSON object')) + return false + } + const entries = Object.entries(parsed) + const invalidValue = entries.find(([, to]) => typeof to !== 'string') + if (invalidValue) { + setJsonError(t('Model mapping values must be strings')) + return false + } + setRows((previousRows) => { + const remainingRows = [...previousRows] + return entries.map(([from, to], index) => { + const toString = String(to) + const existingIndex = remainingRows.findIndex( + (row) => + row.from === from || + (row.from === from && row.to === toString) || + previousRows[index]?.id === row.id + ) + if (existingIndex >= 0) { + const [existing] = remainingRows.splice(existingIndex, 1) + return { + id: existing.id, + from, + to: toString, + } + } return { - id: existing.id, + id: createRowId(), from, to: toString, } - } - return { - id: createRowId(), - from, - to: toString, - } + }) }) - }) - setJsonError(null) - return true - } catch (_error) { - setJsonError(t('Model mapping must be valid JSON format')) - return false - } - } + setJsonError(null) + return true + } catch (_error) { + setJsonError(t('Model mapping must be valid JSON format')) + return false + } + }, + [createRowId, t] + ) // Parse JSON to rows when value changes externally useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect setJsonValue(props.value) parseJsonToRows(props.value) - }, [props.value]) + }, [parseJsonToRows, props.value]) const convertRowsToJson = (updatedRows: MappingRow[]): string => { if (updatedRows.length === 0) { @@ -222,7 +225,7 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) { return (
-
+
@@ -264,7 +267,7 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) { {rows.length > 0 ? (
-
+
{t('Original Model')}
{t('Replacement Model')}
@@ -272,9 +275,10 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) { {rows.map((row) => (
handleRowChange(row.id, 'from', e.target.value) @@ -282,6 +286,7 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) { placeholder='gpt-3.5-turbo' disabled={props.disabled} list={sourceListId} + aria-label={t('Original Model')} /> -
- ) -} - -// --------------------------------------------------------------------------- -// Price input field -// --------------------------------------------------------------------------- - -type PriceFieldProps = { - label: string - hint?: string - value: number - onChange: (next: number) => void -} - -function PriceField({ label, hint, value, onChange }: PriceFieldProps) { - return ( -
- - - {hint &&

{hint}

} -
- ) -} +import { RawExprEditor, VisualEditor } from './tiered-pricing-visual-editor' // --------------------------------------------------------------------------- -// Single tier card (visual editor) +// Main editor // --------------------------------------------------------------------------- -type VisualTierCardProps = { - tier: VisualTier - index: number - total: number - onChange: (next: VisualTier) => void - onRemove: () => void - onAddCondition: () => void +export type TieredPricingEditorProps = { + modelName?: string + modelIdentity?: string + billingExpr: string + requestRuleExpr: string + onBillingExprChange: (next: string) => void + onRequestRuleExprChange: (next: string) => void } -function VisualTierCard({ - tier, - index, - total, - onChange, - onRemove, - onAddCondition, -}: VisualTierCardProps) { - const { t } = useTranslation() - const cacheMode = getTierCacheMode(tier) - - const handleConditionChange = ( - conditionIndex: number, - next: TierConditionInput - ) => { - const conditions = [...tier.conditions] - conditions[conditionIndex] = next - onChange({ ...tier, conditions }) - } - - const handleConditionRemove = (conditionIndex: number) => { - onChange({ - ...tier, - conditions: tier.conditions.filter((_, i) => i !== conditionIndex), - }) - } - - const handlePriceChange = (field: keyof VisualTier, value: number) => { - onChange({ ...tier, [field]: value }) - } - - const handleCacheModeChange = (mode: CacheMode) => { - onChange({ - ...tier, - cache_mode: mode, - cache_create_1h_unit_cost: - mode === CACHE_MODE_TIMED ? (tier.cache_create_1h_unit_cost ?? 0) : 0, - }) - } - - const inputUnitPrice = unitCostToPrice(tier.input_unit_cost) - const outputUnitPrice = unitCostToPrice(tier.output_unit_cost) - const hasMediaPricing = MEDIA_PRICE_VARS.some((variable) => { - const fieldKey = variable.tierField as keyof VisualTier - return unitCostToPrice((tier[fieldKey] as number | undefined) ?? 0) > 0 - }) - const [mediaOpen, setMediaOpen] = useState(hasMediaPricing) - - useEffect(() => { - // Reveal newly configured media prices when controlled data changes. - // eslint-disable-next-line react-hooks/set-state-in-effect - if (hasMediaPricing) setMediaOpen(true) - }, [hasMediaPricing]) - - const renderPriceVariable = ( - variable: (typeof BILLING_EXTRA_VARS)[number] - ) => { - const fieldKey = variable.tierField as keyof VisualTier - const value = unitCostToPrice((tier[fieldKey] as number | undefined) ?? 0) - - return ( - handlePriceChange(fieldKey, priceToUnitCost(next))} - /> - ) - } - - return ( -
-
-
- - {t('Tier')} {index + 1} / {total} - - {tier.conditions.length === 0 && ( - {t('Fallback tier')} - )} - - onChange({ ...tier, label: event.target.value }) - } - placeholder={t('Tier name')} - className='h-7 w-36' - /> -
- -
- - {/* Conditions */} -
-
- - -
- {tier.conditions.length === 0 ? ( -

- {t('Always matches (default tier).')} -

- ) : ( - tier.conditions.map((condition, conditionIndex) => ( - handleConditionChange(conditionIndex, next)} - onRemove={() => handleConditionRemove(conditionIndex)} - /> - )) - )} -
- -
-
- - - {PRICE_SUFFIX} - -
- -
-
- - handlePriceChange('input_unit_cost', priceToUnitCost(value)) - } - /> - - handlePriceChange('output_unit_cost', priceToUnitCost(value)) - } - /> -
- -
-
- - value !== null && handleCacheModeChange(value as CacheMode) - } - > - - - {t('Generic cache')} - - - {t('Time-sliced cache (Claude)')} - - - -
-
- {CACHE_PRICE_VARS.map((variable) => { - if (variable.key === 'cc1h' && cacheMode !== CACHE_MODE_TIMED) { - return null - } - return renderPriceVariable(variable) - })} -
-
-
-
+type EditorMode = 'visual' | 'raw' - {/* Media prices */} -
- - {mediaOpen && ( -
- {MEDIA_PRICE_VARS.map(renderPriceVariable)} -
- )} -
-
- ) +type RawExprFallback = { + rawExpr: string + billingExpr: string + requestRuleExpr: string } -// --------------------------------------------------------------------------- -// Visual editor (list of tiers) -// --------------------------------------------------------------------------- - -type VisualEditorProps = { +type InitialEditorState = { + editorMode: EditorMode visualConfig: VisualConfig | null - onChange: (next: VisualConfig) => void + rawExpr: string + requestRuleGroups: RequestRuleGroup[] + requestRulesCompatible: boolean + rawExprFallback: RawExprFallback | null } -function VisualEditor({ visualConfig, onChange }: VisualEditorProps) { - const { t } = useTranslation() - const config = useMemo( - () => normalizeVisualConfig(visualConfig), - [visualConfig] - ) - - const handleTierChange = (index: number, next: VisualTier) => { - const tiers = [...config.tiers] - tiers[index] = normalizeVisualTier(next) - onChange({ ...config, tiers }) - } - - const handleAddTier = () => { - const tiers = [...config.tiers] - const lastIndex = tiers.length - 1 - // When adding a new fallback, give the previous catch-all tier a default - // upper-bound condition so the expression compiles into a sane two-tier - // shape. Mirrors the legacy editor UX for adding tiers. - if (lastIndex >= 0 && tiers[lastIndex].conditions.length === 0) { - tiers[lastIndex] = normalizeVisualTier({ - ...tiers[lastIndex], - conditions: [{ var: 'len', op: '<', value: 200000 }], - }) - } - tiers.push( - normalizeVisualTier({ - label: `tier_${tiers.length + 1}`, - conditions: [], - input_unit_cost: 0, - output_unit_cost: 0, - }) - ) - onChange({ ...config, tiers }) - } - - const handleRemoveTier = (index: number) => { - const tiers = config.tiers.filter((_, i) => i !== index) - onChange({ ...config, tiers: tiers.length > 0 ? tiers : config.tiers }) - } - - const handleAddCondition = (index: number) => { - const tier = config.tiers[index] - if (tier.conditions.length >= 2) return - // Prefer `len` (input length) over `p`/`c` for tier conditions because - // `p` is subject to auto-exclusion when sub-categories like `cr` are - // priced separately, which can misroute long-input requests into shorter - // tiers when cache-hits reduce the effective `p`. - const usedVars = new Set(tier.conditions.map((c) => c.var)) - const nextVar: TierConditionInput['var'] = usedVars.has('len') ? 'c' : 'len' - onChange({ - ...config, - tiers: config.tiers.map((current, i) => - i === index - ? { - ...current, - conditions: [ - ...tier.conditions, - { var: nextVar, op: '<', value: 200000 }, - ], - } - : current - ), - }) +function createInitialEditorState( + billingExpr: string, + requestRuleExpr: string +): InitialEditorState { + const parsedConfig = tryParseVisualConfig(billingExpr) + const parsedRequestRuleGroups = tryParseRequestRuleExpr(requestRuleExpr) + const rawExpr = combineBillingExpr(billingExpr || '', requestRuleExpr || '') + return { + editorMode: parsedConfig || !billingExpr ? 'visual' : 'raw', + visualConfig: + parsedConfig || (!billingExpr ? createDefaultVisualConfig() : null), + rawExpr, + requestRuleGroups: parsedRequestRuleGroups ?? [], + requestRulesCompatible: parsedRequestRuleGroups !== null, + rawExprFallback: + parsedRequestRuleGroups === null && billingExpr && requestRuleExpr + ? { rawExpr, billingExpr, requestRuleExpr } + : null, } - - return ( -
-

- {t( - 'Each tier supports up to 2 conditions. The last tier without conditions is the fallback.' - )} -

- {config.tiers.map((tier, index) => ( - handleTierChange(index, next)} - onRemove={() => handleRemoveTier(index)} - onAddCondition={() => handleAddCondition(index)} - /> - ))} - -
- ) -} - -// --------------------------------------------------------------------------- -// Raw expression editor -// --------------------------------------------------------------------------- - -type RawExprEditorProps = { - exprString: string - onChange: (value: string) => void -} - -function RawExprEditor({ exprString, onChange }: RawExprEditorProps) { - const { t } = useTranslation() - return ( -
- - -
- {t('Variables')}: len, p, c,{' '} - cr, cc, cc1h,{' '} - img, img_o, ai,{' '} - ao -
-
- {t('Functions')}: tier(name, value), max,{' '} - min, ceil, floor,{' '} - abs, header(name),{' '} - param(path), has(source, text) -
-
-
-