diff --git a/internal/app/route_proxy_api.go b/internal/app/route_proxy_api.go index b1607c5d..d6fa9c6b 100644 --- a/internal/app/route_proxy_api.go +++ b/internal/app/route_proxy_api.go @@ -3,14 +3,21 @@ package app import ( "bytes" "context" + "crypto/sha256" + "database/sql" + "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" "net/url" + "strconv" "strings" "time" + "sub2api-cn-relay-manager/internal/access" + "sub2api-cn-relay-manager/internal/host/sub2api" "sub2api-cn-relay-manager/internal/routing" "sub2api-cn-relay-manager/internal/store/sqlite" ) @@ -18,18 +25,19 @@ import ( const routeChatCompletionsPath = "/v1/chat/completions" type ProxyRouteChatCompletionsRequest struct { - RequestID string `json:"request_id,omitempty"` - LogicalGroupID string `json:"logical_group_id"` - PublicModel string `json:"public_model"` - Scope string `json:"scope"` - SubjectID string `json:"subject_id"` - UserKey string `json:"user_key,omitempty"` - ConversationKey string `json:"conversation_key,omitempty"` - GatewayAPIKey string `json:"gateway_api_key"` - Messages []ChatCompletionMessage `json:"messages,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - Sync bool `json:"sync,omitempty"` + RequestID string `json:"request_id,omitempty"` + LogicalGroupID string `json:"logical_group_id"` + PublicModel string `json:"public_model"` + Scope string `json:"scope"` + SubjectID string `json:"subject_id"` + UserKey string `json:"user_key,omitempty"` + ConversationKey string `json:"conversation_key,omitempty"` + GatewayAPIKey string `json:"gateway_api_key"` + SubscriptionUserID string `json:"subscription_user_id,omitempty"` + Messages []ChatCompletionMessage `json:"messages,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + Sync bool `json:"sync,omitempty"` } type ChatCompletionMessage struct { @@ -43,18 +51,21 @@ type ProxyRouteChatCompletionsResult struct { } type RouteChatCompletionsForwardInfo struct { - OK bool `json:"ok"` - HostID string `json:"host_id"` - HostBaseURL string `json:"host_base_url"` - ShadowGroupID string `json:"shadow_group_id"` - ShadowModel string `json:"shadow_model"` - UpstreamPath string `json:"upstream_path"` - UpstreamStatus int `json:"upstream_status"` - LatencyMS int64 `json:"latency_ms"` - ContentType string `json:"content_type,omitempty"` - ErrorClass string `json:"error_class,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` - Response any `json:"response,omitempty"` + OK bool `json:"ok"` + HostID string `json:"host_id"` + HostBaseURL string `json:"host_base_url"` + ShadowGroupID string `json:"shadow_group_id"` + ShadowModel string `json:"shadow_model"` + EffectiveGatewayKeySource string `json:"effective_gateway_key_source,omitempty"` + EffectiveGatewayKeyFingerprint string `json:"effective_gateway_key_fingerprint,omitempty"` + ManagedUserID string `json:"managed_user_id,omitempty"` + UpstreamPath string `json:"upstream_path"` + UpstreamStatus int `json:"upstream_status"` + LatencyMS int64 `json:"latency_ms"` + ContentType string `json:"content_type,omitempty"` + ErrorClass string `json:"error_class,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + Response any `json:"response,omitempty"` } func handleProxyRouteChatCompletions(w http.ResponseWriter, r *http.Request, fn func(context.Context, ProxyRouteChatCompletionsRequest) (ProxyRouteChatCompletionsResult, error)) { @@ -82,8 +93,9 @@ func buildProxyRouteChatCompletionsAction( ) func(context.Context, ProxyRouteChatCompletionsRequest) (ProxyRouteChatCompletionsResult, error) { return func(ctx context.Context, req ProxyRouteChatCompletionsRequest) (ProxyRouteChatCompletionsResult, error) { req.GatewayAPIKey = strings.TrimSpace(req.GatewayAPIKey) - if req.GatewayAPIKey == "" { - return ProxyRouteChatCompletionsResult{}, fmt.Errorf("gateway_api_key is required") + req.SubscriptionUserID = strings.TrimSpace(req.SubscriptionUserID) + if req.GatewayAPIKey == "" && req.SubscriptionUserID == "" { + return ProxyRouteChatCompletionsResult{}, fmt.Errorf("gateway_api_key or subscription_user_id is required") } resolveInfo, err := resolveRoute(ctx, ResolveRouteRequest{ @@ -110,17 +122,29 @@ func buildProxyRouteChatCompletionsAction( if err != nil { return ProxyRouteChatCompletionsResult{}, fmt.Errorf("get shadow host %q: %w", resolveInfo.ShadowHostID, err) } + hostClient, err := newSub2APIClient(hostRow.BaseURL, authFromStoredHost(hostRow)) + if err != nil { + return ProxyRouteChatCompletionsResult{}, err + } shadowModel := strings.TrimSpace(resolveInfo.ShadowModel) if shadowModel == "" { shadowModel = strings.TrimSpace(resolveInfo.PublicModel) } - forward := proxyChatCompletionToShadowHost(ctx, hostRow.BaseURL, req.GatewayAPIKey, shadowModel, req.Messages, req.MaxTokens, req.Temperature) + gatewayAPIKey, gatewayKeySource, managedUserID, err := resolveProxyGatewayAPIKey(ctx, store, hostRow, hostClient, resolveInfo, req) + if err != nil { + return ProxyRouteChatCompletionsResult{}, err + } + + forward := proxyChatCompletionToShadowHost(ctx, hostRow.BaseURL, gatewayAPIKey, shadowModel, req.Messages, req.MaxTokens, req.Temperature) forward.HostID = strings.TrimSpace(hostRow.HostID) forward.HostBaseURL = strings.TrimSpace(hostRow.BaseURL) forward.ShadowGroupID = strings.TrimSpace(resolveInfo.ShadowGroupID) forward.ShadowModel = shadowModel + forward.EffectiveGatewayKeySource = gatewayKeySource + forward.EffectiveGatewayKeyFingerprint = fingerprintRouteProxySecret(gatewayAPIKey) + forward.ManagedUserID = managedUserID if err := appendProxyRouteDecisionLog(ctx, writerSource, req, resolveInfo, forward); err != nil { return ProxyRouteChatCompletionsResult{}, err @@ -317,6 +341,78 @@ func classifyProxyUpstreamStatus(statusCode int) string { } } +func resolveProxyGatewayAPIKey( + ctx context.Context, + store *sqlite.DB, + hostRow sqlite.Host, + hostClient *sub2api.Client, + resolveInfo ResolveRouteInfo, + req ProxyRouteChatCompletionsRequest, +) (string, string, string, error) { + gatewayAPIKey := strings.TrimSpace(req.GatewayAPIKey) + if gatewayAPIKey != "" { + return gatewayAPIKey, access.ProbeKeySourceRequestedProbeAPIKey, "", nil + } + + subscriptionUserID := strings.TrimSpace(req.SubscriptionUserID) + if subscriptionUserID == "" { + return "", "", "", fmt.Errorf("gateway_api_key or subscription_user_id is required") + } + + shadowGroupHostResourceID, err := resolveShadowGroupHostResourceID(ctx, store, hostRow, hostClient, strings.TrimSpace(resolveInfo.ShadowGroupID)) + if err != nil { + return "", "", "", err + } + accessRef, err := hostClient.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{ + UserSelector: subscriptionUserID, + GroupID: shadowGroupHostResourceID, + }) + if err != nil { + return "", "", "", fmt.Errorf("ensure subscription access for route %q: %w", resolveInfo.RouteID, err) + } + gatewayAPIKey = strings.TrimSpace(accessRef.APIKey) + if gatewayAPIKey == "" { + return "", "", "", fmt.Errorf("managed subscription access api key is empty") + } + return gatewayAPIKey, access.ProbeKeySourceManagedSubscription, strings.TrimSpace(accessRef.UserID), nil +} + +func resolveShadowGroupHostResourceID( + ctx context.Context, + store *sqlite.DB, + hostRow sqlite.Host, + hostClient *sub2api.Client, + shadowGroupID string, +) (string, error) { + shadowGroupID = strings.TrimSpace(shadowGroupID) + if shadowGroupID == "" { + return "", fmt.Errorf("shadow_group_id is required") + } + if _, err := strconv.ParseInt(shadowGroupID, 10, 64); err == nil { + return shadowGroupID, nil + } + + resource, err := store.ManagedResources().GetByResourceIdentity(ctx, hostRow.ID, "group", shadowGroupID) + if err == nil { + return strings.TrimSpace(resource.HostResourceID), nil + } + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return "", fmt.Errorf("lookup shadow group %q in managed resources: %w", shadowGroupID, err) + } + + snapshot, err := hostClient.ListManagedResources(ctx, sub2api.ListManagedResourcesRequest{GroupName: shadowGroupID}) + if err != nil { + return "", fmt.Errorf("list host groups for %q: %w", shadowGroupID, err) + } + if len(snapshot.Groups) == 1 { + return strings.TrimSpace(snapshot.Groups[0].ID), nil + } + if len(snapshot.Groups) > 1 { + return "", fmt.Errorf("multiple host groups matched shadow_group_id %q", shadowGroupID) + } + return "", fmt.Errorf("shadow group %q not found on host %q", shadowGroupID, hostRow.HostID) +} + func resolveProxyUserKey(req ProxyRouteChatCompletionsRequest) string { if key := strings.TrimSpace(req.UserKey); key != "" { return key @@ -327,6 +423,15 @@ func resolveProxyUserKey(req ProxyRouteChatCompletionsRequest) string { return "" } +func fingerprintRouteProxySecret(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + sum := sha256.Sum256([]byte(trimmed)) + return "sha256:" + hex.EncodeToString(sum[:]) +} + func resolveProxyConversationKey(req ProxyRouteChatCompletionsRequest) string { if key := strings.TrimSpace(req.ConversationKey); key != "" { return key diff --git a/internal/app/route_proxy_api_test.go b/internal/app/route_proxy_api_test.go index d369496d..27eb313f 100644 --- a/internal/app/route_proxy_api_test.go +++ b/internal/app/route_proxy_api_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "strings" "testing" "sub2api-cn-relay-manager/internal/store/sqlite" @@ -224,6 +225,188 @@ func TestNewActionSetProxyRouteChatCompletionsFlow(t *testing.T) { } } +func TestNewActionSetProxyRouteChatCompletionsManagedSubscriptionFlow(t *testing.T) { + t.Parallel() + + var ( + gotAuthHeader string + gotModel string + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.RequestURI(), "/api/v1/admin/users?"): + _, _ = w.Write([]byte(`{"data":{"items":[]}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/users": + _, _ = w.Write([]byte(`{"data":{"id":84,"email":"relay-sub-managed-user@sub2api.local"}}`)) + case r.Method == http.MethodPut && r.URL.Path == "/api/v1/admin/users/84": + _, _ = w.Write([]byte(`{"data":{"id":84}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/users/84/balance": + _, _ = w.Write([]byte(`{"data":{"id":84}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/subscriptions/assign": + _, _ = w.Write([]byte(`{"data":{"id":401}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/auth/login": + _, _ = w.Write([]byte(`{"data":{"access_token":"user-jwt"}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/keys": + _, _ = w.Write([]byte(`{"data":{"id":501,"key":"sk-relay-key","name":"managed-key"}}`)) + case r.Method == http.MethodPut && r.URL.Path == "/api/v1/admin/api-keys/501": + _, _ = w.Write([]byte(`{"data":{"api_key":{"id":501}}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/v1/chat/completions": + gotAuthHeader = r.Header.Get("Authorization") + var payload struct { + Model string `json:"model"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("json.Decode() error = %v", err) + } + gotModel = payload.Model + writeJSON(w, http.StatusOK, map[string]any{ + "id": "chatcmpl_proxy_managed", + "choices": []map[string]any{ + { + "message": map[string]any{ + "role": "assistant", + "content": "pong-managed", + }, + }, + }, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + dsn := "file:" + filepath.ToSlash(filepath.Join(t.TempDir(), "route-proxy-managed.db")) + "?_busy_timeout=5000" + actions := NewActionSet(dsn) + ctx := context.Background() + + store, err := sqlite.Open(ctx, dsn) + if err != nil { + t.Fatalf("sqlite.Open() error = %v", err) + } + defer store.Close() + + hostID, err := store.Hosts().Create(ctx, sqlite.Host{ + HostID: "remote43-managed", + BaseURL: server.URL, + HostVersion: "0.1.126", + AuthType: "bearer", + AuthToken: "host-admin-token", + }) + if err != nil { + t.Fatalf("Hosts().Create() error = %v", err) + } + packID, err := store.Packs().Create(ctx, sqlite.Pack{ + PackID: "managed-pack", + Version: "1.0.0", + Checksum: "sha256-managed-pack", + Vendor: "tksea", + ManifestJSON: "{}", + }) + if err != nil { + t.Fatalf("Packs().Create() error = %v", err) + } + providerID, err := store.Providers().Create(ctx, sqlite.Provider{ + PackID: packID, + ProviderID: "managed-provider", + DisplayName: "Managed Provider", + BaseURL: "https://api.asxs.top/v1", + Platform: "openai", + }) + if err != nil { + t.Fatalf("Providers().Create() error = %v", err) + } + batchID, err := store.ImportBatches().Create(ctx, sqlite.ImportBatch{ + HostID: hostID, + PackID: packID, + ProviderID: providerID, + Mode: "strict", + BatchStatus: "succeeded", + AccessStatus: "subscription_ready", + }) + if err != nil { + t.Fatalf("ImportBatches().Create() error = %v", err) + } + if _, err := actions.CreateLogicalGroup(ctx, CreateLogicalGroupRequest{ + LogicalGroupID: "gpt-shared-managed", + DisplayName: "GPT Shared Managed", + Status: "active", + RoutePolicy: "priority", + StickyMode: "conversation_preferred", + ConversationTTLSeconds: 1200, + UserModelTTLSeconds: 600, + FailoverThreshold: 2, + CooldownSeconds: 300, + }); err != nil { + t.Fatalf("CreateLogicalGroup() error = %v", err) + } + if _, err := actions.CreateLogicalGroupModel(ctx, CreateLogicalGroupModelRequest{ + LogicalGroupID: "gpt-shared-managed", + PublicModel: "gpt-5.4", + Status: "active", + }); err != nil { + t.Fatalf("CreateLogicalGroupModel() error = %v", err) + } + if _, err := actions.CreateLogicalGroupRoute(ctx, CreateLogicalGroupRouteRequest{ + LogicalGroupID: "gpt-shared-managed", + RouteID: "asxs-managed", + Name: "ASXS Managed", + Status: "active", + Priority: 10, + ShadowGroupID: "101", + ShadowHostID: "remote43-managed", + UpstreamBaseURLHint: "https://api.asxs.top/v1", + }); err != nil { + t.Fatalf("CreateLogicalGroupRoute() error = %v", err) + } + if _, err := actions.CreateLogicalGroupRouteModel(ctx, CreateLogicalGroupRouteModelRequest{ + LogicalGroupID: "gpt-shared-managed", + RouteID: "asxs-managed", + PublicModel: "gpt-5.4", + ShadowModel: "gpt-5.4-asxs", + Status: "active", + }); err != nil { + t.Fatalf("CreateLogicalGroupRouteModel() error = %v", err) + } + if _, err := store.ManagedResources().Create(ctx, sqlite.ManagedResource{ + BatchID: batchID, + HostID: hostID, + ResourceType: "group", + HostResourceID: "101", + ResourceName: "shadow-group-asxs", + }); err != nil { + t.Fatalf("ManagedResources().Create() error = %v", err) + } + + result, err := actions.ProxyRouteChatCompletions(ctx, ProxyRouteChatCompletionsRequest{ + RequestID: "req-proxy-managed-1", + LogicalGroupID: "gpt-shared-managed", + PublicModel: "gpt-5.4", + Scope: "conversation", + SubjectID: "conv-managed-1", + SubscriptionUserID: "crm-user-1", + Sync: true, + }) + if err != nil { + t.Fatalf("ProxyRouteChatCompletions() error = %v", err) + } + if !strings.HasPrefix(gotAuthHeader, "Bearer sk-relay-") { + t.Fatalf("Authorization header = %q, want Bearer sk-relay-*", gotAuthHeader) + } + if gotModel != "gpt-5.4-asxs" { + t.Fatalf("forwarded model = %q, want gpt-5.4-asxs", gotModel) + } + if result.Forward.EffectiveGatewayKeySource != "managed_subscription" { + t.Fatalf("EffectiveGatewayKeySource = %q, want managed_subscription", result.Forward.EffectiveGatewayKeySource) + } + if result.Forward.EffectiveGatewayKeyFingerprint == "" { + t.Fatal("EffectiveGatewayKeyFingerprint = empty, want hashed managed key fingerprint") + } + if result.Forward.ManagedUserID != "84" { + t.Fatalf("ManagedUserID = %q, want 84", result.Forward.ManagedUserID) + } +} + func TestProxyChatCompletionToShadowHostReportsNon2xx(t *testing.T) { t.Parallel() @@ -274,3 +457,154 @@ func TestRouteProxyHelpers(t *testing.T) { t.Fatalf("resolveProxyConversationKey(conversation) = %q, want conv-1", got) } } + +func TestResolveShadowGroupHostResourceID(t *testing.T) { + t.Parallel() + + dsn := "file:" + filepath.ToSlash(filepath.Join(t.TempDir(), "route-proxy-helper.db")) + "?_busy_timeout=5000" + ctx := context.Background() + store, err := sqlite.Open(ctx, dsn) + if err != nil { + t.Fatalf("sqlite.Open() error = %v", err) + } + defer store.Close() + + hostID, err := store.Hosts().Create(ctx, sqlite.Host{ + HostID: "helper-host", + BaseURL: "https://helper.example.com", + HostVersion: "0.1.126", + AuthType: "bearer", + AuthToken: "host-token", + }) + if err != nil { + t.Fatalf("Hosts().Create() error = %v", err) + } + hostRow, err := store.Hosts().GetByID(ctx, hostID) + if err != nil { + t.Fatalf("Hosts().GetByID() error = %v", err) + } + + if got, err := resolveShadowGroupHostResourceID(ctx, store, hostRow, nil, "101"); err != nil || got != "101" { + t.Fatalf("resolveShadowGroupHostResourceID(numeric) = (%q, %v), want 101", got, err) + } + + packID, err := store.Packs().Create(ctx, sqlite.Pack{ + PackID: "helper-pack", + Version: "1.0.0", + Checksum: "sha256-helper", + Vendor: "tksea", + ManifestJSON: "{}", + }) + if err != nil { + t.Fatalf("Packs().Create() error = %v", err) + } + providerID, err := store.Providers().Create(ctx, sqlite.Provider{ + PackID: packID, + ProviderID: "helper-provider", + DisplayName: "Helper Provider", + BaseURL: "https://helper.example.com/v1", + Platform: "openai", + }) + if err != nil { + t.Fatalf("Providers().Create() error = %v", err) + } + batchID, err := store.ImportBatches().Create(ctx, sqlite.ImportBatch{ + HostID: hostID, + PackID: packID, + ProviderID: providerID, + Mode: "strict", + BatchStatus: "succeeded", + AccessStatus: "subscription_ready", + }) + if err != nil { + t.Fatalf("ImportBatches().Create() error = %v", err) + } + if _, err := store.ManagedResources().Create(ctx, sqlite.ManagedResource{ + BatchID: batchID, + HostID: hostID, + ResourceType: "group", + HostResourceID: "202", + ResourceName: "shadow-group-name", + }); err != nil { + t.Fatalf("ManagedResources().Create() error = %v", err) + } + if got, err := resolveShadowGroupHostResourceID(ctx, store, hostRow, nil, "202"); err != nil || got != "202" { + t.Fatalf("resolveShadowGroupHostResourceID(store identity) = (%q, %v), want 202", got, err) + } +} + +func TestResolveShadowGroupHostResourceIDFallsBackToHostList(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.RequestURI(), "/api/v1/admin/groups"): + _, _ = w.Write([]byte(`{"data":[{"id":"303","name":"shadow-group-remote"}]}`)) + case strings.HasPrefix(r.URL.RequestURI(), "/api/v1/admin/channels"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case strings.HasPrefix(r.URL.RequestURI(), "/api/v1/admin/payment/plans"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case strings.HasPrefix(r.URL.RequestURI(), "/api/v1/admin/accounts"): + _, _ = w.Write([]byte(`{"data":{"items":[],"pages":1}}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + dsn := "file:" + filepath.ToSlash(filepath.Join(t.TempDir(), "route-proxy-fallback.db")) + "?_busy_timeout=5000" + ctx := context.Background() + store, err := sqlite.Open(ctx, dsn) + if err != nil { + t.Fatalf("sqlite.Open() error = %v", err) + } + defer store.Close() + + hostID, err := store.Hosts().Create(ctx, sqlite.Host{ + HostID: "fallback-host", + BaseURL: server.URL, + HostVersion: "0.1.126", + AuthType: "bearer", + AuthToken: "host-token", + }) + if err != nil { + t.Fatalf("Hosts().Create() error = %v", err) + } + hostRow, err := store.Hosts().GetByID(ctx, hostID) + if err != nil { + t.Fatalf("Hosts().GetByID() error = %v", err) + } + hostClient, err := newSub2APIClient(server.URL, CreateHostAuth{Type: "bearer", Token: "host-token"}) + if err != nil { + t.Fatalf("newSub2APIClient() error = %v", err) + } + + got, err := resolveShadowGroupHostResourceID(ctx, store, hostRow, hostClient, "shadow-group-remote") + if err != nil { + t.Fatalf("resolveShadowGroupHostResourceID(host fallback) error = %v", err) + } + if got != "303" { + t.Fatalf("resolveShadowGroupHostResourceID(host fallback) = %q, want 303", got) + } +} + +func TestAPIProxyRouteChatCompletionsRejectsMissingGatewayAndSubscriptionUser(t *testing.T) { + t.Parallel() + + handler := NewAPIHandler("secret-token", ActionSet{ + ProxyRouteChatCompletions: buildProxyRouteChatCompletionsAction("file::memory:?cache=shared", func(context.Context, ResolveRouteRequest) (ResolveRouteInfo, error) { + t.Fatal("ResolveRoute should not be called when auth inputs are missing") + return ResolveRouteInfo{}, nil + }, newLazyRouteLogWriter("file::memory:?cache=shared")), + }) + + request := httptestRequest(t, http.MethodPost, "/api/routing/proxy/chat/completions", map[string]any{ + "logical_group_id": "gpt-shared", + "public_model": "gpt-5.4", + "scope": "conversation", + "subject_id": "conv-1", + }, "secret-token") + response := httptestRecorder(handler, request) + assertStatusCode(t, response, http.StatusBadRequest) + assertJSONContains(t, response.Body().Bytes(), "error.message", "gateway_api_key or subscription_user_id is required") +}