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
13 changes: 6 additions & 7 deletions gateway/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,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 +204,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 {

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.

P0 Badge Use or remove the captured body bytes

This new cache-miss branch no longer uses the bodyBytes := writer.body.Bytes() assignment just above it; cd gateway && go test ./... fails with ./cache.go:207:3: declared and not used: bodyBytes, so the gateway package cannot build. Remove the assignment or use it when parsing/caching the SSE result.

Useful? React with 👍 / 👎.

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
40 changes: 29 additions & 11 deletions gateway/cache_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
Expand Down Expand Up @@ -58,8 +59,10 @@ func TestCacheIntegration_FullFlow(t *testing.T) {
aiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
aiCalls.Add(1)
time.Sleep(100 * time.Millisecond)
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
w.Write([]byte(`{"choices":[{"message":{"content":"AI Summary Result"}}]}`))
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"AI Summary Result\"}}]}\n\n"))
w.Write([]byte("data: [DONE]\n\n"))
}))
defer aiServer.Close()

Expand Down Expand Up @@ -198,18 +201,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 {

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.

P2 Badge Request SSE before parsing cache responses

The new assertion helper parses only data: events, but the requests built by makeRequest above still omit Accept: text/event-stream, so both the cache miss and cache hit take the JSON response path and this parser returns an empty string. Set the Accept header in the helper, or keep this test on the JSON/header contract and assert result from the JSON body.

Useful? React with 👍 / 👎.

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 the missing strings import in cache tests

This new SSE parser calls strings.Split, strings.HasPrefix, and strings.Trim*, but gateway/cache_integration_test.go does not import strings; after the production compile error is fixed, the gateway test package still fails to compile with undefined: strings. Add the import so the cache integration test can run.

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
Comment on lines +214 to +216

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 the cache integration mock to emit SSE

After the missing import is fixed, this parser only accumulates text fields from data: SSE chunks, but the test's mock OpenRouter server still returns the old non-streaming JSON shape, so the handler produces no text chunks and the cache integration test fails with empty results whenever Redis is available. Change the mock response to stream data: {"choices":[{"delta":{"content":...}}]} and [DONE] events.

Useful? React with 👍 / 👎.

}
}
}
}
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)
}
}
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
}
if response, ok := chunk["response"].(string); ok {
return response, nil
}
return "", fmt.Errorf("invalid response from Ollama: missing response field")
}
Comment on lines +86 to +95

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 | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== relevant files ==="
git ls-files | rg 'gateway/internal/ai/ollama.go|go.mod|go.sum|README|openapi|ollama|ai'

echo
echo "=== ollama.go excerpt ==="
cat -n gateway/internal/ai/ollama.go | sed -n '1,220p'

echo
echo "=== dependency versions mentioning ollama ==="
rg -n '"ollama|github.com/ollama' go.mod go.sum gateway -g '!**/node_modules/**'

echo
echo "=== repo mentions of /api/generate or error field ==="
rg -n 'api/generate|"\berror\b"|missing response field|ollama' gateway README.md . -g '!**/node_modules/**'

Repository: AnkanMisra/MicroAI-Paygate

Length of output: 6137


🌐 Web query:

Ollama /api/generate streaming error field contract error chunk response field

💡 Result:

When using the Ollama /api/generate endpoint with streaming enabled (the default), the API uses newline-delimited JSON (NDJSON) [1][2]. Each chunk in the stream is an independent JSON object sent on a new line [2][3]. If an error occurs mid-stream, Ollama sends an error object as a JSON chunk within the NDJSON stream [4][5]. This error chunk typically contains an "error" field describing the issue [5][6][7]. Because the HTTP connection is already open and partial data may have been sent, the API does not change the HTTP status code when this occurs [5]. To handle this, client implementations must parse each line of the stream independently [2][3]. If a chunk contains an "error" field, the client should treat it as an error and stop processing the stream [5][7]. Example of an error chunk in a stream: {"error":"an error was encountered while running the model"} [5] If you prefer to receive a single, standard JSON response instead of a stream—which simplifies error handling by allowing the API to return a traditional HTTP error status—you can disable streaming by setting "stream": false in your request body [1][8].

Citations:


Surface Ollama stream errors
/api/generate streams can return {"error":"..."} chunks; return that message here instead of the generic “missing response field” 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 86 - 95, The ollamaStream.Recv
method currently treats any non-response chunk as a generic missing-field
failure; update it to explicitly detect Ollama error chunks from /api/generate
by checking for an "error" field in the decoded chunk and returning that message
as the error. Keep the existing decode handling, but in ollamaStream.Recv prefer
returning the streamed error text before falling back to the invalid response
error when neither "response" nor "error" is present.


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
}
91 changes: 91 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"
"bytes"
"context"
"encoding/json"
Expand Down Expand Up @@ -100,3 +101,93 @@ func (p *OpenRouterProvider) Generate(ctx context.Context, text string) (string,

return content, nil
}

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) && !s.sawDone {
return "", fmt.Errorf("stream ended without [DONE] sentinel")
}
return "", err
Comment on lines +114 to +118

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 Reject OpenRouter streams that end before DONE

A bare io.EOF from ReadBytes is returned exactly like the [DONE] sentinel, and handleSummarize treats any io.EOF as a clean completion; if the OpenRouter connection drops mid-stream or returns a non-SSE body, the gateway signs and caches a partial or empty summary as a successful paid response. Track whether [DONE] was actually received and return an error for EOF before that sentinel.

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 +111 to +151

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 | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== File listing ==\n'
git ls-files 'gateway/internal/ai/openrouter.go' 'gateway/internal/ai/*' | sed -n '1,120p'

printf '\n== Outline ==\n'
ast-grep outline gateway/internal/ai/openrouter.go --view expanded || true

printf '\n== Relevant lines from gateway/internal/ai/openrouter.go ==\n'
cat -n gateway/internal/ai/openrouter.go | sed -n '1,240p'

Repository: AnkanMisra/MicroAI-Paygate

Length of output: 7140


🌐 Web query:

OpenRouter streaming API SSE error payload format data: {"error":...} finish_reason error model version streaming chat completions

💡 Result:

OpenRouter handles streaming errors differently depending on whether tokens have already been sent to the client [1][2]. If an error occurs before any tokens are streamed, the API returns a standard JSON error response with the appropriate HTTP status code [1][2]. If an error occurs mid-stream (after tokens have been sent), the HTTP status code remains 200 OK because headers were already transmitted [1][2]. In this case, the error is delivered as a Server-Sent Event (SSE) containing a unified JSON payload [1][2]. The structure of this mid-stream error SSE payload is as follows [2]: data: {"id":"cmpl-abc123","object":"chat.completion.chunk","created":1234567890,"model":"model-name","provider":"provider-name","error":{"code":"error_code","message":"error message"},"choices":[{"index":0,"delta":{"content":""},"finish_reason":"error"}]} Key characteristics of this payload include: - The error object is included at the top level of the JSON alongside standard fields like id, object, and model [1][2]. - A choices array is provided with finish_reason set to "error" to formally terminate the stream [1][2]. - The stream is terminated by the server immediately after this event is sent [3][2]. OpenRouter normalizes finish_reason values across different models, and "error" is one of the standardized values used to indicate an abnormal stream termination [4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Search for OpenRouter stream handling in repo ==\n'
rg -n "finish_reason|\\[DONE\\]|data: \\{\"error\"|openrouter" gateway -g '*.go' -g '*.md' -g '*.yaml' | sed -n '1,200p'

Repository: AnkanMisra/MicroAI-Paygate

Length of output: 2763


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import json, textwrap, sys, re
from pathlib import Path
p = Path('gateway/internal/ai/openrouter.go')
text = p.read_text()
for i, line in enumerate(text.splitlines(), 1):
    if 100 <= i <= 170:
        print(f"{i:4}: {line}")
PY

Repository: AnkanMisra/MicroAI-Paygate

Length of output: 2302


Surface OpenRouter stream errors in gateway/internal/ai/openrouter.go. Mid-stream SSE errors can arrive as a chunk with error and finish_reason: "error", but Recv() only returns delta.content, which can turn the failure into an empty/normal result instead of an 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/openrouter.go` around lines 110 - 146, The
openRouterStream Recv() path is ignoring SSE error payloads and only extracting
delta.content, so mid-stream OpenRouter failures can be treated like normal
empty output. Update Recv() in openrouter.go to detect chunks containing an
error or a finish_reason of "error" before returning content, and return a
non-nil error instead of continuing. Keep the existing chunk parsing flow around
json.Unmarshal, choices, and delta, but add explicit handling for the error case
so callers can surface the stream failure.


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