Skip to content

Add orcarouter_chat example#323

Open
zhenjunchen-png wants to merge 5 commits into
reflex-dev:mainfrom
zhenjunchen-png:feat/orcarouter-chat-example
Open

Add orcarouter_chat example#323
zhenjunchen-png wants to merge 5 commits into
reflex-dev:mainfrom
zhenjunchen-png:feat/orcarouter-chat-example

Conversation

@zhenjunchen-png

Copy link
Copy Markdown

Adds an orcarouter_chat example: a minimal Reflex chat app that calls
OrcaRouter, an OpenAI-compatible LLM gateway, and lets you switch the model
from a dropdown populated at startup from OrcaRouter's public pricing
catalog, with a curated fallback list when that endpoint is unreachable.

Disclosure: I'm an engineer on the OrcaRouter team.

@greptile-apps

greptile-apps Bot commented May 30, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a new orcarouter_chat example: a minimal Reflex streaming chat app that uses OrcaRouter as an OpenAI-compatible LLM gateway, with a runtime-populated model dropdown and optional fallback routing.

  • State architecture: ConfigState (model picker, catalog fetch) and ChatState (streaming, messages) are deliberately split to avoid re-rendering the model picker on every streamed token — a sound Reflex pattern.
  • Catalog loading: load_models correctly uses @rx.event(background=True) and snapshots state before the 10-second HTTP call, though the get_state(ConfigState) call inside the async with self: block in submit (line 243) still holds ChatState's lock while acquiring ConfigState's lock, which was flagged in a previous review thread and remains unaddressed.
  • Resilience: graceful fallback to a curated model list, temperature-retry logic for reasoning models, and user-visible error callouts are all well-handled.

Confidence Score: 3/5

The main app file has a concurrency issue in the submit path that should be resolved before this example ships.

In submit, await self.get_state(ConfigState) is called on line 243 while ChatState's state lock is still held (the async with self: block opened on line 231 has not yet exited). This means every message send holds ChatState's lock while waiting to acquire ConfigState's lock — any concurrent event that needs ChatState (e.g. the user typing the next message) is blocked for the entire duration. The issue was flagged in a prior review round and is still present in the current code.

orcarouter_chat/orcarouter_chat/orcarouter_chat.py — specifically the submit background handler around lines 231–246.

Important Files Changed

Filename Overview
orcarouter_chat/orcarouter_chat/orcarouter_chat.py Core app file with ConfigState/ChatState split, background streaming, and live model catalog fetch. Contains an unresolved cross-state lock hold: get_state(ConfigState) is awaited on line 243 while ChatState's lock is still held from async with self: on line 231, blocking any concurrent state mutation (e.g., typing) until ConfigState's lock is also free.
orcarouter_chat/rxconfig.py Minimal Reflex config; correctly points env_file to .env so the API key is loaded at startup.
orcarouter_chat/requirements.txt Four dependencies with appropriate minimum versions; all are used directly by the app.
orcarouter_chat/README.md Clear setup guide covering key acquisition, env file, and reflex run; accurately describes what the example demonstrates.
orcarouter_chat/.env.example Single-line placeholder for the API key; correct and minimal.
orcarouter_chat/.gitignore Standard Reflex gitignore entries including .env and .venv/; no concerns.
orcarouter_chat/orcarouter_chat/init.py Empty init file; expected for a Reflex app package.

Reviews (3): Last reviewed commit: "Guard against Clear during streaming" | Re-trigger Greptile

Comment thread orcarouter_chat/orcarouter_chat/orcarouter_chat.py Outdated
Comment thread orcarouter_chat/orcarouter_chat/orcarouter_chat.py Outdated
Comment thread orcarouter_chat/orcarouter_chat/orcarouter_chat.py Outdated
- load_models: run as background task (release state lock during HTTP fetch)
- Enter key submits via rx.form (was: button-only)
- pinned custom models persist across catalog refreshes
- assets/favicon.ico: include reflex default

@masenf masenf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

it might be worthwhile to separate the processing loop and message streaming to its own state. that way other elements on the page won't constantly be re-rendering as the model streams partial messages.

Comment on lines +25 to +34
try:
from dotenv import load_dotenv

_here = Path(__file__).resolve().parent
for candidate in (_here / ".env", _here.parent / ".env"):
if candidate.exists():
load_dotenv(candidate, override=False)
break
except ImportError:
pass

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

reflex does have dotenv handling built in if you have python-dotenv installed.

you have env_file in rxconfig.py, so is this boilerplate actually needed here?

- drop dotenv boilerplate: reflex already loads .env via Config(env_file=".env") + python-dotenv from requirements
- split State into ConfigState (model picker, fallback toggle, catalog) and ChatState (messages, streaming, prompt, tokens) so chunk-by-chunk message updates do not re-render config components
self.prompt = ""
history = list(self.messages[:-1])

config = await self.get_state(ConfigState)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

i tried to submit a prompt and it crashed here

[Reflex Backend Exception]
 Traceback (most recent call last):
  File 
"/Users/masenf/code/reflex-dev/reflex-examples/orcarouter_chat/.venv/lib/python3
.14/site-packages/reflex_base/event/processor/event_processor.py", line 737, in 
_finish_task
    future.result()
    ~~~~~~~~~~~~~^^
  File 
"/Users/masenf/code/reflex-dev/reflex-examples/orcarouter_chat/.venv/lib/python3
.14/site-packages/reflex_base/event/processor/event_processor.py", line 728, in 
_finish_task
    result = task.result()
  File 
"/Users/masenf/code/reflex-dev/reflex-examples/orcarouter_chat/.venv/lib/python3
.14/site-packages/reflex_base/event/processor/event_processor.py", line 565, in 
_process_event_queue_entry
    await self._execute_event(entry=entry, 
registered_handler=registered_handler)
  File 
"/Users/masenf/code/reflex-dev/reflex-examples/orcarouter_chat/.venv/lib/python3
.14/site-packages/reflex_base/event/processor/base_state_processor.py", line 
371, in _execute_event
    await process_event(
    ...<4 lines>...
    )
  File 
"/Users/masenf/code/reflex-dev/reflex-examples/orcarouter_chat/.venv/lib/python3
.14/site-packages/reflex_base/event/processor/base_state_processor.py", line 
238, in process_event
    events = await fn(**payload)
             ^^^^^^^^^^^^^^^^^^^
  File 
"/Users/masenf/code/reflex-dev/reflex-examples/orcarouter_chat/orcarouter_chat/o
rcarouter_chat.py", line 251, in submit
    config = await self.get_state(ConfigState)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File 
"/Users/masenf/code/reflex-dev/reflex-examples/orcarouter_chat/.venv/lib/python3
.14/site-packages/reflex/istate/proxy.py", line 308, in get_state
    raise ImmutableStateError(msg)
reflex_base.utils.exceptions.ImmutableStateError: Background task StateProxy is 
immutable outside of a context manager. Use `async with self` to modify state.

get_state calls have to be done inside async with self

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks, fixed in b6b5006. Moved the get_state call inside async with self. Confirmed locally with a few prompts.

Background-task StateProxy is immutable outside of the locking context;
calling get_state outside `async with self` raises ImmutableStateError
(reported by @masenf running the example locally).
Comment on lines +227 to +245
@rx.event(background=True)
async def submit(self):
async with self:
prompt = self.prompt.strip()
if not prompt or self.streaming:
return
api_key = os.environ.get("ORCAROUTER_API_KEY", "").strip()
if not api_key:
self.error = (
"ORCAROUTER_API_KEY is not set. Create a .env next to "
"rxconfig.py (or export the env var before running) and "
"restart `reflex run`."
)
return
config = await self.get_state(ConfigState)
model = config.model
use_fallback = config.use_fallback_route
fallback_csv = config.fallback_models_csv
self.error = ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 get_state(ConfigState) is awaited on line 241 while the ChatState state lock is still held from async with self: on line 229. Any other event that needs ChatState's lock (e.g., set_prompt as the user types) is blocked until ConfigState's lock is also free. Moving get_state before the async with self: block eliminates this cross-lock hold.

Suggested change
@rx.event(background=True)
async def submit(self):
async with self:
prompt = self.prompt.strip()
if not prompt or self.streaming:
return
api_key = os.environ.get("ORCAROUTER_API_KEY", "").strip()
if not api_key:
self.error = (
"ORCAROUTER_API_KEY is not set. Create a .env next to "
"rxconfig.py (or export the env var before running) and "
"restart `reflex run`."
)
return
config = await self.get_state(ConfigState)
model = config.model
use_fallback = config.use_fallback_route
fallback_csv = config.fallback_models_csv
self.error = ""
@rx.event(background=True)
async def submit(self):
config = await self.get_state(ConfigState)
model = config.model
use_fallback = config.use_fallback_route
fallback_csv = config.fallback_models_csv
async with self:
prompt = self.prompt.strip()
if not prompt or self.streaming:
return
api_key = os.environ.get("ORCAROUTER_API_KEY", "").strip()
if not api_key:
self.error = (
"ORCAROUTER_API_KEY is not set. Create a .env next to "
"rxconfig.py (or export the env var before running) and "
"restart `reflex run`."
)
return
self.error = ""

Rule Used: API calls should be made as background tasks to av... (source)

Learned From
reflex-dev/flexgen#2091

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Keeping get_state inside async with self, since moving it outside the lock raises ImmutableStateError on the StateProxy (already caught earlier on this PR). Clear-during-streaming race is fixed in 1fadc07: early return in clear_chat when streaming is active, plus a length check inside the consume loop.

Radix Themes' Button only greys the element visually when disabled is
set; it still fires on_click. Belt and suspenders:
- visual disabled=ChatState.streaming on the Clear button
- early return in clear_chat when streaming is active
- length and role check in the consume loop so a stray clear cannot
  IndexError on msgs[-1]
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