Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/self-evolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 11 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 20 additions & 1 deletion server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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:
Expand All @@ -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", ""))
Expand Down Expand Up @@ -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")
Expand Down
6 changes: 3 additions & 3 deletions tests/test_browser_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -182,15 +182,15 @@ 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'"
)
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:
Expand Down
51 changes: 51 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading