fix fresh-host acceptance and document real-host debugging learnings

This commit is contained in:
phamnazage-jpg
2026-05-21 21:19:19 +08:00
parent 7c6e18f94d
commit 3ba3244ea6
85 changed files with 1721 additions and 162 deletions

View File

@@ -24,12 +24,15 @@ type ClosureRequest struct {
Subscriptions []SubscriptionTarget
GroupID string
ExpectedModel string
Prompt string
MaxTokens int
}
type Host interface {
EnsureSubscriptionAccess(ctx context.Context, req sub2api.EnsureSubscriptionAccessRequest) (sub2api.SubscriptionAccessRef, error)
AssignSubscription(ctx context.Context, req sub2api.AssignSubscriptionRequest) (sub2api.SubscriptionRef, error)
CheckGatewayAccess(ctx context.Context, req sub2api.GatewayAccessCheckRequest) (sub2api.GatewayAccessResult, error)
CheckGatewayCompletion(ctx context.Context, req sub2api.GatewayCompletionCheckRequest) (sub2api.GatewayCompletionResult, error)
}
type Service struct {
@@ -92,5 +95,20 @@ func (s *Service) Close(ctx context.Context, req ClosureRequest) (sub2api.Gatewa
if err != nil {
return sub2api.GatewayAccessResult{}, fmt.Errorf("check gateway access: %w", err)
}
if result.OK && result.HasExpectedModel && strings.TrimSpace(req.ExpectedModel) != "" {
completion, err := s.host.CheckGatewayCompletion(ctx, sub2api.GatewayCompletionCheckRequest{
APIKey: probeAPIKey,
Model: req.ExpectedModel,
Prompt: req.Prompt,
MaxTokens: req.MaxTokens,
})
if err != nil {
return sub2api.GatewayAccessResult{}, fmt.Errorf("check gateway completion: %w", err)
}
result.CompletionOK = completion.OK
result.CompletionStatus = completion.StatusCode
result.CompletionType = completion.ContentType
result.CompletionBody = completion.BodyPreview
}
return result, nil
}

View File

@@ -36,7 +36,8 @@ func TestValidateAllowsManagedSubscriptionProbeWithoutExplicitAPIKey(t *testing.
func TestServiceCloseAssignsSubscriptionsAndProbesGateway(t *testing.T) {
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"}},
completionResult: sub2api.GatewayCompletionResult{OK: true, StatusCode: 200, ContentType: "application/json"},
managedAccess: map[string]sub2api.SubscriptionAccessRef{
"user-1": {UserID: "host-user-1", APIKey: "managed-user-key"},
},
@@ -60,14 +61,21 @@ func TestServiceCloseAssignsSubscriptionsAndProbesGateway(t *testing.T) {
if host.gatewayProbe.APIKey != "managed-user-key" || host.gatewayProbe.ExpectedModel != "deepseek-chat" {
t.Fatalf("gateway probe = %+v, want api key + expected model", host.gatewayProbe)
}
if host.completionProbe.APIKey != "managed-user-key" || host.completionProbe.Model != "deepseek-chat" {
t.Fatalf("completion probe = %+v, want api key + model", host.completionProbe)
}
if !result.OK || !result.HasExpectedModel {
t.Fatalf("gateway result = %+v, want success", result)
}
if !result.CompletionOK {
t.Fatalf("completion result = %+v, want success", result)
}
}
func TestServiceCloseSubscriptionManagedKeyOverridesExplicitProbeAPIKey(t *testing.T) {
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"}},
completionResult: sub2api.GatewayCompletionResult{OK: true, StatusCode: 200},
managedAccess: map[string]sub2api.SubscriptionAccessRef{
"user-1": {UserID: "host-user-1", APIKey: "managed-user-key"},
},
@@ -107,12 +115,15 @@ func TestServiceCloseReturnsSubscriptionErrorBeforeGatewayProbe(t *testing.T) {
}
type fakeClosureHost struct {
assigned []sub2api.AssignSubscriptionRequest
managedAccess map[string]sub2api.SubscriptionAccessRef
assignErr error
gatewayProbe sub2api.GatewayAccessCheckRequest
gatewayResult sub2api.GatewayAccessResult
gatewayErr error
assigned []sub2api.AssignSubscriptionRequest
managedAccess map[string]sub2api.SubscriptionAccessRef
assignErr error
gatewayProbe sub2api.GatewayAccessCheckRequest
gatewayResult sub2api.GatewayAccessResult
gatewayErr error
completionProbe sub2api.GatewayCompletionCheckRequest
completionResult sub2api.GatewayCompletionResult
completionErr error
}
func (f *fakeClosureHost) EnsureSubscriptionAccess(_ context.Context, req sub2api.EnsureSubscriptionAccessRequest) (sub2api.SubscriptionAccessRef, error) {
@@ -137,3 +148,11 @@ func (f *fakeClosureHost) CheckGatewayAccess(_ context.Context, req sub2api.Gate
}
return f.gatewayResult, nil
}
func (f *fakeClosureHost) CheckGatewayCompletion(_ context.Context, req sub2api.GatewayCompletionCheckRequest) (sub2api.GatewayCompletionResult, error) {
f.completionProbe = req
if f.completionErr != nil {
return sub2api.GatewayCompletionResult{}, f.completionErr
}
return f.completionResult, nil
}

View File

@@ -215,7 +215,7 @@ func TestAPIProviderStatusReturnsSummary(t *testing.T) {
ProviderStatus: "drifted",
LatestAccessStatus: provision.AccessStatusSelfServiceReady,
LatestReconcileStatus: "drifted",
LatestReconcileSummary: map[string]any{"missing_count": 1},
LatestReconcileSummary: map[string]any{"missing_count": 1, "stale_noise_count": 2},
ManagedResources: []sqlite.ManagedResource{{}, {}},
AccessClosures: []sqlite.AccessClosureRecord{{}},
ReconcileRuns: []sqlite.ReconcileRun{{}},
@@ -228,6 +228,7 @@ func TestAPIProviderStatusReturnsSummary(t *testing.T) {
assertJSONContains(t, response.Body().Bytes(), "provider_status", "drifted")
assertJSONContains(t, response.Body().Bytes(), "managed_resources_count", float64(2))
assertJSONContains(t, response.Body().Bytes(), "latest_reconcile_summary.missing_count", float64(1))
assertJSONContains(t, response.Body().Bytes(), "latest_reconcile_summary.stale_noise_count", float64(2))
}
func TestAPIProviderAccessStatusReturnsSummary(t *testing.T) {
@@ -306,7 +307,7 @@ func TestAPIReconcileProviderReturnsSummary(t *testing.T) {
if req.AccessAPIKey != "user-key" {
t.Fatalf("AccessAPIKey = %q, want user-key", req.AccessAPIKey)
}
return provision.ReconcileResult{BatchID: 7, Status: "drifted", MissingCount: 1, ExtraCount: 2, ProbeFailureCount: 1, AccessStatus: provision.AccessStatusBroken, Summary: map[string]any{"probe_failures": 1}}, nil
return provision.ReconcileResult{BatchID: 7, Status: "drifted", MissingCount: 1, ExtraCount: 2, StaleNoiseCount: 3, ProbeFailureCount: 1, AccessStatus: provision.AccessStatusBroken, Summary: map[string]any{"probe_failures": 1, "stale_noise_count": 3}}, nil
},
})
request := httptestRequest(t, http.MethodPost, "/api/providers/deepseek/reconcile", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip", "access_api_key": "user-key"}, "secret-token")
@@ -314,7 +315,9 @@ func TestAPIReconcileProviderReturnsSummary(t *testing.T) {
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "status", "drifted")
assertJSONContains(t, response.Body().Bytes(), "missing_count", float64(1))
assertJSONContains(t, response.Body().Bytes(), "stale_noise_count", float64(3))
assertJSONContains(t, response.Body().Bytes(), "summary.probe_failures", float64(1))
assertJSONContains(t, response.Body().Bytes(), "summary.stale_noise_count", float64(3))
}
func waitForHealthz(t *testing.T, url string) *http.Response {

View File

@@ -709,12 +709,13 @@ func handleReconcileProvider(w http.ResponseWriter, r *http.Request, fn func(con
return
}
writeJSON(w, http.StatusOK, map[string]any{
"provider_id": req.ProviderID,
"batch_id": result.BatchID,
"status": result.Status,
"missing_count": result.MissingCount,
"extra_count": result.ExtraCount,
"summary": result.Summary,
"provider_id": req.ProviderID,
"batch_id": result.BatchID,
"status": result.Status,
"missing_count": result.MissingCount,
"extra_count": result.ExtraCount,
"stale_noise_count": result.StaleNoiseCount,
"summary": result.Summary,
})
}
@@ -1333,13 +1334,22 @@ func NewActionSet(sqliteDSN string) ActionSet {
}
accessSvc := access.NewService(client)
gwResult, err := accessSvc.Close(ctx, access.ClosureRequest{Mode: access.ModeSubscription, ProbeAPIKey: req.AccessAPIKey, Subscriptions: subscriptions, GroupID: groupID, ExpectedModel: providerManifest.SmokeTestModel})
gwResult, err := accessSvc.Close(ctx, access.ClosureRequest{Mode: access.ModeSubscription, ProbeAPIKey: req.AccessAPIKey, Subscriptions: subscriptions, GroupID: groupID, ExpectedModel: providerManifest.SmokeTestModel, Prompt: "ping", MaxTokens: 8})
if err != nil {
return AssignAccessSubscriptionsResult{}, err
}
accessStatus := deriveAccessStatus(gwResult)
accessPayload, _ := json.Marshal(map[string]any{"status_code": gwResult.StatusCode, "ok": gwResult.OK, "has_expected_model": gwResult.HasExpectedModel, "models": gwResult.Models})
accessPayload, _ := json.Marshal(map[string]any{
"status_code": gwResult.StatusCode,
"ok": gwResult.OK,
"has_expected_model": gwResult.HasExpectedModel,
"models": gwResult.Models,
"completion_ok": gwResult.CompletionOK,
"completion_status": gwResult.CompletionStatus,
"completion_type": gwResult.CompletionType,
"completion_preview": gwResult.CompletionBody,
})
if _, err := store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{BatchID: batch.ID, ClosureType: access.ModeSubscription, Status: accessStatus, DetailsJSON: string(accessPayload)}); err != nil {
return AssignAccessSubscriptionsResult{}, fmt.Errorf("record access closure: %w", err)
}
@@ -1579,7 +1589,7 @@ func packRecordToInfo(pack sqlite.Pack) PackInfo {
}
func deriveAccessStatus(gw sub2api.GatewayAccessResult) string {
if gw.OK && gw.HasExpectedModel {
if provision.GatewayAccessReady(gw) {
return provision.AccessStatusSubscriptionReady
}
return provision.AccessStatusBroken

View File

@@ -34,9 +34,13 @@ func (c *Client) BatchCreateAccounts(ctx context.Context, req BatchCreateAccount
return models, nil
}
func (c *Client) TestAccount(ctx context.Context, accountID string) (ProbeResult, error) {
func (c *Client) TestAccount(ctx context.Context, accountID, modelID string) (ProbeResult, error) {
path := "/api/v1/admin/accounts/" + accountID + "/test"
statusCode, _, body, err := c.perform(ctx, http.MethodPost, path, map[string]any{})
req := map[string]any{}
if strings.TrimSpace(modelID) != "" {
req["model_id"] = strings.TrimSpace(modelID)
}
statusCode, _, body, err := c.perform(ctx, http.MethodPost, path, req)
if err != nil {
return ProbeResult{}, err
}
@@ -158,35 +162,77 @@ func parseProbeResult(body []byte) (ProbeResult, error) {
return ProbeResult{}, fmt.Errorf("missing data event")
}
var event struct {
type probeEvent struct {
Type string `json:"type"`
Status string `json:"status"`
Message string `json:"message"`
Error string `json:"error"`
OK *bool `json:"ok"`
Success *bool `json:"success"`
}
if err := json.Unmarshal([]byte(payloads[len(payloads)-1]), &event); err != nil {
return ProbeResult{}, err
}
ok := false
switch {
case event.OK != nil:
ok = *event.OK
case event.Success != nil:
ok = *event.Success
default:
switch strings.ToLower(strings.TrimSpace(event.Status)) {
case "ok", "pass", "passed", "success", "succeeded":
ok = true
var latest ProbeResult
sawProbeState := false
for _, payload := range payloads {
var event probeEvent
if err := json.Unmarshal([]byte(payload), &event); err != nil {
return ProbeResult{}, err
}
message := strings.TrimSpace(event.Message)
if message == "" {
message = strings.TrimSpace(event.Error)
}
eventType := strings.ToLower(strings.TrimSpace(event.Type))
if eventType == "error" || strings.TrimSpace(event.Error) != "" {
if message == "" {
message = "account probe returned an error event"
}
return ProbeResult{
OK: false,
Status: "failed",
Message: message,
}, nil
}
ok := false
switch {
case event.OK != nil:
ok = *event.OK
case event.Success != nil:
ok = *event.Success
default:
switch strings.ToLower(strings.TrimSpace(event.Status)) {
case "ok", "pass", "passed", "success", "succeeded":
ok = true
}
}
if eventType == "test_complete" {
return ProbeResult{
OK: ok,
Status: normalizeProbeStatus(event.Status, ok),
Message: message,
}, nil
}
if event.Status != "" || event.OK != nil || event.Success != nil {
latest = ProbeResult{
OK: ok,
Status: normalizeProbeStatus(event.Status, ok),
Message: message,
}
sawProbeState = true
}
}
status := normalizeProbeStatus(event.Status, ok)
return ProbeResult{
OK: ok,
Status: status,
Message: strings.TrimSpace(event.Message),
}, nil
if sawProbeState {
return latest, nil
}
return ProbeResult{}, fmt.Errorf("missing probe status event")
}
func normalizeProbeStatus(status string, ok bool) string {

View File

@@ -25,11 +25,12 @@ type HostAdapter interface {
CreateAccount(ctx context.Context, req CreateAccountRequest) (AccountRef, error)
BatchCreateAccounts(ctx context.Context, req BatchCreateAccountsRequest) ([]AccountRef, error)
DeleteAccount(ctx context.Context, accountID string) error
TestAccount(ctx context.Context, accountID string) (ProbeResult, error)
TestAccount(ctx context.Context, accountID, modelID string) (ProbeResult, 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)
CheckGatewayAccess(ctx context.Context, req GatewayAccessCheckRequest) (GatewayAccessResult, error)
CheckGatewayCompletion(ctx context.Context, req GatewayCompletionCheckRequest) (GatewayCompletionResult, error)
ListManagedResources(ctx context.Context, req ListManagedResourcesRequest) (ManagedResourceSnapshot, error)
}
@@ -159,6 +160,20 @@ type SubscriptionRef struct {
ID string `json:"id"`
}
type GatewayCompletionCheckRequest struct {
APIKey string
Model string
Prompt string
MaxTokens int
}
type GatewayCompletionResult struct {
OK bool `json:"ok"`
StatusCode int `json:"status_code"`
ContentType string `json:"content_type,omitempty"`
BodyPreview string `json:"body_preview,omitempty"`
}
type Client struct {
baseURL *url.URL
httpClient *http.Client

View File

@@ -17,12 +17,16 @@ type GatewayAccessResult struct {
StatusCode int `json:"status_code"`
Models []string `json:"models"`
HasExpectedModel bool `json:"has_expected_model"`
CompletionOK bool `json:"completion_ok"`
CompletionStatus int `json:"completion_status"`
CompletionType string `json:"completion_content_type,omitempty"`
CompletionBody string `json:"completion_body_preview,omitempty"`
}
func (c *Client) CheckGatewayAccess(ctx context.Context, req GatewayAccessCheckRequest) (GatewayAccessResult, error) {
gatewayClient := *c
gatewayClient.apiKey = strings.TrimSpace(req.APIKey)
gatewayClient.bearerToken = ""
gatewayClient.apiKey = ""
gatewayClient.bearerToken = strings.TrimSpace(req.APIKey)
statusCode, _, body, err := gatewayClient.perform(ctx, http.MethodGet, "/v1/models", nil)
if err != nil {
@@ -43,6 +47,44 @@ func (c *Client) CheckGatewayAccess(ctx context.Context, req GatewayAccessCheckR
return result, nil
}
func (c *Client) CheckGatewayCompletion(ctx context.Context, req GatewayCompletionCheckRequest) (GatewayCompletionResult, error) {
gatewayClient := *c
gatewayClient.apiKey = ""
gatewayClient.bearerToken = strings.TrimSpace(req.APIKey)
model := strings.TrimSpace(req.Model)
if model == "" {
return GatewayCompletionResult{}, nil
}
prompt := strings.TrimSpace(req.Prompt)
if prompt == "" {
prompt = "ping"
}
maxTokens := req.MaxTokens
if maxTokens <= 0 {
maxTokens = 8
}
payload := map[string]any{
"model": model,
"messages": []map[string]string{
{"role": "user", "content": prompt},
},
"max_tokens": maxTokens,
"temperature": 0,
}
statusCode, headers, body, err := gatewayClient.perform(ctx, http.MethodPost, "/v1/chat/completions", payload)
if err != nil {
return GatewayCompletionResult{}, err
}
return GatewayCompletionResult{
OK: statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices,
StatusCode: statusCode,
ContentType: strings.TrimSpace(headers.Get("Content-Type")),
BodyPreview: previewGatewayBody(body, 400),
}, nil
}
func decodeGatewayModelIDs(body []byte) []string {
var payload struct {
Data []struct {
@@ -60,3 +102,11 @@ func decodeGatewayModelIDs(body []byte) []string {
}
return nil
}
func previewGatewayBody(body []byte, limit int) string {
trimmed := strings.TrimSpace(string(body))
if limit <= 0 || len(trimmed) <= limit {
return trimmed
}
return trimmed[:limit]
}

View File

@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
)
@@ -20,7 +22,7 @@ func (c *Client) ListManagedResources(ctx context.Context, req ListManagedResour
if err != nil {
return ManagedResourceSnapshot{}, fmt.Errorf("list plans: %w", err)
}
accounts, err := c.listNamedResources(ctx, "/api/v1/admin/accounts", "")
accounts, err := c.listNamedResourcesPaged(ctx, "/api/v1/admin/accounts", 100)
if err != nil {
return ManagedResourceSnapshot{}, fmt.Errorf("list accounts: %w", err)
}
@@ -34,36 +36,71 @@ func (c *Client) ListManagedResources(ctx context.Context, req ListManagedResour
}
func (c *Client) listNamedResources(ctx context.Context, path, expectedName string) ([]NamedResource, error) {
statusCode, _, body, err := c.perform(ctx, "GET", path, nil)
resources, _, err := c.listNamedResourcesPage(ctx, path)
if err != nil {
return nil, err
}
if statusCode < 200 || statusCode >= 300 {
return nil, newHTTPError("GET", path, statusCode, body)
}
resources, err := decodeNamedResources(body)
if err != nil {
return nil, fmt.Errorf("decode %s response: %w", path, err)
}
return filterNamedResourcesByName(resources, expectedName), nil
}
func decodeNamedResources(body []byte) ([]NamedResource, error) {
func (c *Client) listNamedResourcesPaged(ctx context.Context, path string, pageSize int) ([]NamedResource, error) {
if pageSize <= 0 {
pageSize = 100
}
page := 1
all := make([]NamedResource, 0)
for {
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("page_size", strconv.Itoa(pageSize))
resources, pages, err := c.listNamedResourcesPage(ctx, path+"?"+query.Encode())
if err != nil {
return nil, err
}
all = append(all, resources...)
if pages <= page || pages == 0 {
return all, nil
}
page++
}
}
func (c *Client) listNamedResourcesPage(ctx context.Context, path string) ([]NamedResource, int, error) {
statusCode, _, body, err := c.perform(ctx, "GET", path, nil)
if err != nil {
return nil, 0, err
}
if statusCode < 200 || statusCode >= 300 {
return nil, 0, newHTTPError("GET", path, statusCode, body)
}
resources, pages, err := decodeNamedResources(body)
if err != nil {
return nil, 0, fmt.Errorf("decode %s response: %w", path, err)
}
return resources, pages, nil
}
func decodeNamedResources(body []byte) ([]NamedResource, int, error) {
var resources []NamedResource
if err := decodeEnvelopeObject(body, &resources); err == nil {
return resources, nil
return resources, 1, nil
}
var wrapper struct {
Data struct {
Items []NamedResource `json:"items"`
Pages int `json:"pages"`
} `json:"data"`
}
if err := json.Unmarshal(body, &wrapper); err != nil {
return nil, err
return nil, 0, err
}
return wrapper.Data.Items, nil
pages := wrapper.Data.Pages
if pages <= 0 {
pages = 1
}
return wrapper.Data.Items, pages, nil
}
func filterNamedResourcesByName(resources []NamedResource, expectedName string) []NamedResource {

View File

@@ -228,34 +228,43 @@ func TestFilterNamedResourcesByPrefix(t *testing.T) {
func TestDecodeNamedResources(t *testing.T) {
t.Run("envelope", func(t *testing.T) {
resources, err := decodeNamedResources([]byte(`{"data":[{"id":"r1","name":"n1"}]}`))
resources, pages, err := decodeNamedResources([]byte(`{"data":[{"id":"r1","name":"n1"}]}`))
if err != nil {
t.Fatal(err)
}
if pages != 1 {
t.Fatalf("pages = %d, want 1", pages)
}
if len(resources) != 1 || resources[0].ID != "r1" {
t.Fatalf("got %+v", resources)
}
})
t.Run("numeric id", func(t *testing.T) {
resources, err := decodeNamedResources([]byte(`{"data":{"items":[{"id":1,"name":"default"}]}}`))
resources, pages, err := decodeNamedResources([]byte(`{"data":{"items":[{"id":1,"name":"default"}],"pages":2}}`))
if err != nil {
t.Fatal(err)
}
if pages != 2 {
t.Fatalf("pages = %d, want 2", pages)
}
if len(resources) != 1 || resources[0].ID != "1" {
t.Fatalf("got %+v", resources)
}
})
t.Run("wrapper with items", func(t *testing.T) {
resources, err := decodeNamedResources([]byte(`{"data":{"items":[{"id":"r2","name":"n2"}]}}`))
resources, pages, err := decodeNamedResources([]byte(`{"data":{"items":[{"id":"r2","name":"n2"}]}}`))
if err != nil {
t.Fatal(err)
}
if pages != 1 {
t.Fatalf("pages = %d, want 1", pages)
}
if len(resources) != 1 || resources[0].ID != "r2" {
t.Fatalf("got %+v", resources)
}
})
t.Run("invalid json", func(t *testing.T) {
_, err := decodeNamedResources([]byte(`not json`))
_, _, err := decodeNamedResources([]byte(`not json`))
if err == nil {
t.Fatal("expected error")
}
@@ -904,6 +913,12 @@ func TestEnsureSubscriptionAccessWithMock(t *testing.T) {
func TestCheckGatewayAccessWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer gk" {
t.Fatalf("Authorization = %q, want %q", got, "Bearer gk")
}
if got := r.Header.Get("x-api-key"); got != "" {
t.Fatalf("x-api-key = %q, want empty", got)
}
w.Write([]byte(`{"data":[{"id":"gpt-4"},{"id":"claude-3"}]}`))
}))
defer srv.Close()
@@ -920,6 +935,50 @@ func TestCheckGatewayAccessWithMock(t *testing.T) {
}
}
func TestCheckGatewayCompletionWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Fatalf("path = %q, want /v1/chat/completions", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer gk" {
t.Fatalf("Authorization = %q, want %q", got, "Bearer gk")
}
if got := r.Header.Get("x-api-key"); got != "" {
t.Fatalf("x-api-key = %q, want empty", got)
}
var payload struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if payload.Model != "gpt-4" {
t.Fatalf("model = %q, want gpt-4", payload.Model)
}
if payload.MaxTokens != 8 {
t.Fatalf("max_tokens = %d, want 8", payload.MaxTokens)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"choices":[{"message":{"content":"pong"}}]}`))
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
result, err := client.CheckGatewayCompletion(context.Background(), GatewayCompletionCheckRequest{APIKey: "gk", Model: "gpt-4"})
if err != nil {
t.Fatal(err)
}
if !result.OK {
t.Fatal("expected completion OK=true")
}
if result.StatusCode != 200 {
t.Fatalf("status = %d, want 200", result.StatusCode)
}
if result.ContentType != "application/json" {
t.Fatalf("content type = %q, want application/json", result.ContentType)
}
}
func TestBatchCreateAccountsWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
@@ -1037,19 +1096,93 @@ func TestListManagedResourcesWithMock(t *testing.T) {
}
}
func TestTestAccountWithMock(t *testing.T) {
func TestListManagedResourcesLoadsAllAccountPages(t *testing.T) {
accountPages := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/admin/groups", "/api/v1/admin/channels":
_, _ = w.Write([]byte(`{"data":{"items":[{"id":"r1","name":"resource-1"}],"total":1,"page":1,"page_size":20,"pages":1}}`))
case "/api/v1/admin/payment/plans":
_, _ = w.Write([]byte(`{"data":[{"id":"plan_1","name":"plan-1"}]}`))
case "/api/v1/admin/accounts":
accountPages++
page := r.URL.Query().Get("page")
if page == "" {
page = "1"
}
if got := r.URL.Query().Get("page_size"); got != "100" {
t.Fatalf("page_size = %q, want 100", got)
}
switch page {
case "1":
_, _ = w.Write([]byte(`{"data":{"items":[{"id":"account_1","name":"deepseek-01"}],"total":2,"page":1,"page_size":100,"pages":2}}`))
case "2":
_, _ = w.Write([]byte(`{"data":{"items":[{"id":"account_2","name":"deepseek-02"}],"total":2,"page":2,"page_size":100,"pages":2}}`))
default:
t.Fatalf("unexpected accounts page %q", page)
}
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
snapshot, err := client.ListManagedResources(context.Background(), ListManagedResourcesRequest{AccountNamePrefix: "deepseek-"})
if err != nil {
t.Fatal(err)
}
if accountPages != 2 {
t.Fatalf("account pages fetched = %d, want 2", accountPages)
}
if len(snapshot.Accounts) != 2 || snapshot.Accounts[0].ID != "account_1" || snapshot.Accounts[1].ID != "account_2" {
t.Fatalf("Accounts = %+v, want both paged accounts", snapshot.Accounts)
}
}
func TestTestAccountWithMock(t *testing.T) {
var requestBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
w.Write([]byte("data: {\"status\":\"passed\",\"ok\":true}\n"))
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
result, err := client.TestAccount(context.Background(), "a1")
result, err := client.TestAccount(context.Background(), "a1", "MiniMax-M2.7-highspeed")
if err != nil {
t.Fatal(err)
}
if !result.OK {
t.Fatal("expected OK=true")
}
if got := requestBody["model_id"]; got != "MiniMax-M2.7-highspeed" {
t.Fatalf("model_id = %#v, want MiniMax-M2.7-highspeed", got)
}
}
func TestTestAccountWithMockSSEError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte("data: {\"type\":\"test_start\",\"model\":\"MiniMax-M2.7-highspeed\"}\n\n"))
w.Write([]byte("data: {\"type\":\"error\",\"error\":\"账号本身可正常使用,但当前测试接口仅支持 Responses API 路径。请直接通过实际 API 调用验证。\"}\n\n"))
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
result, err := client.TestAccount(context.Background(), "a1", "")
if err != nil {
t.Fatal(err)
}
if result.OK {
t.Fatal("expected OK=false for SSE error event")
}
if result.Status != "failed" {
t.Fatalf("Status = %q, want failed", result.Status)
}
if !strings.Contains(result.Message, "测试接口仅支持 Responses API 路径") {
t.Fatalf("Message = %q, want propagated SSE error message", result.Message)
}
}
func TestGetAccountModelsWithMock(t *testing.T) {

View File

@@ -12,6 +12,8 @@ import (
"strings"
)
const envAllowInsecureProviderBaseURL = "SUB2API_CRM_ALLOW_INSECURE_PROVIDER_BASE_URLS"
type Manifest struct {
PackID string `json:"pack_id"`
Version string `json:"version"`
@@ -155,6 +157,8 @@ func loadProviders(root string, providersDir string) ([]ProviderManifest, error)
func validateProviders(providers []ProviderManifest) error {
seen := make(map[string]struct{}, len(providers))
allowInsecureBaseURL := strings.EqualFold(strings.TrimSpace(os.Getenv(envAllowInsecureProviderBaseURL)), "1") ||
strings.EqualFold(strings.TrimSpace(os.Getenv(envAllowInsecureProviderBaseURL)), "true")
for _, provider := range providers {
providerID := strings.TrimSpace(provider.ProviderID)
missingDefaultModel := firstMissingDefaultModel(provider.DefaultModels, provider.ChannelTemplate.ModelMapping)
@@ -163,7 +167,7 @@ func validateProviders(providers []ProviderManifest) error {
return fmt.Errorf("provider manifest: provider_id is required")
case strings.TrimSpace(provider.DisplayName) == "":
return fmt.Errorf("provider %q: display_name is required", providerID)
case !strings.HasPrefix(strings.TrimSpace(provider.BaseURL), "https://"):
case !hasAllowedProviderBaseURL(strings.TrimSpace(provider.BaseURL), allowInsecureBaseURL):
return fmt.Errorf("provider %q: base_url must use https", providerID)
case strings.TrimSpace(provider.Platform) == "":
return fmt.Errorf("provider %q: platform is required", providerID)
@@ -198,6 +202,13 @@ func validateProviders(providers []ProviderManifest) error {
return nil
}
func hasAllowedProviderBaseURL(baseURL string, allowInsecureBaseURL bool) bool {
if strings.HasPrefix(baseURL, "https://") {
return true
}
return allowInsecureBaseURL && strings.HasPrefix(baseURL, "http://")
}
func validateChecksums(root string, checksumFile string) error {
path := filepath.Join(root, checksumFile)
file, err := os.Open(path)

View File

@@ -82,6 +82,23 @@ func TestLoadDirRejectsInvalidProviderSchema(t *testing.T) {
}
}
func TestLoadDirAllowsInsecureProviderBaseURLWhenExplicitlyEnabled(t *testing.T) {
t.Setenv(envAllowInsecureProviderBaseURL, "1")
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":"http://insecure.example.com","platform":"openai","account_type":"apikey","default_models":["deepseek-v4-pro"],"smoke_test_model":"deepseek-v4-pro","group_template":{"name":"g","rate_multiplier":1},"channel_template":{"name":"c","model_mapping":{"deepseek-v4-pro":"deepseek-v4-pro"}},"plan_template":{"name":"p","price":1,"validity_days":30,"validity_unit":"day"},"import":{"supports_multi_key":true,"supports_strict":true,"supports_partial":true}}`,
})
loaded, err := LoadDir(packDir)
if err != nil {
t.Fatalf("LoadDir() error = %v, want insecure http provider accepted when explicitly enabled", err)
}
if len(loaded.Providers) != 1 || loaded.Providers[0].BaseURL != "http://insecure.example.com" {
t.Fatalf("LoadDir() providers = %+v, want insecure provider retained", loaded.Providers)
}
}
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"}`,

View File

@@ -73,6 +73,7 @@ type ReconcileResult struct {
Status string
MissingCount int
ExtraCount int
StaleNoiseCount int
ProbeFailureCount int
AccessStatus string
Summary map[string]any
@@ -128,7 +129,11 @@ func (s *ReconcileService) Reconcile(ctx context.Context, req ReconcileRequest)
default:
return ReconcileResult{}, fmt.Errorf("latest import batch is %s; run import again before reconcile", batchRow.BatchStatus)
}
storedResources, err := s.store.ManagedResources().GetByBatchID(ctx, batchRow.ID)
storedResources, err := s.storedResourcesForReconcile(ctx, providerRow.ID, hostRow.ID, batchRow.ID)
if err != nil {
return ReconcileResult{}, err
}
currentBatchResources, err := s.store.ManagedResources().GetByBatchID(ctx, batchRow.ID)
if err != nil {
return ReconcileResult{}, err
}
@@ -145,6 +150,14 @@ func (s *ReconcileService) Reconcile(ctx context.Context, req ReconcileRequest)
return ReconcileResult{}, fmt.Errorf("list managed resources: %w", err)
}
missing, extra := diffManagedResources(storedResources, snapshot)
rawExtra := extra
staleNoiseAccounts := classifyHistoricalAccountNoise(currentBatchResources, snapshot.Accounts, SuggestAccountNamePrefix(req.Provider))
if len(staleNoiseAccounts) > 0 {
extra -= len(staleNoiseAccounts)
if extra < 0 {
extra = 0
}
}
probeFailures, err := s.rerunAccountProbes(ctx, batchItems, req.Provider.SmokeTestModel)
if err != nil {
return ReconcileResult{}, err
@@ -160,12 +173,15 @@ func (s *ReconcileService) Reconcile(ctx context.Context, req ReconcileRequest)
status = "degraded"
}
summary := map[string]any{
"missing_count": missing,
"extra_count": extra,
"host_version": hostVersion,
"probe_failures": probeFailures,
"access_status": accessStatus,
"access_rechecked": accessChecked,
"missing_count": missing,
"extra_count": extra,
"raw_extra_count": rawExtra,
"stale_noise_count": len(staleNoiseAccounts),
"stale_noise_accounts": staleNoiseAccounts,
"host_version": hostVersion,
"probe_failures": probeFailures,
"access_status": accessStatus,
"access_rechecked": accessChecked,
}
summaryJSON, err := json.Marshal(summary)
if err != nil {
@@ -174,7 +190,7 @@ func (s *ReconcileService) Reconcile(ctx context.Context, req ReconcileRequest)
if _, err := s.store.ReconcileRuns().Create(ctx, sqlite.ReconcileRun{BatchID: batchRow.ID, HostID: hostRow.ID, ProviderID: providerRow.ID, Status: status, SummaryJSON: string(summaryJSON)}); err != nil {
return ReconcileResult{}, err
}
return ReconcileResult{BatchID: batchRow.ID, Status: status, MissingCount: missing, ExtraCount: extra, ProbeFailureCount: probeFailures, AccessStatus: accessStatus, Summary: summary}, nil
return ReconcileResult{BatchID: batchRow.ID, Status: status, MissingCount: missing, ExtraCount: extra, StaleNoiseCount: len(staleNoiseAccounts), ProbeFailureCount: probeFailures, AccessStatus: accessStatus, Summary: summary}, nil
}
func (s *ReconcileService) rerunAccountProbes(ctx context.Context, items []sqlite.ImportBatchItem, expectedModel string) (int, error) {
@@ -190,7 +206,7 @@ func (s *ReconcileService) rerunAccountProbes(ctx context.Context, items []sqlit
if strings.TrimSpace(accountID) == "" {
return 0, fmt.Errorf("import batch item %d missing account_id in probe summary", item.ID)
}
probe, err := s.host.TestAccount(ctx, accountID)
probe, err := s.host.TestAccount(ctx, accountID, expectedModel)
if err != nil {
return 0, fmt.Errorf("re-test account %s: %w", accountID, err)
}
@@ -199,15 +215,22 @@ func (s *ReconcileService) rerunAccountProbes(ctx context.Context, items []sqlit
return 0, fmt.Errorf("reload account models %s: %w", accountID, err)
}
smokeModelSeen := hasModel(models, expectedModel)
status := firstNonEmpty(probe.Status, "unknown")
result := AccountImportResult{
Probe: probe,
Models: models,
SmokeModelSeen: smokeModelSeen,
}
status := result.ValidationStatus()
payload, err := json.Marshal(map[string]any{
"account_id": accountID,
"probe_ok": probe.OK,
"probe_status": probe.Status,
"probe_message": probe.Message,
"models": models,
"smoke_model_seen": smokeModelSeen,
"reconcile_rerun": true,
"account_id": accountID,
"probe_ok": probe.OK,
"probe_status": probe.Status,
"probe_message": probe.Message,
"models": models,
"smoke_model_seen": smokeModelSeen,
"probe_advisory": result.HasAdvisoryWarning(),
"validation_status": status,
"reconcile_rerun": true,
})
if err != nil {
return 0, fmt.Errorf("marshal probe rerun summary for %s: %w", accountID, err)
@@ -218,7 +241,7 @@ func (s *ReconcileService) rerunAccountProbes(ctx context.Context, items []sqlit
if _, err := s.store.ProbeResults().Create(ctx, sqlite.ProbeResult{BatchItemID: item.ID, ProbeType: "account_smoke_rerun", Status: status, SummaryJSON: string(payload)}); err != nil {
return 0, err
}
if !probe.OK || !smokeModelSeen {
if result.HasBlockingFailure() {
failures++
}
}
@@ -239,6 +262,21 @@ func (s *ReconcileService) rerunAccessClosure(ctx context.Context, batchID int64
return "", false, fmt.Errorf("re-check gateway access: %w", err)
}
if result.OK && result.HasExpectedModel {
completion, err := s.host.CheckGatewayCompletion(ctx, sub2api.GatewayCompletionCheckRequest{
APIKey: probeAPIKey,
Model: expectedModel,
Prompt: "ping",
MaxTokens: 8,
})
if err != nil {
return "", false, fmt.Errorf("re-check gateway completion: %w", err)
}
result.CompletionOK = completion.OK
result.CompletionStatus = completion.StatusCode
result.CompletionType = completion.ContentType
result.CompletionBody = completion.BodyPreview
}
if GatewayAccessReady(result) {
status = deriveHealthyAccessStatus(latest.ClosureType)
} else {
status = AccessStatusBroken
@@ -248,6 +286,10 @@ func (s *ReconcileService) rerunAccessClosure(ctx context.Context, batchID int64
"ok": result.OK,
"has_expected_model": result.HasExpectedModel,
"models": result.Models,
"completion_ok": result.CompletionOK,
"completion_status": result.CompletionStatus,
"completion_type": result.CompletionType,
"completion_preview": result.CompletionBody,
"reconcile_rerun": true,
})
if err != nil {
@@ -302,6 +344,42 @@ func accountIDFromProbeSummary(summaryJSON string) (string, error) {
return strings.TrimSpace(accountID), nil
}
func (s *ReconcileService) storedResourcesForReconcile(ctx context.Context, providerID, hostID, batchID int64) ([]sqlite.ManagedResource, error) {
storedResources, err := s.store.ManagedResources().GetByBatchID(ctx, batchID)
if err != nil {
return nil, err
}
sharedResources, err := s.store.ManagedResources().ListByProviderIDAndHostID(ctx, providerID, hostID)
if err != nil {
return nil, err
}
merged := make([]sqlite.ManagedResource, 0, len(storedResources)+len(sharedResources))
seen := make(map[string]struct{}, len(storedResources)+len(sharedResources))
appendUnique := func(resource sqlite.ManagedResource) {
resourceType := strings.TrimSpace(resource.ResourceType)
resourceID := strings.TrimSpace(resource.HostResourceID)
if resourceType == "" || resourceID == "" {
return
}
key := resourceType + ":" + resourceID
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
merged = append(merged, resource)
}
for _, resource := range storedResources {
appendUnique(resource)
}
for _, resource := range sharedResources {
switch strings.TrimSpace(resource.ResourceType) {
case "group", "channel", "plan":
appendUnique(resource)
}
}
return merged, nil
}
func diffManagedResources(stored []sqlite.ManagedResource, snapshot sub2api.ManagedResourceSnapshot) (int, int) {
live := map[string]map[string]struct{}{
"group": make(map[string]struct{}),
@@ -348,3 +426,32 @@ func diffManagedResources(stored []sqlite.ManagedResource, snapshot sub2api.Mana
}
return missing, extra
}
func classifyHistoricalAccountNoise(currentBatchResources []sqlite.ManagedResource, snapshotAccounts []sub2api.NamedResource, accountNamePrefix string) []sub2api.NamedResource {
currentAccountIDs := make(map[string]struct{})
for _, resource := range currentBatchResources {
if strings.TrimSpace(resource.ResourceType) != "account" {
continue
}
if id := strings.TrimSpace(resource.HostResourceID); id != "" {
currentAccountIDs[id] = struct{}{}
}
}
prefix := strings.TrimSpace(accountNamePrefix)
staleNoise := make([]sub2api.NamedResource, 0)
for _, account := range snapshotAccounts {
id := strings.TrimSpace(account.ID)
if id == "" {
continue
}
if _, ok := currentAccountIDs[id]; ok {
continue
}
if prefix != "" && !strings.HasPrefix(strings.TrimSpace(account.Name), prefix) {
continue
}
staleNoise = append(staleNoise, sub2api.NamedResource{ID: id, Name: strings.TrimSpace(account.Name)})
}
return staleNoise
}

View File

@@ -31,6 +31,10 @@ const (
AccessStatusSelfServiceReady = "self_service_ready"
AccessStatusFullyReady = "fully_ready"
AccessStatusBroken = "broken"
AccountStatusPassed = "passed"
AccountStatusWarning = "warning"
AccountStatusFailed = "failed"
)
type AccessRequest struct {
@@ -70,11 +74,80 @@ type AccountImportResult struct {
SmokeModelSeen bool
}
func (r AccountImportResult) ValidationStatus() string {
if !r.SmokeModelSeen {
return AccountStatusFailed
}
if r.Probe.OK {
return AccountStatusPassed
}
if isAdvisoryAccountProbeFailure(r.Probe) {
return AccountStatusWarning
}
return AccountStatusFailed
}
func (r AccountImportResult) HasBlockingFailure() bool {
return r.ValidationStatus() == AccountStatusFailed
}
func (r AccountImportResult) HasAdvisoryWarning() bool {
return r.ValidationStatus() == AccountStatusWarning
}
type hostAdapter interface {
sub2api.HostAdapter
CheckGatewayAccess(ctx context.Context, req sub2api.GatewayAccessCheckRequest) (sub2api.GatewayAccessResult, error)
}
func GatewayAccessReady(result sub2api.GatewayAccessResult) bool {
return result.OK && result.HasExpectedModel && result.CompletionOK
}
func isAdvisoryAccountProbeFailure(probe sub2api.ProbeResult) bool {
if probe.OK {
return false
}
message := strings.ToLower(strings.TrimSpace(probe.Message))
if message == "" {
return false
}
if isTransientAccountProbeFailure(message) {
return true
}
if !strings.Contains(message, "responses api") {
return false
}
return strings.Contains(message, "当前测试接口仅支持") ||
strings.Contains(message, "账号本身可正常使用") ||
strings.Contains(message, "please directly") ||
strings.Contains(message, "actual api")
}
func isTransientAccountProbeFailure(message string) bool {
if !(strings.Contains(message, "429") ||
strings.Contains(message, "rate limit") ||
strings.Contains(message, "too many requests") ||
strings.Contains(message, "502") ||
strings.Contains(message, "503") ||
strings.Contains(message, "504") ||
strings.Contains(message, "bad gateway") ||
strings.Contains(message, "service unavailable") ||
strings.Contains(message, "timeout")) {
return false
}
return strings.Contains(message, "api returned") ||
strings.Contains(message, "rate_limit") ||
strings.Contains(message, "upstream") ||
strings.Contains(message, "temporar") ||
strings.Contains(message, "retry")
}
type resolvedManagedResources struct {
Group sub2api.GroupRef
Channel sub2api.ChannelRef
@@ -143,7 +216,7 @@ func (s *ImportService) Import(ctx context.Context, req ImportRequest) (report I
}
rollback.AddAccounts(accounts)
for _, account := range accounts {
probe, err := s.host.TestAccount(ctx, account.ID)
probe, err := s.host.TestAccount(ctx, account.ID, req.Provider.SmokeTestModel)
if err != nil {
return failOrDegrade(report, req.Mode, fmt.Errorf("test account %s: %w", account.ID, err))
}
@@ -157,7 +230,7 @@ func (s *ImportService) Import(ctx context.Context, req ImportRequest) (report I
failedAccounts := 0
for _, account := range report.Accounts {
if !account.Probe.OK || !account.SmokeModelSeen {
if account.HasBlockingFailure() {
failedAccounts++
}
}
@@ -188,7 +261,7 @@ func (s *ImportService) Import(ctx context.Context, req ImportRequest) (report I
report.BatchStatus = BatchStatusSucceeded
report.ProviderStatus = ProviderStatusActive
if failedAccounts > 0 || !gateway.OK || !gateway.HasExpectedModel {
if failedAccounts > 0 || !GatewayAccessReady(gateway) {
report.BatchStatus = BatchStatusPartial
report.ProviderStatus = ProviderStatusDegraded
}
@@ -198,7 +271,7 @@ func (s *ImportService) Import(ctx context.Context, req ImportRequest) (report I
case AccessModeSelfService:
report.AccessStatus = AccessStatusSelfServiceReady
}
if !gateway.OK || !gateway.HasExpectedModel {
if !GatewayAccessReady(gateway) {
report.AccessStatus = AccessStatusBroken
}
return report, nil

View File

@@ -65,6 +65,9 @@ func TestImportServiceImportSubscriptionFlow(t *testing.T) {
if host.gatewayProbe.ExpectedModel != "deepseek-chat" {
t.Fatalf("gateway probe model = %q, want %q", host.gatewayProbe.ExpectedModel, "deepseek-chat")
}
if host.testedModels["account_1"] != "deepseek-chat" || host.testedModels["account_2"] != "deepseek-chat" {
t.Fatalf("testedModels = %#v, want deepseek-chat for all created accounts", host.testedModels)
}
}
func TestImportServiceStrictModeFailsWhenAnyAccountProbeFails(t *testing.T) {
@@ -111,6 +114,136 @@ func TestImportServiceRejectsUnknownMode(t *testing.T) {
}
}
func TestImportServiceMarksBrokenWhenCompletionSmokeFails(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {OK: true, Status: "passed"},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
completionResult: sub2api.GatewayCompletionResult{OK: false, StatusCode: 502, ContentType: "application/json", BodyPreview: `{"error":"upstream_error"}`},
}
report, 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 report.BatchStatus != BatchStatusPartial {
t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusPartial)
}
if report.ProviderStatus != ProviderStatusDegraded {
t.Fatalf("ProviderStatus = %q, want %q", report.ProviderStatus, ProviderStatusDegraded)
}
if report.AccessStatus != AccessStatusBroken {
t.Fatalf("AccessStatus = %q, want %q", report.AccessStatus, AccessStatusBroken)
}
}
func TestImportServiceTreatsResponsesOnlyProbeFailureAsAdvisoryWhenGatewaySucceeds(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "minimax-01"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {
OK: false,
Status: "failed",
Message: "账号本身可正常使用,但当前测试接口仅支持 Responses API 路径。请直接通过实际 API 调用验证。",
},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{
OK: true,
StatusCode: 200,
HasExpectedModel: true,
Models: []string{"deepseek-chat"},
CompletionOK: true,
CompletionStatus: 200,
CompletionType: "text/event-stream",
},
}
report, 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 report.BatchStatus != BatchStatusSucceeded {
t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusSucceeded)
}
if report.ProviderStatus != ProviderStatusActive {
t.Fatalf("ProviderStatus = %q, want %q", report.ProviderStatus, ProviderStatusActive)
}
if report.AccessStatus != AccessStatusSelfServiceReady {
t.Fatalf("AccessStatus = %q, want %q", report.AccessStatus, AccessStatusSelfServiceReady)
}
if len(report.Accounts) != 1 {
t.Fatalf("Accounts len = %d, want 1", len(report.Accounts))
}
if got := report.Accounts[0].ValidationStatus(); got != AccountStatusWarning {
t.Fatalf("ValidationStatus = %q, want %q", got, AccountStatusWarning)
}
}
func TestImportServiceTreatsTransientProbeFailureAsAdvisoryWhenGatewaySucceeds(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {
OK: false,
Status: "failed",
Message: "API returned 429: {\"error\":{\"message\":\"Rate limited (429); user=1997/2000 model=49/50; daily_exhausted=False\",\"type\":\"rate_limit_error\",\"code\":\"rate_limit_exceeded\"}}",
},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{
OK: true,
StatusCode: 200,
HasExpectedModel: true,
Models: []string{"deepseek-chat"},
CompletionOK: true,
CompletionStatus: 200,
CompletionType: "application/json",
},
}
report, 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 report.BatchStatus != BatchStatusSucceeded {
t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusSucceeded)
}
if report.ProviderStatus != ProviderStatusActive {
t.Fatalf("ProviderStatus = %q, want %q", report.ProviderStatus, ProviderStatusActive)
}
if report.AccessStatus != AccessStatusSelfServiceReady {
t.Fatalf("AccessStatus = %q, want %q", report.AccessStatus, AccessStatusSelfServiceReady)
}
if got := report.Accounts[0].ValidationStatus(); got != AccountStatusWarning {
t.Fatalf("ValidationStatus = %q, want %q", got, AccountStatusWarning)
}
}
func TestImportServiceStrictModeRollsBackCreatedResources(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1"}, {ID: "account_2"}},
@@ -325,7 +458,7 @@ func TestImportDeletesExistingProviderAccountsBeforeGatewayClosure(t *testing.T)
if !reflect.DeepEqual(host.deletedResources, wantDeleted) {
t.Fatalf("deleted resources = %#v, want %#v", host.deletedResources, wantDeleted)
}
if !reflect.DeepEqual(host.callSequence, []string{"deleteAccount:account_old_2", "deleteAccount:account_old_1", "gateway"}) {
if !reflect.DeepEqual(host.callSequence, []string{"deleteAccount:account_old_2", "deleteAccount:account_old_1", "gateway", "completion"}) {
t.Fatalf("call sequence = %#v, want stale-account cleanup before gateway probe", host.callSequence)
}
}
@@ -374,6 +507,7 @@ type fakeHostAdapter struct {
hostVersion string
assignedSubscriptions []sub2api.AssignSubscriptionRequest
gatewayProbe sub2api.GatewayAccessCheckRequest
completionProbe sub2api.GatewayCompletionCheckRequest
deletedResources []string
managedSnapshot sub2api.ManagedResourceSnapshot
listManagedReq sub2api.ListManagedResourcesRequest
@@ -386,6 +520,9 @@ type fakeHostAdapter struct {
updateChannelID string
updateChannelReq sub2api.CreateChannelRequest
callSequence []string
completionResult sub2api.GatewayCompletionResult
completionErr error
testedModels map[string]string
}
func (f *fakeHostAdapter) GetHostVersion(context.Context) (string, error) {
@@ -444,7 +581,11 @@ func (f *fakeHostAdapter) DeleteAccount(_ context.Context, accountID string) err
f.deletedResources = append(f.deletedResources, "account:"+accountID)
return nil
}
func (f *fakeHostAdapter) TestAccount(_ context.Context, accountID string) (sub2api.ProbeResult, error) {
func (f *fakeHostAdapter) TestAccount(_ context.Context, accountID, modelID string) (sub2api.ProbeResult, error) {
if f.testedModels == nil {
f.testedModels = map[string]string{}
}
f.testedModels[accountID] = modelID
result, ok := f.testResults[accountID]
if !ok {
return sub2api.ProbeResult{}, fmt.Errorf("missing test result for %s", accountID)
@@ -476,6 +617,17 @@ func (f *fakeHostAdapter) CheckGatewayAccess(_ context.Context, req sub2api.Gate
}
return f.gatewayResult, nil
}
func (f *fakeHostAdapter) CheckGatewayCompletion(_ context.Context, req sub2api.GatewayCompletionCheckRequest) (sub2api.GatewayCompletionResult, error) {
f.callSequence = append(f.callSequence, "completion")
f.completionProbe = req
if f.completionErr != nil {
return sub2api.GatewayCompletionResult{}, f.completionErr
}
if f.completionResult.StatusCode == 0 && !f.completionResult.OK {
return sub2api.GatewayCompletionResult{OK: true, StatusCode: 200, ContentType: "application/json"}, nil
}
return f.completionResult, nil
}
func (f *fakeHostAdapter) ListManagedResources(_ context.Context, req sub2api.ListManagedResourcesRequest) (sub2api.ManagedResourceSnapshot, error) {
f.listManagedReq = req
return sub2api.ManagedResourceSnapshot{

View File

@@ -105,6 +105,63 @@ func TestReconcileServiceReturnsDegradedWhenProbeRerunFails(t *testing.T) {
}
}
func TestReconcileServiceIgnoresAdvisoryProbeFailureWhenModelsAndGatewayAreHealthy(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(t, store)
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {
OK: false,
Status: "failed",
Message: "账号本身可正常使用,但当前测试接口仅支持 Responses API 路径。请直接通过实际 API 调用验证。",
},
"account_2": {
OK: false,
Status: "failed",
Message: "账号本身可正常使用,但当前测试接口仅支持 Responses API 路径。请直接通过实际 API 调用验证。",
},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
"account_2": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{
OK: true,
StatusCode: 200,
HasExpectedModel: true,
Models: []string{"deepseek-chat"},
CompletionOK: true,
CompletionStatus: 200,
},
}
seedRuntimeImportForReconcile(t, store, host)
host.managedSnapshot = sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组-self-service"}},
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"}},
}
result, err := NewReconcileService(store, host).Reconcile(context.Background(), ReconcileRequest{
HostID: "host-1",
HostBaseURL: "https://sub2api.example.com",
AccessProbeAPIKey: "user-key",
Pack: pack.LoadedPack{Manifest: pack.Manifest{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", MinHostVersion: "0.1.126", MaxHostVersion: "0.2.x"}},
Provider: sampleProviderManifest(),
})
if err != nil {
t.Fatalf("Reconcile() error = %v", err)
}
if result.Status != "active" {
t.Fatalf("Status = %q, want active", result.Status)
}
if result.ProbeFailureCount != 0 {
t.Fatalf("ProbeFailureCount = %d, want 0", result.ProbeFailureCount)
}
}
func TestReconcileServiceReturnsDriftedWhenManagedResourceMissing(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(t, store)
@@ -232,6 +289,181 @@ func TestReconcileServicePassesAccountNamePrefixToManagedResourceSnapshot(t *tes
}
}
func TestReconcileServiceIgnoresSharedResourceReuseAcrossBatches(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(t, store)
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {OK: true, Status: "passed"},
"account_2": {OK: true, Status: "passed"},
"account_3": {OK: true, Status: "passed"},
"account_4": {OK: true, Status: "passed"},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
"account_2": {{ID: "deepseek-chat"}},
"account_3": {{ID: "deepseek-chat"}},
"account_4": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
}
seedRuntimeImportForReconcile(t, store, host)
ctx := context.Background()
packRow, err := store.Packs().GetByPackID(ctx, "openai-cn-pack")
if err != nil {
t.Fatalf("Packs().GetByPackID() error = %v", err)
}
providerRow, err := store.Providers().GetByPackIDAndProviderID(ctx, packRow.ID, sampleProviderManifest().ProviderID)
if err != nil {
t.Fatalf("Providers().GetByPackIDAndProviderID() error = %v", err)
}
hostRow, err := store.Hosts().GetByHostID(ctx, "host-1")
if err != nil {
t.Fatalf("Hosts().GetByHostID() error = %v", err)
}
latestBatchID, err := store.ImportBatches().Create(ctx, sqlite.ImportBatch{HostID: hostRow.ID, PackID: packRow.ID, ProviderID: providerRow.ID, Mode: ImportModePartial, BatchStatus: BatchStatusSucceeded, AccessStatus: AccessStatusSelfServiceReady})
if err != nil {
t.Fatalf("ImportBatches().Create() error = %v", err)
}
if _, err := store.ImportBatchItems().Create(ctx, sqlite.ImportBatchItem{BatchID: latestBatchID, KeyFingerprint: "key-3", AccountStatus: "passed", ProbeSummaryJSON: `{"account_id":"account_3"}`}); err != nil {
t.Fatalf("ImportBatchItems().Create() error = %v", err)
}
if _, err := store.ImportBatchItems().Create(ctx, sqlite.ImportBatchItem{BatchID: latestBatchID, KeyFingerprint: "key-4", AccountStatus: "passed", ProbeSummaryJSON: `{"account_id":"account_4"}`}); err != nil {
t.Fatalf("ImportBatchItems().Create() error = %v", err)
}
if _, err := store.ManagedResources().Create(ctx, sqlite.ManagedResource{BatchID: latestBatchID, HostID: hostRow.ID, ResourceType: "account", HostResourceID: "account_3", ResourceName: "deepseek-03"}); err != nil {
t.Fatalf("ManagedResources().Create(account_3) error = %v", err)
}
if _, err := store.ManagedResources().Create(ctx, sqlite.ManagedResource{BatchID: latestBatchID, HostID: hostRow.ID, ResourceType: "account", HostResourceID: "account_4", ResourceName: "deepseek-04"}); err != nil {
t.Fatalf("ManagedResources().Create(account_4) error = %v", err)
}
if _, err := store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{BatchID: latestBatchID, ClosureType: AccessModeSelfService, Status: AccessStatusSelfServiceReady, DetailsJSON: "{}"}); err != nil {
t.Fatalf("AccessClosures().Create() error = %v", err)
}
host.managedSnapshot = sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组-self-service"}},
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道-self-service"}},
Accounts: []sub2api.NamedResource{{ID: "account_3", Name: "deepseek-03"}, {ID: "account_4", Name: "deepseek-04"}},
}
result, err := NewReconcileService(store, host).Reconcile(ctx, ReconcileRequest{
HostID: "host-1",
HostBaseURL: "https://sub2api.example.com",
AccessProbeAPIKey: "user-key",
Pack: pack.LoadedPack{Manifest: pack.Manifest{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", MinHostVersion: "0.1.126", MaxHostVersion: "0.2.x"}},
Provider: sampleProviderManifest(),
})
if err != nil {
t.Fatalf("Reconcile() error = %v", err)
}
if result.BatchID != latestBatchID {
t.Fatalf("BatchID = %d, want latest batch %d", result.BatchID, latestBatchID)
}
if result.Status != "active" {
t.Fatalf("Status = %q, want active when only shared resources are reused", result.Status)
}
if result.MissingCount != 0 || result.ExtraCount != 0 {
t.Fatalf("Managed resource diff = (%d, %d), want (0, 0)", result.MissingCount, result.ExtraCount)
}
}
func TestReconcileServiceClassifiesHistoricalAccountsAsStaleNoise(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(t, store)
host := &fakeHostAdapter{
testResults: map[string]sub2api.ProbeResult{
"account_3": {OK: true, Status: "passed"},
"account_4": {OK: true, Status: "passed"},
},
models: map[string][]sub2api.AccountModel{
"account_3": {{ID: "deepseek-chat"}},
"account_4": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
}
seedRuntimeImportForReconcile(t, store, host)
ctx := context.Background()
packRow, err := store.Packs().GetByPackID(ctx, "openai-cn-pack")
if err != nil {
t.Fatalf("Packs().GetByPackID() error = %v", err)
}
providerRow, err := store.Providers().GetByPackIDAndProviderID(ctx, packRow.ID, sampleProviderManifest().ProviderID)
if err != nil {
t.Fatalf("Providers().GetByPackIDAndProviderID() error = %v", err)
}
hostRow, err := store.Hosts().GetByHostID(ctx, "host-1")
if err != nil {
t.Fatalf("Hosts().GetByHostID() error = %v", err)
}
latestBatchID, err := store.ImportBatches().Create(ctx, sqlite.ImportBatch{HostID: hostRow.ID, PackID: packRow.ID, ProviderID: providerRow.ID, Mode: ImportModePartial, BatchStatus: BatchStatusSucceeded, AccessStatus: AccessStatusSelfServiceReady})
if err != nil {
t.Fatalf("ImportBatches().Create() error = %v", err)
}
for _, item := range []struct {
keyFingerprint string
accountID string
accountName string
}{
{keyFingerprint: "key-3", accountID: "account_3", accountName: "deepseek-03"},
{keyFingerprint: "key-4", accountID: "account_4", accountName: "deepseek-04"},
} {
if _, err := store.ImportBatchItems().Create(ctx, sqlite.ImportBatchItem{BatchID: latestBatchID, KeyFingerprint: item.keyFingerprint, AccountStatus: "passed", ProbeSummaryJSON: `{"account_id":"` + item.accountID + `"}`}); err != nil {
t.Fatalf("ImportBatchItems().Create(%s) error = %v", item.accountID, err)
}
if _, err := store.ManagedResources().Create(ctx, sqlite.ManagedResource{BatchID: latestBatchID, HostID: hostRow.ID, ResourceType: "account", HostResourceID: item.accountID, ResourceName: item.accountName}); err != nil {
t.Fatalf("ManagedResources().Create(%s) error = %v", item.accountID, err)
}
}
if _, err := store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{BatchID: latestBatchID, ClosureType: AccessModeSelfService, Status: AccessStatusSelfServiceReady, DetailsJSON: "{}"}); err != nil {
t.Fatalf("AccessClosures().Create() error = %v", err)
}
host.managedSnapshot = sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组-self-service"}},
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"}, {ID: "account_3", Name: "deepseek-03"}, {ID: "account_4", Name: "deepseek-04"}},
}
result, err := NewReconcileService(store, host).Reconcile(ctx, ReconcileRequest{
HostID: "host-1",
HostBaseURL: "https://sub2api.example.com",
AccessProbeAPIKey: "user-key",
Pack: pack.LoadedPack{Manifest: pack.Manifest{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", MinHostVersion: "0.1.126", MaxHostVersion: "0.2.x"}},
Provider: sampleProviderManifest(),
})
if err != nil {
t.Fatalf("Reconcile() error = %v", err)
}
if result.BatchID != latestBatchID {
t.Fatalf("BatchID = %d, want latest batch %d", result.BatchID, latestBatchID)
}
if result.Status != "active" {
t.Fatalf("Status = %q, want active when only historical same-prefix accounts remain (missing=%d extra=%d stale=%d summary=%#v)", result.Status, result.MissingCount, result.ExtraCount, result.StaleNoiseCount, result.Summary)
}
if result.ExtraCount != 0 {
t.Fatalf("ExtraCount = %d, want 0 after stale-noise classification", result.ExtraCount)
}
if result.StaleNoiseCount != 2 {
t.Fatalf("StaleNoiseCount = %d, want 2", result.StaleNoiseCount)
}
if got, ok := result.Summary["stale_noise_count"].(int); !ok || got != 2 {
t.Fatalf("Summary[stale_noise_count] = %#v, want int(2)", result.Summary["stale_noise_count"])
}
accounts, ok := result.Summary["stale_noise_accounts"].([]sub2api.NamedResource)
if !ok {
t.Fatalf("Summary[stale_noise_accounts] type = %T, want []sub2api.NamedResource", result.Summary["stale_noise_accounts"])
}
if len(accounts) != 2 || accounts[0].ID != "account_1" || accounts[1].ID != "account_2" {
t.Fatalf("Summary[stale_noise_accounts] = %#v, want historical accounts 1 and 2", accounts)
}
}
func TestReconcileServiceRejectsRolledBackLatestBatch(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(t, store)

View File

@@ -169,13 +169,16 @@ func (s *RuntimeImportService) ensureProvider(ctx context.Context, packID int64,
func (s *RuntimeImportService) persistRuntimeArtifacts(ctx context.Context, batchID, hostID int64, accessMode string, report ImportReport, includeManagedResources bool) error {
for i, account := range report.Accounts {
validationStatus := account.ValidationStatus()
payload, err := json.Marshal(map[string]any{
"account_id": account.Ref.ID,
"probe_ok": account.Probe.OK,
"probe_status": account.Probe.Status,
"probe_message": account.Probe.Message,
"models": account.Models,
"smoke_model_seen": account.SmokeModelSeen,
"account_id": account.Ref.ID,
"probe_ok": account.Probe.OK,
"probe_status": account.Probe.Status,
"probe_message": account.Probe.Message,
"models": account.Models,
"smoke_model_seen": account.SmokeModelSeen,
"probe_advisory": account.HasAdvisoryWarning(),
"validation_status": validationStatus,
})
if err != nil {
return fmt.Errorf("marshal account probe summary: %w", err)
@@ -183,7 +186,7 @@ func (s *RuntimeImportService) persistRuntimeArtifacts(ctx context.Context, batc
itemID, err := s.store.ImportBatchItems().Create(ctx, sqlite.ImportBatchItem{
BatchID: batchID,
KeyFingerprint: fingerprintKey(report.AcceptedKeys, i),
AccountStatus: firstNonEmpty(account.Probe.Status, "unknown"),
AccountStatus: validationStatus,
ProbeSummaryJSON: string(payload),
})
if err != nil {
@@ -192,7 +195,7 @@ func (s *RuntimeImportService) persistRuntimeArtifacts(ctx context.Context, batc
if _, err := s.store.ProbeResults().Create(ctx, sqlite.ProbeResult{
BatchItemID: itemID,
ProbeType: "account_smoke",
Status: firstNonEmpty(account.Probe.Status, "unknown"),
Status: validationStatus,
SummaryJSON: string(payload),
}); err != nil {
return err
@@ -223,6 +226,10 @@ func (s *RuntimeImportService) persistRuntimeArtifacts(ctx context.Context, batc
"ok": report.Gateway.OK,
"has_expected_model": report.Gateway.HasExpectedModel,
"models": report.Gateway.Models,
"completion_ok": report.Gateway.CompletionOK,
"completion_status": report.Gateway.CompletionStatus,
"completion_type": report.Gateway.CompletionType,
"completion_preview": report.Gateway.CompletionBody,
})
if err != nil {
return fmt.Errorf("marshal gateway access summary: %w", err)

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"fmt"
"path/filepath"
"strings"
"testing"
_ "modernc.org/sqlite"
@@ -171,6 +172,70 @@ func TestRuntimeImportServicePersistsFailedBatchAfterStrictRollback(t *testing.T
}
}
func TestRuntimeImportServicePersistsWarningAccountStatusForAdvisoryProbeFailure(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(t, store)
seedProvisionHost(t, store, "host-1", "https://sub2api.example.com")
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {
OK: false,
Status: "failed",
Message: "账号本身可正常使用,但当前测试接口仅支持 Responses API 路径。请直接通过实际 API 调用验证。",
},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{
OK: true,
StatusCode: 200,
HasExpectedModel: true,
Models: []string{"deepseek-chat"},
CompletionOK: true,
CompletionStatus: 200,
},
}
svc := NewRuntimeImportService(store, host)
result, err := svc.Import(context.Background(), RuntimeImportRequest{
HostID: "host-1",
HostBaseURL: "https://sub2api.example.com",
Pack: pack.LoadedPack{
Manifest: pack.Manifest{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", MinHostVersion: "0.1.126", MaxHostVersion: "0.2.x"},
Checksum: "checksum-1",
},
Provider: sampleProviderManifest(),
Mode: ImportModePartial,
Keys: []string{"key-1"},
Access: AccessRequest{
Mode: AccessModeSelfService,
ProbeAPIKey: "user-key",
},
})
if err != nil {
t.Fatalf("RuntimeImportService.Import() error = %v", err)
}
if result.Report.BatchStatus != BatchStatusSucceeded {
t.Fatalf("BatchStatus = %q, want %q", result.Report.BatchStatus, BatchStatusSucceeded)
}
var accountStatus string
var summary string
if err := store.SQLDB().QueryRowContext(context.Background(), "SELECT account_status, probe_summary_json FROM import_batch_items WHERE batch_id = ? ORDER BY id LIMIT 1", result.BatchID).Scan(&accountStatus, &summary); err != nil {
t.Fatalf("query import batch item: %v", err)
}
if accountStatus != AccountStatusWarning {
t.Fatalf("account_status = %q, want %q", accountStatus, AccountStatusWarning)
}
if !strings.Contains(summary, "\"probe_advisory\":true") {
t.Fatalf("probe_summary_json = %s, want probe_advisory=true", summary)
}
}
func TestRuntimeImportServicePersistsPartialManagedResourcesOnAccessFailure(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(t, store)