From 387ac4ab2c122939cac7f9633c623239cb856c23 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Tue, 30 Jun 2026 22:21:59 -0700 Subject: [PATCH] feat(#2): consume GET /me + GET /capabilities via --whoami one-shot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1 coverage-audit slice (capabilities+me). Both endpoints had no caller; add them as cheap boot-time debug primitives. - sessions.py: get_me (GET /me — identity/whoami) + get_capabilities (GET /capabilities — Echo ephemeral-template discovery). Mirror get_persona_state: 200 -> parsed dict verbatim, non-200 -> SessionApiFailed. Freeform dicts (frozen OpenAPI types both as objects). - cli.py: new --whoami one-shot mode (mirrors --send). Fetches both, prints an identity + capabilities report, exits. Standalone probe: mutually exclusive with --send/--session/--new/--agent; opens no session. New ParsedArgs.whoami field + main() dispatch. - Contract #2 amended (2 FNs) + validated. TDD: 5 wrapper tests + 5 cli tests (validation + mode + error). Coverage map: REST 9/40. Suite 538 green; touched code ruff-clean. Audit note: /capabilities is the Echo ephemeral-template discovery endpoint, not a generic server-caps endpoint (coverage-map framing corrected). TUI-surfacing of /me + /capabilities deferred. --- docs/contracts/issues/2.contract.md | 45 ++++++++++++ docs/coverage-map.md | 6 +- persistent-memory.md | 2 + pyproject.toml | 2 +- src/ratatoskr/cli.py | 104 +++++++++++++++++++++++----- src/ratatoskr/sessions.py | 32 +++++++++ tests/test_cli.py | 69 ++++++++++++++++++ tests/test_sessions.py | 95 +++++++++++++++++++++++++ uv.lock | 2 +- 9 files changed, 334 insertions(+), 23 deletions(-) diff --git a/docs/contracts/issues/2.contract.md b/docs/contracts/issues/2.contract.md index 1b71a33..9ef04cb 100644 --- a/docs/contracts/issues/2.contract.md +++ b/docs/contracts/issues/2.contract.md @@ -206,3 +206,48 @@ TESTS: limit_above_max [adversarial]: limit=300 → AssertionError; no HTTP issued empty_cursor [adversarial]: cursor="" → AssertionError; no HTTP issued ``` + +## Amendment 2026-06-30 — boot-time introspection reads (v1 coverage-audit: capabilities+me) + +The v1 coverage-audit added two read-only server-introspection endpoints as +cheap debug primitives (surfaced via a new `ratatoskr --whoami` one-shot). Both +mirror `get_persona_state`: GET, 200 → parsed dict verbatim, any non-200 → +`SessionApiFailed`. The frozen OpenAPI types both responses as freeform objects, +so the wrappers return `dict[str, Any]` (not a typed dataclass). + +```contract +FN get_me(client: httpx.AsyncClient) -> dict[str, Any] +BRIEF: GET /me — the authenticated principal's identity + key metadata (spec §GET /me). Boot-time whoami: verify the key without agent-config side effects. Returns parsed JSON verbatim; spec documents {user_id, scopes, tier, display_name?, key_id?, key_label?, ...} with optional fields OMITTED (not null). Read-only, rate-exempt, no audit emission. +PRE: [PRE-001 hard] client is not None -- assert client is not None +POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json() +ERROR_ROUTING: + HTTP non-200 (incl. 401 bad/absent key when auth enabled): + local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content) + flow_control: abort + state_recovery: none (caller decides: bad key → re-key; degraded tier="unknown" is still a 200) +STEPS: + 1. [setup, prescriptive] assert client is not None + 2. [sequential, prescriptive] resp = await client.get("/me") + 3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed +TESTS: + happy_authenticated [happy,tracer]: 200 {user_id, scopes, tier, key_id} → dict returned verbatim + anonymous_dev_mode: 200 {user_id:"anonymous", tier:"anonymous"} → dict; no key_* fields (omitted) + 401_raises [error]: 401 → SessionApiFailed(status=401) + +FN get_capabilities(client: httpx.AsyncClient) -> dict[str, Any] +BRIEF: GET /capabilities — server capability discovery (spec §Ephemeral Templates). Returns {ephemeral_templates: {echo: {allowed_models, default_model, system_prompt_max_bytes}}}. Any authenticated caller may read it (no instantiate scope). Parsed dict verbatim; any non-200 → SessionApiFailed. +PRE: [PRE-001 hard] client is not None -- assert client is not None +POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json() +ERROR_ROUTING: + HTTP non-200: + local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content) + flow_control: abort + state_recovery: none +STEPS: + 1. [setup, prescriptive] assert client is not None + 2. [sequential, prescriptive] resp = await client.get("/capabilities") + 3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed +TESTS: + happy [happy]: 200 {ephemeral_templates:{echo:{...}}} → dict returned verbatim + non_200_raises [error]: 500 → SessionApiFailed(status=500) +``` diff --git a/docs/coverage-map.md b/docs/coverage-map.md index ebe462f..08c307d 100644 --- a/docs/coverage-map.md +++ b/docs/coverage-map.md @@ -48,7 +48,7 @@ resolved (§ Surface 1, scope-resolution table). | Surface | Points | ✅ covered-live | ⬜ gap (in-scope) | 🚫 excluded-by-design | |---|---|---|---|---| -| REST (OpenAPI 2.2.0, path groups) | 40 | 7 | 11 | 22 | +| REST (OpenAPI 2.2.0, path groups) | 40 | 9 | 9 | 22 | | SSE events | 11 | 11 | 0 | 0 | | Bifrost provider planes | 8 verbs | 8 | 0 | (10 gated verbs deferred) | @@ -75,6 +75,8 @@ sub-gap). | `POST /agents/define` | ✅ | `tier3.py:175` → `_run_define` | Tier-3 create | | `PATCH /agents/{id}` | ✅ | `tier3.py:219` → `_run_patch` | Tier-3 mutate (system_prompt/model) | | `DELETE /agents/{id}` | ✅ | `tier3.py:242` → `_run_delete` | Tier-3 hard-delete | +| `GET /me` | ✅ | `sessions.py:411` `get_me` → `cli.py` `--whoami` | identity/whoami probe; 401→SessionApiFailed | +| `GET /capabilities` | ✅ | `sessions.py:428` `get_capabilities` → `cli.py` `--whoami` | Echo ephemeral-template discovery | **Sub-gaps inside ✅ path groups** (the method we use is live; a sibling method on the same path is an unwired frontier item — see frontier Tier 1): @@ -95,8 +97,6 @@ on the same path is an unwired frontier item — see frontier Tier 1): | `GET /admin/events` | ⬜ | design-brief §5 v1 **AdminEvents pane**; = issue **#11**, **blocked** on `admin.events.read` scope (infra-ops) | | `GET /admin/sessions/{id}/bifrost` | ⬜ | design-brief §5 v1 **BifrostState widget** — never built; admin-key-gated | | `GET /admin/sessions/{id}/tools` | ⬜ | design-brief §5 v1 **Tools widget** — never built; admin-key-gated | -| `GET /capabilities` | ⬜ | server capability discovery — a turn flows through what's advertised | -| `GET /me` | ⬜ | whoami / key-identity — "which key am I against" is a debug primitive | | (`GET /sessions` picker · resume) | ⬜ | sub-gaps above — presenter-wiring only, wrappers exist | **Tier 2 — rounds out I/O coverage under A (postdates the design-brief):** diff --git a/persistent-memory.md b/persistent-memory.md index 0be6b6f..48d695b 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -153,6 +153,8 @@ decision. Captures rationale that won't be obvious from code alone. - `[2026-06-30]` **(b2) session-picker SHIPPED (`v0.18.7`) — bare TUI mode → startup picker (design-brief §4).** `list_sessions` had NO caller; now bare TUI mode (no `--session`/`--new`) resolves via `list_sessions` in `_resolve_then_run`: **0 sessions → `[no_sessions]` error, exit 14** (resume-only, honors §4 "no in-app session creation — `--new` flag only"); **exactly 1 → auto-resume** (§4 "picker only when >1"); **≥2 → new `SessionPickerApp`** (Textual `App[str|None]`, mirrors `AgentPickerApp`; ListView of sessions) → resume the pick (Esc/Ctrl-D → exit 0). cli `_parse` relaxed: bare TUI now VALID (was "pass exactly one" error); `--send` still requires one flag (non-interactive, no picker); `--agent` forbidden in bare mode; `run_tui` PRE-002 XOR→"not both". Direct in-session TDD (contract #6 amendment, validated OK): 3 widget pilot tests + 5 `_resolve_then_run` resolution tests + 3 cli validation tests. Suite **528 green**; touched code ruff-clean (mypy: only the `BINDINGS` list-invariance warning every App in tui.py already carries — consistent). **DESIGN NOTE — bare+0-sessions → error (clause-consistent). The friendlier auto-fall-through-to-new alternative is DEFERRED pending operator preference (it would create a session without `--new`, against the §4 negative clause).** **Frontier now: `GET /capabilities`+`GET /me` → BifrostState/Tools widgets (`GET /admin/sessions/{id}/{bifrost,tools}`, admin-key) → #11 AdminEvents (BLOCKED on `admin.events.read`).** heid-code-review NOT run on b1 or b2 (offered). +- `[2026-06-30]` **capabilities+me slice SHIPPED (`v0.18.8`) — `GET /me` + `GET /capabilities` consumed via a new `--whoami` one-shot.** `get_me`/`get_capabilities` added to sessions.py (mirror `get_persona_state`: 200→dict verbatim, non-200→`SessionApiFailed`; freeform dicts per the frozen OpenAPI). New `ratatoskr --whoami` CLI mode (mirrors `--send`'s non-interactive shape) fetches both + prints an identity+capabilities report; standalone probe (mutually exclusive with `--send`/`--session`/`--new`/`--agent`, opens no session; new `ParsedArgs.whoami` field + main() dispatch). **`/capabilities` is the Echo EPHEMERAL-TEMPLATE discovery endpoint** (`{ephemeral_templates:{echo:{allowed_models,default_model,system_prompt_max_bytes}}}`), NOT a generic server-caps endpoint (audit finding — the coverage-map's earlier "server capability discovery" framing was imprecise). `/me` = whoami (`{user_id,scopes,tier,key_id?,...}`, optionals omitted-not-null). Contract-skip privilege invoked (low-effort GET wrappers) but contract #2 amended (2 FNs, validated OK) to keep the sessions spec canonical + honest test citations. TDD: 5 wrapper tests + 5 cli tests (validation + mode + error). Suite **538 green**; touched code ruff-clean (mypy: only `no-any-return` on `resp.json()`→dict, identical to the pre-existing `get_persona_state`). **Coverage: REST 9/40 ✅ (up from 7).** TUI-surfacing of /me (footer identity line) + /capabilities DEFERRED — the one-shot is the minimal tracer. **Frontier now: BifrostState + Tools widgets (`GET /admin/sessions/{id}/{bifrost,tools}`, admin-key-gated) → #11 AdminEvents (BLOCKED on `admin.events.read`).** + _41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._ _For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._ diff --git a/pyproject.toml b/pyproject.toml index 353f014..9be7390 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.18.7" +version = "0.18.8" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/cli.py b/src/ratatoskr/cli.py index 43e5689..40505eb 100644 --- a/src/ratatoskr/cli.py +++ b/src/ratatoskr/cli.py @@ -12,7 +12,7 @@ import signal import sys from dataclasses import dataclass, field from importlib.metadata import PackageNotFoundError, version -from typing import TextIO +from typing import Any, TextIO import httpx @@ -24,6 +24,8 @@ from ratatoskr.sessions import ( SessionApiFailed, create_session, endpoint_for_plane, + get_capabilities, + get_me, ) from ratatoskr.sse_client import ( AffectUpdate, @@ -97,6 +99,9 @@ class ParsedArgs: bifrost: BifrostBinding | None = None bifrost_plane: str | None = None consumer_key: str | None = None + # Standalone boot-time orientation probe: GET /me + GET /capabilities, print, + # exit. Mutually exclusive with the session/turn flags (opens no session). + whoami: bool = False class _ArgparseError(Exception): @@ -121,6 +126,7 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs: parser.add_argument("--api-key", dest="api_key") parser.add_argument("--server") parser.add_argument("--raw", action="store_true") + parser.add_argument("--whoami", action="store_true") # Issue #5: required for per-end-user agents (lofn etc.); optional otherwise (mimir). parser.add_argument("--end-user-id", dest="end_user_id", default=None) # Issue #17: bind the created session to our own Bifrost provider plane. @@ -141,23 +147,32 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs: # Issue #5 INV-001: --end-user-id, if passed, MUST be non-empty (mirrors --send). if ns.end_user_id is not None and not ns.end_user_id: raise UsageError("--end-user-id must be non-empty when passed") - if ns.session and ns.new: - raise UsageError("--session and --new are mutually exclusive") - if not ns.session and not ns.new: - # Bare TUI mode → startup session picker (design-brief §4). --send is - # non-interactive (no picker can open), so it still requires one flag; - # --agent belongs with --new (bare mode resumes, it doesn't create). - if ns.send is not None: - raise UsageError("--send requires --session or --new (no interactive picker)") - if ns.agent: - raise UsageError("--agent belongs with --new; bare TUI mode opens the session picker") - if ns.session and ns.agent: - raise UsageError("--agent is required with --new and forbidden with --session") - if ns.new and not ns.agent and ns.send is not None: - # Issue #8: --agent stays required for --send --new (non-interactive, - # cannot prompt). Bare --new (TUI mode) accepts None — picker drives - # the choice via list_agents in _resolve_then_run. - raise UsageError("--agent is required when --new is passed in --send mode") + if ns.whoami: + # Standalone boot-time probe (GET /me + /capabilities): opens no session. + if ns.send is not None or ns.session or ns.new or ns.agent: + raise UsageError( + "--whoami is a standalone probe (no --send/--session/--new/--agent)" + ) + else: + if ns.session and ns.new: + raise UsageError("--session and --new are mutually exclusive") + if not ns.session and not ns.new: + # Bare TUI mode → startup session picker (design-brief §4). --send is + # non-interactive (no picker can open), so it still requires one flag; + # --agent belongs with --new (bare mode resumes, it doesn't create). + if ns.send is not None: + raise UsageError("--send requires --session or --new (no interactive picker)") + if ns.agent: + raise UsageError( + "--agent belongs with --new; bare TUI mode opens the session picker" + ) + if ns.session and ns.agent: + raise UsageError("--agent is required with --new and forbidden with --session") + if ns.new and not ns.agent and ns.send is not None: + # Issue #8: --agent stays required for --send --new (non-interactive, + # cannot prompt). Bare --new (TUI mode) accepts None — picker drives + # the choice via list_agents in _resolve_then_run. + raise UsageError("--agent is required when --new is passed in --send mode") api_key = ns.api_key or os.environ.get("WORLDTREE_API_KEY") or "" if not api_key: @@ -206,6 +221,7 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs: bifrost=bifrost, bifrost_plane=bifrost_plane, consumer_key=consumer_key, + whoami=ns.whoami, ) @@ -556,6 +572,56 @@ async def _amain(args: ParsedArgs) -> int: loop.remove_signal_handler(signal.SIGINT) +def _format_whoami(me: dict[str, Any], caps: dict[str, Any]) -> str: + """Render the --whoami report: identity (GET /me) + server capabilities.""" + lines = ["identity:"] + lines.append(f" user_id: {me.get('user_id', '?')}") + lines.append(f" tier: {me.get('tier', '?')}") + lines.append(f" scopes: {', '.join(me.get('scopes', [])) or '(none)'}") + for k in ("display_name", "key_id", "key_label"): + if k in me: + lines.append(f" {k}: {me[k]}") + lines.append("capabilities:") + templates = caps.get("ephemeral_templates", {}) + if templates: + for name, spec in templates.items(): + models = ", ".join(spec.get("allowed_models", [])) + lines.append( + f" ephemeral_template {name}: default={spec.get('default_model', '?')} " + f"max_bytes={spec.get('system_prompt_max_bytes', '?')} models=[{models}]" + ) + else: + lines.append(" (no ephemeral templates advertised)") + return "\n".join(lines) + "\n" + + +async def _whoami(args: ParsedArgs) -> int: + """--whoami one-shot: GET /me + GET /capabilities, print a compact report, exit. + + A boot-time orientation probe (mirrors --send's non-interactive shape): + "who am I against this server, and what does it offer." Opens no session. + Errors land on stderr with the same [session_api_failed] / [network_error] + vocab + exit codes as the other modes. + """ + assert isinstance(args, ParsedArgs) + async with httpx.AsyncClient( + base_url=args.server_url, + headers={"Authorization": f"Bearer {args.api_key}", "User-Agent": USER_AGENT}, + timeout=httpx.Timeout(connect=10.0, read=10.0, write=10.0, pool=10.0), + ) as client: + try: + me = await get_me(client) + caps = await get_capabilities(client) + except SessionApiFailed as exc: + sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n") + return 20 + except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc: + sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n") + return 21 + sys.stdout.write(_format_whoami(me, caps)) + return 0 + + def main(argv: list[str] | None = None) -> int: """Sync entry point. Maps UsageError/_AuthError to exit codes BEFORE the event loop.""" assert argv is None or all(isinstance(a, str) for a in argv) @@ -571,6 +637,8 @@ def main(argv: list[str] | None = None) -> int: # argparse's --help / --version short-circuit via SystemExit(0). Pass the code # through verbatim — argparse already printed help to stdout. return int(exc.code) if exc.code is not None else 0 + if args.whoami: + return asyncio.run(_whoami(args)) if args.send_content is None: # TUI mode — lazy import preserves INV-001 (no textual in cli at module scope). from ratatoskr.tui import run_tui diff --git a/src/ratatoskr/sessions.py b/src/ratatoskr/sessions.py index 0d626ec..9fd7310 100644 --- a/src/ratatoskr/sessions.py +++ b/src/ratatoskr/sessions.py @@ -406,3 +406,35 @@ async def get_persona_state( if resp.status_code == 403 and error_code == "auth_scope_denied": raise AuthScopeDenied(scope="persona.read") raise SessionApiFailed(status=resp.status_code, body=resp.content) + + +async def get_me(client: httpx.AsyncClient) -> dict[str, Any]: + """GET /me — the authenticated principal's identity + key metadata (spec §GET /me). + + Boot-time whoami: verify the key without agent-config side effects. Returns + the parsed dict verbatim (freeform per the frozen OpenAPI; the spec documents + `{user_id, scopes, tier, display_name?, key_id?, key_label?, ...}`, optional + fields omitted-not-null). 401 (bad/absent key when auth is enabled) — like + every other non-200 — surfaces as SessionApiFailed (get_persona_state + precedent). Read-only, rate-exempt, no audit emission. + """ + assert client is not None + resp = await client.get("/me") + if resp.status_code == 200: + return resp.json() + raise SessionApiFailed(status=resp.status_code, body=resp.content) + + +async def get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]: + """GET /capabilities — server capability discovery (spec §Ephemeral Templates). + + Returns `{ephemeral_templates: {echo: {allowed_models, default_model, + system_prompt_max_bytes}}}` — what the server offers before a client decides + to instantiate. Any authenticated caller may read it (no scope). Parsed dict + verbatim; any non-200 → SessionApiFailed. + """ + assert client is not None + resp = await client.get("/capabilities") + if resp.status_code == 200: + return resp.json() + raise SessionApiFailed(status=resp.status_code, body=resp.content) diff --git a/tests/test_cli.py b/tests/test_cli.py index fc887e1..2ba9633 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1564,3 +1564,72 @@ class TestBifrostBindCli: ) rc = await _amain(args) assert rc == 22 + + +class TestWhoami: + """--whoami one-shot probe (slice: capabilities+me): GET /me + GET /capabilities.""" + + def test_whoami_standalone_accepted(self) -> None: + """whoami_standalone_accepted: --whoami alone → valid; whoami=True, no turn flags.""" + args = _parse_args(["--whoami", "--api-key", "k"]) + assert args.whoami is True + assert args.send_content is None + assert args.session_id is None + assert args.new is False + + def test_whoami_with_send_rejected(self) -> None: + """whoami_with_send_rejected [adversarial]: --whoami + --send → UsageError.""" + with pytest.raises(UsageError, match="standalone probe"): + _parse_args(["--whoami", "--send", "hi", "--api-key", "k"]) + + def test_whoami_with_new_rejected(self) -> None: + """whoami_with_new_rejected [adversarial]: --whoami + --new → UsageError.""" + with pytest.raises(UsageError, match="standalone probe"): + _parse_args(["--whoami", "--new", "--agent", "m", "--api-key", "k"]) + + @respx.mock + def test_whoami_mode_prints_report(self, capsys: pytest.CaptureFixture[str]) -> None: + """whoami_mode_prints_report [happy,tracer]: /me + /capabilities → stdout report; exit 0.""" + respx.get("https://w.example/me").mock( + return_value=httpx.Response( + 200, + json={ + "user_id": "alice", + "scopes": ["conversations.read", "conversations.write"], + "tier": "user", + "key_id": "a1b2c3d4", + }, + ) + ) + respx.get("https://w.example/capabilities").mock( + return_value=httpx.Response( + 200, + json={ + "ephemeral_templates": { + "echo": { + "allowed_models": ["glm5-turbo"], + "default_model": "glm5-turbo", + "system_prompt_max_bytes": 32768, + } + } + }, + ) + ) + rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"]) + assert rc == 0 + out = capsys.readouterr().out + assert "user_id: alice" in out + assert "tier: user" in out + assert "key_id: a1b2c3d4" in out + assert "ephemeral_template echo" in out + assert "glm5-turbo" in out + + @respx.mock + def test_whoami_me_auth_failure_exits_20(self, capsys: pytest.CaptureFixture[str]) -> None: + """whoami_me_auth_failure [error]: /me 401 → exit 20 [session_api_failed].""" + respx.get("https://w.example/me").mock( + return_value=httpx.Response(401, json={"detail": "auth_invalid"}) + ) + rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"]) + assert rc == 20 + assert "[session_api_failed]" in capsys.readouterr().err diff --git a/tests/test_sessions.py b/tests/test_sessions.py index dd21c65..b9fcfbc 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -18,6 +18,8 @@ from ratatoskr.sessions import ( SessionPage, create_session, endpoint_for_plane, + get_capabilities, + get_me, get_persona_state, list_agents, list_sessions, @@ -896,3 +898,96 @@ class TestGetPersonaState: with pytest.raises(PersonaNotConfigured) as exc_info: await get_persona_state(client, "domari") assert exc_info.value.agent_id == "domari" + + +class TestGetMe: + """docs/contracts/issues/2.contract.md FN get_me (slice: capabilities+me).""" + + @respx.mock + async def test_happy_authenticated(self) -> None: + """happy_authenticated [happy,tracer]: 200 → parsed identity dict verbatim.""" + respx.get("https://w.example/me").mock( + return_value=httpx.Response( + 200, + json={ + "user_id": "alice", + "scopes": ["conversations.read", "conversations.write"], + "tier": "user", + "key_id": "a1b2c3d4", + "key_label": "alice phone", + }, + ) + ) + async with httpx.AsyncClient(base_url="https://w.example") as client: + me = await get_me(client) + assert me["user_id"] == "alice" + assert me["tier"] == "user" + assert me["key_id"] == "a1b2c3d4" + assert me["scopes"] == ["conversations.read", "conversations.write"] + + @respx.mock + async def test_anonymous_dev_mode(self) -> None: + """anonymous_dev_mode: 200 anonymous shape → dict with tier=anonymous.""" + respx.get("https://w.example/me").mock( + return_value=httpx.Response( + 200, + json={ + "user_id": "anonymous", + "scopes": ["conversations.read"], + "tier": "anonymous", + }, + ) + ) + async with httpx.AsyncClient(base_url="https://w.example") as client: + me = await get_me(client) + assert me["tier"] == "anonymous" + assert "key_id" not in me # optional fields omitted, not null + + @respx.mock + async def test_401_raises_session_api_failed(self) -> None: + """401_raises [error]: bad/absent key → SessionApiFailed(status=401).""" + respx.get("https://w.example/me").mock( + return_value=httpx.Response(401, json={"detail": "auth_invalid"}) + ) + async with httpx.AsyncClient(base_url="https://w.example") as client: + with pytest.raises(SessionApiFailed) as exc: + await get_me(client) + assert exc.value.status == 401 + + +class TestGetCapabilities: + """docs/contracts/issues/2.contract.md FN get_capabilities (slice: capabilities+me).""" + + @respx.mock + async def test_happy(self) -> None: + """happy [happy]: 200 → ephemeral_templates dict verbatim.""" + respx.get("https://w.example/capabilities").mock( + return_value=httpx.Response( + 200, + json={ + "ephemeral_templates": { + "echo": { + "allowed_models": ["glm5-turbo", "glm4.7"], + "default_model": "glm5-turbo", + "system_prompt_max_bytes": 32768, + } + } + }, + ) + ) + async with httpx.AsyncClient(base_url="https://w.example") as client: + caps = await get_capabilities(client) + echo = caps["ephemeral_templates"]["echo"] + assert echo["default_model"] == "glm5-turbo" + assert echo["system_prompt_max_bytes"] == 32768 + + @respx.mock + async def test_non_200_raises(self) -> None: + """non_200_raises [error]: 500 → SessionApiFailed(status=500).""" + respx.get("https://w.example/capabilities").mock( + return_value=httpx.Response(500, content=b"boom") + ) + async with httpx.AsyncClient(base_url="https://w.example") as client: + with pytest.raises(SessionApiFailed) as exc: + await get_capabilities(client) + assert exc.value.status == 500 diff --git a/uv.lock b/uv.lock index 793ebdc..e9247f8 100644 --- a/uv.lock +++ b/uv.lock @@ -1052,7 +1052,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.18.7" +version = "0.18.8" source = { editable = "." } dependencies = [ { name = "httpx" },