feat(batch): add live reuse admin verification flow

This commit is contained in:
phamnazage-jpg
2026-05-27 20:23:42 +08:00
parent 02580cda0b
commit ebd86a4256
16 changed files with 1768 additions and 9 deletions

View File

@@ -37,6 +37,11 @@ func (r batchImportRuntimeRunner) execute(ctx context.Context) (BatchImportRunCr
ItemStore: r.store.ImportRunItems(),
ProbeModels: probe.ProviderModels,
ProbeCapabilities: probe.ProbeCapabilities,
InspectReuse: batchImportReuseInspector{
store: r.store,
hostRow: r.hostRow,
currentRunID: runID,
}.Inspect,
Provisioner: batchImportProvisioner{
store: r.store,
hostRow: r.hostRow,

View File

@@ -0,0 +1,421 @@
package app
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strconv"
"strings"
"sub2api-cn-relay-manager/internal/batch"
"sub2api-cn-relay-manager/internal/pack"
"sub2api-cn-relay-manager/internal/probe"
"sub2api-cn-relay-manager/internal/store/sqlite"
)
type batchImportReuseInspector struct {
store *sqlite.DB
hostRow sqlite.Host
currentRunID string
}
func (i batchImportReuseInspector) Inspect(ctx context.Context, input batch.ReuseLookupInput) (batch.ReuseLookupResult, error) {
if i.store == nil {
return batch.ReuseLookupResult{}, fmt.Errorf("store is required")
}
if i.hostRow.ID <= 0 {
return batch.ReuseLookupResult{}, fmt.Errorf("host row is required")
}
if reuse, ok, err := i.lookupPriorRunItem(ctx, input); err != nil {
return batch.ReuseLookupResult{}, err
} else if ok {
return reuse, nil
}
return i.lookupLegacyImportBatch(ctx, input)
}
func (i batchImportReuseInspector) lookupPriorRunItem(ctx context.Context, input batch.ReuseLookupInput) (batch.ReuseLookupResult, bool, error) {
runs, err := i.store.ImportRuns().List(ctx, 1000)
if err != nil {
return batch.ReuseLookupResult{}, false, err
}
for _, run := range runs {
if strings.TrimSpace(run.HostID) != strings.TrimSpace(i.hostRow.HostID) {
continue
}
if strings.TrimSpace(run.RunID) == strings.TrimSpace(i.currentRunID) {
continue
}
items, err := i.store.ImportRunItems().ListByRunID(ctx, run.RunID)
if err != nil {
return batch.ReuseLookupResult{}, false, err
}
for _, item := range items {
if strings.TrimSpace(item.ProviderID) != strings.TrimSpace(input.ProviderID) {
continue
}
if !apiKeyFingerprintMatches(item.APIKeyFingerprint, input.APIKeyFingerprint) {
continue
}
return i.reuseFromRunItem(ctx, item)
}
}
return batch.ReuseLookupResult{}, false, nil
}
func (i batchImportReuseInspector) reuseFromRunItem(ctx context.Context, item sqlite.ImportRunItem) (batch.ReuseLookupResult, bool, error) {
modelMapping, err := i.loadExistingModelMapping(ctx, item.ProviderID)
if err != nil {
return batch.ReuseLookupResult{}, false, err
}
reusedAccountID := int64(0)
if item.AccountID != nil {
reusedAccountID = *item.AccountID
} else if item.ReusedFromAccountID != nil {
reusedAccountID = *item.ReusedFromAccountID
}
state := strings.TrimSpace(item.MatchedAccountState)
if state == "" {
state = string(batch.MatchedAccountStateNone)
}
return batch.ReuseLookupResult{
ProviderMatched: true,
ExistingProviderID: strings.TrimSpace(item.ProviderID),
ExistingAccessStatus: normalizeRunItemAccessStatus(item.AccessStatus),
ExistingCanonicalFamilys: parseStringArrayJSON(item.CanonicalFamiliesJSON),
MatchedAccountID: reusedAccountID,
MatchedAccountState: batch.MatchedAccountState(state),
ExistingModelMapping: modelMapping,
LegacyBatchID: item.LegacyBatchID,
}, true, nil
}
func (i batchImportReuseInspector) lookupLegacyImportBatch(ctx context.Context, input batch.ReuseLookupInput) (batch.ReuseLookupResult, error) {
providers, err := i.lookupLegacyProviders(ctx, input)
if err != nil {
return batch.ReuseLookupResult{}, err
}
type candidate struct {
provider sqlite.Provider
batch sqlite.ImportBatch
item sqlite.ImportBatchItem
resources []sqlite.ManagedResource
}
var best *candidate
for _, providerRow := range providers {
batches, err := i.store.ImportBatches().ListByProviderIDAndHostID(ctx, providerRow.ID, i.hostRow.ID)
if err != nil {
return batch.ReuseLookupResult{}, err
}
for _, batchRow := range batches {
items, err := i.store.ImportBatchItems().GetByBatchID(ctx, batchRow.ID)
if err != nil {
return batch.ReuseLookupResult{}, err
}
for _, item := range items {
if !apiKeyFingerprintMatches(item.KeyFingerprint, input.APIKeyFingerprint) {
continue
}
resources, err := i.store.ManagedResources().GetByBatchID(ctx, batchRow.ID)
if err != nil {
return batch.ReuseLookupResult{}, err
}
best = &candidate{
provider: providerRow,
batch: batchRow,
item: item,
resources: resources,
}
break
}
if best != nil && best.batch.ID == batchRow.ID {
break
}
}
if best != nil {
break
}
}
if best == nil {
return batch.ReuseLookupResult{}, nil
}
modelMapping, err := providerModelMapping(best.provider)
if err != nil {
return batch.ReuseLookupResult{}, err
}
canonicalFamilies, err := providerCanonicalFamilies(best.provider)
if err != nil {
return batch.ReuseLookupResult{}, err
}
accountHostID, err := accountIDFromProbeSummary(best.item.ProbeSummaryJSON)
if err != nil {
return batch.ReuseLookupResult{}, err
}
return batch.ReuseLookupResult{
ProviderMatched: true,
ExistingProviderID: strings.TrimSpace(best.provider.ProviderID),
ExistingAccessStatus: normalizeLegacyBatchAccessStatus(best.batch.AccessStatus),
ExistingCanonicalFamilys: canonicalFamilies,
MatchedAccountID: resolveManagedAccountNumericID(accountHostID, best.resources),
MatchedAccountState: normalizeLegacyMatchedAccountState(best.item.AccountStatus, best.batch.AccessStatus),
ExistingModelMapping: modelMapping,
LegacyBatchID: int64Ptr(best.batch.ID),
}, nil
}
func (i batchImportReuseInspector) lookupLegacyProviders(ctx context.Context, input batch.ReuseLookupInput) ([]sqlite.Provider, error) {
seen := make(map[int64]struct{})
providers := make([]sqlite.Provider, 0)
appendUnique := func(rows []sqlite.Provider) {
for _, row := range rows {
if _, ok := seen[row.ID]; ok {
continue
}
seen[row.ID] = struct{}{}
providers = append(providers, row)
}
}
if strings.TrimSpace(input.ProviderID) != "" {
rows, err := i.store.Providers().ListByProviderID(ctx, input.ProviderID)
if err != nil {
return nil, err
}
appendUnique(rows)
}
if strings.TrimSpace(input.BaseURL) != "" {
rows, err := i.store.Providers().ListByBaseURL(ctx, strings.TrimSpace(input.BaseURL))
if err != nil {
return nil, err
}
appendUnique(rows)
}
return providers, nil
}
func (i batchImportReuseInspector) loadExistingModelMapping(ctx context.Context, providerID string) (map[string]string, error) {
providers, err := i.store.Providers().ListByProviderID(ctx, providerID)
if err != nil {
return nil, err
}
if len(providers) == 0 {
return nil, nil
}
for idx := len(providers) - 1; idx >= 0; idx-- {
providerRow := providers[idx]
batchRow, err := i.store.ImportBatches().GetLatestByProviderIDAndHostID(ctx, providerRow.ID, i.hostRow.ID)
if err != nil {
if err == sql.ErrNoRows {
continue
}
return nil, err
}
if batchRow.ID <= 0 {
continue
}
return providerModelMapping(providerRow)
}
return nil, nil
}
func providerModelMapping(providerRow sqlite.Provider) (map[string]string, error) {
type channelTemplatePayload struct {
ModelMapping map[string]string `json:"model_mapping"`
}
var manifest pack.ProviderManifest
if strings.TrimSpace(providerRow.ManifestJSON) != "" && strings.TrimSpace(providerRow.ManifestJSON) != "{}" {
if err := json.Unmarshal([]byte(providerRow.ManifestJSON), &manifest); err != nil {
return nil, fmt.Errorf("decode provider manifest for %q: %w", providerRow.ProviderID, err)
}
if len(manifest.ChannelTemplate.ModelMapping) > 0 {
return cloneStringMap(manifest.ChannelTemplate.ModelMapping), nil
}
}
if strings.TrimSpace(providerRow.ChannelTemplateJSON) != "" && strings.TrimSpace(providerRow.ChannelTemplateJSON) != "{}" {
var payload channelTemplatePayload
if err := json.Unmarshal([]byte(providerRow.ChannelTemplateJSON), &payload); err != nil {
return nil, fmt.Errorf("decode provider channel template for %q: %w", providerRow.ProviderID, err)
}
return cloneStringMap(payload.ModelMapping), nil
}
return map[string]string{}, nil
}
func providerCanonicalFamilies(providerRow sqlite.Provider) ([]string, error) {
models := make([]string, 0)
var manifest pack.ProviderManifest
if strings.TrimSpace(providerRow.ManifestJSON) != "" && strings.TrimSpace(providerRow.ManifestJSON) != "{}" {
if err := json.Unmarshal([]byte(providerRow.ManifestJSON), &manifest); err != nil {
return nil, fmt.Errorf("decode provider manifest for %q: %w", providerRow.ProviderID, err)
}
models = append(models, manifest.DefaultModels...)
for _, mapped := range manifest.ChannelTemplate.ModelMapping {
models = append(models, mapped)
}
}
models = append(models, parseStringArrayJSON(providerRow.DefaultModelsJSON)...)
seen := make(map[string]struct{}, len(models))
families := make([]string, 0, len(models))
for _, modelID := range models {
canonical := probe.CanonicalModelFamily(modelID)
if canonical == "" {
continue
}
if _, ok := seen[canonical]; ok {
continue
}
seen[canonical] = struct{}{}
families = append(families, canonical)
}
return families, nil
}
func normalizeRunItemAccessStatus(raw string) batch.AccessStatus {
switch strings.TrimSpace(raw) {
case string(batch.AccessStatusActive):
return batch.AccessStatusActive
case string(batch.AccessStatusDegraded):
return batch.AccessStatusDegraded
case string(batch.AccessStatusBroken):
return batch.AccessStatusBroken
default:
return batch.AccessStatusUnknown
}
}
func normalizeLegacyBatchAccessStatus(raw string) batch.AccessStatus {
switch strings.TrimSpace(raw) {
case "subscription_ready", "self_service_ready", "fully_ready":
return batch.AccessStatusActive
case "degraded":
return batch.AccessStatusDegraded
case "broken":
return batch.AccessStatusBroken
default:
return batch.AccessStatusUnknown
}
}
func normalizeLegacyMatchedAccountState(accountStatus string, batchAccessStatus string) batch.MatchedAccountState {
if normalizeLegacyBatchAccessStatus(batchAccessStatus) == batch.AccessStatusBroken {
return batch.MatchedAccountStateBroken
}
switch strings.TrimSpace(accountStatus) {
case "passed", "warning":
return batch.MatchedAccountStateActive
case "disabled":
return batch.MatchedAccountStateDisabled
case "deprecated":
return batch.MatchedAccountStateDeprecated
case "failed":
return batch.MatchedAccountStateBroken
default:
return batch.MatchedAccountStateNone
}
}
func accountIDFromProbeSummary(summaryJSON string) (string, error) {
if strings.TrimSpace(summaryJSON) == "" {
return "", nil
}
var payload map[string]any
if err := json.Unmarshal([]byte(summaryJSON), &payload); err != nil {
return "", err
}
accountID, _ := payload["account_id"].(string)
return strings.TrimSpace(accountID), nil
}
func resolveManagedAccountNumericID(accountHostID string, resources []sqlite.ManagedResource) int64 {
accountHostID = strings.TrimSpace(accountHostID)
if accountHostID == "" {
return 0
}
if numericID, err := strconv.ParseInt(accountHostID, 10, 64); err == nil && numericID > 0 {
return numericID
}
for _, resource := range resources {
if strings.TrimSpace(resource.ResourceType) != "account" {
continue
}
if strings.TrimSpace(resource.HostResourceID) == accountHostID {
return resource.ID
}
}
return 0
}
func apiKeyFingerprintMatches(stored string, lookup string) bool {
stored = normalizeFingerprint(stored)
lookup = normalizeFingerprint(lookup)
if stored == "" || lookup == "" {
return false
}
if stored == lookup {
return true
}
return strings.HasPrefix(stored, lookup) || strings.HasPrefix(lookup, stored)
}
func normalizeFingerprint(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
return strings.TrimPrefix(trimmed, "sha256:")
}
func parseStringArrayJSON(raw string) []string {
values := []string{}
if strings.TrimSpace(raw) == "" {
return values
}
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return []string{}
}
return values
}
func cloneStringMap(input map[string]string) map[string]string {
if len(input) == 0 {
return map[string]string{}
}
cloned := make(map[string]string, len(input))
for key, value := range input {
cloned[key] = value
}
return cloned
}
func int64Ptr(value int64) *int64 {
if value <= 0 {
return nil
}
cloned := value
return &cloned
}

View File

@@ -2,12 +2,16 @@ package app
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"sub2api-cn-relay-manager/internal/batch"
"sub2api-cn-relay-manager/internal/pack"
"sub2api-cn-relay-manager/internal/store/sqlite"
"sub2api-cn-relay-manager/internal/testutil"
)
@@ -197,6 +201,120 @@ func TestBatchImportHTTP(t *testing.T) {
t.Fatalf("item = %+v, want current_stage=done and access_status=active", items[0])
}
})
t.Run("create run action reuses matched legacy account", func(t *testing.T) {
t.Parallel()
server := httptest.NewServer(newBatchImportActionStubServer(t))
defer server.Close()
dsn := testutil.SQLiteTestDSN(t, "reuse-state.db", true)
store := testutil.OpenSQLiteStore(t, dsn)
defer closeAppTestStore(t, store)
hostPK := mustCreateBatchImportActionHost(t, store, server.URL)
packPK, providerPK := mustSeedLegacyBatchImportProvider(t, store, server.URL)
legacyBatchID := mustCreateLegacyReusableBatch(t, store, hostPK, packPK, providerPK, "entry-key", "account_1")
action := buildCreateBatchImportRunAction(dsn)
result, err := action(context.Background(), CreateBatchImportRunRequest{
HostID: "host-1",
Mode: "strict",
AccessMode: "self_service",
ConfirmWaitTimeoutSec: 1,
ProbeAPIKey: "gateway-key",
Entries: []BatchImportEntryRequest{{
BaseURL: server.URL,
APIKey: "entry-key",
RequestedModels: []string{"kimi-k2.6"},
}},
})
if err != nil {
t.Fatalf("buildCreateBatchImportRunAction() reuse error = %v", err)
}
if strings.TrimSpace(result.RunID) == "" {
t.Fatalf("result.RunID = %q, want non-empty", result.RunID)
}
items, err := store.ImportRunItems().ListByRunID(context.Background(), result.RunID)
if err != nil {
t.Fatalf("ImportRunItems().ListByRunID() reuse error = %v", err)
}
if len(items) != 1 {
t.Fatalf("len(items) = %d, want 1", len(items))
}
item := items[0]
if !item.ProvisionReused || item.AccountResolution != "reused" || item.MatchedAccountState != "active" {
t.Fatalf("reuse item = %+v, want provision_reused + reused + active", item)
}
if item.LegacyBatchID == nil || *item.LegacyBatchID != legacyBatchID {
t.Fatalf("LegacyBatchID = %v, want %d", item.LegacyBatchID, legacyBatchID)
}
if item.CurrentStage != "done" || item.AccessStatus != "active" {
t.Fatalf("reuse item final state = %+v, want done/active", item)
}
batches, err := store.ImportBatches().ListByProviderIDAndHostID(context.Background(), providerPK, hostPK)
if err != nil {
t.Fatalf("ImportBatches().ListByProviderIDAndHostID() error = %v", err)
}
if len(batches) != 1 {
t.Fatalf("len(batches) = %d, want 1 legacy batch only", len(batches))
}
})
t.Run("create run action reuses legacy account when pack provider id differs from normalized runtime id", func(t *testing.T) {
t.Parallel()
server := httptest.NewServer(newBatchImportActionStubServer(t))
defer server.Close()
dsn := testutil.SQLiteTestDSN(t, "reuse-baseurl-fallback.db", true)
store := testutil.OpenSQLiteStore(t, dsn)
defer closeAppTestStore(t, store)
hostPK := mustCreateBatchImportActionHost(t, store, server.URL)
packPK, providerPK := mustSeedLegacyBatchImportProviderWithID(t, store, server.URL, "legacy-pack-provider")
legacyBatchID := mustCreateLegacyReusableBatch(t, store, hostPK, packPK, providerPK, "entry-key", "101")
action := buildCreateBatchImportRunAction(dsn)
result, err := action(context.Background(), CreateBatchImportRunRequest{
HostID: "host-1",
Mode: "strict",
AccessMode: "self_service",
ConfirmWaitTimeoutSec: 1,
ProbeAPIKey: "gateway-key",
Entries: []BatchImportEntryRequest{{
BaseURL: server.URL,
APIKey: "entry-key",
RequestedModels: []string{"kimi-k2.6"},
}},
})
if err != nil {
t.Fatalf("buildCreateBatchImportRunAction() base_url fallback reuse error = %v", err)
}
if strings.TrimSpace(result.RunID) == "" {
t.Fatalf("result.RunID = %q, want non-empty", result.RunID)
}
items, err := store.ImportRunItems().ListByRunID(context.Background(), result.RunID)
if err != nil {
t.Fatalf("ImportRunItems().ListByRunID() base_url fallback error = %v", err)
}
if len(items) != 1 {
t.Fatalf("len(items) = %d, want 1", len(items))
}
item := items[0]
if !item.ProvisionReused || item.AccountResolution != "reused" || item.MatchedAccountState != "active" {
t.Fatalf("reuse item = %+v, want provision_reused + reused + active", item)
}
if item.LegacyBatchID == nil || *item.LegacyBatchID != legacyBatchID {
t.Fatalf("LegacyBatchID = %v, want %d", item.LegacyBatchID, legacyBatchID)
}
if item.CurrentStage != "done" || item.AccessStatus != "active" {
t.Fatalf("reuse item final state = %+v, want done/active", item)
}
})
}
func TestBatchImportWrapperFunctions(t *testing.T) {
@@ -363,6 +481,21 @@ func newBatchImportActionStubServer(t *testing.T) http.Handler {
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"items": []map[string]any{{"id": "kimi-k2.6", "display_name": "Kimi K2.6", "type": "chat"}}}})
})
mux.HandleFunc("/api/v1/admin/accounts/101/test", func(w http.ResponseWriter, r *http.Request) {
if !requireBatchImportActionAdminToken(t, w, r) {
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("event: result\n"))
_, _ = w.Write([]byte("data: {\"status\":\"passed\",\"message\":\"smoke passed\",\"ok\":true}\n\n"))
})
mux.HandleFunc("/api/v1/admin/accounts/101/models", func(w http.ResponseWriter, r *http.Request) {
if !requireBatchImportActionAdminToken(t, w, r) {
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"items": []map[string]any{{"id": "kimi-k2.6", "display_name": "Kimi K2.6", "type": "chat"}}}})
})
mux.HandleFunc("/api/v1/admin/subscriptions/assign", func(w http.ResponseWriter, r *http.Request) {
if !requireBatchImportActionAdminToken(t, w, r) {
return
@@ -425,3 +558,142 @@ func requireBatchImportActionAdminToken(t *testing.T, w http.ResponseWriter, r *
}
return true
}
func mustCreateBatchImportActionHost(t *testing.T, store *sqlite.DB, baseURL string) int64 {
t.Helper()
hostPK, err := store.Hosts().Create(context.Background(), sqlite.Host{
HostID: "host-1",
BaseURL: baseURL,
HostVersion: "0.1.126",
CapabilityProbeJSON: "{}",
AuthType: "apikey",
AuthToken: "host-token",
})
if err != nil {
t.Fatalf("Hosts().Create() error = %v", err)
}
return hostPK
}
func mustSeedLegacyBatchImportProvider(t *testing.T, store *sqlite.DB, baseURL string) (int64, int64) {
t.Helper()
return mustSeedLegacyBatchImportProviderWithID(t, store, baseURL, batch.NormalizeProviderID(baseURL))
}
func mustSeedLegacyBatchImportProviderWithID(t *testing.T, store *sqlite.DB, baseURL, providerID string) (int64, int64) {
t.Helper()
packPK, err := store.Packs().Create(context.Background(), sqlite.Pack{
PackID: "seed-pack",
Version: "1.0.0",
Checksum: "seed-pack@1.0.0",
Vendor: "test",
TargetHost: "sub2api",
ManifestJSON: `{"pack_id":"seed-pack","version":"1.0.0","target_host":"sub2api"}`,
})
if err != nil {
t.Fatalf("Packs().Create() error = %v", err)
}
providerManifest := pack.ProviderManifest{
ProviderID: strings.TrimSpace(providerID),
DisplayName: "Legacy Reuse Provider",
BaseURL: baseURL,
Platform: "openai",
AccountType: "apikey",
DefaultModels: []string{"kimi-k2.6"},
SmokeTestModel: "kimi-k2.6",
GroupTemplate: pack.GroupTemplate{
Name: "legacy-group",
RateMultiplier: 1,
},
ChannelTemplate: pack.ChannelTemplate{
Name: "legacy-channel",
ModelMapping: map[string]string{"kimi-k2.6": "kimi-k2.6"},
},
PlanTemplate: pack.PlanTemplate{
Name: "legacy-plan",
Price: 1,
ValidityDays: 30,
ValidityUnit: "day",
},
Import: pack.ImportOptions{
SupportsMultiKey: true,
SupportsStrict: true,
SupportsPartial: true,
},
}
manifestJSON, err := json.Marshal(providerManifest)
if err != nil {
t.Fatalf("json.Marshal(providerManifest) error = %v", err)
}
defaultModelsJSON, err := json.Marshal(providerManifest.DefaultModels)
if err != nil {
t.Fatalf("json.Marshal(defaultModels) error = %v", err)
}
channelTemplateJSON, err := json.Marshal(providerManifest.ChannelTemplate)
if err != nil {
t.Fatalf("json.Marshal(channelTemplate) error = %v", err)
}
providerPK, err := store.Providers().Create(context.Background(), sqlite.Provider{
PackID: packPK,
ProviderID: providerManifest.ProviderID,
DisplayName: providerManifest.DisplayName,
BaseURL: providerManifest.BaseURL,
Platform: providerManifest.Platform,
AccountType: providerManifest.AccountType,
DefaultModelsJSON: string(defaultModelsJSON),
SmokeTestModel: providerManifest.SmokeTestModel,
ChannelTemplateJSON: string(channelTemplateJSON),
ManifestJSON: string(manifestJSON),
})
if err != nil {
t.Fatalf("Providers().Create() error = %v", err)
}
return packPK, providerPK
}
func mustCreateLegacyReusableBatch(t *testing.T, store *sqlite.DB, hostPK int64, packPK int64, providerPK int64, apiKey string, accountID string) int64 {
t.Helper()
batchID, err := store.ImportBatches().Create(context.Background(), sqlite.ImportBatch{
HostID: hostPK,
PackID: packPK,
ProviderID: providerPK,
Mode: "strict",
BatchStatus: "succeeded",
AccessStatus: "self_service_ready",
})
if err != nil {
t.Fatalf("ImportBatches().Create() error = %v", err)
}
if _, err := store.ImportBatchItems().Create(context.Background(), sqlite.ImportBatchItem{
BatchID: batchID,
KeyFingerprint: fullSHA256Fingerprint(apiKey),
AccountStatus: "passed",
ProbeSummaryJSON: fmt.Sprintf(`{"account_id":"%s"}`, accountID),
}); err != nil {
t.Fatalf("ImportBatchItems().Create() error = %v", err)
}
if _, err := store.ManagedResources().Create(context.Background(), sqlite.ManagedResource{
BatchID: batchID,
HostID: hostPK,
ResourceType: "account",
HostResourceID: accountID,
ResourceName: "legacy-account",
}); err != nil {
t.Fatalf("ManagedResources().Create(account) error = %v", err)
}
return batchID
}
func fullSHA256Fingerprint(value string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(value)))
return fmt.Sprintf("sha256:%x", sum[:])
}

View File

@@ -7,6 +7,7 @@ import (
)
type ReuseInput struct {
ProviderMatched bool
ProviderID string
CanonicalModelFamilies []string
MatchedAccountID int64
@@ -36,7 +37,7 @@ func DecideReuse(input ReuseInput) ReuseDecision {
decision.MatchedAccountState = MatchedAccountStateNone
}
if !sameProvider(input.ProviderID, input.ExistingProviderID) || !decision.FamilyCovered {
if !providerMatched(input) || !decision.FamilyCovered {
return decision
}
@@ -93,3 +94,10 @@ func canonicalFamiliesCovered(requested []string, existing []string) bool {
func sameProvider(left, right string) bool {
return strings.TrimSpace(left) != "" && strings.TrimSpace(left) == strings.TrimSpace(right)
}
func providerMatched(input ReuseInput) bool {
if input.ProviderMatched {
return true
}
return sameProvider(input.ProviderID, input.ExistingProviderID)
}

View File

@@ -9,6 +9,7 @@ func TestDecideReuse(t *testing.T) {
t.Parallel()
decision := DecideReuse(ReuseInput{
ProviderMatched: true,
ProviderID: "api-deepseek-12345678",
CanonicalModelFamilies: []string{"kimi-k2.6"},
MatchedAccountID: 101,
@@ -38,6 +39,7 @@ func TestDecideReuse(t *testing.T) {
t.Parallel()
decision := DecideReuse(ReuseInput{
ProviderMatched: true,
ProviderID: "api-kimi-12345678",
CanonicalModelFamilies: []string{"kimi-k2.6"},
MatchedAccountID: 202,
@@ -61,6 +63,7 @@ func TestDecideReuse(t *testing.T) {
t.Parallel()
brokenProvider := DecideReuse(ReuseInput{
ProviderMatched: true,
ProviderID: "api-deepseek-12345678",
CanonicalModelFamilies: []string{"deepseek-v4-pro"},
MatchedAccountState: MatchedAccountStateActive,
@@ -76,6 +79,7 @@ func TestDecideReuse(t *testing.T) {
}
brokenAccount := DecideReuse(ReuseInput{
ProviderMatched: true,
ProviderID: "api-deepseek-12345678",
CanonicalModelFamilies: []string{"deepseek-v4-pro"},
MatchedAccountState: MatchedAccountStateBroken,
@@ -95,6 +99,7 @@ func TestDecideReuse(t *testing.T) {
t.Parallel()
decision := DecideReuse(ReuseInput{
ProviderMatched: true,
ProviderID: "api-kimi-12345678",
CanonicalModelFamilies: []string{"kimi-k2.6"},
MatchedAccountState: MatchedAccountStateActive,
@@ -110,4 +115,26 @@ func TestDecideReuse(t *testing.T) {
t.Fatal("FamilyCovered = false, want true")
}
})
t.Run("base url matched legacy provider is reused even when provider ids differ", func(t *testing.T) {
t.Parallel()
decision := DecideReuse(ReuseInput{
ProviderMatched: true,
ProviderID: "api-53hk-42797c06",
CanonicalModelFamilies: []string{"minimax-m2.7-highspeed"},
MatchedAccountID: 101,
MatchedAccountState: MatchedAccountStateActive,
ExistingProviderID: "minimax-53hk",
ExistingAccessStatus: AccessStatusActive,
ExistingCanonicalFamilys: []string{"minimax-m2.5-highspeed", "minimax-m2.7-highspeed"},
})
if !decision.ProvisionReused {
t.Fatal("ProvisionReused = false, want true")
}
if decision.AccountResolution != AccountResolutionReused {
t.Fatalf("AccountResolution = %q, want %q", decision.AccountResolution, AccountResolutionReused)
}
})
}

View File

@@ -53,12 +53,14 @@ type ReuseLookupInput struct {
}
type ReuseLookupResult struct {
ProviderMatched bool
ExistingProviderID string
ExistingAccessStatus AccessStatus
ExistingCanonicalFamilys []string
MatchedAccountID int64
MatchedAccountState MatchedAccountState
ExistingModelMapping map[string]string
LegacyBatchID *int64
}
type ProvisionRequest struct {
@@ -201,6 +203,7 @@ func (s BatchImportService) StartRun(ctx context.Context, req BatchImportRunRequ
}
reuseDecision := DecideReuse(ReuseInput{
ProviderMatched: reuseLookup.ProviderMatched,
ProviderID: providerID,
CanonicalModelFamilies: canonicalFamilies,
MatchedAccountID: reuseLookup.MatchedAccountID,
@@ -231,6 +234,8 @@ func (s BatchImportService) StartRun(ctx context.Context, req BatchImportRunRequ
ProvisionReused: reuseDecision.ProvisionReused,
ReusedFromProviderID: reuseDecision.ReusedFromProviderID,
ReusedFromAccountID: int64PtrIfSet(reuseDecision.ReusedFromAccountID),
LegacyBatchID: reuseLookup.LegacyBatchID,
LegacyProviderID: strings.TrimSpace(reuseLookup.ExistingProviderID),
}
if reuseDecision.ProvisionReused {

View File

@@ -112,6 +112,47 @@ func (r *ProvidersRepo) ListByProviderID(ctx context.Context, providerID string)
return providers, nil
}
func (r *ProvidersRepo) ListByBaseURL(ctx context.Context, baseURL string) ([]Provider, error) {
baseURL = strings.TrimSpace(baseURL)
if baseURL == "" {
return nil, fmt.Errorf("base_url is required")
}
rows, err := r.db.QueryContext(ctx, `SELECT id, pack_id, provider_id, display_name, base_url, platform, account_type, default_models_json, smoke_test_model, group_template_json, channel_template_json, plan_template_json, import_options_json, manifest_json FROM providers WHERE base_url = ? ORDER BY id`, baseURL)
if err != nil {
return nil, fmt.Errorf("query providers by base_url %q: %w", baseURL, err)
}
defer rows.Close()
providers := make([]Provider, 0)
for rows.Next() {
var provider Provider
if err := rows.Scan(
&provider.ID,
&provider.PackID,
&provider.ProviderID,
&provider.DisplayName,
&provider.BaseURL,
&provider.Platform,
&provider.AccountType,
&provider.DefaultModelsJSON,
&provider.SmokeTestModel,
&provider.GroupTemplateJSON,
&provider.ChannelTemplateJSON,
&provider.PlanTemplateJSON,
&provider.ImportOptionsJSON,
&provider.ManifestJSON,
); err != nil {
return nil, fmt.Errorf("scan provider by base_url %q: %w", baseURL, err)
}
providers = append(providers, provider)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate providers by base_url %q: %w", baseURL, err)
}
return providers, nil
}
func (r *ProvidersRepo) GetByPackIDAndProviderID(ctx context.Context, packID int64, providerID string) (Provider, error) {
if packID <= 0 {
return Provider{}, fmt.Errorf("pack_id is required")

View File

@@ -55,6 +55,24 @@ func TestProvidersRepoListByProviderID(t *testing.T) {
}
}
func TestProvidersRepoListByBaseURL(t *testing.T) {
store := openTestDB(t)
packID1 := createTestPackWithSuffix(t, store, "base-a")
packID2 := createTestPackWithSuffix(t, store, "base-b")
store.Providers().Create(context.Background(), Provider{PackID: packID1, ProviderID: "minimax-53hk", DisplayName: "MM1", BaseURL: "https://api.53hk.cn/v1", Platform: "openai"})
store.Providers().Create(context.Background(), Provider{PackID: packID2, ProviderID: "api-53hk-42797c06", DisplayName: "MM2", BaseURL: "https://api.53hk.cn/v1", Platform: "openai"})
providers, err := store.Providers().ListByBaseURL(context.Background(), "https://api.53hk.cn/v1")
if err != nil {
t.Fatalf("ListByBaseURL() error = %v", err)
}
if len(providers) != 2 {
t.Fatalf("ListByBaseURL() count = %d, want 2", len(providers))
}
}
func TestProvidersRepoListByPackID(t *testing.T) {
store := openTestDB(t)
packID := createTestPack(t, store)
@@ -105,6 +123,18 @@ func TestProvidersRepoListByProviderIDEmpty(t *testing.T) {
}
}
func TestProvidersRepoListByBaseURLEmpty(t *testing.T) {
store := openTestDB(t)
providers, err := store.Providers().ListByBaseURL(context.Background(), "https://missing.example.com/v1")
if err != nil {
t.Fatalf("ListByBaseURL() error = %v", err)
}
if len(providers) != 0 {
t.Fatalf("ListByBaseURL() count = %d, want 0", len(providers))
}
}
func TestProvidersRepoUpsertCreatesNew(t *testing.T) {
store := openTestDB(t)
packID := createTestPack(t, store)