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
10 changes: 10 additions & 0 deletions integrations/adk-middleware/python/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `add_adk_fastapi_endpoint()` and `create_adk_app()` accept extra keyword
arguments and forward them to `app.post` for the agent route (`name`,
`tags`, `operation_id`, `summary`, `dependencies`, `include_in_schema`,
...), so an application can give the route the same metadata, OpenAPI
identity and dependencies as the rest of its API. The derived
`<path>/capabilities` and `/agents/state` routes keep their own identity,
because FastAPI requires a unique `operation_id` and `name` per operation.

## [0.7.0] - 2026-06-22

### Added
Expand Down
15 changes: 14 additions & 1 deletion integrations/adk-middleware/python/src/ag_ui_adk/endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ def add_adk_fastapi_endpoint(
extract_headers: Optional[List[str]] = None,
extract_state_from_request: Optional[Callable[[Request, RunAgentInput], Coroutine[dict[str,Any], Any, Any]]] = None,
agent_resolver: Optional[AgentResolver] = None,
**kwargs: Any,
):
"""Add ADK middleware endpoint to FastAPI app.

Expand All @@ -331,6 +332,11 @@ def add_adk_fastapi_endpoint(
agent_resolver: Optional async function that can select an ``ADKAgent``
for the request after state extraction. Returning ``None`` uses
the default agent.
**kwargs: Forwarded to ``app.post`` for the agent route (``name``,
``tags``, ``operation_id``, ``dependencies``, ``include_in_schema``,
...). They do not apply to the other routes this helper registers,
because values such as ``operation_id`` and ``name`` must stay
unique per operation.

Note:
This function also adds an experimental POST /agents/state endpoint for
Expand Down Expand Up @@ -385,7 +391,7 @@ async def agent_resolver(request, input_data):

default_agent = agent

@app.post(path)
@app.post(path, **kwargs)
async def adk_endpoint(input_data: RunAgentInput, request: Request):
"""ADK middleware endpoint.

Expand Down Expand Up @@ -653,6 +659,7 @@ def create_adk_app(
extract_headers: Optional[List[str]] = None,
extract_state_from_request: Optional[Callable[[Request, RunAgentInput], Coroutine[dict[str,Any], Any, Any]]] = None,
agent_resolver: Optional[AgentResolver] = None,
**kwargs: Any,
) -> FastAPI:
"""Create a FastAPI app with ADK middleware endpoint.

Expand All @@ -667,6 +674,11 @@ def create_adk_app(
agent_resolver: Optional async function that can select an ``ADKAgent``
for the request after state extraction. Returning ``None`` uses
the default agent.
**kwargs: Forwarded to ``app.post`` for the agent route (``name``,
``tags``, ``operation_id``, ``dependencies``, ``include_in_schema``,
...). They do not apply to the other routes this helper registers,
because values such as ``operation_id`` and ``name`` must stay
unique per operation.

Returns:
FastAPI application instance
Expand All @@ -679,5 +691,6 @@ def create_adk_app(
extract_headers=extract_headers,
extract_state_from_request=extract_state_from_request,
agent_resolver=agent_resolver,
**kwargs,
)
return app
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env python
"""Tests for route metadata passthrough on the FastAPI endpoint helper.

The agent route used to be registered with a bare decorator, so an
application embedding the middleware could not set its ``name``, ``tags``,
``operation_id``, ``dependencies`` or ``include_in_schema``. Extra keyword
arguments now reach ``app.post`` for the agent route only: the capabilities
and ``/agents/state`` routes keep their own identity, because FastAPI
requires a unique ``operation_id`` and ``name`` per operation.
"""

import pytest
from unittest.mock import MagicMock
from fastapi import Depends, FastAPI, HTTPException
from fastapi.testclient import TestClient

from ag_ui_adk.endpoint import add_adk_fastapi_endpoint, create_adk_app
from ag_ui_adk.adk_agent import ADKAgent


@pytest.fixture
def mock_agent():
return MagicMock(spec=ADKAgent)


class TestEndpointRouteKwargs:
def test_metadata_lands_on_the_agent_route(self, mock_agent):
app = FastAPI()
add_adk_fastapi_endpoint(
app,
mock_agent,
path="/",
name="adk_agent",
tags=["ADK"],
summary="ADK middleware endpoint",
operation_id="run_agent",
)

operation = app.openapi()["paths"]["/"]["post"]
assert operation["tags"] == ["ADK"]
assert operation["summary"] == "ADK middleware endpoint"
assert operation["operationId"] == "run_agent"
assert app.url_path_for("adk_agent") == "/"

def test_other_routes_keep_their_own_identity(self, mock_agent):
"""A duplicated ``operation_id`` is what breaks OpenAPI client
generators, so the helper's other routes stay untouched."""
app = FastAPI()
add_adk_fastapi_endpoint(
app, mock_agent, path="/", tags=["ADK"], operation_id="run_agent"
)

paths = app.openapi()["paths"]
others = [paths[p][m] for p, m in (("/capabilities", "get"), ("/agents/state", "post"))]
for operation in others:
assert operation["operationId"] != "run_agent"
assert "tags" not in operation

def test_dependencies_guard_the_agent_route_only(self, mock_agent):
def deny():
raise HTTPException(status_code=401, detail="nope")

app = FastAPI()
add_adk_fastapi_endpoint(
app, mock_agent, path="/", dependencies=[Depends(deny)]
)
client = TestClient(app)

# The dependency runs before the body is parsed, so the agent is
# never invoked.
assert client.post("/", json={}).status_code == 401

# The helper's other routes are left unguarded.
routes = {r.path: r for r in app.routes if hasattr(r, "dependencies")}
assert routes["/capabilities"].dependencies == []
assert routes["/agents/state"].dependencies == []

def test_create_adk_app_forwards_route_kwargs(self, mock_agent):
app = create_adk_app(mock_agent, path="/", tags=["ADK"], name="adk_agent")

assert app.openapi()["paths"]["/"]["post"]["tags"] == ["ADK"]
assert app.url_path_for("adk_agent") == "/"

def test_default_registration_is_unchanged(self, mock_agent):
app = FastAPI()
add_adk_fastapi_endpoint(app, mock_agent, path="/")

assert "tags" not in app.openapi()["paths"]["/"]["post"]
24 changes: 19 additions & 5 deletions integrations/agent-spec/python/ag_ui_agentspec/endpoint.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import asyncio

from fastapi import FastAPI, Request
from typing import Any

from fastapi import APIRouter, FastAPI, Request
from fastapi.responses import StreamingResponse

from ag_ui.encoder import EventEncoder
Expand All @@ -14,11 +16,23 @@
from ag_ui_agentspec.agentspec_tracing_exporter import EVENT_QUEUE


def add_agentspec_fastapi_endpoint(app: FastAPI, agentspec_agent: AgentSpecAgent, path: str = "/"):
"""Adds an Agent Spec endpoint to the FastAPI app."""

def add_agentspec_fastapi_endpoint(
app: FastAPI | APIRouter,
agentspec_agent: AgentSpecAgent,
path: str = "/",
**kwargs: Any,
):
"""Adds an Agent Spec endpoint to the FastAPI app.

Args:
app: FastAPI application or APIRouter to register the route on.
agentspec_agent: Agent Spec agent to serve.
path: Path of the agent route.
**kwargs: Forwarded to ``app.post`` (``name``, ``tags``,
``operation_id``, ``dependencies``, ``include_in_schema``, ...).
"""

@app.post(path)
@app.post(path, **kwargs)
async def agentic_chat_endpoint(input_data: RunAgentInput, request: Request):
"""Agentic chat endpoint"""

Expand Down
82 changes: 82 additions & 0 deletions integrations/agent-spec/python/tests/test_endpoint_route_kwargs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Tests for route metadata passthrough on the FastAPI endpoint helper.

The agent route used to be registered with a bare decorator, so an
application embedding the agent could not set its ``name``, ``tags``,
``operation_id``, ``dependencies`` or ``include_in_schema``. Extra keyword
arguments now reach ``app.post`` for the agent route only.
"""

import unittest
from unittest.mock import MagicMock

from fastapi import APIRouter, Depends, FastAPI, HTTPException
from fastapi.testclient import TestClient

from ag_ui_agentspec.agent import AgentSpecAgent
from ag_ui_agentspec.endpoint import add_agentspec_fastapi_endpoint


def _make_agent():
return MagicMock(spec=AgentSpecAgent)


def _register(app, **kwargs):
return add_agentspec_fastapi_endpoint(app, _make_agent(), "/agent", **kwargs)


class TestEndpointRouteKwargs(unittest.TestCase):
def test_metadata_lands_on_the_agent_route(self):
app = FastAPI()
_register(
app,
name="agent_run",
tags=["Agent"],
summary="AG-UI agent endpoint",
operation_id="run_agent",
)

operation = app.openapi()["paths"]["/agent"]["post"]
self.assertEqual(operation["tags"], ["Agent"])
self.assertEqual(operation["summary"], "AG-UI agent endpoint")
self.assertEqual(operation["operationId"], "run_agent")

# ``name`` makes the route reachable by reverse lookup.
self.assertEqual(app.url_path_for("agent_run"), "/agent")

def test_include_in_schema_hides_the_agent_route(self):
app = FastAPI()
_register(app, include_in_schema=False)

self.assertNotIn("/agent", app.openapi()["paths"])

def test_dependencies_guard_the_agent_route(self):
def deny():
raise HTTPException(status_code=401, detail="nope")

app = FastAPI()
_register(app, dependencies=[Depends(deny)])

# The dependency runs before the body is parsed, so the agent is
# never invoked.
self.assertEqual(
TestClient(app).post("/agent", json={}).status_code, 401
)

def test_registers_on_an_api_router(self):
router = APIRouter()
_register(router, name="agent_run")

app = FastAPI()
app.include_router(router, prefix="/v1")

self.assertEqual(app.url_path_for("agent_run"), "/v1/agent")

def test_default_registration_is_unchanged(self):
app = FastAPI()
_register(app)

self.assertNotIn("tags", app.openapi()["paths"]["/agent"]["post"])


if __name__ == "__main__":
unittest.main()
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from fastapi import FastAPI, Request
from typing import Any

from fastapi import APIRouter, FastAPI, Request
from fastapi.responses import StreamingResponse

from ag_ui.core.types import RunAgentInput
Expand All @@ -7,10 +9,26 @@
from .adapter import ClaudeAgentAdapter


def add_claude_fastapi_endpoint(app: FastAPI, adapter: ClaudeAgentAdapter, path: str = "/"):
"""Adds a Claude Agent SDK endpoint to the FastAPI app."""

@app.post(path)
def add_claude_fastapi_endpoint(
app: FastAPI | APIRouter,
adapter: ClaudeAgentAdapter,
path: str = "/",
**kwargs: Any,
):
"""Adds a Claude Agent SDK endpoint to the FastAPI app.

Args:
app: FastAPI application or APIRouter to register the routes on.
adapter: Claude Agent SDK adapter to serve.
path: Path of the agent route.
**kwargs: Forwarded to ``app.post`` for the agent route (``name``,
``tags``, ``operation_id``, ``dependencies``, ``include_in_schema``,
...). They do not apply to the other routes this helper registers,
because values such as ``operation_id`` and ``name`` must stay
unique per operation.
"""

@app.post(path, **kwargs)
async def claude_agent_endpoint(input_data: RunAgentInput, request: Request):
accept_header = request.headers.get("accept")
encoder = EventEncoder(accept=accept_header)
Expand Down
Loading
Loading