Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ graph TD

## 1b. Container Health Checks & Auto-Restart

All core containers are configured with health checks in the Quadlet templates under [`quadlets/`](quadlets/). The legacy [`pod.yaml`](pod.yaml) is retained as a compatibility template. Quadlet `HealthOnFailure=kill` together with systemd `Restart=always` enables automatic recovery from unhealthy containers.
All core containers are configured with health checks in the Quadlet templates under [`quadlets/`](https://github.com/sheepdestroyer/LLM-Routing/tree/main/quadlets). The legacy [`pod.yaml`](https://github.com/sheepdestroyer/LLM-Routing/blob/main/pod.yaml) is retained as a compatibility template. Quadlet `HealthOnFailure=kill` together with systemd `Restart=always` enables automatic recovery from unhealthy containers.

| Container | Liveness Probe | Readiness Probe |
|:---|---:|---:|
Expand Down Expand Up @@ -770,7 +770,7 @@ To maximize throughput under concurrent queries, `llama-server` is configured wi
#### 3. Custom Memory Endpoint Proxy & MCP Server
To allow Goose (and other agents) to store, list, and delete persistent preference/factual memories, we implemented a custom memory stack:
* **Triage Router Memory Proxy**: Exposes a catch-all route `@app.api_route("/v1/memory{path:path}", methods=["GET", "POST", "DELETE", "PUT"])` in `router/main.py` that intercepts memory calls and proxies them to the LiteLLM gateway (port 4000) using the securely-loaded `LITELLM_MASTER_KEY` authorization.
* **Memory MCP Bridge Server**: Created a custom stdio MCP server in [memory_mcp.py](router/memory_mcp.py) that exposes the `rememberMemory`, `retrieveMemories`, and `removeSpecificMemory` tools. The script proxies these commands directly to `http://localhost:5000/v1/memory`.
* **Memory MCP Bridge Server**: Created a custom stdio MCP server in [memory_mcp.py](https://github.com/sheepdestroyer/LLM-Routing/blob/main/router/memory_mcp.py) that exposes the `rememberMemory`, `retrieveMemories`, and `removeSpecificMemory` tools. The script proxies these commands directly to `http://localhost:5000/v1/memory`.
* **Goose Integration**: The built-in memory extension is disabled in `~/.config/goose/config.yaml` and replaced with the `litellm-memory` custom command-line extension running our bridge server.

## 9c. Ollama Proxy Integration (via LiteLLM ollama_chat)
Expand Down Expand Up @@ -816,10 +816,31 @@ For auto-routing modes, the Triage Router handles failures by silently falling b
## 9d. Live Stack Tier Testing & Verification

The repository includes an automated integration script to test the 5-tier intent routing pipeline on the live gateway stack:
* **Location**: [verify_reasoning_tiers.py](scripts/verification/verify_reasoning_tiers.py)
* **Location**: [verify_reasoning_tiers.py](https://github.com/sheepdestroyer/LLM-Routing/blob/main/scripts/verification/verify_reasoning_tiers.py)

This script acts as an end-to-end routing smoke test by sending five sequential chat completion requests (from simple to advanced prompt complexities) to the gateway's `llm-routing-auto-free` auto-triage route, verifying that:
1. The gateway successfully routes the prompt to the expected LiteLLM model group or provider.

## 9e. Home Assistant Integration & Responses API Support

LLM-Routing provides full compatibility with Home Assistant's `openai_conversation` official integration, supporting both legacy Chat Completions (`/v1/chat/completions`) and the OpenAI Responses API (`/v1/responses` and `/responses`).

### Configuration in Home Assistant
* **Base URL**: `https://litellm.x570.vendeuvre.lan/v1` (Production) or `https://dev.vendeuvre.lan/v1` (Dev)
* **API Key**: Any valid LiteLLM key or master key
* **Supported Models for Home Assistant**:
* `local-qwen-3.6-hass` (Recommended: local Qwen model with thinking disabled for fast Assist action responses)
* `local-qwen-3.6` (Local Qwen model with preserve_thinking enabled)
* `gpt-4o-mini` (Model alias provided for Home Assistant configuration flows & AI task options)
* `gpt-4o` (Model alias provided for high-capability task options)
* `llm-routing-auto-free` (Automatic complexity classification across free tiers)

### Responses API & Tools Compatibility
* **Endpoints**: Exposed on both `POST /v1/responses` and `POST /responses`.
* **Home Location Lookup**: `client.responses.create` calls issued during HA configuration using `gpt-4o-mini` succeed without `Invalid model name` errors.
* **Assist Tools / Function Calling**: Fully supported. Home Assistant tool definitions (`HassTurnOn`, `HassTurnOff`, custom intent schemas) are correctly formatted and returned as `function_call` output items.
* **Code Interpreter**: Supported; request/response events pass through to compatible backends without schema rejection.
* **Web Search**: Passed through gracefully to backends supporting web search; local models return informative responses if search capability is not enabled on the underlying model.
2. The responses are returned successfully with acceptable latency.

### How to Run
Expand Down
54 changes: 54 additions & 0 deletions litellm/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ litellm_settings:
- gpt-4o-mini-transcribe
- gpt-4o-mini-tts
- tts-1
- local-qwen-3.6
- local-qwen-3.6-hass
- gpt-4o-mini
- gpt-4o
fallbacks:
- agent-simple-core:
- agent-medium-core
Expand Down Expand Up @@ -100,6 +104,14 @@ model_list:
preserve_thinking: true
request_timeout: 600
model_name: local-qwen-3.6
model_info:
supports_vision: true
supports_reasoning: true
supports_function_calling: true
mode: chat
max_tokens: 524288
max_input_tokens: 524288
is_public_model_group: true
- litellm_params:
api_base: LLAMA_CLASSIFIER_URL_PLACEHOLDER
api_key: local-token
Expand All @@ -109,6 +121,48 @@ model_list:
enable_thinking: false
request_timeout: 600
model_name: local-qwen-3.6-hass
model_info:
supports_vision: true
supports_reasoning: true
supports_function_calling: true
mode: chat
max_tokens: 524288
max_input_tokens: 524288
is_public_model_group: true
- litellm_params:
api_base: LLAMA_CLASSIFIER_URL_PLACEHOLDER
api_key: local-token
model: openai/local-qwen-3.6-hass
extra_body:
chat_template_kwargs:
enable_thinking: false
request_timeout: 600
model_name: gpt-4o-mini
model_info:
supports_vision: true
supports_reasoning: true
supports_function_calling: true
mode: chat
max_tokens: 524288
max_input_tokens: 524288
is_public_model_group: true
- litellm_params:
api_base: LLAMA_CLASSIFIER_URL_PLACEHOLDER
api_key: local-token
model: openai/local-qwen-3.6-hass
extra_body:
chat_template_kwargs:
enable_thinking: false
request_timeout: 600
model_name: gpt-4o
model_info:
supports_vision: true
supports_reasoning: true
supports_function_calling: true
mode: chat
max_tokens: 524288
max_input_tokens: 524288
is_public_model_group: true
Comment on lines +124 to +165

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.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does LiteLLM's model_info supports_reasoning flag change request/response handling when true vs false for OpenAI-compatible backends?

💡 Result:

In LiteLLM, the supports_reasoning flag (found in model_info and accessed via litellm.supports_reasoning(model)) is primarily a capability indicator used by LiteLLM’s internal logic to determine if a model supports reasoning-related parameters (such as thinking blocks or reasoning effort) [1][2]. It does not automatically change request/response handling solely based on its true/false value; rather, it is a metadata lookup that informs whether LiteLLM permits or attempts to translate specific reasoning parameters for that model [1][3][4]. When using OpenAI-compatible backends, the presence and state of this flag influence behavior in the following ways: 1. Parameter Validation: LiteLLM uses this flag to validate requests [5][4]. If you attempt to pass reasoning-specific parameters (like include_reasoning or reasoning_effort) to a model where supports_reasoning is false, LiteLLM may raise an error (e.g., UnsupportedParamsError) or fail to process the request correctly because the model is not registered as supporting those features [3][5]. 2. Parameter Transformation: When set to true, LiteLLM’s internal logic is more likely to permit and correctly transform or pass through reasoning-related fields (like thinking blocks) to the backend provider [1][4]. If the flag is false, LiteLLM may strip these parameters or fail to map them, depending on the current configuration and the use of parameters like drop_params [1][5]. 3. Automated Bridging: For some providers, when a model with supports_reasoning=true is used in conjunction with specific tools or parameters, LiteLLM may automatically route the request through internal bridges (such as the Responses API bridge) to ensure compatibility [1][6]. In summary, the flag acts as a gatekeeper for reasoning features within the LiteLLM library [1][2]. Setting it to true tells LiteLLM that it is valid to attempt reasoning-based interactions with that specific model, whereas setting it to false restricts those capabilities to prevent invalid parameter errors when communicating with backends that do not support them [3][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== config excerpt =="
sed -n '100,180p' litellm/config.yaml

echo
echo "== model aliases/references =="
rg -n "local-qwen-3.6-hass|enable_thinking|supports_reasoning|gpt-4o-mini|gpt-4o|model_group" litellm -S

echo
echo "== repository files =="
git ls-files litellm | sed -n '1,120p'

Repository: sheepdestroyer/LLM-Routing

Length of output: 5429


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== entrypoint outline =="
ast-grep outline litellm/entrypoint.py || true

echo
echo "== entrypoint relevant sections =="
wc -l litellm/entrypoint.py
sed -n '1,260p' litellm/entrypoint.py

echo
echo "== tests =="
cat -n litellm/tests/test_entrypoint.py

Repository: sheepdestroyer/LLM-Routing

Length of output: 10311


Set supports_reasoning to false for the hass-only aliases.

local-qwen-3.6-hass, gpt-4o-mini, and gpt-4o use chat_template_kwargs.enable_thinking: false, but their model_info still declares reasoning support. Sync these aliases with the local-qwen-3.6 behavior so model capability and configuration match.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@litellm/config.yaml` around lines 124 - 165, Update the model_info entries
for the local-qwen-3.6-hass, gpt-4o-mini, and gpt-4o aliases to set
supports_reasoning to false. Leave the existing enable_thinking configuration
and other capability flags unchanged, matching the local-qwen-3.6 behavior.

- litellm_params:
api_base: LLAMA_CLASSIFIER_URL_PLACEHOLDER
api_key: local-token
Expand Down
129 changes: 129 additions & 0 deletions router/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1971,6 +1971,131 @@ async def proxy_models():
raise HTTPException(status_code=502, detail="Model proxy failed")


@app.api_route("/v1/responses", methods=["POST"])
@app.api_route("/responses", methods=["POST"])
async def responses_api(request: Request):
"""Handle incoming OpenAI Responses API requests (/v1/responses and /responses).

Proxies requests to LiteLLM's /v1/responses endpoint, performing triage classification
when an auto model (e.g. llm-routing-auto-free) is requested, while supporting model aliases
(such as gpt-4o-mini, local-qwen-3.6-hass) and tool/streaming executions.
"""
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON payload")

await sync_cooldowns_from_valkey()

client_model = body.get("model", "llm-routing-auto-free")

AUTO_MODELS = {
"llm-routing-auto-free",
"llm-routing-auto-agy",
"llm-routing-auto-ollama",
"llm-routing-auto-agy-ollama",
}

last_user_message = ""
input_field = body.get("input")
if isinstance(input_field, str):
last_user_message = input_field
elif isinstance(input_field, list):
for item in input_field:
if isinstance(item, str):
last_user_message += item
elif isinstance(item, dict):
if item.get("type") == "text":
last_user_message += item.get("text", "")
elif item.get("role") == "user":
content = item.get("content") or ""
if isinstance(content, str):
last_user_message += content
elif isinstance(content, list):
last_user_message += "".join(
b.get("text", "")
for b in content
if isinstance(b, dict) and b.get("type") == "text"
)

Comment on lines +1999 to +2020

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

OpenAI Responses API input array content part type input_text vs text field name

💡 Result:

The distinction between input_text and text field names in the OpenAI API depends on which specific API endpoint and resource you are using. These are not interchangeable; using the incorrect type often results in an invalid_value error [1][2]. 1. Chat Completions API: For the standard Chat Completions endpoint (client.chat.completions.create), the content part type for text is strictly "text" [3]. Example structure: { "role": "user", "content": [ { "type": "text", "text": "Your message here" } ] } 2. Responses API: For the newer Responses API (client.responses.create), the content part type for text is "input_text" [4][5][6]. Example structure: { "role": "user", "content": [ { "type": "input_text", "text": "Your message here" } ] } Note that within the Responses API, if you are providing context from a previous assistant response, you may be required to use "output_text" instead of "input_text" [7][2]. In summary, always check the documentation for the specific endpoint you are targeting, as the API enforces these type schemas strictly to distinguish between input and output modalities across different services [1][7][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate file:"
git ls-files | rg '(^|/)router/main\.py$' || true

echo
echo "Relevant section:"
sed -n '1960,2045p' router/main.py

echo
echo "Search for last_user_message and chat_completions handling:"
rg -n "last_user_message|chat_completions|input_field|AUTO_MODELS|input_text|type.*text" router/main.py | head -200

echo
echo "Diff stat/name-only:"
git diff --stat
git diff -- router/main.py | sed -n '1,220p'

Repository: sheepdestroyer/LLM-Routing

Length of output: 5467


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Relevant chat_completions section:"
sed -n '2100,2245p' router/main.py

echo
echo "Programmatic behavior probe for current extraction loop:"
python3 - <<'PY'
def extract_current(input_field):
    last_user_message = ""
    if isinstance(input_field, str):
        last_user_message = input_field
    elif isinstance(input_field, list):
        for item in input_field:
            if isinstance(item, str):
                last_user_message += item
            elif isinstance(item, dict):
                if item.get("type") == "text":
                    last_user_message += item.get("text", "")
                elif item.get("role") == "user":
                    content = item.get("content") or ""
                    if isinstance(content, str):
                        last_user_message += content
                    elif isinstance(content, list):
                        last_user_message += "".join(
                            b.get("text", "")
                            for b in content
                            if isinstance(b, dict) and b.get("type") == "text"
                        )
    return last_user_message

def extract_proposed(input_field):
    last_user_message = ""
    if isinstance(input_field, str):
        last_user_message = input_field
    elif isinstance(input_field, list):
        for item in reversed(input_field):
            if isinstance(item, dict) and item.get("role") == "user":
                content = item.get("content") or ""
                if isinstance(content, str):
                    last_user_message = content
                elif isinstance(content, list):
                    last_user_message = "".join(
                        b.get("text", "")
                        for b in content
                        if isinstance(b, dict) and b.get("type") == "input_text"
                    )
                break
    return last_user_message

payloads = [
    {"Responses structured input with input_text": [
        {"role": "user", "content": [{"type": "input_text", "text": "Last user input"}]},
        {"role": "assistant", "content": "assistant output"},
        {"role": "user", "content": [{"type": "input_text", "text": "Oldest user input"}]},
    ]},
    {"Responses with top-level text object": [
        {"role": "user", "content": [{"type": "text", "text": "User text"}]},
    ]},
    {"Responses plain strings": [
        "Last",
        "Oldest",
    ]},
]
for name, input_field in payloads.items():
    current = extract_current(input_field)
    proposed = extract_proposed(input_field)
    print(f"{name}: current={current!r}, proposed={proposed!r}")
PY

Repository: sheepdestroyer/LLM-Routing

Length of output: 5610


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Programmatic behavior probe for current extraction loop:"
python3 - <<'PY'
def extract_current(input_field):
    last_user_message = ""
    if isinstance(input_field, str):
        last_user_message = input_field
    elif isinstance(input_field, list):
        for item in input_field:
            if isinstance(item, str):
                last_user_message += item
            elif isinstance(item, dict):
                if item.get("type") == "text":
                    last_user_message += item.get("text", "")
                elif item.get("role") == "user":
                    content = item.get("content") or ""
                    if isinstance(content, str):
                        last_user_message += content
                    elif isinstance(content, list):
                        last_user_message += "".join(
                            b.get("text", "")
                            for b in content
                            if isinstance(b, dict) and b.get("type") == "text"
                        )
    return last_user_message

def extract_proposed(input_field):
    last_user_message = ""
    if isinstance(input_field, str):
        last_user_message = input_field
    elif isinstance(input_field, list):
        for item in reversed(input_field):
            if isinstance(item, dict) and item.get("role") == "user":
                content = item.get("content") or ""
                if isinstance(content, str):
                    last_user_message = content
                elif isinstance(content, list):
                    last_user_message = "".join(
                        b.get("text", "")
                        for b in content
                        if isinstance(b, dict) and b.get("type") == "input_text"
                    )
                break
    return last_user_message

payloads = {
    "Responses structured input with input_text": [
        {"role": "user", "content": [{"type": "input_text", "text": "Last user input"}]},
        {"role": "assistant", "content": "assistant output"},
        {"role": "user", "content": [{"type": "input_text", "text": "Oldest user input"}]},
    ],
    "Responses with top-level text object": [
        {"role": "user", "content": [{"type": "text", "text": "User text"}]},
    ],
    "Responses plain strings": [
        "Last",
        "Oldest",
    ],
}
for name, input_field in payloads.items():
    current = extract_current(input_field)
    proposed = extract_proposed(input_field)
    print(f"{name}: current={current!r}, proposed={proposed!r}")
PY

Repository: sheepdestroyer/LLM-Routing

Length of output: 436


Extract the last user input before triage and support input_text content parts.

In responses_api, structured Responses payloads use type: "input_text" for user text, so the current type == "text" guards leave those inputs empty and force triage to instructions or an empty string. Also, chat_completions uses reversed(messages) and stops at the first user message, while this loop keeps concatenating all strings/content blocks in input, merging multiple turns. Fix both checks to use input_text and extract only the latest user turn before classifying.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/main.py` around lines 1999 - 2020, Update the last-user-input
extraction in responses_api to recognize input_text for both top-level items and
nested content blocks, and change the input-list traversal to inspect items in
reverse and stop after extracting the latest user turn. Keep chat_completions
aligned with this behavior by selecting only the most recent user message rather
than concatenating multiple turns.

if not last_user_message:
instructions = body.get("instructions")
if isinstance(instructions, str):
last_user_message = instructions
Comment on lines +1999 to +2024

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.

suggestion: Concatenating multiple input segments without separators may degrade triage classification quality.

When input is a list, all segments are concatenated into last_user_message without any separators. For long or structured inputs this can obscure boundaries between messages or content types and negatively impact classify_request. Consider inserting simple delimiters (spaces/newlines) between segments or restricting which parts are included in triage to improve routing accuracy.

Suggested change
last_user_message = ""
input_field = body.get("input")
if isinstance(input_field, str):
last_user_message = input_field
elif isinstance(input_field, list):
for item in input_field:
if isinstance(item, str):
last_user_message += item
elif isinstance(item, dict):
if item.get("type") == "text":
last_user_message += item.get("text", "")
elif item.get("role") == "user":
content = item.get("content") or ""
if isinstance(content, str):
last_user_message += content
elif isinstance(content, list):
last_user_message += "".join(
b.get("text", "")
for b in content
if isinstance(b, dict) and b.get("type") == "text"
)
if not last_user_message:
instructions = body.get("instructions")
if isinstance(instructions, str):
last_user_message = instructions
last_user_message = ""
input_field = body.get("input")
if isinstance(input_field, str):
last_user_message = input_field
elif isinstance(input_field, list):
# Collect segments and join with a delimiter to preserve boundaries for triage
segments: list[str] = []
for item in input_field:
if isinstance(item, str):
segments.append(item)
elif isinstance(item, dict):
if item.get("type") == "text":
segments.append(item.get("text", ""))
elif item.get("role") == "user":
content = item.get("content") or ""
if isinstance(content, str):
segments.append(content)
elif isinstance(content, list):
segments.append(
"".join(
b.get("text", "")
for b in content
if isinstance(b, dict) and b.get("type") == "text"
)
)
# Use a simple space delimiter to avoid degrading classification by merging segments
last_user_message = " ".join(s for s in segments if s)
if not last_user_message:
instructions = body.get("instructions")
if isinstance(instructions, str):
last_user_message = instructions


target_model = client_model
if client_model in AUTO_MODELS or client_model == "llm-routing-ollama":
bypass_cache = request.headers.get("x-bypass-cache") == "true"
(
target_model,
triage_latency,
was_cache_hit,
raw_classification,
) = await classify_request(last_user_message, bypass_cache=bypass_cache)
logger.info(f"Responses API Triage decision: Routing to -> '{target_model}'")

body_to_send = body.copy()
body_to_send["model"] = target_model

litellm_key = os.getenv("LITELLM_MASTER_KEY")
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
auth_header = f"Bearer {litellm_key}"

headers = {
"Authorization": auth_header,
"Content-Type": request.headers.get("content-type", "application/json"),
}

is_streaming = body_to_send.get("stream", False)
client = get_http_client()
url = f"{LITELLM_URL}/v1/responses"

logger.info(f"Proxying Responses API request for model={target_model} to {url}")

if is_streaming:
async def response_streamer():
try:
async with client.stream(
"POST",
url,
json=body_to_send,
headers=headers,
timeout=600.0,
) as resp:
async for chunk in resp.aiter_bytes():
yield chunk
except Exception as stream_err:
logger.error(f"Error during Responses API streaming proxy: {stream_err}")
raise

return StreamingResponse(response_streamer(), media_type="text/event-stream")
Comment on lines +2050 to +2072

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.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Streaming path never checks the upstream status code — errors get silently returned as HTTP 200.

Unlike chat_completions' streaming branch (which uses client.build_request + client.send(stream=True) and explicitly checks r.status_code == 200 before returning a StreamingResponse, else raising HTTPException), responses_api's response_streamer() opens client.stream(...) and immediately starts yielding bytes with no status check. If LiteLLM returns a 400/401/429/500 for a streaming request, the client still receives HTTP 200 with media_type="text/event-stream", masking the error and breaking Home Assistant's ability to react to failures — this directly conflicts with the PR's stated goal of "correct propagation of ... errors."

🐛 Proposed fix (mirrors the existing chat_completions pattern)
     if is_streaming:
-        async def response_streamer():
-            try:
-                async with client.stream(
-                    "POST",
-                    url,
-                    json=body_to_send,
-                    headers=headers,
-                    timeout=600.0,
-                ) as resp:
-                    async for chunk in resp.aiter_bytes():
-                        yield chunk
-            except Exception as stream_err:
-                logger.error(f"Error during Responses API streaming proxy: {stream_err}")
-                raise
-
-        return StreamingResponse(response_streamer(), media_type="text/event-stream")
+        req = client.build_request(
+            "POST", url, json=body_to_send, headers=headers, timeout=600.0
+        )
+        resp = await client.send(req, stream=True)
+        if resp.status_code != 200:
+            error_body = await resp.aread()
+            await resp.aclose()
+            logger.warning(
+                f"Responses API stream failed ({resp.status_code}): {error_body[:300]}"
+            )
+            raise HTTPException(status_code=resp.status_code, detail="Responses proxy failed")
+
+        async def response_streamer():
+            try:
+                async for chunk in resp.aiter_bytes():
+                    yield chunk
+            except Exception as stream_err:
+                logger.error(f"Error during Responses API streaming proxy: {stream_err}")
+                raise
+            finally:
+                await resp.aclose()
+
+        return StreamingResponse(response_streamer(), media_type="text/event-stream")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
is_streaming = body_to_send.get("stream", False)
client = get_http_client()
url = f"{LITELLM_URL}/v1/responses"
logger.info(f"Proxying Responses API request for model={target_model} to {url}")
if is_streaming:
async def response_streamer():
try:
async with client.stream(
"POST",
url,
json=body_to_send,
headers=headers,
timeout=600.0,
) as resp:
async for chunk in resp.aiter_bytes():
yield chunk
except Exception as stream_err:
logger.error(f"Error during Responses API streaming proxy: {stream_err}")
raise
return StreamingResponse(response_streamer(), media_type="text/event-stream")
is_streaming = body_to_send.get("stream", False)
client = get_http_client()
url = f"{LITELLM_URL}/v1/responses"
logger.info(f"Proxying Responses API request for model={target_model} to {url}")
if is_streaming:
req = client.build_request(
"POST", url, json=body_to_send, headers=headers, timeout=600.0
)
resp = await client.send(req, stream=True)
if resp.status_code != 200:
error_body = await resp.aread()
await resp.aclose()
logger.warning(
f"Responses API stream failed ({resp.status_code}): {error_body[:300]}"
)
raise HTTPException(status_code=resp.status_code, detail="Responses proxy failed")
async def response_streamer():
try:
async for chunk in resp.aiter_bytes():
yield chunk
except Exception as stream_err:
logger.error(f"Error during Responses API streaming proxy: {stream_err}")
raise
finally:
await resp.aclose()
return StreamingResponse(response_streamer(), media_type="text/event-stream")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/main.py` around lines 2050 - 2072, Update the streaming branch in
responses_api’s response_streamer to validate the upstream resp.status_code
before yielding any bytes. Propagate non-success responses as HTTP errors,
preserving the existing successful StreamingResponse behavior and matching the
status handling used by chat_completions.

else:
try:
r = await client.post(
url,
json=body_to_send,
headers=headers,
timeout=600.0,
)
response_headers = dict(r.headers)
for h in [
"content-encoding",
"content-length",
"transfer-encoding",
"connection",
]:
response_headers.pop(h, None)
return Response(
content=r.content,
status_code=r.status_code,
headers=response_headers,
)
except Exception as e:
logger.error(f"Failed to proxy Responses API request: {e}")
raise HTTPException(status_code=502, detail="Responses proxy failed")


@app.post("/v1/chat/completions")
async def chat_completions(request: Request):
"""Handle incoming OpenAI-compatible chat completions requests.
Expand Down Expand Up @@ -2025,6 +2150,10 @@ async def chat_completions(request: Request):
"agent-reasoning-core",
"agent-advanced-core",
"llm-routing-agy",
"local-qwen-3.6",
"local-qwen-3.6-hass",
"gpt-4o-mini",
"gpt-4o",
}

AUTO_MODELS = {
Expand Down
Loading
Loading