diff --git a/.env.example b/.env.example index d14731a..95cc5c7 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ PORT=3000 NODE_ENV=development # AI Service Configuration -# AI provider selection: "openrouter" (default) or "ollama" +# AI provider selection: "openrouter" (default), "ollama", or "mock" (local/demo only) AI_PROVIDER=openrouter # OpenRouter Configuration (when AI_PROVIDER=openrouter) diff --git a/README.md b/README.md index f530c5b..9b292ee 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ flowchart TB Gateway["gateway/ Go Gin API :3000"] Verifier["verifier/ Rust Axum :3002"] Redis["Redis 7 receipt store by default\noptional response cache"] - AI["AI provider: OpenRouter or Ollama"] + AI["AI provider: OpenRouter, Ollama, or Mock"] Wallet["EVM wallet on configured chain\nBase Sepolia default"] Client --> Web @@ -107,7 +107,7 @@ flowchart TB Summarize["POST /api/ai/summarize"] PaymentContext["Create PaymentContext\nrecipient, token, amount, chainId, nonce, timestamp"] VerifyClient["Verifier HTTP client\nVERIFIER_URL"] - AIClient["AI provider client\nOpenRouter or Ollama"] + AIClient["AI provider client\nOpenRouter, Ollama, or Mock"] ReceiptSigner["Receipt signer\nserver wallet key"] ReceiptLookup["GET /api/receipts/{id}"] Health["/healthz and /readyz"] @@ -132,6 +132,7 @@ flowchart TB subgraph Providers["AI providers"] OpenRouter["OpenRouter chat completions"] Ollama["Local Ollama generate API"] + Mock["Mock offline provider"] end Browser --> Gin @@ -148,6 +149,7 @@ flowchart TB Summarize -->|verified cache miss| AIClient AIClient --> OpenRouter AIClient --> Ollama + AIClient --> Mock Summarize --> ReceiptSigner --> Receipts ReceiptSigner --> MemoryStore Cache -->|cached response receipt| ReceiptSigner @@ -316,7 +318,7 @@ Core local variables live in [.env.example](.env.example). Production placeholde | Variable | Service | Notes | | --- | --- | --- | -| `AI_PROVIDER` | Gateway | `openrouter` by default, `ollama` for local Ollama experiments. | +| `AI_PROVIDER` | Gateway | `openrouter` by default, `ollama` for local Ollama experiments, or `mock` for offline local/demo use. | | `OPENROUTER_API_KEY` | Gateway | Required when using OpenRouter. Never commit a real key. | | `OPENROUTER_MODEL` | Gateway | OpenRouter model name. Demo docs use `z-ai/glm-4.5-air:free` unless overridden. | | `OLLAMA_URL`, `OLLAMA_MODEL` | Gateway | Used only when `AI_PROVIDER=ollama`. | diff --git a/docker-compose.yml b/docker-compose.yml index dddd77c..c185ca5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,8 +16,13 @@ services: - METRICS_ENABLED=${METRICS_ENABLED:-true} - METRICS_PATH=${METRICS_PATH:-/metrics} # Pass through from .env file + - AI_PROVIDER + - OLLAMA_URL=${OLLAMA_URL:-http://host.docker.internal:11434} + - OLLAMA_MODEL - OPENROUTER_API_KEY - OPENROUTER_MODEL + - OPENROUTER_URL + - RUNNING_IN_DOCKER=true - SERVER_WALLET_PRIVATE_KEY - RECIPIENT_ADDRESS - PAYMENT_AMOUNT @@ -37,6 +42,8 @@ services: start_period: 10s networks: - microai-net + extra_hosts: + - "host.docker.internal:host-gateway" verifier: build: ./verifier diff --git a/gateway/README.md b/gateway/README.md index 47ad390..7ed0b01 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -70,7 +70,7 @@ The verifier route `POST /verify` is not a gateway route. It belongs to the inte | `ratelimit.go` | Token bucket implementation. | | `metrics.go` | Prometheus metric definitions and metrics endpoint config helpers. | | `middleware.go` | Request timeout, correlation ID, and request metrics middleware. | -| `internal/ai/` | OpenRouter and Ollama provider implementations. | +| `internal/ai/` | OpenRouter, Ollama, and Mock provider implementations. | | `openapi.yaml` | Public gateway API contract. | ## Configuration @@ -88,7 +88,7 @@ Common optional variables: | Variable | Default | Notes | | --- | --- | --- | | `PORT` | `3000` | Gateway listen port. | -| `AI_PROVIDER` | `openrouter` | Supported values: `openrouter`, `ollama`. | +| `AI_PROVIDER` | `openrouter` | Supported values: `openrouter`, `ollama`, `mock` (local/demo only). | | `OPENROUTER_MODEL` | `z-ai/glm-4.5-air:free` in code/docs unless overridden | OpenRouter model. | | `OPENROUTER_URL` | `https://openrouter.ai/api/v1/chat/completions` provider default | Used by tests and custom OpenRouter-compatible endpoints. | | `OLLAMA_URL` | `http://localhost:11434` | Used when `AI_PROVIDER=ollama`. | @@ -129,7 +129,7 @@ cd gateway RECEIPT_STORE=memory CACHE_ENABLED=false go run . ``` -The verifier must be reachable at `VERIFIER_URL` for signed requests. OpenRouter startup requires `OPENROUTER_API_KEY` unless `AI_PROVIDER=ollama`. +The verifier must be reachable at `VERIFIER_URL` for signed requests. OpenRouter startup requires `OPENROUTER_API_KEY` unless `AI_PROVIDER` is set to `ollama` or `mock`. ## Testing diff --git a/gateway/cache.go b/gateway/cache.go index 031d172..8bafb54 100644 --- a/gateway/cache.go +++ b/gateway/cache.go @@ -108,6 +108,8 @@ func CacheMiddleware() gin.HandlerFunc { if model == "" { model = "llama2" } + } else if os.Getenv("AI_PROVIDER") == "mock" { + model = "mock" } else if model == "" { model = "z-ai/glm-4.5-air:free" } diff --git a/gateway/config_test.go b/gateway/config_test.go index 0023a35..fcbbf87 100644 --- a/gateway/config_test.go +++ b/gateway/config_test.go @@ -474,3 +474,76 @@ func TestGetReceiptTTL(t *testing.T) { func stringPtr(value string) *string { return &value } + +func TestValidateConfig_MockProvider(t *testing.T) { + t.Run("allowed in dev environment", func(t *testing.T) { + t.Setenv("AI_PROVIDER", "mock") + t.Setenv("NODE_ENV", "development") + t.Setenv("APP_ENV", "") + t.Setenv("ALLOW_MOCK_PROVIDER", "") + t.Setenv("CACHE_ENABLED", "false") + t.Setenv("RECIPIENT_ADDRESS", "") + t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + t.Setenv("VERIFIER_URL", "http://127.0.0.1:3002") + t.Setenv("RECEIPT_STORE", "memory") + + err := validateConfig() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + + t.Run("allowed with ALLOW_MOCK_PROVIDER", func(t *testing.T) { + t.Setenv("AI_PROVIDER", "mock") + t.Setenv("NODE_ENV", "") + t.Setenv("APP_ENV", "") + t.Setenv("ALLOW_MOCK_PROVIDER", "true") + t.Setenv("CACHE_ENABLED", "false") + t.Setenv("RECIPIENT_ADDRESS", "") + t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + t.Setenv("VERIFIER_URL", "http://127.0.0.1:3002") + t.Setenv("RECEIPT_STORE", "memory") + + err := validateConfig() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + + t.Run("rejected in production", func(t *testing.T) { + t.Setenv("AI_PROVIDER", "mock") + t.Setenv("NODE_ENV", "production") + t.Setenv("APP_ENV", "") + t.Setenv("ALLOW_MOCK_PROVIDER", "true") + t.Setenv("CACHE_ENABLED", "false") + t.Setenv("RECIPIENT_ADDRESS", "") + t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + t.Setenv("VERIFIER_URL", "http://127.0.0.1:3002") + t.Setenv("RECEIPT_STORE", "memory") + + err := validateConfig() + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "disabled in production") { + t.Fatalf("expected error to mention disabled in production, got %v", err) + } + }) + + t.Run("rejected outside allowed env", func(t *testing.T) { + t.Setenv("AI_PROVIDER", "mock") + t.Setenv("NODE_ENV", "") + t.Setenv("APP_ENV", "") + t.Setenv("ALLOW_MOCK_PROVIDER", "") + t.Setenv("CACHE_ENABLED", "false") + t.Setenv("RECIPIENT_ADDRESS", "") + t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + t.Setenv("VERIFIER_URL", "http://127.0.0.1:3002") + t.Setenv("RECEIPT_STORE", "memory") + + err := validateConfig() + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} diff --git a/gateway/internal/ai/mock.go b/gateway/internal/ai/mock.go new file mode 100644 index 0000000..896b382 --- /dev/null +++ b/gateway/internal/ai/mock.go @@ -0,0 +1,18 @@ +package ai + +import "context" + +type MockProvider struct { + Name string +} + +func NewMockProvider() *MockProvider { + return &MockProvider{Name: "Mock"} +} + +func (m *MockProvider) Generate(ctx context.Context, text string) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return "This is a deterministic mock summary of the input text for local/demo testing.", nil +} diff --git a/gateway/internal/ai/ollama.go b/gateway/internal/ai/ollama.go index 57847cb..e1eb9b1 100644 --- a/gateway/internal/ai/ollama.go +++ b/gateway/internal/ai/ollama.go @@ -24,6 +24,13 @@ func NewOllamaProvider() *OllamaProvider { url = "http://localhost:11434" } + if os.Getenv("RUNNING_IN_DOCKER") == "true" { + if url == "http://localhost:11434" || url == "http://localhost:11434/" || + url == "http://127.0.0.1:11434" || url == "http://127.0.0.1:11434/" { + url = "http://host.docker.internal:11434" + } + } + model := os.Getenv("OLLAMA_MODEL") if model == "" { model = "llama2" diff --git a/gateway/internal/ai/provider.go b/gateway/internal/ai/provider.go index ec02797..e0c74e0 100644 --- a/gateway/internal/ai/provider.go +++ b/gateway/internal/ai/provider.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" ) // Provider defines the interface for AI service providers @@ -25,7 +26,37 @@ func NewProvider() (Provider, error) { return NewOpenRouterProvider(), nil case "ollama": return NewOllamaProvider(), nil + case "mock": + if err := IsMockAllowed(os.Getenv("NODE_ENV"), os.Getenv("APP_ENV"), os.Getenv("ALLOW_MOCK_PROVIDER")); err != nil { + return nil, err + } + return NewMockProvider(), nil default: return nil, fmt.Errorf("unsupported AI provider: %s", providerType) } } + +// IsMockAllowed evaluates NODE_ENV, APP_ENV, and ALLOW_MOCK_PROVIDER to see if the mock provider can be run. +// It normalizes checks by lowercasing and trimming spaces, evaluating production prefixes, +// and treating truthy values for ALLOW_MOCK_PROVIDER. +func IsMockAllowed(nodeEnv, appEnv, allowMock string) error { + nodeEnv = strings.ToLower(strings.TrimSpace(nodeEnv)) + appEnv = strings.ToLower(strings.TrimSpace(appEnv)) + allowMock = strings.ToLower(strings.TrimSpace(allowMock)) + + isProd := strings.HasPrefix(nodeEnv, "prod") || strings.HasPrefix(appEnv, "prod") + if isProd { + return fmt.Errorf("mock AI provider is disabled in production environments") + } + + isAllowedEnv := nodeEnv == "development" || nodeEnv == "local" || nodeEnv == "demo" || nodeEnv == "test" || nodeEnv == "dev" || + appEnv == "development" || appEnv == "local" || appEnv == "demo" || appEnv == "test" || appEnv == "dev" + + isExplicitlyAllowed := allowMock == "true" || allowMock == "1" || allowMock == "yes" + + if !isAllowedEnv && !isExplicitlyAllowed { + return fmt.Errorf("mock AI provider is disabled. Enable it by setting ALLOW_MOCK_PROVIDER=true, or set NODE_ENV/APP_ENV to development/local/demo/test") + } + + return nil +} diff --git a/gateway/internal/ai/provider_test.go b/gateway/internal/ai/provider_test.go index 26147d0..ebfdec1 100644 --- a/gateway/internal/ai/provider_test.go +++ b/gateway/internal/ai/provider_test.go @@ -1,6 +1,7 @@ package ai import ( + "context" "testing" ) @@ -10,6 +11,7 @@ func TestNewProvider(t *testing.T) { providerType string wantType string wantErr bool + setupEnv func(t *testing.T) }{ { name: "default to openrouter", @@ -29,6 +31,92 @@ func TestNewProvider(t *testing.T) { wantType: "*ai.OllamaProvider", wantErr: false, }, + { + name: "mock provider allowed via NODE_ENV=test", + providerType: "mock", + wantType: "*ai.MockProvider", + wantErr: false, + setupEnv: func(t *testing.T) { + t.Setenv("NODE_ENV", "test") + }, + }, + { + name: "mock provider allowed via ALLOW_MOCK_PROVIDER=true", + providerType: "mock", + wantType: "*ai.MockProvider", + wantErr: false, + setupEnv: func(t *testing.T) { + t.Setenv("ALLOW_MOCK_PROVIDER", "true") + }, + }, + { + name: "mock provider allowed via ALLOW_MOCK_PROVIDER=1", + providerType: "mock", + wantType: "*ai.MockProvider", + wantErr: false, + setupEnv: func(t *testing.T) { + t.Setenv("ALLOW_MOCK_PROVIDER", "1") + }, + }, + { + name: "mock provider allowed via ALLOW_MOCK_PROVIDER=yes", + providerType: "mock", + wantType: "*ai.MockProvider", + wantErr: false, + setupEnv: func(t *testing.T) { + t.Setenv("ALLOW_MOCK_PROVIDER", "yes") + }, + }, + { + name: "mock provider allowed via ALLOW_MOCK_PROVIDER=TRUE (case insensitive)", + providerType: "mock", + wantType: "*ai.MockProvider", + wantErr: false, + setupEnv: func(t *testing.T) { + t.Setenv("ALLOW_MOCK_PROVIDER", "TRUE") + }, + }, + { + name: "mock provider rejected in production", + providerType: "mock", + wantType: "", + wantErr: true, + setupEnv: func(t *testing.T) { + t.Setenv("NODE_ENV", "production") + t.Setenv("ALLOW_MOCK_PROVIDER", "true") + }, + }, + { + name: "mock provider rejected in production prefix (prod-east)", + providerType: "mock", + wantType: "", + wantErr: true, + setupEnv: func(t *testing.T) { + t.Setenv("NODE_ENV", "prod-east") + t.Setenv("ALLOW_MOCK_PROVIDER", "true") + }, + }, + { + name: "mock provider rejected in production prefix (PROD) (case insensitive)", + providerType: "mock", + wantType: "", + wantErr: true, + setupEnv: func(t *testing.T) { + t.Setenv("APP_ENV", "PROD") + t.Setenv("ALLOW_MOCK_PROVIDER", "true") + }, + }, + { + name: "mock provider rejected outside allowed environments", + providerType: "mock", + wantType: "", + wantErr: true, + setupEnv: func(t *testing.T) { + t.Setenv("NODE_ENV", "") + t.Setenv("APP_ENV", "") + t.Setenv("ALLOW_MOCK_PROVIDER", "") + }, + }, { name: "unsupported provider", providerType: "invalid", @@ -39,7 +127,14 @@ func TestNewProvider(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Setenv("NODE_ENV", "") + t.Setenv("APP_ENV", "") + t.Setenv("ALLOW_MOCK_PROVIDER", "") + t.Setenv("AI_PROVIDER", tt.providerType) + if tt.setupEnv != nil { + tt.setupEnv(t) + } provider, err := NewProvider() @@ -70,6 +165,10 @@ func TestNewProvider(t *testing.T) { if _, ok := provider.(*OllamaProvider); !ok { t.Errorf("NewProvider() returned %T, want *OllamaProvider", provider) } + case "*ai.MockProvider": + if _, ok := provider.(*MockProvider); !ok { + t.Errorf("NewProvider() returned %T, want *MockProvider", provider) + } } }) } @@ -135,3 +234,17 @@ func TestNewOllamaProvider_Defaults(t *testing.T) { t.Errorf("expected default model 'llama2', got '%s'", provider.model) } } + +func TestMockProvider(t *testing.T) { + provider := NewMockProvider() + ctx := context.Background() + resp, err := provider.Generate(ctx, "hello world") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + expected := "This is a deterministic mock summary of the input text for local/demo testing." + if resp != expected { + t.Errorf("expected '%s', got '%s'", expected, resp) + } +} diff --git a/gateway/main.go b/gateway/main.go index 41be12d..eb46de0 100644 --- a/gateway/main.go +++ b/gateway/main.go @@ -74,6 +74,10 @@ func validateConfig() error { providerType := os.Getenv("AI_PROVIDER") if providerType == "" || providerType == "openrouter" { required = append(required, "OPENROUTER_API_KEY") + } else if providerType == "mock" { + if err := ai.IsMockAllowed(os.Getenv("NODE_ENV"), os.Getenv("APP_ENV"), os.Getenv("ALLOW_MOCK_PROVIDER")); err != nil { + return err + } } if err := validateReceiptStoreMode(); err != nil { @@ -1040,6 +1044,9 @@ func handleReadyz(c *gin.Context) { case "ollama": aiStatus = checkOllamaHealth() checks["ollama"] = aiStatus + case "mock": + aiStatus = "ok" + checks["mock"] = aiStatus } checks["ai_provider"] = gin.H{ "provider": providerType, @@ -1127,6 +1134,12 @@ var checkOllamaHealth = func() string { if ollamaURL == "" { ollamaURL = "http://localhost:11434" } + if os.Getenv("RUNNING_IN_DOCKER") == "true" { + if ollamaURL == "http://localhost:11434" || ollamaURL == "http://localhost:11434/" || + ollamaURL == "http://127.0.0.1:11434" || ollamaURL == "http://127.0.0.1:11434/" { + ollamaURL = "http://host.docker.internal:11434" + } + } healthURL := strings.TrimSuffix(ollamaURL, "/") + "/api/tags" ctx, cancel := context.WithTimeout(context.Background(), getHealthCheckTimeout()) diff --git a/gateway/main_test.go b/gateway/main_test.go index 56933d7..5dde0b1 100644 --- a/gateway/main_test.go +++ b/gateway/main_test.go @@ -3,11 +3,13 @@ package main import ( "bytes" "encoding/json" + "gateway/internal/ai" "net/http" "net/http/httptest" "os" "strconv" "strings" + "sync" "testing" "github.com/gin-gonic/gin" @@ -616,3 +618,205 @@ func TestHandleReadyz_RedisUnreachable(t *testing.T) { checks := response["checks"].(map[string]interface{}) require.Equal(t, "unreachable", checks["redis"]) } + +func TestHandleReadyz_MockProvider(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("AI_PROVIDER", "mock") + t.Setenv("NODE_ENV", "test") + t.Setenv("RECEIPT_STORE", "memory") + t.Setenv("CACHE_ENABLED", "false") + + origVerifier := checkVerifierHealth + defer func() { + checkVerifierHealth = origVerifier + }() + + checkVerifierHealth = func() string { return "ok" } + + r := gin.Default() + r.GET("/readyz", handleReadyz) + + req, _ := http.NewRequest(http.MethodGet, "/readyz", nil) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + require.Equal(t, true, response["ready"]) + + checks := response["checks"].(map[string]interface{}) + require.Equal(t, "ok", checks["verifier"]) + require.Equal(t, "ok", checks["mock"]) + + aiProviderCheck := checks["ai_provider"].(map[string]interface{}) + require.Equal(t, "mock", aiProviderCheck["provider"]) + require.Equal(t, "ok", aiProviderCheck["status"]) +} + +func TestHandleSummarize_MockProvider(t *testing.T) { + // Expected test values + expectedRecipient := "0x2cAF48b4BA1C58721a85dFADa5aC01C2DFa62219" + expectedAmount := "0.001" + expectedToken := "USDC" + expectedChainID := 84532 + expectedSignature := "test-signature" + + var expectedNonce string + var expectedTimestamp uint64 + + // Set up a verifier that asserts the parsed context parameters match exactly + verifier := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req VerifyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"is_valid":false,"error":"failed to decode verify request"}`)) + return + } + + if req.Signature != expectedSignature { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"is_valid":false,"error":"signature mismatch"}`)) + return + } + if req.Context.Nonce != expectedNonce { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"is_valid":false,"error":"nonce mismatch"}`)) + return + } + if req.Context.Timestamp != expectedTimestamp { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"is_valid":false,"error":"timestamp mismatch"}`)) + return + } + if req.Context.Recipient != expectedRecipient { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"is_valid":false,"error":"recipient mismatch"}`)) + return + } + if req.Context.Amount != expectedAmount { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"is_valid":false,"error":"amount mismatch"}`)) + return + } + if req.Context.Token != expectedToken { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"is_valid":false,"error":"token mismatch"}`)) + return + } + if req.Context.ChainID != expectedChainID { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"is_valid":false,"error":"chain_id mismatch"}`)) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"is_valid":true, "recovered_address":"0xabc","error":""}`)) + })) + defer verifier.Close() + + // Environment + t.Setenv("AI_PROVIDER", "mock") + t.Setenv("NODE_ENV", "test") + t.Setenv("VERIFIER_URL", verifier.URL) + t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + t.Setenv("RECEIPT_STORE", "memory") + t.Setenv("CACHE_ENABLED", "false") + + serverPrivateKeyTestMu.Lock() + receiptGlobalsTestMu.Lock() + + // Save package-level globals + origAIProvider := aiProvider + origReceiptStore := getActiveReceiptStore() + origKey := serverPrivateKey + origKeyErr := serverPrivateKeyErr + + defer func() { + aiProvider = origAIProvider + setActiveReceiptStore(origReceiptStore) + serverPrivateKey = origKey + serverPrivateKeyErr = origKeyErr + serverPrivateKeyOnce = sync.Once{} + + receiptGlobalsTestMu.Unlock() + serverPrivateKeyTestMu.Unlock() + }() + + // Reset private key once/error states for this test + serverPrivateKey = nil + serverPrivateKeyErr = nil + serverPrivateKeyOnce = sync.Once{} + + // Initialize AI provider for this test + var err error + aiProvider, err = ai.NewProvider() + require.NoError(t, err) + + // Initialize receipt store for this test + err = initReceiptStore() + require.NoError(t, err) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.POST("/api/ai/summarize", handleSummarize) + + // Step 1: Send unsigned request to trigger 402 challenge + reqBody1 := strings.NewReader(`{"text":"hello world text to summarize"}`) + req1, _ := http.NewRequest("POST", "/api/ai/summarize", reqBody1) + req1.Header.Set("Content-Type", "application/json") + w1 := httptest.NewRecorder() + r.ServeHTTP(w1, req1) + + require.Equal(t, http.StatusPaymentRequired, w1.Code) + + var challengeResp map[string]interface{} + err = json.Unmarshal(w1.Body.Bytes(), &challengeResp) + require.NoError(t, err) + + paymentCtx, ok := challengeResp["paymentContext"].(map[string]interface{}) + require.True(t, ok) + + expectedNonce, ok = paymentCtx["nonce"].(string) + require.True(t, ok) + + timestampFloat, ok := paymentCtx["timestamp"].(float64) + require.True(t, ok) + expectedTimestamp = uint64(timestampFloat) + + // Verify returned payment context fields match expectations + require.Equal(t, expectedRecipient, paymentCtx["recipient"]) + require.Equal(t, expectedAmount, paymentCtx["amount"]) + require.Equal(t, expectedToken, paymentCtx["token"]) + require.Equal(t, float64(expectedChainID), paymentCtx["chainId"]) + + // Step 2: Send signed retry using context values + reqBody2 := strings.NewReader(`{"text":"hello world text to summarize"}`) + req2, _ := http.NewRequest("POST", "/api/ai/summarize", reqBody2) + req2.Header.Set("X-402-Signature", expectedSignature) + req2.Header.Set("X-402-Nonce", expectedNonce) + req2.Header.Set("X-402-Timestamp", strconv.FormatUint(expectedTimestamp, 10)) + req2.Header.Set("Content-Type", "application/json") + + w2 := httptest.NewRecorder() + r.ServeHTTP(w2, req2) + + require.Equal(t, http.StatusOK, w2.Code) + + var response2 map[string]interface{} + err = json.Unmarshal(w2.Body.Bytes(), &response2) + require.NoError(t, err) + + // Verify deterministic response + expected := "This is a deterministic mock summary of the input text for local/demo testing." + require.Equal(t, expected, response2["result"]) + + // Check that X-402-Receipt header is set + receiptHeader := w2.Header().Get("X-402-Receipt") + require.NotEmpty(t, receiptHeader) +} +