Compare commits

...

8 Commits

Author SHA1 Message Date
Daniel Lok 7b1fe038f5 chore(openapi): regenerate spec against latest main
Rebased onto current main, which added new routes. Regenerated the spec
to cover them:
- POST /v1/sessions/{session_id}/hooks/antigravity-elicitation-request
- POST /v1/sessions/{session_id}/hooks/native-permission-request
- GET/POST  /v1/sessions/{session_id}/agent/mcp-servers
- PUT/DELETE /v1/sessions/{session_id}/agent/mcp-servers/{server_name}

The MCP routes carry a new `session_mcp_servers` tag, so add a matching
_TAGS entry ("Session MCP Servers", placed after Session Resources) with
a display name and description — otherwise the reference would render a
raw, undescribed snake_case group (the latent gap Polly flagged).

Spec is the output of `python scripts/dump_openapi.py`; drift test
passes and the zero-reST invariant holds.

Co-authored-by: Isaac
2026-06-25 15:42:52 +08:00
Daniel Lok 37c5ffa67d fix(openapi): clarify conditional session cookie name and _TAGS scope
Address Polly review notes on the OpenAPI enrichment:

- The session cookie is `__Host-ap_session` only under HTTPS
  (secure_cookies); on plain HTTP it is `ap_session`. Since the sole
  advertised server is http://127.0.0.1:6767, name the sessionCookieAuth
  scheme `ap_session` to match and document the HTTPS-prefixed variant in
  both the scheme description and info.description.
- Note in a comment that _TAGS intentionally covers only the stub-build
  surface emitted by generate_spec() (terminals is WebSocket-only; auth
  is absent unless a login_url provider is configured), so a future HTTP
  route there gets a tag rather than silently rendering undescribed.

Co-authored-by: Isaac
2026-06-25 15:39:44 +08:00
Daniel Lok 065dbbe357 feat(openapi): give session-list endpoints typed item schemas
GET /v1/sessions and .../child_sessions pointed their 200 schema at the
shared PaginatedList, whose `data` is `list[Any]` (it is reused across
endpoints with heterogeneous item types) — so the rendered reference
example showed an unhelpful empty `data: []`.

Add typed paginated models mirroring the existing
SessionResourcePaginatedList: SessionList (`data: list[SessionListItem]`)
and ChildSessionList (`data: list[ChildSessionSummary]`), and point the
two endpoints at them via responses={200: {"model": ...}} (response_model
stays None — no runtime change). The reference now renders a populated
SessionListItem / ChildSessionSummary example, and both item models are
materialized into components.schemas.

list_session_items keeps PaginatedList: its items are a heterogeneous
transcript union with no single concrete model.

Co-authored-by: Isaac
2026-06-25 15:39:44 +08:00
Daniel Lok 746836cce6 feat(openapi): convert reST in schema/model docstrings, not just operations
The first reST→Markdown pass only handled operation descriptions, so
Pydantic model docstrings still leaked raw `:param:` field lists into
`components.schemas.*.description` (e.g. Delete Session → ConversationDeleted
rendered ":param id: ... :param object: ..." as literal text).

Generalize the conversion:
- extract a shared parser/rebuilder (`_parse_rst_doc` / `_reformat_doc`);
- reformat every component schema recursively, moving each `:param name:`
  onto the matching `properties[name].description`;
- reformat response descriptions too;
- add a final pass normalizing inline `:role:`X`` roles and `` ``literal`` ``
  spans across all remaining descriptions (responses, info, tags, security);
- flatten multi-line `` ``...`` `` literals containing nested backticks into
  one valid Markdown code span.

Verified: zero residual reST markers anywhere in the spec; ruff clean;
drift test passes.

Co-authored-by: Isaac
2026-06-25 15:39:44 +08:00
Daniel Lok 5016a43aff feat(openapi): render reST docstrings as Markdown in the reference
FastAPI uses each route handler's docstring verbatim as the operation
description, but our docstrings are Sphinx/reST — `:param:` / `:returns:`
/ `:raises:` field lists and inline `:class:`Foo`` roles. Docs renderers
(Scalar) treat the description as Markdown, so the field lists collapsed
into one unreadable run of literal `:param x:` text.

Add a post-processing pass in dump_openapi.py that converts each
operation's reST docstring to Markdown:
- `:param name:` whose name matches a query/path parameter is moved onto
  that parameter's description (renders inline in the parameter table);
- request-body / form `:param` entries become a **Parameters** list;
- `:returns:` -> **Returns:** line, `:raises:` -> **Raises** list;
- framework-internal params (request/response/...) are dropped;
- inline `:role:`X`` roles and reST `` ``X`` `` literals normalize to
  Markdown `` `X` `` code spans.

Regenerate openapi.json; drift test passes.

Co-authored-by: Isaac
2026-06-25 15:39:44 +08:00
Daniel Lok 1600ebb30b feat(openapi): advertise response schemas for session read/write endpoints
The session-level reads/writes set response_model=None (to skip FastAPI's
response re-validation/serialization), which left their success-response
bodies with an empty schema — so the rendered reference showed `null`
examples. Declare the body schema via responses={<code>: {"model": <Model>}}
on the ten endpoints that return a clean Pydantic model (SessionResponse,
PaginatedList, PermissionObject, ConversationDeleted), keeping
response_model=None so runtime behavior is unchanged.

Proxy / raw-Response / content-type-dispatch routes are left as-is — they
have no clean schema to advertise. openapi.json regenerated (37 -> 27
empty-schema operations); drift test passes.

Co-authored-by: Isaac
2026-06-25 15:39:44 +08:00
Daniel Lok b6a232a028 feat(openapi): hide internal endpoints and split out session resources
Mark internal plumbing with include_in_schema=False so it stays out of
the published spec and the public reference: the three harness callback
webhooks (hooks/*), the MCP proxy, Post Event, the elicitation get +
resolve pair, the environment file-diff endpoint, and terminal transfer
(9 operations; 78 -> 69).

Split the session-resource subtree (.../sessions/{id}/resources — files,
terminals, sandboxed environments) out of the broad "Sessions" group
into its own "Session Resources" section. The sessions router inherits a
single tag from include_router, so the split is a prefix-based retag in
dump_openapi.py rather than a router refactor.

Co-authored-by: Isaac
2026-06-25 15:39:44 +08:00
Daniel Lok 860ea550a1 feat(openapi): enrich spec metadata and sync reference to the site
Add the document-level metadata that docs/SDK tooling needs but FastAPI
doesn't emit — info.description (purpose, base URL, cookie/proxy auth
model), servers (127.0.0.1:6767), top-level tags with descriptions and
display order, securitySchemes (proxy header + session cookie), and a
synthetic `system` tag for the untagged utility endpoints — in
scripts/dump_openapi.py, and regenerate openapi.json.

Add .github/workflows/sync-openapi-to-site.yml: when openapi.json
changes on main, mint a token from the omnigent-ci App and open/update
a PR on omnigent-site that copies the spec into public/openapi.json,
where it is rendered as the public API reference.

Co-authored-by: Isaac
2026-06-25 15:39:44 +08:00
5 changed files with 2953 additions and 755 deletions
@@ -0,0 +1,95 @@
name: Sync OpenAPI to site
# Keeps the public API reference on the omnigent website in sync with
# the spec generated here. When openapi.json changes on main, copy it
# into omnigent-site/public/openapi.json and open (or update) a PR there.
#
# Cross-repo writes can't use the workflow's own GITHUB_TOKEN (it's
# scoped to this repo), so we mint a short-lived token from the
# omnigent-ci GitHub App — the same App used by oss-regen-on-comment.yml
# — scoped to omnigent-site. The App must be installed on omnigent-site
# with contents + pull-requests write.
on:
push:
branches: [main]
paths: [openapi.json]
# Manual trigger for backfills / re-syncs after editing this workflow.
workflow_dispatch:
# One sync at a time; a newer spec supersedes an in-flight run.
concurrency:
group: sync-openapi-to-site
cancel-in-progress: true
permissions:
contents: read
jobs:
sync:
name: Open sync PR on omnigent-site
runs-on: ubuntu-latest
# Skip cleanly on forks / installs where the App isn't configured,
# rather than failing the token step with a confusing error.
if: ${{ vars.OMNIGENT_BOT_APP_ID != '' }}
env:
SYNC_BRANCH: auto/openapi-sync
TARGET_REPO: ${{ github.repository_owner }}/omnigent-site
steps:
- name: Checkout omnigent (spec source)
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
path: omnigent
- name: Mint App token for omnigent-site
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ vars.OMNIGENT_BOT_APP_ID }}
private-key: ${{ secrets.OMNIGENT_BOT_APP_KEY }}
owner: ${{ github.repository_owner }}
repositories: omnigent-site
- name: Checkout omnigent-site (sync target)
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
repository: ${{ env.TARGET_REPO }}
token: ${{ steps.app-token.outputs.token }}
path: site
- name: Copy spec into the site
run: cp omnigent/openapi.json site/public/openapi.json
# Commit + push to a fixed branch and open a PR if one isn't
# already open. If a PR exists, the force-push updates it in place
# — so repeated spec changes collapse into a single rolling PR.
- name: Open or update sync PR
working-directory: site
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
if [ -z "$(git status --porcelain -- public/openapi.json)" ]; then
echo "openapi.json already in sync — nothing to do."
exit 0
fi
git config user.name "omnigent-ci[bot]"
git config user.email "294685417+omnigent-ci[bot]@users.noreply.github.com"
git switch -C "$SYNC_BRANCH"
git add public/openapi.json
git commit -m "chore(api): sync openapi.json from omnigent@${GITHUB_SHA:0:7}"
git push --force origin "$SYNC_BRANCH"
if [ -n "$(gh pr list --head "$SYNC_BRANCH" --state open --json number --jq '.[].number')" ]; then
echo "PR already open for $SYNC_BRANCH — the force-push updated it."
exit 0
fi
# Build the body with printf so YAML block indentation never
# leaks leading spaces into the Markdown.
short="${GITHUB_SHA:0:7}"
body="$(printf 'Automated sync of `public/openapi.json` from [omnigent@`%s`](https://github.com/%s/commit/%s).\n\nGenerated by `.github/workflows/sync-openapi-to-site.yml`. Merging publishes the updated API reference at `/reference`.' "$short" "$GITHUB_REPOSITORY" "$GITHUB_SHA")"
gh pr create \
--base main \
--head "$SYNC_BRANCH" \
--title "chore(api): sync OpenAPI reference from omnigent" \
--body "$body"
+39 -6
View File
@@ -193,6 +193,7 @@ from omnigent.server.routes._host_worktree import CreatedWorktree
from omnigent.server.routes._origin import require_trusted_origin
from omnigent.server.schemas import (
AgentObject,
ChildSessionList,
ChildSessionSummary,
CompletedEvent,
ConversationDeleted,
@@ -228,6 +229,7 @@ from omnigent.server.schemas import (
SessionInterruptedEvent,
SessionInterruptedPayload,
SessionLabelsResponse,
SessionList,
SessionListItem,
SessionModelEvent,
SessionModelOptionsEvent,
@@ -13063,8 +13065,11 @@ def create_sessions_router(
@router.get(
"/sessions/{session_id}",
# See create_session for the response_model=None rationale.
# See create_session for the response_model=None rationale. We keep
# response_model=None (no response re-validation/serialization) but
# still advertise the body schema for docs/SDK tooling via responses=.
response_model=None,
responses={200: {"model": SessionResponse}},
)
async def get_session(
request: Request,
@@ -13173,6 +13178,7 @@ def create_sessions_router(
@router.get(
"/sessions",
response_model=None,
responses={200: {"model": SessionList}},
)
async def list_sessions(
request: Request,
@@ -13700,6 +13706,7 @@ def create_sessions_router(
@router.patch(
"/sessions/{session_id}",
response_model=None,
responses={200: {"model": SessionResponse}},
)
async def update_session(
request: Request,
@@ -14073,10 +14080,11 @@ def create_sessions_router(
@router.post(
"/sessions/{source_id}/fork",
status_code=201,
# response_model=None: handler returns SessionResponse
# but we suppress the OpenAPI schema injection to match
# the convention of sibling routes.
# response_model=None keeps FastAPI from re-validating/serializing
# the handler's SessionResponse; responses= still advertises the
# body schema to docs/SDK tooling.
response_model=None,
responses={201: {"model": SessionResponse}},
)
async def fork_session(
request: Request,
@@ -14265,9 +14273,11 @@ def create_sessions_router(
@router.post(
"/sessions/{session_id}/switch-agent",
# response_model=None: handler returns SessionResponse but we
# suppress the OpenAPI schema injection to match sibling routes.
# response_model=None keeps FastAPI from re-validating/serializing
# the handler's SessionResponse; responses= still advertises the
# body schema to docs/SDK tooling.
response_model=None,
responses={200: {"model": SessionResponse}},
)
async def switch_session_agent(
request: Request,
@@ -14476,6 +14486,8 @@ def create_sessions_router(
@router.post(
"/sessions/{session_id}/hooks/permission-request",
# Internal harness callback webhook — hidden from the public API reference.
include_in_schema=False,
response_model=None,
# CSRF hardening: body is parsed via request.json(); require a JSON
# Content-Type so a cross-site text/plain request can't reach it.
@@ -15041,6 +15053,8 @@ def create_sessions_router(
@router.post(
"/sessions/{session_id}/hooks/codex-elicitation-request",
# Internal harness callback webhook — hidden from the public API reference.
include_in_schema=False,
response_model=None,
# CSRF hardening: body is parsed via request.json(); require a JSON
# Content-Type so a cross-site text/plain request can't reach it.
@@ -15203,6 +15217,8 @@ def create_sessions_router(
@router.post(
"/sessions/{session_id}/hooks/cursor-permission-request",
# Internal harness callback webhook — hidden from the public API reference.
include_in_schema=False,
response_model=None,
# CSRF hardening: body is parsed via request.json(); require a JSON
# Content-Type so a cross-site text/plain request can't reach it.
@@ -15394,6 +15410,7 @@ def create_sessions_router(
@router.get(
"/sessions/{session_id}/items",
response_model=None,
responses={200: {"model": PaginatedList}},
)
async def list_session_items(
request: Request,
@@ -15454,6 +15471,7 @@ def create_sessions_router(
@router.get(
"/sessions/{session_id}/child_sessions",
response_model=None,
responses={200: {"model": ChildSessionList}},
)
async def list_child_sessions(
request: Request,
@@ -16023,6 +16041,8 @@ def create_sessions_router(
@router.post(
"/sessions/{session_id}/resources/terminals/{terminal_id}/transfer",
# Internal terminal transfer — hidden from the public API reference.
include_in_schema=False,
response_model=None,
# CSRF hardening: body is parsed via request.json(); require a JSON
# Content-Type so a cross-site text/plain request can't reach it.
@@ -16589,6 +16609,8 @@ def create_sessions_router(
@router.get(
"/sessions/{session_id}/resources/environments/{environment_id}/diff/{relative_path:path}",
# Internal (UI diff view) — hidden from the public API reference.
include_in_schema=False,
response_model=None,
)
async def read_environment_file_diff(
@@ -16822,6 +16844,8 @@ def create_sessions_router(
@router.post(
"/sessions/{session_id}/elicitations/{elicitation_id}/resolve",
# Internal elicitation flow — hidden from the public API reference.
include_in_schema=False,
status_code=202,
# response_model=None: the body is a small acknowledgement
# dict, not a domain model.
@@ -16890,6 +16914,8 @@ def create_sessions_router(
@router.get(
"/sessions/{session_id}/elicitations/{elicitation_id}",
# Internal elicitation flow — hidden from the public API reference.
include_in_schema=False,
response_model=None,
)
async def get_elicitation(
@@ -16946,6 +16972,8 @@ def create_sessions_router(
@router.post(
"/sessions/{session_id}/events",
# Internal event ingestion — hidden from the public API reference.
include_in_schema=False,
status_code=202,
# response_model=None: the body is a small acknowledgement
# dict, not a domain model.
@@ -18089,6 +18117,7 @@ def create_sessions_router(
@router.delete(
"/sessions/{session_id}",
response_model=None,
responses={200: {"model": ConversationDeleted}},
)
async def delete_session(
request: Request,
@@ -18243,6 +18272,7 @@ def create_sessions_router(
@router.put(
"/sessions/{session_id}/permissions",
response_model=None,
responses={200: {"model": PermissionObject}},
)
async def grant_permission(
request: Request,
@@ -18376,6 +18406,7 @@ def create_sessions_router(
@router.get(
"/sessions/{session_id}/permissions",
response_model=None,
responses={200: {"model": list[PermissionObject]}},
)
async def list_permissions(
request: Request,
@@ -18755,6 +18786,8 @@ def create_sessions_router(
@router.post(
"/sessions/{session_id}/mcp",
# Internal MCP proxy — hidden from the public API reference.
include_in_schema=False,
response_model=None, # Returns a raw Response with application/json
# CSRF hardening: the MCP Streamable HTTP contract already mandates
# an application/json request body; enforce it so a cross-site
+20
View File
@@ -1944,6 +1944,26 @@ class SessionListItem(BaseModel):
comments_updated_at: int | None = None
class SessionList(BaseModel):
"""Paginated list of sessions; ``data`` is a page of ``SessionListItem``."""
object: Literal["list"] = "list"
data: list[SessionListItem] = Field(default_factory=list)
first_id: str | None = None
last_id: str | None = None
has_more: bool = False
class ChildSessionList(BaseModel):
"""Paginated list of child sessions; ``data`` is a page of ``ChildSessionSummary``."""
object: Literal["list"] = "list"
data: list[ChildSessionSummary] = Field(default_factory=list)
first_id: str | None = None
last_id: str | None = None
has_more: bool = False
# ── Permissions ────────────────────────────────────────────────────
+2241 -749
View File
File diff suppressed because it is too large Load Diff
+558
View File
@@ -37,6 +37,7 @@ from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
from pathlib import Path
from typing import Any
@@ -86,6 +87,223 @@ _SSE_ROUTES: list[tuple[str, str]] = [
("/v1/sessions/{session_id}/stream", "get"),
]
# ── Document-level enrichment ─────────────────────────────────────
#
# FastAPI emits accurate per-operation schemas but none of the
# document-level metadata an integrator needs: no ``servers``, no auth
# description, no ``info.description``, and only bare snake_case tags.
# We inject that connective tissue here so the published reference
# (rendered by Scalar on the omnigent website) is usable for building
# an integration. Keeping it in this script — rather than scattering it
# across the route decorators — confines presentation concerns to the
# spec-generation layer, and the drift test
# (``tests/server/test_openapi_drift.py``) guards the result.
# Self-hosted base URL. ``omnigent server`` binds 127.0.0.1:6767 by
# default (see ``_DEFAULT_LOCAL_PORT`` in
# ``omnigent/host/local_server.py``).
_SERVERS: list[dict[str, str]] = [
{
"url": "http://127.0.0.1:6767",
"description": "Self-hosted Omnigent server (default local port).",
},
]
# Markdown prose shown at the top of the rendered reference. Covers
# what the API is, the self-hosted base URL, and the deployment-driven
# auth model (there is no bearer/API-key scheme — see
# ``omnigent/server/auth.py``).
_INFO_DESCRIPTION: str = """\
Omnigent is an open-source meta-harness for building and running AI \
agents. This is the REST API exposed by the Omnigent server: use it to \
create and drive **sessions**, manage **agents**, **hosts**, and \
**runners**, attach **contextual policies**, post **comments**, and work \
with session **resources** — files, terminals, and sandboxed \
environments.
## Base URL
Omnigent is self-hosted. The server binds `http://127.0.0.1:6767` by \
default (`omnigent server`); point the base URL at your own deployment.
## Authentication
There is no API-key or bearer-token scheme. Identity is supplied by the \
deployment's configured auth provider (`OMNIGENT_AUTH_PROVIDER`):
- **Trusted proxy header** (default) — an upstream proxy injects an \
identity header (`X-Forwarded-Email`, configurable). Single-user local \
runtimes fall back to a reserved `local` user.
- **Session cookie** — a signed session cookie minted after an \
interactive OIDC or accounts login. It is named `ap_session` over HTTP \
(the advertised local default) and `__Host-ap_session` under HTTPS, where \
the `__Host-` prefix guards against subdomain cookie-tossing.
Auth is configured server-side; clients send the cookie or proxy header \
according to your deployment.
## Streaming
`GET /v1/sessions/{session_id}/stream` streams Server-Sent Events \
(`text/event-stream`). Each event conforms to the `ServerStreamEvent` \
schema documented below.
"""
# Auth representations. Omnigent has no bearer/API-key scheme — identity
# arrives via a trusted-proxy header or a signed session cookie,
# selected by ``OMNIGENT_AUTH_PROVIDER``. We model both as OpenAPI
# ``apiKey`` schemes so SDK generators and the reference can surface
# them. We deliberately do NOT assert a top-level ``security``
# requirement: the active scheme is deployment-specific, and public
# endpoints (``/health``, ``/api/version``) require none — the prose in
# :data:`_INFO_DESCRIPTION` carries the human-facing explanation.
_SECURITY_SCHEMES: dict[str, dict[str, str]] = {
"proxyHeaderAuth": {
"type": "apiKey",
"in": "header",
"name": "X-Forwarded-Email",
"description": (
"Trusted-proxy identity header (header-auth mode, the "
"default). The header name is configurable via "
"``OMNIGENT_AUTH_HEADER``."
),
},
"sessionCookieAuth": {
"type": "apiKey",
"in": "cookie",
# Named to match the advertised HTTP server. The ``__Host-``
# prefix requires HTTPS (browsers drop it on plain HTTP), so the
# cookie is ``ap_session`` for the default local deployment and
# ``__Host-ap_session`` only under HTTPS — see ``secure_cookies``
# in ``accounts_config.py`` / ``oidc.py``.
"name": "ap_session",
"description": (
"Signed session cookie minted after an interactive OIDC or "
"accounts login (oidc / accounts auth modes). Named "
"``ap_session`` over HTTP (the advertised local default); "
"under HTTPS the secure ``__Host-ap_session`` prefixed form "
"is used instead."
),
},
}
# Tag display metadata: human descriptions + sidebar order. Each
# ``name`` MUST match the tag FastAPI puts on operations (the route
# decorators use these snake_case values). ``x-displayName`` gives docs
# tooling a readable label in place of the raw tag. Order here is the
# order tags render in the reference sidebar.
#
# This intentionally covers only the stub-build surface that
# ``generate_spec()`` emits: the ``terminals`` router is WebSocket-only
# (no HTTP operations in the spec) and the ``auth`` router is mounted
# only when an auth provider with a ``login_url`` is configured (absent
# in the stub build). If either ever surfaces HTTP operations here, add
# its tag below so the operation doesn't render without a description.
_TAGS: list[dict[str, str]] = [
{
"name": "sessions",
"x-displayName": "Sessions",
"description": (
"Create, inspect, fork, and drive agent sessions — the core "
"unit of work. Covers session items and events, agent "
"binding, permissions, labels, and child sessions. The "
"files, terminals, and sandboxed environments attached to a "
"session live under Session Resources."
),
},
{
"name": "session_resources",
"x-displayName": "Session Resources",
"description": (
"Files, terminals, and sandboxed environments attached to a "
"session: upload and read files, create and manage "
"terminals, and read, write, edit, and search the "
"environment filesystem."
),
},
{
"name": "session_mcp_servers",
"x-displayName": "Session MCP Servers",
"description": (
"Manage the MCP server declarations on a session's bound "
"agent: list the configured servers and create, update, and "
"remove them on session-scoped agents."
),
},
{
"name": "agents",
"x-displayName": "Agents",
"description": "Discover the built-in agents available to bind to a session.",
},
{
"name": "hosts",
"x-displayName": "Hosts",
"description": (
"Hosts that can launch runners. Browse the host filesystem and create directories."
),
},
{
"name": "runners",
"x-displayName": "Runners",
"description": "Launch runners on a host and check their status.",
},
{
"name": "session_policies",
"x-displayName": "Session Policies",
"description": (
"Contextual policies scoped to a single session — list, create, update, and remove."
),
},
{
"name": "default_policies",
"x-displayName": "Default Policies",
"description": "Server-level default policies applied to new sessions.",
},
{
"name": "policy_registry",
"x-displayName": "Policy Registry",
"description": "The catalog of policy types available to instantiate.",
},
{
"name": "comments",
"x-displayName": "Comments",
"description": (
"Threaded comments on a session, including sending a comment to the agent."
),
},
{
"name": "system",
"x-displayName": "System",
"description": "Health, version, and identity endpoints for the running server.",
},
]
# Utility endpoints FastAPI leaves untagged. We assign them a synthetic
# ``system`` tag so they group cleanly in the reference instead of
# floating in an unlabeled "default" bucket. Keyed ``(path, method)``
# like :data:`_SSE_ROUTES`; keep accurate if the route inventory grows.
_SYSTEM_ROUTES: list[tuple[str, str]] = [
("/health", "get"),
("/api/version", "get"),
("/v1/info", "get"),
("/v1/me", "get"),
]
# HTTP methods that denote an operation object inside a path item
# (everything else under a path — ``parameters``, ``servers``, … — is
# not an operation and must be skipped when retagging).
_HTTP_METHODS: frozenset[str] = frozenset(
{"get", "put", "post", "delete", "patch", "options", "head", "trace"},
)
# Path prefix whose operations form the dedicated "Session Resources"
# group. The sessions router is mounted with ``tags=["sessions"]`` in
# app.py, so every session route — including the resource subtree —
# inherits that single tag. We split this subtree (files, terminals,
# sandboxed environments) into its own section in the published
# reference rather than fracturing the router.
_SESSION_RESOURCES_PREFIX: str = "/v1/sessions/{session_id}/resources"
def _build_app_with_stub_stores() -> Any:
"""
@@ -199,6 +417,342 @@ def _rewrite_sse_route(
sse_entry["itemSchema"] = sse_entry.pop("schema")
def _tag_system_routes(paths: dict[str, Any]) -> None:
"""
Assign the synthetic ``system`` tag to untagged utility routes.
FastAPI leaves ``/health``, ``/api/version``, ``/v1/info``, and
``/v1/me`` untagged. Without a tag they render in an unlabeled
"default" bucket in the reference; tagging them groups the lot
under "System". Only fills in a tag where none exists — never
overrides one FastAPI already set.
No-op for any ``(path, method)`` not present, so
:data:`_SYSTEM_ROUTES` stays resilient to inventory changes.
:param paths: The OpenAPI ``paths`` map; mutated in place.
"""
for path, method in _SYSTEM_ROUTES:
op = paths.get(path, {}).get(method)
if op is None:
continue
if not op.get("tags"):
op["tags"] = ["system"]
def _retag_session_resources(paths: dict[str, Any]) -> None:
"""
Move the session-resource subtree into its own ``session_resources`` tag.
Every operation whose path starts with
:data:`_SESSION_RESOURCES_PREFIX` has its tag list *replaced* (not
appended) with ``["session_resources"]`` so it renders as a
dedicated section instead of inheriting the broad ``sessions`` tag.
Prefix-based so newly added resource endpoints group automatically.
:param paths: The OpenAPI ``paths`` map; mutated in place.
"""
for path, methods in paths.items():
if not path.startswith(_SESSION_RESOURCES_PREFIX):
continue
for method, op in methods.items():
if method in _HTTP_METHODS and isinstance(op, dict):
op["tags"] = ["session_resources"]
# ── reStructuredText docstring → Markdown ─────────────────────────
#
# FastAPI uses each route handler's docstring verbatim as the OpenAPI
# operation ``description``. Our docstrings are Sphinx/reST: ``:param
# name:`` / ``:returns:`` / ``:raises Exc:`` field lists and inline
# ``:class:`Foo``` cross-reference roles. Docs renderers (Scalar) treat
# the description as Markdown, so reST field lists collapse into one
# unreadable run of literal text. We convert that markup to Markdown:
#
# * each ``:param name:`` whose name matches a real query/path
# parameter is moved onto that parameter's ``description`` (so it
# renders inline in the parameter table, not in the prose blob);
# * request-body / form ``:param`` entries that have no matching
# parameter become a Markdown ``**Parameters**`` bullet list;
# * ``:returns:`` becomes a ``**Returns:**`` line, ``:raises:`` a
# ``**Raises**`` bullet list;
# * framework-internal params (``request``/``response``/…) are dropped;
# * inline ``:role:`X``` roles collapse to `` `X` `` and reST double
# backticks (`` ``X`` ``) normalize to Markdown single backticks.
# Field-list line markers (matched at column 0; continuation lines are
# indented and accumulate onto the field opened above them).
_RST_PARAM = re.compile(r"^:(?:param|parameter|arg|argument|keyword|kwarg)\s+(\S+)\s*:\s*(.*)$")
_RST_RETURNS = re.compile(r"^:returns?\s*:\s*(.*)$")
_RST_RAISES = re.compile(r"^:raises?\s+([^:]+?)\s*:\s*(.*)$")
# Any other reST field marker (``:rtype:``, ``:type x:``, …) — dropped.
_RST_OTHER_FIELD = re.compile(r"^:[a-zA-Z][\w ]*:")
# Inline cross-reference role, e.g. ``:class:`Foo``` → `` `Foo` ``.
_RST_ROLE = re.compile(r":[a-zA-Z]+:`([^`]+)`")
# reST inline literal (double backtick) → Markdown code span (single).
# Non-greedy + DOTALL so a literal may span lines and contain nested
# single backticks (e.g. a role left inside it); the replacement flattens
# those so the resulting code span is valid.
_RST_DOUBLE_BACKTICK = re.compile(r"``(.+?)``", re.DOTALL)
# Handler parameters that are FastAPI plumbing, not API inputs.
_INTERNAL_PARAMS = frozenset(
{"request", "response", "websocket", "ws", "background_tasks", "bg", "_", "args", "kwargs"},
)
def _rst_double_backtick_to_code(match: re.Match[str]) -> str:
"""Flatten a reST ``literal`` into a single-line Markdown code span."""
inner = re.sub(r"\s+", " ", match.group(1).replace("`", "")).strip()
return f"`{inner}`"
def _rst_inline_to_md(text: str) -> str:
"""Convert inline reST roles / literals in *text* to Markdown."""
text = _RST_ROLE.sub(r"`\1`", text)
return _RST_DOUBLE_BACKTICK.sub(_rst_double_backtick_to_code, text)
def _rst_field_text(lines: list[str]) -> str:
"""Join a field's (possibly multi-line) body into one Markdown string."""
joined = re.sub(r"\s+", " ", " ".join(lines)).strip()
return _rst_inline_to_md(joined)
def _parse_rst_doc(desc: str) -> tuple[str, list[tuple[str, str | None, str]]]:
"""
Split a reST docstring into Markdown prose and parsed fields.
Lines before the first reST field marker are prose; ``:param:`` /
``:returns:`` / ``:raises:`` open a field that subsequent indented
continuation lines accumulate onto. Unknown field markers (e.g.
``:rtype:``) are discarded.
:param desc: The raw (reST) description text.
:returns: ``(prose_markdown, fields)`` where ``fields`` is a list of
``(kind, name, text)`` triples (``kind`` in ``param`` /
``returns`` / ``raises``) with ``text`` already Markdown.
"""
prose: list[str] = []
fields: list[tuple[str, str | None, list[str]]] = []
cur: tuple[str, str | None, list[str]] | None = None
in_fields = False
for line in desc.split("\n"):
param_m = _RST_PARAM.match(line)
if param_m:
in_fields = True
cur = ("param", param_m.group(1).strip().lstrip("*"), [param_m.group(2)])
fields.append(cur)
continue
returns_m = _RST_RETURNS.match(line)
if returns_m:
in_fields = True
cur = ("returns", None, [returns_m.group(1)])
fields.append(cur)
continue
raises_m = _RST_RAISES.match(line)
if raises_m:
in_fields = True
cur = ("raises", raises_m.group(1).strip(), [raises_m.group(2)])
fields.append(cur)
continue
if in_fields and _RST_OTHER_FIELD.match(line):
cur = ("drop", None, []) # unknown field (e.g. :rtype:) — discard
fields.append(cur)
continue
if in_fields:
if cur is not None:
cur[2].append(line)
else:
prose.append(line)
prose_md = _rst_inline_to_md("\n".join(prose).strip())
parsed = [(kind, name, _rst_field_text(body)) for kind, name, body in fields if kind != "drop"]
return prose_md, parsed
def _reformat_doc(
desc: str | None,
targets: dict[str, Any],
internal: frozenset[str] | None = None,
) -> str | None:
"""
Convert one reST ``description`` to Markdown.
Each ``:param name:`` whose ``name`` is a key in *targets* (a
parameter or property object) is moved onto that object's own
``description``; entries with no matching target become a Markdown
``**Parameters**`` list. ``:returns:`` / ``:raises:`` become
``**Returns:**`` / ``**Raises**`` sections. Names in *internal*
(FastAPI plumbing) are dropped.
:param desc: The raw description, or ``None``.
:param targets: Map of name -> object that may receive a moved
``description`` (empty when there are no field targets).
:param internal: Parameter names to drop entirely (``None`` = drop
none, used for schema fields).
:returns: The rebuilt Markdown description, or the original falsy
value when *desc* is empty.
"""
if not desc:
return desc
skip = internal or frozenset()
prose_md, fields = _parse_rst_doc(desc)
body_params: list[tuple[str, str]] = []
raises: list[tuple[str, str]] = []
returns: str | None = None
for kind, name, text in fields:
if kind == "param":
if not text or name in skip:
continue
target = targets.get(name) if name else None
if isinstance(target, dict):
# Move onto the matching field; don't clobber an explicit
# Field/Query description if one already exists.
if not target.get("description"):
target["description"] = text
else:
body_params.append((name or "", text))
elif kind == "raises" and text:
raises.append((name or "", text))
elif kind == "returns" and text:
returns = text
sections: list[str] = []
if prose_md:
sections.append(prose_md)
if body_params:
sections.append("**Parameters**\n\n" + "\n".join(f"- `{n}` — {t}" for n, t in body_params))
if returns:
sections.append(f"**Returns:** {returns}")
if raises:
sections.append("**Raises**\n\n" + "\n".join(f"- `{e}` — {t}" for e, t in raises))
return "\n\n".join(sections)
def _reformat_operation_doc(op: dict[str, Any]) -> None:
"""
Rewrite an operation's (and its responses') reST docs as Markdown.
Matched ``:param:`` entries move onto ``op['parameters']``; response
descriptions are reformatted with no field targets.
:param op: An OpenAPI operation object; mutated in place.
"""
if op.get("description"):
targets = {p.get("name"): p for p in op.get("parameters", []) if isinstance(p, dict)}
op["description"] = _reformat_doc(op["description"], targets, _INTERNAL_PARAMS)
for resp in (op.get("responses") or {}).values():
if isinstance(resp, dict) and resp.get("description"):
resp["description"] = _reformat_doc(resp["description"], {})
def _reformat_schema_node(node: Any) -> None:
"""
Rewrite a JSON-Schema node's reST ``description`` as Markdown.
A model's docstring becomes its schema ``description`` with
``:param name:`` entries describing its fields; each moves onto the
matching ``properties[name]`` description. Recurses into nested
schema positions so inline sub-objects are handled too.
:param node: A JSON-Schema object (non-dicts are ignored); mutated
in place.
"""
if not isinstance(node, dict):
return
if node.get("description"):
props = node.get("properties")
node["description"] = _reformat_doc(
node["description"],
props if isinstance(props, dict) else {},
)
properties = node.get("properties")
if isinstance(properties, dict):
for sub in properties.values():
_reformat_schema_node(sub)
for defs_key in ("$defs", "definitions"):
defs = node.get(defs_key)
if isinstance(defs, dict):
for sub in defs.values():
_reformat_schema_node(sub)
for child_key in ("items", "additionalProperties"):
_reformat_schema_node(node.get(child_key))
for combinator in ("allOf", "anyOf", "oneOf", "prefixItems"):
members = node.get(combinator)
if isinstance(members, list):
for sub in members:
_reformat_schema_node(sub)
def _reformat_descriptions(paths: dict[str, Any]) -> None:
"""Convert every operation's reST description to Markdown in place."""
for methods in paths.values():
for method, op in methods.items():
if method in _HTTP_METHODS and isinstance(op, dict):
_reformat_operation_doc(op)
def _reformat_component_schemas(components: dict[str, Any]) -> None:
"""Convert every component schema's reST description to Markdown."""
schemas = components.get("schemas")
if isinstance(schemas, dict):
for schema in schemas.values():
_reformat_schema_node(schema)
def _normalize_inline_descriptions(node: Any) -> None:
"""
Final safety net: normalize inline reST in any remaining description.
Walks the whole document and converts inline ``:role:`X``` roles and
reST double-backtick literals to Markdown `` `X` `` in every
``description`` string — covering responses, ``info``, tags and
security schemes that the structured passes don't rewrite.
:param node: Any spec fragment; mutated in place.
"""
if isinstance(node, dict):
for key, value in node.items():
if key == "description" and isinstance(value, str):
node[key] = _rst_inline_to_md(value)
else:
_normalize_inline_descriptions(value)
elif isinstance(node, list):
for value in node:
_normalize_inline_descriptions(value)
def _enrich_spec(spec: dict[str, Any]) -> None:
"""
Inject document-level metadata for docs / SDK tooling.
Adds ``info.description``, ``servers``, top-level ``tags`` with
human-readable descriptions, and ``components.securitySchemes`` —
none of which FastAPI emits — tags the untagged utility routes, and
rewrites reST docstrings (operations, parameters, and component
schemas) as Markdown.
Mutates ``spec`` in place. See the module-level enrichment
constants for the rationale behind each value.
:param spec: The generated OpenAPI dict; mutated in place.
"""
info = spec.setdefault("info", {})
info["description"] = _INFO_DESCRIPTION
spec["servers"] = _SERVERS
spec["tags"] = _TAGS
components = spec.setdefault("components", {})
components["securitySchemes"] = _SECURITY_SCHEMES
paths = spec.setdefault("paths", {})
_tag_system_routes(paths)
_retag_session_resources(paths)
_reformat_descriptions(paths)
_reformat_component_schemas(components)
_normalize_inline_descriptions(spec)
def generate_spec() -> dict[str, Any]:
"""
Build, generate, and post-process the OpenAPI 3.2 spec.
@@ -236,6 +790,10 @@ def generate_spec() -> dict[str, Any]:
for path, method in _SSE_ROUTES:
_rewrite_sse_route(paths, path, method)
# Inject document-level metadata (servers, auth, tags, prose) that
# FastAPI doesn't emit but docs / SDK tooling needs.
_enrich_spec(spec)
return spec # type: ignore[no-any-return]