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 pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "khwan"
version = "0.4.1"
version = "0.4.2"
description = "Khwan hosted client — the cognition layer (memory + identity + learning) for your own agent. Bring your own model."
readme = "README.md"
requires-python = ">=3.9"
Expand Down
31 changes: 24 additions & 7 deletions src/khwan/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,31 @@ def _retry_delay(attempt: int, retry_after: Optional[str]) -> float:
return base * (0.75 + random.random() * 0.5)


def _error_message(status: int, text: str) -> str:
"""A message that says what to DO, for the statuses with an obvious answer."""
return {
401: "unauthorized — bad or missing API key",
def _error_message(status: int, text: str, bearer: bool = False) -> str:
"""What to DO about this status, followed by what the server actually said.

The hint alone used to be the whole message, and the server's text was
dropped. That hides the only part that distinguishes causes: a 401 for a
credential that was rejected reads identically to a 401 for one that never
arrived, and the caller cannot tell which without guessing.

The 401 hint also depends on WHICH credential this client carries. An
OAuth-authenticated caller has no API key to check, and being sent to look
for one is a wrong turn rather than a vague one.
"""
hint = {
401: ("unauthorized — the OAuth token was rejected or missing; it may have "
"expired, in which case authorize again")
if bearer else
("unauthorized — bad or missing API key"),
402: "payment required — add a payment method / upgrade your plan",
404: "not found — check the core in X-Khwan-Core exists",
429: "rate limited / over your plan's limit — retry later",
}.get(status, text[:300])
}.get(status)
detail = (text or "").strip()[:300]
if hint and detail:
return f"{hint} ({detail})"
return hint or detail


def _auth_headers(api_key: Optional[str], user_id: Optional[str], core: Optional[str],
Expand Down Expand Up @@ -234,7 +251,7 @@ def _request(self, method: str, path: str, body: Optional[dict] = None) -> dict:
continue

if r.status_code // 100 != 2:
raise KhwanError(r.status_code, _error_message(r.status_code, r.text))
raise KhwanError(r.status_code, _error_message(r.status_code, r.text, bool(self._bearer)))
return r.json() if r.content else {}

# ---- the memory loop: prepare → (your model) → record ----
Expand Down Expand Up @@ -535,7 +552,7 @@ async def _request(self, method: str, path: str, body: Optional[dict] = None) ->
continue

if r.status_code // 100 != 2:
raise KhwanError(r.status_code, _error_message(r.status_code, r.text))
raise KhwanError(r.status_code, _error_message(r.status_code, r.text, bool(self._bearer)))
return r.json() if r.content else {}

# ---- the memory loop ----
Expand Down
42 changes: 42 additions & 0 deletions test_bearer.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,45 @@ def test_async_client_refuses_both():
from khwan import AsyncKhwan
with pytest.raises(ValueError, match="exactly one"):
AsyncKhwan(api_key="kwk_live_x", bearer_token=JWT)


# ── what an error says ────────────────────────────────────────────────────────
# The hint used to be the whole message and the server's text was discarded,
# which hides the only part that separates causes.

from khwan import _error_message # noqa: E402


def test_the_servers_own_words_survive():
"""`Invalid bearer token` and `Missing credentials` are different problems."""
rejected = _error_message(401, "Invalid bearer token", True)
absent = _error_message(401, "Missing credentials (X-API-Key or Authorization: Bearer)", True)
assert "Invalid bearer token" in rejected
assert "Missing credentials" in absent
assert rejected != absent


def test_a_bearer_caller_is_not_sent_looking_for_an_api_key():
"""The wrong turn: an OAuth caller has no API key to check."""
msg = _error_message(401, "Invalid bearer token", True)
assert "API key" not in msg
assert "authorize again" in msg


def test_an_api_key_caller_still_hears_about_the_key():
msg = _error_message(401, "Invalid API key", False)
assert "API key" in msg


def test_other_statuses_keep_their_hint_and_gain_the_detail():
msg = _error_message(402, "per-user memory: the free plan allows 3 sub-brain(s)")
assert "upgrade" in msg
assert "free plan allows 3" in msg


def test_an_unmapped_status_is_just_the_server_text():
assert _error_message(500, "internal error") == "internal error"


def test_no_detail_leaves_the_hint_alone():
assert _error_message(429, "") == "rate limited / over your plan's limit — retry later"
Loading