diff --git a/routes/chat_routes.py b/routes/chat_routes.py index 6294d3adb..e2747bab1 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -4152,6 +4152,8 @@ def setup_chat_routes( yield f'data: {json.dumps({"type": "chat_terminal", "data": _terminal_metrics})}\n\n' yield chunk elif chunk.startswith("event: "): + if chunk.startswith("event: error"): + _stream_set(session, status="error") yield chunk elif chunk == "data: [DONE]\n\n": if _chat_terminal_saved: diff --git a/src/clean_agent_preview.py b/src/clean_agent_preview.py index 0420b740b..1e9edb2d2 100644 --- a/src/clean_agent_preview.py +++ b/src/clean_agent_preview.py @@ -6792,10 +6792,25 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac break else: yield event({'delta': '\nThe preview reached its round limit. Please narrow the request.'}) + except httpx.HTTPStatusError as exc: + status = exc.response.status_code + if status == 402: + detail = 'Payment required by the selected model provider (HTTP 402). Check its billing or credits, or choose another model.' + elif status in (401, 403): + detail = f'The selected model provider rejected access (HTTP {status}). Check its credentials and permissions.' + elif status == 429: + detail = 'The selected model provider is rate limiting requests (HTTP 429). Wait before retrying or choose another model.' + elif status >= 500: + detail = f'The selected model provider is unavailable (HTTP {status}). Retry later or choose another model.' + else: + detail = f'The selected model provider rejected the request (HTTP {status}). Check the provider or choose another model.' + logging.getLogger(__name__).warning('Clean v3 provider request failed with HTTP %s', status) + yield f'event: error\ndata: {json.dumps({"status": status, "error": detail})}\n\n' + return except Exception: - import logging logging.getLogger(__name__).exception('Clean v3 preview failed') - yield event({'delta': '\nThe v3 test encountered an error. No fallback model or fabricated tool call was used.'}) + yield f'event: error\ndata: {json.dumps({"status": 500, "error": "The model request failed unexpectedly. Check the server log and retry."})}\n\n' + return elapsed = time.monotonic() - started ttft = first_token - started if first_token else None yield event({'type': 'metrics', 'data': { diff --git a/tests/test_clean_agent_preview.py b/tests/test_clean_agent_preview.py index 5ac2ae625..7b2ff4aba 100644 --- a/tests/test_clean_agent_preview.py +++ b/tests/test_clean_agent_preview.py @@ -4516,6 +4516,53 @@ async def test_stream_emits_incremental_text_and_persistable_history(monkeypatch assert raw[-1] == 'data: [DONE]\n\n' +@pytest.mark.asyncio +@pytest.mark.parametrize('status,expected', [ + (402, 'billing or credits'), + (401, 'credentials and permissions'), + (429, 'rate limiting'), + (503, 'unavailable'), +]) +async def test_preview_provider_http_failure_is_terminal_error_not_assistant_text(monkeypatch, status, expected): + import httpx + import src.clean_agent_preview as module + + class Response: + status_code = status + + async def __aenter__(self): return self + async def __aexit__(self, *args): pass + + def raise_for_status(self): + request = httpx.Request('POST', 'https://provider.example/v1/chat/completions') + response = httpx.Response(status, request=request) + raise httpx.HTTPStatusError('provider secret must not be shown', request=request, response=response) + + class Client: + def __init__(self, **kwargs): pass + async def __aenter__(self): return self + async def __aexit__(self, *args): pass + def stream(self, *args, **kwargs): return Response() + + monkeypatch.setattr(module.httpx, 'AsyncClient', Client) + contract = resolve_full_inventory_contract(schemas=[], policy=ToolPolicy()) + raw = [chunk async for chunk in stream_preview( + endpoint_url='https://provider.example/v1/chat/completions', model='test', + messages=[{'role': 'user', 'content': 'hello'}], headers={}, + turn_contract=contract, session_id='test', owner='test', + disabled_tools=set(), tool_policy=ToolPolicy(), + )] + + assert raw[-1].startswith('event: error\ndata: ') + assert all('"delta"' not in chunk for chunk in raw) + assert all('"type": "metrics"' not in chunk for chunk in raw) + assert 'data: [DONE]' not in raw + payload = json.loads(raw[-1].split('data: ', 1)[1]) + assert payload['status'] == status + assert expected in payload['error'] + assert 'provider secret' not in raw[-1] + + @pytest.mark.asyncio async def test_ajax_c375_clean_runtime_uses_progressive_thinking_without_leaking(monkeypatch): import src.clean_agent_preview as module @@ -6003,7 +6050,9 @@ async def test_context_recovery_is_bounded_and_not_used_for_other_errors( disabled_tools=set(), tool_policy=ToolPolicy())] assert len(requests) == expected_requests assert not any('"type": "tool_start"' in chunk for chunk in raw) - assert any('encountered an error' in chunk for chunk in raw) + assert raw[-1].startswith('event: error\ndata: ') + assert json.loads(raw[-1].split('data: ', 1)[1])['status'] == status + assert all('"delta"' not in chunk for chunk in raw) @pytest.mark.asyncio