Skip to content

⚗️(evalap) add a simple OpenAI compatible endpoint to test - #176

Open
qbey wants to merge 1 commit into
mainfrom
qbey/add-evalap-evaluation
Open

⚗️(evalap) add a simple OpenAI compatible endpoint to test#176
qbey wants to merge 1 commit into
mainfrom
qbey/add-evalap-evaluation

Conversation

@qbey

@qbey qbey commented Nov 21, 2025

Copy link
Copy Markdown
Member

Purpose

This provides a quick and dirty way to run evaluation on the Conversations assistant.

Proposal

  • allow EvalAP evaluation

Summary by CodeRabbit

  • New Features

    • Added an OpenAI-compatible chat completions endpoint for evaluation purposes (development and testing environments only).
  • Documentation

    • Comprehensive EvalAP integration guide with configuration steps, setup instructions, and sample code for conducting evaluations with multiple model configurations and feature variations.

✏️ Tip: You can customize this high-level summary in your review settings.

This provide a quick and dirty way to run evaluation on the
Conversations assistant.
@qbey qbey added noChangeLog This does not require a changelog line backend labels Nov 21, 2025
@qbey qbey self-assigned this Nov 21, 2025
@coderabbitai

coderabbitai Bot commented Nov 21, 2025

Copy link
Copy Markdown

Walkthrough

The PR adds an OpenAI-compatible chat completions endpoint (/v1/chat/completions) for development and testing environments, including comprehensive documentation for EvalAP integration setup and a Django view implementation with helper functions for message conversion and response building.

Changes

Cohort / File(s) Summary
Documentation
docs/evaluation.md
Adds comprehensive guide for EvalAP integration including Conversations configuration, LLM setup, Docker host resolution, dataset creation, and sample Python code for interaction with EvalAP endpoints.
URL Routing
src/backend/core/urls.py
Imports ChatCompletionsView and adds a new URL pattern for /v1/chat/completions endpoint, conditionally exposed in development and test environments.
Chat Completions Implementation
src/backend/evaluation/views.py
Introduces ChatCompletionsView class with helper functions: create_openai_response() for building OpenAI-compatible response payloads, openai_messages_to_ui_messages() for converting OpenAI-style messages to internal UIMessage format, and request handling logic for feature flag patching, conversation management, and event aggregation.

Sequence Diagram

sequenceDiagram
    actor Client
    participant ChatCompletionsView
    participant ConversationService
    participant AIAgentService
    participant EventAggregator

    Client->>ChatCompletionsView: POST /v1/chat/completions<br/>(messages, model, feature_flags)
    
    ChatCompletionsView->>ChatCompletionsView: Parse request & validate messages
    ChatCompletionsView->>ChatCompletionsView: Convert OpenAI messages to UIMessage format
    ChatCompletionsView->>ConversationService: Create conversation instance
    ChatCompletionsView->>ChatCompletionsView: Patch feature flags from request
    
    ChatCompletionsView->>AIAgentService: Initialize with conversation
    ChatCompletionsView->>AIAgentService: Start message processing
    
    AIAgentService->>EventAggregator: Stream events (TextPart, FinishMessagePart, usage)
    EventAggregator->>EventAggregator: Accumulate content & usage
    
    EventAggregator->>ChatCompletionsView: Final aggregated result
    ChatCompletionsView->>ChatCompletionsView: Build OpenAI-compatible response
    ChatCompletionsView->>ChatCompletionsView: Cleanup conversation
    
    ChatCompletionsView->>Client: Return response (id, model, content, usage)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Message conversion logic (openai_messages_to_ui_messages): Requires careful validation of OpenAI message format compatibility with internal UIMessage structure, particularly handling of text content and list-of-parts structures.
  • Feature flag patching mechanism: Review how feature flags are dynamically overridden and ensure isolation between requests.
  • Event aggregation in ChatCompletionsView: Verify correct accumulation of TextPart content and usage metrics across event stream.
  • Conversation lifecycle and cleanup: Ensure proper resource cleanup and no state leakage between evaluations.
  • Environment gating: Confirm the conditional routing (development/tests only) is correctly implemented.

Suggested labels

documentation, feature, evaluation

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: adding an OpenAI-compatible endpoint for testing/evaluation purposes, which aligns with the PR's primary objective and the code changes introducing ChatCompletionsView.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch qbey/add-evalap-evaluation

Tip

📝 Customizable high-level summaries are now available in beta!

You can now customize how CodeRabbit generates the high-level summary in your pull requests — including its content, structure, tone, and formatting.

  • Provide your own instructions using the high_level_summary_instructions setting.
  • Format the summary however you like (bullet lists, tables, multi-section layouts, contributor stats, etc.).
  • Use high_level_summary_in_walkthrough to move the summary from the description to the walkthrough section.

Example instruction:

"Divide the high-level summary into five sections:

  1. 📝 Description — Summarize the main change in 50–60 words, explaining what was done.
  2. 📓 References — List relevant issues, discussions, documentation, or related PRs.
  3. 📦 Dependencies & Requirements — Mention any new/updated dependencies, environment variable changes, or configuration updates.
  4. 📊 Contributor Summary — Include a Markdown table showing contributions:
    | Contributor | Lines Added | Lines Removed | Files Changed |
  5. ✔️ Additional Notes — Add any extra reviewer context.
    Keep each section concise (under 200 words) and use bullet or numbered lists for clarity."

Note: This feature is currently in beta for Pro-tier users, and pricing will be announced later.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (4)
docs/evaluation.md (2)

3-19: Tighten wording in the intro/warning block for clarity.

Minor nits only:

  • Consider using “OpenAI-compatible” (no space) consistently.
  • The sentence with “etc)” would read better as “etc.).” or by removing “etc” entirely.
  • You may want to explicitly state here that stream is ignored and only simple, non‑streaming chat completion requests are supported (even though it’s implied later).

32-99: Clarify how the model parameter in /v1/chat/completions maps to these LLM configurations.

Right now the JSON shows model HRIDs like "mistral-medium-2508-raw" / "mistral-medium-2508-with-web-search", but the doc doesn’t explicitly say “this is what you must send as the model value in the OpenAI‑like request body.”

Adding a short note such as “When calling /v1/chat/completions, set model to one of these hrid values” would make the end‑to‑end flow easier to follow and reduce guesswork when wiring EvalAP.

src/backend/evaluation/views.py (2)

138-151: Global monkey‑patching of is_feature_enabled is fragile under concurrency.

Overriding core.feature_flags.helpers.is_feature_enabled at module level per request means:

  • Concurrent evaluations can interfere with each other (the last request to patch “wins”, and all in‑flight runs start using that request’s feature_flags).
  • Any other code paths in the same process that rely on feature flags (e.g. normal chat UI in dev) will see the overridden behavior while an eval request is running.

Given this is labeled “quick and dirty” and dev/test‑only, you might accept the risk for strictly sequential runs, but it’s still worth at least calling out or tightening if you expect EvalAP to run multiple jobs in parallel.

Safer options (even if deferred):

  • Thread/task‑local or context‑local feature‑flag overrides instead of a global monkey‑patch.
  • Passing feature_flags directly into AIAgentService / the agent stack.
  • If you keep the monkey‑patch, wrap it in a try/finally and restore the original function at the end of the request to limit the blast radius.

158-197: Ensure temporary conversations are always cleaned up, even on errors.

You create a transient ChatConversation and delete it at the end:

conversation = await ChatConversation.objects.acreate(...)
...
await conversation.adelete()

If _run_agent or anything in between raises, the delete is skipped and the DB will accumulate leftover eval conversations over time.

Wrapping the agent run and response construction in try/finally would avoid leaks:

-        # Create a new conversation
-        conversation = await ChatConversation.objects.acreate(
-            owner=user,
-            messages=[],
-            pydantic_messages=[],
-        )
+        # Create a new conversation (ephemeral, for this eval request only)
+        conversation = await ChatConversation.objects.acreate(
+            owner=user,
+            messages=[],
+            pydantic_messages=[],
+        )
@@
-        async for event in service._run_agent(ui_messages):
-            ...
-
-        response_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
-        response = JsonResponse(
-            create_openai_response(response_id, model, full_content, "stop", final_usage)
-        )
-
-        # Remove the conversation to avoid accumulation
-        await conversation.adelete()
-
-        return response
+        try:
+            async for event in service._run_agent(ui_messages):
+                ...
+
+            response_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
+            response = JsonResponse(
+                create_openai_response(response_id, model, full_content, "stop", final_usage)
+            )
+            return response
+        finally:
+            # Remove the conversation to avoid accumulation
+            await conversation.adelete()

This keeps the DB clean while still treating conversations as ephemeral for eval.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 93ee3cd and 995f3f2.

📒 Files selected for processing (3)
  • docs/evaluation.md (1 hunks)
  • src/backend/core/urls.py (2 hunks)
  • src/backend/evaluation/views.py (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
src/backend/evaluation/views.py (5)
src/backend/chat/ai_sdk_types.py (2)
  • TextUIPart (137-147)
  • UIMessage (272-280)
src/backend/chat/clients/pydantic_ai.py (2)
  • AIAgentService (103-811)
  • _run_agent (354-742)
src/backend/chat/models.py (1)
  • ChatConversation (18-75)
src/backend/chat/vercel_ai_sdk/core/events_v4.py (1)
  • TextPart (49-61)
src/backend/conversations/settings.py (1)
  • ENVIRONMENT (924-926)
src/backend/core/urls.py (2)
src/backend/evaluation/views.py (1)
  • ChatCompletionsView (101-198)
src/backend/conversations/settings.py (1)
  • ENVIRONMENT (924-926)
🪛 LanguageTool
docs/evaluation.md

[style] ~13-~13: In American English, abbreviations like “etc.” require a period.
Context: ...tputs (no streaming, no function calls, etc). The result returned will already have...

(ETC_PERIOD)


[style] ~23-~23: You have already used this phrasing in nearby sentences. Consider replacing it to add variety to your writing.
Context: ...t you want to run. For instance, if you want to compare the Agent answer with and witho...

(REP_WANT_TO_VB)

🪛 markdownlint-cli2 (0.18.1)
docs/evaluation.md

29-29: Dollar signs used before commands without showing output

(MD014, commands-show-output)


108-108: Dollar signs used before commands without showing output

(MD014, commands-show-output)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: test-e2e-chromium
  • GitHub Check: test-back
  • GitHub Check: lint-back
  • GitHub Check: build-and-push-frontend
  • GitHub Check: build-and-push-backend
🔇 Additional comments (2)
src/backend/core/urls.py (1)

13-14: Dev/test‑only routing for /v1/chat/completions looks consistent.

The import and conditional URL pattern line up with the docs and with the environment check inside ChatCompletionsView, and the path matches the examples (/v1/chat/completions on port 8071). No issues from a routing or exposure standpoint.

Also applies to: 46-51

src/backend/evaluation/views.py (1)

33-61: OpenAI‑style response builder is minimal but sufficient for non‑streaming EvalAP usage.

The payload shape (id, object, created, model, single choice, usage) matches what EvalAP and most OpenAI‑compatible tooling expect for a simple, non‑streaming chat completion. Given the “quick and dirty / evaluation‑only” scope, not exposing extra fields (e.g. logprobs) is reasonable.

Comment thread docs/evaluation.md
Comment on lines +117 to +211
I needed to update the Docker compose file to add:

```yaml
extra_hosts:
- "host.docker.internal:host-gateway"
```

Globally I followed the instructions in the EvalAP documentation, had a few issues with the stack initialization
but finally managed to run it with.

### Create the dataset

Read the EvalAP documentation to create a new dataset. I did a simple dataset with only two samples to check
the evaluation works.

### Create the evaluation

Same as before, read the EvalAP documentation to create a new evaluation.

The important part is to configure the model to call Conversations, and use the extra parameters to
adapt feature flags if needed.

```python
import requests

# Replace with your Evalap API endpoint
API_URL = "http://localhost:8000/v1"

# Replace with your API key or authentication token
HEADERS = {
"Authorization": "Bearer YOUR_API_KEY",
"Content-Type": "application/json"
}

# Define your experiment set with CV schema
expset_name = "model_comparison_v1"
expset_readme = "Comparing performance of various LLMs on a QA dataset."
metrics = ["judge_precision", "output_length", "generation_time"]

# Parameters common to all experiments
common_params = {
"dataset": "Dataset Bidon", # assuming this dataset has been added before
#"model": {"sampling_params": {"temperature": 0.2}},
"metrics": metrics,
"judge_model": "albert-large",
}

# Parameters that will vary across experiments
grid_params = {
"model": [
{
"name": "etalab-plateform-mistral-medium-2508",
"aliased_name": "Mistral Medium",
# base_url points to Conversations API
"base_url": f"http://host.docker.internal:8071/v1",
"api_key": "plop",
"extra_params": {
"feature_flags": {
# Disable RAG tool
"tool_rag_french_public_services": "DISABLED",
},
},
},
{
"name": "etalab-plateform-mistral-medium-2508",
"aliased_name": "Mistral Medium + RAG",
"base_url": f"http://host.docker.internal:8071/v1",
"api_key": "plop",
"extra_params": {
"feature_flags": {
# Enable RAG tool
"tool_rag_french_public_services": "ENABLED",
},
},
},
],
}

# Create the experiment set with CV schema
expset = {
"name": expset_name,
"readme": expset_readme,
"cv": {
"common_params": common_params,
"grid_params": grid_params,
"repeat": 3 # Run each combination 3 times to measure variability
}
}

# Launch the experiment set
requests.delete(f'{API_URL}/experiment_set/12', json=expset, headers=HEADERS)
response = requests.post(f'{API_URL}/experiment_set', json=expset, headers=HEADERS)
expset_id = response.json()["id"]
print(f"Experiment set {expset_id} is running")
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Avoid hard‑coded DELETE /experiment_set/12 in the example script.

As written:

requests.delete(f'{API_URL}/experiment_set/12', json=expset, headers=HEADERS)

anyone copy‑pasting this might accidentally delete a real experiment set in their EvalAP instance. At minimum, this deserves a strong comment or should be removed from the “happy path” example.

You could, for example, either:

  • Comment it out with an explanation (“uncomment if you really want to wipe experiment_set 12”), or
  • Replace 12 with a placeholder and a warning in the surrounding text.
🤖 Prompt for AI Agents
In docs/evaluation.md around lines 117 to 211, the example script contains a
hard-coded destructive call requests.delete(f'{API_URL}/experiment_set/12')
which can accidentally delete real data; remove that line from the “happy path”
example (or comment it out) and instead show a safe alternative: use a clearly
named placeholder variable (e.g. EXPERIMENT_SET_ID) or wrap the delete in an
explicit conditional/confirmation and add a one-line warning in the surrounding
text explaining that delete is destructive and must be used intentionally.
Ensure the example posted for copy-paste does not perform destructive actions by
default.

Comment on lines +64 to +97
def openai_messages_to_ui_messages(openai_messages: List[dict]) -> List[UIMessage]:
"""
Convert OpenAI message format to UIMessage format for the backend view.
"""
ui_messages = []
for msg in openai_messages:
role = msg.get("role", "user")
content = msg.get("content", "")

# Handle content that can be string or list of content parts
if isinstance(content, list):
parts = []
for part in content:
if part.get("type") == "text":
parts.append(TextUIPart(type="text", text=part.get("text", "")))
# Add handling for images, etc. as needed
ui_messages.append(
UIMessage(
id=msg.get("id", str(uuid.uuid4())),
role=role,
parts=parts,
content="".join(content),
)
)
else:
ui_messages.append(
UIMessage(
id=msg.get("id", str(uuid.uuid4())),
role=role,
parts=[TextUIPart(type="text", text=content or "")],
content=content or "",
)
)
return ui_messages

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Bug: "".join(content) on a list of content parts will raise at runtime.

When content is a list, it’s a list of dicts (OpenAI content parts), so:

content="".join(content)

will raise TypeError: sequence item 0: expected str instance, dict found.

You probably want to concatenate only the text pieces you already extracted into TextUIParts.

A safe fix would look like:

-    for msg in openai_messages:
+    for msg in openai_messages:
         role = msg.get("role", "user")
         content = msg.get("content", "")
@@
-        if isinstance(content, list):
-            parts = []
-            for part in content:
-                if part.get("type") == "text":
-                    parts.append(TextUIPart(type="text", text=part.get("text", "")))
-                # Add handling for images, etc. as needed
-            ui_messages.append(
-                UIMessage(
-                    id=msg.get("id", str(uuid.uuid4())),
-                    role=role,
-                    parts=parts,
-                    content="".join(content),
-                )
-            )
+        if isinstance(content, list):
+            parts: List[TextUIPart] = []
+            text_chunks: List[str] = []
+            for part in content:
+                if part.get("type") == "text":
+                    text = (part.get("text") or "")
+                    parts.append(TextUIPart(type="text", text=text))
+                    text_chunks.append(text)
+                # TODO: handle non-text parts (images, etc.) if needed later
+
+            ui_messages.append(
+                UIMessage(
+                    id=msg.get("id", str(uuid.uuid4())),
+                    role=role,
+                    parts=parts,
+                    content="".join(text_chunks),
+                )
+            )

This keeps the UIMessage parts and string content consistent for the common text‑only case.

🤖 Prompt for AI Agents
In src/backend/evaluation/views.py around lines 64 to 97, when handling the case
where message["content"] is a list the code incorrectly calls "".join(content)
on a list of dicts which raises a TypeError; instead build the parts list from
text parts (as you already do) and create a content_str by concatenating the
text values from those parts (e.g., join the TextUIPart.text values), then pass
that content_str into UIMessage.content so the UIMessage.parts and content are
consistent and no TypeError occurs.

Comment on lines +115 to +137
async def post(self, request):
"""
Handle POST requests to the chat completions endpoint.
"""
if settings.ENVIRONMENT not in ["development", "tests"]:
return JsonResponse(
{"error": "This endpoint is for evaluation purposes only."}, status=403
)

# Enforce the user
user = await User.objects.aget(email="conversations@conversations.world")

# Parse request body to get parameters
try:
body = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({"error": "Invalid JSON"}, status=400)

# Extract parameters
model = body.get("model", "default-model")
messages = body.get("messages", [])
feature_flags = body.get("feature_flags", {})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Handle missing seed user more gracefully.

User.objects.aget(email="conversations@conversations.world") will 500 if the demo/seed user does not exist (or is duplicated), which is easy to hit when someone spins up the stack without running the demo target first.

For a smoother DX, consider catching this and returning a clear error:

-        # Enforce the user
-        user = await User.objects.aget(email="conversations@conversations.world")
+        # Enforce the user used for evaluation
+        try:
+            user = await User.objects.aget(email="conversations@conversations.world")
+        except User.DoesNotExist:
+            return JsonResponse(
+                {
+                    "error": (
+                        "Evaluation user 'conversations@conversations.world' not found. "
+                        "Did you run `make demo` to seed demo data?"
+                    )
+                },
+                status=500,
+            )

That makes misconfiguration obvious instead of surfacing as an opaque 500.

🤖 Prompt for AI Agents
In src/backend/evaluation/views.py around lines 115-137, the code directly
awaits User.objects.aget(email="conversations@conversations.world") which will
raise exceptions (DoesNotExist or MultipleObjectsReturned) and produce an opaque
500; wrap the aget call in a try/except that catches User.DoesNotExist and
User.MultipleObjectsReturned (importing them if needed), return a clear
JsonResponse (e.g. status 400 or 422) with a message like "Seed user
'conversations@conversations.world' not found or duplicated; run demo seeding"
and optionally log the exception for troubleshooting so the endpoint fails with
a helpful error instead of an opaque 500.

@sonarqubecloud

Copy link
Copy Markdown

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

Labels

backend noChangeLog This does not require a changelog line

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant