Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f4ad1c2
fix(backend): correct AI/ML API base URL to /v1 and add attribution h…
aimlapihello Aug 4, 2026
93e4e0e
feat(platform): surface aimlapi.com first in the builder + rename pro…
aimlapihello Aug 4, 2026
5533cce
merge: aimlapi.com branding + recommended placement (PR B) into umbrella
ngrink Aug 4, 2026
ed7d73d
chore(deploy): accept NEXT_PUBLIC_FRONTEND_BASE_URL as a frontend bui…
aimlapihello Aug 5, 2026
45fc0a3
fix(frontend): route SSR auth-token self-call to loopback, not public…
aimlapihello Aug 6, 2026
3a0799e
feat(platform): brand aiml_api as aimlapi.com + Recommended badge in …
aimlapihello Aug 6, 2026
04e5128
feat(platform): AIMLAPI 'Get API key' device-grant onboarding
aimlapihello Aug 6, 2026
9f75269
fix(frontend): call the aimlapi device-grant via the /api-prefixed pr…
aimlapihello Aug 6, 2026
d8a1acb
feat(backend): make AIML API inference base URL env-overridable (AIML…
aimlapihello Aug 6, 2026
a084a87
fix(frontend): repair AIMLAPI Get API key popup + align key field to …
Aug 6, 2026
8f18bbb
fix(frontend): wrap long provider descriptions + tidy AIMLAPI key row
Aug 6, 2026
ca88c8e
fix(frontend): drop AIMLAPI key-row hint + shorter provider description
Aug 6, 2026
5947232
feat(platform): dynamically load full AIMLAPI model catalog
Aug 6, 2026
12dca57
Revert "feat(platform): dynamically load full AIMLAPI model catalog"
Aug 7, 2026
8ccaa91
Revert "Revert "feat(platform): dynamically load full AIMLAPI model c…
Aug 7, 2026
9797c1f
feat(platform): aimlapi-only model picker (aggregator build)
Aug 7, 2026
7c4b0ad
fix(platform): collapse AIMLAPI alias models + Recommended group
Aug 7, 2026
9ea626c
fix(frontend): show aimlapi.com label in block-menu integration bread…
Aug 7, 2026
b3ccdab
feat(frontend): Get API key button in the in-builder API-key modal
Aug 7, 2026
19cd696
fix(blocks): scope Stagehand LLM credentials to its supported providers
Aug 7, 2026
56da22d
Revert "fix(blocks): scope Stagehand LLM credentials to its supported…
Aug 7, 2026
27a1e3e
feat(frontend): sort Stagehand blocks last in the aimlapi.com integra…
Aug 7, 2026
57fe4cf
feat: validate AIMLAPI key before saving; show error if invalid
Aug 10, 2026
d499738
refactor: validate AIMLAPI key via GET /billing/balance from the browser
Aug 10, 2026
41b3a25
merge: upstream/dev into the aimlapi aggregator build
aimlapihello Aug 21, 2026
e8babed
fix(aimlapi): send the attribution pair on the catalog fetch too
aimlapihello Aug 21, 2026
4653b5c
fix(aimlapi): attribute the browser-side key check too
aimlapihello Aug 21, 2026
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
Empty file.
77 changes: 77 additions & 0 deletions autogpt_platform/backend/backend/api/features/aimlapi/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""AIMLAPI endpoints + partner attribution for the "Get API key" flow.

Production values are compiled in; every value is overridable via an
``AIMLAPI_*`` environment variable, so the same build runs against staging by
changing only the endpoint/partner env. No staging URL is hard-coded.
"""

import os
from dataclasses import dataclass

# Provisioned AutoGPT partner — the same id is valid on staging and production,
# so it ships as the compiled-in default. Override with AIMLAPI_PARTNER_ID only
# for a staging-only test id.
DEFAULT_AIMLAPI_PARTNER_ID = "part_T70zDIEvQLKSMzMQ7asjdtKR"
DEFAULT_AIMLAPI_PARTNER_NAME = "AutoGPT"

# Client identifier reported on every AIMLAPI request (analytics + Mailchimp).
AIMLAPI_SOURCE = "agent/autogpt"
# Shown on the consent screen so the user sees which app is asking for a key.
AGENT_NAME = "AutoGPT"
# Spend cap presented on the consent screen, in USD minor units ($10).
DEFAULT_AIMLAPI_REQUESTED_USD_LIMIT_MINOR = 1000


def _env_or_default(name: str, default: str) -> str:
value = (os.getenv(name) or "").strip()
return value or default


@dataclass(frozen=True, slots=True)
class AimlapiEndpoints:
app_base_url: str
verification_base_url: str


def resolve_endpoints() -> AimlapiEndpoints:
return AimlapiEndpoints(
# Host of the device-authorization + token endpoints.
app_base_url=_env_or_default(
"AIMLAPI_APP_URL", "https://app.aimlapi.com"
).rstrip("/"),
# Base of the browser consent page, which lives in the web app under
# ``/app`` (the bare host is a static marketing site). The create
# response returns a production URL even on staging, so the consent URL
# is always rebuilt from this base (default = prod, override for
# staging, e.g. https://staging.aimlapi.com/app).
verification_base_url=_env_or_default(
"AIMLAPI_VERIFICATION_BASE_URL", "https://aimlapi.com/app"
).rstrip("/"),
)


def resolve_partner_id() -> str:
return _env_or_default("AIMLAPI_PARTNER_ID", DEFAULT_AIMLAPI_PARTNER_ID)


def resolve_partner_name() -> str:
return _env_or_default("AIMLAPI_PARTNER_NAME", DEFAULT_AIMLAPI_PARTNER_NAME)


def resolve_requested_usd_limit_minor() -> int:
raw = os.getenv("AIMLAPI_REQUESTED_USD_LIMIT_MINOR")
if raw is None:
return DEFAULT_AIMLAPI_REQUESTED_USD_LIMIT_MINOR
try:
value = int(raw)
except (TypeError, ValueError):
return DEFAULT_AIMLAPI_REQUESTED_USD_LIMIT_MINOR
return value if value > 0 else DEFAULT_AIMLAPI_REQUESTED_USD_LIMIT_MINOR


def attribution_headers() -> dict[str, str]:
"""Headers that mark AIMLAPI traffic as agent-sourced for this partner."""
return {
"X-AIMLAPI-Source": AIMLAPI_SOURCE,
"X-AIMLAPI-Partner-ID": resolve_partner_id(),
}
93 changes: 93 additions & 0 deletions autogpt_platform/backend/backend/api/features/aimlapi/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""API endpoints for the AIMLAPI "Get API key" device-authorization flow.

``start`` opens an authorization the user approves in the browser; ``poll``
returns the issued key once approved. The device code lives in a server-side
map keyed by request id — the browser only ever sees the request id and the
consent URL, so no redirect URI has to be registered for a given deployment.
"""

import time
from typing import Annotated

from autogpt_libs.auth import get_user_id
from fastapi import APIRouter, HTTPException, Security
from pydantic import BaseModel

from backend.api.features.aimlapi.service import (
TERMINAL_FAILURE_STATUSES,
AimlapiAuthError,
AuthorizationRequest,
poll_authorization,
start_authorization,
)

router = APIRouter()

# request_id -> pending authorization. In-memory and per-process on purpose:
# the device code must never reach the browser, and the flow is short-lived
# (~15 min). A multi-worker deployment would need a shared store; a single
# instance is the common self-hosted case.
_PENDING: dict[str, AuthorizationRequest] = {}


def _prune(now: float) -> None:
for request_id in [rid for rid, a in _PENDING.items() if now >= a.expires_at]:
_PENDING.pop(request_id, None)


class AuthorizeStartResponse(BaseModel):
request_id: str
verification_uri: str
interval: int
expires_in: int


class AuthorizePollRequest(BaseModel):
request_id: str


class AuthorizePollResponse(BaseModel):
status: str
api_key: str | None = None


@router.post("/authorize/start")
async def authorize_start(
user_id: Annotated[str, Security(get_user_id)],
) -> AuthorizeStartResponse:
"""Begin a device authorization; the caller opens ``verification_uri``."""
now = time.time()
_prune(now)
try:
authorization = await start_authorization()
except AimlapiAuthError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
_PENDING[authorization.request_id] = authorization
return AuthorizeStartResponse(
request_id=authorization.request_id,
verification_uri=authorization.verification_uri,
interval=authorization.interval,
expires_in=max(0, int(authorization.expires_at - now)),
)


@router.post("/authorize/poll")
async def authorize_poll(
body: AuthorizePollRequest,
user_id: Annotated[str, Security(get_user_id)],
) -> AuthorizePollResponse:
"""Exchange the server-held device code for the issued key, if approved."""
now = time.time()
_prune(now)
authorization = _PENDING.get(body.request_id)
if authorization is None:
# Unknown or already-expired request id: report 'expired' rather than
# 404 so the frontend shows the same "start again" state.
return AuthorizePollResponse(status="expired")
try:
result = await poll_authorization(authorization)
except AimlapiAuthError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
if result.status == "ready" or result.status in TERMINAL_FAILURE_STATUSES:
_PENDING.pop(body.request_id, None)
return AuthorizePollResponse(status=result.status, api_key=result.api_key or None)
182 changes: 182 additions & 0 deletions autogpt_platform/backend/backend/api/features/aimlapi/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""AIMLAPI "Get API key" via OAuth 2.0 Device Authorization Grant (RFC 8628).

The app creates an authorization request, the user approves it on the aimlapi
consent page in the browser, and the app polls a token endpoint until the
issued API key comes back. The device code and the issued key stay server-side
— the browser only opens the consent URL, so no redirect URI or loopback
listener is needed (this is what makes the flow safe for arbitrary self-hosted
origins).
"""

import time
from dataclasses import dataclass
from urllib.parse import urlencode, urlparse, urlunparse

import httpx

from backend.api.features.aimlapi.config import (
AGENT_NAME,
attribution_headers,
resolve_endpoints,
resolve_partner_id,
resolve_partner_name,
resolve_requested_usd_limit_minor,
)

DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
HTTP_TIMEOUT_SECONDS = 15.0
# Terminal, non-recoverable outcomes reported by the token endpoint.
TERMINAL_FAILURE_STATUSES = {
"cancelled",
"canceled",
"denied",
"error",
"expired",
"failed",
"rejected",
}


class AimlapiAuthError(RuntimeError):
"""A safe, user-presentable failure of the AIMLAPI authorization flow."""


@dataclass(frozen=True, slots=True)
class AuthorizationRequest:
request_id: str
device_code: str
verification_uri: str
interval: int
expires_at: float


@dataclass(frozen=True, slots=True)
class AuthorizationPollResult:
status: str
api_key: str = ""


def _positive_int(value: object, default: int) -> int:
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
return default
try:
parsed = int(value)
except (ValueError, OverflowError):
return default
return parsed if parsed > 0 else default


def _response_json(response: httpx.Response) -> dict:
try:
data = response.json()
except ValueError as exc:
raise AimlapiAuthError("AIMLAPI returned an invalid response") from exc
if not isinstance(data, dict):
raise AimlapiAuthError("AIMLAPI returned an invalid response")
return data


def _verification_uri(request_id: str) -> str:
"""Rebuild the consent URL from the configured verification base.

The create response returns a production consent URL even on staging, so
the URL opened in the browser is always derived from
``AIMLAPI_VERIFICATION_BASE_URL`` rather than trusted from the response.
``source`` rides on the URL so a sign-up during consent is attributed to
this integration (headers cannot cross the browser OAuth redirect).
"""
from backend.api.features.aimlapi.config import AIMLAPI_SOURCE

endpoint = resolve_endpoints().verification_base_url
parsed = urlparse(endpoint)
if not parsed.scheme or not parsed.netloc:
raise AimlapiAuthError("AIMLAPI verification URL is invalid")
path = f'{parsed.path.rstrip("/")}/agent/authorize'
query = urlencode({"request": request_id, "source": AIMLAPI_SOURCE})
return urlunparse((parsed.scheme, parsed.netloc, path, "", query, ""))


async def start_authorization() -> AuthorizationRequest:
"""Create a device authorization and return what the browser/poller need."""
endpoints = resolve_endpoints()
payload = {
"partnerId": resolve_partner_id(),
"partnerName": resolve_partner_name(),
"agentName": AGENT_NAME,
"returnUrl": endpoints.verification_base_url,
"requestedUsdLimitMinor": resolve_requested_usd_limit_minor(),
}
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS) as client:
response = await client.post(
f"{endpoints.app_base_url}/v3/agent-auth/authorizations",
json=payload,
headers=attribution_headers(),
)
except httpx.HTTPError as exc:
raise AimlapiAuthError("Unable to start AIMLAPI authorization") from exc

if response.status_code not in (200, 201):
raise AimlapiAuthError(
f"AIMLAPI authorization failed with HTTP {response.status_code}"
)

data = _response_json(response)
request_id = str(data.get("requestId") or "").strip()
device_code = str(data.get("deviceCode") or "").strip()
if not request_id or not device_code:
raise AimlapiAuthError("AIMLAPI authorization response is incomplete")

interval = _positive_int(data.get("interval"), 5)
expires_in = _positive_int(data.get("expiresIn"), 900)
return AuthorizationRequest(
request_id=request_id,
device_code=device_code,
verification_uri=_verification_uri(request_id),
interval=interval,
expires_at=time.time() + expires_in,
)


async def poll_authorization(
authorization: AuthorizationRequest,
) -> AuthorizationPollResult:
"""Exchange the device code for the issued key, or report the wait state."""
if time.time() >= authorization.expires_at:
return AuthorizationPollResult(status="expired")

endpoints = resolve_endpoints()
payload = {
"partnerId": resolve_partner_id(),
"deviceCode": authorization.device_code,
"grant_type": DEVICE_CODE_GRANT,
}
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS) as client:
response = await client.post(
f"{endpoints.app_base_url}/v3/agent-auth/token",
json=payload,
headers=attribution_headers(),
)
except httpx.HTTPError as exc:
raise AimlapiAuthError("Unable to check AIMLAPI authorization") from exc

if response.status_code not in (200, 201):
raise AimlapiAuthError(
f"AIMLAPI authorization check failed with HTTP {response.status_code}"
)

data = _response_json(response)
status = str(data.get("status") or "").strip().lower()
api_key = str(
data.get("apiKey")
or data.get("api_key")
or data.get("access_token")
or data.get("key")
or ""
).strip()
if api_key:
return AuthorizationPollResult(status="ready", api_key=api_key)
if status in TERMINAL_FAILURE_STATUSES:
return AuthorizationPollResult(status=status)
return AuthorizationPollResult(status=status or "pending")
14 changes: 13 additions & 1 deletion autogpt_platform/backend/backend/api/features/builder/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,11 @@ def _build_marketplace_items(
return results


# Providers surfaced at the top of the builder integration list (marked
# "Recommended" in the UI). Order within this tuple is the display priority.
FEATURED_PROVIDERS: tuple[str, ...] = ("aiml_api",)


def get_providers(
query: str = "",
page: int = 1,
Expand All @@ -601,7 +606,14 @@ def get_providers(

all_providers = _get_all_providers()

for provider in all_providers.values():
# Recommended providers are surfaced first in the builder's integration list.
# sorted() is stable, so non-featured providers keep their existing order.
ordered_providers = sorted(
all_providers.values(),
key=lambda p: 0 if p.name.value in FEATURED_PROVIDERS else 1,
)

for provider in ordered_providers:
if (
query not in provider.name.value.lower()
and query not in provider.description.lower()
Expand Down
Loading
Loading