feat(batch): add live reuse admin verification flow
This commit is contained in:
@@ -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,
|
||||
|
||||
421
internal/app/batch_runtime_reuse.go
Normal file
421
internal/app/batch_runtime_reuse.go
Normal 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
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user