Skip to content

Commit d257e80

Browse files
anshal21claude
andcommitted
Add Google ADK adapter + sidecar HTTP/1.1 framing fix
- google_adk.firstops_before_tool_callback: one agent-level callback governs every tool call — block (non-None return) + scrub (rewrite args in place). - _common: HARNESS_GOOGLE_ADK, stamped into audit metadata. - proxy: HTTP/1.1 + Content-Length framing, strip server/date/transfer-encoding headers, close-on-stream — fixes litellm/aiohttp 'Duplicate Server header' rejection so ADK's LiteLLM transport accepts the chain-link. - adk extra, runnable example, 4 adapter tests, README + description. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1b61801 commit d257e80

7 files changed

Lines changed: 243 additions & 15 deletions

File tree

README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
# FirstOps Python SDK
22

3-
Govern what your AI agents do. FirstOps applies identity, policy enforcement, credential brokering, and audit to every **LLM call**, **tool call**, and **MCP call** your agent makes — across LangGraph, the Claude Agent SDK, and the OpenAI Agents SDK, or any custom loop.
3+
Govern what your AI agents do. FirstOps applies identity, policy enforcement, credential brokering, and audit to every **LLM call**, **tool call**, and **MCP call** your agent makes — across LangGraph, the Claude Agent SDK, the OpenAI Agents SDK, and Google ADK, or any custom loop.
44

55
## Install
66

77
```bash
88
pip install "firstops[langgraph]" # LangGraph + LangChain
99
pip install "firstops[claude]" # Claude Agent SDK
1010
pip install "firstops[openai]" # OpenAI Agents SDK
11+
pip install "firstops[adk]" # Google ADK
1112
pip install "firstops[all]" # all of the above
1213
pip install firstops # core only (management client / custom loops)
1314
```
@@ -75,6 +76,16 @@ guard = firstops_tool_input_guardrail(fo)
7576
def send_email(to: str, body: str) -> str: ...
7677
```
7778

79+
**Google ADK** — one `before_tool_callback` governs every tool (block + rewrite args):
80+
81+
```python
82+
from google.adk.agents import LlmAgent
83+
from firstops.integrations.google_adk import firstops_before_tool_callback
84+
85+
agent = LlmAgent(name="assistant", model=..., tools=[...],
86+
before_tool_callback=firstops_before_tool_callback(fo))
87+
```
88+
7889
**Any framework / custom loop** — the base API:
7990

8091
```python
@@ -100,6 +111,7 @@ Runnable agents in [`examples/`](examples/) — each governs the LLM and tool ca
100111
- LangGraph — [`langgraph_basic.py`](examples/langgraph_basic.py), [`langgraph_notion_mcp.py`](examples/langgraph_notion_mcp.py)
101112
- Claude Agent SDK — [`claude_sdk_basic.py`](examples/claude_sdk_basic.py), [`claude_sdk_mcp.py`](examples/claude_sdk_mcp.py)
102113
- OpenAI Agents SDK — [`openai_agents_basic.py`](examples/openai_agents_basic.py), [`openai_agents_mcp.py`](examples/openai_agents_mcp.py)
114+
- Google ADK — [`google_adk_basic.py`](examples/google_adk_basic.py)
103115

104116
See [`examples/README.md`](examples/README.md) for the env vars to run them.
105117

@@ -122,7 +134,7 @@ admin.connections.register(principal_id=agent.id, name="slack", upstream_url="ht
122134
## Documentation
123135

124136
- [Docs home](https://firstops.dev/docs)
125-
- Guides: [LangChain / LangGraph](https://firstops.dev/docs/guides/langchain) · [Claude Agent SDK](https://firstops.dev/docs/guides/claude-sdk) · [OpenAI Agents SDK](https://firstops.dev/docs/guides/openai-agents)
137+
- Guides: [LangChain / LangGraph](https://firstops.dev/docs/guides/langchain) · [Claude Agent SDK](https://firstops.dev/docs/guides/claude-sdk) · [OpenAI Agents SDK](https://firstops.dev/docs/guides/openai-agents) · [Google ADK](https://firstops.dev/docs/guides/google-adk)
126138
- Concepts: [Identity](https://firstops.dev/docs/concepts/identity) · [Enforcement](https://firstops.dev/docs/concepts/enforcement) · [Connections](https://firstops.dev/docs/concepts/connections)
127139
- [Repository](https://github.com/firstops-dev/firstops-python)
128140

examples/google_adk_basic.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Google ADK agent governed by FirstOps.
2+
3+
One `before_tool_callback` governs every tool call (block + rewrite args). The
4+
LLM runs through the sidecar chain-link via LiteLLM pointed at OpenAI.
5+
6+
Run with the env vars in README.md (needs OPENAI_API_KEY).
7+
"""
8+
9+
import asyncio
10+
import os
11+
12+
import firstops
13+
from firstops.integrations.google_adk import firstops_before_tool_callback
14+
from google.adk.agents import LlmAgent
15+
from google.adk.models.lite_llm import LiteLlm
16+
from google.adk.runners import InMemoryRunner
17+
from google.genai import types
18+
19+
from _shared import load_config, trace
20+
21+
APP = "firstops-demo"
22+
23+
24+
def get_weather(city: str) -> dict:
25+
"""Get the current weather for a city."""
26+
print(f" [TOOL get_weather] city={city}")
27+
return {"weather": f"21C and sunny in {city}"}
28+
29+
30+
def send_email(to: str, body: str) -> dict:
31+
"""Send an email to a recipient."""
32+
print(f" [TOOL send_email] to={to} body={body!r}")
33+
return {"status": "sent"}
34+
35+
36+
async def main():
37+
cfg = load_config()
38+
fo = firstops.init(
39+
cfg["agent_id"], cfg["key_pem"], gateway_url=cfg["gateway"], port=cfg["port"]
40+
)
41+
trace(fo)
42+
try:
43+
agent = LlmAgent(
44+
name="assistant",
45+
model=LiteLlm(
46+
model="openai/gpt-4o-mini",
47+
api_base=firstops.llm_base_url("openai"), # -> sidecar chain-link
48+
api_key=os.environ["OPENAI_API_KEY"],
49+
),
50+
instruction="You are a helpful assistant.",
51+
tools=[get_weather, send_email],
52+
before_tool_callback=firstops_before_tool_callback(fo), # governs tools
53+
)
54+
runner = InMemoryRunner(agent=agent, app_name=APP)
55+
session = await runner.session_service.create_session(app_name=APP, user_id="u1")
56+
msg = types.Content(
57+
role="user",
58+
parts=[types.Part(text="Check the weather in Paris, then email it to alice@example.com.")],
59+
)
60+
print("\n>>> running Google ADK agent\n")
61+
async for event in runner.run_async(
62+
user_id="u1", session_id=session.id, new_message=msg
63+
):
64+
if event.content and event.content.parts:
65+
for part in event.content.parts:
66+
if getattr(part, "text", None) and part.text.strip():
67+
print(f" [ADK] {part.text.strip()[:160]}")
68+
finally:
69+
firstops.shutdown()
70+
71+
72+
if __name__ == "__main__":
73+
asyncio.run(main())

pyproject.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
55
[project]
66
name = "firstops"
77
version = "0.2.0"
8-
description = "Govern MCP, tool calls, and LLM traffic for AI agents — across LangGraph, Claude Agent SDK, and OpenAI Agents."
8+
description = "Govern MCP, tool calls, and LLM traffic for AI agents — across LangGraph, Claude Agent SDK, OpenAI Agents, and Google ADK."
99
readme = "README.md"
1010
requires-python = ">=3.10"
1111
license = "MIT"
@@ -26,7 +26,7 @@ classifiers = [
2626
]
2727
keywords = [
2828
"mcp", "dpop", "agent", "security", "governance", "llm",
29-
"langgraph", "langchain", "openai-agents", "claude", "guardrails",
29+
"langgraph", "langchain", "openai-agents", "claude", "google-adk", "guardrails",
3030
]
3131
dependencies = [
3232
"cryptography>=42.0",
@@ -54,13 +54,17 @@ claude = [
5454
openai = [
5555
"openai-agents",
5656
]
57+
adk = [
58+
"google-adk[extensions]",
59+
]
5760
all = [
5861
"langchain>=1.0",
5962
"langgraph",
6063
"langchain-openai",
6164
"langchain-mcp-adapters",
6265
"claude-agent-sdk",
6366
"openai-agents",
67+
"google-adk[extensions]",
6468
]
6569
dev = [
6670
"pytest>=8.0",

src/firstops/integrations/_common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
HARNESS_LANGGRAPH = "langgraph"
2323
HARNESS_CLAUDE = "claude-agent-sdk"
2424
HARNESS_OPENAI_AGENTS = "openai-agents"
25+
HARNESS_GOOGLE_ADK = "google-adk"
2526

2627

2728
def _json_safe(value: Any) -> Any:
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Google ADK adapter — `before_tool_callback` governs every tool call.
2+
3+
ADK's agent-level `before_tool_callback` can both **block** a tool (return a
4+
result, which short-circuits the call) and **rewrite its args** (mutate the args
5+
dict in place) — so FirstOps gets block and scrub on Google ADK.
6+
7+
Usage::
8+
9+
from google.adk.agents import LlmAgent
10+
from firstops.integrations.google_adk import firstops_before_tool_callback
11+
12+
agent = LlmAgent(
13+
name="assistant",
14+
model=...,
15+
tools=[...],
16+
before_tool_callback=firstops_before_tool_callback(fo),
17+
)
18+
19+
The callback is a plain function (ADK invokes it as
20+
``callback(tool=, args=, tool_context=)``), so this adapter needs no ADK import.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
from typing import Any
26+
27+
from firstops import _runtime
28+
from firstops.integrations._common import (
29+
ACTION_DENY,
30+
ACTION_MODIFY,
31+
HARNESS_GOOGLE_ADK,
32+
decide,
33+
)
34+
35+
36+
def firstops_before_tool_callback(fo=None):
37+
"""Return a ``before_tool_callback`` that governs every tool call."""
38+
rt = fo if fo is not None else _runtime.runtime()
39+
40+
def _before_tool(tool, args, tool_context) -> dict[str, Any] | None:
41+
tool_name = getattr(tool, "name", "") or ""
42+
action, payload = decide(
43+
rt, tool_name, args, can_apply_modify=True, harness=HARNESS_GOOGLE_ADK
44+
)
45+
if action == ACTION_DENY:
46+
# A non-None return short-circuits the tool; this becomes the result
47+
# the model sees.
48+
return {"status": "denied", "error": f"blocked by FirstOps policy: {payload}"}
49+
if action == ACTION_MODIFY and isinstance(payload, dict) and isinstance(args, dict):
50+
# Rewrite the call's args in place (full replacement with scrubbed input).
51+
args.clear()
52+
args.update(payload)
53+
return None
54+
55+
return _before_tool

src/firstops/proxy.py

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@
4444
}
4545
)
4646

47+
# Response headers we must not forward back to the client: hop-by-hop, plus the
48+
# ones BaseHTTPRequestHandler sets itself (Server/Date) — forwarding the
49+
# upstream's copies duplicates them, which strict clients (aiohttp/litellm)
50+
# reject with "Duplicate 'Server' header".
51+
_RESP_STRIP_HEADERS = frozenset(
52+
{"transfer-encoding", "connection", "server", "date"}
53+
)
54+
4755
# Hard cap on a forwarded request body (defensive against a huge Content-Length).
4856
_MAX_BODY_BYTES = 100 * 1024 * 1024
4957

@@ -210,6 +218,12 @@ def _make_handler(identity: Identity, local_port: int, enforcement, llm_upstream
210218
client = httpx.Client(timeout=httpx.Timeout(120.0, connect=10.0))
211219

212220
class ProxyHandler(BaseHTTPRequestHandler):
221+
# HTTP/1.1 so strict clients (litellm/aiohttp, Node MCP) get clean
222+
# keep-alive framing. Non-streaming responses carry an exact
223+
# Content-Length (see _forward); streaming responses close the
224+
# connection explicitly.
225+
protocol_version = "HTTP/1.1"
226+
213227
def do_POST(self):
214228
self._dispatch("POST")
215229

@@ -264,6 +278,7 @@ def _handle_llm(self, method: str):
264278
if denial is not None:
265279
self.send_response(403)
266280
self.send_header("Content-Type", "application/json")
281+
self.send_header("Content-Length", str(len(denial)))
267282
self.end_headers()
268283
self.wfile.write(denial)
269284
return
@@ -364,20 +379,39 @@ def _forward(self, method: str, url: str, headers: dict, body: bytes | None):
364379
method, url, headers=headers, content=body,
365380
timeout=httpx.Timeout(120.0, connect=10.0),
366381
) as resp:
382+
is_stream = "text/event-stream" in resp.headers.get("content-type", "")
383+
384+
if is_stream:
385+
# Streaming (SSE): pass bytes through raw with flushing so
386+
# events arrive live. No Content-Length, so close the
387+
# connection to delimit the body under HTTP/1.1.
388+
self.close_connection = True
389+
self.send_response(resp.status_code)
390+
for k, v in resp.headers.items():
391+
if k.lower() not in _RESP_STRIP_HEADERS:
392+
self.send_header(k, v)
393+
self.send_header("Connection", "close")
394+
self.end_headers()
395+
for chunk in resp.iter_raw():
396+
self.wfile.write(chunk)
397+
self.wfile.flush()
398+
return
399+
400+
# Non-streaming: buffer the DECODED body and send it with an
401+
# exact Content-Length, dropping Content-Encoding/Length from
402+
# upstream. This gives a cleanly-framed response that ANY
403+
# client reads correctly (httpx, aiohttp, Node) — a
404+
# close-delimited gzipped body trips stricter clients.
405+
payload = resp.read() # httpx auto-decompresses
367406
self.send_response(resp.status_code)
368407
for k, v in resp.headers.items():
369-
if k.lower() not in ("transfer-encoding", "connection"):
408+
if k.lower() not in _RESP_STRIP_HEADERS and k.lower() not in (
409+
"content-encoding", "content-length",
410+
):
370411
self.send_header(k, v)
412+
self.send_header("Content-Length", str(len(payload)))
371413
self.end_headers()
372-
373-
# Forward the body RAW: httpx auto-decompresses iter_bytes()/
374-
# read(), but we keep the upstream Content-Encoding/Length
375-
# headers, so we must pass the original (possibly gzipped)
376-
# bytes through untouched or the client's decode fails.
377-
# Flush per chunk so SSE/streaming responses arrive live.
378-
for chunk in resp.iter_raw():
379-
self.wfile.write(chunk)
380-
self.wfile.flush()
414+
self.wfile.write(payload)
381415
except httpx.HTTPError as e:
382416
logger.error("upstream request failed: %s", e)
383417
self.send_error(502, "upstream request failed")
@@ -386,10 +420,12 @@ def _stream_sse(self, url: str, headers: dict):
386420
"""Stream an SSE response, rewriting gateway URLs to localhost."""
387421
try:
388422
with httpx.stream("GET", url, headers=headers, timeout=None) as resp:
423+
self.close_connection = True # SSE: no length, close-delimited
389424
self.send_response(resp.status_code)
390425
for k, v in resp.headers.items():
391-
if k.lower() not in ("transfer-encoding", "connection"):
426+
if k.lower() not in _RESP_STRIP_HEADERS:
392427
self.send_header(k, v)
428+
self.send_header("Connection", "close")
393429
self.end_headers()
394430

395431
gateway_msg_url = gateway + "/mcp/sse/message"

tests/test_integrations.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,50 @@ def test_openai_guardrail_requires_sdk():
181181
def test_langgraph_middleware_requires_langchain():
182182
with pytest.raises(RuntimeError, match="langchain"):
183183
langgraph.FirstOpsMiddleware(_rt(Decision(action="allow")))
184+
185+
186+
# ---- Google ADK adapter ----------------------------------------------------
187+
188+
from firstops.integrations import google_adk
189+
190+
191+
class _AdkTool:
192+
def __init__(self, name):
193+
self.name = name
194+
195+
196+
def test_adk_allow_returns_none_and_stamps_harness():
197+
rt = _rt(Decision(action="allow"))
198+
cb = google_adk.firstops_before_tool_callback(rt)
199+
args = {"city": "Paris"}
200+
assert cb(tool=_AdkTool("get_weather"), args=args, tool_context=None) is None
201+
assert args == {"city": "Paris"} # unchanged
202+
ev = rt.enforcement.events[0]
203+
assert ev.tool_name == "get_weather"
204+
assert ev.metadata == {"harness": "google-adk"}
205+
206+
207+
def test_adk_deny_short_circuits_with_result_dict():
208+
rt = _rt(Decision(action="deny", reason="destructive"))
209+
out = google_adk.firstops_before_tool_callback(rt)(
210+
tool=_AdkTool("run_shell"), args={"cmd": "x"}, tool_context=None
211+
)
212+
assert out is not None # non-None return blocks the tool
213+
assert out["status"] == "denied"
214+
assert "destructive" in out["error"]
215+
216+
217+
def test_adk_modify_rewrites_args_in_place():
218+
rt = _rt(_modify({"to": "[REDACTED]"}))
219+
args = {"to": "secret@example.com"}
220+
out = google_adk.firstops_before_tool_callback(rt)(
221+
tool=_AdkTool("send_email"), args=args, tool_context=None
222+
)
223+
assert out is None # proceed
224+
assert args == {"to": "[REDACTED]"} # rewritten in place
225+
226+
227+
def test_adk_adapter_needs_no_framework():
228+
# The callback is a plain function — constructing it must not require ADK.
229+
cb = google_adk.firstops_before_tool_callback(_rt(Decision(action="allow")))
230+
assert callable(cb)

0 commit comments

Comments
 (0)