From f4ad1c2eeda837ef62fcdfa0707e6fee316bbe34 Mon Sep 17 00:00:00 2001 From: aimlapi Date: Tue, 4 Aug 2026 12:51:38 +0300 Subject: [PATCH 01/25] fix(backend): correct AI/ML API base URL to /v1 and add attribution headers The AI/ML API provider pointed at https://api.aimlapi.com/v2, which does not exist on the public API (all /v2 routes 404); chat completions therefore failed. Point the provider at /v1, and send X-AIMLAPI-Source / X-AIMLAPI-Partner-ID so provider-side attribution works. Extend the dispatch test to cover both. --- autogpt_platform/backend/backend/util/llm/providers.py | 4 +++- .../backend/backend/util/llm/providers_test.py | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/autogpt_platform/backend/backend/util/llm/providers.py b/autogpt_platform/backend/backend/util/llm/providers.py index cb0060ca2aef..668abf1d4c65 100644 --- a/autogpt_platform/backend/backend/util/llm/providers.py +++ b/autogpt_platform/backend/backend/util/llm/providers.py @@ -364,7 +364,7 @@ async def _dispatch_sync( ) if provider == "aiml_api": return await _call_openai_compat( - base_url="https://api.aimlapi.com/v2", + base_url="https://api.aimlapi.com/v1", model=model, api_key=api_key, messages=messages, @@ -379,6 +379,8 @@ async def _dispatch_sync( "X-Project": "AutoGPT", "X-Title": "AutoGPT", "HTTP-Referer": "https://github.com/Significant-Gravitas/AutoGPT", + "X-AIMLAPI-Source": "agent/autogpt", + "X-AIMLAPI-Partner-ID": "part_T70zDIEvQLKSMzMQ7asjdtKR", }, ) if provider == "v0": diff --git a/autogpt_platform/backend/backend/util/llm/providers_test.py b/autogpt_platform/backend/backend/util/llm/providers_test.py index ebc6e149b108..da528edc29c9 100644 --- a/autogpt_platform/backend/backend/util/llm/providers_test.py +++ b/autogpt_platform/backend/backend/util/llm/providers_test.py @@ -724,6 +724,14 @@ async def test_dispatches_with_default_headers(self): # AI/ML expects branded headers on the client construction. ctor_kwargs = constructor.call_args.kwargs assert ctor_kwargs["default_headers"]["X-Project"] == "AutoGPT" + # AI/ML API is served under /v1 (the /v2 host does not exist). + assert ctor_kwargs["base_url"] == "https://api.aimlapi.com/v1" + # Attribution headers identify agent-originated traffic to the provider. + assert ctor_kwargs["default_headers"]["X-AIMLAPI-Source"] == "agent/autogpt" + assert ( + ctor_kwargs["default_headers"]["X-AIMLAPI-Partner-ID"] + == "part_T70zDIEvQLKSMzMQ7asjdtKR" + ) # --------------------------------------------------------------------------- From 93e4e0eae759e209fe3833455be6c64c96b7d908 Mon Sep 17 00:00:00 2001 From: aimlapi Date: Tue, 4 Aug 2026 13:38:53 +0300 Subject: [PATCH 02/25] feat(platform): surface aimlapi.com first in the builder + rename provider Sort featured providers (AIML API) to the top of the builder integration provider list, and show the provider as 'aimlapi.com' in the credentials UI. --- .../backend/backend/api/features/builder/db.py | 14 +++++++++++++- .../src/providers/agent-credentials/helper.ts | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/autogpt_platform/backend/backend/api/features/builder/db.py b/autogpt_platform/backend/backend/api/features/builder/db.py index 0a99132bc9f0..acb60d895731 100644 --- a/autogpt_platform/backend/backend/api/features/builder/db.py +++ b/autogpt_platform/backend/backend/api/features/builder/db.py @@ -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, @@ -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() diff --git a/autogpt_platform/frontend/src/providers/agent-credentials/helper.ts b/autogpt_platform/frontend/src/providers/agent-credentials/helper.ts index 94661574ff69..f99ac4cb8783 100644 --- a/autogpt_platform/frontend/src/providers/agent-credentials/helper.ts +++ b/autogpt_platform/frontend/src/providers/agent-credentials/helper.ts @@ -5,7 +5,7 @@ export function toDisplayName(provider: unknown): string { // Special cases that need manual handling const specialCases: Record = { - aiml_api: "AI/ML", + aiml_api: "aimlapi.com", d_id: "D-ID", e2b: "E2B", llama_api: "Llama API", From ed7d73ddfe00d239abd5db2d2a5a6a38493fa9d6 Mon Sep 17 00:00:00 2001 From: aimlapi Date: Wed, 5 Aug 2026 17:50:36 +0300 Subject: [PATCH 03/25] chore(deploy): accept NEXT_PUBLIC_FRONTEND_BASE_URL as a frontend build arg Lets a hosted deployment bake its real public origin into the client bundle (the browser API client calls ${NEXT_PUBLIC_FRONTEND_BASE_URL}/api/proxy). Defaults to localhost; overridden via compose build.args on the dev server. --- autogpt_platform/frontend/Dockerfile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/autogpt_platform/frontend/Dockerfile b/autogpt_platform/frontend/Dockerfile index 13c4360e0cda..468767d2ab1e 100644 --- a/autogpt_platform/frontend/Dockerfile +++ b/autogpt_platform/frontend/Dockerfile @@ -16,6 +16,11 @@ ENV NEXT_PUBLIC_PW_TEST=$NEXT_PUBLIC_PW_TEST # Keep Docker builds defaulting to false to avoid the memory hit. ARG NEXT_PUBLIC_SOURCEMAPS="false" ENV NEXT_PUBLIC_SOURCEMAPS=$NEXT_PUBLIC_SOURCEMAPS +# Public origin baked into the client bundle (Next inlines NEXT_PUBLIC_* at +# build). The browser API client calls `${NEXT_PUBLIC_FRONTEND_BASE_URL}/api/proxy`, +# so a hosted deployment must build with its real origin, not localhost. +ARG NEXT_PUBLIC_FRONTEND_BASE_URL="http://localhost:3000" +ENV NEXT_PUBLIC_FRONTEND_BASE_URL=$NEXT_PUBLIC_FRONTEND_BASE_URL ENV NODE_ENV="production" # Merge env files appropriately based on environment RUN if [ -f .env.production ]; then \ From 45fc0a35761c95f356b2c36b06a9cd22f13a3a60 Mon Sep 17 00:00:00 2001 From: aimlapi Date: Thu, 6 Aug 2026 13:19:37 +0300 Subject: [PATCH 04/25] fix(frontend): route SSR auth-token self-call to loopback, not public origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getServerAuthToken fetched ${BETTER_AUTH_URL}/api/auth/token — the Next server calling itself via the PUBLIC url. Behind a reverse proxy the container cannot reach its own public origin (hairpin), so every SSR token fetch hung to the 10s timeout and login/signup failed. Target the loopback instead (AUTH_SELF_BASE_URL override, default http://localhost:3000). --- .../src/lib/autogpt-server-api/helpers.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/helpers.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/helpers.ts index 3ad69a4ced6a..5c4f0f30174a 100644 --- a/autogpt_platform/frontend/src/lib/autogpt-server-api/helpers.ts +++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/helpers.ts @@ -158,16 +158,16 @@ export const getServerAuthToken = cache(async (): Promise => { .map(({ name, value }) => `${name}=${encodeURIComponent(value)}`) .join("; "); - const baseURL = - process.env.BETTER_AUTH_URL || - process.env.NEXT_PUBLIC_FRONTEND_BASE_URL || - "http://localhost:3000"; - // This is a request from the Next server back to itself, which the route - // handlers avoid by minting in-process (lib/auth/server/getServerAuthToken). - // This module is in the client bundle graph and so cannot import `auth` - // (-> `pg`), so the SSR path keeps the HTTP hop. Bound it: an unbounded - // self-request can occupy a worker waiting on a worker, and that deadlock - // is what made the Copilot page hang forever instead of erroring. + // This is a request from the Next server back to itself, so it must target + // the loopback interface — NOT the public origin. Behind a reverse proxy the + // container cannot reach its own public URL (hairpin NAT), so using + // BETTER_AUTH_URL here makes every SSR auth-token fetch hang until the 10s + // timeout and breaks login/signup. Default to loopback; override with + // AUTH_SELF_BASE_URL if the server listens elsewhere. + // The route handlers avoid this hop entirely by minting in-process + // (lib/auth/server/getServerAuthToken); this module is in the client bundle + // graph and cannot import `auth` (-> `pg`), so the SSR path keeps the HTTP hop. + const baseURL = process.env.AUTH_SELF_BASE_URL || "http://localhost:3000"; const response = await fetch(`${baseURL}/api/auth/token`, { headers: { cookie: cookieHeader }, cache: "no-store", From 3a0799e4c5e222cd4b77c4fb5ef40b90a0956d65 Mon Sep 17 00:00:00 2001 From: aimlapi Date: Thu, 6 Aug 2026 13:54:31 +0300 Subject: [PATCH 05/25] feat(platform): brand aiml_api as aimlapi.com + Recommended badge in integration surfaces - Display name -> 'aimlapi.com' in the builder integration list, the Connect-a- service modal, the credential field, and the model picker provider label. - New provider description (text/image/video/audio/embeddings, one key). - Green 'Recommended' badge + pin aimlapi.com first in both the builder list (backend featured order) and the Connect-a-service modal (sort). --- .../blocks/_static_provider_configs.py | 6 ++++- .../backend/backend/blocks/llm.py | 8 +++++- .../NewBlockMenu/Integration.tsx | 26 +++++++++++++++---- .../components/ProviderRow.tsx | 12 +++++++-- .../ConnectServiceDialog/helpers.ts | 11 +++++++- .../contextual/IntegrationsPanel/helpers.ts | 1 + .../custom/CredentialField/helpers.ts | 2 +- 7 files changed, 55 insertions(+), 11 deletions(-) diff --git a/autogpt_platform/backend/backend/blocks/_static_provider_configs.py b/autogpt_platform/backend/backend/blocks/_static_provider_configs.py index fbb5d9976d68..506a56ea2073 100644 --- a/autogpt_platform/backend/backend/blocks/_static_provider_configs.py +++ b/autogpt_platform/backend/backend/blocks/_static_provider_configs.py @@ -21,7 +21,11 @@ _STATIC_PROVIDER_CONFIGS: dict[str, tuple[str, tuple[CredentialsType, ...]]] = { # LLM providers that share blocks/llm.py - "aiml_api": ("Unified access to 100+ AI models", ("api_key",)), + "aiml_api": ( + "Access 1,000+ AI models: text, image, video, audio and embeddings - " + "through a single API key. One-click setup.", + ("api_key",), + ), "anthropic": ("Claude language models", ("api_key",)), "groq": ("Fast LLM inference", ("api_key",)), "llama_api": ("Llama model hosting", ("api_key",)), diff --git a/autogpt_platform/backend/backend/blocks/llm.py b/autogpt_platform/backend/backend/blocks/llm.py index 05b0261464cb..752719d0cdb8 100644 --- a/autogpt_platform/backend/backend/blocks/llm.py +++ b/autogpt_platform/backend/backend/blocks/llm.py @@ -444,7 +444,13 @@ def max_output_tokens(self) -> int | None: ), # claude-haiku-4-5-20251001 # https://docs.aimlapi.com/api-overview/model-database/text-models LlmModel.AIML_API_LLAMA3_3_70B: ModelMetadata( - "aiml_api", 128000, None, "Llama 3.3 70B Instruct Turbo", "AI/ML", "Meta", 1 + "aiml_api", + 128000, + None, + "Llama 3.3 70B Instruct Turbo", + "aimlapi.com", + "Meta", + 1, ), # https://console.groq.com/docs/models LlmModel.LLAMA3_3_70B: ModelMetadata( diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/Integration.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/Integration.tsx index 32e63b8183c6..49ee65bb0139 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/Integration.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/Integration.tsx @@ -1,6 +1,13 @@ +import { Badge } from "@/components/atoms/Badge/Badge"; import { Button } from "@/components/__legacy__/ui/button"; import { Skeleton } from "@/components/__legacy__/ui/skeleton"; import { beautifyString, cn } from "@/lib/utils"; + +// Providers surfaced with an "aimlapi.com" label + "Recommended" badge in the +// builder integration list. +const RECOMMENDED_PROVIDERS: Record = { + aiml_api: "aimlapi.com", +}; import Image from "next/image"; import React, { ButtonHTMLAttributes } from "react"; @@ -46,11 +53,20 @@ export const Integration: IntegrationComponent = ({
- {title && ( -

- {beautifyString(title)} -

- )} +
+ {title && ( +

+ {title && RECOMMENDED_PROVIDERS[title] + ? RECOMMENDED_PROVIDERS[title] + : beautifyString(title)} +

+ )} + {title && RECOMMENDED_PROVIDERS[title] ? ( + + Recommended + + ) : null} +
{number_of_blocks} diff --git a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/ProviderRow.tsx b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/ProviderRow.tsx index d6c81ec18380..3f24ab6307fd 100644 --- a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/ProviderRow.tsx +++ b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/ProviderRow.tsx @@ -4,6 +4,7 @@ import Image from "next/image"; import { useState } from "react"; import { PlusIcon } from "@phosphor-icons/react"; +import { Badge } from "@/components/atoms/Badge/Badge"; import type { ConnectableProvider } from "../helpers"; interface Props { @@ -41,8 +42,15 @@ export function ProviderRow({ provider, onSelect }: Props) { /> )} - - {provider.name} + + + {provider.name} + + {provider.recommended ? ( + + Recommended + + ) : null} {provider.description ?? provider.id} diff --git a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/helpers.ts b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/helpers.ts index 8754480c58ad..cf8df9a02ca1 100644 --- a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/helpers.ts +++ b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/helpers.ts @@ -7,11 +7,15 @@ export type AuthMethod = (typeof AuthType)[keyof typeof AuthType]; export { AuthType }; +// Providers surfaced first in the "Connect a service" list, marked "Recommended". +const RECOMMENDED_PROVIDERS: ReadonlySet = new Set(["aiml_api"]); + export interface ConnectableProvider { id: string; name: string; description?: string | null; supportedAuthTypes: AuthMethod[]; + recommended?: boolean; } const KNOWN_AUTH_METHODS: ReadonlySet = new Set( @@ -38,9 +42,14 @@ export function toConnectableProviders( name: formatProviderName(item.name), description: item.description, supportedAuthTypes: normalizeAuthTypes(item.supported_auth_types), + recommended: RECOMMENDED_PROVIDERS.has(item.name), }); } - result.sort((a, b) => a.name.localeCompare(b.name)); + // Recommended providers first, then alphabetical. + result.sort((a, b) => { + if (a.recommended !== b.recommended) return a.recommended ? -1 : 1; + return a.name.localeCompare(b.name); + }); return result; } diff --git a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/helpers.ts b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/helpers.ts index 22c068c6124e..65409347ef95 100644 --- a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/helpers.ts +++ b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/helpers.ts @@ -32,6 +32,7 @@ export function typeBadgeLabel(type: CredentialType): string { } const PROVIDER_DISPLAY_NAME_OVERRIDES: Record = { + aiml_api: "aimlapi.com", github: "GitHub", google: "Google", google_maps: "Google Maps", diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/CredentialField/helpers.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/CredentialField/helpers.ts index e2b1a05d54a6..8e0262f581dc 100644 --- a/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/CredentialField/helpers.ts +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/CredentialField/helpers.ts @@ -62,7 +62,7 @@ export const filterCredentialsByProvider = ( export function toDisplayName(provider: string): string { // Special cases that need manual handling const specialCases: Record = { - aiml_api: "AI/ML", + aiml_api: "aimlapi.com", d_id: "D-ID", e2b: "E2B", llama_api: "Llama API", From 04e5128c8eb01f24ed9c7f353bd8d15f8fbae779 Mon Sep 17 00:00:00 2001 From: aimlapi Date: Thu, 6 Aug 2026 14:47:30 +0300 Subject: [PATCH 06/25] feat(platform): AIMLAPI 'Get API key' device-grant onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: self-contained api/features/aimlapi module (config + service + router) implementing the RFC 8628 device-authorization 'agent-auth' flow — POST /api/aimlapi/authorize/{start,poll}; device code kept server-side, X-AIMLAPI-Source (agent/autogpt) + X-AIMLAPI-Partner-ID on every call, source on the consent URL. Frontend: 'Get API key' button in the aimlapi.com Connect-a-service form (opens the consent tab, polls, auto-fills the key, green success / red error), prefilled Name 'My aimlapi.com key'. --- .../backend/api/features/aimlapi/__init__.py | 0 .../backend/api/features/aimlapi/config.py | 77 ++++++++ .../backend/api/features/aimlapi/router.py | 93 +++++++++ .../backend/api/features/aimlapi/service.py | 182 ++++++++++++++++++ .../backend/backend/api/rest_api.py | 6 + .../DetailView/ApiKeyConnectForm.tsx | 77 ++++++-- .../DetailView/useApiKeyConnectForm.ts | 93 ++++++++- 7 files changed, 507 insertions(+), 21 deletions(-) create mode 100644 autogpt_platform/backend/backend/api/features/aimlapi/__init__.py create mode 100644 autogpt_platform/backend/backend/api/features/aimlapi/config.py create mode 100644 autogpt_platform/backend/backend/api/features/aimlapi/router.py create mode 100644 autogpt_platform/backend/backend/api/features/aimlapi/service.py diff --git a/autogpt_platform/backend/backend/api/features/aimlapi/__init__.py b/autogpt_platform/backend/backend/api/features/aimlapi/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/autogpt_platform/backend/backend/api/features/aimlapi/config.py b/autogpt_platform/backend/backend/api/features/aimlapi/config.py new file mode 100644 index 000000000000..ac779ee9143a --- /dev/null +++ b/autogpt_platform/backend/backend/api/features/aimlapi/config.py @@ -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(), + } diff --git a/autogpt_platform/backend/backend/api/features/aimlapi/router.py b/autogpt_platform/backend/backend/api/features/aimlapi/router.py new file mode 100644 index 000000000000..30ad76b5a1f4 --- /dev/null +++ b/autogpt_platform/backend/backend/api/features/aimlapi/router.py @@ -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) diff --git a/autogpt_platform/backend/backend/api/features/aimlapi/service.py b/autogpt_platform/backend/backend/api/features/aimlapi/service.py new file mode 100644 index 000000000000..6ebb7a2b7728 --- /dev/null +++ b/autogpt_platform/backend/backend/api/features/aimlapi/service.py @@ -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") diff --git a/autogpt_platform/backend/backend/api/rest_api.py b/autogpt_platform/backend/backend/api/rest_api.py index 76136f1179c7..125bf2e66dc4 100644 --- a/autogpt_platform/backend/backend/api/rest_api.py +++ b/autogpt_platform/backend/backend/api/rest_api.py @@ -86,6 +86,7 @@ from .external.fastapi_app import external_api from .features.analytics import router as analytics_router +from .features.aimlapi.router import router as aimlapi_router from .features.integrations.router import router as integrations_router from .middleware.security import SecurityHeadersMiddleware from .utils.cors import build_cors_params @@ -356,6 +357,11 @@ async def validation_error_handler( prefix="/api/integrations", tags=["v1", "integrations"], ) +app.include_router( + aimlapi_router, + prefix="/api/aimlapi", + tags=["aimlapi"], +) app.include_router( analytics_router, prefix="/api/analytics", diff --git a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/ApiKeyConnectForm.tsx b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/ApiKeyConnectForm.tsx index 9242511864be..ceca7473ce77 100644 --- a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/ApiKeyConnectForm.tsx +++ b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/ApiKeyConnectForm.tsx @@ -23,10 +23,13 @@ export function ApiKeyConnectForm({ providerName, onSuccess, }: Props) { - const { form, handleSubmit, isPending } = useApiKeyConnectForm({ - provider, - onSuccess, - }); + const isAimlapi = provider === "aiml_api"; + const { form, handleSubmit, isPending, getApiKey, oauthStatus, oauthMessage } = + useApiKeyConnectForm({ + provider, + defaultTitle: isAimlapi ? `My ${providerName} key` : undefined, + onSuccess, + }); return (
@@ -56,16 +59,62 @@ export function ApiKeyConnectForm({ render={({ field }) => ( - + {isAimlapi ? ( +
+
+
+ +
+ or +
+ + + Continue with aimlapi.com + +
+
+ {oauthStatus === "success" && oauthMessage ? ( +

+ {oauthMessage} +

+ ) : null} + {oauthStatus === "error" && oauthMessage ? ( +

+ {oauthMessage} +

+ ) : null} +
+ ) : ( + + )}
diff --git a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/useApiKeyConnectForm.ts b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/useApiKeyConnectForm.ts index 349c85d28eef..7acc198166b9 100644 --- a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/useApiKeyConnectForm.ts +++ b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/useApiKeyConnectForm.ts @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { useQueryClient } from "@tanstack/react-query"; @@ -15,9 +15,12 @@ import { apiKeyConnectSchema, type ApiKeyConnectFormValues } from "./schema"; interface Args { provider: string; + defaultTitle?: string; onSuccess: () => void; } +type OAuthStatus = "idle" | "authorizing" | "success" | "error"; + function toUnixSeconds(value: string | undefined): number | undefined { if (!value) return undefined; const ms = Date.parse(value); @@ -25,23 +28,92 @@ function toUnixSeconds(value: string | undefined): number | undefined { return Math.floor(ms / 1000); } -export function useApiKeyConnectForm({ provider, onSuccess }: Args) { +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// The AIMLAPI "Get API key" flow lives behind the frontend's server proxy, +// which forwards `/api/proxy/` to the backend and injects auth. +async function postProxy(path: string, body: unknown): Promise { + const res = await fetch(`/api/proxy/${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body ?? {}), + }); + if (!res.ok) throw new Error(`Request failed (${res.status})`); + return (await res.json()) as T; +} + +export function useApiKeyConnectForm({ provider, defaultTitle, onSuccess }: Args) { const queryClient = useQueryClient(); const [isPending, setIsPending] = useState(false); + const [oauthStatus, setOauthStatus] = useState("idle"); + const [oauthMessage, setOauthMessage] = useState(null); + const authorizingRef = useRef(false); const form = useForm({ resolver: zodResolver(apiKeyConnectSchema), - defaultValues: { title: "", apiKey: "", expiresAt: "" }, + defaultValues: { title: defaultTitle ?? "", apiKey: "", expiresAt: "" }, mode: "onChange", }); + async function getApiKey() { + if (authorizingRef.current) return; + authorizingRef.current = true; + setOauthStatus("authorizing"); + setOauthMessage(null); + + // Open the consent tab synchronously so pop-up blockers allow it, then + // point it at the verification URL once the backend returns one. + const consentWindow = window.open("", "_blank", "noopener,noreferrer"); + + try { + const start = await postProxy<{ + request_id: string; + verification_uri: string; + interval: number; + expires_in: number; + }>("aimlapi/authorize/start", {}); + + if (consentWindow) consentWindow.location.href = start.verification_uri; + else window.open(start.verification_uri, "_blank", "noopener,noreferrer"); + + const intervalMs = Math.max(1, start.interval) * 1000; + const deadline = Date.now() + Math.max(1, start.expires_in) * 1000; + + while (Date.now() < deadline) { + await sleep(intervalMs); + const poll = await postProxy<{ status: string; api_key: string | null }>( + "aimlapi/authorize/poll", + { request_id: start.request_id }, + ); + if (poll.status === "ready" && poll.api_key) { + form.setValue("apiKey", poll.api_key, { + shouldValidate: true, + shouldDirty: true, + }); + setOauthStatus("success"); + setOauthMessage("Your key has already been generated and added above."); + return; + } + if (poll.status !== "pending" && poll.status !== "authorizing") { + throw new Error("Sign-in failed. Please try again."); + } + } + throw new Error("Sign-in timed out. Please try again."); + } catch (error) { + if (consentWindow && !consentWindow.closed) consentWindow.close(); + setOauthStatus("error"); + setOauthMessage( + error instanceof Error ? error.message : "Sign-in failed. Please try again.", + ); + } finally { + authorizingRef.current = false; + } + } + async function handleSubmit(values: ApiKeyConnectFormValues) { setIsPending(true); try { // customMutator throws on non-2xx, so reaching this line means success. - // Trust HTTP semantics rather than pinning to a specific 2xx code — - // proxies / future backend changes can swap 201 ↔ 200 without this - // breaking and silently failing in production. await postV1CreateCredentials(provider, { provider, type: "api_key", @@ -67,5 +139,12 @@ export function useApiKeyConnectForm({ provider, onSuccess }: Args) { } } - return { form, handleSubmit, isPending }; + return { + form, + handleSubmit, + isPending, + getApiKey, + oauthStatus, + oauthMessage, + }; } From 9f7526944ce229f79b7ed13138ae5160851a9af2 Mon Sep 17 00:00:00 2001 From: aimlapi Date: Thu, 6 Aug 2026 14:54:34 +0300 Subject: [PATCH 07/25] fix(frontend): call the aimlapi device-grant via the /api-prefixed proxy path --- .../components/DetailView/useApiKeyConnectForm.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/useApiKeyConnectForm.ts b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/useApiKeyConnectForm.ts index 7acc198166b9..ecd6a8c1a1f0 100644 --- a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/useApiKeyConnectForm.ts +++ b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/useApiKeyConnectForm.ts @@ -71,7 +71,7 @@ export function useApiKeyConnectForm({ provider, defaultTitle, onSuccess }: Args verification_uri: string; interval: number; expires_in: number; - }>("aimlapi/authorize/start", {}); + }>("api/aimlapi/authorize/start", {}); if (consentWindow) consentWindow.location.href = start.verification_uri; else window.open(start.verification_uri, "_blank", "noopener,noreferrer"); @@ -82,7 +82,7 @@ export function useApiKeyConnectForm({ provider, defaultTitle, onSuccess }: Args while (Date.now() < deadline) { await sleep(intervalMs); const poll = await postProxy<{ status: string; api_key: string | null }>( - "aimlapi/authorize/poll", + "api/aimlapi/authorize/poll", { request_id: start.request_id }, ); if (poll.status === "ready" && poll.api_key) { From d8a1acb5d5e6e7f327ce873d9715f478b894f36b Mon Sep 17 00:00:00 2001 From: aimlapi Date: Thu, 6 Aug 2026 14:57:53 +0300 Subject: [PATCH 08/25] feat(backend): make AIML API inference base URL env-overridable (AIMLAPI_INFERENCE_URL) Lets a deployment point inference at staging (api-staging.aimlapi.com/v1) without a code change; defaults to production. --- autogpt_platform/backend/backend/util/llm/providers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/autogpt_platform/backend/backend/util/llm/providers.py b/autogpt_platform/backend/backend/util/llm/providers.py index 668abf1d4c65..d864ef659a42 100644 --- a/autogpt_platform/backend/backend/util/llm/providers.py +++ b/autogpt_platform/backend/backend/util/llm/providers.py @@ -34,6 +34,7 @@ import functools import json as json_module import logging +import os from datetime import datetime, timezone from typing import Any, Literal, cast @@ -364,7 +365,9 @@ async def _dispatch_sync( ) if provider == "aiml_api": return await _call_openai_compat( - base_url="https://api.aimlapi.com/v1", + base_url=os.getenv( + "AIMLAPI_INFERENCE_URL", "https://api.aimlapi.com/v1" + ), model=model, api_key=api_key, messages=messages, From a084a8780dcfa12feef5d12da630e02a7a190962 Mon Sep 17 00:00:00 2001 From: aimlapi Date: Thu, 6 Aug 2026 16:09:10 +0300 Subject: [PATCH 09/25] fix(frontend): repair AIMLAPI Get API key popup + align key field to design - window.open must not use noopener when we need the handle to redirect the tab; with noopener it returns null, leaving a blank about:blank page - move the 'Have a key? Paste it here.' hint to a full-width label row (right-aligned) and drop the divider bars around 'or' --- .../DetailView/ApiKeyConnectForm.tsx | 25 +++++++++++++++---- .../DetailView/useApiKeyConnectForm.ts | 8 +++--- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/ApiKeyConnectForm.tsx b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/ApiKeyConnectForm.tsx index ceca7473ce77..1423b14d8209 100644 --- a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/ApiKeyConnectForm.tsx +++ b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/ApiKeyConnectForm.tsx @@ -2,6 +2,7 @@ import { Button } from "@/components/atoms/Button/Button"; import { Input } from "@/components/atoms/Input/Input"; +import { Text } from "@/components/atoms/Text/Text"; import { Form, FormControl, @@ -60,7 +61,19 @@ export function ApiKeyConnectForm({ {isAimlapi ? ( -
+
+
+ + API key + + + Have a key? Paste it here. + +
- or -
+ + or + +
- + Continue with aimlapi.com
diff --git a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/useApiKeyConnectForm.ts b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/useApiKeyConnectForm.ts index ecd6a8c1a1f0..1802f3582be9 100644 --- a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/useApiKeyConnectForm.ts +++ b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/useApiKeyConnectForm.ts @@ -62,8 +62,10 @@ export function useApiKeyConnectForm({ provider, defaultTitle, onSuccess }: Args setOauthMessage(null); // Open the consent tab synchronously so pop-up blockers allow it, then - // point it at the verification URL once the backend returns one. - const consentWindow = window.open("", "_blank", "noopener,noreferrer"); + // point it at the verification URL once the backend returns one. Do NOT + // pass `noopener` here: with it, window.open() returns null and we lose the + // handle needed to redirect the tab — leaving a blank about:blank page. + const consentWindow = window.open("about:blank", "_blank"); try { const start = await postProxy<{ @@ -74,7 +76,7 @@ export function useApiKeyConnectForm({ provider, defaultTitle, onSuccess }: Args }>("api/aimlapi/authorize/start", {}); if (consentWindow) consentWindow.location.href = start.verification_uri; - else window.open(start.verification_uri, "_blank", "noopener,noreferrer"); + else window.open(start.verification_uri, "_blank"); const intervalMs = Math.max(1, start.interval) * 1000; const deadline = Date.now() + Math.max(1, start.expires_in) * 1000; From 8f18bbba8fd44b086e793f8fcc19ef58f3fd537f Mon Sep 17 00:00:00 2001 From: aimlapi Date: Thu, 6 Aug 2026 18:19:29 +0300 Subject: [PATCH 10/25] fix(frontend): wrap long provider descriptions + tidy AIMLAPI key row - provider descriptions in the connect list row and detail header wrap to two lines (line-clamp-2) instead of a single truncated line that ran off to the right; the list row grows (min-h) to fit the second line - drop the extra 'Have a key? Paste it here.' hint on the AIMLAPI key field - put the key input, 'or' and the button on one horizontal line and give the button the same rounding (rounded-xl) and height as the input --- .../DetailView/ApiKeyConnectForm.tsx | 50 ++++++++----------- .../components/DetailView/DetailView.tsx | 2 +- .../components/ProviderRow.tsx | 4 +- 3 files changed, 23 insertions(+), 33 deletions(-) diff --git a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/ApiKeyConnectForm.tsx b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/ApiKeyConnectForm.tsx index 1423b14d8209..88be15b47aa2 100644 --- a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/ApiKeyConnectForm.tsx +++ b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/ApiKeyConnectForm.tsx @@ -62,19 +62,10 @@ export function ApiKeyConnectForm({ {isAimlapi ? (
-
- - API key - - - Have a key? Paste it here. - -
-
+ + API key + +
- - or + or + +
+
+ + Continue with aimlapi.com -
- - - Continue with aimlapi.com - -
{oauthStatus === "success" && oauthMessage ? (

diff --git a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/DetailView.tsx b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/DetailView.tsx index f5abf22b4c29..f9b78c252b47 100644 --- a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/DetailView.tsx +++ b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/DetailView.tsx @@ -70,7 +70,7 @@ export function DetailView({ provider, onBack, onSuccess }: Props) { {provider.name} {description ? ( - + {description} ) : null} diff --git a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/ProviderRow.tsx b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/ProviderRow.tsx index 3f24ab6307fd..d6ce14ed1079 100644 --- a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/ProviderRow.tsx +++ b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/ProviderRow.tsx @@ -21,7 +21,7 @@ export function ProviderRow({ provider, onSelect }: Props) {

-
- - Continue with aimlapi.com - -
{oauthStatus === "success" && oauthMessage ? (

{oauthMessage} diff --git a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/DetailView.tsx b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/DetailView.tsx index f9b78c252b47..f5abf22b4c29 100644 --- a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/DetailView.tsx +++ b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/DetailView.tsx @@ -70,7 +70,7 @@ export function DetailView({ provider, onBack, onSuccess }: Props) { {provider.name} {description ? ( - + {description} ) : null} diff --git a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/ProviderRow.tsx b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/ProviderRow.tsx index d6ce14ed1079..3f24ab6307fd 100644 --- a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/ProviderRow.tsx +++ b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/ProviderRow.tsx @@ -21,7 +21,7 @@ export function ProviderRow({ provider, onSelect }: Props) { +

+ {oauthStatus === "success" && oauthMessage ? ( +

+ {oauthMessage} +

+ ) : null} + {oauthStatus === "error" && oauthMessage ? ( +

+ {oauthMessage} +

+ ) : null} +
+ ) : ( - - )} + ) + } /> void; } -type OAuthStatus = "idle" | "authorizing" | "success" | "error"; - function toUnixSeconds(value: string | undefined): number | undefined { if (!value) return undefined; const ms = Date.parse(value); @@ -28,26 +27,9 @@ function toUnixSeconds(value: string | undefined): number | undefined { return Math.floor(ms / 1000); } -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -// The AIMLAPI "Get API key" flow lives behind the frontend's server proxy, -// which forwards `/api/proxy/` to the backend and injects auth. -async function postProxy(path: string, body: unknown): Promise { - const res = await fetch(`/api/proxy/${path}`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body ?? {}), - }); - if (!res.ok) throw new Error(`Request failed (${res.status})`); - return (await res.json()) as T; -} - export function useApiKeyConnectForm({ provider, defaultTitle, onSuccess }: Args) { const queryClient = useQueryClient(); const [isPending, setIsPending] = useState(false); - const [oauthStatus, setOauthStatus] = useState("idle"); - const [oauthMessage, setOauthMessage] = useState(null); - const authorizingRef = useRef(false); const form = useForm({ resolver: zodResolver(apiKeyConnectSchema), @@ -55,62 +37,9 @@ export function useApiKeyConnectForm({ provider, defaultTitle, onSuccess }: Args mode: "onChange", }); - async function getApiKey() { - if (authorizingRef.current) return; - authorizingRef.current = true; - setOauthStatus("authorizing"); - setOauthMessage(null); - - // Open the consent tab synchronously so pop-up blockers allow it, then - // point it at the verification URL once the backend returns one. Do NOT - // pass `noopener` here: with it, window.open() returns null and we lose the - // handle needed to redirect the tab — leaving a blank about:blank page. - const consentWindow = window.open("about:blank", "_blank"); - - try { - const start = await postProxy<{ - request_id: string; - verification_uri: string; - interval: number; - expires_in: number; - }>("api/aimlapi/authorize/start", {}); - - if (consentWindow) consentWindow.location.href = start.verification_uri; - else window.open(start.verification_uri, "_blank"); - - const intervalMs = Math.max(1, start.interval) * 1000; - const deadline = Date.now() + Math.max(1, start.expires_in) * 1000; - - while (Date.now() < deadline) { - await sleep(intervalMs); - const poll = await postProxy<{ status: string; api_key: string | null }>( - "api/aimlapi/authorize/poll", - { request_id: start.request_id }, - ); - if (poll.status === "ready" && poll.api_key) { - form.setValue("apiKey", poll.api_key, { - shouldValidate: true, - shouldDirty: true, - }); - setOauthStatus("success"); - setOauthMessage("Your key has already been generated and added above."); - return; - } - if (poll.status !== "pending" && poll.status !== "authorizing") { - throw new Error("Sign-in failed. Please try again."); - } - } - throw new Error("Sign-in timed out. Please try again."); - } catch (error) { - if (consentWindow && !consentWindow.closed) consentWindow.close(); - setOauthStatus("error"); - setOauthMessage( - error instanceof Error ? error.message : "Sign-in failed. Please try again.", - ); - } finally { - authorizingRef.current = false; - } - } + const { getApiKey, oauthStatus, oauthMessage } = useAimlapiGetApiKey((key) => + form.setValue("apiKey", key, { shouldValidate: true, shouldDirty: true }), + ); async function handleSubmit(values: ApiKeyConnectFormValues) { setIsPending(true); diff --git a/autogpt_platform/frontend/src/hooks/useAimlapiGetApiKey.ts b/autogpt_platform/frontend/src/hooks/useAimlapiGetApiKey.ts new file mode 100644 index 000000000000..1dfbcdecedea --- /dev/null +++ b/autogpt_platform/frontend/src/hooks/useAimlapiGetApiKey.ts @@ -0,0 +1,88 @@ +"use client"; + +import { useRef, useState } from "react"; + +export type AimlapiOAuthStatus = "idle" | "authorizing" | "success" | "error"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// The AIMLAPI "Get API key" flow lives behind the frontend's server proxy, +// which forwards `/api/proxy/` to the backend and injects auth. +async function postProxy(path: string, body: unknown): Promise { + const res = await fetch(`/api/proxy/${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body ?? {}), + }); + if (!res.ok) throw new Error(`Request failed (${res.status})`); + return (await res.json()) as T; +} + +// Device-authorization ("Get API key") grant for AIMLAPI: open a consent tab, +// poll the backend, and hand the issued key to `onKey`. Shared by the settings +// Connect-service form and the in-builder "Add new API key" modal. +export function useAimlapiGetApiKey(onKey: (apiKey: string) => void) { + const [oauthStatus, setOauthStatus] = useState("idle"); + const [oauthMessage, setOauthMessage] = useState(null); + const authorizingRef = useRef(false); + + async function getApiKey() { + if (authorizingRef.current) return; + authorizingRef.current = true; + setOauthStatus("authorizing"); + setOauthMessage(null); + + // Open the consent tab synchronously so pop-up blockers allow it, then + // point it at the verification URL once the backend returns one. Do NOT + // pass `noopener` here: with it, window.open() returns null and we lose the + // handle needed to redirect the tab — leaving a blank about:blank page. + const consentWindow = window.open("about:blank", "_blank"); + + try { + const start = await postProxy<{ + request_id: string; + verification_uri: string; + interval: number; + expires_in: number; + }>("api/aimlapi/authorize/start", {}); + + if (consentWindow) consentWindow.location.href = start.verification_uri; + else window.open(start.verification_uri, "_blank"); + + const intervalMs = Math.max(1, start.interval) * 1000; + const deadline = Date.now() + Math.max(1, start.expires_in) * 1000; + + while (Date.now() < deadline) { + await sleep(intervalMs); + const poll = await postProxy<{ status: string; api_key: string | null }>( + "api/aimlapi/authorize/poll", + { request_id: start.request_id }, + ); + if (poll.status === "ready" && poll.api_key) { + onKey(poll.api_key); + setOauthStatus("success"); + setOauthMessage( + "Your key has already been generated and added above.", + ); + return; + } + if (poll.status !== "pending" && poll.status !== "authorizing") { + throw new Error("Sign-in failed. Please try again."); + } + } + throw new Error("Sign-in timed out. Please try again."); + } catch (error) { + if (consentWindow && !consentWindow.closed) consentWindow.close(); + setOauthStatus("error"); + setOauthMessage( + error instanceof Error + ? error.message + : "Sign-in failed. Please try again.", + ); + } finally { + authorizingRef.current = false; + } + } + + return { getApiKey, oauthStatus, oauthMessage }; +} From 19cd6962e591e5555f1cd5cc60aae551f9d71c5f Mon Sep 17 00:00:00 2001 From: aimlapi Date: Fri, 7 Aug 2026 15:37:07 +0300 Subject: [PATCH 19/25] fix(blocks): scope Stagehand LLM credentials to its supported providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stagehand blocks only run OpenAI/Anthropic models (StagehandRecommendedLlmModel) but their model credential used the shared AICredentials, which advertises every LLM provider — surfacing Stagehand under each provider's integration (incl. aimlapi.com) despite it not being usable there. Narrow the field to Anthropic/OpenAI so Stagehand no longer appears under those integrations. --- .../backend/blocks/stagehand/blocks.py | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/autogpt_platform/backend/backend/blocks/stagehand/blocks.py b/autogpt_platform/backend/backend/blocks/stagehand/blocks.py index afd302c4f86b..89b02554c36f 100644 --- a/autogpt_platform/backend/backend/blocks/stagehand/blocks.py +++ b/autogpt_platform/backend/backend/blocks/stagehand/blocks.py @@ -1,17 +1,18 @@ import logging from enum import Enum +from typing import Literal from stagehand import AsyncStagehand from stagehand.types.session_act_params import Options as ActOptions from backend.blocks.llm import ( MODEL_METADATA, - AICredentials, - AICredentialsField, LlmModel, ModelMetadata, ) from backend.blocks.stagehand._config import stagehand as stagehand_provider +from backend.data.model import CredentialsField +from backend.integrations.providers import ProviderName from backend.sdk import ( APIKeyCredentials, Block, @@ -78,6 +79,26 @@ def max_output_tokens(self) -> int | None: return MODEL_METADATA[LlmModel(self.value)].max_output_tokens +# Stagehand only supports the OpenAI/Anthropic models above, so its LLM key +# field advertises just those providers (unlike the shared AICredentials, which +# lists every LLM provider and would surface Stagehand under each of them). +StagehandModelProviderName = Literal[ProviderName.ANTHROPIC, ProviderName.OPENAI] +StagehandModelCredentials = CredentialsMetaInput[ + StagehandModelProviderName, Literal["api_key"] +] + + +def StagehandModelCredentialsField() -> StagehandModelCredentials: + return CredentialsField( + description="API key for the LLM provider Stagehand uses.", + discriminator="model", + discriminator_mapping={ + model.value: model.metadata.provider + for model in StagehandRecommendedLlmModel + }, + ) + + class StagehandObserveBlock(Block): class Input(BlockSchemaInput): # Browserbase credentials (Stagehand provider) or raw API key @@ -96,7 +117,9 @@ class Input(BlockSchemaInput): default=StagehandRecommendedLlmModel.CLAUDE_4_6_SONNET, advanced=False, ) - model_credentials: AICredentials = AICredentialsField() + model_credentials: StagehandModelCredentials = ( + StagehandModelCredentialsField() + ) url: str = SchemaField( description="URL to navigate to.", ) @@ -179,7 +202,9 @@ class Input(BlockSchemaInput): default=StagehandRecommendedLlmModel.CLAUDE_4_6_SONNET, advanced=False, ) - model_credentials: AICredentials = AICredentialsField() + model_credentials: StagehandModelCredentials = ( + StagehandModelCredentialsField() + ) url: str = SchemaField( description="URL to navigate to.", ) @@ -275,7 +300,9 @@ class Input(BlockSchemaInput): default=StagehandRecommendedLlmModel.CLAUDE_4_6_SONNET, advanced=False, ) - model_credentials: AICredentials = AICredentialsField() + model_credentials: StagehandModelCredentials = ( + StagehandModelCredentialsField() + ) url: str = SchemaField( description="URL to navigate to.", ) From 56da22d3a9c357dc00f57c511bb64ffac2f52038 Mon Sep 17 00:00:00 2001 From: aimlapi Date: Fri, 7 Aug 2026 15:41:26 +0300 Subject: [PATCH 20/25] Revert "fix(blocks): scope Stagehand LLM credentials to its supported providers" This reverts commit 19cd6962e591e5555f1cd5cc60aae551f9d71c5f. --- .../backend/blocks/stagehand/blocks.py | 37 +++---------------- 1 file changed, 5 insertions(+), 32 deletions(-) diff --git a/autogpt_platform/backend/backend/blocks/stagehand/blocks.py b/autogpt_platform/backend/backend/blocks/stagehand/blocks.py index 89b02554c36f..afd302c4f86b 100644 --- a/autogpt_platform/backend/backend/blocks/stagehand/blocks.py +++ b/autogpt_platform/backend/backend/blocks/stagehand/blocks.py @@ -1,18 +1,17 @@ import logging from enum import Enum -from typing import Literal from stagehand import AsyncStagehand from stagehand.types.session_act_params import Options as ActOptions from backend.blocks.llm import ( MODEL_METADATA, + AICredentials, + AICredentialsField, LlmModel, ModelMetadata, ) from backend.blocks.stagehand._config import stagehand as stagehand_provider -from backend.data.model import CredentialsField -from backend.integrations.providers import ProviderName from backend.sdk import ( APIKeyCredentials, Block, @@ -79,26 +78,6 @@ def max_output_tokens(self) -> int | None: return MODEL_METADATA[LlmModel(self.value)].max_output_tokens -# Stagehand only supports the OpenAI/Anthropic models above, so its LLM key -# field advertises just those providers (unlike the shared AICredentials, which -# lists every LLM provider and would surface Stagehand under each of them). -StagehandModelProviderName = Literal[ProviderName.ANTHROPIC, ProviderName.OPENAI] -StagehandModelCredentials = CredentialsMetaInput[ - StagehandModelProviderName, Literal["api_key"] -] - - -def StagehandModelCredentialsField() -> StagehandModelCredentials: - return CredentialsField( - description="API key for the LLM provider Stagehand uses.", - discriminator="model", - discriminator_mapping={ - model.value: model.metadata.provider - for model in StagehandRecommendedLlmModel - }, - ) - - class StagehandObserveBlock(Block): class Input(BlockSchemaInput): # Browserbase credentials (Stagehand provider) or raw API key @@ -117,9 +96,7 @@ class Input(BlockSchemaInput): default=StagehandRecommendedLlmModel.CLAUDE_4_6_SONNET, advanced=False, ) - model_credentials: StagehandModelCredentials = ( - StagehandModelCredentialsField() - ) + model_credentials: AICredentials = AICredentialsField() url: str = SchemaField( description="URL to navigate to.", ) @@ -202,9 +179,7 @@ class Input(BlockSchemaInput): default=StagehandRecommendedLlmModel.CLAUDE_4_6_SONNET, advanced=False, ) - model_credentials: StagehandModelCredentials = ( - StagehandModelCredentialsField() - ) + model_credentials: AICredentials = AICredentialsField() url: str = SchemaField( description="URL to navigate to.", ) @@ -300,9 +275,7 @@ class Input(BlockSchemaInput): default=StagehandRecommendedLlmModel.CLAUDE_4_6_SONNET, advanced=False, ) - model_credentials: StagehandModelCredentials = ( - StagehandModelCredentialsField() - ) + model_credentials: AICredentials = AICredentialsField() url: str = SchemaField( description="URL to navigate to.", ) From 27a1e3edf5c23189da25531a15c1119fb6db8442 Mon Sep 17 00:00:00 2001 From: aimlapi Date: Fri, 7 Aug 2026 15:47:41 +0300 Subject: [PATCH 21/25] feat(frontend): sort Stagehand blocks last in the aimlapi.com integration list --- .../IntegrationBlocks/IntegrationBlocks.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/IntegrationBlocks/IntegrationBlocks.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/IntegrationBlocks/IntegrationBlocks.tsx index a940c48771c9..50b869af28dc 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/IntegrationBlocks/IntegrationBlocks.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/IntegrationBlocks/IntegrationBlocks.tsx @@ -1,5 +1,5 @@ import { Button } from "@/components/__legacy__/ui/button"; -import React, { Fragment } from "react"; +import React, { Fragment, useMemo } from "react"; import { IntegrationBlock } from "../IntergrationBlock"; import { Skeleton } from "@/components/__legacy__/ui/skeleton"; import { useIntegrationBlocks } from "./useIntegrationBlocks"; @@ -23,6 +23,18 @@ export const IntegrationBlocks = () => { refetch, } = useIntegrationBlocks(); + // Stagehand blocks reach the aimlapi.com list only through the shared LLM + // credential (their models are OpenAI/Anthropic-only), so keep them after the + // model blocks that actually run on the aimlapi.com key. + const orderedBlocks = useMemo(() => { + if (integration !== "aiml_api") return allBlocks; + const stagehandRank = (name?: string) => + name?.toLowerCase().startsWith("stagehand") ? 1 : 0; + return [...allBlocks].sort( + (a, b) => stagehandRank(a.name) - stagehandRank(b.name), + ); + }, [allBlocks, integration]); + if (blocksLoading) { return (
@@ -90,7 +102,7 @@ export const IntegrationBlocks = () => {
- {allBlocks.map((block) => ( + {orderedBlocks.map((block) => ( Date: Mon, 10 Aug 2026 15:00:11 +0300 Subject: [PATCH 22/25] feat: validate AIMLAPI key before saving; show error if invalid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add POST /api/aimlapi/validate-key: sends an empty /chat/completions request (auth is checked before the body, so a bad key returns 401 while a valid key returns 400 — no completion generated, zero cost). Both credential forms (the settings Connect form and the in-builder API-key modal) verify a manually entered aimlapi.com key on submit and show an error instead of saving an invalid key. Fail open on network/backend errors so a real key is never blocked. --- .../backend/api/features/aimlapi/config.py | 8 +++++++ .../backend/api/features/aimlapi/router.py | 22 +++++++++++++++++++ .../backend/api/features/aimlapi/service.py | 21 ++++++++++++++++++ .../useAPIKeyCredentialsModal.ts | 17 ++++++++++++++ .../DetailView/useApiKeyConnectForm.ts | 17 +++++++++++++- .../frontend/src/hooks/useAimlapiGetApiKey.ts | 18 +++++++++++++++ 6 files changed, 102 insertions(+), 1 deletion(-) diff --git a/autogpt_platform/backend/backend/api/features/aimlapi/config.py b/autogpt_platform/backend/backend/api/features/aimlapi/config.py index ac779ee9143a..c32d045dc0c7 100644 --- a/autogpt_platform/backend/backend/api/features/aimlapi/config.py +++ b/autogpt_platform/backend/backend/api/features/aimlapi/config.py @@ -50,6 +50,14 @@ def resolve_endpoints() -> AimlapiEndpoints: ) +def resolve_inference_base_url() -> str: + # OpenAI-compatible inference base (same override the LLM transport uses), + # used to verify an API key against ``/chat/completions``. + return _env_or_default( + "AIMLAPI_INFERENCE_URL", "https://api.aimlapi.com/v1" + ).rstrip("/") + + def resolve_partner_id() -> str: return _env_or_default("AIMLAPI_PARTNER_ID", DEFAULT_AIMLAPI_PARTNER_ID) diff --git a/autogpt_platform/backend/backend/api/features/aimlapi/router.py b/autogpt_platform/backend/backend/api/features/aimlapi/router.py index 30ad76b5a1f4..9717caf4406e 100644 --- a/autogpt_platform/backend/backend/api/features/aimlapi/router.py +++ b/autogpt_platform/backend/backend/api/features/aimlapi/router.py @@ -19,6 +19,7 @@ AuthorizationRequest, poll_authorization, start_authorization, + validate_api_key, ) router = APIRouter() @@ -51,6 +52,27 @@ class AuthorizePollResponse(BaseModel): api_key: str | None = None +class ValidateKeyRequest(BaseModel): + api_key: str + + +class ValidateKeyResponse(BaseModel): + valid: bool + + +@router.post("/validate-key") +async def validate_key( + body: ValidateKeyRequest, + user_id: Annotated[str, Security(get_user_id)], +) -> ValidateKeyResponse: + """Report whether a manually-entered API key authenticates against AIMLAPI.""" + try: + valid = await validate_api_key(body.api_key) + except AimlapiAuthError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return ValidateKeyResponse(valid=valid) + + @router.post("/authorize/start") async def authorize_start( user_id: Annotated[str, Security(get_user_id)], diff --git a/autogpt_platform/backend/backend/api/features/aimlapi/service.py b/autogpt_platform/backend/backend/api/features/aimlapi/service.py index 6ebb7a2b7728..ea257621ea15 100644 --- a/autogpt_platform/backend/backend/api/features/aimlapi/service.py +++ b/autogpt_platform/backend/backend/api/features/aimlapi/service.py @@ -18,6 +18,7 @@ AGENT_NAME, attribution_headers, resolve_endpoints, + resolve_inference_base_url, resolve_partner_id, resolve_partner_name, resolve_requested_usd_limit_minor, @@ -180,3 +181,23 @@ async def poll_authorization( if status in TERMINAL_FAILURE_STATUSES: return AuthorizationPollResult(status=status) return AuthorizationPollResult(status=status or "pending") + + +async def validate_api_key(api_key: str) -> bool: + """Return whether ``api_key`` authenticates against AIMLAPI. + + Sends an intentionally empty ``/chat/completions`` request: AIMLAPI checks + auth before validating the body, so a bad key returns 401 while a valid key + returns 400 (missing fields). No completion is generated, so the check is + free. Raises ``AimlapiAuthError`` if AIMLAPI can't be reached. + """ + base = resolve_inference_base_url() + headers = {"Authorization": f"Bearer {api_key}", **attribution_headers()} + try: + async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS) as client: + response = await client.post( + f"{base}/chat/completions", headers=headers, json={} + ) + except httpx.HTTPError as exc: + raise AimlapiAuthError("Unable to reach AIMLAPI to verify the key") from exc + return response.status_code != 401 diff --git a/autogpt_platform/frontend/src/components/contextual/CredentialsInput/components/APIKeyCredentialsModal/useAPIKeyCredentialsModal.ts b/autogpt_platform/frontend/src/components/contextual/CredentialsInput/components/APIKeyCredentialsModal/useAPIKeyCredentialsModal.ts index 1f3d4c9085b7..db069718ceb7 100644 --- a/autogpt_platform/frontend/src/components/contextual/CredentialsInput/components/APIKeyCredentialsModal/useAPIKeyCredentialsModal.ts +++ b/autogpt_platform/frontend/src/components/contextual/CredentialsInput/components/APIKeyCredentialsModal/useAPIKeyCredentialsModal.ts @@ -1,4 +1,6 @@ import useCredentials from "@/hooks/useCredentials"; +import { validateAimlapiApiKey } from "@/hooks/useAimlapiGetApiKey"; +import { toast } from "@/components/molecules/Toast/use-toast"; import { BlockIOCredentialsSubSchema, CredentialsMetaInput, @@ -56,6 +58,21 @@ export function useAPIKeyCredentialsModal({ if (!credentials || credentials.isLoading) return; setIsSubmitting(true); try { + if ( + credentials.provider === "aiml_api" && + !(await validateAimlapiApiKey(values.apiKey)) + ) { + form.setError("apiKey", { + message: "This aimlapi.com API key is invalid.", + }); + toast({ + title: "Invalid API key", + description: "This aimlapi.com API key is invalid.", + variant: "destructive", + }); + return; + } + const expiresAt = values.expiresAt ? new Date(values.expiresAt).getTime() / 1000 : undefined; diff --git a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/useApiKeyConnectForm.ts b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/useApiKeyConnectForm.ts index f8830b6e0ea2..e87a5e7a52ae 100644 --- a/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/useApiKeyConnectForm.ts +++ b/autogpt_platform/frontend/src/components/contextual/IntegrationsPanel/components/ConnectServiceDialog/components/DetailView/useApiKeyConnectForm.ts @@ -10,7 +10,10 @@ import { postV1CreateCredentials, } from "@/app/api/__generated__/endpoints/integrations/integrations"; import { toast } from "@/components/molecules/Toast/use-toast"; -import { useAimlapiGetApiKey } from "@/hooks/useAimlapiGetApiKey"; +import { + useAimlapiGetApiKey, + validateAimlapiApiKey, +} from "@/hooks/useAimlapiGetApiKey"; import { apiKeyConnectSchema, type ApiKeyConnectFormValues } from "./schema"; @@ -44,6 +47,18 @@ export function useApiKeyConnectForm({ provider, defaultTitle, onSuccess }: Args async function handleSubmit(values: ApiKeyConnectFormValues) { setIsPending(true); try { + if (provider === "aiml_api" && !(await validateAimlapiApiKey(values.apiKey))) { + form.setError("apiKey", { + message: "This aimlapi.com API key is invalid.", + }); + toast({ + title: "Invalid API key", + description: "This aimlapi.com API key is invalid.", + variant: "destructive", + }); + return; + } + // customMutator throws on non-2xx, so reaching this line means success. await postV1CreateCredentials(provider, { provider, diff --git a/autogpt_platform/frontend/src/hooks/useAimlapiGetApiKey.ts b/autogpt_platform/frontend/src/hooks/useAimlapiGetApiKey.ts index 1dfbcdecedea..772d931ad9c6 100644 --- a/autogpt_platform/frontend/src/hooks/useAimlapiGetApiKey.ts +++ b/autogpt_platform/frontend/src/hooks/useAimlapiGetApiKey.ts @@ -6,6 +6,24 @@ export type AimlapiOAuthStatus = "idle" | "authorizing" | "success" | "error"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +// Verify a manually-entered AIMLAPI key via the backend (which calls AIMLAPI). +// Returns false only when the key is definitively invalid; on a network/backend +// error we can't tell, so return true (fail open) rather than block a save. +export async function validateAimlapiApiKey(apiKey: string): Promise { + try { + const res = await fetch("/api/proxy/api/aimlapi/validate-key", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ api_key: apiKey }), + }); + if (!res.ok) return true; + const data = (await res.json()) as { valid?: boolean }; + return data.valid !== false; + } catch { + return true; + } +} + // The AIMLAPI "Get API key" flow lives behind the frontend's server proxy, // which forwards `/api/proxy/` to the backend and injects auth. async function postProxy(path: string, body: unknown): Promise { From d499738efba03892b8fc71a7441a666fd6375f6c Mon Sep 17 00:00:00 2001 From: aimlapi Date: Mon, 10 Aug 2026 15:21:48 +0300 Subject: [PATCH 23/25] refactor: validate AIMLAPI key via GET /billing/balance from the browser Drop the backend validate-key endpoint. The balance endpoint is CORS-enabled, so the browser verifies an entered key directly (401 = invalid). Base URL comes from NEXT_PUBLIC_AIMLAPI_API_URL (prod default) so a staging deploy checks against staging. --- .../backend/api/features/aimlapi/config.py | 8 ------- .../backend/api/features/aimlapi/router.py | 22 ------------------- .../backend/api/features/aimlapi/service.py | 21 ------------------ autogpt_platform/frontend/Dockerfile | 4 ++++ .../frontend/src/hooks/useAimlapiGetApiKey.ts | 22 ++++++++++--------- 5 files changed, 16 insertions(+), 61 deletions(-) diff --git a/autogpt_platform/backend/backend/api/features/aimlapi/config.py b/autogpt_platform/backend/backend/api/features/aimlapi/config.py index c32d045dc0c7..ac779ee9143a 100644 --- a/autogpt_platform/backend/backend/api/features/aimlapi/config.py +++ b/autogpt_platform/backend/backend/api/features/aimlapi/config.py @@ -50,14 +50,6 @@ def resolve_endpoints() -> AimlapiEndpoints: ) -def resolve_inference_base_url() -> str: - # OpenAI-compatible inference base (same override the LLM transport uses), - # used to verify an API key against ``/chat/completions``. - return _env_or_default( - "AIMLAPI_INFERENCE_URL", "https://api.aimlapi.com/v1" - ).rstrip("/") - - def resolve_partner_id() -> str: return _env_or_default("AIMLAPI_PARTNER_ID", DEFAULT_AIMLAPI_PARTNER_ID) diff --git a/autogpt_platform/backend/backend/api/features/aimlapi/router.py b/autogpt_platform/backend/backend/api/features/aimlapi/router.py index 9717caf4406e..30ad76b5a1f4 100644 --- a/autogpt_platform/backend/backend/api/features/aimlapi/router.py +++ b/autogpt_platform/backend/backend/api/features/aimlapi/router.py @@ -19,7 +19,6 @@ AuthorizationRequest, poll_authorization, start_authorization, - validate_api_key, ) router = APIRouter() @@ -52,27 +51,6 @@ class AuthorizePollResponse(BaseModel): api_key: str | None = None -class ValidateKeyRequest(BaseModel): - api_key: str - - -class ValidateKeyResponse(BaseModel): - valid: bool - - -@router.post("/validate-key") -async def validate_key( - body: ValidateKeyRequest, - user_id: Annotated[str, Security(get_user_id)], -) -> ValidateKeyResponse: - """Report whether a manually-entered API key authenticates against AIMLAPI.""" - try: - valid = await validate_api_key(body.api_key) - except AimlapiAuthError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - return ValidateKeyResponse(valid=valid) - - @router.post("/authorize/start") async def authorize_start( user_id: Annotated[str, Security(get_user_id)], diff --git a/autogpt_platform/backend/backend/api/features/aimlapi/service.py b/autogpt_platform/backend/backend/api/features/aimlapi/service.py index ea257621ea15..6ebb7a2b7728 100644 --- a/autogpt_platform/backend/backend/api/features/aimlapi/service.py +++ b/autogpt_platform/backend/backend/api/features/aimlapi/service.py @@ -18,7 +18,6 @@ AGENT_NAME, attribution_headers, resolve_endpoints, - resolve_inference_base_url, resolve_partner_id, resolve_partner_name, resolve_requested_usd_limit_minor, @@ -181,23 +180,3 @@ async def poll_authorization( if status in TERMINAL_FAILURE_STATUSES: return AuthorizationPollResult(status=status) return AuthorizationPollResult(status=status or "pending") - - -async def validate_api_key(api_key: str) -> bool: - """Return whether ``api_key`` authenticates against AIMLAPI. - - Sends an intentionally empty ``/chat/completions`` request: AIMLAPI checks - auth before validating the body, so a bad key returns 401 while a valid key - returns 400 (missing fields). No completion is generated, so the check is - free. Raises ``AimlapiAuthError`` if AIMLAPI can't be reached. - """ - base = resolve_inference_base_url() - headers = {"Authorization": f"Bearer {api_key}", **attribution_headers()} - try: - async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS) as client: - response = await client.post( - f"{base}/chat/completions", headers=headers, json={} - ) - except httpx.HTTPError as exc: - raise AimlapiAuthError("Unable to reach AIMLAPI to verify the key") from exc - return response.status_code != 401 diff --git a/autogpt_platform/frontend/Dockerfile b/autogpt_platform/frontend/Dockerfile index 468767d2ab1e..0a0ae4e01877 100644 --- a/autogpt_platform/frontend/Dockerfile +++ b/autogpt_platform/frontend/Dockerfile @@ -21,6 +21,10 @@ ENV NEXT_PUBLIC_SOURCEMAPS=$NEXT_PUBLIC_SOURCEMAPS # so a hosted deployment must build with its real origin, not localhost. ARG NEXT_PUBLIC_FRONTEND_BASE_URL="http://localhost:3000" ENV NEXT_PUBLIC_FRONTEND_BASE_URL=$NEXT_PUBLIC_FRONTEND_BASE_URL +# AIMLAPI inference base the browser hits directly to verify an entered API key +# (GET /billing/balance). Defaults to production; a staging deploy overrides it. +ARG NEXT_PUBLIC_AIMLAPI_API_URL="https://api.aimlapi.com/v1" +ENV NEXT_PUBLIC_AIMLAPI_API_URL=$NEXT_PUBLIC_AIMLAPI_API_URL ENV NODE_ENV="production" # Merge env files appropriately based on environment RUN if [ -f .env.production ]; then \ diff --git a/autogpt_platform/frontend/src/hooks/useAimlapiGetApiKey.ts b/autogpt_platform/frontend/src/hooks/useAimlapiGetApiKey.ts index 772d931ad9c6..d1f2e1a3004d 100644 --- a/autogpt_platform/frontend/src/hooks/useAimlapiGetApiKey.ts +++ b/autogpt_platform/frontend/src/hooks/useAimlapiGetApiKey.ts @@ -6,19 +6,21 @@ export type AimlapiOAuthStatus = "idle" | "authorizing" | "success" | "error"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); -// Verify a manually-entered AIMLAPI key via the backend (which calls AIMLAPI). -// Returns false only when the key is definitively invalid; on a network/backend -// error we can't tell, so return true (fail open) rather than block a save. +const AIMLAPI_BASE_URL = + process.env.NEXT_PUBLIC_AIMLAPI_API_URL?.replace(/\/$/, "") || + "https://api.aimlapi.com/v1"; + +// Verify a manually-entered AIMLAPI key by calling the balance endpoint with it +// (CORS-enabled, so the browser can hit it directly — no backend needed). A bad +// key returns 401; anything else means the key authenticates. Returns false +// only when the key is definitively invalid; on a network error we can't tell, +// so return true (fail open) rather than block a legitimate save. export async function validateAimlapiApiKey(apiKey: string): Promise { try { - const res = await fetch("/api/proxy/api/aimlapi/validate-key", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ api_key: apiKey }), + const res = await fetch(`${AIMLAPI_BASE_URL}/billing/balance`, { + headers: { Authorization: `Bearer ${apiKey}` }, }); - if (!res.ok) return true; - const data = (await res.json()) as { valid?: boolean }; - return data.valid !== false; + return res.status !== 401; } catch { return true; } From e8babed9bbe4bcfb8ad3a3cd36e7e856656dd4cb Mon Sep 17 00:00:00 2001 From: aimlapi Date: Fri, 21 Aug 2026 20:46:50 +0500 Subject: [PATCH 24/25] fix(aimlapi): send the attribution pair on the catalog fetch too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HEADERS.md requires X-AIMLAPI-Source + X-AIMLAPI-Partner-ID on EVERY aimlapi.com request — "inference/API calls, catalog, checkout, and auth alike, not just login/sign-up". The catalog loader called GET /v1/models bare, so the one request every boot makes was the one request that went unattributed. Reuses `attribution_headers()` from the feature config rather than restating the pair, so the id cannot drift between the two call sites. The import is function-local: that module is stdlib-only, and keeping it out of the module header avoids `data/` taking a load-time dependency on `api/`. Verified live: the fetch now carries both headers and still returns the full 302-model catalog. Co-Authored-By: Claude Opus 5 --- .../backend/backend/data/llm_registry/aiml_catalog.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/autogpt_platform/backend/backend/data/llm_registry/aiml_catalog.py b/autogpt_platform/backend/backend/data/llm_registry/aiml_catalog.py index d48aa13cc83f..85dbb6eab382 100644 --- a/autogpt_platform/backend/backend/data/llm_registry/aiml_catalog.py +++ b/autogpt_platform/backend/backend/data/llm_registry/aiml_catalog.py @@ -150,9 +150,16 @@ def _load_snapshot() -> list[AimlModel]: def _fetch_live() -> list[AimlModel]: + # HEADERS.md: the attribution pair goes on EVERY aimlapi.com request, the + # catalog included — not just inference and sign-up. Imported lazily and + # from the feature config so there is exactly one definition of the pair; + # that module is stdlib-only, so this costs nothing at import time. + from backend.api.features.aimlapi.config import attribution_headers + response = requests.get( _MODELS_URL, params={"include": "capabilities,modalities,pricing"}, + headers=attribution_headers(), timeout=_FETCH_TIMEOUT_SECONDS, ) response.raise_for_status() From 4653b5cc229d3ff21654bbfec4676cf86bd088f3 Mon Sep 17 00:00:00 2001 From: aimlapi Date: Fri, 21 Aug 2026 21:10:34 +0500 Subject: [PATCH 25/25] fix(aimlapi): attribute the browser-side key check too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last aimlapi.com call that went out unattributed. HEADERS.md wants the pair on every request; this one is issued from the browser, so it cannot inherit the backend client's default headers and has to carry them itself. Custom headers turn this from a simple request into a preflighted one, so this is only safe because the API allows them: OPTIONS on /v1/billing/balance returns access-control-allow-headers "authorization,x-aimlapi-source,x-aimlapi-partner-id" and reflects the caller's origin. Verified against production, and the GET still answers 401 for a bad key — the status this function keys off is unchanged. The partner id also needs a Dockerfile ARG: Next inlines NEXT_PUBLIC_* at build time, so without it the documented env override could never reach the bundle. Co-Authored-By: Claude Opus 5 --- autogpt_platform/frontend/Dockerfile | 7 ++++++ .../frontend/src/hooks/useAimlapiGetApiKey.ts | 22 ++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/autogpt_platform/frontend/Dockerfile b/autogpt_platform/frontend/Dockerfile index ecb07f305cba..41dd73f64bb8 100644 --- a/autogpt_platform/frontend/Dockerfile +++ b/autogpt_platform/frontend/Dockerfile @@ -26,6 +26,13 @@ ENV NEXT_PUBLIC_FRONTEND_BASE_URL=$NEXT_PUBLIC_FRONTEND_BASE_URL # (GET /billing/balance). Defaults to production; a staging deploy overrides it. ARG NEXT_PUBLIC_AIMLAPI_API_URL="https://api.aimlapi.com/v1" ENV NEXT_PUBLIC_AIMLAPI_API_URL=$NEXT_PUBLIC_AIMLAPI_API_URL +# Partner id sent as X-AIMLAPI-Partner-ID on that same browser-side call. The +# production id is valid on staging too, so this default fits both; it is only +# overridden for a staging-only test id. Declared here because Next inlines +# NEXT_PUBLIC_* at build — without the ARG the override could not reach the +# bundle at all. +ARG NEXT_PUBLIC_AIMLAPI_PARTNER_ID="part_T70zDIEvQLKSMzMQ7asjdtKR" +ENV NEXT_PUBLIC_AIMLAPI_PARTNER_ID=$NEXT_PUBLIC_AIMLAPI_PARTNER_ID ENV NODE_ENV="production" # Merge env files appropriately based on environment RUN if [ -f .env.production ]; then \ diff --git a/autogpt_platform/frontend/src/hooks/useAimlapiGetApiKey.ts b/autogpt_platform/frontend/src/hooks/useAimlapiGetApiKey.ts index d1f2e1a3004d..8552bc7b4252 100644 --- a/autogpt_platform/frontend/src/hooks/useAimlapiGetApiKey.ts +++ b/autogpt_platform/frontend/src/hooks/useAimlapiGetApiKey.ts @@ -10,6 +10,23 @@ const AIMLAPI_BASE_URL = process.env.NEXT_PUBLIC_AIMLAPI_API_URL?.replace(/\/$/, "") || "https://api.aimlapi.com/v1"; +// Attribution pair, required on EVERY aimlapi.com request — not just sign-up. +// The balance check below runs in the browser, so it cannot inherit the +// backend client's default headers and has to carry them itself. The partner +// id is valid on both staging and production, so it ships compiled in; the env +// override exists only for a staging-only test id. Mirrors +// backend/api/features/aimlapi/config.py. +const AIMLAPI_SOURCE = "agent/autogpt"; +const AIMLAPI_PARTNER_ID = + process.env.NEXT_PUBLIC_AIMLAPI_PARTNER_ID || "part_T70zDIEvQLKSMzMQ7asjdtKR"; + +function attributionHeaders(): Record { + return { + "X-AIMLAPI-Source": AIMLAPI_SOURCE, + "X-AIMLAPI-Partner-ID": AIMLAPI_PARTNER_ID, + }; +} + // Verify a manually-entered AIMLAPI key by calling the balance endpoint with it // (CORS-enabled, so the browser can hit it directly — no backend needed). A bad // key returns 401; anything else means the key authenticates. Returns false @@ -18,7 +35,10 @@ const AIMLAPI_BASE_URL = export async function validateAimlapiApiKey(apiKey: string): Promise { try { const res = await fetch(`${AIMLAPI_BASE_URL}/billing/balance`, { - headers: { Authorization: `Bearer ${apiKey}` }, + headers: { + Authorization: `Bearer ${apiKey}`, + ...attributionHeaders(), + }, }); return res.status !== 401; } catch {