diff --git a/internal/host/sub2api/sub2api_test.go b/internal/host/sub2api/sub2api_test.go index d27cd7df..401d0c6f 100644 --- a/internal/host/sub2api/sub2api_test.go +++ b/internal/host/sub2api/sub2api_test.go @@ -657,6 +657,108 @@ func TestCreateChannelWithMock(t *testing.T) { } } +func TestUpdateChannelWithMock(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Fatalf("method = %s, want PUT", r.Method) + } + if r.URL.Path != "/api/v1/admin/channels/201" { + t.Fatalf("path = %s, want /api/v1/admin/channels/201", r.URL.Path) + } + var req struct { + ModelMapping map[string]map[string]string `json:"model_mapping"` + ModelPricing []struct { + Platform string `json:"platform"` + Models []string `json:"models"` + BillingMode string `json:"billing_mode"` + } `json:"model_pricing"` + RestrictModels bool `json:"restrict_models"` + BillingModelSource string `json:"billing_model_source"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req.ModelMapping["openai"]["deepseek-v4-pro"] != "deepseek-v4-pro" { + t.Fatalf("model_mapping = %+v, want openai/deepseek-v4-pro passthrough", req.ModelMapping) + } + if len(req.ModelPricing) != 1 || req.ModelPricing[0].Platform != "openai" || req.ModelPricing[0].BillingMode != "token" { + t.Fatalf("model_pricing = %+v, want openai/token entry", req.ModelPricing) + } + if !req.RestrictModels { + t.Fatal("restrict_models = false, want true") + } + if req.BillingModelSource != "channel_mapped" { + t.Fatalf("billing_model_source = %q, want channel_mapped", req.BillingModelSource) + } + w.Write([]byte(`{"data":{"id":201,"name":"ch"}}`)) + })) + defer srv.Close() + client, _ := NewClient(srv.URL, WithAPIKey("k")) + if err := client.UpdateChannel(context.Background(), "201", CreateChannelRequest{ + Name: "ch", + GroupIDs: []string{"101"}, + ModelMapping: map[string]string{"deepseek-v4-pro": "deepseek-v4-pro"}, + ModelPricing: []ChannelModelPricing{{Platform: "openai", Models: []string{"deepseek-v4-pro"}, BillingMode: "token"}}, + RestrictModels: true, + BillingModelSource: "channel_mapped", + }); err != nil { + t.Fatal(err) + } +} + +func TestCreateChannelRequestMarshalJSONDefaultsPricingPlatform(t *testing.T) { + t.Run("request platform", func(t *testing.T) { + payload, err := json.Marshal(CreateChannelRequest{ + Name: "ch", + GroupIDs: []string{"101"}, + Platform: "openai", + ModelMapping: map[string]string{"deepseek-v4-pro": "deepseek-v4-pro"}, + ModelPricing: []ChannelModelPricing{{Models: []string{"deepseek-v4-pro"}, BillingMode: "token"}}, + }) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + var got struct { + ModelMapping map[string]map[string]string `json:"model_mapping"` + ModelPricing []struct { + Platform string `json:"platform"` + } `json:"model_pricing"` + } + if err := json.Unmarshal(payload, &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if got.ModelMapping["openai"]["deepseek-v4-pro"] != "deepseek-v4-pro" { + t.Fatalf("model_mapping = %+v, want openai/deepseek-v4-pro passthrough", got.ModelMapping) + } + if len(got.ModelPricing) != 1 || got.ModelPricing[0].Platform != "openai" { + t.Fatalf("model_pricing = %+v, want platform openai", got.ModelPricing) + } + }) + + t.Run("openai fallback", func(t *testing.T) { + payload, err := json.Marshal(CreateChannelRequest{ + Name: "ch", + GroupIDs: []string{"101"}, + ModelMapping: map[string]string{"deepseek-v4-pro": "deepseek-v4-pro"}, + ModelPricing: []ChannelModelPricing{{Models: []string{"deepseek-v4-pro"}, BillingMode: "token"}}, + }) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + var got struct { + ModelPricing []struct { + Platform string `json:"platform"` + } `json:"model_pricing"` + } + if err := json.Unmarshal(payload, &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(got.ModelPricing) != 1 || got.ModelPricing[0].Platform != "openai" { + t.Fatalf("model_pricing = %+v, want platform openai fallback", got.ModelPricing) + } + }) +} + func TestCreatePlanWithMock(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req struct { diff --git a/scripts/real_host_acceptance.sh b/scripts/real_host_acceptance.sh index 798bd6d9..3d30ced8 100755 --- a/scripts/real_host_acceptance.sh +++ b/scripts/real_host_acceptance.sh @@ -43,6 +43,53 @@ save_json() { printf '%s\n' "$payload" > "$ARTIFACT_DIR/$name.json" } +write_checklist_guide() { + mkdir -p "$ARTIFACT_DIR" + cat > "$ARTIFACT_DIR/00-artifact-guide.txt" < 速查清单对应 + +清单 1(环境 / host 前置) +- 01-create-host.json +- 02-probe-host.json + +清单 2(channel 宿主契约 / 导入落库) +- 03-install-pack.json +- 04-preview-import.json +- 05-import.json +- 05a-batch-detail-pre-access.json(若拿到 batch_id 且非 dry-run) +- 08-provider-status.json +- 09-reconcile.json +- 10-batch-detail.json(若拿到 batch_id 且非 dry-run) + +清单 3(access / key 闭环状态) +- 06-access-preview.json +- 07-access-status.json + +清单 4(必须分层留证据,不可混用) +- account 视角:由 AFTER_IMPORT_HOOK_COMMAND 额外落证据,例如 GET /api/v1/admin/accounts/:id/models +- 普通用户 / managed key 视角:由 AFTER_IMPORT_HOOK_COMMAND 额外落证据,例如 GET /v1/models +- completion 视角:由 AFTER_IMPORT_HOOK_COMMAND 额外落证据,例如 POST /v1/chat/completions + +红线: +- /api/v1/admin/accounts/:id/models 正确 ≠ /v1/models 正确 +- /v1/models 正确 ≠ /v1/chat/completions 正确 +- admin API 成功 ≠ 普通用户链路成功 + +当前 hook 配置:$( [[ -n "$AFTER_IMPORT_HOOK_COMMAND" ]] && printf 'enabled' || printf 'disabled' ) +EOF +} + +print_artifact_summary() { + echo "artifact guide: $ARTIFACT_DIR/00-artifact-guide.txt" + echo "checklist import evidence: 04-preview-import.json 05-import.json 05a-batch-detail-pre-access.json(optional) 08-provider-status.json 09-reconcile.json" + echo "checklist access evidence: 06-access-preview.json 07-access-status.json" + if [[ -n "$AFTER_IMPORT_HOOK_COMMAND" ]]; then + echo "checklist layered evidence: see 05b-after-import-hook.stdout.txt / 05b-after-import-hook.stderr.txt and hook-generated files under $ARTIFACT_DIR" + else + echo "checklist layered evidence: missing hook-generated /accounts/:id/models, /v1/models, /v1/chat/completions artifacts" + fi +} + curl_json() { local method="$1" local path="$2" @@ -135,6 +182,7 @@ export HOST_API_KEY HOST_BEARER_TOKEN ACCESS_API_KEY SUBSCRIPTION_USERS KEYS mkdir -p "$ARTIFACT_DIR" echo "artifacts: $ARTIFACT_DIR" +write_checklist_guide HOST_AUTH_JSON="$(build_host_auth_payload)" export HOST_AUTH_JSON @@ -281,4 +329,5 @@ PY save_json 11-rollback "$RESP_ROLLBACK" fi +print_artifact_summary echo "acceptance flow completed" diff --git a/tests/integration/host_stub_test.go b/tests/integration/host_stub_test.go index a6d3d1f5..7ef64fdf 100644 --- a/tests/integration/host_stub_test.go +++ b/tests/integration/host_stub_test.go @@ -217,6 +217,89 @@ func TestSub2APIHostAdapterChecksGatewayAccess(t *testing.T) { } } +func TestSub2APIHostAdapterSeparatesAccountModelsFromGatewayModels(t *testing.T) { + server := newSub2APIStubServer(t, sub2APIStubConfig{ + requireAPIKey: true, + version: "0.1.126", + accountModels: []map[string]any{{"id": "deepseek-account-only", "display_name": "DeepSeek Account Only", "type": "chat"}}, + gatewayModels: []map[string]any{{"id": "deepseek-gateway-only"}}, + gatewayExpectedKey: "managed-user-key", + }) + defer server.Close() + + client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key")) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + accountModels, err := client.GetAccountModels(context.Background(), "account_1") + if err != nil { + t.Fatalf("GetAccountModels() error = %v", err) + } + if len(accountModels) != 1 || accountModels[0].ID != "deepseek-account-only" { + t.Fatalf("GetAccountModels() = %+v, want admin account models only", accountModels) + } + + gatewayResult, err := client.CheckGatewayAccess(context.Background(), sub2api.GatewayAccessCheckRequest{ + APIKey: "managed-user-key", + ExpectedModel: "deepseek-gateway-only", + }) + if err != nil { + t.Fatalf("CheckGatewayAccess() error = %v", err) + } + if !gatewayResult.OK || !gatewayResult.HasExpectedModel { + t.Fatalf("CheckGatewayAccess() = %+v, want gateway models only", gatewayResult) + } + if len(gatewayResult.Models) != 1 || gatewayResult.Models[0] != "deepseek-gateway-only" { + t.Fatalf("gateway models = %+v, want gateway-only model list", gatewayResult.Models) + } + if gatewayResult.Models[0] == accountModels[0].ID { + t.Fatalf("gateway models = %+v unexpectedly matched account models %+v", gatewayResult.Models, accountModels) + } +} + +func TestSub2APIHostAdapterGatewayProbeDoesNotReuseAdminCredential(t *testing.T) { + server := newSub2APIStubServer(t, sub2APIStubConfig{ + requireAPIKey: true, + version: "0.1.126", + gatewayExpectedKey: "managed-user-key", + }) + defer server.Close() + + client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key")) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + result, err := client.CheckGatewayAccess(context.Background(), sub2api.GatewayAccessCheckRequest{ + APIKey: "managed-user-key", + ExpectedModel: "deepseek-chat", + }) + if err != nil { + t.Fatalf("CheckGatewayAccess() error = %v", err) + } + if !result.OK { + t.Fatalf("CheckGatewayAccess() = %+v, want OK=true with managed user key", result) + } + + wrongKeyResult, err := client.CheckGatewayAccess(context.Background(), sub2api.GatewayAccessCheckRequest{ + APIKey: "api-key", + ExpectedModel: "deepseek-chat", + }) + if err != nil { + t.Fatalf("CheckGatewayAccess() with admin key error = %v", err) + } + if wrongKeyResult.OK { + t.Fatalf("CheckGatewayAccess() with admin key = %+v, want OK=false", wrongKeyResult) + } + if wrongKeyResult.StatusCode != http.StatusUnauthorized { + t.Fatalf("StatusCode = %d, want %d", wrongKeyResult.StatusCode, http.StatusUnauthorized) + } + if wrongKeyResult.HasExpectedModel { + t.Fatalf("CheckGatewayAccess() with admin key = %+v, want HasExpectedModel=false", wrongKeyResult) + } +} + func TestSub2APIHostAdapterDeletesManagedResources(t *testing.T) { server := newSub2APIStubServer(t, sub2APIStubConfig{ requireAPIKey: true, @@ -277,13 +360,35 @@ func TestSub2APIHostAdapterListManagedResources(t *testing.T) { } type sub2APIStubConfig struct { - requireAPIKey bool - version string + requireAPIKey bool + version string + accountModels []map[string]any + gatewayModels []map[string]any + gatewayExpectedKey string } func newSub2APIStubServer(t *testing.T, cfg sub2APIStubConfig) *httptest.Server { t.Helper() + accountModels := cfg.accountModels + if len(accountModels) == 0 { + accountModels = []map[string]any{ + {"id": "deepseek-chat", "display_name": "DeepSeek Chat", "type": "chat"}, + {"id": "deepseek-reasoner", "display_name": "DeepSeek Reasoner", "type": "reasoning"}, + } + } + gatewayModels := cfg.gatewayModels + if len(gatewayModels) == 0 { + gatewayModels = []map[string]any{ + {"id": "deepseek-chat"}, + {"id": "deepseek-reasoner"}, + } + } + gatewayExpectedKey := cfg.gatewayExpectedKey + if gatewayExpectedKey == "" { + gatewayExpectedKey = "user-api-key" + } + mux := http.NewServeMux() mux.HandleFunc("/api/v1/admin/system/version", func(w http.ResponseWriter, r *http.Request) { if !mustStubAuth(t, w, r, cfg.requireAPIKey) { @@ -458,10 +563,7 @@ func newSub2APIStubServer(t *testing.T, cfg sub2APIStubConfig) *httptest.Server } writeJSON(t, w, http.StatusOK, map[string]any{ "data": map[string]any{ - "items": []map[string]any{ - {"id": "deepseek-chat", "display_name": "DeepSeek Chat", "type": "chat"}, - {"id": "deepseek-reasoner", "display_name": "DeepSeek Reasoner", "type": "reasoning"}, - }, + "items": accountModels, }, }) default: @@ -489,16 +591,13 @@ func newSub2APIStubServer(t *testing.T, cfg sub2APIStubConfig) *httptest.Server }) }) mux.HandleFunc("/v1/models", func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("x-api-key"); got != "user-api-key" { + if got := r.Header.Get("x-api-key"); got != gatewayExpectedKey { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) return } writeJSON(t, w, http.StatusOK, map[string]any{ - "data": []map[string]any{ - {"id": "deepseek-chat"}, - {"id": "deepseek-reasoner"}, - }, + "data": gatewayModels, }) })