diff --git a/docs/self-evolution.md b/docs/self-evolution.md index 2ab94cb..fdbe53b 100644 --- a/docs/self-evolution.md +++ b/docs/self-evolution.md @@ -9,7 +9,7 @@ Historical verification snapshot: `51be33361422e55e1f2f00c33a0e0f8c56132a91` (the post-#54 `main` revision, captured before this #55 documentation-only update). Snapshot date: 2026-09-04. -Current repository test count at this snapshot: **193 unittest cases**. +Current repository test count at this snapshot: **194 unittest cases**. ## Verified surface diff --git a/main.py b/main.py index b4f5467..166b614 100644 --- a/main.py +++ b/main.py @@ -121,6 +121,16 @@ def _terminal_supports_unicode() -> bool: "https://github.com/EvanProgramming/OpenKyrozen/releases/download/" f"{RELEASE_TAG}/openkyrozen-{RELEASE_VERSION}-py3-none-any.whl" ) +PROVIDER_UNAVAILABLE_CODE = "provider_unavailable" +PROVIDER_UNAVAILABLE_MESSAGE = ( + "No LLM provider is configured. Set DEEPSEEK_API_KEY or configure a local provider before sending chat." +) + + +class ProviderUnavailableError(RuntimeError): + """Raised when a chat request reaches the provider boundary unconfigured.""" + + code = PROVIDER_UNAVAILABLE_CODE try: __version__ = importlib.metadata.version("openkyrozen") @@ -3661,7 +3671,7 @@ def _get_llm_response(messages: list[dict[str, str]], model: str | None = None, on_chunk: Any = None, on_stream_end: Any = None) -> str: global _last_prompt_tokens, _last_completion_tokens, _total_prompt_tokens, _total_completion_tokens if llm_provider is None: - return "[Error] LLM provider not initialised" + raise ProviderUnavailableError(PROVIDER_UNAVAILABLE_MESSAGE) try: with usage_scope( store=memory_bank.store, user_id=memory_bank.user_id, diff --git a/server.py b/server.py index 4fc2530..96beac4 100644 --- a/server.py +++ b/server.py @@ -925,6 +925,12 @@ async def api_chat(request: Request): try: reply = _run_session_chat(session, msg) + except _agent.ProviderUnavailableError as exc: + _audit("ERROR", _agent.PROVIDER_UNAVAILABLE_CODE, session["user_id"]) + raise HTTPException( + status_code=503, + detail={"code": _agent.PROVIDER_UNAVAILABLE_CODE, "message": str(exc)}, + ) from exc except Exception as e: _audit("ERROR", str(e), session["user_id"]) raise HTTPException(500, str(e)) @@ -1005,6 +1011,12 @@ def run_streaming_turn() -> None: events.put({"event": "error", "error": str(reply)}) else: events.put({"event": "complete", "reply": reply}) + except _agent.ProviderUnavailableError as exc: + events.put({ + "event": "error", + "code": _agent.PROVIDER_UNAVAILABLE_CODE, + "error": str(exc), + }) except Exception as exc: events.put({"event": "error", "error": str(exc)}) finally: @@ -1024,7 +1036,7 @@ async def generate(): elif kind == "tasks": yield f"data: {json.dumps({'event': 'tasks', 'tasks': event.get('tasks', [])}, ensure_ascii=False)}\n\n" elif kind == "error": - yield f"data: {json.dumps({'event': 'error', 'error': event.get('error', 'stream failed')}, ensure_ascii=False)}\n\n" + yield f"data: {json.dumps({'event': 'error', 'code': event.get('code', 'stream_error'), 'error': event.get('error', 'stream failed')}, ensure_ascii=False)}\n\n" break elif kind == "complete": reply = str(event.get("reply", "")) @@ -1723,6 +1735,13 @@ async def mcp_endpoint(request: Request): session = _get_or_create_session(session_id, _SERVER_ACTOR_ID) try: reply = _run_session_chat(session, msg) + except _agent.ProviderUnavailableError as exc: + return _mcp_error( + request_id, + -32002, + "LLM provider unavailable", + {"code": _agent.PROVIDER_UNAVAILABLE_CODE, "message": str(exc)}, + ) except Exception as exc: _audit("MCP_CHAT_ERROR", type(exc).__name__, _SERVER_ACTOR_ID) return _mcp_error(request_id, -32603, "Internal error") diff --git a/tests/test_browser_manager.py b/tests/test_browser_manager.py index 2ca5f17..a466b65 100644 --- a/tests/test_browser_manager.py +++ b/tests/test_browser_manager.py @@ -136,7 +136,7 @@ def log_message(self, *_args): 'data: {"chunk":"世界"}\n\n', 'data: {"cost":"Cost: split-stream"}\n\n', 'data: [DONE]\n\n' - ] : ['data: {"error":"mock provider failure"}\n\n']; + ] : ['data: {"event":"error","code":"provider_unavailable","error":"No LLM provider is configured. Configure a provider before sending chat."}\n\n']; const bytes = new TextEncoder().encode(frames.join('')); return new Response(new ReadableStream({ start(controller) { @@ -182,7 +182,7 @@ def log_message(self, *_args): page.fill("#user-input", "error boundary") page.click("#send-btn") page.wait_for_function( - "document.querySelectorAll('.msg.assistant .content')[1].textContent.includes('mock provider failure')" + "document.querySelectorAll('.msg.assistant .content')[1].textContent.includes('No LLM provider is configured')" ) page.wait_for_function( "document.getElementById('status').textContent === 'Error'" @@ -190,7 +190,7 @@ def log_message(self, *_args): self.assertEqual(page.locator("#status").text_content(), "Error") self.assertEqual( page.locator(".msg.assistant .content").nth(1).text_content(), - "Error: mock provider failure", + "Error: No LLM provider is configured. Configure a provider before sending chat.", ) finally: if browser is not None: diff --git a/tests/test_server.py b/tests/test_server.py index 23ccdfc..e36a35b 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -224,6 +224,57 @@ def reply(session, message): self.assertEqual(failed.json()["error"], {"code": -32603, "message": "Internal error"}) self.assertNotIn("must-not-leak", failed.text) + def test_missing_provider_fails_honestly_across_rest_stream_and_mcp(self): + client = TestClient(server.app) + sessions = { + "rest": "missing-provider-rest", + "stream": "missing-provider-stream", + "mcp": "missing-provider-mcp", + } + original_provider = server._agent.llm_provider + try: + with patch.object(server._agent, "llm_provider", None): + rest = client.post("/api/chat", json={ + "message": "hello", "session_id": sessions["rest"], + }) + streamed = client.post("/api/chat/stream", json={ + "message": "hello", "session_id": sessions["stream"], + }) + mcp = client.post("/mcp", json={ + "jsonrpc": "2.0", "id": 93, "method": "chat/send", + "params": {"message": "hello", "session_id": sessions["mcp"]}, + }) + finally: + server._agent.llm_provider = original_provider + + self.assertEqual(rest.status_code, 503, rest.text) + detail = rest.json()["detail"] + self.assertEqual(detail["code"], server._agent.PROVIDER_UNAVAILABLE_CODE) + self.assertIn("configure", detail["message"]) + self.assertNotIn("[Error]", rest.text) + + self.assertEqual(streamed.status_code, 200, streamed.text) + self.assertEqual(streamed.text.count('"event": "error"'), 1) + self.assertIn('"code": "provider_unavailable"', streamed.text) + self.assertNotIn('"event": "completion"', streamed.text) + self.assertNotIn("data: [DONE]", streamed.text) + + self.assertEqual(mcp.status_code, 200, mcp.text) + mcp_body = mcp.json() + self.assertEqual(mcp_body["error"]["code"], -32002) + self.assertEqual( + mcp_body["error"]["data"]["code"], + server._agent.PROVIDER_UNAVAILABLE_CODE, + ) + self.assertNotIn("result", mcp_body) + + for session_id in sessions.values(): + messages = server._agent.memory_bank.store.list_events( + "session.message", workspace_id=server._agent.memory_bank.workspace_id, + session_id=session_id, user_id=server._SERVER_ACTOR_ID, + ) + self.assertEqual(messages, [], session_id) + def test_ollama_initialization_never_prompts_for_a_key(self): original_config = server._agent._provider_config original_provider = server._agent.llm_provider