Files
ttstd-infinite-canvas/backend/internal/app/channel_model_upstream_rename_test.go
VicoHu c995ad901e fix(models): 系统渠道模型 - 上游 ID 重命名级联价格档并补管理端可见性
根因:价格档 provider_model_key 非空时优先于模型级上游键发往供应商,统一价格档曾把当时的顶层上游键固化为档位显式值,模型级重命名后被旧值静默遮蔽,任务请求继续发往旧上游模型(Agent 及其他入口共用该解析路径)。

- SaveAdminChannelModel 在模型级上游键变更时,把与旧值相同的档位键级联为新值;管理员显式配置的其他上游 SKU 不受影响
- 编辑无价格档旧模型不再预填顶层上游键;统一价格档检测到与模型级不一致的存量上游键时展示告警并支持一键清除恢复跟随模型默认;模型身份区提示档位独立上游键数量,比较口径统一剥 models/ 前缀
- 新增后端级联与请求解析回归测试、前端价格档表单单测
- 同步 backend-database.mdx 价格档语义与 pending-test.mdx 待验收项
2026-09-16 13:19:11 +08:00

108 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"testing"
"infinite-canvas/backend/internal/model"
)
// 模型级上游键重命名后,任务请求必须使用新键;价格档中与旧值相同的上游键属于
// “跟随模型默认”的隐式固化,保存时必须级联跟随(见 SaveAdminChannelModel)。
func upstreamRenameFixture(t *testing.T) (*Service, *model.User, *model.ModelChannel) {
t.Helper()
svc, db := newChannelModelTestService(t)
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin}
channel := model.ModelChannel{ID: "channel-1", UserID: admin.ID, Scope: model.ChannelScopeSystem, Enabled: true, Name: "model-gateway", BaseURL: "https://example.com/v1", APIKey: "key", APIFormat: "openai", ModelsJSON: `[]`}
if err := db.Create(&channel).Error; err != nil {
t.Fatal(err)
}
return svc, admin, &channel
}
func TestSaveAdminChannelModelCascadesUpstreamKeyToMatchingTiers(t *testing.T) {
svc, admin, channel := upstreamRenameFixture(t)
enabled := true
first, err := svc.SaveAdminChannelModel(admin, channel.ID, "", ChannelModelRequest{
ModelKey: "deepseek-v4-flash", ProviderModelKey: "deepseek-v4-flash", DisplayName: "deepseek-v4-flash",
Capability: "text", Protocol: string(model.ChannelInterfaceChatCompletion),
CapabilityConfig: DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceChatCompletion), "deepseek-v4-flash"),
PriceTiers: []ChannelModelPriceTierRequest{
{Selector: map[string]string{}, ProviderModelKey: "deepseek-v4-flash", BillingMode: "fixed_request", PriceConfigured: true, Enabled: &enabled},
{Selector: map[string]string{"operation": "text_generation"}, ProviderModelKey: "deepseek-v4-flash-preview", BillingMode: "fixed_request", PriceConfigured: true, Enabled: &enabled},
},
Enabled: &enabled,
})
if err != nil {
t.Fatalf("initial save failed: %v", err)
}
// 管理端表单会把已存档位键原样回传(统一价格档的上游键字段不可见),重命名顶层上游键。
renamed, err := svc.SaveAdminChannelModel(admin, channel.ID, first.ID, ChannelModelRequest{
ModelKey: "deepseek-v4.1-flash", ProviderModelKey: "deepseek-v4.1-flash", DisplayName: "deepseek-v4.1-flash",
Capability: "text", Protocol: string(model.ChannelInterfaceChatCompletion),
CapabilityConfig: DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceChatCompletion), "deepseek-v4.1-flash"),
PriceTiers: []ChannelModelPriceTierRequest{
{Selector: map[string]string{}, ProviderModelKey: "deepseek-v4-flash", BillingMode: "fixed_request", PriceConfigured: true, Enabled: &enabled},
{Selector: map[string]string{"operation": "text_generation"}, ProviderModelKey: "deepseek-v4-flash-preview", BillingMode: "fixed_request", PriceConfigured: true, Enabled: &enabled},
},
Enabled: &enabled,
})
if err != nil {
t.Fatalf("rename save failed: %v", err)
}
if len(renamed.PriceTiers) != 2 {
t.Fatalf("price tiers = %#v, want 2", renamed.PriceTiers)
}
for _, tier := range renamed.PriceTiers {
if tier.ProviderModelKey == "deepseek-v4-flash" {
t.Fatalf("tier %s still carries the retired upstream key deepseek-v4-flash; it must follow the model rename", tier.ID)
}
}
// 管理员显式配置的其他上游 SKU 不能被级联覆盖。
keptDistinct := false
for _, tier := range renamed.PriceTiers {
if tier.ProviderModelKey == "deepseek-v4-flash-preview" {
keptDistinct = true
}
}
if !keptDistinct {
t.Fatalf("distinct tier upstream key must survive the rename: %#v", renamed.PriceTiers)
}
}
func TestRenamedUpstreamKeyReachesSystemChannelRequests(t *testing.T) {
svc, admin, channel := upstreamRenameFixture(t)
enabled := true
first, err := svc.SaveAdminChannelModel(admin, channel.ID, "", ChannelModelRequest{
ModelKey: "deepseek-v4-flash", ProviderModelKey: "deepseek-v4-flash", DisplayName: "deepseek-v4-flash",
Capability: "text", Protocol: string(model.ChannelInterfaceChatCompletion),
CapabilityConfig: DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceChatCompletion), "deepseek-v4-flash"),
PriceTiers: []ChannelModelPriceTierRequest{{ProviderModelKey: "deepseek-v4-flash", BillingMode: "fixed_request", PriceConfigured: true, Enabled: &enabled}},
Enabled: &enabled,
})
if err != nil {
t.Fatalf("initial save failed: %v", err)
}
if _, err := svc.SaveAdminChannelModel(admin, channel.ID, first.ID, ChannelModelRequest{
ModelKey: "deepseek-v4.1-flash", ProviderModelKey: "deepseek-v4.1-flash", DisplayName: "deepseek-v4.1-flash",
Capability: "text", Protocol: string(model.ChannelInterfaceChatCompletion),
CapabilityConfig: DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceChatCompletion), "deepseek-v4.1-flash"),
PriceTiers: []ChannelModelPriceTierRequest{{ProviderModelKey: "deepseek-v4-flash", BillingMode: "fixed_request", PriceConfigured: true, Enabled: &enabled}},
Enabled: &enabled,
}); err != nil {
t.Fatalf("rename save failed: %v", err)
}
// 用户在画布 Agent 选中新模型发起任务;config 形状与 cloud_agent.go 组装一致。
resolved, err := svc.resolveSystemChannelModelSelection(map[string]any{
"config": map[string]any{"channelId": channel.ID, "model": "deepseek-v4.1-flash", "channelModelKey": "deepseek-v4.1-flash"},
}, "canvas_text", "")
if err != nil {
t.Fatalf("resolveSystemChannelModelSelection() error = %v", err)
}
config := resolved["config"].(map[string]any)
if got := config["providerModelKey"]; got != "deepseek-v4.1-flash" {
t.Fatalf("providerModelKey = %v, want deepseek-v4.1-flash (user-selected model must reach the upstream request)", got)
}
}