Skip to content

Arena

src.domains.arena.endpoints

REST API endpoints for stateless LLM testing in the arena.

The arena provides a lightweight query interface for testing models without creating conversations or storing history. Useful for quick model comparisons, experimentation, and benchmarking.

Key Features: - Stateless: No conversation history or message storage. - Streaming: Real-time token generation via Server-Sent Events. - KB-aware: Automatically injects relevant context from attached Knowledge Bases. - Customizable: Supports temperature, top_p, and custom instructions. The output budget is not a request parameter: the backend derives it from the context window the model runs in (see docs/guides/conversations.md).

Architecture

┌──────────────┐ │ POST /arena/ │ │ {llm_id}/query│ └───────┬──────┘ │ (1) Validate llm_id + payload (ArenaQueryPayload) ↓ ┌──────────────┐ │ ArenaService │ ← get_prompting_strategy(param_size) │.query_llm_ │ ← build_agent_system_prompt() + KB context │ stream() │ ← AgentRunner (stateless) → ChatOpenAI(base_url) └───────┬──────┘ │ (2) Yield tokens via StreamingResponse ↓ ┌──────────────┐ │ Client │ ← Receives text/plain stream └──────────────┘

Use Cases
  • Model comparison: Query 2+ models with identical prompt, compare outputs.
  • Quick testing: Test custom prompts without creating conversations.
  • Benchmarking: Measure generation speed and quality for different models.
Endpoints
  • POST /arena/{llm_id}/query → Stream stateless response for given question.
Example

POST /arena/42/query { "question": "Explain quantum entanglement in simple terms.", "temperature": 0.7, "top_p": 0.9, "custom_prompt": "Use analogies suitable for a 10-year-old." } Response: StreamingResponse(text/plain) → "Imagine two magic coins..."

get_arena_service(db=Depends(get_db))

Dependency injection provider for ArenaService.

Parameters:

Name Type Description Default
db Session

Database session injected by FastAPI.

Depends(get_db)

Returns:

Type Description
ArenaService

Configured ArenaService instance with database access.

Source code in backend/src/domains/arena/endpoints.py
def get_arena_service(db: Session = Depends(get_db)) -> ArenaService:
    """Dependency injection provider for ArenaService.

    Args:
        db: Database session injected by FastAPI.

    Returns:
        Configured ArenaService instance with database access.
    """
    return ArenaService(db)

query_arena(llm_id, payload, service=Depends(get_arena_service)) async

Stream stateless LLM response without conversation history.

Queries a model with a single question, no context from previous messages. Useful for quick testing, benchmarking, and model comparison. Automatically injects KB context if the model has a Knowledge Base attached.

Parameters:

Name Type Description Default
llm_id int

Database ID of the LLM to query.

required
payload ArenaQueryPayload

Query request with question, temperature, top_p, custom_prompt.

required
service ArenaService

ArenaService instance injected by FastAPI.

Depends(get_arena_service)

Returns:

Name Type Description
StreamingResponse

Real-time token stream (text/plain).

Raises:

Type Description
HTTPException

404 if llm_id not found, 500 on model loading or generation errors.

Example

POST /arena/42/query { "question": "What is the Heisenberg uncertainty principle?", "temperature": 0.7, "top_p": 0.9, "custom_prompt": "Explain like I'm 5 years old." } Response: StreamingResponse → "Imagine you have a tiny ball..."

Source code in backend/src/domains/arena/endpoints.py
@router.post("/{llm_id}/query")
async def query_arena(
    llm_id: int, payload: ArenaQueryPayload, service: ArenaService = Depends(get_arena_service)
):
    """Stream stateless LLM response without conversation history.

    Queries a model with a single question, no context from previous messages. Useful
    for quick testing, benchmarking, and model comparison. Automatically injects KB
    context if the model has a Knowledge Base attached.

    Args:
        llm_id: Database ID of the LLM to query.
        payload: Query request with question, temperature, top_p, custom_prompt.
        service: ArenaService instance injected by FastAPI.

    Returns:
        StreamingResponse: Real-time token stream (text/plain).

    Raises:
        HTTPException: 404 if llm_id not found, 500 on model loading or generation errors.

    Example:
        POST /arena/42/query
        {
            "question": "What is the Heisenberg uncertainty principle?",
            "temperature": 0.7,
            "top_p": 0.9,
            "custom_prompt": "Explain like I'm 5 years old."
        }
        Response: StreamingResponse → "Imagine you have a tiny ball..."
    """
    logger.info(f"Arena query request for LLM {llm_id}")

    # Validate LLM exists BEFORE starting StreamingResponse
    # (StreamingResponse returns 200 immediately, exceptions inside async generator are lost)
    service._get_llm(llm_id)

    return StreamingResponse(
        service.query_llm_stream(llm_id, payload),
        media_type="text/plain",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",
        },
    )

src.domains.arena.schemas

Pydantic validation schemas for arena stateless queries.

Defines request schemas for the arena domain, validating query parameters and generation settings for stateless LLM testing.

Example

from src.domains.arena.schemas import ArenaQueryPayload

query = ArenaQueryPayload( question="What is quantum computing?", temperature=0.7, top_p=0.9, custom_prompt="Use simple language" )

ArenaQueryPayload

Bases: BaseModel

Request schema for stateless arena queries with generation parameters.

Validates user input for arena LLM queries, enforcing ranges for sampling parameters and ensuring non-empty questions. Includes optional custom instructions for specialized prompting.

Attributes:

Name Type Description
question str

The question/prompt to send to the model (trimmed; may be empty only when images or documents are attached).

images Optional[List[str]]

Optional base64 data-URL images attached to the question (vision models).

attachments Optional[List[str]]

Local filesystem paths of documents/folders attached to the question.

temperature Optional[float]

Sampling temperature (0.0=deterministic, 2.0=creative, default=0.1).

top_p Optional[float]

Nucleus sampling threshold (0.0-1.0, default=0.5).

max_new_tokens Optional[int]

Accepted and ignored -- the output budget comes from the model's context window, not from the client.

custom_prompt Optional[str]

Optional additional instructions appended to system prompt.

Example

payload = ArenaQueryPayload( ... question="Explain relativity", ... temperature=0.7, ... top_p=0.9, ... custom_prompt="Use analogies" ... )

validate_question_or_attachments()

Ensure the turn carries text, at least one image, or a document.

Image-only asks are valid vision-model turns (mirrors conversations,

136 C) and a document-only ask is a valid "what does this say?" turn

(#492); a fully empty ask is still rejected.

Returns:

Type Description

The payload with a trimmed question.

Raises:

Type Description
ValueError

If question is empty/whitespace and nothing is attached.

Source code in backend/src/domains/arena/schemas.py
@model_validator(mode="after")
def validate_question_or_attachments(self):
    """Ensure the turn carries text, at least one image, or a document.

    Image-only asks are valid vision-model turns (mirrors conversations,
    #136 C) and a document-only ask is a valid "what does this say?" turn
    (#492); a fully empty ask is still rejected.

    Returns:
        The payload with a trimmed question.

    Raises:
        ValueError: If question is empty/whitespace and nothing is attached.
    """
    self.question = self.question.strip()
    if not self.question and not self.images and not self.attachments:
        raise ValueError("Question cannot be empty or whitespace")
    return self

src.domains.arena.services

Business logic for stateless arena LLM queries with KB-aware prompting.

The arena is "conversation minus state": a single-model, no-history streaming query. Since the LangChain refactor it shares the conversation AgentRunner but runs it statelessly (thread_id=None, no checkpointer, no summarization).

Pipeline: 1. Validate the question and fetch the LLM. 2. Pick a prompting strategy (param_size-based) and, if the model has a KB attached, retrieve top-k chunks (hybrid pgvector search via kb_utils). 3. Build a real system prompt (size-adaptive + custom + KB context) and stream the answer as raw token text via the shared agent runner.

The A/B "duel" is orchestrated entirely on the frontend (N parallel calls to this endpoint); the runner's engine guard serializes them on the single-model engine, so model swaps don't thrash the subprocess.

ArenaService(db)

Service layer for arena stateless query processing.

Initialize arena service with database session.

Parameters:

Name Type Description Default
db

SQLAlchemy database session for repository access.

required
Source code in backend/src/domains/arena/services.py
def __init__(self, db):
    """Initialize arena service with database session.

    Args:
        db: SQLAlchemy database session for repository access.
    """
    logger.debug("Initializing ArenaService")
    self.arena_repo = ArenaRepository(db)
    self.runner = AgentRunner(checkpointer=None)  # stateless: no history persisted
    self.db = db

query_llm_stream(llm_id, payload) async

Query an LLM in stateless arena mode and stream response tokens (raw text).

Raises:

Type Description
InvalidInputException

empty question.

ModelNotFoundException

llm_id not found (via _get_llm; the endpoint also validates eagerly before opening the stream).

KnowledgeBase * Exception

KB retrieval failure.

Generation/model-load failures are NOT raised here — the runner yields the [ERROR_MESSAGE_SYSTEM] sentinel inline (unified with conversation).

Source code in backend/src/domains/arena/services.py
async def query_llm_stream(
    self,
    llm_id: int,
    payload: ArenaQueryPayload,
) -> AsyncGenerator[str, None]:
    """Query an LLM in stateless arena mode and stream response tokens (raw text).

    Raises:
        InvalidInputException: empty question.
        ModelNotFoundException: ``llm_id`` not found (via ``_get_llm``; the
            endpoint also validates eagerly before opening the stream).
        KnowledgeBase*Exception: KB retrieval failure.

    Generation/model-load failures are NOT raised here — the runner yields the
    ``[ERROR_MESSAGE_SYSTEM]`` sentinel inline (unified with conversation).
    """
    if not payload.question.strip() and not payload.images and not payload.attachments:
        raise InvalidInputException("question")

    start_s = time.perf_counter()
    # Local-first policy: log the question content (bounded), never image
    # bytes — only their count and encoded size (mirrors conversations).
    image_note = ""
    if payload.images:
        total_b64_chars = sum(len(url) for url in payload.images)
        image_note = f", images={len(payload.images)} ({total_b64_chars} base64 chars)"
    if payload.attachments:
        image_note += f", attachments={len(payload.attachments)}"
    logger.info(
        f"Arena query started for LLM {llm_id}{image_note}: "
        f"{truncate_for_log(payload.question, 2000)}"
    )
    llm = self._get_llm(llm_id)

    param_size = llm.param_size if getattr(llm, "param_size", None) else 2
    strategy = get_prompting_strategy(param_size)

    # Derive the turn's mode (plain / systematic-KB / agentic-KB) from the
    # model's tool-calling capability (#84). Retrieval is injected so it runs
    # only in systematic mode and keeps arena's raise-on-failure policy.
    # #310: arena panels have no conversation row, so the web toggle follows
    # the GLOBAL user setting directly (kept deliberately simple).
    settings_repo = User_Settings_Repository(self.db)
    web_search_enabled = await run_in_threadpool(settings_repo.get_web_search_enabled)
    # 1.1.2: same reasoning -- an arena panel has no conversation row, so
    # its reasoning effort is the GLOBAL default, read per turn.
    effort_level = await run_in_threadpool(settings_repo.get_default_reasoning_effort)
    effort_plan = await run_in_threadpool(plan_reasoning_effort, llm, effort_level)
    plan = await run_in_threadpool(
        plan_turn,
        llm,
        question=payload.question,
        retrieve=lambda: self._retrieve_kb_excerpts(llm, payload.question, strategy),
        custom_prompt=payload.custom_prompt,
        web_search_enabled=web_search_enabled,
        effort_section=effort_plan.prompt_section,
    )

    # Omitted values resolve to the MODEL's defaults (#388); explicit wins.
    defaults = resolve_sampling_defaults(llm)
    params = GenParams(
        temperature=defaults.temperature
        if payload.temperature is None
        else payload.temperature,
        top_p=defaults.top_p if payload.top_p is None else payload.top_p,
        # Only the FALLBACK output budget (same contract as a conversation):
        # the per-call budget computed from the running window
        # (``src.agents.output_budget``) overrides it whenever the engine
        # reports one, and this value is what an engine that reports none
        # runs with. ``payload.max_new_tokens`` is deliberately not read --
        # the arena panel no longer offers the control, and the field stays
        # accepted only so an older client is not rejected.
        max_tokens=defaults.max_tokens,
    )

    # Safety net (#133/#212): unless the model is positively vision-capable,
    # the runner strips images before inference — tell the user up front
    # instead of silently dropping the attachment.
    supports_vision = await run_in_threadpool(detect_supports_vision, llm.link)

    # Documents attached to this question (#492): the same resolver the
    # conversation uses, so both paths read files exactly once, the same way.
    attachments = await run_in_threadpool(resolve_attachments, payload.attachments)

    response = ""
    if payload.images and supports_vision is not True:
        response += IMAGES_IGNORED_NOTICE
        yield IMAGES_IGNORED_NOTICE
    attachment_notice = build_attachment_notice(attachments)
    if attachment_notice:
        response += attachment_notice
        yield attachment_notice
    # ``aclosing``: whenever THIS generator is closed, the runner is closed
    # with it, inside the guard. Starlette never closes a body iterator
    # itself: a disconnect noticed while ``send()`` is in progress leaves
    # this generator suspended at its yield, and closing it then still
    # depends on its finalization.
    async with contextlib.aclosing(
        self.runner.astream_text(
            llm=llm,
            user_message=self._build_user_message(
                prepend_attachment_block(attachments.block, payload.question), payload.images
            ),
            system_prompt=plan.system_prompt,
            params=params,
            thread_id=None,
            summarize=False,
            kb_context_block=plan.kb_context_block,
            kb_language_line=plan.kb_language_line,
            tools=plan.tools,
            context=plan.context,
            supports_vision=supports_vision,
            effort_plan=effort_plan,
        )
    ) as tokens:
        async for token in tokens:
            response += token
            yield token

    duration_ms = (time.perf_counter() - start_s) * 1000
    logger.info(
        f"Arena query completed for LLM {llm_id} "
        f"(model={getattr(llm, 'name', '?')}): duration_ms={duration_ms:.0f}, "
        f"response_chars={len(response)}, "
        f"preview={truncate_for_log(response, 500)}"
    )