fix fresh-host acceptance and document real-host debugging learnings
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}`,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user