From 60bf8f0fd10515ac524f1907d1f01d7f2ec78d65 Mon Sep 17 00:00:00 2001 From: phamnazage-jpg Date: Fri, 22 May 2026 14:33:29 +0800 Subject: [PATCH] feat(batch): add provider id and reuse policy --- internal/batch/provider_id.go | 63 ++++++++++++++++ internal/batch/provider_id_test.go | 20 +++++ internal/batch/reuse_policy.go | 95 +++++++++++++++++++++++ internal/batch/reuse_policy_test.go | 113 ++++++++++++++++++++++++++++ 4 files changed, 291 insertions(+) create mode 100644 internal/batch/provider_id.go create mode 100644 internal/batch/provider_id_test.go create mode 100644 internal/batch/reuse_policy.go create mode 100644 internal/batch/reuse_policy_test.go diff --git a/internal/batch/provider_id.go b/internal/batch/provider_id.go new file mode 100644 index 00000000..2e84f875 --- /dev/null +++ b/internal/batch/provider_id.go @@ -0,0 +1,63 @@ +package batch + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "net/url" + "path" + "strings" +) + +func NormalizeProviderID(baseURL string) string { + parsedURL, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil { + return "" + } + if parsedURL.Scheme == "" || parsedURL.Host == "" { + return "" + } + + normalizedHost := normalizeProviderHost(parsedURL.Hostname()) + normalizedPath := path.Clean(strings.TrimSpace(parsedURL.EscapedPath())) + if normalizedPath == "." { + normalizedPath = "" + } + + normalizedURL := parsedURL.Scheme + "://" + strings.ToLower(parsedURL.Hostname()) + normalizedPath + digest := sha256.Sum256([]byte(normalizedURL)) + hash := hex.EncodeToString(digest[:]) + if len(hash) > 8 { + hash = hash[len(hash)-8:] + } + + if normalizedHost == "" { + normalizedHost = "provider" + } + return fmt.Sprintf("%s-%s", normalizedHost, hash) +} + +func normalizeProviderHost(host string) string { + host = strings.TrimSpace(strings.ToLower(host)) + if host == "" { + return "" + } + + parts := strings.Split(host, ".") + filtered := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + filtered = append(filtered, part) + } + + if len(filtered) > 1 { + filtered = filtered[:len(filtered)-1] + } + if len(filtered) == 0 { + return strings.ReplaceAll(host, ".", "-") + } + return strings.Join(filtered, "-") +} diff --git a/internal/batch/provider_id_test.go b/internal/batch/provider_id_test.go new file mode 100644 index 00000000..59b3f125 --- /dev/null +++ b/internal/batch/provider_id_test.go @@ -0,0 +1,20 @@ +package batch + +import "testing" + +func TestNormalizeProviderID(t *testing.T) { + t.Parallel() + + first := NormalizeProviderID("https://api.deepseek.com/v1") + second := NormalizeProviderID("https://api.deepseek.com/proxy/v1") + + if first == second { + t.Fatalf("NormalizeProviderID() = %q for both URLs, want distinct ids", first) + } + if first == "" || second == "" { + t.Fatalf("NormalizeProviderID() returned empty ids: %q %q", first, second) + } + if got := first[:12]; got != "api-deepseek" { + t.Fatalf("NormalizeProviderID() prefix = %q, want api-deepseek*", first) + } +} diff --git a/internal/batch/reuse_policy.go b/internal/batch/reuse_policy.go new file mode 100644 index 00000000..a6fe4e0c --- /dev/null +++ b/internal/batch/reuse_policy.go @@ -0,0 +1,95 @@ +package batch + +import ( + "strings" + + "sub2api-cn-relay-manager/internal/probe" +) + +type ReuseInput struct { + ProviderID string + CanonicalModelFamilies []string + MatchedAccountID int64 + MatchedAccountState MatchedAccountState + ExistingProviderID string + ExistingAccessStatus AccessStatus + ExistingCanonicalFamilys []string +} + +type ReuseDecision struct { + ProvisionReused bool + ReusedFromProviderID string + ReusedFromAccountID int64 + MatchedAccountState MatchedAccountState + AccountResolution AccountResolution + FamilyCovered bool +} + +func DecideReuse(input ReuseInput) ReuseDecision { + decision := ReuseDecision{ + MatchedAccountState: input.MatchedAccountState, + AccountResolution: AccountResolutionCreated, + FamilyCovered: canonicalFamiliesCovered(input.CanonicalModelFamilies, input.ExistingCanonicalFamilys), + } + + if input.MatchedAccountState == "" { + decision.MatchedAccountState = MatchedAccountStateNone + } + + if !sameProvider(input.ProviderID, input.ExistingProviderID) || !decision.FamilyCovered { + return decision + } + + if input.ExistingAccessStatus == AccessStatusBroken || input.MatchedAccountState == MatchedAccountStateBroken { + decision.AccountResolution = AccountResolutionReplaced + return decision + } + + switch input.MatchedAccountState { + case MatchedAccountStateDisabled, MatchedAccountStateDeprecated: + decision.ProvisionReused = true + decision.ReusedFromProviderID = strings.TrimSpace(input.ExistingProviderID) + decision.ReusedFromAccountID = input.MatchedAccountID + decision.AccountResolution = AccountResolutionReactivated + return decision + case MatchedAccountStateActive, MatchedAccountStateNone, "": + decision.ProvisionReused = true + decision.ReusedFromProviderID = strings.TrimSpace(input.ExistingProviderID) + decision.ReusedFromAccountID = input.MatchedAccountID + decision.AccountResolution = AccountResolutionReused + return decision + default: + return decision + } +} + +func canonicalFamiliesCovered(requested []string, existing []string) bool { + if len(requested) == 0 || len(existing) == 0 { + return false + } + + existingSet := make(map[string]struct{}, len(existing)) + for _, family := range existing { + canonical := probe.CanonicalModelFamily(family) + if canonical == "" { + continue + } + existingSet[canonical] = struct{}{} + } + + for _, family := range requested { + canonical := probe.CanonicalModelFamily(family) + if canonical == "" { + return false + } + if _, ok := existingSet[canonical]; !ok { + return false + } + } + + return true +} + +func sameProvider(left, right string) bool { + return strings.TrimSpace(left) != "" && strings.TrimSpace(left) == strings.TrimSpace(right) +} diff --git a/internal/batch/reuse_policy_test.go b/internal/batch/reuse_policy_test.go new file mode 100644 index 00000000..c6d49c96 --- /dev/null +++ b/internal/batch/reuse_policy_test.go @@ -0,0 +1,113 @@ +package batch + +import "testing" + +func TestDecideReuse(t *testing.T) { + t.Parallel() + + t.Run("active provider with covered family is reused", func(t *testing.T) { + t.Parallel() + + decision := DecideReuse(ReuseInput{ + ProviderID: "api-deepseek-12345678", + CanonicalModelFamilies: []string{"kimi-k2.6"}, + MatchedAccountID: 101, + MatchedAccountState: MatchedAccountStateActive, + ExistingProviderID: "api-deepseek-12345678", + ExistingAccessStatus: AccessStatusActive, + ExistingCanonicalFamilys: []string{"kimi 2.6"}, + }) + + if !decision.ProvisionReused { + t.Fatal("ProvisionReused = false, want true") + } + if decision.AccountResolution != AccountResolutionReused { + t.Fatalf("AccountResolution = %q, want %q", decision.AccountResolution, AccountResolutionReused) + } + if decision.MatchedAccountState != MatchedAccountStateActive { + t.Fatalf("MatchedAccountState = %q, want %q", decision.MatchedAccountState, MatchedAccountStateActive) + } + }) + + t.Run("disabled or deprecated account is reactivated", func(t *testing.T) { + t.Parallel() + + for _, state := range []MatchedAccountState{MatchedAccountStateDisabled, MatchedAccountStateDeprecated} { + state := state + t.Run(string(state), func(t *testing.T) { + t.Parallel() + + decision := DecideReuse(ReuseInput{ + ProviderID: "api-kimi-12345678", + CanonicalModelFamilies: []string{"kimi-k2.6"}, + MatchedAccountID: 202, + MatchedAccountState: state, + ExistingProviderID: "api-kimi-12345678", + ExistingAccessStatus: AccessStatusActive, + ExistingCanonicalFamilys: []string{"kimi-2.6"}, + }) + + if !decision.ProvisionReused { + t.Fatal("ProvisionReused = false, want true") + } + if decision.AccountResolution != AccountResolutionReactivated { + t.Fatalf("AccountResolution = %q, want %q", decision.AccountResolution, AccountResolutionReactivated) + } + }) + } + }) + + t.Run("broken provider or account is replaced", func(t *testing.T) { + t.Parallel() + + brokenProvider := DecideReuse(ReuseInput{ + ProviderID: "api-deepseek-12345678", + CanonicalModelFamilies: []string{"deepseek-v4-pro"}, + MatchedAccountState: MatchedAccountStateActive, + ExistingProviderID: "api-deepseek-12345678", + ExistingAccessStatus: AccessStatusBroken, + ExistingCanonicalFamilys: []string{"deepseek-v4-pro"}, + }) + if brokenProvider.ProvisionReused { + t.Fatal("ProvisionReused = true, want false for broken provider") + } + if brokenProvider.AccountResolution != AccountResolutionReplaced { + t.Fatalf("AccountResolution = %q, want %q", brokenProvider.AccountResolution, AccountResolutionReplaced) + } + + brokenAccount := DecideReuse(ReuseInput{ + ProviderID: "api-deepseek-12345678", + CanonicalModelFamilies: []string{"deepseek-v4-pro"}, + MatchedAccountState: MatchedAccountStateBroken, + ExistingProviderID: "api-deepseek-12345678", + ExistingAccessStatus: AccessStatusActive, + ExistingCanonicalFamilys: []string{"deepseek-v4-pro"}, + }) + if brokenAccount.ProvisionReused { + t.Fatal("ProvisionReused = true, want false for broken account") + } + if brokenAccount.AccountResolution != AccountResolutionReplaced { + t.Fatalf("AccountResolution = %q, want %q", brokenAccount.AccountResolution, AccountResolutionReplaced) + } + }) + + t.Run("same family different alias counts as covered", func(t *testing.T) { + t.Parallel() + + decision := DecideReuse(ReuseInput{ + ProviderID: "api-kimi-12345678", + CanonicalModelFamilies: []string{"kimi-k2.6"}, + MatchedAccountState: MatchedAccountStateActive, + ExistingProviderID: "api-kimi-12345678", + ExistingAccessStatus: AccessStatusActive, + ExistingCanonicalFamilys: []string{"kimi 2.6"}, + }) + + if !decision.ProvisionReused { + t.Fatal("ProvisionReused = false, want true") + } + if !decision.FamilyCovered { + t.Fatal("FamilyCovered = false, want true") + } + }) +}