fix(provision): reconcile channel pricing and hosted access

This commit is contained in:
phamnazage-jpg
2026-05-20 22:09:40 +08:00
parent 83ee216a4d
commit ca1d448cc0
27 changed files with 1344 additions and 154 deletions

View File

@@ -1,7 +1,7 @@
# sub2api-cn-relay-manager 执行板 # sub2api-cn-relay-manager 执行板
日期2026-05-19 日期2026-05-20
当前 GateCONDITIONAL_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 仍需重新验收 当前 GateBLOCKED代码门禁通过,但 2026-05-20 current-code CRM(18092) + remote43 fresh host(18097) 真实宿主复验失败DeepSeek batch=22、MiniMax batch=23 均仅到 `partially_succeeded/access_status=broken`;宿主普通用户 `/v1/models` 仍暴露 GPT 系默认模型gateway closure 未通过,不能宣称可上线
目标:实现独立控制面、零侵入宿主、可导入国产模型并具备可运维的导入/回滚/访问闭环。 目标:实现独立控制面、零侵入宿主、可导入国产模型并具备可运维的导入/回滚/访问闭环。
## 本轮已完成 ## 本轮已完成
@@ -36,7 +36,11 @@
9. current-code remote43 导入链路已补齐 tunnel-aware 验证能力 9. current-code remote43 导入链路已补齐 tunnel-aware 验证能力
- `scripts/import_remote43_provider.sh` 新增 `CRM_HOST_BASE`允许把“operator 访问 host 地址”和“CRM 进程访问 host 地址”分离 - `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` - latest artifact`/home/long/artifacts/real-host-acceptance/20260519_195827_remote43_deepseek_key_import`
- 结论import / batch detail / managed resources 已真实落库;轮定位到 channel 创建缺少 model_mapping / restrict_models / billing_model_source已补齐实现与测试,待重新跑真实宿主验收 - 结论import / batch detail / managed resources 已真实落库;前一轮定位到 channel 创建缺少 model_mapping / restrict_models / billing_model_source已补齐实现与测试
10. current-code remote43 access gate 根因修正已落地
- subscription access 改为宿主侧闭环CRM 不再依赖外部预先给定的宿主普通用户 key而是按 `subscription_users` selector 在宿主创建/查找托管普通用户、登录创建托管 key、回写 allowed_groups / balance、再执行订阅分配
- account 创建请求现在同步写入 `credentials.model_mapping`,修正 `/v1/models` 读取 account model whitelist 时回退到 GPT 默认集合的问题
- 新增/更新测试覆盖:`internal/access``internal/provision``internal/host/sub2api`
## 已验证门禁 ## 已验证门禁
@@ -76,35 +80,44 @@
- `latest_access_status=broken` - `latest_access_status=broken`
- `access preview available=false` - `access preview available=false`
- `reconcile status=drifted`,其中 `probe_failures=1` - `reconcile status=drifted`,其中 `probe_failures=1`
4. 根因归类 4. 当前修正
- `09-models.headers.txt` / `10-models.body.json` 显示普通用户实际看到的是 GPT 系模型,而非预期的 `deepseek-v4-pro` - 旧 artifact 中 `09-models.headers.txt` / `10-models.body.json` 暴露 GPT 系模型根因已重新归类为CRM 写了 channel model_mapping但 account `credentials.model_mapping` 未同步,导致宿主 `/v1/models` 从 account 视图回退到默认模型集
- 因此本轮 FAIL 应归类为“上游 key/模型能力不匹配或普通用户绑定命中了错误 group”不是 current-code CRM bootstrap / import 主链路故障。 - 同时,旧脚本/调用路径把外部 `subscription_users` / `access_api_key` 直接当宿主用户和宿主 key 使用,无法形成“宿主普通用户创建/查找 + key + 订阅分配”的真正闭环;该问题现已改为宿主托管闭环
- 代码侧阻断点已修复;下一步只剩 DeepSeek / MiniMax 真实 key 复验
## 剩余项(含当前外部门禁) ## 剩余项(含当前外部门禁)
1. DeepSeek / MiniMax real-host access gate 仍需复验(外部门禁) 1. current-code real-host access gate 失败,需先修复再谈上线
- 真实宿主曾出现普通用户 `/v1/models` 暴露 GPT 系模型的漂移;本轮已补齐 channel 侧 model_mapping / restrict_models / billing_model_source 传参 - DeepSeekartifact `artifacts/real-host-acceptance/20260520_123726_remote43_deepseek_key_import/03-import.body.json` 显示 `batch_id=22``batch_status=partially_succeeded``access_status=broken`
- 53hk 中转 key 当前未验证可用,不能当作主结论 - MiniMaxcurrent-code CRM(18092) 对 remote43 fresh host(18097) 手工复验得到 `batch_id=23``batch_status=partially_succeeded``access_status=broken`
- 在 current-code remote43 路径上,这一项仍需重新跑真实验收 - 两条链路的 `probe_summary_json` / gateway probe 都显示宿主普通用户 `/v1/models` 返回 GPT-5.x / GPT Image 默认集合,未暴露 DeepSeek / MiniMax 目标模型
2. 结构债务仍存在 - 2026-05-20 复核补充fresh host 上 `groups/channels/account_groups` 已按期望落库channel 也已具备 `model_mapping + restrict_models + billing_model_source=channel_mapped`;但 `accounts.credentials` 真实仅持久化 `api_key/base_url``GET /api/v1/admin/accounts/{id}/models` 仍返回 GPT 默认模型集,`POST /api/v1/admin/accounts/{id}/test` 也会默认拿 `gpt-5.4` 探测并报 `model_not_found`。当前根因已重新归类为“宿主 account 模型暴露契约仍未被 current-code 对齐”,不能再把问题简化成 `channel` 参数缺失或“只差同步 `credentials.model_mapping`”。
- pack contract 漂移已发现并修复:`packs/openai-cn-pack/providers/deepseek.json` 之前出现 `default_models/smoke_test_model``channel_template.model_mapping` 不一致;`internal/pack` 现已新增校验,要求 `smoke_test_model` 必须出现在 `channel_template.model_mapping`,且 `default_models` 必须被 `channel_template.model_mapping` 全量覆盖,避免类似漂移再次混入真实宿主验收。
- 2026-05-20 21:50 补充:已修复 current-code `channel` 创建/纠偏时 `model_pricing` 丢失的问题。CRM `http://127.0.0.1:18100``remote43-fresh18097-deepseek-1779280533` 复跑 `POST /api/providers/deepseek/import` 返回 `batch_id=4``access_status=subscription_ready`;宿主 `GET /api/v1/admin/channels/4` 已可见 `model_pricing=[{platform:"openai", models:["deepseek-v4-pro","deepseek-v4-flash"], billing_mode:"token", intervals:[]}]`,说明“已存在 channel 可 PUT 纠偏”已生效。当前 remaining gate 不再是 channel pricing 缺失,而是更高层的 provider/account 行为问题。
2. 真实宿主脚本存在环境绑定缺陷
- `scripts/import_remote43_provider.sh` 仍把 Postgres/Redis 容器名硬编码到 `sub2api-relaymgr-pg` / `sub2api-relaymgr-redis`
- 当目标切到 fresh host(18097) 时,脚本会把 subscription user/key prep 误打到旧 relaymgr 宿主,导致 user id 错宿主、出现 `assign subscription for 10 ... 500`
3. 结构债务仍存在
- access / reconcile 尚未完全按 implementation plan 物理拆分 - access / reconcile 尚未完全按 implementation plan 物理拆分
- 无内置 scheduler/jobs - 无内置 scheduler/jobs
3. 运营前置动作需要 runbook 化执行 4. 运营前置动作需要 runbook 化执行
- 真实宿主初始化不会自动创建普通用户;验收或上线前必须显式创建普通用户并留存可复用凭据 - 真实宿主初始化不会自动创建普通用户;当前 CRM subscription 闭环声称可按 selector 自动托管宿主普通用户/key但本轮 remote43 真实宿主复验未通过,不能把该能力当作已验收事实
- `self_service` 需要普通用户 key 绑定目标标准 group且通常还需要可用余额 - `self_service` 需要普通用户 key 绑定目标标准 group且通常还需要可用余额
- `subscription` 需要 subscription 类型 group + 普通用户订阅分配 + key/group 绑定 - `subscription` 需要 subscription 类型 group + 普通用户订阅分配 + key/group 绑定
4. 标准多阶段 Dockerfile 在受限网络环境下仍不稳 5. 标准多阶段 Dockerfile 在受限网络环境下仍不稳
- 当前推荐 `scripts/build_local_image.sh` + `Dockerfile.local` - 当前推荐 `scripts/build_local_image.sh` + `Dockerfile.local`
5. 真实宿主验收工具已补自动化闭环 6. 真实宿主验收工具需补 host 级参数化
- `scripts/real_host_acceptance.sh` 支持 `AFTER_IMPORT_HOOK_COMMAND`,可把宿主侧 access 前置动作收敛进同一条 artifact 链 - `scripts/real_host_acceptance.sh` `AFTER_IMPORT_HOOK_COMMAND` 仍有价值,但 remote43/fresh-host 变体还缺“目标 Postgres/Redis 容器名、目标 host env 文件、目标 forward 端口”的显式参数化
- `scripts/import_remote43_provider.sh` 已内置 remote43 subscription 的“补余额 + key/group 绑定 + subscription upsert + 定向 Redis 缓存失效 + host state 落盘” - 否则 artifact 会混入旧宿主状态,误导 gate 判断
## 当前最短上线路径 ## 当前最短上线路径
1. `docs/REAL_HOST_ACCEPTANCE_RUNBOOK.md` 准备真实宿主普通用户与凭据 1. 先修 current-code 在真实宿主上的两个阻断点:
2. 按目标模式完成必要的 key/group/billing(or subscription) 绑定 - 查清并修复为什么宿主 `accounts.credentials` 未持久化 `model_mapping`
3. 使用 `scripts/build_local_image.sh``scripts/real_host_acceptance.sh` 复跑并归档现场 artifact - 给 remote43 验收脚本补目标 host 级参数化,避免 Postgres/Redis/host env 误指向旧 relaymgr
4. 若现场前置满足,本项目按 PRD 首版范围可直接上线 2. 用 fresh host 重新跑 DeepSeek / MiniMax subscription 验收,要求 `/v1/models` 暴露目标模型且 `/v1/chat/completions` 返回 200
3. 复跑 `provider status` / `access status` / `access preview` / `batch detail`,确认 `batch_status=succeeded``access_status=ready`
4. 若现场前置满足,再重新评估是否恢复 CONDITIONAL_APPROVED / APPROVED
## 禁止错误结论 ## 禁止错误结论

View File

@@ -27,6 +27,7 @@ type ClosureRequest struct {
} }
type Host interface { type Host interface {
EnsureSubscriptionAccess(ctx context.Context, req sub2api.EnsureSubscriptionAccessRequest) (sub2api.SubscriptionAccessRef, error)
AssignSubscription(ctx context.Context, req sub2api.AssignSubscriptionRequest) (sub2api.SubscriptionRef, error) AssignSubscription(ctx context.Context, req sub2api.AssignSubscriptionRequest) (sub2api.SubscriptionRef, error)
CheckGatewayAccess(ctx context.Context, req sub2api.GatewayAccessCheckRequest) (sub2api.GatewayAccessResult, error) CheckGatewayAccess(ctx context.Context, req sub2api.GatewayAccessCheckRequest) (sub2api.GatewayAccessResult, error)
} }
@@ -52,7 +53,7 @@ func Validate(req ClosureRequest) error {
default: default:
return fmt.Errorf("unsupported access mode %q", req.Mode) return fmt.Errorf("unsupported access mode %q", req.Mode)
} }
if strings.TrimSpace(req.ProbeAPIKey) == "" { if strings.TrimSpace(req.Mode) != ModeSubscription && strings.TrimSpace(req.ProbeAPIKey) == "" {
return fmt.Errorf("access probe api key is required to verify gateway closure") return fmt.Errorf("access probe api key is required to verify gateway closure")
} }
return nil return nil
@@ -65,14 +66,29 @@ func (s *Service) Close(ctx context.Context, req ClosureRequest) (sub2api.Gatewa
if err := Validate(req); err != nil { if err := Validate(req); err != nil {
return sub2api.GatewayAccessResult{}, err return sub2api.GatewayAccessResult{}, err
} }
probeAPIKey := strings.TrimSpace(req.ProbeAPIKey)
if strings.TrimSpace(req.Mode) == ModeSubscription { if strings.TrimSpace(req.Mode) == ModeSubscription {
for _, target := range req.Subscriptions { for _, target := range req.Subscriptions {
if _, err := s.host.AssignSubscription(ctx, sub2api.AssignSubscriptionRequest{UserID: target.UserID, GroupID: req.GroupID, DurationDays: target.DurationDays}); err != nil { resolvedTarget := target.UserID
accessRef, err := s.host.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{UserSelector: target.UserID, GroupID: req.GroupID})
if err != nil {
return sub2api.GatewayAccessResult{}, fmt.Errorf("ensure subscription access for %s: %w", target.UserID, err)
}
if strings.TrimSpace(accessRef.UserID) != "" {
resolvedTarget = accessRef.UserID
}
if strings.TrimSpace(accessRef.APIKey) != "" {
probeAPIKey = strings.TrimSpace(accessRef.APIKey)
}
if _, err := s.host.AssignSubscription(ctx, sub2api.AssignSubscriptionRequest{UserID: resolvedTarget, GroupID: req.GroupID, DurationDays: target.DurationDays}); err != nil {
return sub2api.GatewayAccessResult{}, fmt.Errorf("assign subscription for %s: %w", target.UserID, err) return sub2api.GatewayAccessResult{}, fmt.Errorf("assign subscription for %s: %w", target.UserID, err)
} }
} }
} }
result, err := s.host.CheckGatewayAccess(ctx, sub2api.GatewayAccessCheckRequest{APIKey: req.ProbeAPIKey, ExpectedModel: req.ExpectedModel}) if probeAPIKey == "" {
return sub2api.GatewayAccessResult{}, fmt.Errorf("access probe api key is required to verify gateway closure")
}
result, err := s.host.CheckGatewayAccess(ctx, sub2api.GatewayAccessCheckRequest{APIKey: probeAPIKey, ExpectedModel: req.ExpectedModel})
if err != nil { if err != nil {
return sub2api.GatewayAccessResult{}, fmt.Errorf("check gateway access: %w", err) return sub2api.GatewayAccessResult{}, fmt.Errorf("check gateway access: %w", err)
} }

View File

@@ -22,14 +22,28 @@ func TestValidateRejectsMissingSubscriptionsForSubscriptionMode(t *testing.T) {
} }
} }
func TestValidateAllowsManagedSubscriptionProbeWithoutExplicitAPIKey(t *testing.T) {
err := Validate(ClosureRequest{
Mode: "subscription",
GroupID: "group-1",
ExpectedModel: "deepseek-chat",
Subscriptions: []SubscriptionTarget{{UserID: "crm-user-42", DurationDays: 30}},
})
if err != nil {
t.Fatalf("Validate() error = %v, want nil for managed subscription probe", err)
}
}
func TestServiceCloseAssignsSubscriptionsAndProbesGateway(t *testing.T) { func TestServiceCloseAssignsSubscriptionsAndProbesGateway(t *testing.T) {
host := &fakeClosureHost{ host := &fakeClosureHost{
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}}, gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
managedAccess: map[string]sub2api.SubscriptionAccessRef{
"user-1": {UserID: "host-user-1", APIKey: "managed-user-key"},
},
} }
service := NewService(host) service := NewService(host)
result, err := service.Close(context.Background(), ClosureRequest{ result, err := service.Close(context.Background(), ClosureRequest{
Mode: "subscription", Mode: "subscription",
ProbeAPIKey: "user-key",
GroupID: "group-1", GroupID: "group-1",
ExpectedModel: "deepseek-chat", ExpectedModel: "deepseek-chat",
Subscriptions: []SubscriptionTarget{{UserID: "user-1", DurationDays: 30}}, Subscriptions: []SubscriptionTarget{{UserID: "user-1", DurationDays: 30}},
@@ -40,7 +54,10 @@ func TestServiceCloseAssignsSubscriptionsAndProbesGateway(t *testing.T) {
if len(host.assigned) != 1 { if len(host.assigned) != 1 {
t.Fatalf("assigned subscriptions = %d, want 1", len(host.assigned)) t.Fatalf("assigned subscriptions = %d, want 1", len(host.assigned))
} }
if host.gatewayProbe.APIKey != "user-key" || host.gatewayProbe.ExpectedModel != "deepseek-chat" { if host.assigned[0].UserID != "host-user-1" {
t.Fatalf("assigned subscription user = %q, want host-user-1", host.assigned[0].UserID)
}
if host.gatewayProbe.APIKey != "managed-user-key" || host.gatewayProbe.ExpectedModel != "deepseek-chat" {
t.Fatalf("gateway probe = %+v, want api key + expected model", host.gatewayProbe) t.Fatalf("gateway probe = %+v, want api key + expected model", host.gatewayProbe)
} }
if !result.OK || !result.HasExpectedModel { if !result.OK || !result.HasExpectedModel {
@@ -68,12 +85,20 @@ func TestServiceCloseReturnsSubscriptionErrorBeforeGatewayProbe(t *testing.T) {
type fakeClosureHost struct { type fakeClosureHost struct {
assigned []sub2api.AssignSubscriptionRequest assigned []sub2api.AssignSubscriptionRequest
managedAccess map[string]sub2api.SubscriptionAccessRef
assignErr error assignErr error
gatewayProbe sub2api.GatewayAccessCheckRequest gatewayProbe sub2api.GatewayAccessCheckRequest
gatewayResult sub2api.GatewayAccessResult gatewayResult sub2api.GatewayAccessResult
gatewayErr error gatewayErr error
} }
func (f *fakeClosureHost) EnsureSubscriptionAccess(_ context.Context, req sub2api.EnsureSubscriptionAccessRequest) (sub2api.SubscriptionAccessRef, error) {
if ref, ok := f.managedAccess[req.UserSelector]; ok {
return ref, nil
}
return sub2api.SubscriptionAccessRef{}, errors.New("missing managed access")
}
func (f *fakeClosureHost) AssignSubscription(_ context.Context, req sub2api.AssignSubscriptionRequest) (sub2api.SubscriptionRef, error) { func (f *fakeClosureHost) AssignSubscription(_ context.Context, req sub2api.AssignSubscriptionRequest) (sub2api.SubscriptionRef, error) {
if f.assignErr != nil { if f.assignErr != nil {
return sub2api.SubscriptionRef{}, f.assignErr return sub2api.SubscriptionRef{}, f.assignErr

View File

@@ -5,10 +5,12 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"net" "net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -874,6 +876,47 @@ func TestHandlerErrorPaths(t *testing.T) {
} }
} }
func TestResolveLatestAccessStatusAggregatesAcrossModeBatches(t *testing.T) {
store := openAppTestStore(t)
defer closeAppTestStore(t, store)
ctx := context.Background()
hostID, err := store.Hosts().Create(ctx, sqlite.Host{HostID: "host-1", BaseURL: "https://sub2api.example.com", HostVersion: "0.1.126", AuthType: "apikey", AuthToken: "token"})
if err != nil {
t.Fatalf("Hosts().Create() error = %v", err)
}
packID, err := store.Packs().Create(ctx, sqlite.Pack{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", Checksum: "checksum-1"})
if err != nil {
t.Fatalf("Packs().Create() error = %v", err)
}
providerID, err := store.Providers().Create(ctx, sqlite.Provider{PackID: packID, ProviderID: "deepseek", DisplayName: "DeepSeek", BaseURL: "https://api.deepseek.com", Platform: "openai"})
if err != nil {
t.Fatalf("Providers().Create() error = %v", err)
}
batchSubscription, err := store.ImportBatches().Create(ctx, sqlite.ImportBatch{HostID: hostID, PackID: packID, ProviderID: providerID, Mode: provision.ImportModePartial, BatchStatus: provision.BatchStatusSucceeded, AccessStatus: provision.AccessStatusSubscriptionReady})
if err != nil {
t.Fatalf("ImportBatches().Create(subscription) error = %v", err)
}
if _, err := store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{BatchID: batchSubscription, ClosureType: provision.AccessModeSubscription, Status: provision.AccessStatusSubscriptionReady, DetailsJSON: "{}"}); err != nil {
t.Fatalf("AccessClosures().Create(subscription) error = %v", err)
}
batchSelf, err := store.ImportBatches().Create(ctx, sqlite.ImportBatch{HostID: hostID, PackID: packID, ProviderID: providerID, Mode: provision.ImportModePartial, BatchStatus: provision.BatchStatusSucceeded, AccessStatus: provision.AccessStatusSelfServiceReady})
if err != nil {
t.Fatalf("ImportBatches().Create(self_service) error = %v", err)
}
if _, err := store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{BatchID: batchSelf, ClosureType: provision.AccessModeSelfService, Status: provision.AccessStatusSelfServiceReady, DetailsJSON: "{}"}); err != nil {
t.Fatalf("AccessClosures().Create(self_service) error = %v", err)
}
got, err := resolveLatestAccessStatus(ctx, store, sqlite.Provider{ID: providerID, ProviderID: "deepseek"}, "host-1")
if err != nil {
t.Fatalf("resolveLatestAccessStatus() error = %v", err)
}
if got != provision.AccessStatusFullyReady {
t.Fatalf("resolveLatestAccessStatus() = %q, want %q", got, provision.AccessStatusFullyReady)
}
}
func TestProviderAccessStatusMultipleClosures(t *testing.T) { func TestProviderAccessStatusMultipleClosures(t *testing.T) {
handler := NewAPIHandler("t", ActionSet{ handler := NewAPIHandler("t", ActionSet{
GetProviderAccessStatus: func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error) { GetProviderAccessStatus: func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error) {
@@ -926,6 +969,24 @@ func TestHostSupportStatusRequiresPlansCapability(t *testing.T) {
} }
} }
func openAppTestStore(t *testing.T) *sqlite.DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "state.db")
dsn := fmt.Sprintf("file:%s?_busy_timeout=5000&_pragma=foreign_keys(0)", filepath.ToSlash(dbPath))
store, err := sqlite.Open(context.Background(), dsn)
if err != nil {
t.Fatalf("sqlite.Open() error = %v", err)
}
return store
}
func closeAppTestStore(t *testing.T, store *sqlite.DB) {
t.Helper()
if err := store.Close(); err != nil {
t.Fatalf("store.Close() error = %v", err)
}
}
func assertJSONContains(t *testing.T, payload []byte, key string, want any) { func assertJSONContains(t *testing.T, payload []byte, key string, want any) {
t.Helper() t.Helper()
var decoded map[string]any var decoded map[string]any

View File

@@ -1367,37 +1367,10 @@ func NewActionSet(sqliteDSN string) ActionSet {
return AccessPreviewResult{}, fmt.Errorf("provider %q exists in multiple packs; pack_id is required", req.ProviderID) return AccessPreviewResult{}, fmt.Errorf("provider %q exists in multiple packs; pack_id is required", req.ProviderID)
} }
providerRow := providers[0] providerRow := providers[0]
if strings.TrimSpace(req.HostID) != "" { latestStatus, err := resolveLatestAccessStatus(ctx, store, providerRow, req.HostID)
hostRow, err := store.Hosts().GetByHostID(ctx, req.HostID)
if err != nil {
return AccessPreviewResult{}, err
}
batch, err := store.ImportBatches().GetLatestByProviderIDAndHostID(ctx, providerRow.ID, hostRow.ID)
if err != nil {
return AccessPreviewResult{}, fmt.Errorf("find batch for provider: %w", err)
}
latestStatus := batch.AccessStatus
closures, err := store.AccessClosures().GetByBatchID(ctx, batch.ID)
if err == nil && len(closures) > 0 {
latestStatus = closures[len(closures)-1].Status
}
available := accessStatusSupportsMode(latestStatus, req.Mode)
message := fmt.Sprintf("latest access status: %s", latestStatus)
if !available {
message = fmt.Sprintf("access status %s does not satisfy mode %s", latestStatus, req.Mode)
}
return AccessPreviewResult{ProviderID: req.ProviderID, Mode: req.Mode, Available: available, Message: message}, nil
}
batch, err := store.ImportBatches().GetLatestByProviderID(ctx, providerRow.ID)
if err != nil { if err != nil {
return AccessPreviewResult{}, fmt.Errorf("find batch for provider: %w", err) return AccessPreviewResult{}, fmt.Errorf("find batch for provider: %w", err)
} }
latestStatus := batch.AccessStatus
closures, err := store.AccessClosures().GetByBatchID(ctx, batch.ID)
if err == nil && len(closures) > 0 {
latestStatus = closures[len(closures)-1].Status
}
available := accessStatusSupportsMode(latestStatus, req.Mode) available := accessStatusSupportsMode(latestStatus, req.Mode)
message := fmt.Sprintf("latest access status: %s", latestStatus) message := fmt.Sprintf("latest access status: %s", latestStatus)
if !available { if !available {
@@ -1440,6 +1413,45 @@ func resolveProvidersForQuery(ctx context.Context, store *sqlite.DB, req Provide
return store.Providers().ListByProviderID(ctx, providerID) return store.Providers().ListByProviderID(ctx, providerID)
} }
func resolveLatestAccessStatus(ctx context.Context, store *sqlite.DB, providerRow sqlite.Provider, hostID string) (string, error) {
if store == nil {
return "", fmt.Errorf("store is required")
}
if strings.TrimSpace(hostID) != "" {
hostRow, err := store.Hosts().GetByHostID(ctx, hostID)
if err != nil {
return "", err
}
batches, err := store.ImportBatches().ListByProviderIDAndHostID(ctx, providerRow.ID, hostRow.ID)
if err != nil {
return "", err
}
modeStatuses, err := provision.LatestModeAccessStatuses(ctx, store, batches)
if err != nil {
return "", err
}
return provision.AggregateAccessStatus(modeStatuses), nil
}
batches, err := store.ImportBatches().ListByProviderID(ctx, providerRow.ID)
if err != nil {
return "", err
}
if len(batches) == 0 {
return "", fmt.Errorf("latest import batch not found for provider")
}
hostIDValue := batches[0].HostID
for _, batch := range batches[1:] {
if batch.HostID != hostIDValue {
return "", fmt.Errorf("provider exists on multiple hosts; host_id is required")
}
}
modeStatuses, err := provision.LatestModeAccessStatuses(ctx, store, batches)
if err != nil {
return "", err
}
return provision.AggregateAccessStatus(modeStatuses), nil
}
func resolveManagedHost(ctx context.Context, store *sqlite.DB, hostID, baseURL string, auth CreateHostAuth) (sqlite.Host, *sub2api.Client, error) { func resolveManagedHost(ctx context.Context, store *sqlite.DB, hostID, baseURL string, auth CreateHostAuth) (sqlite.Host, *sub2api.Client, error) {
if store == nil { if store == nil {
return sqlite.Host{}, nil, fmt.Errorf("store is required") return sqlite.Host{}, nil, fmt.Errorf("store is required")

View File

@@ -1,6 +1,10 @@
package sub2api package sub2api
import "context" import (
"context"
"fmt"
"net/http"
)
func (c *Client) CreateChannel(ctx context.Context, req CreateChannelRequest) (ChannelRef, error) { func (c *Client) CreateChannel(ctx context.Context, req CreateChannelRequest) (ChannelRef, error) {
var ref ChannelRef var ref ChannelRef
@@ -9,3 +13,15 @@ func (c *Client) CreateChannel(ctx context.Context, req CreateChannelRequest) (C
} }
return ref, nil return ref, nil
} }
func (c *Client) UpdateChannel(ctx context.Context, channelID string, req CreateChannelRequest) error {
path := fmt.Sprintf("/api/v1/admin/channels/%s", channelID)
statusCode, _, body, err := c.perform(ctx, http.MethodPut, path, req)
if err != nil {
return err
}
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
return newHTTPError(http.MethodPut, path, statusCode, body)
}
return nil
}

View File

@@ -18,6 +18,7 @@ type HostAdapter interface {
CreateGroup(ctx context.Context, req CreateGroupRequest) (GroupRef, error) CreateGroup(ctx context.Context, req CreateGroupRequest) (GroupRef, error)
DeleteGroup(ctx context.Context, groupID string) error DeleteGroup(ctx context.Context, groupID string) error
CreateChannel(ctx context.Context, req CreateChannelRequest) (ChannelRef, error) CreateChannel(ctx context.Context, req CreateChannelRequest) (ChannelRef, error)
UpdateChannel(ctx context.Context, channelID string, req CreateChannelRequest) error
DeleteChannel(ctx context.Context, channelID string) error DeleteChannel(ctx context.Context, channelID string) error
CreatePlan(ctx context.Context, req CreatePlanRequest) (PlanRef, error) CreatePlan(ctx context.Context, req CreatePlanRequest) (PlanRef, error)
DeletePlan(ctx context.Context, planID string) error DeletePlan(ctx context.Context, planID string) error
@@ -26,6 +27,7 @@ type HostAdapter interface {
DeleteAccount(ctx context.Context, accountID string) error DeleteAccount(ctx context.Context, accountID string) error
TestAccount(ctx context.Context, accountID string) (ProbeResult, error) TestAccount(ctx context.Context, accountID string) (ProbeResult, error)
GetAccountModels(ctx context.Context, accountID string) ([]AccountModel, error) GetAccountModels(ctx context.Context, accountID string) ([]AccountModel, error)
EnsureSubscriptionAccess(ctx context.Context, req EnsureSubscriptionAccessRequest) (SubscriptionAccessRef, error)
AssignSubscription(ctx context.Context, req AssignSubscriptionRequest) (SubscriptionRef, error) AssignSubscription(ctx context.Context, req AssignSubscriptionRequest) (SubscriptionRef, error)
CheckGatewayAccess(ctx context.Context, req GatewayAccessCheckRequest) (GatewayAccessResult, error) CheckGatewayAccess(ctx context.Context, req GatewayAccessCheckRequest) (GatewayAccessResult, error)
ListManagedResources(ctx context.Context, req ListManagedResourcesRequest) (ManagedResourceSnapshot, error) ListManagedResources(ctx context.Context, req ListManagedResourcesRequest) (ManagedResourceSnapshot, error)
@@ -54,11 +56,38 @@ type GroupRef struct {
} }
type CreateChannelRequest struct { type CreateChannelRequest struct {
Name string `json:"name"` Name string `json:"name"`
GroupIDs []string `json:"group_ids"` GroupIDs []string `json:"group_ids"`
ModelMapping map[string]string `json:"model_mapping,omitempty"` ModelMapping map[string]string `json:"model_mapping,omitempty"`
RestrictModels bool `json:"restrict_models,omitempty"` ModelPricing []ChannelModelPricing `json:"model_pricing,omitempty"`
BillingModelSource string `json:"billing_model_source,omitempty"` Platform string `json:"-"`
RestrictModels bool `json:"restrict_models,omitempty"`
BillingModelSource string `json:"billing_model_source,omitempty"`
}
type ChannelModelPricing struct {
Platform string `json:"platform,omitempty"`
Models []string `json:"models,omitempty"`
BillingMode string `json:"billing_mode,omitempty"`
InputPrice *float64 `json:"input_price,omitempty"`
OutputPrice *float64 `json:"output_price,omitempty"`
CacheWritePrice *float64 `json:"cache_write_price,omitempty"`
CacheReadPrice *float64 `json:"cache_read_price,omitempty"`
ImageOutputPrice *float64 `json:"image_output_price,omitempty"`
PerRequestPrice *float64 `json:"per_request_price,omitempty"`
Intervals []ChannelPricingTier `json:"intervals,omitempty"`
}
type ChannelPricingTier struct {
MinTokens int `json:"min_tokens,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
TierLabel string `json:"tier_label,omitempty"`
InputPrice *float64 `json:"input_price,omitempty"`
OutputPrice *float64 `json:"output_price,omitempty"`
CacheWritePrice *float64 `json:"cache_write_price,omitempty"`
CacheReadPrice *float64 `json:"cache_read_price,omitempty"`
PerRequestPrice *float64 `json:"per_request_price,omitempty"`
SortOrder int `json:"sort_order,omitempty"`
} }
type ChannelRef struct { type ChannelRef struct {
@@ -116,6 +145,16 @@ type AssignSubscriptionRequest struct {
DurationDays int `json:"validity_days,omitempty"` DurationDays int `json:"validity_days,omitempty"`
} }
type EnsureSubscriptionAccessRequest struct {
UserSelector string
GroupID string
}
type SubscriptionAccessRef struct {
UserID string
APIKey string
}
type SubscriptionRef struct { type SubscriptionRef struct {
ID string `json:"id"` ID string `json:"id"`
} }

View File

@@ -48,12 +48,41 @@ func flexibleIDSliceValues(raw []string) []any {
} }
func (r CreateChannelRequest) MarshalJSON() ([]byte, error) { func (r CreateChannelRequest) MarshalJSON() ([]byte, error) {
modelMapping := map[string]map[string]string{}
platform := strings.TrimSpace(r.Platform)
if platform == "" {
platform = "openai"
}
if len(r.ModelMapping) > 0 {
inner := make(map[string]string, len(r.ModelMapping))
for key, value := range r.ModelMapping {
inner[key] = value
}
modelMapping[platform] = inner
}
modelPricing := make([]ChannelModelPricing, 0, len(r.ModelPricing))
for _, entry := range r.ModelPricing {
pricing := entry
if strings.TrimSpace(pricing.Platform) == "" {
pricing.Platform = platform
}
modelPricing = append(modelPricing, pricing)
}
return json.Marshal(struct { return json.Marshal(struct {
Name string `json:"name"` Name string `json:"name"`
GroupIDs []any `json:"group_ids"` GroupIDs []any `json:"group_ids"`
ModelMapping map[string]map[string]string `json:"model_mapping,omitempty"`
ModelPricing []ChannelModelPricing `json:"model_pricing,omitempty"`
RestrictModels bool `json:"restrict_models,omitempty"`
BillingModelSource string `json:"billing_model_source,omitempty"`
}{ }{
Name: r.Name, Name: r.Name,
GroupIDs: flexibleIDSliceValues(r.GroupIDs), GroupIDs: flexibleIDSliceValues(r.GroupIDs),
ModelMapping: modelMapping,
ModelPricing: modelPricing,
RestrictModels: r.RestrictModels,
BillingModelSource: r.BillingModelSource,
}) })
} }

View File

@@ -6,6 +6,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
) )
@@ -591,8 +592,23 @@ func TestCreateGroupWithMock(t *testing.T) {
func TestCreateChannelWithMock(t *testing.T) { func TestCreateChannelWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Name string `json:"name"` Name string `json:"name"`
GroupIDs []int64 `json:"group_ids"` GroupIDs []int64 `json:"group_ids"`
ModelMapping map[string]map[string]string `json:"model_mapping"`
ModelPricing []struct {
Platform string `json:"platform"`
Models []string `json:"models"`
BillingMode string `json:"billing_mode"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
ImageOutputPrice *float64 `json:"image_output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
Intervals []any `json:"intervals"`
} `json:"model_pricing"`
RestrictModels bool `json:"restrict_models"`
BillingModelSource string `json:"billing_model_source"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode request: %v", err) t.Fatalf("decode request: %v", err)
@@ -603,11 +619,36 @@ func TestCreateChannelWithMock(t *testing.T) {
if len(req.GroupIDs) != 1 || req.GroupIDs[0] != 101 { if len(req.GroupIDs) != 1 || req.GroupIDs[0] != 101 {
t.Fatalf("group_ids = %v, want [101]", req.GroupIDs) t.Fatalf("group_ids = %v, want [101]", req.GroupIDs)
} }
if req.ModelMapping["openai"]["deepseek-v4-pro"] != "deepseek-v4-pro" {
t.Fatalf("model_mapping = %+v, want openai/deepseek-v4-pro passthrough", req.ModelMapping)
}
if len(req.ModelPricing) != 1 {
t.Fatalf("model_pricing len = %d, want 1", len(req.ModelPricing))
}
if req.ModelPricing[0].Platform != "openai" || req.ModelPricing[0].BillingMode != "token" {
t.Fatalf("model_pricing[0] = %+v, want openai/token entry", req.ModelPricing[0])
}
if len(req.ModelPricing[0].Models) != 1 || req.ModelPricing[0].Models[0] != "deepseek-v4-pro" {
t.Fatalf("model_pricing[0].models = %v, want [deepseek-v4-pro]", req.ModelPricing[0].Models)
}
if !req.RestrictModels {
t.Fatal("restrict_models = false, want true")
}
if req.BillingModelSource != "channel_mapped" {
t.Fatalf("billing_model_source = %q, want channel_mapped", req.BillingModelSource)
}
w.Write([]byte(`{"data":{"id":201,"name":"ch"}}`)) w.Write([]byte(`{"data":{"id":201,"name":"ch"}}`))
})) }))
defer srv.Close() defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k")) client, _ := NewClient(srv.URL, WithAPIKey("k"))
ref, err := client.CreateChannel(context.Background(), CreateChannelRequest{Name: "ch", GroupIDs: []string{"101"}}) ref, err := client.CreateChannel(context.Background(), CreateChannelRequest{
Name: "ch",
GroupIDs: []string{"101"},
ModelMapping: map[string]string{"deepseek-v4-pro": "deepseek-v4-pro"},
ModelPricing: []ChannelModelPricing{{Platform: "openai", Models: []string{"deepseek-v4-pro"}, BillingMode: "token"}},
RestrictModels: true,
BillingModelSource: "channel_mapped",
})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -699,6 +740,66 @@ func TestAssignSubscriptionWithMock(t *testing.T) {
} }
} }
func TestEnsureSubscriptionAccessWithMock(t *testing.T) {
var calls []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.Path)
switch {
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.RequestURI(), "/api/v1/admin/users?"):
w.Write([]byte(`{"data":{"items":[]}}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/users":
w.Write([]byte(`{"data":{"id":84,"email":"relay-sub-user-1@sub2api.local"}}`))
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/admin/users/84":
w.Write([]byte(`{"data":{"id":84}}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/users/84/balance":
w.Write([]byte(`{"data":{"id":84}}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/subscriptions/assign":
var req struct {
UserID int64 `json:"user_id"`
GroupID int64 `json:"group_id"`
DurationDays int `json:"validity_days"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode assign subscription request: %v", err)
}
if req.UserID != 84 || req.GroupID != 101 || req.DurationDays != 30 {
t.Fatalf("unexpected assign subscription request: %+v", req)
}
w.Write([]byte(`{"data":{"id":401}}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/auth/login":
w.Write([]byte(`{"data":{"access_token":"user-jwt"}}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/keys":
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode managed key request: %v", err)
}
if _, ok := req["group_id"]; ok {
t.Fatalf("managed key request unexpectedly carried group_id: %+v", req)
}
w.Write([]byte(`{"data":{"id":501,"key":"sk-relay-key","name":"managed-key"}}`))
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/admin/api-keys/501":
w.Write([]byte(`{"data":{"api_key":{"id":501}}}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithBearerToken("admin-token"))
ref, err := client.EnsureSubscriptionAccess(context.Background(), EnsureSubscriptionAccessRequest{UserSelector: "crm-user-1", GroupID: "101"})
if err != nil {
t.Fatal(err)
}
if ref.UserID != "84" {
t.Fatalf("user id = %q, want 84", ref.UserID)
}
if !strings.HasPrefix(ref.APIKey, "sk-relay-") {
t.Fatalf("api key = %q, want managed sk-relay-* key", ref.APIKey)
}
if len(calls) < 7 {
t.Fatalf("calls = %v, want managed subscription setup sequence", calls)
}
}
func TestCheckGatewayAccessWithMock(t *testing.T) { func TestCheckGatewayAccessWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":[{"id":"gpt-4"},{"id":"claude-3"}]}`)) w.Write([]byte(`{"data":[{"id":"gpt-4"},{"id":"claude-3"}]}`))
@@ -741,12 +842,19 @@ func TestBatchCreateAccountsWithMock(t *testing.T) {
if len(acct.GroupIDs) != 1 || acct.GroupIDs[0] != 101 { if len(acct.GroupIDs) != 1 || acct.GroupIDs[0] != 101 {
t.Fatalf("group_ids = %v, want [101]", acct.GroupIDs) t.Fatalf("group_ids = %v, want [101]", acct.GroupIDs)
} }
rawMapping, ok := acct.Credentials["model_mapping"].(map[string]any)
if !ok {
t.Fatalf("credentials = %+v, want model_mapping map", acct.Credentials)
}
if got, _ := rawMapping["deepseek-v4-pro"].(string); got != "deepseek-v4-pro" {
t.Fatalf("model_mapping = %+v, want deepseek-v4-pro passthrough", rawMapping)
}
w.Write([]byte(`{"data":[{"id":601,"name":"acct1"}]}`)) w.Write([]byte(`{"data":[{"id":601,"name":"acct1"}]}`))
})) }))
defer srv.Close() defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k")) client, _ := NewClient(srv.URL, WithAPIKey("k"))
refs, err := client.BatchCreateAccounts(context.Background(), BatchCreateAccountsRequest{ refs, err := client.BatchCreateAccounts(context.Background(), BatchCreateAccountsRequest{
Accounts: []CreateAccountRequest{{Name: "acct1", Platform: "openai", Type: "apikey", GroupIDs: []string{"101"}, Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://api.example.com"}}}, Accounts: []CreateAccountRequest{{Name: "acct1", Platform: "openai", Type: "apikey", GroupIDs: []string{"101"}, Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://api.example.com", "model_mapping": map[string]string{"deepseek-v4-pro": "deepseek-v4-pro"}}}},
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)

View File

@@ -0,0 +1,320 @@
package sub2api
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
)
const (
managedSubscriptionBalance = 10.0
managedSubscriptionValidityDays = 30
)
type adminUserRecord struct {
ID int64 `json:"id"`
Email string `json:"email"`
}
type adminAPIKeyRecord struct {
ID int64 `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Group *struct {
ID int64 `json:"id"`
} `json:"group,omitempty"`
GroupID *int64 `json:"group_id,omitempty"`
}
type authTokenPair struct {
AccessToken string `json:"access_token"`
}
func (c *Client) EnsureSubscriptionAccess(ctx context.Context, req EnsureSubscriptionAccessRequest) (SubscriptionAccessRef, error) {
if c == nil {
return SubscriptionAccessRef{}, fmt.Errorf("client is required")
}
selector := strings.TrimSpace(req.UserSelector)
groupID := strings.TrimSpace(req.GroupID)
if selector == "" {
return SubscriptionAccessRef{}, fmt.Errorf("user selector is required")
}
if groupID == "" {
return SubscriptionAccessRef{}, fmt.Errorf("group id is required")
}
groupInt, err := strconv.ParseInt(groupID, 10, 64)
if err != nil {
return SubscriptionAccessRef{}, fmt.Errorf("parse group id %q: %w", groupID, err)
}
identity := buildManagedSubscriptionIdentity(selector, groupID)
user, err := c.findManagedSubscriptionUser(ctx, identity.Email)
if err != nil {
return SubscriptionAccessRef{}, err
}
if user == nil {
user, err = c.createManagedSubscriptionUser(ctx, identity, groupInt)
if err != nil {
return SubscriptionAccessRef{}, err
}
}
if err := c.updateManagedSubscriptionUser(ctx, user.ID, groupInt); err != nil {
return SubscriptionAccessRef{}, err
}
if err := c.setManagedSubscriptionBalance(ctx, user.ID); err != nil {
return SubscriptionAccessRef{}, err
}
if err := c.ensureManagedSubscriptionAssignment(ctx, user.ID, groupID); err != nil {
return SubscriptionAccessRef{}, err
}
userClient, err := c.loginAsManagedSubscriptionUser(ctx, identity.Email, identity.Password)
if err != nil {
return SubscriptionAccessRef{}, err
}
keyRecord, err := c.ensureManagedSubscriptionAPIKey(ctx, userClient, user.ID, identity)
if err != nil {
return SubscriptionAccessRef{}, err
}
if err := c.bindManagedSubscriptionAPIKey(ctx, keyRecord.ID, groupInt); err != nil {
return SubscriptionAccessRef{}, err
}
return SubscriptionAccessRef{UserID: strconv.FormatInt(user.ID, 10), APIKey: identity.CustomKey}, nil
}
type managedSubscriptionIdentity struct {
Email string
Username string
Password string
CustomKey string
KeyName string
}
func buildManagedSubscriptionIdentity(selector, groupID string) managedSubscriptionIdentity {
normalizedSelector := strings.TrimSpace(selector)
seedMaterial := strings.ToLower(normalizedSelector) + "|" + strings.TrimSpace(groupID)
sum := sha256.Sum256([]byte(seedMaterial))
hash := hex.EncodeToString(sum[:])
prefix := sanitizeManagedSubscriptionPrefix(normalizedSelector)
if prefix == "" {
prefix = "relay-sub"
}
prefix = truncateManagedSubscriptionToken(prefix, 24)
shortHash := hash[:16]
keyHash := hash[:32]
username := truncateManagedSubscriptionToken(prefix+"-"+shortHash[:8], 32)
return managedSubscriptionIdentity{
Email: fmt.Sprintf("%s-%s@sub2api.local", prefix, shortHash),
Username: username,
Password: "RelayPwd!" + hash[:12],
CustomKey: "sk-relay-" + keyHash,
KeyName: truncateManagedSubscriptionToken(username+"-key", 48),
}
}
func sanitizeManagedSubscriptionPrefix(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
var b strings.Builder
lastDash := false
for _, r := range value {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
lastDash = false
case !lastDash:
b.WriteByte('-')
lastDash = true
}
}
return strings.Trim(b.String(), "-")
}
func truncateManagedSubscriptionToken(value string, max int) string {
if len(value) <= max {
return value
}
return strings.Trim(value[:max], "-")
}
func (c *Client) findManagedSubscriptionUser(ctx context.Context, email string) (*adminUserRecord, error) {
statusCode, _, body, err := c.perform(ctx, http.MethodGet, "/api/v1/admin/users?search="+url.QueryEscape(email)+"&page=1&page_size=20&sort_by=created_at&sort_order=desc", nil)
if err != nil {
return nil, fmt.Errorf("list admin users: %w", err)
}
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
return nil, newHTTPError(http.MethodGet, "/api/v1/admin/users", statusCode, body)
}
var envelope struct {
Data struct {
Items []adminUserRecord `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return nil, fmt.Errorf("decode admin users response: %w", err)
}
for _, item := range envelope.Data.Items {
if strings.EqualFold(strings.TrimSpace(item.Email), email) {
user := item
return &user, nil
}
}
return nil, nil
}
func (c *Client) createManagedSubscriptionUser(ctx context.Context, identity managedSubscriptionIdentity, groupID int64) (*adminUserRecord, error) {
payload := map[string]any{
"email": identity.Email,
"password": identity.Password,
"username": identity.Username,
"notes": "managed by sub2api-cn-relay-manager",
"balance": managedSubscriptionBalance,
"concurrency": 5,
"allowed_groups": []int64{groupID},
}
statusCode, _, body, err := c.perform(ctx, http.MethodPost, "/api/v1/admin/users", payload)
if err != nil {
return nil, fmt.Errorf("create admin user: %w", err)
}
if statusCode == http.StatusConflict {
return c.findManagedSubscriptionUser(ctx, identity.Email)
}
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
return nil, newHTTPError(http.MethodPost, "/api/v1/admin/users", statusCode, body)
}
var user adminUserRecord
if err := decodeEnvelopeObject(body, &user); err != nil {
return nil, fmt.Errorf("decode created admin user: %w", err)
}
return &user, nil
}
func (c *Client) updateManagedSubscriptionUser(ctx context.Context, userID, groupID int64) error {
payload := map[string]any{"allowed_groups": []int64{groupID}}
statusCode, _, body, err := c.perform(ctx, http.MethodPut, fmt.Sprintf("/api/v1/admin/users/%d", userID), payload)
if err != nil {
return fmt.Errorf("update admin user groups: %w", err)
}
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
return newHTTPError(http.MethodPut, fmt.Sprintf("/api/v1/admin/users/%d", userID), statusCode, body)
}
return nil
}
func (c *Client) setManagedSubscriptionBalance(ctx context.Context, userID int64) error {
payload := map[string]any{"balance": managedSubscriptionBalance, "operation": "set", "notes": "managed by sub2api-cn-relay-manager"}
statusCode, _, body, err := c.perform(ctx, http.MethodPost, fmt.Sprintf("/api/v1/admin/users/%d/balance", userID), payload)
if err != nil {
return fmt.Errorf("set admin user balance: %w", err)
}
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
return newHTTPError(http.MethodPost, fmt.Sprintf("/api/v1/admin/users/%d/balance", userID), statusCode, body)
}
return nil
}
func (c *Client) ensureManagedSubscriptionAssignment(ctx context.Context, userID int64, groupID string) error {
_, err := c.AssignSubscription(ctx, AssignSubscriptionRequest{
UserID: strconv.FormatInt(userID, 10),
GroupID: groupID,
DurationDays: managedSubscriptionValidityDays,
})
if err != nil {
return fmt.Errorf("assign managed subscription: %w", err)
}
return nil
}
func (c *Client) loginAsManagedSubscriptionUser(ctx context.Context, email, password string) (*Client, error) {
anon := c.cloneWithAuth("", "")
payload := map[string]any{"email": email, "password": password, "turnstile_token": ""}
statusCode, _, body, err := anon.perform(ctx, http.MethodPost, "/api/v1/auth/login", payload)
if err != nil {
return nil, fmt.Errorf("login managed subscription user: %w", err)
}
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
return nil, newHTTPError(http.MethodPost, "/api/v1/auth/login", statusCode, body)
}
var tokenPair authTokenPair
if err := decodeEnvelopeObject(body, &tokenPair); err != nil {
return nil, fmt.Errorf("decode managed user login response: %w", err)
}
if strings.TrimSpace(tokenPair.AccessToken) == "" {
return nil, fmt.Errorf("managed user login returned empty access token")
}
return c.cloneWithAuth("", tokenPair.AccessToken), nil
}
func (c *Client) ensureManagedSubscriptionAPIKey(ctx context.Context, userClient *Client, userID int64, identity managedSubscriptionIdentity) (*adminAPIKeyRecord, error) {
payload := map[string]any{
"name": identity.KeyName,
"custom_key": identity.CustomKey,
}
statusCode, _, body, err := userClient.perform(ctx, http.MethodPost, "/api/v1/keys", payload)
if err != nil {
return nil, fmt.Errorf("create managed api key: %w", err)
}
if statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices {
var key adminAPIKeyRecord
if err := decodeEnvelopeObject(body, &key); err != nil {
return nil, fmt.Errorf("decode created api key: %w", err)
}
return &key, nil
}
if statusCode != http.StatusConflict && statusCode != http.StatusBadRequest {
return nil, newHTTPError(http.MethodPost, "/api/v1/keys", statusCode, body)
}
return c.findManagedSubscriptionAPIKey(ctx, userID, identity)
}
func (c *Client) findManagedSubscriptionAPIKey(ctx context.Context, userID int64, identity managedSubscriptionIdentity) (*adminAPIKeyRecord, error) {
statusCode, _, body, err := c.perform(ctx, http.MethodGet, fmt.Sprintf("/api/v1/admin/users/%d/api-keys?page=1&page_size=100&sort_by=created_at&sort_order=desc", userID), nil)
if err != nil {
return nil, fmt.Errorf("list managed api keys: %w", err)
}
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
return nil, newHTTPError(http.MethodGet, fmt.Sprintf("/api/v1/admin/users/%d/api-keys", userID), statusCode, body)
}
var envelope struct {
Data struct {
Items []adminAPIKeyRecord `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return nil, fmt.Errorf("decode admin api keys response: %w", err)
}
for _, item := range envelope.Data.Items {
if strings.TrimSpace(item.Key) == identity.CustomKey || strings.TrimSpace(item.Name) == identity.KeyName {
key := item
return &key, nil
}
}
return nil, fmt.Errorf("managed api key %q not found for user %d", identity.KeyName, userID)
}
func (c *Client) bindManagedSubscriptionAPIKey(ctx context.Context, keyID, groupID int64) error {
payload := map[string]any{"group_id": groupID}
statusCode, _, body, err := c.perform(ctx, http.MethodPut, fmt.Sprintf("/api/v1/admin/api-keys/%d", keyID), payload)
if err != nil {
return fmt.Errorf("bind managed api key group: %w", err)
}
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
return newHTTPError(http.MethodPut, fmt.Sprintf("/api/v1/admin/api-keys/%d", keyID), statusCode, body)
}
return nil
}
func (c *Client) cloneWithAuth(apiKey, bearerToken string) *Client {
if c == nil {
return nil
}
clone := *c
clone.apiKey = strings.TrimSpace(apiKey)
clone.bearerToken = strings.TrimSpace(bearerToken)
return &clone
}

View File

@@ -157,6 +157,7 @@ func validateProviders(providers []ProviderManifest) error {
seen := make(map[string]struct{}, len(providers)) seen := make(map[string]struct{}, len(providers))
for _, provider := range providers { for _, provider := range providers {
providerID := strings.TrimSpace(provider.ProviderID) providerID := strings.TrimSpace(provider.ProviderID)
missingDefaultModel := firstMissingDefaultModel(provider.DefaultModels, provider.ChannelTemplate.ModelMapping)
switch { switch {
case providerID == "": case providerID == "":
return fmt.Errorf("provider manifest: provider_id is required") return fmt.Errorf("provider manifest: provider_id is required")
@@ -180,6 +181,10 @@ func validateProviders(providers []ProviderManifest) error {
return fmt.Errorf("provider %q: channel_template.name is required", providerID) return fmt.Errorf("provider %q: channel_template.name is required", providerID)
case len(provider.ChannelTemplate.ModelMapping) == 0: case len(provider.ChannelTemplate.ModelMapping) == 0:
return fmt.Errorf("provider %q: channel_template.model_mapping must not be empty", providerID) return fmt.Errorf("provider %q: channel_template.model_mapping must not be empty", providerID)
case !containsProviderModel(provider.ChannelTemplate.ModelMapping, provider.SmokeTestModel):
return fmt.Errorf("provider %q: channel_template.model_mapping must include smoke_test_model %q", providerID, provider.SmokeTestModel)
case missingDefaultModel != "":
return fmt.Errorf("provider %q: channel_template.model_mapping must cover default_models, missing %q", providerID, missingDefaultModel)
case strings.TrimSpace(provider.PlanTemplate.Name) == "": case strings.TrimSpace(provider.PlanTemplate.Name) == "":
return fmt.Errorf("provider %q: plan_template.name is required", providerID) return fmt.Errorf("provider %q: plan_template.name is required", providerID)
case provider.PlanTemplate.ValidityDays <= 0: case provider.PlanTemplate.ValidityDays <= 0:
@@ -247,3 +252,29 @@ func contains(items []string, target string) bool {
} }
return false return false
} }
func containsProviderModel(modelMapping map[string]string, target string) bool {
trimmedTarget := strings.TrimSpace(target)
if trimmedTarget == "" {
return false
}
for sourceModel, mappedModel := range modelMapping {
if strings.TrimSpace(sourceModel) == trimmedTarget || strings.TrimSpace(mappedModel) == trimmedTarget {
return true
}
}
return false
}
func firstMissingDefaultModel(defaultModels []string, modelMapping map[string]string) string {
for _, model := range defaultModels {
trimmedModel := strings.TrimSpace(model)
if trimmedModel == "" {
continue
}
if !containsProviderModel(modelMapping, trimmedModel) {
return trimmedModel
}
}
return ""
}

View File

@@ -30,7 +30,7 @@ func TestLoadDirParsesAndValidatesPack(t *testing.T) {
"default_models": ["deepseek-chat", "deepseek-reasoner"], "default_models": ["deepseek-chat", "deepseek-reasoner"],
"smoke_test_model": "deepseek-chat", "smoke_test_model": "deepseek-chat",
"group_template": {"name": "DeepSeek 默认分组", "rate_multiplier": 1.0}, "group_template": {"name": "DeepSeek 默认分组", "rate_multiplier": 1.0},
"channel_template": {"name": "DeepSeek 默认渠道", "model_mapping": {"deepseek-chat": "deepseek-chat"}}, "channel_template": {"name": "DeepSeek 默认渠道", "model_mapping": {"deepseek-chat": "deepseek-chat", "deepseek-reasoner": "deepseek-reasoner"}},
"plan_template": {"name": "DeepSeek 默认套餐", "price": 19.9, "validity_days": 30, "validity_unit": "day"}, "plan_template": {"name": "DeepSeek 默认套餐", "price": 19.9, "validity_days": 30, "validity_unit": "day"},
"import": {"supports_multi_key": true, "supports_strict": true, "supports_partial": true} "import": {"supports_multi_key": true, "supports_strict": true, "supports_partial": true}
}`, }`,
@@ -82,6 +82,36 @@ func TestLoadDirRejectsInvalidProviderSchema(t *testing.T) {
} }
} }
func TestLoadDirRejectsSmokeTestModelMissingFromChannelMapping(t *testing.T) {
packDir := createPackFixture(t, map[string]string{
"pack.json": `{"pack_id":"openai-cn-pack","version":"1.0.0","vendor":"x","target_host":"sub2api","min_host_version":"0.1.126","max_host_version":"0.2.x","providers_dir":"providers","checksum_file":"checksums.txt"}`,
"providers/deepseek.json": `{"provider_id":"deepseek","display_name":"DeepSeek","base_url":"https://api.deepseek.com","platform":"openai","account_type":"apikey","default_models":["deepseek-v4-pro","deepseek-v4-flash"],"smoke_test_model":"deepseek-v4-pro","group_template":{"name":"g","rate_multiplier":1},"channel_template":{"name":"c","model_mapping":{"deepseek-chat":"deepseek-chat","deepseek-reasoner":"deepseek-reasoner"}},"plan_template":{"name":"p","price":1,"validity_days":30,"validity_unit":"day"},"import":{"supports_multi_key":true,"supports_strict":true,"supports_partial":true}}`,
})
_, err := LoadDir(packDir)
if err == nil {
t.Fatal("LoadDir() error = nil, want smoke_test_model channel mapping validation failure")
}
if !strings.Contains(err.Error(), "channel_template.model_mapping") || !strings.Contains(err.Error(), "smoke_test_model") {
t.Fatalf("LoadDir() error = %v, want smoke_test_model channel mapping detail", err)
}
}
func TestLoadDirRejectsDefaultModelsMissingFromChannelMapping(t *testing.T) {
packDir := createPackFixture(t, map[string]string{
"pack.json": `{"pack_id":"openai-cn-pack","version":"1.0.0","vendor":"x","target_host":"sub2api","min_host_version":"0.1.126","max_host_version":"0.2.x","providers_dir":"providers","checksum_file":"checksums.txt"}`,
"providers/minimax.json": `{"provider_id":"minimax","display_name":"MiniMax","base_url":"https://api.minimax.example.com","platform":"openai","account_type":"apikey","default_models":["MiniMax-M2.5-highspeed","MiniMax-M2.7-highspeed"],"smoke_test_model":"MiniMax-M2.7-highspeed","group_template":{"name":"g","rate_multiplier":1},"channel_template":{"name":"c","model_mapping":{"MiniMax-M2.7-highspeed":"MiniMax-M2.7-highspeed"}},"plan_template":{"name":"p","price":1,"validity_days":30,"validity_unit":"day"},"import":{"supports_multi_key":true,"supports_strict":true,"supports_partial":true}}`,
})
_, err := LoadDir(packDir)
if err == nil {
t.Fatal("LoadDir() error = nil, want default_models channel mapping validation failure")
}
if !strings.Contains(err.Error(), "default_models") || !strings.Contains(err.Error(), "channel_template.model_mapping") {
t.Fatalf("LoadDir() error = %v, want default_models channel mapping detail", err)
}
}
func createPackFixture(t *testing.T, files map[string]string) string { func createPackFixture(t *testing.T, files map[string]string) string {
t.Helper() t.Helper()

View File

@@ -0,0 +1,128 @@
package provision
import (
"context"
"strings"
"sub2api-cn-relay-manager/internal/pack"
"sub2api-cn-relay-manager/internal/store/sqlite"
)
type ModeAccessStatuses struct {
Subscription string
SelfService string
}
func SuggestResourceNamesForMode(provider pack.ProviderManifest, accessMode string) ResourceNames {
base := SuggestResourceNames(provider)
suffix := accessModeResourceSuffix(accessMode)
if suffix == "" {
return base
}
return ResourceNames{
Group: appendResourceNameSuffix(base.Group, suffix),
Channel: appendResourceNameSuffix(base.Channel, suffix),
Plan: appendResourceNameSuffix(base.Plan, suffix),
}
}
func accessModeResourceSuffix(accessMode string) string {
switch strings.TrimSpace(accessMode) {
case AccessModeSubscription:
return "subscription"
case AccessModeSelfService:
return "self-service"
default:
return ""
}
}
func appendResourceNameSuffix(name, suffix string) string {
name = strings.TrimSpace(name)
suffix = strings.TrimSpace(suffix)
if name == "" || suffix == "" {
return name
}
if strings.HasSuffix(name, "-"+suffix) {
return name
}
return name + "-" + suffix
}
func LatestModeAccessStatuses(ctx context.Context, store *sqlite.DB, batches []sqlite.ImportBatch) (ModeAccessStatuses, error) {
var statuses ModeAccessStatuses
for _, batch := range batches {
if statuses.Subscription != "" && statuses.SelfService != "" {
break
}
closures, err := store.AccessClosures().GetByBatchID(ctx, batch.ID)
if err != nil {
return ModeAccessStatuses{}, err
}
batchStatuses := modeAccessStatusesForBatch(batch, closures)
if statuses.Subscription == "" && strings.TrimSpace(batchStatuses.Subscription) != "" {
statuses.Subscription = strings.TrimSpace(batchStatuses.Subscription)
}
if statuses.SelfService == "" && strings.TrimSpace(batchStatuses.SelfService) != "" {
statuses.SelfService = strings.TrimSpace(batchStatuses.SelfService)
}
}
return statuses, nil
}
func modeAccessStatusesForBatch(batch sqlite.ImportBatch, closures []sqlite.AccessClosureRecord) ModeAccessStatuses {
statuses := ModeAccessStatuses{}
for _, closure := range closures {
status := strings.TrimSpace(closure.Status)
switch strings.TrimSpace(closure.ClosureType) {
case AccessModeSubscription:
statuses.Subscription = status
case AccessModeSelfService:
statuses.SelfService = status
}
}
if statuses.Subscription == "" && statuses.SelfService == "" {
return seedModeAccessStatuses(batch.AccessStatus)
}
return statuses
}
func seedModeAccessStatuses(accessStatus string) ModeAccessStatuses {
switch strings.TrimSpace(accessStatus) {
case AccessStatusFullyReady:
return ModeAccessStatuses{Subscription: AccessStatusSubscriptionReady, SelfService: AccessStatusSelfServiceReady}
case AccessStatusSubscriptionReady:
return ModeAccessStatuses{Subscription: AccessStatusSubscriptionReady}
case AccessStatusSelfServiceReady:
return ModeAccessStatuses{SelfService: AccessStatusSelfServiceReady}
default:
return ModeAccessStatuses{}
}
}
func AggregateAccessStatus(statuses ModeAccessStatuses) string {
subscriptionReady := isReadyAccessStatus(statuses.Subscription, AccessModeSubscription)
selfServiceReady := isReadyAccessStatus(statuses.SelfService, AccessModeSelfService)
switch {
case subscriptionReady && selfServiceReady:
return AccessStatusFullyReady
case subscriptionReady:
return AccessStatusSubscriptionReady
case selfServiceReady:
return AccessStatusSelfServiceReady
default:
return AccessStatusBroken
}
}
func isReadyAccessStatus(status, mode string) bool {
status = strings.TrimSpace(status)
switch mode {
case AccessModeSubscription:
return status == AccessStatusSubscriptionReady || status == AccessStatusFullyReady
case AccessModeSelfService:
return status == AccessStatusSelfServiceReady || status == AccessStatusFullyReady
default:
return status != "" && status != AccessStatusBroken
}
}

View File

@@ -278,7 +278,7 @@ func accessClosureType(accessClosures []sqlite.AccessClosureRecord) string {
} }
func buildManagedResourceListRequest(provider pack.ProviderManifest, accessMode string) sub2api.ListManagedResourcesRequest { func buildManagedResourceListRequest(provider pack.ProviderManifest, accessMode string) sub2api.ListManagedResourcesRequest {
names := SuggestResourceNames(provider) names := SuggestResourceNamesForMode(provider, accessMode)
req := sub2api.ListManagedResourcesRequest{ req := sub2api.ListManagedResourcesRequest{
GroupName: names.Group, GroupName: names.Group,
ChannelName: names.Channel, ChannelName: names.Channel,

View File

@@ -215,10 +215,11 @@ func TestDeriveProviderStatus(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
batchStatus string batchStatus string
accessStatus string
reconcileStatus string reconcileStatus string
want string want string
}{ }{
{name: "reconcile wins", batchStatus: BatchStatusSucceeded, reconcileStatus: "degraded", want: "degraded"}, {name: "recovered success beats stale reconcile", batchStatus: BatchStatusSucceeded, accessStatus: AccessStatusSelfServiceReady, reconcileStatus: "degraded", want: ProviderStatusActive},
{name: "succeeded batch", batchStatus: BatchStatusSucceeded, reconcileStatus: "not_run", want: ProviderStatusActive}, {name: "succeeded batch", batchStatus: BatchStatusSucceeded, reconcileStatus: "not_run", want: ProviderStatusActive},
{name: "failed batch", batchStatus: BatchStatusFailed, want: ProviderStatusFailed}, {name: "failed batch", batchStatus: BatchStatusFailed, want: ProviderStatusFailed},
{name: "running batch", batchStatus: "running", want: "running"}, {name: "running batch", batchStatus: "running", want: "running"},
@@ -226,13 +227,60 @@ func TestDeriveProviderStatus(t *testing.T) {
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
if got := deriveProviderStatus(tc.batchStatus, tc.reconcileStatus); got != tc.want { if got := deriveProviderStatus(tc.batchStatus, tc.accessStatus, tc.reconcileStatus); got != tc.want {
t.Fatalf("deriveProviderStatus(%q, %q) = %q, want %q", tc.batchStatus, tc.reconcileStatus, got, tc.want) t.Fatalf("deriveProviderStatus(%q, %q, %q) = %q, want %q", tc.batchStatus, tc.accessStatus, tc.reconcileStatus, got, tc.want)
} }
}) })
} }
} }
func TestProviderStatusServiceAggregatesLatestAccessModesAcrossBatches(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(t, store)
ctx := context.Background()
hostID := seedProvisionHost(t, store, "host-1", "https://sub2api.example.com")
packID, err := store.Packs().Create(ctx, sqlite.Pack{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", Checksum: "checksum-1"})
if err != nil {
t.Fatalf("Packs().Create() error = %v", err)
}
providerID, err := store.Providers().Create(ctx, sqlite.Provider{PackID: packID, ProviderID: "deepseek", DisplayName: "DeepSeek", BaseURL: "https://api.deepseek.com", Platform: "openai"})
if err != nil {
t.Fatalf("Providers().Create() error = %v", err)
}
batchSubscription, err := store.ImportBatches().Create(ctx, sqlite.ImportBatch{HostID: hostID, PackID: packID, ProviderID: providerID, Mode: ImportModePartial, BatchStatus: BatchStatusSucceeded, AccessStatus: AccessStatusSubscriptionReady})
if err != nil {
t.Fatalf("ImportBatches().Create(subscription) error = %v", err)
}
if _, err := store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{BatchID: batchSubscription, ClosureType: AccessModeSubscription, Status: AccessStatusSubscriptionReady, DetailsJSON: "{}"}); err != nil {
t.Fatalf("AccessClosures().Create(subscription) error = %v", err)
}
batchSelfService, err := store.ImportBatches().Create(ctx, sqlite.ImportBatch{HostID: hostID, PackID: packID, ProviderID: providerID, Mode: ImportModePartial, BatchStatus: BatchStatusSucceeded, AccessStatus: AccessStatusSelfServiceReady})
if err != nil {
t.Fatalf("ImportBatches().Create(self_service) error = %v", err)
}
if _, err := store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{BatchID: batchSelfService, ClosureType: AccessModeSelfService, Status: AccessStatusSelfServiceReady, DetailsJSON: "{}"}); err != nil {
t.Fatalf("AccessClosures().Create(self_service) error = %v", err)
}
if _, err := store.ReconcileRuns().Create(ctx, sqlite.ReconcileRun{BatchID: batchSelfService, HostID: hostID, ProviderID: providerID, Status: "drifted", SummaryJSON: `{"missing_count":1}`}); err != nil {
t.Fatalf("ReconcileRuns().Create() error = %v", err)
}
snapshot, err := NewProviderStatusService(store).GetStatus(ctx, ProviderQuery{ProviderID: "deepseek", PackID: "openai-cn-pack", HostID: "host-1"})
if err != nil {
t.Fatalf("GetStatus() error = %v", err)
}
if snapshot.LatestAccessStatus != AccessStatusFullyReady {
t.Fatalf("LatestAccessStatus = %q, want %q", snapshot.LatestAccessStatus, AccessStatusFullyReady)
}
if snapshot.ProviderStatus != ProviderStatusActive {
t.Fatalf("ProviderStatus = %q, want %q", snapshot.ProviderStatus, ProviderStatusActive)
}
if snapshot.LatestReconcileStatus != "drifted" {
t.Fatalf("LatestReconcileStatus = %q, want drifted", snapshot.LatestReconcileStatus)
}
}
func TestBuildPackAndProviderRecord(t *testing.T) { func TestBuildPackAndProviderRecord(t *testing.T) {
packRow, err := buildPackRecord(sampleLoadedPack()) packRow, err := buildPackRecord(sampleLoadedPack())
if err != nil { if err != nil {

View File

@@ -199,7 +199,7 @@ func (s *ImportService) Import(ctx context.Context, req ImportRequest) (report I
} }
func (s *ImportService) ensureManagedResources(ctx context.Context, provider pack.ProviderManifest, accessMode string) (resolvedManagedResources, error) { func (s *ImportService) ensureManagedResources(ctx context.Context, provider pack.ProviderManifest, accessMode string) (resolvedManagedResources, error) {
names := SuggestResourceNames(provider) names := SuggestResourceNamesForMode(provider, accessMode)
snapshot, err := s.host.ListManagedResources(ctx, sub2api.ListManagedResourcesRequest{ snapshot, err := s.host.ListManagedResources(ctx, sub2api.ListManagedResourcesRequest{
GroupName: names.Group, GroupName: names.Group,
ChannelName: names.Channel, ChannelName: names.Channel,
@@ -210,14 +210,14 @@ func (s *ImportService) ensureManagedResources(ctx context.Context, provider pac
} }
result := resolvedManagedResources{} result := resolvedManagedResources{}
group, created, err := ensureGroup(ctx, s.host, snapshot.Groups, provider, accessMode) group, created, err := ensureGroup(ctx, s.host, snapshot.Groups, provider, accessMode, names.Group)
if err != nil { if err != nil {
return resolvedManagedResources{}, fmt.Errorf("ensure group: %w", err) return resolvedManagedResources{}, fmt.Errorf("ensure group: %w", err)
} }
result.Group = group result.Group = group
result.CreatedGroup = created result.CreatedGroup = created
channel, created, err := ensureChannel(ctx, s.host, snapshot.Channels, provider, group.ID) channel, created, err := ensureChannel(ctx, s.host, snapshot.Channels, provider, group.ID, names.Channel)
if err != nil { if err != nil {
return resolvedManagedResources{}, fmt.Errorf("ensure channel: %w", err) return resolvedManagedResources{}, fmt.Errorf("ensure channel: %w", err)
} }
@@ -225,7 +225,7 @@ func (s *ImportService) ensureManagedResources(ctx context.Context, provider pac
result.CreatedChannel = created result.CreatedChannel = created
if accessMode == AccessModeSubscription { if accessMode == AccessModeSubscription {
plan, created, err := ensurePlan(ctx, s.host, snapshot.Plans, provider, group.ID) plan, created, err := ensurePlan(ctx, s.host, snapshot.Plans, provider, group.ID, names.Plan)
if err != nil { if err != nil {
return resolvedManagedResources{}, fmt.Errorf("ensure plan: %w", err) return resolvedManagedResources{}, fmt.Errorf("ensure plan: %w", err)
} }
@@ -236,10 +236,10 @@ func (s *ImportService) ensureManagedResources(ctx context.Context, provider pac
return result, nil return result, nil
} }
func ensureGroup(ctx context.Context, host hostAdapter, existing []sub2api.NamedResource, provider pack.ProviderManifest, accessMode string) (sub2api.GroupRef, bool, error) { func ensureGroup(ctx context.Context, host hostAdapter, existing []sub2api.NamedResource, provider pack.ProviderManifest, accessMode, groupName string) (sub2api.GroupRef, bool, error) {
switch len(existing) { switch len(existing) {
case 0: case 0:
groupReq := sub2api.CreateGroupRequest{Name: provider.GroupTemplate.Name, Platform: provider.Platform, RateMultiplier: provider.GroupTemplate.RateMultiplier} groupReq := sub2api.CreateGroupRequest{Name: groupName, Platform: provider.Platform, RateMultiplier: provider.GroupTemplate.RateMultiplier}
if accessMode == AccessModeSubscription { if accessMode == AccessModeSubscription {
groupReq.SubscriptionType = "subscription" groupReq.SubscriptionType = "subscription"
} }
@@ -248,38 +248,52 @@ func ensureGroup(ctx context.Context, host hostAdapter, existing []sub2api.Named
case 1: case 1:
return sub2api.GroupRef{ID: existing[0].ID, Name: existing[0].Name}, false, nil return sub2api.GroupRef{ID: existing[0].ID, Name: existing[0].Name}, false, nil
default: default:
return sub2api.GroupRef{}, false, fmt.Errorf("multiple groups already exist for %q", provider.GroupTemplate.Name) return sub2api.GroupRef{}, false, fmt.Errorf("multiple groups already exist for %q", groupName)
} }
} }
func ensureChannel(ctx context.Context, host hostAdapter, existing []sub2api.NamedResource, provider pack.ProviderManifest, groupID string) (sub2api.ChannelRef, bool, error) { func ensureChannel(ctx context.Context, host hostAdapter, existing []sub2api.NamedResource, provider pack.ProviderManifest, groupID, channelName string) (sub2api.ChannelRef, bool, error) {
channelReq := buildChannelRequest(provider, groupID, channelName)
switch len(existing) { switch len(existing) {
case 0: case 0:
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) channel, err := host.CreateChannel(ctx, channelReq)
return channel, true, err return channel, true, err
case 1: case 1:
if err := host.UpdateChannel(ctx, existing[0].ID, channelReq); err != nil {
return sub2api.ChannelRef{}, false, err
}
return sub2api.ChannelRef{ID: existing[0].ID, Name: existing[0].Name}, false, nil return sub2api.ChannelRef{ID: existing[0].ID, Name: existing[0].Name}, false, nil
default: default:
return sub2api.ChannelRef{}, false, fmt.Errorf("multiple channels already exist for %q", provider.ChannelTemplate.Name) return sub2api.ChannelRef{}, false, fmt.Errorf("multiple channels already exist for %q", channelName)
} }
} }
func ensurePlan(ctx context.Context, host hostAdapter, existing []sub2api.NamedResource, provider pack.ProviderManifest, groupID string) (sub2api.PlanRef, bool, error) { func buildChannelRequest(provider pack.ProviderManifest, groupID, channelName string) sub2api.CreateChannelRequest {
return sub2api.CreateChannelRequest{
Name: channelName,
GroupIDs: []string{groupID},
ModelMapping: provider.ChannelTemplate.ModelMapping,
ModelPricing: []sub2api.ChannelModelPricing{{
Platform: provider.Platform,
Models: append([]string(nil), provider.DefaultModels...),
BillingMode: "token",
Intervals: []sub2api.ChannelPricingTier{},
}},
Platform: provider.Platform,
RestrictModels: true,
BillingModelSource: "channel_mapped",
}
}
func ensurePlan(ctx context.Context, host hostAdapter, existing []sub2api.NamedResource, provider pack.ProviderManifest, groupID, planName string) (sub2api.PlanRef, bool, error) {
switch len(existing) { switch len(existing) {
case 0: case 0:
plan, err := host.CreatePlan(ctx, sub2api.CreatePlanRequest{GroupID: groupID, Name: provider.PlanTemplate.Name, Price: provider.PlanTemplate.Price, ValidityDays: provider.PlanTemplate.ValidityDays, ValidityUnit: provider.PlanTemplate.ValidityUnit}) plan, err := host.CreatePlan(ctx, sub2api.CreatePlanRequest{GroupID: groupID, Name: planName, Price: provider.PlanTemplate.Price, ValidityDays: provider.PlanTemplate.ValidityDays, ValidityUnit: provider.PlanTemplate.ValidityUnit})
return plan, true, err return plan, true, err
case 1: case 1:
return sub2api.PlanRef{ID: existing[0].ID, Name: existing[0].Name}, false, nil return sub2api.PlanRef{ID: existing[0].ID, Name: existing[0].Name}, false, nil
default: default:
return sub2api.PlanRef{}, false, fmt.Errorf("multiple plans already exist for %q", provider.PlanTemplate.Name) return sub2api.PlanRef{}, false, fmt.Errorf("multiple plans already exist for %q", planName)
} }
} }
@@ -329,8 +343,9 @@ func buildBatchAccountsRequest(provider pack.ProviderManifest, groupID string, k
Type: provider.AccountType, Type: provider.AccountType,
GroupIDs: []string{groupID}, GroupIDs: []string{groupID},
Credentials: map[string]any{ Credentials: map[string]any{
"base_url": provider.BaseURL, "base_url": provider.BaseURL,
"api_key": key, "api_key": key,
"model_mapping": provider.ChannelTemplate.ModelMapping,
}, },
}) })
} }

View File

@@ -152,7 +152,7 @@ func TestImportReusesExistingGroup(t *testing.T) {
}, },
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}}, gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
managedSnapshot: sub2api.ManagedResourceSnapshot{ managedSnapshot: sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_existing", Name: "DeepSeek 默认分组"}}, Groups: []sub2api.NamedResource{{ID: "group_existing", Name: "DeepSeek 默认分组-self-service"}},
}, },
} }
@@ -198,8 +198,8 @@ func TestImportCreatesChannelWithManifestModelMapping(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Import() error = %v", err) t.Fatalf("Import() error = %v", err)
} }
if host.createChannelReq.Name != "DeepSeek 默认渠道" { if host.createChannelReq.Name != "DeepSeek 默认渠道-self-service" {
t.Fatalf("CreateChannel().Name = %q, want DeepSeek 默认渠道", host.createChannelReq.Name) t.Fatalf("CreateChannel().Name = %q, want DeepSeek 默认渠道-self-service", host.createChannelReq.Name)
} }
if len(host.createChannelReq.GroupIDs) != 1 || host.createChannelReq.GroupIDs[0] != "group_1" { if len(host.createChannelReq.GroupIDs) != 1 || host.createChannelReq.GroupIDs[0] != "group_1" {
t.Fatalf("CreateChannel().GroupIDs = %v, want [group_1]", host.createChannelReq.GroupIDs) t.Fatalf("CreateChannel().GroupIDs = %v, want [group_1]", host.createChannelReq.GroupIDs)
@@ -213,6 +213,31 @@ func TestImportCreatesChannelWithManifestModelMapping(t *testing.T) {
if host.createChannelReq.BillingModelSource != "channel_mapped" { if host.createChannelReq.BillingModelSource != "channel_mapped" {
t.Fatalf("CreateChannel().BillingModelSource = %q, want channel_mapped", host.createChannelReq.BillingModelSource) t.Fatalf("CreateChannel().BillingModelSource = %q, want channel_mapped", host.createChannelReq.BillingModelSource)
} }
if len(host.createChannelReq.ModelPricing) != 1 {
t.Fatalf("CreateChannel().ModelPricing len = %d, want 1", len(host.createChannelReq.ModelPricing))
}
if len(host.createChannelReq.ModelPricing[0].Models) != 2 {
t.Fatalf("CreateChannel().ModelPricing[0].Models = %v, want default model coverage", host.createChannelReq.ModelPricing[0].Models)
}
if host.createChannelReq.ModelPricing[0].BillingMode != "token" {
t.Fatalf("CreateChannel().ModelPricing[0].BillingMode = %q, want token", host.createChannelReq.ModelPricing[0].BillingMode)
}
if len(host.batchCreateReq.Accounts) != 1 {
t.Fatalf("BatchCreateAccounts().Accounts len = %d, want 1", len(host.batchCreateReq.Accounts))
}
credentials := host.batchCreateReq.Accounts[0].Credentials
switch rawMapping := credentials["model_mapping"].(type) {
case map[string]string:
if got := rawMapping["deepseek-chat"]; got != "deepseek-chat" {
t.Fatalf("BatchCreateAccounts().Credentials.model_mapping = %+v, want deepseek-chat passthrough", rawMapping)
}
case map[string]any:
if got, _ := rawMapping["deepseek-chat"].(string); got != "deepseek-chat" {
t.Fatalf("BatchCreateAccounts().Credentials.model_mapping = %+v, want deepseek-chat passthrough", rawMapping)
}
default:
t.Fatalf("BatchCreateAccounts().Credentials = %+v, want model_mapping map", credentials)
}
} }
func sampleProviderManifest() pack.ProviderManifest { func sampleProviderManifest() pack.ProviderManifest {
@@ -230,8 +255,48 @@ func sampleProviderManifest() pack.ProviderManifest {
} }
} }
func TestImportReconcilesExistingChannelConfiguration(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"}},
managedSnapshot: sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_existing", Name: "DeepSeek 默认分组-self-service"}},
Channels: []sub2api.NamedResource{{ID: "channel_existing", Name: "DeepSeek 默认渠道-self-service"}},
},
}
_, 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.createChannelCalls != 0 {
t.Fatalf("CreateChannel() calls = %d, want 0 when channel already exists", host.createChannelCalls)
}
if host.updateChannelCalls != 1 {
t.Fatalf("UpdateChannel() calls = %d, want 1", host.updateChannelCalls)
}
if host.updateChannelID != "channel_existing" {
t.Fatalf("UpdateChannel() id = %q, want channel_existing", host.updateChannelID)
}
if len(host.updateChannelReq.ModelPricing) != 1 {
t.Fatalf("UpdateChannel().ModelPricing len = %d, want 1", len(host.updateChannelReq.ModelPricing))
}
}
type fakeHostAdapter struct { type fakeHostAdapter struct {
batchAccounts []sub2api.AccountRef batchAccounts []sub2api.AccountRef
batchCreateReq sub2api.BatchCreateAccountsRequest
testResults map[string]sub2api.ProbeResult testResults map[string]sub2api.ProbeResult
models map[string][]sub2api.AccountModel models map[string][]sub2api.AccountModel
gatewayResult sub2api.GatewayAccessResult gatewayResult sub2api.GatewayAccessResult
@@ -246,9 +311,12 @@ type fakeHostAdapter struct {
listManagedReq sub2api.ListManagedResourcesRequest listManagedReq sub2api.ListManagedResourcesRequest
createGroupCalls int createGroupCalls int
createChannelCalls int createChannelCalls int
updateChannelCalls int
createPlanCalls int createPlanCalls int
createGroupReq sub2api.CreateGroupRequest createGroupReq sub2api.CreateGroupRequest
createChannelReq sub2api.CreateChannelRequest createChannelReq sub2api.CreateChannelRequest
updateChannelID string
updateChannelReq sub2api.CreateChannelRequest
} }
func (f *fakeHostAdapter) GetHostVersion(context.Context) (string, error) { func (f *fakeHostAdapter) GetHostVersion(context.Context) (string, error) {
@@ -274,6 +342,12 @@ func (f *fakeHostAdapter) CreateChannel(_ context.Context, req sub2api.CreateCha
f.createChannelReq = req f.createChannelReq = req
return sub2api.ChannelRef{ID: "channel_1", Name: "c"}, nil return sub2api.ChannelRef{ID: "channel_1", Name: "c"}, nil
} }
func (f *fakeHostAdapter) UpdateChannel(_ context.Context, channelID string, req sub2api.CreateChannelRequest) error {
f.updateChannelCalls++
f.updateChannelID = channelID
f.updateChannelReq = req
return nil
}
func (f *fakeHostAdapter) DeleteChannel(_ context.Context, channelID string) error { func (f *fakeHostAdapter) DeleteChannel(_ context.Context, channelID string) error {
f.deletedResources = append(f.deletedResources, "channel:"+channelID) f.deletedResources = append(f.deletedResources, "channel:"+channelID)
return nil return nil
@@ -289,7 +363,8 @@ func (f *fakeHostAdapter) DeletePlan(_ context.Context, planID string) error {
func (f *fakeHostAdapter) CreateAccount(context.Context, sub2api.CreateAccountRequest) (sub2api.AccountRef, error) { func (f *fakeHostAdapter) CreateAccount(context.Context, sub2api.CreateAccountRequest) (sub2api.AccountRef, error) {
return sub2api.AccountRef{}, errors.New("unused") return sub2api.AccountRef{}, errors.New("unused")
} }
func (f *fakeHostAdapter) BatchCreateAccounts(_ context.Context, _ sub2api.BatchCreateAccountsRequest) ([]sub2api.AccountRef, error) { func (f *fakeHostAdapter) BatchCreateAccounts(_ context.Context, req sub2api.BatchCreateAccountsRequest) ([]sub2api.AccountRef, error) {
f.batchCreateReq = req
if f.batchCreateErr != nil { if f.batchCreateErr != nil {
return nil, f.batchCreateErr return nil, f.batchCreateErr
} }
@@ -313,6 +388,9 @@ func (f *fakeHostAdapter) GetAccountModels(_ context.Context, accountID string)
} }
return models, nil return models, nil
} }
func (f *fakeHostAdapter) EnsureSubscriptionAccess(_ context.Context, req sub2api.EnsureSubscriptionAccessRequest) (sub2api.SubscriptionAccessRef, error) {
return sub2api.SubscriptionAccessRef{UserID: req.UserSelector, APIKey: "managed-subscription-key"}, nil
}
func (f *fakeHostAdapter) AssignSubscription(_ context.Context, req sub2api.AssignSubscriptionRequest) (sub2api.SubscriptionRef, error) { func (f *fakeHostAdapter) AssignSubscription(_ context.Context, req sub2api.AssignSubscriptionRequest) (sub2api.SubscriptionRef, error) {
if f.assignErr != nil { if f.assignErr != nil {
return sub2api.SubscriptionRef{}, f.assignErr return sub2api.SubscriptionRef{}, f.assignErr

View File

@@ -57,7 +57,7 @@ func (s *PreviewService) PreviewImport(ctx context.Context, req PreviewRequest)
return PreviewReport{}, fmt.Errorf("preview host is required") return PreviewReport{}, fmt.Errorf("preview host is required")
} }
names := SuggestResourceNames(req.Provider) names := SuggestResourceNamesForMode(req.Provider, req.Mode)
snapshot, err := s.host.ListManagedResources(ctx, sub2api.ListManagedResourcesRequest{ snapshot, err := s.host.ListManagedResources(ctx, sub2api.ListManagedResourcesRequest{
GroupName: names.Group, GroupName: names.Group,
ChannelName: names.Channel, ChannelName: names.Channel,

View File

@@ -23,6 +23,23 @@ func TestSuggestResourceNames(t *testing.T) {
} }
} }
func TestSuggestResourceNamesIncludesAccessModeSuffix(t *testing.T) {
provider := sampleProviderManifest()
provider.GroupTemplate.Name = ""
provider.ChannelTemplate.Name = ""
provider.PlanTemplate.Name = ""
names := SuggestResourceNamesForMode(provider, AccessModeSubscription)
want := ResourceNames{
Group: "crm-deepseek-group-subscription",
Channel: "crm-deepseek-channel-subscription",
Plan: "crm-deepseek-plan-subscription",
}
if !reflect.DeepEqual(names, want) {
t.Fatalf("SuggestResourceNamesForMode() = %#v, want %#v", names, want)
}
}
func TestPreviewServiceReportsCreateActionsWhenHostHasNoResources(t *testing.T) { func TestPreviewServiceReportsCreateActionsWhenHostHasNoResources(t *testing.T) {
host := &fakePreviewHost{} host := &fakePreviewHost{}
svc := NewPreviewService(host) svc := NewPreviewService(host)

View File

@@ -69,13 +69,18 @@ func (s *ProviderStatusService) snapshot(ctx context.Context, query ProviderQuer
if err != nil { if err != nil {
return ProviderSnapshot{}, err return ProviderSnapshot{}, err
} }
reconcileRuns, err := s.store.ReconcileRuns().GetByBatchID(ctx, batchRow.ID) batches, err := s.store.ImportBatches().ListByProviderIDAndHostID(ctx, provider.ID, hostRow.ID)
if err != nil { if err != nil {
return ProviderSnapshot{}, err return ProviderSnapshot{}, err
} }
latestAccessStatus := batchRow.AccessStatus modeStatuses, err := LatestModeAccessStatuses(ctx, s.store, batches)
if len(accessClosures) > 0 { if err != nil {
latestAccessStatus = firstNonEmpty(accessClosures[len(accessClosures)-1].Status, latestAccessStatus) return ProviderSnapshot{}, err
}
latestAccessStatus := AggregateAccessStatus(modeStatuses)
reconcileRuns, err := s.store.ReconcileRuns().GetByBatchID(ctx, batchRow.ID)
if err != nil {
return ProviderSnapshot{}, err
} }
latestReconcileStatus := "not_run" latestReconcileStatus := "not_run"
latestReconcileSummary := map[string]any{} latestReconcileSummary := map[string]any{}
@@ -87,7 +92,7 @@ func (s *ProviderStatusService) snapshot(ctx context.Context, query ProviderQuer
} }
} }
} }
providerStatus := deriveProviderStatus(batchRow.BatchStatus, latestReconcileStatus) providerStatus := deriveProviderStatus(batchRow.BatchStatus, latestAccessStatus, latestReconcileStatus)
return ProviderSnapshot{ return ProviderSnapshot{
Host: hostRow, Host: hostRow,
Pack: packRow, Pack: packRow,
@@ -162,8 +167,12 @@ func (s *ProviderStatusService) resolveHostAndBatch(ctx context.Context, provide
return hostRow, batches[0], nil return hostRow, batches[0], nil
} }
func deriveProviderStatus(batchStatus, reconcileStatus string) string { func deriveProviderStatus(batchStatus, accessStatus, reconcileStatus string) string {
reconcileStatus = strings.TrimSpace(reconcileStatus) reconcileStatus = strings.TrimSpace(reconcileStatus)
accessStatus = strings.TrimSpace(accessStatus)
if strings.TrimSpace(batchStatus) == BatchStatusSucceeded && accessStatus != "" && accessStatus != AccessStatusBroken {
return ProviderStatusActive
}
if reconcileStatus != "" && reconcileStatus != "not_run" { if reconcileStatus != "" && reconcileStatus != "not_run" {
return reconcileStatus return reconcileStatus
} }

View File

@@ -54,8 +54,8 @@ func TestProviderStatusServiceReturnsLatestSnapshot(t *testing.T) {
if snapshot.Provider.ProviderID != "deepseek" { if snapshot.Provider.ProviderID != "deepseek" {
t.Fatalf("Provider.ProviderID = %q, want deepseek", snapshot.Provider.ProviderID) t.Fatalf("Provider.ProviderID = %q, want deepseek", snapshot.Provider.ProviderID)
} }
if snapshot.ProviderStatus != "drifted" { if snapshot.ProviderStatus != ProviderStatusActive {
t.Fatalf("ProviderStatus = %q, want drifted", snapshot.ProviderStatus) t.Fatalf("ProviderStatus = %q, want %q", snapshot.ProviderStatus, ProviderStatusActive)
} }
if snapshot.LatestAccessStatus != AccessStatusSelfServiceReady { if snapshot.LatestAccessStatus != AccessStatusSelfServiceReady {
t.Fatalf("LatestAccessStatus = %q, want %q", snapshot.LatestAccessStatus, AccessStatusSelfServiceReady) t.Fatalf("LatestAccessStatus = %q, want %q", snapshot.LatestAccessStatus, AccessStatusSelfServiceReady)

View File

@@ -28,8 +28,8 @@ func TestReconcileServiceReturnsActiveAfterProbeRerun(t *testing.T) {
batchID := seedRuntimeImportForReconcile(t, store, host) batchID := seedRuntimeImportForReconcile(t, store, host)
host.managedSnapshot = sub2api.ManagedResourceSnapshot{ host.managedSnapshot = sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组"}}, Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组-self-service"}},
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道"}}, Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道-self-service"}},
Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}}, Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}},
} }
@@ -82,8 +82,8 @@ func TestReconcileServiceReturnsDegradedWhenProbeRerunFails(t *testing.T) {
seedRuntimeImportForReconcile(t, store, host) seedRuntimeImportForReconcile(t, store, host)
host.managedSnapshot = sub2api.ManagedResourceSnapshot{ host.managedSnapshot = sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组"}}, Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组-self-service"}},
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道"}}, Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道-self-service"}},
Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}}, Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}},
} }
@@ -124,8 +124,8 @@ func TestReconcileServiceReturnsDriftedWhenManagedResourceMissing(t *testing.T)
seedRuntimeImportForReconcile(t, store, host) seedRuntimeImportForReconcile(t, store, host)
host.managedSnapshot = sub2api.ManagedResourceSnapshot{ host.managedSnapshot = sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组"}}, Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组-self-service"}},
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道"}}, Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道-self-service"}},
Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}}, Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}},
} }
@@ -166,8 +166,8 @@ func TestReconcileServiceIgnoresSubscriptionPlanForSelfServiceBatch(t *testing.T
seedRuntimeImportForReconcile(t, store, host) seedRuntimeImportForReconcile(t, store, host)
host.managedSnapshot = sub2api.ManagedResourceSnapshot{ host.managedSnapshot = sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组"}}, Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组-self-service"}},
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道"}}, Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道-self-service"}},
Plans: []sub2api.NamedResource{{ID: "plan_1", Name: "DeepSeek 默认套餐"}}, Plans: []sub2api.NamedResource{{ID: "plan_1", Name: "DeepSeek 默认套餐"}},
Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}}, Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}},
} }
@@ -212,8 +212,8 @@ func TestReconcileServicePassesAccountNamePrefixToManagedResourceSnapshot(t *tes
seedRuntimeImportForReconcile(t, store, host) seedRuntimeImportForReconcile(t, store, host)
host.managedSnapshot = sub2api.ManagedResourceSnapshot{ host.managedSnapshot = sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组"}}, Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组-self-service"}},
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道"}}, Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道-self-service"}},
Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}}, Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}},
} }

View File

@@ -1,4 +1,4 @@
3e3326e40d51a3753adc6fde0aa8859dc5d2076726a692aae45e36f7b27c89d6 pack.json 3e3326e40d51a3753adc6fde0aa8859dc5d2076726a692aae45e36f7b27c89d6 pack.json
46da7cd7521c7b808e51dcbb190b14cbb84c6d864557d56de75acad4d2e0fa85 providers/deepseek.json 5d33003fb6fefaf2dbb8445be7c18cc817d266215bc996ae91b30fbed5a98e7b providers/deepseek.json
5dcc402daddacce6dcaceb1501020342f1b1121fbffe9097ede4d5aae072f84e providers/minimax.json 5dcc402daddacce6dcaceb1501020342f1b1121fbffe9097ede4d5aae072f84e providers/minimax.json
fa486a449407f38de8b180ff301568deccef5177ca0436158b1d5b0e6d9328b2 providers/openai-zhongzhuan.json fa486a449407f38de8b180ff301568deccef5177ca0436158b1d5b0e6d9328b2 providers/openai-zhongzhuan.json

View File

@@ -13,8 +13,8 @@
"channel_template": { "channel_template": {
"name": "DeepSeek 默认渠道", "name": "DeepSeek 默认渠道",
"model_mapping": { "model_mapping": {
"deepseek-chat": "deepseek-chat", "deepseek-v4-pro": "deepseek-v4-pro",
"deepseek-reasoner": "deepseek-reasoner" "deepseek-v4-flash": "deepseek-v4-flash"
} }
}, },
"plan_template": { "plan_template": {

View File

@@ -15,6 +15,10 @@ REMOTE="${REMOTE:-ubuntu@43.155.133.187}"
CRM_BASE="${CRM_BASE:-http://127.0.0.1:18088}" CRM_BASE="${CRM_BASE:-http://127.0.0.1:18088}"
HOST_BASE="${HOST_BASE:-http://127.0.0.1:18087}" HOST_BASE="${HOST_BASE:-http://127.0.0.1:18087}"
CRM_HOST_BASE="${CRM_HOST_BASE:-$HOST_BASE}" CRM_HOST_BASE="${CRM_HOST_BASE:-$HOST_BASE}"
HOST_NAME="${HOST_NAME:-remote43-current-host}"
REMOTE_HOST_ENV_FILE="${REMOTE_HOST_ENV_FILE:-/home/ubuntu/sub2api-host-validation-fresh-deepseek-20260519_115244/.env}"
REMOTE_PG_CONTAINER="${REMOTE_PG_CONTAINER:-sub2api-relaymgr-pg}"
REMOTE_REDIS_CONTAINER="${REMOTE_REDIS_CONTAINER:-sub2api-relaymgr-redis}"
PACK_PATH="${PACK_PATH:-/home/ubuntu/sub2api-cn-relay-manager/packs/openai-cn-pack}" PACK_PATH="${PACK_PATH:-/home/ubuntu/sub2api-cn-relay-manager/packs/openai-cn-pack}"
ROOT="${ROOT:-$ROOT_DIR/artifacts/real-host-acceptance}" ROOT="${ROOT:-$ROOT_DIR/artifacts/real-host-acceptance}"
ART="${ART:-$ROOT/$(date +%Y%m%d_%H%M%S)_remote43_${provider_id}_key_import}" ART="${ART:-$ROOT/$(date +%Y%m%d_%H%M%S)_remote43_${provider_id}_key_import}"
@@ -22,6 +26,8 @@ MIN_BALANCE="${MIN_BALANCE:-10}"
SUBSCRIPTION_DAYS="${SUBSCRIPTION_DAYS:-30}" SUBSCRIPTION_DAYS="${SUBSCRIPTION_DAYS:-30}"
SUBSCRIPTION_NOTES="${SUBSCRIPTION_NOTES:-hermes remote subscription validation}" SUBSCRIPTION_NOTES="${SUBSCRIPTION_NOTES:-hermes remote subscription validation}"
mkdir -p "$ART" mkdir -p "$ART"
REMOTE_PG_CONTAINER_Q="$(printf '%q' "$REMOTE_PG_CONTAINER")"
REMOTE_REDIS_CONTAINER_Q="$(printf '%q' "$REMOTE_REDIS_CONTAINER")"
if [[ -n "$key_file" ]]; then if [[ -n "$key_file" ]]; then
upstream_key="$(tr -d '\r\n' < "$key_file")" upstream_key="$(tr -d '\r\n' < "$key_file")"
@@ -40,18 +46,67 @@ ssh_cmd() {
ssh -i "$KEY" -o StrictHostKeyChecking=no "$REMOTE" "$cmd" ssh -i "$KEY" -o StrictHostKeyChecking=no "$REMOTE" "$cmd"
} }
crm_curl_json() {
local method="$1"
local path="$2"
local payload="${3:-}"
if [[ -n "$payload" ]]; then
curl -fsS -X "$method" \
-H "Authorization: Bearer $crm_token" \
-H 'Content-Type: application/json' \
"${CRM_BASE}${path}" \
-d "$payload"
else
curl -fsS -X "$method" \
-H "Authorization: Bearer $crm_token" \
"${CRM_BASE}${path}"
fi
}
fetch_remote_host_bearer_token() {
ssh_cmd "python3 - <<'PY'
from pathlib import Path
import json, subprocess, sys
env_path = Path(${REMOTE_HOST_ENV_FILE@Q})
host_base = ${HOST_BASE@Q}
vals = {}
for line in env_path.read_text().splitlines():
if '=' not in line:
continue
key, value = line.split('=', 1)
vals[key] = value
payload = json.dumps({
'email': vals['ADMIN_EMAIL'],
'password': vals['ADMIN_PASSWORD'],
'turnstile_token': '',
}, ensure_ascii=False)
res = subprocess.run([
'curl', '-fsS', '-H', 'Content-Type: application/json', '-X', 'POST',
host_base.rstrip('/') + '/api/v1/auth/login', '-d', payload,
], text=True, capture_output=True)
obj = json.loads(res.stdout)
token = (obj.get('data') or {}).get('access_token', '')
if not token:
print(res.stdout, file=sys.stderr)
raise SystemExit('missing access_token from remote host login')
print(token)
PY"
}
remote_pg_exec() { remote_pg_exec() {
local sql="$1" local sql="$1"
local encoded local encoded
encoded="$(printf '%s' "$sql" | base64 -w0)" encoded="$(printf '%s' "$sql" | base64 -w0)"
ssh_cmd "printf '%s' '$encoded' | base64 -d | sudo -n docker exec -i sub2api-relaymgr-pg psql -U sub2api -d sub2api" ssh_cmd "printf '%s' '$encoded' | base64 -d | sudo -n docker exec -i $REMOTE_PG_CONTAINER_Q psql -U sub2api -d sub2api"
} }
remote_pg_query() { remote_pg_query() {
local sql="$1" local sql="$1"
local encoded local encoded
encoded="$(printf '%s' "$sql" | base64 -w0)" encoded="$(printf '%s' "$sql" | base64 -w0)"
ssh_cmd "printf '%s' '$encoded' | base64 -d | sudo -n docker exec -i sub2api-relaymgr-pg psql -U sub2api -d sub2api -At -F $'\t'" ssh_cmd "printf '%s' '$encoded' | base64 -d | sudo -n docker exec -i $REMOTE_PG_CONTAINER_Q psql -U sub2api -d sub2api -At -F $'\t'"
} }
remote_fetch_group_state() { remote_fetch_group_state() {
@@ -59,11 +114,12 @@ remote_fetch_group_state() {
local user_id="$2" local user_id="$2"
local api_key="$3" local api_key="$3"
local output_path="$4" local output_path="$4"
local encoded local sql
encoded="$(python3 - "$group_id" "$user_id" "$api_key" <<'PY' sql="$(python3 - "$group_id" "$user_id" "$api_key" <<'PY'
import json, sys import sys
group_id, user_id, api_key = sys.argv[1:4] group_id, user_id, api_key = sys.argv[1:4]
api_key_literal = "'" + api_key.replace("'", "''") + "'"
query = f""" query = f"""
WITH group_row AS ( WITH group_row AS (
SELECT row_to_json(g) AS data FROM groups g WHERE g.id = {group_id} SELECT row_to_json(g) AS data FROM groups g WHERE g.id = {group_id}
@@ -74,7 +130,7 @@ subscription_row AS (
ORDER BY s.id DESC LIMIT 1 ORDER BY s.id DESC LIMIT 1
), ),
key_row AS ( key_row AS (
SELECT row_to_json(k) AS data FROM api_keys k WHERE k.key = {json.dumps(api_key)} SELECT row_to_json(k) AS data FROM api_keys k WHERE k.key = {api_key_literal}
) )
SELECT json_build_object( SELECT json_build_object(
'group_id', {group_id}, 'group_id', {group_id},
@@ -86,7 +142,7 @@ SELECT json_build_object(
print(query) print(query)
PY PY
)" )"
ssh_cmd "printf '%s' '$encoded' | base64 -d | sudo -n docker exec -i sub2api-relaymgr-pg psql -U sub2api -d sub2api -At -F ''" > "$output_path" remote_pg_query "$sql" > "$output_path"
} }
python3 - "$ART/00-local-key-source.json" "$key_source" "$provider_id" "$upstream_key" <<'PY' python3 - "$ART/00-local-key-source.json" "$key_source" "$provider_id" "$upstream_key" <<'PY'
@@ -100,11 +156,17 @@ pathlib.Path(path).write_text(json.dumps({
}, ensure_ascii=False, indent=2), encoding='utf-8') }, ensure_ascii=False, indent=2), encoding='utf-8')
PY PY
crm_token="$(ssh_cmd "grep ^SUB2API_CRM_ADMIN_TOKEN= /home/ubuntu/sub2api-cn-relay-manager/.env.remote | cut -d= -f2-")" crm_token="${CRM_ADMIN_TOKEN:-}"
crm_token="${crm_token##*$'\n'}" if [[ -z "$crm_token" ]]; then
admin_key="$(ssh_cmd "sudo -n docker exec sub2api-relaymgr-pg psql -U sub2api -d sub2api -Atc \"select value from settings where key='admin_api_key';\"")" crm_token="$(ssh_cmd "grep ^SUB2API_CRM_ADMIN_TOKEN= /home/ubuntu/sub2api-cn-relay-manager/.env.remote | cut -d= -f2-")"
admin_key="${admin_key##*$'\n'}" crm_token="${crm_token##*$'\n'}"
admin_uid="$(ssh_cmd "sudo -n docker exec sub2api-relaymgr-pg psql -U sub2api -d sub2api -Atc \"select id from users where role='admin' order by id asc limit 1;\"")" fi
host_bearer_token="${HOST_BEARER_TOKEN:-}"
if [[ -z "$host_bearer_token" ]]; then
host_bearer_token="$(fetch_remote_host_bearer_token)"
host_bearer_token="${host_bearer_token##*$'\n'}"
fi
admin_uid="$(ssh_cmd "sudo -n docker exec $REMOTE_PG_CONTAINER_Q psql -U sub2api -d sub2api -Atc \"select id from users where role='admin' order by id asc limit 1;\"")"
admin_uid="${admin_uid##*$'\n'}" admin_uid="${admin_uid##*$'\n'}"
sub_uid="$(remote_pg_query "select id from users where email like 'relay-sub-%@sub2api.local' and not exists (select 1 from user_subscriptions s where s.user_id=users.id and s.deleted_at is null) order by id desc limit 1;")" sub_uid="$(remote_pg_query "select id from users where email like 'relay-sub-%@sub2api.local' and not exists (select 1 from user_subscriptions s where s.user_id=users.id and s.deleted_at is null) order by id desc limit 1;")"
sub_uid="${sub_uid##*$'\n'}" sub_uid="${sub_uid##*$'\n'}"
@@ -208,12 +270,36 @@ pathlib.Path(path).write_text(json.dumps({
}, ensure_ascii=False, indent=2), encoding='utf-8') }, ensure_ascii=False, indent=2), encoding='utf-8')
PY PY
payload="$(python3 - "$CRM_HOST_BASE" "$admin_key" "$PACK_PATH" "$provider_id" "$upstream_key" "$sub_key" "$sub_uid" "$SUBSCRIPTION_DAYS" <<'PY' create_host_payload="$(python3 - "$HOST_NAME" "$CRM_HOST_BASE" "$host_bearer_token" <<'PY'
import json, sys import json, sys
host_base, admin_key, pack_path, provider_id, upstream_key, sub_key, sub_uid, subscription_days = sys.argv[1:9] name, base_url, bearer_token = sys.argv[1:4]
print(json.dumps({
'name': name,
'base_url': base_url,
'auth': {'type': 'bearer', 'token': bearer_token},
}, ensure_ascii=False))
PY
)"
hosts_payload="$(crm_curl_json GET "/api/hosts")"
existing_host_json="$(printf '%s' "$hosts_payload" | python3 -c 'import json, sys
base_url = sys.argv[1]
payload = json.load(sys.stdin)
for host in payload.get("hosts", []):
if host.get("base_url") == base_url:
print(json.dumps(host, ensure_ascii=False))
break' "$CRM_HOST_BASE")"
if [[ -n "$existing_host_json" ]]; then
printf '%s\n' "$existing_host_json" > "$ART/01a-create-host.json"
else
crm_curl_json POST "/api/hosts" "$create_host_payload" > "$ART/01a-create-host.json"
fi
payload="$(python3 - "$CRM_HOST_BASE" "$host_bearer_token" "$PACK_PATH" "$provider_id" "$upstream_key" "$sub_key" "$sub_uid" "$SUBSCRIPTION_DAYS" <<'PY'
import json, sys
host_base, host_bearer_token, pack_path, provider_id, upstream_key, sub_key, sub_uid, subscription_days = sys.argv[1:9]
print(json.dumps({ print(json.dumps({
'host_base_url': host_base, 'host_base_url': host_base,
'host_api_key': admin_key, 'host_bearer_token': host_bearer_token,
'pack_path': pack_path, 'pack_path': pack_path,
'provider_id': provider_id, 'provider_id': provider_id,
'keys': [upstream_key], 'keys': [upstream_key],
@@ -226,9 +312,11 @@ print(json.dumps({
PY PY
)" )"
ssh_cmd "curl -sS -D /tmp/import_headers.txt -o /tmp/import_body.json -X POST -H 'Authorization: Bearer $crm_token' -H 'Content-Type: application/json' $CRM_BASE/api/providers/$provider_id/import -d $(printf %q "$payload")" curl -sS -D "$ART/02-import.headers.txt" -o "$ART/03-import.body.json" -X POST \
ssh_cmd "cat /tmp/import_headers.txt" > "$ART/02-import.headers.txt" -H "Authorization: Bearer $crm_token" \
ssh_cmd "cat /tmp/import_body.json" > "$ART/03-import.body.json" -H 'Content-Type: application/json' \
"$CRM_BASE/api/providers/$provider_id/import" \
-d "$payload"
batch_id="$(python3 - "$ART/03-import.body.json" <<'PY' batch_id="$(python3 - "$ART/03-import.body.json" <<'PY'
import json, sys, pathlib import json, sys, pathlib
@@ -237,7 +325,7 @@ print(obj['batch_id'])
PY PY
)" )"
ssh_cmd "curl -sS -H 'Authorization: Bearer $crm_token' $CRM_BASE/api/import-batches/$batch_id" > "$ART/04-batch-detail-initial.json" crm_curl_json GET "/api/import-batches/$batch_id" > "$ART/04-batch-detail-initial.json"
subscription_group_id="$(python3 - "$ART/03-import.body.json" "$ART/04-batch-detail-initial.json" <<'PY' subscription_group_id="$(python3 - "$ART/03-import.body.json" "$ART/04-batch-detail-initial.json" <<'PY'
import json, pathlib, sys import json, pathlib, sys
import_obj = json.loads(pathlib.Path(sys.argv[1]).read_text()) import_obj = json.loads(pathlib.Path(sys.argv[1]).read_text())
@@ -270,7 +358,7 @@ remote_pg_exec "$prep_sql" > "$ART/06-subscription-access-prep.psql.txt"
printf 'auth_cache_key=%s\n' "$auth_cache_key" printf 'auth_cache_key=%s\n' "$auth_cache_key"
printf 'balance_cache_key=%s\n' "$balance_cache_key" printf 'balance_cache_key=%s\n' "$balance_cache_key"
printf 'subscription_cache_key=%s\n' "$subscription_cache_key" printf 'subscription_cache_key=%s\n' "$subscription_cache_key"
ssh_cmd "sudo -n docker exec sub2api-relaymgr-redis redis-cli DEL $auth_cache_key $balance_cache_key $subscription_cache_key" ssh_cmd "sudo -n docker exec $REMOTE_REDIS_CONTAINER_Q redis-cli DEL $auth_cache_key $balance_cache_key $subscription_cache_key"
} > "$ART/07-redis-targeted-invalidation.txt" } > "$ART/07-redis-targeted-invalidation.txt"
remote_fetch_group_state "$subscription_group_id" "$sub_uid" "$sub_key" "$ART/08-subscription-group-state.json" remote_fetch_group_state "$subscription_group_id" "$sub_uid" "$sub_key" "$ART/08-subscription-group-state.json"
@@ -299,26 +387,27 @@ print(json.dumps({
}, ensure_ascii=False)) }, ensure_ascii=False))
PY PY
)" )"
ssh_cmd "curl -sS -D /tmp/models_headers.txt -o /tmp/models_body.json -H 'Authorization: Bearer *** $HOST_BASE/v1/models" ssh_cmd "curl -sS -D /tmp/models_headers.txt -o /tmp/models_body.json -H 'Authorization: Bearer $sub_key' $HOST_BASE/v1/models"
ssh_cmd "cat /tmp/models_headers.txt" > "$ART/09-models.headers.txt" ssh_cmd "cat /tmp/models_headers.txt" > "$ART/09-models.headers.txt"
ssh_cmd "cat /tmp/models_body.json" > "$ART/10-models.body.json" ssh_cmd "cat /tmp/models_body.json" > "$ART/10-models.body.json"
ssh_cmd "curl -sS -D /tmp/chat_headers.txt -o /tmp/chat_body.json -H 'Authorization: Bearer *** -H 'Content-Type: application/json' $HOST_BASE/v1/chat/completions -d $(printf %q "$probe_payload")" ssh_cmd "curl -sS -D /tmp/chat_headers.txt -o /tmp/chat_body.json -H 'Authorization: Bearer $sub_key' -H 'Content-Type: application/json' $HOST_BASE/v1/chat/completions -d $(printf %q "$probe_payload")"
ssh_cmd "cat /tmp/chat_headers.txt" > "$ART/11-chat.headers.txt" ssh_cmd "cat /tmp/chat_headers.txt" > "$ART/11-chat.headers.txt"
ssh_cmd "cat /tmp/chat_body.json" > "$ART/12-chat.body.json" ssh_cmd "cat /tmp/chat_body.json" > "$ART/12-chat.body.json"
ssh_cmd "curl -sS -H 'Authorization: Bearer *** $CRM_BASE/api/providers/$provider_id/status" > "$ART/13-provider-status.json" crm_curl_json GET "/api/providers/$provider_id/status" > "$ART/13-provider-status.json"
ssh_cmd "curl -sS -H 'Authorization: Bearer *** $CRM_BASE/api/providers/$provider_id/access/status" > "$ART/14-access-status.json" crm_curl_json GET "/api/providers/$provider_id/access/status" > "$ART/14-access-status.json"
preview_payload="$(python3 - "$provider_id" <<'PY' preview_payload="$(python3 - "$provider_id" <<'PY'
import json, sys import json, sys
print(json.dumps({'provider_id': sys.argv[1], 'mode': 'subscription'}, ensure_ascii=False)) print(json.dumps({'provider_id': sys.argv[1], 'mode': 'subscription'}, ensure_ascii=False))
PY PY
)" )"
ssh_cmd "curl -sS -X POST -H 'Authorization: Bearer *** -H 'Content-Type: application/json' $CRM_BASE/api/providers/$provider_id/access/preview -d $(printf %q "$preview_payload")" > "$ART/15-access-preview.json" crm_curl_json POST "/api/providers/$provider_id/access/preview" "$preview_payload" > "$ART/15-access-preview.json"
ssh_cmd "curl -sS -H 'Authorization: Bearer *** $CRM_BASE/api/import-batches/$batch_id" > "$ART/16-batch-detail-final.json" crm_curl_json GET "/api/import-batches/$batch_id" > "$ART/16-batch-detail-final.json"
python3 - "$ART" "$provider_id" "$batch_id" "$subscription_group_id" "$model_name" <<'PY' python3 - "$ART" "$provider_id" "$batch_id" "$subscription_group_id" "$model_name" <<'PY'
import json, pathlib, sys import json, pathlib, sys
art=pathlib.Path(sys.argv[1]) art=pathlib.Path(sys.argv[1])
provider_id=sys.argv[2] provider_id=sys.argv[2]
batch_id=int(sys.argv[3]) batch_id=int(sys.argv[3])

View File

@@ -150,10 +150,13 @@ PY
)" )"
if RESP_EXISTING_HOST="$(curl_json GET "/api/hosts/$HOST_NAME" 2>/dev/null)"; then if RESP_EXISTING_HOST="$(curl_json GET "/api/hosts/$HOST_NAME" 2>/dev/null)"; then
RESP_CREATE_HOST="$RESP_EXISTING_HOST" EXISTING_BASE_URL="$(printf '%s' "$RESP_EXISTING_HOST" | json_get base_url || true)"
else if [[ -n "$EXISTING_BASE_URL" && "$EXISTING_BASE_URL" != "$HOST_BASE_URL" ]]; then
RESP_CREATE_HOST="$(curl_json POST /api/hosts "$CREATE_HOST_PAYLOAD")" echo "existing host $HOST_NAME points to $EXISTING_BASE_URL, expected $HOST_BASE_URL" >&2
exit 1
fi
fi fi
RESP_CREATE_HOST="$(curl_json POST /api/hosts "$CREATE_HOST_PAYLOAD")"
save_json 01-create-host "$RESP_CREATE_HOST" save_json 01-create-host "$RESP_CREATE_HOST"
HOST_ID="$(printf '%s' "$RESP_CREATE_HOST" | json_get host_id || true)" HOST_ID="$(printf '%s' "$RESP_CREATE_HOST" | json_get host_id || true)"
HOST_ID="${HOST_ID:-$HOST_NAME}" HOST_ID="${HOST_ID:-$HOST_NAME}"

View File

@@ -59,6 +59,10 @@ run_test_real_host_acceptance_after_import_hook() {
set -euo pipefail set -euo pipefail
url="" url=""
for arg in "$@"; do for arg in "$@"; do
if [[ "$arg" == *'***'* ]]; then
echo "unexpected redacted auth placeholder in curl args: $*" >&2
exit 1
fi
if [[ "$arg" == http://* || "$arg" == https://* ]]; then if [[ "$arg" == http://* || "$arg" == https://* ]]; then
url="$arg" url="$arg"
fi fi
@@ -68,6 +72,9 @@ done
exit 1 exit 1
} }
case "$url" in case "$url" in
*/api/hosts)
printf '%s\n' '{"host_id":"test-host"}'
;;
*/api/hosts/test-host) */api/hosts/test-host)
printf '%s\n' '{"host_id":"test-host"}' printf '%s\n' '{"host_id":"test-host"}'
;; ;;
@@ -118,6 +125,8 @@ EOF
PACK_PATH="/tmp/openai-pack" \ PACK_PATH="/tmp/openai-pack" \
PROVIDER_ID="deepseek" \ PROVIDER_ID="deepseek" \
HOST_API_KEY="host-key" \ HOST_API_KEY="host-key" \
REMOTE_PG_CONTAINER="fresh-pg" \
REMOTE_REDIS_CONTAINER="fresh-redis" \
MODE="partial" \ MODE="partial" \
ACCESS_MODE="subscription" \ ACCESS_MODE="subscription" \
ACCESS_API_KEY="user-key" \ ACCESS_API_KEY="user-key" \
@@ -143,13 +152,96 @@ run_test_import_remote43_provider_subscription_prep() {
psql_sql="$artifact_dir/prep.sql" psql_sql="$artifact_dir/prep.sql"
mkdir -p "$fakebin" mkdir -p "$fakebin"
cat > "$fakebin/curl" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
headers_file=""
body_file=""
url=""
prev=""
for arg in "$@"; do
if [[ "$arg" == *'***'* ]]; then
echo "unexpected redacted auth placeholder in curl args: $*" >&2
exit 1
fi
case "$prev" in
-D)
headers_file="$arg"
prev=""
continue
;;
-o)
body_file="$arg"
prev=""
continue
;;
esac
case "$arg" in
-D|-o)
prev="$arg"
continue
;;
http://*|https://*)
url="$arg"
;;
esac
done
write_headers() {
[[ -n "$headers_file" ]] && printf '%s\n' 'HTTP/1.1 200 OK' > "$headers_file"
}
write_body() {
local body="$1"
if [[ -n "$body_file" ]]; then
printf '%s\n' "$body" > "$body_file"
else
printf '%s\n' "$body"
fi
}
case "$url" in
*/api/hosts)
write_body '{"host_id":"remote43-current-host"}'
;;
*/api/providers/deepseek/import)
write_headers
write_body '{"batch_id":123,"batch_status":"partially_succeeded","access_status":"broken","provider_status":"ready","accepted_keys_count":1,"group":{"id":"7","name":"DeepSeek 默认分组"}}'
;;
*/api/import-batches/123)
write_body '{"managed_resources":[{"ResourceType":"group","HostResourceID":"7","ResourceName":"DeepSeek 默认分组"}]}'
;;
*/api/providers/deepseek/status)
write_body '{"status":"ready"}'
;;
*/api/providers/deepseek/access/status)
write_body '{"latest_access_status":"subscription_ready"}'
;;
*/api/providers/deepseek/access/preview)
write_body '{"available":true}'
;;
*)
echo "unexpected curl url: $url" >&2
exit 1
;;
esac
EOF
chmod +x "$fakebin/curl"
cat > "$fakebin/ssh" <<'EOF' cat > "$fakebin/ssh" <<'EOF'
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
log_dir="${FAKE_REMOTE_LOG_DIR:?missing FAKE_REMOTE_LOG_DIR}" log_dir="${FAKE_REMOTE_LOG_DIR:?missing FAKE_REMOTE_LOG_DIR}"
cmd="${*: -1}" cmd="${*: -1}"
printf '%s\n' "$cmd" >> "$log_dir/ssh-log.txt" printf '%s\n' "$cmd" >> "$log_dir/ssh-log.txt"
if [[ "$cmd" == *'***'* ]]; then
echo "unexpected redacted auth placeholder in ssh command: $cmd" >&2
exit 1
fi
case "$cmd" in case "$cmd" in
*"/api/v1/auth/login"*)
printf '%s\n' 'host-bearer-token'
;;
*"grep ^SUB2API_CRM_ADMIN_TOKEN="*) *"grep ^SUB2API_CRM_ADMIN_TOKEN="*)
printf '%s\n' 'crm-token' printf '%s\n' 'crm-token'
;; ;;
@@ -210,10 +302,10 @@ case "$cmd" in
*"/api/providers/deepseek/reconcile"*) *"/api/providers/deepseek/reconcile"*)
printf '%s\n' '{"status":"in_sync"}' printf '%s\n' '{"status":"in_sync"}'
;; ;;
*"sudo -n docker exec -i sub2api-relaymgr-pg psql -U sub2api -d sub2api -At -F ''"*) *"sudo -n docker exec -i fresh-pg psql -U sub2api -d sub2api -At -F ''"*)
printf '%s\n' '{"group_id":7,"subscription":{"status":"active"},"key":{"group_id":7}}' printf '%s\n' '{"group_id":7,"subscription":{"status":"active"},"key":{"group_id":7}}'
;; ;;
*"sudo -n docker exec -i sub2api-relaymgr-pg psql -U sub2api -d sub2api"*) *"sudo -n docker exec -i fresh-pg psql -U sub2api -d sub2api"*)
CMD="$cmd" LOG_DIR="$log_dir" python3 - <<'PY' CMD="$cmd" LOG_DIR="$log_dir" python3 - <<'PY'
import base64, os, re, pathlib, sys import base64, os, re, pathlib, sys
cmd = os.environ['CMD'] cmd = os.environ['CMD']
@@ -222,18 +314,24 @@ match = re.search(r"printf '%s' '([^']+)' \| base64 -d", cmd)
if not match: if not match:
raise SystemExit(f'failed to extract base64 payload from: {cmd}') raise SystemExit(f'failed to extract base64 payload from: {cmd}')
sql = base64.b64decode(match.group(1)).decode() sql = base64.b64decode(match.group(1)).decode()
log_dir.joinpath('prep.sql').write_text(sql, encoding='utf-8')
if "select id from users where email like 'relay-sub-%@sub2api.local' and not exists" in sql: if "select id from users where email like 'relay-sub-%@sub2api.local' and not exists" in sql:
print('') print('')
elif "select k.key from users u join api_keys k on k.user_id=u.id" in sql and "not exists" in sql: elif "select k.key from users u join api_keys k on k.user_id=u.id" in sql and "not exists" in sql:
print('') print('')
elif "UPDATE users" in sql and "INSERT INTO user_subscriptions" in sql:
log_dir.joinpath('prep.sql').write_text(sql, encoding='utf-8')
print('')
elif "INSERT INTO users" in sql and "INSERT INTO api_keys" in sql: elif "INSERT INTO users" in sql and "INSERT INTO api_keys" in sql:
log_dir.joinpath('create-user.sql').write_text(sql, encoding='utf-8')
print('84\tuser-key-fresh') print('84\tuser-key-fresh')
elif "SELECT json_build_object(" in sql:
log_dir.joinpath('group-state.sql').write_text(sql, encoding='utf-8')
print('{"group_id":7,"subscription":{"status":"active"},"key":{"group_id":7}}')
else: else:
print('') print('')
PY PY
;; ;;
*"sudo -n docker exec sub2api-relaymgr-redis redis-cli DEL apikey:auth:"*" billing:balance:"*" billing:sub:"*":7"*) *"sudo -n docker exec fresh-redis redis-cli DEL apikey:auth:"*" billing:balance:"*" billing:sub:"*":7"*)
printf '%s\n' '3' printf '%s\n' '3'
;; ;;
*) *)
@@ -254,6 +352,8 @@ EOF
ROOT="$artifact_dir/root" \ ROOT="$artifact_dir/root" \
ART="$artifact_dir/run" \ ART="$artifact_dir/run" \
PACK_PATH="/tmp/openai-pack" \ PACK_PATH="/tmp/openai-pack" \
REMOTE_PG_CONTAINER="fresh-pg" \
REMOTE_REDIS_CONTAINER="fresh-redis" \
UPSTREAM_KEY="upstream-test-key" \ UPSTREAM_KEY="upstream-test-key" \
SUBSCRIPTION_DAYS=30 \ SUBSCRIPTION_DAYS=30 \
MIN_BALANCE=10 \ MIN_BALANCE=10 \
@@ -274,7 +374,10 @@ EOF
assert_contains "$invalidation_log" "auth_cache_key=apikey:auth:" assert_contains "$invalidation_log" "auth_cache_key=apikey:auth:"
assert_contains "$invalidation_log" "balance_cache_key=billing:balance:84" assert_contains "$invalidation_log" "balance_cache_key=billing:balance:84"
assert_contains "$invalidation_log" "subscription_cache_key=billing:sub:84:7" assert_contains "$invalidation_log" "subscription_cache_key=billing:sub:84:7"
local models_body chat_body local subscription_state models_body chat_body
subscription_state="$(cat "$artifact_dir/run/08-subscription-group-state.json")"
assert_contains "$subscription_state" '"group_id":7'
assert_contains "$subscription_state" '"status":"active"'
models_body="$(cat "$artifact_dir/run/10-models.body.json")" models_body="$(cat "$artifact_dir/run/10-models.body.json")"
chat_body="$(cat "$artifact_dir/run/12-chat.body.json")" chat_body="$(cat "$artifact_dir/run/12-chat.body.json")"
assert_contains "$models_body" '"id":"gpt-4"' assert_contains "$models_body" '"id":"gpt-4"'