Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions gateway/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -164,6 +165,25 @@ func CacheMiddleware() gin.HandlerFunc {
c.Abort()
return
}

// === Fix for #241: Prevent replay attacks on cache hits ===
// Normalize to lowercase to prevent hex-casing bypass attacks.
used, err := markTransactionUsed(c.Request.Context(), strings.ToLower(signature))
if err != nil {
respondError(c, 500, "internal_error", fmt.Errorf("failed to check transaction replay: %w", err))
c.Abort()
return
}
if used {
c.JSON(http.StatusConflict, gin.H{
"error": "nonce_already_used",
"message": "Transaction already used",
})
c.Abort()
return
}
// ==========================================================

verificationTotal.WithLabelValues("success").Inc()
// Payment Verified. Store verification for downstream if needed (though we abort)
c.Set("payment_verification", verifyResp)
Expand All @@ -174,9 +194,9 @@ func CacheMiddleware() gin.HandlerFunc {
// Generate receipt for cache hit using current request and cached result.
// Note: request_hash matches current request, response is from cache,
// but both are cryptographically valid since cache key ensures identical text.
if err := generateAndSendReceipt(c, *paymentCtx, verifyResp.RecoveredAddress, requestBody, cached.Result); err != nil {
if err := streamResultAndReceipt(c, *paymentCtx, verifyResp.RecoveredAddress, requestBody, cached.Result); err != nil {
log.Printf("Failed to send cached response receipt: %v", err)
// generateAndSendReceipt already sent an error response (500)
// streamResultAndReceipt already sent an error response
}
c.Abort()
return
Expand Down Expand Up @@ -204,14 +224,13 @@ func CacheMiddleware() gin.HandlerFunc {
// Handler finished. Check status and extract result with proper locking
writer.mu.RLock()
statusCode := writer.ResponseWriter.Status()
bodyBytes := writer.body.Bytes()
writer.mu.RUnlock()

if statusCode == 200 {
// Response format: {"result": "...", "receipt": ...}
var resp map[string]interface{}
if err := json.Unmarshal(bodyBytes, &resp); err == nil {
if result, ok := resp["result"].(string); ok {
// Instead of parsing the SSE stream from bodyBytes, we retrieve the full summary
// which handleSummarize saves in the gin context.
if fullSummaryVal, exists := c.Get("full_summary"); exists {
if result, ok := fullSummaryVal.(string); ok {
// Store asynchronously with a deadline to prevent indefinite goroutines
go func(k, v string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
Expand Down
45 changes: 32 additions & 13 deletions gateway/cache_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"strings"
"gateway/internal/ai"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -36,7 +37,9 @@ func TestCacheIntegration_FullFlow(t *testing.T) {
return
}

isValid := req.Signature == "0xValidSig"
// Accept two distinct valid signatures so the cache-hit request
// uses a fresh signature that isn't blocked by the replay guard.
isValid := req.Signature == "0xValidSig" || req.Signature == "0xValidSig2"
resp := VerifyResponse{
IsValid: isValid,
RecoveredAddress: "0xTestUser",
Expand Down Expand Up @@ -157,9 +160,10 @@ func TestCacheIntegration_FullFlow(t *testing.T) {
}
assertCachePopulated()

// Request 2: Cache Hit (Valid Sig)
// Request 2: Cache Hit — must use a DIFFERENT valid signature so the
// gateway-side replay guard (markTransactionUsed) doesn't block it.
start = time.Now()
w2 := makeRequest("0xValidSig")
w2 := makeRequest("0xValidSig2")
duration2 := time.Since(start)

if w2.Code != 200 {
Expand Down Expand Up @@ -198,18 +202,33 @@ func TestCacheIntegration_FullFlow(t *testing.T) {
}

// Verify Body
var resp1, resp2 map[string]interface{}
if err := json.Unmarshal(w1.Body.Bytes(), &resp1); err != nil {
t.Fatalf("Failed to unmarshal response 1: %v", err)
}
if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil {
t.Fatalf("Failed to unmarshal response 2: %v", err)
parseSSEResultForTest := func(body []byte) string {
lines := strings.Split(string(body), "\n")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add missing strings imports for new SSE parsers

This added SSE parser calls strings.Split/HasPrefix/TrimSpace, but strings is not imported in this test file; the same missing import exists in gateway/receipt_store_integration_test.go where the receipt stream is parsed. The gateway package will fail to compile before the replay regression can run, so add strings to both import blocks.

Useful? React with 👍 / 👎.

var result string
for _, line := range lines {
if strings.HasPrefix(line, "data: ") {
data := strings.TrimSpace(strings.TrimPrefix(line, "data: "))
if data == "[DONE]" || data == "" {
continue
}
var chunk map[string]interface{}
if err := json.Unmarshal([]byte(data), &chunk); err == nil {
if text, ok := chunk["text"].(string); ok {
result += text
}
}
}
}
return result
}

if val, ok := resp1["result"].(string); !ok || val != "AI Summary Result" {
t.Errorf("Unexpected result 1: %v", resp1["result"])
result1 := parseSSEResultForTest(w1.Body.Bytes())
result2 := parseSSEResultForTest(w2.Body.Bytes())

if result1 != "AI Summary Result" {
t.Errorf("Unexpected result 1: %v", result1)
}
if val, ok := resp2["result"].(string); !ok || val != "AI Summary Result" {
t.Errorf("Unexpected result 2: %v", resp2["result"])
if result2 != "AI Summary Result" {
t.Errorf("Unexpected result 2: %v", result2)
Comment on lines +231 to +232

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update cache-hit test for one-use signatures

After adding the cache-hit replay check, the second request in TestCacheIntegration_FullFlow still reuses the same 0xValidSig, so the cache-hit path returns 402 before this assertion instead of the cached summary. The Go workflow starts Redis, so this integration test will fail under go test ./...; use a fresh verifier-accepted signature/nonce for the cache-hit request or adjust the mock accordingly.

Useful? React with 👍 / 👎.

}
}
5 changes: 5 additions & 0 deletions gateway/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"testing"
"time"

"gateway/internal/ai"
"github.com/alicebob/miniredis/v2"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
Expand All @@ -24,6 +25,10 @@ func (p failingProvider) Generate(context.Context, string) (string, error) {
return "", p.err
}

func (p failingProvider) GenerateStream(context.Context, string) (ai.Stream, error) {
return nil, p.err
}

func newSummarizeTestRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
Expand Down
55 changes: 55 additions & 0 deletions gateway/internal/ai/ollama.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,58 @@ func (p *OllamaProvider) Generate(ctx context.Context, text string) (string, err

return response, nil
}

type ollamaStream struct {
resp *http.Response
decoder *json.Decoder
}

func (s *ollamaStream) Recv() (string, error) {
var chunk map[string]interface{}
if err := s.decoder.Decode(&chunk); err != nil {
return "", err // will return io.EOF when stream ends
Comment on lines +88 to +89

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require Ollama's final done event before signing

The Ollama streaming API has a final done: true record, but this implementation treats any decoder EOF as clean completion without tracking that sentinel. If the local Ollama connection closes after emitting some chunks but before the final done record, handleSummarize will treat the partial text as complete and sign/store a receipt for it; add a done flag like the OpenRouter stream and return an incomplete-stream error on bare EOF.

Useful? React with 👍 / 👎.

}
if response, ok := chunk["response"].(string); ok {
return response, nil
}
return "", fmt.Errorf("invalid response from Ollama: missing response field")
}

func (s *ollamaStream) Close() error {
return s.resp.Body.Close()
}

func (p *OllamaProvider) GenerateStream(ctx context.Context, text string) (Stream, error) {
prompt := fmt.Sprintf("Summarize this text in 2 sentences: %s", text)

reqBody, _ := json.Marshal(map[string]interface{}{
"model": p.model,
"prompt": prompt,
"stream": true,
})

req, err := http.NewRequestWithContext(ctx, "POST", p.url+"/api/generate", bytes.NewBuffer(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create Ollama request: %w", err)
}
req.Header.Set("Content-Type", "application/json")

resp, err := http.DefaultClient.Do(req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || ctx.Err() == context.DeadlineExceeded {
return nil, context.DeadlineExceeded
}
return nil, fmt.Errorf("failed to connect to Ollama: %w", err)
}

if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("ollama returned status %d: %s", resp.StatusCode, string(body))
}

return &ollamaStream{
resp: resp,
decoder: json.NewDecoder(resp.Body),
}, nil
}
Comment on lines +101 to +134

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Duplicate request-building logic between Generate and GenerateStream.

The prompt formatting, request construction, and deadline-error handling in GenerateStream (Lines 101-122) is nearly identical to Generate (Lines 39-60), differing only in the "stream" flag and response handling. This duplication makes the two paths prone to diverging (see the related sibling comment on openrouter.go, where the streaming and non-streaming paths already diverge in error handling). Consider extracting the shared request-building step into a helper, e.g. buildGenerateRequest(ctx, url, model, prompt, stream bool) (*http.Request, error).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gateway/internal/ai/ollama.go` around lines 101 - 134,
`OllamaProvider.GenerateStream` duplicates the same prompt/request setup and
deadline handling already present in `Generate`, so extract the shared
request-building logic into a helper such as `buildGenerateRequest` and have
both methods call it. Keep the helper responsible for formatting the prompt,
marshaling the body, creating the HTTP request, and preserving the context
deadline error behavior, while `GenerateStream` should only set up the
streaming-specific response handling and `"stream": true` flag.

99 changes: 99 additions & 0 deletions gateway/internal/ai/openrouter.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ai

import (
"bufio"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

GenerateStream duplicates Generate's request-building logic.

Same DRY concern as in ollama.go: prompt formatting and request construction (Lines 153-168) are copy-pasted from Generate (Lines 45-59), differing only by the "stream": true field. Recommend extracting a shared request builder to reduce future divergence risk — see the adjacent Recv() finding, which is a direct consequence of the streaming and non-streaming paths evolving independently.

Also applies to: 152-188

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gateway/internal/ai/openrouter.go` at line 4, GenerateStream in openrouter.go
is duplicating the prompt formatting and request construction already done in
Generate, with the only meaningful difference being the stream flag. Extract the
shared request-building logic into a common helper used by both Generate and
GenerateStream, and have GenerateStream only set the streaming-specific option
before sending the request. Keep the shared logic centralized around the
existing Generate and GenerateStream methods so future changes don’t diverge.

"bytes"
"context"
"encoding/json"
Expand Down Expand Up @@ -100,3 +101,101 @@ func (p *OpenRouterProvider) Generate(ctx context.Context, text string) (string,

return content, nil
}

// ErrIncompleteStream is returned by Recv when the upstream TCP connection
// closes before the [DONE] sentinel is received. This distinguishes a clean
// completion from a dropped or truncated stream.
var ErrIncompleteStream = errors.New("upstream stream ended without [DONE]")

type openRouterStream struct {
resp *http.Response
reader *bufio.Reader
sawDone bool
}

func (s *openRouterStream) Recv() (string, error) {
for {
line, err := s.reader.ReadBytes('\n')
if err != nil {
if errors.Is(err, io.EOF) {
if s.sawDone {
return "", io.EOF
}
return "", ErrIncompleteStream
}
return "", err
Comment on lines +118 to +126

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not treat a truncated OpenRouter stream as complete

For OpenRouter, normal completion is the [DONE] SSE frame, but this path returns the same io.EOF when the TCP stream ends before that frame (or when a test/custom endpoint returns a non-SSE JSON body). handleSummarize treats io.EOF as success, so a dropped upstream connection can produce a 200 response and signed receipt for an empty or partial summary instead of an upstream error; track whether [DONE] was seen and return an error for bare EOF.

Useful? React with 👍 / 👎.

}
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
if bytes.HasPrefix(line, []byte("data: ")) {
data := bytes.TrimPrefix(line, []byte("data: "))
if string(data) == "[DONE]" {
s.sawDone = true
return "", io.EOF
}
var chunk map[string]interface{}
if err := json.Unmarshal(data, &chunk); err != nil {
continue // skip invalid json
}
choices, ok := chunk["choices"].([]interface{})
if !ok || len(choices) == 0 {
continue
}
choice, ok := choices[0].(map[string]interface{})
if !ok {
continue
}
delta, ok := choice["delta"].(map[string]interface{})
if !ok {
continue
}
if content, ok := delta["content"].(string); ok {
return content, nil
}
}
}
}
Comment on lines +116 to +159

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant files first
ast-grep outline gateway/internal/ai/openrouter.go --view expanded
ast-grep outline gateway/main.go --view expanded

# Read the relevant sections with line numbers
sed -n '1,240p' gateway/internal/ai/openrouter.go | cat -n
printf '\n--- main.go ---\n'
sed -n '1,260p' gateway/main.go | cat -n

# Look for stream/error handling and transaction usage sites
rg -n "markTransactionUsed|GenerateStream|Recv\(\)|io\.EOF|openrouter" gateway -S

Repository: AnkanMisra/MicroAI-Paygate

Length of output: 23374


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the stream consumption path in main.go
sed -n '520,590p' gateway/main.go | cat -n

# Show the Ollama stream parser for comparison
sed -n '70,120p' gateway/internal/ai/ollama.go | cat -n

# Show any tests covering streaming error behavior
sed -n '1,220p' gateway/errors_test.go | cat -n
sed -n '1,180p' gateway/timeout_test.go | cat -n

Repository: AnkanMisra/MicroAI-Paygate

Length of output: 19102


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '520,590p' gateway/main.go | cat -n
printf '\n--- ollama.go ---\n'
sed -n '70,120p' gateway/internal/ai/ollama.go | cat -n
printf '\n--- errors_test.go ---\n'
sed -n '1,220p' gateway/errors_test.go | cat -n
printf '\n--- timeout_test.go ---\n'
sed -n '1,180p' gateway/timeout_test.go | cat -n

Repository: AnkanMisra/MicroAI-Paygate

Length of output: 19170


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the stream consumption path in main.go
sed -n '520,590p' gateway/main.go | cat -n

# Show the Ollama stream parser for comparison
printf '\n--- ollama.go ---\n'
sed -n '70,120p' gateway/internal/ai/ollama.go | cat -n

# Show tests that may cover stream error behavior
printf '\n--- errors_test.go ---\n'
sed -n '1,220p' gateway/errors_test.go | cat -n
printf '\n--- timeout_test.go ---\n'
sed -n '1,180p' gateway/timeout_test.go | cat -n

Repository: AnkanMisra/MicroAI-Paygate

Length of output: 19170


🌐 Web query:

OpenRouter streaming error SSE top-level error field chat.completion.chunk mid-stream error

💡 Result:

When a mid-stream error occurs during an OpenRouter Server-Sent Events (SSE) stream, the API communicates the error by sending a specific JSON object within the stream [1][2]. Because the HTTP status code is already set to 200 OK once streaming begins, the error cannot be signaled via standard HTTP error codes [1][3]. Instead, OpenRouter sends a final SSE chunk with a top-level error field that follows this structure [2][3]: { "id": "cmpl-abc123", "object": "chat.completion.chunk", "created": 1234567890, "model": "...", "provider": "...", "error": { "code": "server_error", "message": "Provider disconnected unexpectedly" }, "choices": [{ "index": 0, "delta": { "content": "" }, "finish_reason": "error" }] } Key behaviors include: - Termination: The stream is terminated immediately after this event is sent [1][2]. - Error Handling: Client implementations should monitor every chunk for the presence of an error field [1]. If found, the application should cease processing the stream and handle the error based on the code and message provided [1][4]. - Distinguishing Network Issues: Be aware that not all mid-stream interruptions are signaled by this JSON event [5]. If the server-side process or network connection terminates abruptly (e.g., a TLS socket closure), you may receive a low-level network error (like a "terminated" TypeError) rather than a clean JSON-formatted error chunk [5]. In such cases, there will be no response body or HTTP status code, necessitating robust retry logic for network-level failures [5].

Citations:


Surface OpenRouter SSE errors instead of treating EOF as success

OpenRouter can emit mid-stream failures as an in-band chunk with a top-level error object. This parser only returns when delta.content is present, so those chunks are skipped and a close immediately after can still fall through to io.EOF as a successful end of stream. Return an error when chunk["error"] is present so truncated generations don’t look successful after the transaction is already marked used.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gateway/internal/ai/openrouter.go` around lines 110 - 146, The
openRouterStream Recv parser is ignoring in-band SSE error chunks and can fall
through to io.EOF as if the stream ended successfully. Update Recv to inspect
each parsed JSON chunk for a top-level error field before checking
choices/delta, and return a non-EOF error when present so mid-stream OpenRouter
failures are surfaced instead of being treated as a normal completion.


func (s *openRouterStream) Close() error {
return s.resp.Body.Close()
}

func (p *OpenRouterProvider) GenerateStream(ctx context.Context, text string) (Stream, error) {
prompt := fmt.Sprintf("Summarize this text in 2 sentences: %s", text)

reqBody, _ := json.Marshal(map[string]interface{}{
"model": p.model,
"messages": []map[string]string{
{"role": "user", "content": prompt},
},
"stream": true,
})

req, err := http.NewRequestWithContext(ctx, "POST", p.url, bytes.NewBuffer(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create OpenRouter request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+p.apiKey)
req.Header.Set("Content-Type", "application/json")

resp, err := http.DefaultClient.Do(req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || ctx.Err() == context.DeadlineExceeded {
return nil, context.DeadlineExceeded
}
return nil, err
}

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("openrouter returned status %d: %s", resp.StatusCode, string(body))
}

return &openRouterStream{
resp: resp,
reader: bufio.NewReader(resp.Body),
}, nil
}
10 changes: 10 additions & 0 deletions gateway/internal/ai/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,20 @@ import (
"os"
)

// Stream defines the interface for streaming AI responses
type Stream interface {
// Recv returns the next token or an error (io.EOF when done)
Recv() (string, error)
// Close releases any resources associated with the stream
Close() error
}

// Provider defines the interface for AI service providers
type Provider interface {
// Generate takes a context and prompt text, returns the AI-generated response
Generate(ctx context.Context, prompt string) (string, error)
// GenerateStream takes a context and prompt text, returns a Stream of generated tokens
GenerateStream(ctx context.Context, prompt string) (Stream, error)
}

// NewProvider creates an AI provider based on the AI_PROVIDER environment variable
Expand Down
Loading
Loading