fix(provision): preserve channel model mapping on import
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# sub2api-cn-relay-manager 执行板
|
||||
|
||||
日期:2026-05-19
|
||||
当前 Gate:CONDITIONAL_APPROVED(代码门禁通过;2026-05-18 fresh redeploy 验证确认 self_service / subscription 访问链路可打通,但 2026-05-19 current-code remote43 DeepSeek 验收仍未闭环,失败根因已收敛为上游 key/模型能力与预期不匹配)
|
||||
当前 Gate:CONDITIONAL_APPROVED(代码门禁通过;2026-05-18 fresh redeploy 验证确认 self_service / subscription 访问链路可打通;2026-05-19 current-code remote43 追踪后发现 DeepSeek/MiniMax 的 channel 创建请求漏传 model_mapping / restrict_models / billing_model_source,已补代码与测试,但真实宿主 access gate 仍需重新验收)
|
||||
目标:实现独立控制面、零侵入宿主、可导入国产模型并具备可运维的导入/回滚/访问闭环。
|
||||
|
||||
## 本轮已完成
|
||||
@@ -36,7 +36,7 @@
|
||||
9. current-code remote43 导入链路已补齐 tunnel-aware 验证能力
|
||||
- `scripts/import_remote43_provider.sh` 新增 `CRM_HOST_BASE`,允许把“operator 访问 host 地址”和“CRM 进程访问 host 地址”分离
|
||||
- latest artifact:`/home/long/artifacts/real-host-acceptance/20260519_195827_remote43_deepseek_key_import`
|
||||
- 结论:import / batch detail / managed resources 已真实落库,但 DeepSeek subscription access gate 仍失败
|
||||
- 结论:import / batch detail / managed resources 已真实落库;本轮定位到 channel 创建缺少 model_mapping / restrict_models / billing_model_source,已补齐实现与测试,待重新跑真实宿主验收
|
||||
|
||||
## 已验证门禁
|
||||
|
||||
@@ -82,9 +82,10 @@
|
||||
|
||||
## 剩余项(含当前外部门禁)
|
||||
|
||||
1. DeepSeek real-host access gate 仍未闭环(外部门禁)
|
||||
- 需要满足 smoke model 的真实 key / group 绑定,确保普通用户 `/v1/models` 暴露 DeepSeek 目标模型而不是 GPT 系模型
|
||||
- 在 current-code remote43 路径上,这一项仍是上线前 NO-go 条件
|
||||
1. DeepSeek / MiniMax real-host access gate 仍需复验(外部门禁)
|
||||
- 真实宿主曾出现普通用户 `/v1/models` 暴露 GPT 系模型的漂移;本轮已补齐 channel 侧 model_mapping / restrict_models / billing_model_source 传参
|
||||
- 53hk 中转 key 当前未验证可用,不能当作主结论
|
||||
- 在 current-code remote43 路径上,这一项仍需重新跑真实验收
|
||||
2. 结构债务仍存在
|
||||
- access / reconcile 尚未完全按 implementation plan 物理拆分
|
||||
- 无内置 scheduler/jobs
|
||||
|
||||
@@ -180,4 +180,4 @@ SKIP_ROLLBACK=1 scripts/real_host_acceptance.sh
|
||||
9. 若需要验证 `reconcile` 收敛,优先在干净宿主场景或隔离 group 下执行,避免历史残留资源把结果污染成 `status=drifted` / `extra_count>0`。
|
||||
10. `scripts/import_remote43_provider.sh` 现已内置 remote43 的 subscription 验收补全动作:会根据 import batch 自动解析目标 group,执行“普通用户最低余额补齐 + key/group 绑定 + user_subscriptions upsert + 定向 Redis 缓存失效(auth / balance / subscription)”,并把 SQL / host state 证据写入 artifact 目录。
|
||||
11. 当 CRM 进程与 operator 到 host 的访问地址不一致时,优先显式设置 `CRM_HOST_BASE`,避免把 CRM 侧探测地址和本地运维隧道地址混用。
|
||||
12. 对 `Upstream service temporarily unavailable` 一类 502,不要先认定是上游聊天链路故障;先看脚本落盘的 `09-models.headers.txt` / `10-models.body.json`。若 `/v1/models` 已返回了别的 provider 模型集(例如 GPT 系列而不是预期的 DeepSeek/Minimax 模型),应优先归类为“普通用户 key/group 绑定漂移或命中了错误 group”,而不是 reopen provider 路由问题。
|
||||
12. 对 `Upstream service temporarily unavailable` 一类 502,不要先认定是上游聊天链路故障;先看脚本落盘的 `09-models.headers.txt` / `10-models.body.json`。若 `/v1/models` 已返回了别的 provider 模型集(例如 GPT 系列而不是预期的 DeepSeek/Minimax 模型),先检查普通用户 key/group 绑定,也要检查 CRM 导入时是否把 provider 的 `channel_template.model_mapping`、`restrict_models`、`billing_model_source` 一并下发到宿主 channel。
|
||||
|
||||
@@ -54,8 +54,11 @@ type GroupRef struct {
|
||||
}
|
||||
|
||||
type CreateChannelRequest struct {
|
||||
Name string `json:"name"`
|
||||
GroupIDs []string `json:"group_ids"`
|
||||
Name string `json:"name"`
|
||||
GroupIDs []string `json:"group_ids"`
|
||||
ModelMapping map[string]string `json:"model_mapping,omitempty"`
|
||||
RestrictModels bool `json:"restrict_models,omitempty"`
|
||||
BillingModelSource string `json:"billing_model_source,omitempty"`
|
||||
}
|
||||
|
||||
type ChannelRef struct {
|
||||
|
||||
@@ -255,7 +255,14 @@ func ensureGroup(ctx context.Context, host hostAdapter, existing []sub2api.Named
|
||||
func ensureChannel(ctx context.Context, host hostAdapter, existing []sub2api.NamedResource, provider pack.ProviderManifest, groupID string) (sub2api.ChannelRef, bool, error) {
|
||||
switch len(existing) {
|
||||
case 0:
|
||||
channel, err := host.CreateChannel(ctx, sub2api.CreateChannelRequest{Name: provider.ChannelTemplate.Name, GroupIDs: []string{groupID}})
|
||||
channelReq := sub2api.CreateChannelRequest{
|
||||
Name: provider.ChannelTemplate.Name,
|
||||
GroupIDs: []string{groupID},
|
||||
ModelMapping: provider.ChannelTemplate.ModelMapping,
|
||||
RestrictModels: true,
|
||||
BillingModelSource: "channel_mapped",
|
||||
}
|
||||
channel, err := host.CreateChannel(ctx, channelReq)
|
||||
return channel, true, err
|
||||
case 1:
|
||||
return sub2api.ChannelRef{ID: existing[0].ID, Name: existing[0].Name}, false, nil
|
||||
|
||||
@@ -141,11 +141,11 @@ func TestImportServiceStrictModeRollsBackCreatedResources(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportServiceReusesExistingManagedResources(t *testing.T) {
|
||||
func TestImportReusesExistingGroup(t *testing.T) {
|
||||
host := &fakeHostAdapter{
|
||||
batchAccounts: []sub2api.AccountRef{{ID: "account_1"}},
|
||||
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}},
|
||||
testResults: map[string]sub2api.ProbeResult{
|
||||
"account_1": {OK: true, Status: "passed"},
|
||||
"account_1": {OK: true, Status: "ready"},
|
||||
},
|
||||
models: map[string][]sub2api.AccountModel{
|
||||
"account_1": {{ID: "deepseek-chat"}},
|
||||
@@ -177,6 +177,44 @@ func TestImportServiceReusesExistingManagedResources(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportCreatesChannelWithManifestModelMapping(t *testing.T) {
|
||||
host := &fakeHostAdapter{
|
||||
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}},
|
||||
testResults: map[string]sub2api.ProbeResult{
|
||||
"account_1": {OK: true, Status: "ready"},
|
||||
},
|
||||
models: map[string][]sub2api.AccountModel{
|
||||
"account_1": {{ID: "deepseek-chat"}},
|
||||
},
|
||||
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
|
||||
}
|
||||
|
||||
_, err := NewImportService(host).Import(context.Background(), ImportRequest{
|
||||
Provider: sampleProviderManifest(),
|
||||
Mode: ImportModePartial,
|
||||
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
|
||||
Keys: []string{"key-1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Import() error = %v", err)
|
||||
}
|
||||
if host.createChannelReq.Name != "DeepSeek 默认渠道" {
|
||||
t.Fatalf("CreateChannel().Name = %q, want DeepSeek 默认渠道", host.createChannelReq.Name)
|
||||
}
|
||||
if len(host.createChannelReq.GroupIDs) != 1 || host.createChannelReq.GroupIDs[0] != "group_1" {
|
||||
t.Fatalf("CreateChannel().GroupIDs = %v, want [group_1]", host.createChannelReq.GroupIDs)
|
||||
}
|
||||
if got := host.createChannelReq.ModelMapping["deepseek-chat"]; got != "deepseek-chat" {
|
||||
t.Fatalf("CreateChannel().ModelMapping = %+v, want deepseek-chat passthrough", host.createChannelReq.ModelMapping)
|
||||
}
|
||||
if !host.createChannelReq.RestrictModels {
|
||||
t.Fatal("CreateChannel().RestrictModels = false, want true")
|
||||
}
|
||||
if host.createChannelReq.BillingModelSource != "channel_mapped" {
|
||||
t.Fatalf("CreateChannel().BillingModelSource = %q, want channel_mapped", host.createChannelReq.BillingModelSource)
|
||||
}
|
||||
}
|
||||
|
||||
func sampleProviderManifest() pack.ProviderManifest {
|
||||
return pack.ProviderManifest{
|
||||
ProviderID: "deepseek",
|
||||
@@ -210,6 +248,7 @@ type fakeHostAdapter struct {
|
||||
createChannelCalls int
|
||||
createPlanCalls int
|
||||
createGroupReq sub2api.CreateGroupRequest
|
||||
createChannelReq sub2api.CreateChannelRequest
|
||||
}
|
||||
|
||||
func (f *fakeHostAdapter) GetHostVersion(context.Context) (string, error) {
|
||||
@@ -230,8 +269,9 @@ func (f *fakeHostAdapter) DeleteGroup(_ context.Context, groupID string) error {
|
||||
f.deletedResources = append(f.deletedResources, "group:"+groupID)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeHostAdapter) CreateChannel(context.Context, sub2api.CreateChannelRequest) (sub2api.ChannelRef, error) {
|
||||
func (f *fakeHostAdapter) CreateChannel(_ context.Context, req sub2api.CreateChannelRequest) (sub2api.ChannelRef, error) {
|
||||
f.createChannelCalls++
|
||||
f.createChannelReq = req
|
||||
return sub2api.ChannelRef{ID: "channel_1", Name: "c"}, nil
|
||||
}
|
||||
func (f *fakeHostAdapter) DeleteChannel(_ context.Context, channelID string) error {
|
||||
|
||||
Reference in New Issue
Block a user