Short answer: Choose the backend API whose completed stream can be proved, replayed, and evaluated independently of any provider SDK.
For an in-app chatbot, “simple” shouldn't mean the fewest lines in a notebook. It should mean one server-owned authentication boundary, one small streaming contract, and enough evidence to tell a finished answer from a response that merely looked finished in the browser. I care about that distinction because my usual path is notebook-to-prod: first I test retrieval and prompts in Python, then I have to ship the same behavior behind a signed-in web app without turning a model client into the architecture.
My evaluation constraint is blunt. A candidate passes only when the browser can reconnect, the server can associate the turn with the correct user, and an automated check can reconcile the visible answer with the committed conversation record. Raw token speed comes later. This rules out the deceptively easy design where browser code holds provider credentials and imports a provider SDK directly. It also gives me a portable alternative to any single SDK: my application owns the interface, while a thin adapter owns provider-specific calls.
What should an authenticated web app demand from a chatbot streaming backend API?
The backend has two trust relationships, and I don't let them blur together. The browser authenticates to my application. My application authenticates to whichever model service sits behind the adapter. A user session is not a model credential, and a model credential never belongs in browser code. That separation also gives the server a stable place to enforce conversation ownership before generation starts.
The request itself can stay boring: conversation identity, a client-generated turn identity, the user's text, and a version for the prompt or behavior under evaluation. The response is a sequence of typed events rather than an unstructured pile of token fragments. I need, at minimum, a start event that identifies the accepted turn, zero or more deltas for display, and a terminal event that states whether the turn was committed. The exact wire encoding matters less than the semantics — the browser must not infer durable success from a closed connection.
No guessing.
I learned that the unpleasant way. In one release, a call returned HTTP 200 and the full reply appeared on screen, but the side effect that saved the assistant turn never happened; 6 hours later, support found conversations that vanished after refresh. The transport had succeeded. The product action had not. Since then, “done” means the terminal event and the durable record agree.
That's the test. It sounds stricter than a quick demo because it is.
I also want cancellation to be explicit. A closed tab may tell the server that nobody needs more display deltas, but it doesn't automatically answer whether the accepted turn should be abandoned, completed, or billed to a usage ledger. I'm not sure why so many prototypes leave that policy implicit; perhaps the happy path hides it. Your mileage may vary, but writing the cancellation rule down before choosing an API exposes more architectural risk than another latency shootout.
Treat streaming as an application contract
I keep a narrow Python interface between chat orchestration and model transport. It lets an eval harness consume the same events as the web delivery layer, without pretending every provider exposes identical options. The adapter may translate a remote stream, but the rest of the application sees only behavior my team is prepared to support.
from dataclasses import dataclass
from typing import AsyncIterator, Literal, Protocol
@dataclass(frozen=True)
class ChatEvent:
kind: Literal["started", "delta", "committed", "rejected"]
turn_id: str
text: str = ""
record_version: int | None = None
class ChatRuntime(Protocol):
async def stream_turn(
self,
*,
user_id: str,
conversation_id: str,
turn_id: str,
message: str,
) -> AsyncIterator[ChatEvent]: ...
async def deliver_turn(
runtime: ChatRuntime,
session_user_id: str,
conversation_id: str,
turn_id: str,
message: str,
) -> AsyncIterator[ChatEvent]:
async for event in runtime.stream_turn(
user_id=session_user_id,
conversation_id=conversation_id,
turn_id=turn_id,
message=message,
):
yield event
if event.kind in {"committed", "rejected"}:
return
This example is intentionally smaller than production code. Session validation and the ownership lookup happen before deliver_turn; persistence belongs inside the runtime transaction boundary or an adjacent application service. The important bit is that the terminal state is data. Tests can assert that every started turn reaches exactly one accepted terminal state, while the UI can wait for committed before representing the answer as durable.
Don't flatten every remote event into delta. Usage, tool requests, citations, safety outcomes, and termination reasons may need separate application types later. I add one only when the product has defined its meaning and my eval set can exercise it. Otherwise an allegedly generic schema becomes a bag of optional fields copied from one provider.
This is also where a “compatible” API can mislead. Matching a request shape doesn't guarantee matching retry, cancellation, ordering, or terminal-event behavior. I run the adapter through contract tests, then swap it without changing browser code. If that test requires importing the provider's classes throughout the app, the boundary isn't real yet.
Compare failure semantics before feature lists
I score candidates with a short failure matrix before I compare model catalogs. It keeps the selection tied to the in-app workflow rather than to an impressive notebook. No row gets a pass from documentation alone; I exercise it against a disposable conversation and inspect both emitted events and stored state.
| Decision axis | Weak evidence | Evidence I accept |
|---|---|---|
| Authentication boundary | Browser can start a stream | Server maps a valid session to an owned conversation |
| Completion | Connection closes after text | Terminal event identifies the committed turn version |
| Retry | “Retries supported” | Repeating one turn identity has a defined, tested result |
| Cancellation | Client can disconnect | Product policy says what happens to generation and persistence |
| Portability | Familiar request fields | Contract tests pass through a second adapter |
| Evaluation | Output looks plausible | Saved fixtures score answer quality and required behavior |
| Cost observation | A monthly total exists | Usage can be attributed to prompt version and conversation |
The catch is that a tiny wrapper costs engineering time. It is not suitable when I'm building a disposable, unauthenticated experiment whose output will never be persisted. In that case, I stick with the provider's SDK because its native types and examples shorten the feedback loop. I also keep the native SDK when the product depends on provider-specific realtime media or event semantics that my abstraction would merely conceal. Portability isn't free, and a false abstraction is worse than an explicit dependency.
For the ordinary authenticated text chatbot, though, the table usually finds the real gaps. A reconnect can duplicate a turn. Two tabs can race on one conversation. A user can submit against a stale record version. Partial text can linger after cancellation. None of these is fixed by choosing a fashionable client library; they need application semantics and tests.
Small differences matter. I record the candidate's result for each scenario as an artifact, including event order and final stored version, so a later adapter upgrade runs against the same expectations. That makes API selection reproducible instead of a meeting where everyone remembers a different demo.
Measure the choice from notebook to production
My last gate is an eval run that joins quality, reliability, latency, and usage without collapsing them into one magic score. I start with representative conversations: short factual turns, retrieval-backed questions, prompt-injection attempts, cancellations, reconnects, and concurrent submissions. Embeddings can support retrieval and similarity-based analysis, but the metric still has to reflect the product decision; proximity alone doesn't establish that an answer is correct. The embeddings guide in References is a useful primary starting point, while the prompt engineering guide provides broader patterns to test rather than accept on faith.
For every fixture, I retain prompt version, adapter version, turn identity, time to first visible delta, time to committed terminal state, final record version, and whatever usage units the adapter can report faithfully. Then I score the answer separately. This keeps prompt-cost awareness grounded: I can see whether a longer retrieval context improved the eval cases enough to justify its added input, without making a volatile price claim or optimizing for short prompts that fail users.
Measure both.
I don't promote a candidate merely because its median stream feels fast. Tail behavior and correctness decide whether the interaction survives real traffic. The deployment check replays duplicate turn identities, expires a session before generation, cancels after the first delta, and reconnects before the terminal event. Observability must link those attempts without logging raw private conversation text by default.
One more constraint: ownership has to remain obvious to the team. The application owns authorization, conversation state, eval definitions, and the public event contract. The adapter owns translation into a model service and translation back. If changing adapters requires edits in React components, persistence records, and eval fixtures, I haven't isolated it.
Before copying this choice, measure the cases your users will actually create and decide which provider-specific capabilities you are willing to give up. A simple backend API is the one that leaves the fewest ambiguous states after those tests, not the one that wins a screenshot of the happy path.
References
- OpenAI, “Embeddings guide”: https://platform.openai.com/docs/guides/embeddings
- Prompt Engineering Guide: https://www.promptingguide.ai
Top comments (0)