Skip to content

feat: SSE streaming for service-mode answer generation - #2393

Open
jdye64 wants to merge 3 commits into
mainfrom
cursor/sse-answer-stream-0aa8
Open

feat: SSE streaming for service-mode answer generation#2393
jdye64 wants to merge 3 commits into
mainfrom
cursor/sse-answer-stream-0aa8

Conversation

@jdye64

@jdye64 jdye64 commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds low-latency token streaming for answer generation in service mode via a new SSE endpoint.

  • POST /v1/answer/stream — streams retrieval + LLM answer tokens as Server-Sent Events
  • LiteLLMClient.stream_complete() / stream_generate() — async LiteLLM streaming with TTFT metrics and incremental think-tag filtering
  • RetrieverServiceClient.aanswer_stream() — async Python client for consuming the SSE stream
  • Refactors shared answer helpers used by both blocking and streaming endpoints

SSE event types

Event Payload
retrieval_done chunk_count, retrieval_latency_s, optional chunks / metadata
metrics ttft_s, optional generation_latency_s
token delta, index
done Full AnswerResult payload (same fields as POST /v1/answer)
error detail

Usage

async for event in client.aanswer_stream("What is RAG?", top_k=5):
    if event["event"] == "token":
        print(event["delta"], end="", flush=True)
    elif event["event"] == "done":
        print("\n", event["answer"])
curl -N -X POST http://localhost:7670/v1/answer/stream \
  -H 'Content-Type: application/json' \
  -d '{"query":"What is RAG?","top_k":5}'

Notes

  • Blocking POST /v1/answer is unchanged for batch/eval workflows.
  • MCP answer tool remains blocking; interactive UIs should use the HTTP stream endpoint.
  • Set reasoning_enabled: false on the request for faster visible TTFT on Nemotron models.

Testing

  • tests/test_service_answer_stream.py
  • tests/test_llm_params.py::TestLiteLLMStreaming
  • Existing tests/test_service_answer_generation.py (refactored helpers)
Open in Web Open in Cursor 

Introduce POST /v1/answer/stream to forward LLM tokens to clients as
Server-Sent Events after VectorDB retrieval, with TTFT metrics and
incremental think-tag filtering. Refactor shared answer helpers and add
RetrieverServiceClient.aanswer_stream() for async consumption.

Co-authored-by: Jeremy Dyer <jdye64@gmail.com>
@copy-pr-bot

copy-pr-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

Fixes pre-commit failure on the answer stream refactor return tuple.

Co-authored-by: Jeremy Dyer <jdye64@gmail.com>
@jdye64
jdye64 marked this pull request as ready for review July 27, 2026 19:49
@jdye64
jdye64 requested review from a team as code owners July 27, 2026 19:49
@jdye64
jdye64 requested a review from drobison00 July 27, 2026 19:49
@greptile-apps

greptile-apps Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds service-mode answer streaming over SSE.

  • Adds asynchronous LiteLLM streaming, token metrics, and incremental think-tag filtering.
  • Adds /v1/answer/stream with retrieval, token, metrics, completion, and error events.
  • Adds an asynchronous Python client for consuming answer streams.
  • Refactors shared retrieval and cached LLM/judge setup used by blocking and streaming answers.
  • Adds endpoint and LiteLLM streaming tests.

Confidence Score: 3/5

The PR should not merge until think-tag closing delimiters split across streamed chunks no longer suppress the visible answer stream.

The incremental filter clears partial closing-tag prefixes, so a normal provider chunk boundary can eliminate all token events while leaving only the eventual done payload; the client also silently drops malformed SSE events and lacks direct regression coverage.

Files Needing Attention: nemo_retriever/src/nemo_retriever/models/llm/text_utils.py; nemo_retriever/src/nemo_retriever/service/client.py

Important Files Changed

Filename Overview
nemo_retriever/src/nemo_retriever/models/llm/clients/litellm.py Adds async completion and RAG token streaming; the final batch result remains correct, but streamed visibility depends on the flawed incremental filter.
nemo_retriever/src/nemo_retriever/models/llm/text_utils.py Adds stateful think-tag filtering that loses closing delimiters split across provider chunks and can suppress all visible tokens.
nemo_retriever/src/nemo_retriever/service/client.py Adds the async SSE client, but its production path lacks direct tests and silently discards malformed event payloads.
nemo_retriever/src/nemo_retriever/service/routers/ingest.py Refactors blocking-answer helpers and adds the streaming route with retrieval, generation, optional judging, and terminal events.
nemo_retriever/tests/test_llm_params.py Verifies LiteLLM streaming invocation and ordinary delta extraction.
nemo_retriever/tests/test_service_answer_stream.py Covers endpoint success, generation failure, disabled configuration, and an unsplit think-tag case, but not split closing tags or the public client.

Sequence Diagram

sequenceDiagram
    participant C as Client
    participant API as /v1/answer/stream
    participant VDB as VectorDB
    participant LLM as LiteLLM
    C->>API: POST answer request
    API->>VDB: Retrieve top-k chunks
    VDB-->>API: Chunks and metadata
    API-->>C: retrieval_done
    API->>LLM: Async streaming completion
    loop Provider deltas
        LLM-->>API: Text delta
        API-->>C: metrics/token SSE
    end
    API-->>C: done or error SSE
Loading
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
nemo_retriever/src/nemo_retriever/models/llm/text_utils.py:37-41
**Preserve split closing tags**

When a provider splits `</think>` across streamed deltas, this branch clears the partial delimiter and leaves the filter in thinking mode, suppressing every subsequent visible token even though the final `done` event contains the batch-parsed answer.

```suggestion
            if self._in_thinking:
                close_idx = self._pending.find(_THINK_CLOSE)
                if close_idx == -1:
                    _, self._pending = _split_safe_suffix(self._pending, _THINK_CLOSE)
                    break
```

### Issue 2 of 3
nemo_retriever/src/nemo_retriever/service/client.py:248-298
**Test the public stream client**

The new `aanswer_stream` method has no tests exercising its production httpx stream, status handling, or SSE parser; the added endpoint tests use `TestClient` and a separate duplicated parser, allowing client-side regressions to pass CI.

### Issue 3 of 3
nemo_retriever/src/nemo_retriever/service/client.py:291-295
**Surface malformed SSE payloads**

When an SSE event contains malformed JSON, this branch silently discards the event and resets its type, so callers receive neither the event nor an actionable stream error. Raise a descriptive client error instead of continuing with missing stream data.

Reviews (2): Last reviewed commit: "Merge branch 'main' into cursor/sse-answ..." | Re-trigger Greptile

Comment on lines +37 to +41
if self._in_thinking:
close_idx = self._pending.find(_THINK_CLOSE)
if close_idx == -1:
self._pending = ""
break

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 Preserve split closing tags

When a provider splits </think> across streamed deltas, this branch clears the partial delimiter and leaves the filter in thinking mode, suppressing every subsequent visible token even though the final done event contains the batch-parsed answer.

Suggested change
if self._in_thinking:
close_idx = self._pending.find(_THINK_CLOSE)
if close_idx == -1:
self._pending = ""
break
if self._in_thinking:
close_idx = self._pending.find(_THINK_CLOSE)
if close_idx == -1:
_, self._pending = _split_safe_suffix(self._pending, _THINK_CLOSE)
break

Knowledge Base Used: Model Backends

Prompt To Fix With AI
This is a comment left during a code review.
Path: nemo_retriever/src/nemo_retriever/models/llm/text_utils.py
Line: 37-41

Comment:
**Preserve split closing tags**

When a provider splits `</think>` across streamed deltas, this branch clears the partial delimiter and leaves the filter in thinking mode, suppressing every subsequent visible token even though the final `done` event contains the batch-parsed answer.

```suggestion
            if self._in_thinking:
                close_idx = self._pending.find(_THINK_CLOSE)
                if close_idx == -1:
                    _, self._pending = _split_safe_suffix(self._pending, _THINK_CLOSE)
                    break
```

**Knowledge Base Used:** [Model Backends](https://app.greptile.com/nvidia-public-github/-/custom-context/knowledge-base/nvidia/nemo-retriever/-/docs/models.md)

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +248 to +298
async def aanswer_stream(
self,
query: str,
*,
top_k: int = 5,
include_chunks: bool = False,
include_metadata: bool = False,
reasoning_enabled: bool | None = None,
reference: str | None = None,
judge: bool = False,
) -> AsyncIterator[dict[str, Any]]:
"""Stream ``POST /v1/answer/stream`` SSE events for one answer request."""
url = f"{self._base_url}/v1/answer/stream"
body: dict[str, Any] = {
"query": query,
"top_k": top_k,
"include_chunks": include_chunks,
"include_metadata": include_metadata,
"judge": judge,
}
if reasoning_enabled is not None:
body["reasoning_enabled"] = reasoning_enabled
if reference is not None:
body["reference"] = reference

async with httpx.AsyncClient(
timeout=httpx.Timeout(None, connect=30.0),
headers=self._auth_headers,
) as client:
async with client.stream("POST", url, json=body) as response:
if response.status_code >= 400:
detail = (await response.aread()).decode(errors="replace")[:500]
raise RuntimeError(f"Service answer stream failed: HTTP {response.status_code}: {detail}")

event_type = ""
data_buf = ""
async for line in response.aiter_lines():
if line.startswith("event:"):
event_type = line[6:].strip()
elif line.startswith("data:"):
data_buf = line[5:].strip()
elif line == "" and data_buf:
try:
payload = json.loads(data_buf)
except json.JSONDecodeError:
data_buf = ""
event_type = ""
continue
yield {"event": event_type or "message", **payload}
data_buf = ""
event_type = ""

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 Test the public stream client

The new aanswer_stream method has no tests exercising its production httpx stream, status handling, or SSE parser; the added endpoint tests use TestClient and a separate duplicated parser, allowing client-side regressions to pass CI.

Rule Used: New functionality must include corresponding unit ... (source)

Knowledge Base Used: Service: HTTP/MCP API surface

Prompt To Fix With AI
This is a comment left during a code review.
Path: nemo_retriever/src/nemo_retriever/service/client.py
Line: 248-298

Comment:
**Test the public stream client**

The new `aanswer_stream` method has no tests exercising its production httpx stream, status handling, or SSE parser; the added endpoint tests use `TestClient` and a separate duplicated parser, allowing client-side regressions to pass CI.

**Rule Used:** New functionality must include corresponding unit ... ([source](.greptile))

**Knowledge Base Used:** [Service: HTTP/MCP API surface](https://app.greptile.com/nvidia-public-github/-/custom-context/knowledge-base/nvidia/nemo-retriever/-/docs/service-api.md)

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +291 to +295
payload = json.loads(data_buf)
except json.JSONDecodeError:
data_buf = ""
event_type = ""
continue

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 Surface malformed SSE payloads

When an SSE event contains malformed JSON, this branch silently discards the event and resets its type, so callers receive neither the event nor an actionable stream error. Raise a descriptive client error instead of continuing with missing stream data.

Knowledge Base Used: Service: HTTP/MCP API surface

Prompt To Fix With AI
This is a comment left during a code review.
Path: nemo_retriever/src/nemo_retriever/service/client.py
Line: 291-295

Comment:
**Surface malformed SSE payloads**

When an SSE event contains malformed JSON, this branch silently discards the event and resets its type, so callers receive neither the event nor an actionable stream error. Raise a descriptive client error instead of continuing with missing stream data.

**Knowledge Base Used:** [Service: HTTP/MCP API surface](https://app.greptile.com/nvidia-public-github/-/custom-context/knowledge-base/nvidia/nemo-retriever/-/docs/service-api.md)

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants