Run an agent
Route your model clients through the proxy as an agent, sync or async, with OpenAI, Anthropic or any httpx client, and handle what the proxy refuses.
guardian.agent() names an enrolled agent and describes it (Describe an agent). bind() then gives you a copy of the model client you already use, routed through the proxy and naming the agent on every request, so each one is signed as that agent. Your client is left as it was.
- OpenAI’s
OpenAIorAsyncOpenAI:agent.bind(OpenAI()). - Anthropic’s
AnthropicorAsyncAnthropic:agent.bind(Anthropic()). - LangChain’s
ChatOpenAIorChatAnthropic, with thelangchainextra:agent.bind(ChatOpenAI(model="gpt-4.1")). - Anything that takes an
httpxclient:agent.httpx_client()oragent.httpx_async_client().
Send what the definition says
The definition records the agent’s instructions, tools and model settings, and the proxy signs every request as matching it. With a model client you call yourself, the SDK adds the agent’s name and definition to each request but doesn’t change what it sends: your requests must send the instructions and tools the definition records, to the model it names. The framework integrations build each request from what they describe, and check each call against the definition.
OpenAI and Anthropic clients
bind() takes OpenAI’s and Anthropic’s clients, sync and async. The Anthropic example runs through a proxy aimed at Anthropic’s endpoint on the Agent Gateway, with VIPER_PROXY_URL set to it, and needs the anthropic package and ANTHROPIC_API_KEY (Before you start):
import asyncio
from openai import AsyncOpenAI
import guardian_agent as ga
INSTRUCTIONS = "You research questions and cite your sources."
async def main() -> None:
guardian = ga.Guardian()
researcher = guardian.agent(
"researcher",
model=ga.Model("openai", "gpt-4.1"),
instructions=INSTRUCTIONS,
)
await researcher.aregister() # optional: fail at start-up, not on the first user
# Close the client when you're done, as for any async client
async with researcher.bind(AsyncOpenAI()) as client:
reply = await client.chat.completions.create(
model="gpt-4.1",
messages=[
{"role": "system", "content": INSTRUCTIONS},
{"role": "user", "content": "Summarise the release notes."},
],
)
print(reply.choices[0].message.content)
asyncio.run(main())from anthropic import Anthropic
from anthropic.types import TextBlock
import guardian_agent as ga
INSTRUCTIONS = "You research questions and cite your sources."
# VIPER_PROXY_URL names a proxy started with --gateway-endpoint ".../v1/anthropic_api"
guardian = ga.Guardian()
researcher = guardian.agent(
"researcher",
model=ga.Model("anthropic", "claude-sonnet-5", max_tokens=1024),
instructions=INSTRUCTIONS,
)
client = researcher.bind(Anthropic())
reply = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
system=INSTRUCTIONS,
messages=[{"role": "user", "content": "Summarise the release notes."}],
)
print("".join(block.text for block in reply.content if isinstance(block, TextBlock)))- Where the client points. A client still aimed at the provider’s own API, as a client is by default, is pointed at the proxy. One aimed anywhere else is refused with
ga.ModelNotRoutedThroughProxy, since its requests would never be signed. One already aimed at the proxy needs the right path:/v1for OpenAI’s clients, none for Anthropic’s, orbind()raisesValueError. - Your provider key is untouched: Viper and the Agent Gateway pass it through to the provider.
- The copy’s HTTP client keeps the original’s timeout, redirect settings and event hooks, and the provider SDK’s connection limits. Other HTTP customisation, such as a custom CA, doesn’t carry over, and isn’t needed to reach a proxy on your own machine.
- OpenAI’s Realtime (WebSocket) API can’t go through the proxy, so a bound client’s Realtime connections fail rather than go unsigned:
bind()aims them at an address that never resolves. That holds until you aim the copy elsewhere yourself, withwith_options(websocket_base_url=...).
LangChain chat models
With the langchain extra and your provider’s LangChain package (langchain-openai, langchain-anthropic), bind() takes LangChain’s ChatOpenAI and ChatAnthropic:
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_openai import ChatOpenAI
import guardian_agent as ga
INSTRUCTIONS = "You research questions and cite your sources."
# The definition takes the chat model itself, so it records its settings exactly
llm = ChatOpenAI(model="gpt-4.1", temperature=0)
guardian = ga.Guardian()
researcher = guardian.agent("researcher", model=llm, instructions=INSTRUCTIONS)
routed = researcher.bind(llm) # a copy, routed through the proxy; llm is unchanged
reply = routed.invoke(
[SystemMessage(INSTRUCTIONS), HumanMessage("Summarise the release notes.")]
)
print(reply.content)The copy routes every call it makes, sync or async, streamed or not, and through bind_tools() and with_structured_output(): bind the model before those. A LangChain model has to be one the definition describes exactly, settings included, or bind() raises ValueError, since its requests would be signed under a definition that doesn’t describe them. Giving the definition the chat model itself, as above, makes them match. It raises TypeError for a model it can’t route: a model name such as "openai:gpt-4.1" (build it with init_chat_model and bind that), one already wrapped by bind_tools() or with_structured_output(), or another kind of chat model. Plain LangChain covers what binding records and refuses in full, and Frameworks covers agents built with create_agent, Deep Agents and LangGraph.
Any httpx client
For anything else that takes an httpx client, agent.httpx_client() and agent.httpx_async_client() give you one whose base URL is the proxy’s. A request anywhere else is refused rather than sent:
import os
import guardian_agent as ga
INSTRUCTIONS = "You research questions and cite your sources."
guardian = ga.Guardian()
researcher = guardian.agent(
"researcher",
model=ga.Model("openai", "gpt-4.1"),
instructions=INSTRUCTIONS,
)
# For a client that takes an httpx client: its base URL is the proxy's,
# and a request anywhere else is refused rather than sent
with researcher.httpx_client(timeout=30) as http:
response = http.post(
"/v1/chat/completions",
headers={"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}"},
json={
"model": "gpt-4.1",
"messages": [
{"role": "system", "content": INSTRUCTIONS},
{"role": "user", "content": "Summarise the release notes."},
],
},
)
print(response.json()["choices"][0]["message"]["content"])They take httpx.Client’s options, such as headers, timeout, auth, limits, verify and http2, and the transport’s retries and local_address. Options that could send requests around the agent (transport, mounts, proxy) raise TypeError, as do trust_env, which a client with its own transport ignores, and any option the SDK doesn’t know. base_url defaults to the proxy’s address. A request to any path at that address goes through. A request to anywhere else is never sent: the client gets a 421 response carrying the model_not_routed_through_proxy refusal, and ga.proxy_error(response) returns it as a ga.ModelNotRoutedThroughProxy.
One proxy per provider
A proxy fronts one provider’s gateway endpoint (viper proxy --gateway-endpoint): /v1/openai_api for OpenAI’s clients, or /v1/anthropic_api for Anthropic’s. An agent that uses both runs through two proxies (Before you start shows how to start them). Make the agent from a Guardian for each, with the same name and definition:
import os
from anthropic import Anthropic
from anthropic.types import TextBlock
from openai import OpenAI
import guardian_agent as ga
INSTRUCTIONS = "You research questions and cite your sources."
# One proxy per gateway endpoint: --gateway-endpoint ".../v1/openai_api" for one,
# ".../v1/anthropic_api" for the other
openai_proxy = ga.Guardian(os.environ["OPENAI_PROXY_URL"])
anthropic_proxy = ga.Guardian(os.environ["ANTHROPIC_PROXY_URL"])
# Both models the agent calls, one through each proxy (a list is also fallback order)
definition = ga.Definition(
model=[
ga.Model("openai", "gpt-4.1"),
ga.Model("anthropic", "claude-sonnet-5", max_tokens=1024),
],
instructions=INSTRUCTIONS,
)
# The same agent, with the same name and definition, through each proxy
drafting = openai_proxy.agent("researcher", definition=definition).bind(OpenAI())
reviewing = anthropic_proxy.agent("researcher", definition=definition).bind(Anthropic())
draft = drafting.chat.completions.create(
model="gpt-4.1",
messages=[
{"role": "system", "content": INSTRUCTIONS},
{"role": "user", "content": "Draft the release summary."},
],
)
review = reviewing.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
system=INSTRUCTIONS,
messages=[
{
"role": "user",
"content": f"Check this draft:\n{draft.choices[0].message.content}",
}
],
)
print("".join(block.text for block in review.content if isinstance(block, TextBlock)))Enroll the agent on the machine once: each proxy loads every agent enrolled on it when it starts, so both hold its key.
The framework integrations take one guardian, so they refuse an agent whose models come from both providers (One provider per agent).
What happens when
guardian.agent(...)checks the definition locally. Nothing is sent.- The first request checks the proxy’s capabilities (once per
Guardian) and registers the definition. Requests made at the same moment share one registration, and if it fails, every request waiting on it gets that failure. - Every HTTP request carries
X-Viper-Agent,X-Viper-Definitionand, for a sub-agent,X-Viper-Subagent. Nothing else is added. - After a proxy restart, the proxy has forgotten the definition: the SDK registers it again and retries the request once.
To fail at start-up instead of on the first user’s request, call agent.register(), or await agent.aregister(), as the async example above does. It returns the definition’s content ID, also at agent.definition_cid. Once registered it doesn’t contact the proxy again, so it isn’t a health check: the next model request after a restart registers again.
Sub-agents
A sub-agent signs as its parent. Route a client through one by its path, as in writer.subagent("critic").bind(OpenAI()) or writer.subagent("critic/fact-checker") for a nested one (Agents and identities). A name the definition doesn’t declare raises ga.SubagentUnknown at subagent(), not on the first request.
When the proxy refuses a request
A refusal reaches you through your model client, as its own error type, so its retries and error handling keep working. Turn it back into this SDK’s error with ga.proxy_error():
import openai
import guardian_agent as ga
INSTRUCTIONS = "You research questions and cite your sources."
guardian = ga.Guardian()
researcher = guardian.agent(
"researcher",
model=ga.Model("openai", "gpt-4.1"),
instructions=INSTRUCTIONS,
)
client = researcher.bind(openai.OpenAI())
try:
reply = client.chat.completions.create(
model="gpt-4.1",
messages=[
{"role": "system", "content": INSTRUCTIONS},
{"role": "user", "content": "Summarise the release notes."},
],
)
print(reply.choices[0].message.content)
except openai.APIStatusError as error:
# The proxy's refusal as this SDK's typed error, or None for the provider's own
if refusal := ga.proxy_error(error):
print(f"refused: {refusal.code}. {refusal.hint or ''}")
raiseproxy_error()returns the SDK’s typed error for a refusal by the proxy, such asga.UpstreamResponseLostorga.SigningFailed, or by the SDK on its behalf, such as a failed registration or a request aimed elsewhere (ga.ModelNotRoutedThroughProxy), with itscode,message,hintandstatus. It takes the client’s error, or a response.- It returns
Nonefor errors that aren’t the proxy’s: the model provider’s, and the Guardian Platform’s own refusals, which the proxy passes on unchanged in the gateway’s shape. A revoked agent, for example, arrives as a403with codeagent_revoked, and a key the gateway doesn’t know as a401with codekey_not_registered, as a bad provider key would. The Guardian Platform can also refuse the key earlier, while the proxy sends the request’s attestation, which arrives as the proxy’ssigning_failed(Errors). - Retries: refusals that retrying can’t fix, or could repeat (
upstream_response_lost, since the model may have run), are marked so OpenAI’s and Anthropic’s clients don’t retry them. If the proxy can’t be reached at all, the client sees a connection error and retries as it would for the provider.
Errors lists every error and its code.
Calls the SDK can’t see
The SDK routes the clients you bind, and the framework integrations route the models they’re given when an agent is made, and refuse a model they find but can’t route, but nothing can see a model your code builds or looks up as it runs. Two things stop such a call going out unsigned, and they cover different calls:
- A call aimed at the proxy that names no agent is refused when you run the proxy with
--require-agent-header. - A call aimed straight at a provider (
api.openai.com, say) never reaches the proxy, so the proxy can’t refuse it. Only the network can: let the agent reach model providers only through the proxy, with a container’s egress rules or a sandbox, such as OpenShell.
Every model call is signed only where both hold.