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
3 changes: 2 additions & 1 deletion .github/published-images.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@
"agent-strands",
"agent-ag2",
"agent-langroid",
"agent-mastra"
"agent-mastra",
"agent-antigravity"
]
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,13 @@ jobs:
. .venv-adk/bin/activate
python -m pip install --requirement agent-adk/requirements.txt --requirement agent-adk/requirements-test.txt
python -m pytest agent-adk/tests -q
- name: Antigravity provider pin and token gate
run: |
set -euo pipefail
python -m venv .venv-antigravity
. .venv-antigravity/bin/activate
python -m pip install --requirement agent-antigravity/requirements.txt --requirement agent-antigravity/requirements-test.txt
python -m pytest agent-antigravity/tests -q
- name: Langroid model-choice and tool-call regressions
run: |
set -euo pipefail
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,13 @@ charts/*/charts/
# The environments are here for the same reason and are the more expensive mistake: `.venv` is
# hundreds of megabytes of platform-specific binaries that would be useless to anybody who cloned
# them. `uv` makes `.venv` and the older tooling makes `venv`, and both spellings are in use across
# these packages.
# these packages. CI gives each Python Bot an environment named after it — `.venv-adk`,
# `.venv-antigravity` — and running those same commands locally leaves them in the tree, so the
# suffixed spelling is ignored too.
__pycache__/
*.py[cod]
.venv/
.venv-*/
venv/
*.egg-info/
.pytest_cache/
Expand Down
8 changes: 7 additions & 1 deletion agent-adk/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
ag-ui-adk
google-adk
litellm
# litellm 1.106.0 fails every model call in this harness: it reaches for `mcp` and reports
# whatever it finds as an OpenAIException, so the error arrives as a connection fault rather
# than the missing import it is. Seen two ways — `cannot import name 'CacheableResult' from
# 'mcp.types'` where an older mcp is pinned by another dependency, and `No module named 'mcp'`
# where there is none at all. 1.105.1 does not reach for it. Lift this when litellm stops
# requiring mcp, or declares it.
litellm<1.106.0
fastapi
python-multipart
uvicorn[standard]
8 changes: 7 additions & 1 deletion agent-agno/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
agno[agui]
litellm
# litellm 1.106.0 fails every model call in this harness: it reaches for `mcp` and reports
# whatever it finds as an OpenAIException, so the error arrives as a connection fault rather
# than the missing import it is. Seen two ways — `cannot import name 'CacheableResult' from
# 'mcp.types'` where an older mcp is pinned by another dependency, and `No module named 'mcp'`
# where there is none at all. 1.105.1 does not reach for it. Lift this when litellm stops
# requiring mcp, or declares it.
litellm<1.106.0
fastapi
python-multipart
uvicorn[standard]
23 changes: 23 additions & 0 deletions agent-antigravity/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Google Antigravity, as a Bot. Python rather than Bun because that is what the integration is
# published in, and the protocol is the only thing a Bot has to share with the others.
#
# `slim` rather than `alpine`: the SDK's wheel bundles a Go `localharness` binary built against
# glibc (manylinux_2_17), and musl cannot load it.
FROM python:3.12-slim

WORKDIR /app

# Dependencies before source, so editing the Bot does not re-resolve the framework.
COPY agent-antigravity/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY agent-antigravity/src ./src
COPY shared/model_providers.py shared/model-providers.json /shared/

ENV PORT=4214
EXPOSE 4214
# 0.0.0.0, not `::`. uvicorn binds `::` as IPv6-only, with no v4-mapped addresses, so a container
# started that way refuses 127.0.0.1: the Compose healthcheck never passes and the server cannot
# reach the Bot. Measured, not assumed. Inside a container this is the container's own namespace,
# and Compose is what decides which host addresses it is published on.
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "4214"]
2 changes: 2 additions & 0 deletions agent-antigravity/requirements-test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
httpx==0.28.1
pytest==9.0.2
3 changes: 3 additions & 0 deletions agent-antigravity/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ag-ui-antigravity
fastapi
uvicorn[standard]
108 changes: 108 additions & 0 deletions agent-antigravity/src/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Google Antigravity as a Bot, through `ag_ui_antigravity`, which AG-UI maintains.

GEMINI-ONLY, AND PINNED. Every other Bot in the box takes whatever provider the deployment chose:
they reach their model through LiteLLM or an equivalent, so `BOT_PROVIDER` moving them to Anthropic
or a compatible endpoint is a setting, not a break. Antigravity is not like that. Its SDK drives a
bundled Go `localharness`, and that harness wants a Gemini key wherever its base url points — the
integration's own README says so. So the provider is pinned here rather than read from the
environment, and the catalogue row says `credential: "google"` so the picker asks for the right key
instead of offering this row to a deployment that holds only an OpenAI one.

The model is still the deployment's to choose. `BOT_MODEL` names which Gemini; the pin only stops
the *provider* drifting to one the harness cannot use.

WHERE the Gemini lives is the deployment's too. The `google` row in `shared/model-providers.json`
names both variables this Bot reads — the key and the base url — and they are read from that row
rather than spelled out here, so renaming either is one edit to the file every language reads. A
base url set there sends the harness at a Gemini-compatible endpoint instead of Google's: a
gateway, a proxy that holds the real key, or a mock while someone tries the Bot out without one.
That url is the API ROOT, with no version path on it — `http://host:4141`, not
`http://host:4141/v1beta`. The harness appends `/v1beta/models/<model>:streamGenerateContent`
itself, and a url carrying the version already gets it twice and 404s, which the harness reports
as the unplaceable `agent executor error: Error 0, Message: Not found`. Measured against a logging
proxy, because that message names neither the url nor the status.
"""

import hmac
import os
import sys
from pathlib import Path

# The spec file every language in the box reads: one level above this Bot in the repository, and
# one level above /app/src in the image the Dockerfile builds.
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared"))
from model_providers import SPEC, bot_settings

from ag_ui_antigravity import AntigravityAgent, create_antigravity_app
from fastapi import Request
from fastapi.responses import JSONResponse
from google.antigravity.models import GeminiAPIEndpoint

TOKEN_HEADER = "x-openbot-agent-token"

#: The Gemini row this Bot is pinned to. Both variable names come from it.
_GOOGLE = SPEC["providers"]["google"]


def _model() -> str:
"""Which Gemini this deployment wants.

`pinned_provider` beats `BOT_PROVIDER`, which is the point: see the module docstring. The
returned model is the bare name, not `provider/model` — Antigravity takes the Gemini model id
directly rather than through a LiteLLM-style prefix.
"""
return bot_settings("agent-antigravity", pinned_provider="google").model


def _api_key() -> str | None:
"""The key, under the name the provider row gives it.

`GEMINI_API_KEY` is the SDK's own spelling and is taken as a second name for the same thing,
because a deployment that already set it for another Google tool should not have to set it
twice. Blank is not a choice: compose hands an unset variable through as an empty string.
"""
for variable in (_GOOGLE["key_variable"], "GEMINI_API_KEY"):
value = (os.environ.get(variable) or "").strip()
if value:
return value
return None


def _agent() -> AntigravityAgent:
"""The harness, pointed at whatever Gemini this deployment runs.

With a base url the endpoint is spelled out: `AntigravityAgent(base_url=...)` is a DIFFERENT
and mutually exclusive path, meant for unauthenticated local servers, and it drops the key.
`endpoint=GeminiAPIEndpoint(...)` is the one that carries both — which is what a gateway, or a
mock in front of the Gemini API, needs.
"""
model, api_key = _model(), _api_key()
base_url = (os.environ.get(_GOOGLE["base_url_variable"]) or "").strip()
if base_url:
return AntigravityAgent(
model=model,
endpoint=GeminiAPIEndpoint(base_url=base_url, api_key=api_key),
)
# Named rather than left to the SDK's own environment read, so a missing key is this Bot's
# failure at start-up rather than a stream that opens and dies.
return AntigravityAgent(model=model, api_key=api_key)


app = create_antigravity_app(_agent(), path="/")


@app.middleware("http")
async def refuse_without_the_server_token(request: Request, call_next):
if request.url.path != "/health":
expected = (os.environ.get("MANAGED_AGENT_TOKEN") or "").strip()
offered = (request.headers.get(TOKEN_HEADER) or "").strip()
if not expected or not hmac.compare_digest(
offered.encode("utf-8"), expected.encode("utf-8")
):
return JSONResponse({"error": "unauthorised"}, status_code=401)
return await call_next(request)


@app.get("/health")
async def health():
return {"ok": True, "harness": "antigravity"}
122 changes: 122 additions & 0 deletions agent-antigravity/tests/test_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""The app this Bot actually serves: it builds, it guards, and health stays open.

Importing `src.main` is most of the point. It constructs a real `AntigravityAgent` and a real
FastAPI app at module scope, so a wrong keyword, a renamed export or a missing dependency fails
here rather than at the first run against a live deployment.
"""

import importlib
import sys
from pathlib import Path

import pytest
from fastapi.testclient import TestClient

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

TOKEN = "test-managed-token"
TOKEN_HEADER = "x-openbot-agent-token"


@pytest.fixture
def client(monkeypatch):
monkeypatch.setenv("MANAGED_AGENT_TOKEN", TOKEN)
monkeypatch.setenv("GOOGLE_API_KEY", "not-a-real-key")
monkeypatch.delenv("BOT_PROVIDER", raising=False)
monkeypatch.delenv("BOT_MODEL", raising=False)
import src.main

importlib.reload(src.main)
return TestClient(src.main.app)


def test_health_needs_no_token(client):
"""Compose polls this before anything holds a token."""
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"ok": True, "harness": "antigravity"}


def test_a_run_without_the_server_token_is_refused(client):
response = client.post("/", json={})
assert response.status_code == 401


def test_a_run_with_the_wrong_token_is_refused(client):
response = client.post("/", json={}, headers={TOKEN_HEADER: "wrong"})
assert response.status_code == 401


def test_the_token_gate_is_not_a_prefix_match(client):
"""`hmac.compare_digest` on the whole value, not a startswith."""
response = client.post("/", json={}, headers={TOKEN_HEADER: TOKEN + "extra"})
assert response.status_code == 401


def test_the_agent_is_built_on_the_pinned_provider(monkeypatch):
"""Even told to use OpenAI, this Bot asks Antigravity for a Gemini."""
monkeypatch.setenv("MANAGED_AGENT_TOKEN", TOKEN)
monkeypatch.setenv("GOOGLE_API_KEY", "not-a-real-key")
monkeypatch.setenv("BOT_PROVIDER", "openai")
monkeypatch.delenv("BOT_MODEL", raising=False)
import src.main

importlib.reload(src.main)
assert src.main._model() == "gemini-3.6-flash"


def _rebuilt():
import src.main

importlib.reload(src.main)
return src.main


def test_a_base_url_becomes_a_gemini_endpoint(monkeypatch):
"""The variable the `google` provider row names points the harness somewhere else.

Reading `_endpoint` is reaching into the SDK, and that is deliberate: the alternative is a test
that only greps this Bot's source, which would pass just as happily with the wrong keyword.
`AntigravityAgent(base_url=...)` is the wrong keyword — it is a separate path for
unauthenticated local servers and it carries no key — so this asserts the key came along.
"""
monkeypatch.setenv("MANAGED_AGENT_TOKEN", TOKEN)
monkeypatch.setenv("GOOGLE_API_KEY", "not-a-real-key")
monkeypatch.setenv("GOOGLE_GENERATIVE_AI_BASE_URL", "http://aimock:4141")
monkeypatch.delenv("BOT_PROVIDER", raising=False)
monkeypatch.delenv("BOT_MODEL", raising=False)
agent = _rebuilt()._agent()
assert agent._endpoint.base_url == "http://aimock:4141"
assert agent._endpoint.api_key == "not-a-real-key"


def test_without_a_base_url_the_key_goes_straight_to_google(monkeypatch):
monkeypatch.setenv("MANAGED_AGENT_TOKEN", TOKEN)
monkeypatch.setenv("GOOGLE_API_KEY", "not-a-real-key")
monkeypatch.delenv("GOOGLE_GENERATIVE_AI_BASE_URL", raising=False)
monkeypatch.delenv("BOT_PROVIDER", raising=False)
monkeypatch.delenv("BOT_MODEL", raising=False)
agent = _rebuilt()._agent()
assert agent._endpoint is None
assert agent._api_key == "not-a-real-key"


def test_a_blank_base_url_is_not_a_choice(monkeypatch):
"""Compose hands an unset variable through as an empty string, and `GeminiAPIEndpoint("")`
would send the harness at nothing at all."""
monkeypatch.setenv("MANAGED_AGENT_TOKEN", TOKEN)
monkeypatch.setenv("GOOGLE_API_KEY", "not-a-real-key")
monkeypatch.setenv("GOOGLE_GENERATIVE_AI_BASE_URL", " ")
monkeypatch.delenv("BOT_PROVIDER", raising=False)
monkeypatch.delenv("BOT_MODEL", raising=False)
assert _rebuilt()._agent()._endpoint is None


def test_the_sdks_own_spelling_of_the_key_is_taken_too(monkeypatch):
monkeypatch.setenv("MANAGED_AGENT_TOKEN", TOKEN)
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
monkeypatch.setenv("GEMINI_API_KEY", "from-the-other-name")
monkeypatch.delenv("GOOGLE_GENERATIVE_AI_BASE_URL", raising=False)
monkeypatch.delenv("BOT_PROVIDER", raising=False)
monkeypatch.delenv("BOT_MODEL", raising=False)
assert _rebuilt()._agent()._api_key == "from-the-other-name"
62 changes: 62 additions & 0 deletions agent-antigravity/tests/test_model_spec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""The spec file this Bot reads, and the one way it differs from every other Bot's row.

`shared/model-providers.json` is one file every language in the box reads. This is the row for
`agent-antigravity` — and unlike the rest, the provider is PINNED. Antigravity's bundled harness
requires a Gemini key wherever its base url points, so a deployment that moved its other Bots to
OpenAI must not drag this one along: it would start, accept work, and fail every call.
"""

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared"))
from model_providers import BotSettings, bot_settings

BOT_ID = "agent-antigravity"
PINNED = "google"


def test_the_row_this_bot_runs(monkeypatch):
monkeypatch.delenv("BOT_PROVIDER", raising=False)
monkeypatch.delenv("BOT_MODEL", raising=False)
assert bot_settings(BOT_ID, pinned_provider=PINNED) == BotSettings(
provider="google", model="gemini-3.6-flash"
)


def test_the_provider_is_pinned_against_a_deployment_that_moved_everything_else(monkeypatch):
"""BOT_PROVIDER wins everywhere else. Here it must not."""
monkeypatch.setenv("BOT_PROVIDER", "openai")
monkeypatch.delenv("BOT_MODEL", raising=False)
assert bot_settings(BOT_ID, pinned_provider=PINNED).provider == "google"


def test_the_model_is_still_the_deployments_to_choose(monkeypatch):
"""Pinning the provider is not pinning the model: any Gemini the deployment names is fine."""
monkeypatch.delenv("BOT_PROVIDER", raising=False)
# Deliberately NOT the file's own model: an override that names what the row already says
# would pass whether or not the environment was read at all.
monkeypatch.setenv("BOT_MODEL", "gemini-3.8-flash")
assert bot_settings(BOT_ID, pinned_provider=PINNED) == BotSettings(
provider="google", model="gemini-3.8-flash"
)


def test_this_bots_source_reads_the_spec_with_the_pin():
source = (Path(__file__).resolve().parents[1] / "src" / "main.py").read_text(encoding="utf-8")
assert 'bot_settings("agent-antigravity", pinned_provider="google")' in source


def test_the_key_and_base_url_come_from_the_provider_row():
"""Not hardcoded here.

`shared/model-providers.json` names Google's key and base-url variables, and a Bot that
spelled them out itself would drift from the file the moment either changed. The base-url
variable is what points this Bot at a Gemini-compatible endpoint — a gateway, or aimock —
instead of Google.
"""
source = (Path(__file__).resolve().parents[1] / "src" / "main.py").read_text(encoding="utf-8")
assert '"key_variable"' in source
assert '"base_url_variable"' in source
assert "GOOGLE_API_KEY" not in source
assert "GOOGLE_GENERATIVE_AI_BASE_URL" not in source
Loading
Loading