Compare commits

...

31 Commits

Author SHA1 Message Date
Bentlybro
ffe9105157 fix(backend): run copilot executor in foreground 2026-04-30 06:12:58 +01:00
Zamil Majdy
4a1741cc15 fix(platform): cancel-banner copy + clearer 422 on currency mismatch (#12947)
## Why

Two regressions surfaced after
[#12933](https://github.com/Significant-Gravitas/AutoGPT/pull/12933)
merged to `dev`:

1. **Cancel-pending banner shows wrong copy.** The merged PR moved
cancel-at-period-end from `BASIC` → `NO_TIER`, but
`PendingChangeBanner.isCancellation` was still keyed on `"BASIC"`. As a
result, a user who cancels their sub now sees *"Scheduled to downgrade
to No subscription on …"* instead of the intended *"Scheduled to cancel
your subscription on …"*. Caught by Sentry on the merged PR.

2. **Currency-mismatch downgrade returns 502 (looks like outage).** A
user with an existing GBP-active sub (Max Price has
`currency_options.gbp`) tried to downgrade to Pro and got 502. The
backend logs show:
   ```
stripe._error.InvalidRequestError: The price specified only supports
`usd`.
   This doesn't match the expected currency: `gbp`.
   ```
The Pro Price is USD-only; Stripe rejects `SubscriptionSchedule.modify`
because phases must share currency. Wrapping that in a generic 502 hid
the real cause and made it read like a Stripe outage.

## What

* Frontend: flip `PendingChangeBanner.isCancellation` from `pendingTier
=== "BASIC"` to `"NO_TIER"`. Update both component and page-level tests
that exercised the cancellation branch.
* Backend: catch `stripe.InvalidRequestError` whose message mentions
`currency` in `update_subscription_tier`, and return **422** with *"Tier
change unavailable for your current billing currency. Cancel your
subscription and re-subscribe at the target tier, or contact support."*
— so users see the actual reason, not a misleading outage message. Other
`StripeError` paths still return 502.
* New backend test asserts the currency-mismatch branch returns 422 with
the new copy.

## How

* `PendingChangeBanner.tsx` line 28: 1-char change (`"BASIC"` →
`"NO_TIER"`).
* `subscription_routes_test.py` and `PendingChangeBanner.test.tsx`
updated to use `NO_TIER` for the cancellation fixture.
* `v1.py` `update_subscription_tier` adds a typed `except
stripe.InvalidRequestError` branch ahead of the generic `StripeError`;
only currency-mismatch messages get the special 422, everything else
falls through to the existing 502.

## The real fix lives in Stripe config

The defensive 422 here is just a clearer error surface. To actually
unblock GBP/EUR users from changing tiers, the per-tier Stripe Prices
(Pro, and Basic if priced) need `currency_options` for GBP added — Max
already has this, which is why Max checkout shows the £/$ toggle. Stripe
locks `currency_options` after a Price has been transacted, so the
procedure is: create a new Price with USD + GBP from the start → update
the `stripe-price-ids` LD flag to the new Price ID. No further code
change required; same Price ID stays per tier, multiple currencies
inside it.

## Checklist

- [x] Component test for new banner copy
- [x] Backend test for 422 currency-mismatch branch
- [x] Format / lint / types pass
- [x] No protected route added — N/A
2026-04-30 10:25:02 +07:00
Krzysztof Czerwinski
c08b9774dc fix(backend/push): skip OS push for onboarding payloads (#12944)
## Why

[#12723](https://github.com/Significant-Gravitas/AutoGPT/pull/12723)
wired Web Push fanout into `AsyncRedisNotificationEventBus.publish()` so
copilot completion events reach users with the tab closed. But the bus
is also used by `data/onboarding.py` for in-page step toasts, and those
started firing OS-level system notifications (`increment_runs`,
`step_completed`, etc.) — unwanted noise.

## What

Smallest possible patch: skip the OS push fanout when `payload.type ==
"onboarding"`. WebSocket delivery is unchanged.

## How

```python
async def publish(self, event: NotificationEvent) -> None:
    await self.publish_event(event, event.user_id)
    # Skip OS push for onboarding step toasts — those are in-page only.
    # TODO: remove once the onboarding/wallet rework lands.
    if event.payload.model_dump().get("type") == "onboarding":
        return
    ...
```

Five-line addition in `backend/data/notification_bus.py`. Marked `TODO`
to remove once the upcoming onboarding/wallet rework decides per-event
whether a system notification is desired.

Tests: added `test_publish_skips_web_push_for_onboarding`; existing
fanout tests continue to validate the happy path with non-onboarding
payloads.

## Test plan

- [x] `poetry run format` (ruff + isort + black + pyright)
- [ ] CI: `poetry run pytest backend/data/notification_bus_test.py`
- [ ] Manual on dev: trigger onboarding step → confirm no OS
notification; finish copilot session → confirm OS notification still
fires.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:20:53 +00:00
Zamil Majdy
fe3d6fb118 feat(platform): subscription credit grants + paywall gate + dialog UX + cross-pod cache (#12933)
## Why

Started as a regression fix for admin-granted user downgrades hitting
Stripe Checkout, broadened to close the surrounding gaps in the Stripe
billing flow that surfaced during testing. Three concrete user-facing
problems the PR resolves:

1. **Admin-granted users couldn't change tier in-app** when their
current tier had no `stripe-price-id-*` LD configured — clicking
Downgrade silently routed to a paid-signup Stripe Checkout instead of
just changing the tier.
2. **Subscription payments granted nothing visible to users** — paying
£20–£320/mo gave higher rate-limit multipliers but no AutoPilot credits
in the user's balance, despite a dialog promising "credit to your next
Stripe invoice" (which users naturally read as AutoGPT credits).
3. **Tier oscillated across page refreshes** — `get_user_by_id` was
process-local cached, so dev's 4 server pods each held their own copy.
Tier could read MAX on one pod and BASIC on another for ~5 min after a
webhook update, depending on which pod the request landed on.

Plus three structural improvements caught during review:

4. **No paywall enforcement for paid-cohort users without subscription**
— non-beta users on `BASIC` (no Stripe sub) could freely use AutoPilot.
5. **Upgrade/downgrade dialog copy was misleading** — implied a Stripe
redirect that doesn't happen for existing-sub modifications, used
"credit" ambiguously, and didn't surface the next-invoice date.
6. **Top-up Checkout created an ephemeral Stripe Product per session** —
no canonical Product for dashboard reporting, no way to scope coupons to
top-ups.

## What

### 1. Admin-granted downgrades skip Checkout (price-id-pruning
regression)

`update_subscription_tier()` used to gate its modify-or-DB-flip block on
`current_tier_price_id is not None`. When a tier was pruned from
`stripe-price-ids` LD, that gate skipped the inner DB-flip branch and
the request fell through to Checkout — sending admin-granted users to a
paid-signup flow when they were trying to *reduce* their tier. Drop the
gate and call `modify_stripe_subscription_for_tier()` unconditionally —
the function self-reports `False` when there's no Stripe sub. One
uniform path for everyone now.

### 2. Subscription credit grant on every paid Stripe invoice

New `invoice.payment_succeeded` webhook handler at
[`credit.py:handle_subscription_payment_success`](autogpt_platform/backend/backend/data/credit.py)
adds a `GRANT` transaction equal to `invoice.amount_paid`, keyed by
`INVOICE-{id}` for idempotency (Stripe webhook retries cannot
double-grant). Initial signup, monthly renewal, and prorated upgrade
charges all surface as AutoGPT balance bumps the moment Stripe confirms
the charge. Skipped: non-subscription invoices, $0 invoices, ENTERPRISE
users.

### 3. Cross-pod user cache

[`user.py:31`](autogpt_platform/backend/backend/data/user.py#L31)
`cache_user_lookup = cached(maxsize=1000, ttl_seconds=300,
shared_cache=True)`. Single line — moves the cache to Redis so all
server pods read/write the same key. The existing
`get_user_by_id.cache_delete(user_id)` invalidations now propagate
cross-pod.

### 4. PaywallGate

New
[`PaywallGate`](autogpt_platform/frontend/src/app/(platform)/PaywallGate/PaywallGate.tsx)
wraps the `(platform)/layout.tsx` route group. When
`ENABLE_PLATFORM_PAYMENT === true` (paid cohort) AND `subscription.tier
=== "BASIC"`, redirects to `/profile/credits` where the credits page
shows a "Pick a plan to continue using AutoGPT" banner above the tier
picker.

Notes:
- **Beta cohort skips entirely** (flag off → `useGetSubscriptionStatus`
query disabled, no redirect).
- **Gates on DB tier, not `has_active_stripe_subscription`** — Sentry
caught that a transient Stripe API error in
`get_active_subscription_period_end()` would set `has_active=false` for
paying users, locking them out. The DB tier is set by webhooks and
persists locally; Stripe API hiccups don't flip it.
- **Exempt routes**: `/profile`, `/admin`, `/auth`, `/login`, `/signup`,
`/reset-password`, `/error`, `/unauthorized`, `/health`. Onboarding
lives in the sibling `(no-navbar)` group, so this gate doesn't conflict
with the in-flight onboarding-paywall integration.

### 5. Upgrade/downgrade dialog clarity

`SubscriptionStatusResponse` now exposes
`has_active_stripe_subscription: bool` and `current_period_end: int |
None`, computed via a new
[`get_active_subscription_period_end`](autogpt_platform/backend/backend/data/credit.py)
helper. Frontend dialogs branch on those:

**Upgrade — modify-in-place** (existing sub):
> Your subscription is upgraded to MAX immediately. On your next invoice
on May 21, 2026, your saved card is charged for the upgrade proration
since today plus the next month at the new rate, with the unused portion
of your current plan automatically deducted. Credits matching the paid
amount are added to your AutoGPT balance once Stripe confirms the
charge.

**Upgrade — Checkout** (no sub):
> You'll be redirected to Stripe to enter payment details and start your
MAX subscription. The first invoice's amount is added to your AutoGPT
balance once Stripe confirms the charge.

**Downgrade (paid → paid)**:
> Switching to PRO takes effect at the end of your current billing
period on May 21, 2026 — no charge today. You keep your current plan
until then. From that date your saved card is billed at the PRO rate,
and matching credits are added to your AutoGPT balance with each paid
invoice.

Toast wording on success matches dialog. Tier labels run through
`getTierLabel()` so we render "Pro/Max/Business" not "PRO/MAX/BUSINESS"
(Sentry-flagged in review).

### 6. Top-up Stripe Product ID via LD flag

New `STRIPE_PRODUCT_ID_TOPUP` LD flag. **Unset (default)** → legacy
inline `product_data` (Stripe creates an ephemeral product per Checkout
— backward-compatible with current behavior). **Set to a Stripe Product
ID** → line item references that Product so all top-ups group under one
entity in Stripe Dashboard reporting; per-session amount stays dynamic
via `price_data.unit_amount`. The two paths are mutually exclusive
(Stripe rejects `product` + `product_data` together).

## How

- Backend changes confined to
[`v1.py`](autogpt_platform/backend/backend/api/features/v1.py),
[`credit.py`](autogpt_platform/backend/backend/data/credit.py),
[`user.py`](autogpt_platform/backend/backend/data/user.py),
[`feature_flag.py`](autogpt_platform/backend/backend/util/feature_flag.py).
- Frontend changes: new
[`PaywallGate`](autogpt_platform/frontend/src/app/(platform)/PaywallGate/PaywallGate.tsx)
component + small edits to
[`(platform)/layout.tsx`](autogpt_platform/frontend/src/app/(platform)/layout.tsx),
`SubscriptionTierSection.tsx`, `useSubscriptionTierSection.ts`,
`helpers.ts`.
- Both backend and frontend pass `user.id` to LD context (verified in
[`feature_flag.py:_fetch_user_context_data`](autogpt_platform/backend/backend/util/feature_flag.py)
and
[`feature-flag-provider.tsx`](autogpt_platform/frontend/src/services/feature-flags/feature-flag-provider.tsx))
for proper per-user targeting.

### Out of scope (follow-ups)

- Hard-paywall onboarding integration (Lluis's work — coordinated;
PaywallGate wraps `(platform)/layout.tsx` and onboarding lives in
`(no-navbar)`, so they don't conflict).
- Beta-users-as-Stripe-trial migration.
- Max-cap usage alerting + "Contact us" routing.
- "No Active Subscription" state rename.
- "Your credits" → "Automation Credits" rename + helper tooltip.
- BASIC tier resurface as a free / cancel-subscription option
(deliberately deferred per current product direction).

## Test plan

### Backend (all green in CI)

- [x] `poetry run pytest
backend/api/features/subscription_routes_test.py` — 41 passed.
- [x] `poetry run pytest backend/data/credit_subscription_test.py`
covering: `handle_subscription_payment_success` (grants credits, skips
non-sub/zero/missing-customer/unknown-user/ENTERPRISE, idempotent on
retry), `get_active_subscription_period_end` (happy path, no-customer
short-circuit, Stripe error swallow), top-up Product ID flag both
branches.
- [x] Type-check (3.11/3.12/3.13) — green after explicit
`list[stripe.checkout.Session.CreateParamsLineItem]` typing on top-up
`line_items`.
- [x] Codecov patch — both backend + frontend green.

### Frontend (all green in CI)

- [x] `pnpm test:unit` — 2154/2154 pass, including 5 new PaywallGate
tests (beta-cohort skip, paid-cohort BASIC redirect, no-redirect for
PRO/MAX/BUSINESS, exempt-prefix matrix, loading-state guard) and updated
`formatCost`/dialog-copy assertions.
- [x] `pnpm types`, `pnpm format`, `pnpm lint` — clean.

### Live verification on `dev-builder.agpt.co` (5/5 pass — see PR
comments)

- [x] Login + credits page renders correctly with Pro + Max cards, BASIC
+ BUSINESS hidden, no paywall banner for active subscriber.
- [x] Downgrade dialog shows new copy with concrete date + "no charge
today" + credit-grant explanation.
- [x] PaywallGate does NOT redirect paying users (MAX tier with active
sub).
- [x] PaywallGate REDIRECTS BASIC user (DB-flipped via `kubectl exec`
for testing, restored after) → `/build` redirects to `/profile/credits`,
violet "Pick a plan to continue using AutoGPT" banner displayed.
- [x] Upgrade dialog (modify-in-place) shows the corrected proration
phrasing.
- [ ] Manual: real production-like test of `invoice.payment_succeeded`
granting credits — fires on next billing cycle (2026-05-21 for the dev
test user); not testable today without manipulating Stripe webhook.
2026-04-29 23:15:29 +07:00
Ubbe
c6d31f8252 feat(frontend): gate onboarding SubscriptionStep behind ENABLE_PLATFORM_PAYMENT (#12943)
### Why / What / How

**Why:** The onboarding `SubscriptionStep` (added in #12935) is
currently shown to every new user, but the platform payment system is
rolled out behind the `ENABLE_PLATFORM_PAYMENT` LaunchDarkly flag. We
need the onboarding plan-selection step to honor the same flag so users
in flag-off cohorts don't hit a payment surface that the rest of the
product won't support.

**What:** Conditionally render the `SubscriptionStep` based on
`ENABLE_PLATFORM_PAYMENT`. When the flag is off the wizard runs `Welcome
→ Role → PainPoints → Preparing` (3 user-interactive steps +
transition); when on, behavior is unchanged (`Welcome → Role →
PainPoints → Subscription → Preparing`).

**How:**
- `page.tsx` reads the flag, computes `totalSteps` (3 vs. 4) and
`preparingStep` (4 vs. 5), and only renders `SubscriptionStep` when the
flag is on.
- `useOnboardingPage.ts` threads the same `preparingStep` into the URL
`parseStep` clamp and into the "submit profile when entering Preparing"
effect, so both adapt to the flag state.
- The Zustand store is left unchanged — its hard `Math.min(5, …)` clamp
is unreachable in flag-off flow because PainPointsStep advances 3 → 4
(Preparing) and that's the terminal step.
- `playwright/utils/onboarding.ts`: with `NEXT_PUBLIC_PW_TEST=true`
LaunchDarkly returns `defaultFlags` (`ENABLE_PLATFORM_PAYMENT: false`),
so the helper now waits up to 2s for the Subscription header and only
clicks a plan CTA if the step is actually rendered.

### Changes 🏗️

- `autogpt_platform/frontend/src/app/(no-navbar)/onboarding/page.tsx` —
gate `SubscriptionStep` on `ENABLE_PLATFORM_PAYMENT`; derive
`totalSteps`/`preparingStep` from the flag.
-
`autogpt_platform/frontend/src/app/(no-navbar)/onboarding/useOnboardingPage.ts`
— make `parseStep` and the profile-submission effect respect the
flag-derived `preparingStep`.
- `autogpt_platform/frontend/src/playwright/utils/onboarding.ts` — make
the Subscription step optional in `completeOnboardingWizard` so E2E
works in both flag states.

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [ ] I have tested my changes according to the test plan:
- [x] Existing onboarding unit tests pass (`pnpm test:unit` — 2447
passed, including `PainPointsStep`, `RoleStep`, `SubscriptionStep`,
store)
  - [x] `pnpm format`, `pnpm lint`, `pnpm types` clean
- [ ] Manual: with flag **off**, walk onboarding and confirm wizard goes
Welcome → Role → PainPoints → Preparing → /copilot, progress bar shows 3
steps
- [ ] Manual: with flag **on** (LD or
`NEXT_PUBLIC_FORCE_FLAG_ENABLE_PLATFORM_PAYMENT=true`), walk onboarding
and confirm SubscriptionStep is present at step 4, progress bar shows 4
steps
- [ ] Manual: with flag **off**, hit `/onboarding?step=5` directly and
confirm it clamps back to step 1 (no orphan Subscription state)
- [ ] Playwright: `completeOnboardingWizard` E2E flow continues to pass
under default `NEXT_PUBLIC_PW_TEST=true` (flag off path)

#### For configuration changes:
- [x] `.env.default` is updated or already compatible with my changes
(no config changes — flag already exists in LaunchDarkly +
`defaultFlags`)
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (none needed)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:12:04 +07:00
John Ababseh
28ae7ebac8 feat(onboarding): add subscription plan selection step (#12935)
## Summary

Adds a new **Subscription Step** (Step 4) to the onboarding wizard,
allowing users to choose a plan (Pro, Max, or Team) before reaching the
"Preparing" step.

## Changes

### New files
- **`steps/SubscriptionStep.tsx`** — Full subscription UI with:
  - Three plan cards (Pro $50/mo, Max $320/mo, Team — coming soon)
- Monthly / yearly billing toggle (yearly shows annual total with 20%
discount, plus monthly equivalent)
- Country selector (28 Stripe-supported countries) that opens upward as
a search modal
  - Localized pricing using live exchange rates
- **`steps/countries.ts`** — Currency data module with exchange rates,
`formatPrice()` helper, and zero-decimal currency handling (JPY, KRW,
HUF, CLP)

### Modified files
- **`store.ts`** — Extended `Step` type to `1 | 2 | 3 | 4 | 5`, added
`selectedPlan` and `selectedBilling` state/actions
- **`page.tsx`** — Wired `SubscriptionStep` as Step 4, moved
`PreparingStep` to Step 5, adjusted progress bar and dot indicators
- **`useOnboardingPage.ts`** — Updated `parseStep` range to 1–5, profile
submission now triggers at Step 5

## Design decisions
- Follows existing component patterns: uses `FadeIn`, `Text`, `Button`
atoms, `cn()` utility, Phosphor icons
- Country selector opens **upward** to avoid clipping below the viewport
- Plan selection advances to Step 5 immediately (Stripe integration is
TODO)
- Exchange rates are hardcoded for now — should be fetched from an API
in production

## TODO
- [ ] Integrate with Stripe checkout / backend subscription API
- [ ] Fetch live exchange rates instead of hardcoded values
- [ ] Add responsive layout for mobile viewports

---------

Co-authored-by: Nicholas Tindle <nicholas.tindle@agpt.co>
Co-authored-by: Lluis Agusti <hi@llu.lu>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Ubbe <hi@ubbe.dev>
2026-04-29 21:26:18 +07:00
Krzysztof Czerwinski
e0f9146d54 feat(platform): add Web Push notifications via VAPID for background delivery (#12723)
### Why / What / How

**Why:** When a user kicks off an AutoPilot task and leaves the platform
(closes the tab, switches to another page, or minimizes the browser),
they have no way of knowing when it completes unless they come back and
check. This breaks the "set it and forget it" promise of automation.

**What:** Adds Web Push notifications using the standard Push API
(VAPID). Push notifications are delivered through free browser vendor
services (Google FCM, Apple APNs, Mozilla Push) to a service worker —
even when all AutoGPT tabs are closed, as long as the browser process is
running. The system is generic and extensible to all notification types,
with copilot session completion as the first integration.

**How:**
- **Backend:** A new `PushSubscription` Prisma model stores per-user
push subscriptions. When a `NotificationEvent` is published to the Redis
notification bus, the existing `notification_worker` in `ws_api.py`
fires a tracked background `send_push_for_user()` task. This uses
`pywebpush` to call the browser push services with VAPID authentication.
Includes per-user TTL-bounded debounce (5s), per-user subscription cap
(20), 410/404 auto-cleanup, periodic scheduler-driven cleanup of
high-failure rows, and route-level SSRF rejection of untrusted
endpoints.
- **Frontend:** A `push-sw.js` service worker handles `push` events and
shows OS notifications via `self.registration.showNotification()`, with
click-to-navigate. A `PushNotificationProvider` mounted at the platform
layout registers the SW and subscription on all pages, posts the current
URL to the SW on every Next.js navigation (since Chrome's
`WindowClient.url` is stale for SPA routing), forwards the user's
notifications-toggle setting to the SW, and tears down on logout. The
copilot in-page notification path defers to the SW when a push
subscription is active so users don't get duplicate alerts.

### Behavior — when does an OS notification fire?

| Where the user is focused | Notifications toggle | OS notification? |
|---|---|---|
| Any `/copilot` page (any session, tab visible + browser focused) | on
| suppressed — sidebar green check + title badge handle it |
| `/library` (or any non-`/copilot` route) | on | **fires** |
| `/copilot` but tab hidden (Cmd-Tab away, minimized, different tab) |
on | **fires** |
| All AutoGPT tabs closed (browser process still running) | on |
**fires** |
| Any state | off | suppressed |
| Anywhere | permission not granted / no push subscription | falls back
to in-page `Notification()` if user is away on `/copilot`; nothing
otherwise |

Click any OS notification → focuses an existing tab and navigates it to
`/copilot?sessionId=<id>`, or opens a new window if no AutoGPT tab is
open.

### Test plan

#### Setup
- [ ] Generate VAPID keys via the snippet in `backend/.env.default` and
set `VAPID_PRIVATE_KEY`, `VAPID_PUBLIC_KEY`, `VAPID_CLAIM_EMAIL` in
`backend/.env`
- [ ] Leave `NEXT_PUBLIC_VAPID_PUBLIC_KEY` unset on the frontend (single
source of truth via `/api/push/vapid-key`)
- [ ] Start backend + frontend, grant notification permission on the
copilot page
- [ ] Verify `push-sw.js` is "activated and is running" in DevTools →
Application → Service Workers
- [ ] Verify `POST /api/push/subscribe` created exactly one DB row in
`PushSubscription` for your user

#### Notification show / suppress matrix
- [ ] Trigger completion **on `/copilot` viewing the same session**, tab
visible + focused → no OS notification (sidebar green check appears)
- [ ] Trigger completion **on `/copilot` viewing a different session**,
tab visible + focused → no OS notification (still considered "in the
feature")
- [ ] Trigger completion **on `/library`**, tab visible + focused → OS
notification fires
- [ ] Trigger completion **on `/copilot`** but with the tab hidden
(Cmd-Tab to another app) → OS notification fires
- [ ] Trigger completion with all AutoGPT tabs closed → OS notification
fires (browser must still be running)
- [ ] Toggle notifications **off** in the copilot UI → trigger
completion → no OS notification
- [ ] Toggle notifications **back on** → trigger completion → OS
notification fires

#### Click behavior
- [ ] OS notification → click → focuses an existing AutoGPT tab and
navigates to `/copilot?sessionId=<id>`
- [ ] OS notification with no AutoGPT tab open → click → opens a new tab
on `/copilot?sessionId=<id>`

#### Lifecycle
- [ ] Logout → DB row removed, browser unsubscribed; no further OS
notifications until login + re-subscribe
- [ ] Stale subscription (e.g. unsubscribed externally) → backend gets
410 from FCM → row auto-deleted; second push attempts no longer fan out
to it

### Changes 🏗️

**Backend — New files:**
- `backend/data/push_subscription.py` — CRUD for push subscriptions:
`upsert` (with `MAX_SUBSCRIPTIONS_PER_USER` cap), `find_many`, `delete`,
`increment_fail_count`, `cleanup_failed_subscriptions`,
`validate_push_endpoint` (HTTPS + push-service hostname allowlist for
SSRF prevention)
- `backend/data/push_sender.py` — Fire-and-forget push delivery with
`cachetools.TTLCache`-bounded debounce, defense-in-depth re-validation
at send time, 410/404 auto-cleanup with regex-based status extraction
(covers pywebpush versions where `e.response` is unset)
- `backend/api/features/push/routes.py` — 3 endpoints: `GET
/api/push/vapid-key`, `POST /api/push/subscribe`, `POST
/api/push/unsubscribe` (all with `requires_user` auth and 400 on invalid
endpoints)
- `backend/api/features/push/model.py` — Pydantic models with
`min_length`/`max_length` constraints on endpoint and crypto keys

**Backend — Modified files:**
- `schema.prisma` — Added `PushSubscription` model + `User` relation
- `pyproject.toml` — Added `pywebpush ^2.3` dependency
- `backend/util/settings.py` — VAPID key fields on `Secrets`;
`push_subscription_cleanup_interval_hours` config
- `backend/api/rest_api.py` — Registered push router at `/api/push`
- `backend/api/ws_api.py` — Notification worker now fires
`send_push_for_user()` as a tracked background task (strong-ref set +
done callback so asyncio doesn't GC it mid-run)
- `backend/data/db_manager.py` — Exposed push subscription RPC methods
on the DB manager async client
- `backend/executor/scheduler.py` — Periodic
`cleanup_failed_push_subscriptions` job (default 24h)
- `backend/.env.default` — VAPID env vars with key generation snippet

**Frontend — New files:**
- `public/push-sw.js` — Service worker: routes pushes via
`NOTIFICATION_MAP`, suppresses when user is on `/copilot`, accepts
`CLIENT_URL` and `NOTIFICATIONS_ENABLED` postMessages so SW logic stays
in sync with SPA navigation and the toggle, click handler with focus →
navigate → openWindow fallback, `pushsubscriptionchange` re-subscribe
with `credentials: include`
- `src/services/push-notifications/registration.ts`, `api.ts`,
`helpers.ts` — SW registration / Push API subscription / backend API
helpers
- `src/services/push-notifications/usePushNotifications.ts` — Hook that
auto-subscribes on login and tears down on logout
- `src/services/push-notifications/useReportClientUrl.ts` — Posts
current pathname+search to SW on every Next.js route change (works
around stale `WindowClient.url`)
- `src/services/push-notifications/useReportNotificationsEnabled.ts` —
Forwards the user's notifications toggle to the SW
- `src/services/push-notifications/PushNotificationProvider.tsx` —
Mounts all three hooks at the platform layout level

**Frontend — Modified files:**
- `src/app/(platform)/layout.tsx` — Mounted `<PushNotificationProvider
/>`
- `src/app/(platform)/copilot/useCopilotNotifications.ts` — Skips
in-page `Notification()` when a SW push subscription is active (avoids
duplicate alerts)
- `src/services/storage/local-storage.ts` — Added
`PUSH_SUBSCRIPTION_REGISTERED` key
- `frontend/.env.default` — Optional `NEXT_PUBLIC_VAPID_PUBLIC_KEY`
(left unset by default to keep `/api/push/vapid-key` as the single
source of truth)

**Configuration changes:**
- New env vars: `VAPID_PRIVATE_KEY`, `VAPID_PUBLIC_KEY`,
`VAPID_CLAIM_EMAIL` (backend); optional `NEXT_PUBLIC_VAPID_PUBLIC_KEY`
(frontend)
- New `push_subscription_cleanup_interval_hours` setting (default 24,
range 1–168)
- New DB migration: `PushSubscription` table
(`20260420120000_add_push_subscription`)

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] All blockers and should-fixes from the autogpt-pr-reviewer review
have been addressed (see PR thread)
- [x] All inline review threads resolved (49 threads addressed)

#### For configuration changes:
- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 13:28:21 +00:00
Bently
c3c2737c42 feat(platform): copilot-bot (Python / discord.py) (#12618)
## Why

AutoPilot needs to reach users on chat platforms — Discord first,
Telegram / Slack / Teams / WhatsApp next. This PR adds the bot service
that bridges those platforms to the AutoPilot backend via the
`PlatformLinkingManager` AppService introduced in #12615.

Two independent linking flows (see #12615 for the rationale):

- **SERVER links**: first person to run `/setup` in a guild claims it.
Anyone in the server can mention the bot; all usage bills to the owner.
- **USER links**: an individual DMs the bot, links their personal
account, DMs bill to their own AutoPilot. A server owner still has to
link their DMs separately.

## What

A Python service using `discord.py`, living alongside the rest of the
backend. Connects to the platform_linking service via cluster-internal
RPC (no shared bearer token) and subscribes to copilot streams directly
on Redis (no HTTP SSE proxy).

Originally prototyped in Node.js with Vercel's Chat SDK — rewritten in
Python after team feedback: the rest of the platform is Python,
`discord.py` was already a dependency, and the Chat SDK's streaming-UI
abstractions don't apply to a headless chat bot.

### Deployment

- **Shares the existing backend Docker image** — no separate Dockerfile,
no separate Artifact Registry. A `copilot-bot` poetry script entry lets
the same image run with `command: ["copilot-bot"]` in the Helm chart.
- **Auto-starts with `poetry run app`** when
`AUTOPILOT_BOT_DISCORD_TOKEN` is set, so the full local dev stack
includes the bot without extra setup.
- **Runs standalone** via `poetry run copilot-bot` for the production
pod.

Infra PR:
[AutoGPT_cloud_infrastructure#310](https://github.com/Significant-Gravitas/AutoGPT_cloud_infrastructure/pull/310).

### File layout

```
backend/copilot/bot/
├── app.py              # CoPilotChatBridge(AppService) + adapter factory + outbound @expose
├── config.py           # Shared (platform-agnostic) config
├── handler.py          # Core logic: routing, linking, batched streaming
├── platform_api.py     # Thin facade over PlatformLinkingManagerClient + stream_registry
├── platform_api_test.py
├── text.py             # split_at_boundary + format_batch
├── threads.py          # Redis-backed thread subscription tracking
├── README.md
└── adapters/
    ├── base.py         # PlatformAdapter ABC + MessageContext
    └── discord/
        ├── adapter.py  # Gateway connection, events, thread creation, buttons
        ├── commands.py # /setup, /help, /unlink
        └── config.py   # Discord token + message limits
```

**Locality rule:** anything platform-specific lives under
`adapters/<platform>/`. `app.py` is the only file that names specific
platforms — it's the factory that picks adapters based on which tokens
are set. Adding Telegram later = drop in `adapters/telegram/` with the
same shape.

### `CoPilotChatBridge` — now an `AppService`

Previously `AppProcess`. Now inherits `AppService`, runs its RPC server
on `Config.copilot_chat_bridge_port=8010`, and exposes two scaffolding
`@expose` methods for the backend→chat-platform direction:

- `send_message_to_channel(platform, channel_id, content)` — stub
- `send_dm(platform, platform_user_id, content)` — stub

Both currently raise `NotImplementedError` — they unlock the
architecture for future features (scheduled agent outputs piped to
Discord, etc.) without another structural change. A matching
`CoPilotChatBridgeClient` + `get_copilot_chat_bridge_client()` factory
lets other services call the bot by the same `AppServiceClient` pattern
used for `NotificationManager` and `PlatformLinkingManager`.

### Bot behaviour

- `/setup` — server only, ephemeral, returns a "Link Server" button.
Rejects DM invocations up front.
- `/help` — ephemeral usage info.
- `/unlink` — ephemeral, opens a "Settings" button pointing at
`AUTOGPT_FRONTEND_URL/profile/settings` (real unlinking needs JWT auth).
- **Thread per conversation**: @mentioning the bot in a channel creates
a thread and routes the reply there. Subsequent messages in that thread
don't need another @mention — thread subscriptions are tracked in Redis
with a 7-day TTL.
- **Batched follow-ups**: messages arriving mid-stream append to a
per-thread pending list; drained as a single follow-up turn when the
current stream ends.
- **Persistent typing indicator**: 8-second re-fire loop.
- **Per-user identity prefix**: every forwarded message tagged `[Message
sent by {name} (Discord user ID: ...)]`.
- **Platform-aware chunking**: long responses split at paragraph → line
→ sentence → word boundaries (1900 chars for Discord).
- **Link buttons** for DM link prompts and `/setup` / `/unlink`
responses.
- **Duplicate message guard**: on `DuplicateChatMessageError` the bot
stays quiet — no double response.

### Env vars

| Variable | Purpose |
|----------|---------|
| `AUTOPILOT_BOT_DISCORD_TOKEN` | Discord bot token — enables the
Discord adapter |
| `AUTOGPT_FRONTEND_URL` | Frontend base URL for link confirmation pages
|
| `REDIS_HOST` / `REDIS_PORT` | Shared with backend — session +
thread-subscription state + direct copilot stream subscription |
| `PLATFORMLINKINGMANAGER_HOST` | Cluster DNS name of the
`PlatformLinkingManager` service (RPC target) |

Gone vs. the previous REST design: `AUTOGPT_API_URL`,
`PLATFORM_BOT_API_KEY`, `SSE_IDLE_TIMEOUT`.

## How

- **Adapter pattern**: `PlatformAdapter` ABC defines `start`, `stop`,
`send_message`, `send_link`, `start_typing`, `create_thread`,
`max_message_length`, `chunk_flush_at`, etc. Each platform implements
the interface; the shared `MessageHandler` calls through it.
- **Control plane over RPC**: `PlatformAPI` (~180 lines) is a thin
facade over `PlatformLinkingManagerClient` — `resolve_server`,
`resolve_user`, `create_link_token`, `create_user_link_token`,
`stream_chat`. The bot never constructs HTTP requests or handles an API
key.
- **Streaming over Redis Streams**: `stream_chat` calls
`start_chat_turn` (backend `@expose`), receives a
`ChatTurnHandle(session_id, turn_id, user_id, subscribe_from="0-0")`,
then subscribes directly via
`stream_registry.subscribe_to_session(...)`. Yields text from
`StreamTextDelta`, terminates on `StreamFinish`, surfaces
`StreamError.errorText` to the user. No SSE parsing, no X-Session-Id
header dance.
- **Error model**: backend domain exceptions (`NotFoundError`,
`LinkAlreadyExistsError`, `DuplicateChatMessageError`) cross the RPC
boundary cleanly (all `ValueError`-based, registered in
`backend.util.exceptions`). The bot catches them by type instead of
inspecting HTTP status codes.
- **Cooperative batching**: `TargetState.processing` flag + per-target
`pending` list. Messages arriving while `processing=True` append; the
running stream's finally block loops to drain the list before releasing.
- **Typing helper for `endpoint_to_async`**: added an `@overload` so
`async def` `@expose` methods on the server type-check correctly on the
client side (the scheduler pattern avoids this by using sync `@expose`,
but the new managers are async).

## Tests

- `backend/copilot/bot/platform_api_test.py` — new. Covers resolve
(server + user), create link tokens (success + `LinkAlreadyExistsError`
propagation), stream chat (yields deltas, terminates on `StreamFinish`,
surfaces `StreamError`, propagates `DuplicateChatMessageError` and
`NotFoundError`, handles `subscribe_to_session` returning `None`).
- `poetry run pyright backend/copilot/bot/` — clean.
- `poetry run ruff check backend/copilot/bot/` — clean.
- `poetry run copilot-bot` starts and connects to Discord Gateway, syncs
slash commands.
- `/setup` in a guild → confirm on frontend → mention bot → AutoPilot
streams back in a created thread.
- Thread follow-ups work without re-mentioning.
- Spamming messages mid-stream produces one batched follow-up.
- Long responses chunk at natural boundaries.
- DM to unlinked user → "Link Account" button → confirm → DMs stream as
that user's AutoPilot.

## Stack

- Backend API: #12615 — merge first
- Frontend link page: #12624
- Infra:
[AutoGPT_cloud_infrastructure#310](https://github.com/Significant-Gravitas/AutoGPT_cloud_infrastructure/pull/310)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
2026-04-29 08:12:15 +00:00
Abhimanyu Yadav
37f247c795 feat(frontend): creator dashboard page for settings v2 (SECRT-2281) (#12934)
### Why / What / How

**Why:** The creator dashboard route under settings v2 currently shows a
"Coming soon" placeholder. SECRT-2281 fills it in so creators can manage
their store submissions from one place.

**What:** Implements the full creator dashboard at
`/settings/creator-dashboard` — stats overview, desktop submissions
table, mobile submissions list, filtering/sorting, selection bar, edit
modal, and empty/loading/error states.

**How:** Page logic lives in `useCreatorDashboardPage.ts` (data fetch,
filter state, modal state, CRUD callbacks); pure transforms in
`helpers.ts`; UI broken into colocated `components/*` (one folder per
component, each ~200–400 lines). Reuses generated API hooks,
`ErrorCard`, and `EditAgentModal` from the design system. Mobile/desktop
split via Tailwind `md:` breakpoints rather than runtime detection.

### Changes 🏗️

- Replace placeholder `page.tsx` with the real dashboard, wired to
`useCreatorDashboardPage`
- Add `useCreatorDashboardPage.ts` (page-level state + handlers) and
`helpers.ts` (filter/sort/stat utilities)
- Add components: `DashboardHeader`, `DashboardSkeleton`, `EmptyState`,
`StatsOverview`, `SubmissionsList` (+ `columns/*`,
`useSubmissionSelection`), `SubmissionItem` (+ `useSubmissionItem`),
`SubmissionSelectionBar`, `MobileSubmissionsList` (+
`MobileSelectionBar`), `MobileSubmissionItem`, `ColumnFilter`
- Set document title to "Creator dashboard – AutoGPT Platform"
- Surface fetch errors via `ErrorCard` with retry; show
`DashboardSkeleton` while loading; show `EmptyState` when there are no
submissions

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [ ] I have tested my changes according to the test plan:
  - [ ] Loading state renders skeleton until submissions load
  - [ ] Empty state renders when the creator has no submissions
  - [ ] Error state renders `ErrorCard` and retry refetches the list
  - [ ] Stats overview reflects approved/pending/rejected/draft counts
- [ ] Desktop list: sort/filter by status and other columns updates the
visible rows
- [ ] Desktop list: selection bar appears on row select and clears on
reset
- [ ] Mobile list (≤ md breakpoint): renders mobile items + selection
bar
- [ ] Edit modal opens for a submission, saves, and refreshes the list
on success
  - [ ] Delete action removes the submission and updates stats
  - [ ] View action navigates to the submission's public detail
  - [ ] Submit/publish entry point opens the publish modal
  - [ ] Document title shows "Creator dashboard – AutoGPT Platform"
2026-04-28 16:51:52 +00:00
Abhimanyu Yadav
ae4a421620 fix(platform): small fixes and stagger animations on settings pages (#12937)
## Why

The new Settings v2 surfaces (preferences, api-keys, integrations,
profile) shipped with a few rough edges spotted in self-review:

- **Timezone saves silently dropped on refresh.** Backend `GET
/auth/user/timezone` resolved the user via
`get_or_create_user(user_data)` (a 5-min in-process cache keyed by the
JWT-payload dict). `update_user_timezone` only invalidates
`get_user_by_id`'s cache, so the GET kept returning the pre-save tz
until TTL expired — looked exactly like "save did nothing."
- **Confusing "Looks like you're in X" CTA on the Time zone card** that
did nothing in the common case (server tz already matched the browser
tz, so clicking it produced no dirty state).
- **Save was disabled out of the gate when server tz was `"not-set"`** —
the hook substituted the browser tz into both `formState` and
`savedState`, so they were equal and `dirty` was false.
- **Lists felt static.** No motion when API keys / integrations mount,
and the loading skeletons popped in all at once instead of handing off
cleanly to the loaded rows.
- **Profile bio textarea** corner clipped against the rounded-3xl border
and the scrollbar overflowed the rounded container.

## What

### Bug fixes
- `GET /auth/user/timezone` now reads via `get_user_by_id(user_id)` —
the same cache `update_user_timezone` already invalidates — so a save
followed by refresh shows the new tz immediately.
- `usePreferencesPage` now treats the raw server tz (`"not-set"`
included) as the saved baseline, while `formState` uses the browser tz
only as a *display* fallback. Effect: when the user has never set a tz,
Save is enabled on first paint and a single click persists the detected
tz.
- Frontend save flow swapped `setQueryData` for `invalidateQueries`,
mirroring the older `/profile/(user)/settings` page so we always re-read
the persisted value.
- Removed the auto-detect "Looks like you're in X" button + its dead
helpers.

### Animations (per Emil Kowalski's guidelines)
Added orchestrated stagger animations that run on both the loaded list
**and** its skeleton, so the loading→loaded handoff is continuous
in-position:

- **API keys list + skeleton:** 280ms ease-out `cubic-bezier(0.16, 1,
0.3, 1)`, 40ms stagger, opacity + 6px translate.
- **Integrations list + skeleton:** 300ms ease-out, 80ms stagger,
opacity + 16px translate (rows are bigger / fewer).
- Both honor `prefers-reduced-motion` via `useReducedMotion`; only
`opacity` and `transform` are animated.

### Misc polish
- Profile bio textarea: `!rounded-tr-md` so the top-right corner doesn't
fight the surrounding `rounded-3xl`, plus a thin styled scrollbar
(`scrollbar-thin scrollbar-thumb-zinc-200
hover:scrollbar-thumb-zinc-300`) that lives inside the rounded container
instead of breaking out of it.

## How

| File | Change |
| --- | --- |
| `backend/api/features/v1.py` | `get_user_timezone_route` now uses
`get_user_by_id` + `Security(get_user_id)` instead of
`get_or_create_user(user_data)` |
| `frontend/.../preferences/usePreferencesPage.ts` | Split init into
`initialFormState` (browser-fallback display) vs `initialSavedState`
(raw server value); swap optimistic `setQueryData` for
`invalidateQueries` after tz mutate |
| `frontend/.../preferences/components/TimezoneCard/TimezoneCard.tsx` |
Drop `initialValue` prop, remove auto-detect button + unused imports |
| `frontend/.../preferences/page.tsx` | Drop `savedState`/`initialValue`
wiring |
| `frontend/.../api-keys/components/APIKeyList/APIKeyList.tsx` | Wrap
rows in container `motion.div` with `staggerChildren`; per-row
`motion.div` with opacity + y variants |
|
`frontend/.../api-keys/components/APIKeyListSkeleton/APIKeyListSkeleton.tsx`
| Same stagger config so loading→loaded matches |
|
`frontend/.../integrations/components/IntegrationsList/IntegrationsList.tsx`
+ `IntegrationsListSkeleton.tsx` | Same pattern for the providers list |
| `frontend/.../profile/components/ProfileForm/ProfileForm.tsx` |
Tailwind classes only — `!rounded-tr-md` + `scrollbar-thin
scrollbar-thumb-zinc-200 hover:scrollbar-thumb-zinc-300` |

## Test plan

- [ ] On `/settings/preferences`: pick a different tz → Save →
hard-refresh → new tz still selected.
- [ ] First-time user (server tz = `not-set`): land on page, Save button
should already be enabled; click Save → toast confirms; refresh → tz
persists.
- [ ] No "Looks like you're in X" button visible.
- [ ] On `/settings/api-keys`: rows fade/slide in staggered on first
mount; loading skeleton uses the same motion.
- [ ] On `/settings/integrations`: provider groups fade/slide in
staggered; skeleton matches.
- [ ] OS "Reduce motion" enabled → no transforms, content appears
instantly on all four surfaces.
- [ ] On `/settings/profile`: bio textarea top-right corner is no longer
hard-cornered against the card; scrollbar fits inside the rounded shape.
- [ ] Existing unit tests still pass: `pnpm test:unit
src/app/\(platform\)/settings/preferences` and `.../api-keys`.
2026-04-28 16:51:40 +00:00
Zamil Majdy
2879528308 feat(backend): Redis Cluster client support (#12900)
## Why

Pre-launch scaling. Redis is currently a single-master pod — a real
SPOF, and not scalable horizontally. To move it to a sharded Redis
Cluster (via KubeBlocks in GKE), the backend has to speak the cluster
protocol.

Keeping both "standalone" and "cluster" code paths would have local dev
not reflect prod. Going **cluster-only**.

## What

- `backend.data.redis_client` now always constructs `RedisCluster`
(sync) / `redis.asyncio.cluster.RedisCluster` (async). Type aliases
`RedisClient` / `AsyncRedisClient` point at the cluster classes.
- `RedisCluster` uses the existing `REDIS_HOST` / `REDIS_PORT` as a
startup node and auto-discovers peers via `CLUSTER SLOTS`.
- Classic Redis pub/sub is broadcast cluster-wide and redis-py's async
`RedisCluster` has no `.pubsub()`; dedicated `get_redis_pubsub[_async]`
helpers return plain `(Async)Redis` clients to the seed node. All
pub/sub callers (`event_bus`, `notification_bus`,
`copilot.pending_messages`) route through these helpers.
- `rate_limit.py` MULTI/EXEC pipelines are split per-counter — daily and
weekly counters hash to different slots, which `RedisCluster` correctly
rejects as `CrossSlotTransactionError`. Per-counter `INCRBY + EXPIRE`
atomicity is preserved; the counters are logically independent budgets.
- `util/cache.py` shared-cache client is also `RedisCluster` now.
- Pre-existing mock-based unit tests updated; new `redis_client_test.py`
covers the swap.

## Local dev

`docker-compose.platform.yml` now runs **2-master Redis Cluster**
(`redis` + `redis-2`, 16384 slots split 0-8191 / 8192-16383). A one-shot
`redis-init` sidecar bootstraps it on first boot via raw `CLUSTER MEET`
+ `CLUSTER ADDSLOTSRANGE` (bundled `redis-cli --cluster create` enforces
a 3-node minimum).

This deliberately catches cross-slot bugs on a laptop rather than in
prod:

```
>>> ALL SMOKE TESTS PASS <<<
[sync] class: RedisCluster
[sync] 20 keys across slots: OK
[sync] colocated MULTI/EXEC: OK [5, 12, 1]
[sync] cross-slot MULTI/EXEC rejected as expected: CrossSlotTransactionError
[sync] EVAL single-key: OK
[sync] pub/sub (classic, broadcast): OK
[async] class: RedisCluster
[async] 15 keys across slots: OK
[async] colocated pipeline: OK
[async] pub/sub: OK
```

`rest_server` `/health` → 200, both shards have connected clients + keys
distributed 19/19 under the smoke run. `executor` boots + connects to
RabbitMQ + Redis cleanly.

For a 3-shard override (6 pods, with replicas) when you want to test
real KubeBlocks topology:
```
docker compose -f docker-compose.yml -f docker-compose.redis-cluster.yml up -d
```

## Deploy order (companion infra PR:
[cloud_infrastructure#312](https://github.com/Significant-Gravitas/AutoGPT_cloud_infrastructure/pull/312))

The existing `helm/redis` chart is updated in that PR to run as a
1-shard cluster (backwards-compatible toggle, default on). That rollout
must land before this PR's image goes live so the backend's
`RedisCluster` client has something to discover.

Sequence:
1. Infra: `helm upgrade redis` (1-shard cluster-enabled)
2. Infra: `helm upgrade rabbit-mq` (3-node cluster)
3. Backend: merge + deploy this PR
4. Follow-up: swap to KubeBlocks `redis-cluster` chart (3-shard sharded,
already staged in infra PR)

## Caveats / follow-ups

- Classic pub/sub via seed node means every node in the cluster sees
every message (broadcast). Fine at current volume; if it becomes hot,
migrate to `SPUBLISH`/`SSUBSCRIBE` (Redis 7+ sharded pub/sub).
- Per-user rate-limit counters (daily vs weekly) lost cross-counter
transactionality, but per-counter atomicity is preserved — the two
counters are independent budgets so no correctness regression.
- Local 2-master cluster crashes lose the cluster state; `redis-init`
idempotently rebootstraps.

## Checklist

- [x] Lint + format pass (`poetry run format` + `poetry run lint`)
- [x] Unit tests pass — `redis_client_test`, `redis_helpers_test`,
`event_bus_test`, `pending_messages_test`, `rate_limit_test`,
`cluster_lock_test`
- [x] Live smoke against 2-master cluster — sync + async; MULTI/EXEC;
EVAL; pub/sub; cross-slot rejection
- [x] Full stack smoke — `rest_server` /health, `executor` boot, keys
distributed across both shards
- [ ] Dev deploy (pending infra PR merge + manual validation)
2026-04-28 22:21:23 +07:00
Ubbe
1974ec6260 fix(frontend/copilot): fix streaming reconnect races, hydration ordering, and reasoning split (#12813)
## Summary

Improves Copilot/AutoPilot streaming reliability across frontend and
backend. The diff now covers the original streaming investigation issues
plus follow-up CI and review fixes from the latest merge with `dev`.

Addresses [SECRT-2240](https://linear.app/autogpt/issue/SECRT-2240),
[SECRT-2241](https://linear.app/autogpt/issue/SECRT-2241), and
[SECRT-2242](https://linear.app/autogpt/issue/SECRT-2242).

## Changes

- Fixes reasoning vs response rendering so action tools such as
`run_block` and `run_agent` do not cause assistant response text to be
hidden inside the collapsed reasoning section.
- Reworks Copilot session lifecycle handling: active-stream hydration,
resume ordering, reconnect timeout recovery, wake resync, session
deletion, title polling, stop handling, and session-switch stale
callback guards.
- Adds a per-session Copilot stream store/registry and transport helpers
to prevent duplicate resumes, duplicate sends, and cross-session
contamination during reconnect or reload flows.
- Adds pending follow-up message support and backend pending-message
safeguards, including sanitization of queued user content and
requeue-on-persist-failure behavior.
- Improves backend stream and executor robustness: active stream
registry checks, bounded cancellation drain with sync fail-close
fallback, Redis helper coverage, and updated SDK response adapter
expectations for post-tool status events.
- Adds and polishes usage-limit UI, including reset gate behavior,
backdrop blending behind the usage-limit card, and usage panel/card
coverage.
- Fixes a chat input Enter-submit race where Playwright and fast users
could fill the textarea and press Enter before React had re-enabled the
submit button, causing the visible message not to send.
- Refactors the Copilot page into smaller hooks/components and adds
focused tests around stream recovery, hydration, pending queueing,
rate-limit gates, and message rendering.

## Test plan

- [x] `poetry run format`
- [x] `poetry run pytest backend/copilot/sdk/response_adapter_test.py
backend/copilot/executor/processor_test.py`
- [x] `pnpm prettier --write` on touched frontend files
- [x] `pnpm vitest run
src/app/(platform)/copilot/components/ChatInput/__tests__/useChatInput.test.ts`
- [x] `pnpm types`
- [x] `pnpm lint` (passes with existing unrelated `next/no-img-element`
warnings)
- [ ] Full GitHub CI after latest push

## Review notes

- The Sentry review thread about unbounded cancellation cleanup is
addressed in `375ec9d5f`: cancellation now waits for normal async
cleanup but exits after `_CANCEL_GRACE_SECONDS` and falls through to the
sync fail-close path.
- The previous backend CI failures were stale test expectations around
the new `StreamStatus("Analyzing result…")` event after tool output;
tests now assert that event explicitly.
- The previous full-stack E2E failure was the Copilot input Enter race;
the input now submits from the live form value instead of depending on a
possibly stale disabled button state.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nicholas Tindle <nicholas.tindle@agpt.co>
2026-04-28 15:40:37 +07:00
Zamil Majdy
932ecd3a07 fix(backend/copilot): normalize model name based on actual transport, not config shape (#12932)
## Summary

When `CHAT_USE_CLAUDE_CODE_SUBSCRIPTION=true` is paired with a populated
`CHAT_BASE_URL=https://openrouter.ai/api/v1` (e.g. left over from an
earlier OpenRouter setup), the SDK was passing the OpenRouter slug
`anthropic/claude-opus-4.7` straight through to the Claude Code CLI
subprocess. The CLI uses OAuth and ignores
`CHAT_BASE_URL`/`CHAT_API_KEY`, so it rejects the slug:

> There's an issue with the selected model (anthropic/claude-opus-4.7).
It may not exist or you may not have access to it.

The bug was in `_normalize_model_name`, which gated on
`config.openrouter_active` (config-shape check) instead of the transport
the CLI actually uses for the turn.

## Changes

- Add `ChatConfig.effective_transport` property returning `subscription`
| `openrouter` | `direct_anthropic`, detected in that priority order.
Subscription wins over OpenRouter config because the CLI subprocess uses
OAuth and ignores the OpenRouter env vars (see `build_sdk_env` mode 1).
- Switch `_normalize_model_name` to gate on `effective_transport`.
Subscription and direct-Anthropic transports both produce the
CLI-friendly hyphenated form (`claude-opus-4-7`) and reject
non-Anthropic vendors loudly.
- `_resolve_sdk_model_for_request` already routes any LD-served override
through `_normalize_model_name`, so a per-user advanced-tier override
under subscription now correctly becomes `claude-opus-4-7` instead of
the OpenRouter slug. The standard-tier \"no LD override → return None\"
behaviour is preserved.
- Update two existing service tests to assert the corrected behaviour
(Kimi LD override under subscription falls back to tier default
normalised for the CLI; Opus advanced override returns hyphenated form).

## Test plan

- [x] `poetry run pytest backend/copilot/sdk/service_helpers_test.py
backend/copilot/sdk/service_test.py backend/copilot/config_test.py -v` —
165 passed.
- [x] `poetry run pytest backend/copilot/sdk/env_test.py
backend/copilot/sdk/p0_guardrails_test.py` — 136 passed (other call
sites of `openrouter_active` unchanged).
- [x] `poetry run ruff format` + `ruff check` clean on touched files.

### New tests added (service_helpers_test.py)

- Subscription transport with OpenRouter base URL set + advanced-tier LD
override → returns `claude-opus-4-7` (not the OpenRouter slug, not
None).
- Subscription transport with OpenRouter base URL set + standard-tier no
override → returns None (existing behaviour preserved).
- Subscription transport rejects non-Anthropic vendor (`moonshotai/...`)
→ ValueError.
- `effective_transport` returns `subscription` when subscription is on
regardless of OpenRouter config; returns `openrouter` when subscription
is off and OpenRouter is fully configured; returns `direct_anthropic`
otherwise.
2026-04-28 11:40:31 +07:00
Zamil Majdy
4a567a55a4 fix(backend/copilot): pause idle timer during pending tools (#12927)
## Summary

Pause the SDK idle timer while a tool call is pending, with a 2-hour
hung-tool cap as backstop. Fixes SECRT-2239 — long-running tools (10+
min, e.g. sub-agent execution) were being silently aborted by the
10-minute idle timeout introduced in #12660.

## What changed (backend only)

- `_IDLE_TIMEOUT_SECONDS = 1800` (30 min) — soft cap when no tool
pending (raised from 10 min)
- `_HUNG_TOOL_CAP_SECONDS = 7200` (2 h) — hard cap when a tool is
pending; protects against truly hung tool calls without false-aborting
legitimate long-running ones
- `_idle_timeout_threshold(adapter)` — returns the appropriate threshold
based on whether any tool is currently pending in the adapter

Backed by 7 regression tests in
`service_test.py::TestIdleTimeoutThreshold`.

## Frontend coordination

The original cherry-pick batch included a `useStreamActivityWatchdog`
hook for client-side wire-silence detection. That hook is dropped from
this PR because it overlaps with Lluis's #12813, which ships the same
component as part of a comprehensive copilot streaming refactor. End
state on dev: his PR contributes the watchdog, this PR contributes the
backend pause + cap.

## Test plan

- 7/7 unit tests in
`backend/copilot/sdk/service_test.py::TestIdleTimeoutThreshold` pass
- pyright clean on `service.py` + `service_test.py`
- /pr-test --fix posted with native-stack run + screenshots:
https://github.com/Significant-Gravitas/AutoGPT/pull/12927#issuecomment-4328320714

## Linear

SECRT-2239
2026-04-28 09:07:16 +07:00
John Ababseh
2b28434786 feat(platform/backend): Filter store creators with approved agents (#10014)
Filtering store creators to only show profiles with an approved agent
keeps the marketplace focused on usable inventory and prevents empty
creator cards.
 
### Changes 🏗️
 
- add a `num_agents > 0` filter to `get_store_creators`
- add a regression test ensuring we only return creators with approved
agents
- keep the existing SQL injection regression tests intact after rebasing
onto `dev`
 
### Checklist 📋
 
#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [ ] I have tested my changes according to the test plan:
- [ ] python3 -m pytest
autogpt_platform/backend/backend/server/v2/store/db_test.py -k
get_store_creators_only_returns_approved *(blocked: repo environment
lacks pytest and related deps)*
 
<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Filter `get_store_creators` to creators with `num_agents > 0` and add
a test to validate the behavior.
> 
> - **Store backend**:
> - Update `get_store_creators` in `backend/server/v2/store/db.py` to
filter creators by `num_agents > 0`.
> - **Tests**:
> - Add `test_get_store_creators_only_returns_approved` in
`backend/server/v2/store/db_test.py` to verify filtering and pagination
count calls.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
c2fca584cce5a8c26dbdadd68696a0033642f193. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Nicholas Tindle <ntindle@users.noreply.github.com>
Co-authored-by: Nicholas Tindle <nicholas.tindle@agpt.co>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ntindle <8845353+ntindle@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-27 22:01:24 +00:00
Zamil Majdy
5d1cdc2bad fix(backend/copilot): surface empty-success ResultMessage as stream error (SECRT-2252) (#12926)
## Summary

- Detect ghost-finished sessions where the SDK returns a `ResultMessage`
with `subtype="success"`, empty `result`, no produced content, and
`output_tokens == 0`.
- Emit `StreamError(code="empty_completion")` instead of silently
calling `StreamFinish`, so the caller (and the user) sees the failure.

## Background

Linear: [SECRT-2252](https://linear.app/agpt/issue/SECRT-2252) — SDK
silent empty completion not retried, leaving the user with a blank
stream (`start -> start-step -> finish-step -> finish`).

## Changes

- `response_adapter.py::convert_message`: in the `ResultMessage` branch,
check `_is_empty_completion()` before falling through to the existing
success path. When matched, close any open step, emit `StreamError`, and
skip `StreamFinish`.
- `response_adapter.py::_is_empty_completion`: new helper that returns
`True` only when `result` is falsy, no text/reasoning was emitted, no
tool calls were registered, no tool results were seen, and
`usage["output_tokens"]` is `0`.
- `response_adapter_test.py`: 4 new unit tests covering empty-success
(None and empty-string variants), non-empty success, and the
non-empty-tokens-but-empty-result fallthrough.

## Out of scope (per ticket)

- Retry-once behavior. This PR only surfaces the error; the caller
decides retry semantics. Follow-up work can wire automatic retry on
`code="empty_completion"`.

## Test plan

- [x] `poetry run pytest backend/copilot/sdk/response_adapter_test.py` —
all 58 tests pass (4 new + 54 existing).
- [x] `poetry run pyright backend/copilot/sdk/response_adapter.py
backend/copilot/sdk/response_adapter_test.py` — clean.

## Checklist

- [x] My code follows the style of this project.
- [x] I have added tests covering my changes.
- [x] I have updated the documentation accordingly. (N/A — internal
adapter behavior)
2026-04-27 17:24:57 +00:00
Abhimanyu Yadav
3c08b90500 feat(frontend): preferences v2 page (SECRT-2279) (#12925)
### Why / What / How

**Why** — Settings v2 needs a dedicated Preferences page covering
account info (email, password reset), time zone, and notification
preferences with a single, predictable save flow. The existing legacy
page at `/profile/(user)/settings` mixes concerns and uses `__legacy__`
UI primitives we are migrating away from. SECRT-2279.

**What** — A new preferences page at `/settings/preferences` built from
atomic / molecular design-system components. Three cards (Account, Time
zone, Notifications) share one inline Save / Discard bar at the bottom.
The page replaces the legacy settings page from a UX standpoint while
keeping the same backend mutations.

<img width="1511" height="899" alt="Screenshot 2026-04-27 at 6 43 09 PM"
src="https://github.com/user-attachments/assets/5762fc41-1654-4764-8fbf-d5dd262e031a"
/>

**How**
- **Page composition (`page.tsx`)** uses a single `usePreferencesPage`
hook that owns dirty/saved/form state. Renders `PreferencesHeader`,
`AccountCard`, `TimezoneCard`, `NotificationsCard`, and the inline
`SaveBar` below the cards.
- **Account card** — Email row shows the current address with a compact
pencil button that opens a width-constrained `Dialog` for editing
(Cancel / Update). Password row is a `NextLink` button that routes to
`/reset-password`.
- **Time zone card** — Single row inside a card: label + info-icon
tooltip on the left; small-size `Select` and the GMT offset chip on the
right. The "auto-detect" prompt shows up only when the saved tz differs
from the browser tz, rendered as a pill on the right side of the card.
- **Notifications card** — Tabs for Agents / Marketplace / Credits;
toggling a switch flips a flag in formState and enables Save.
- **Save flow** — `usePreferencesPage` keeps a `savedState` snapshot
(mirrors the old `react-hook-form` `defaultValues` capture-once
semantics) so the dirty check is fully decoupled from any backend GET
refetch. After a successful mutation, `savedState ← formState`, and the
timezone query cache gets an optimistic `setQueryData` write so the
value isn't snapped back by the (cached) GET endpoint.
- **Skeleton** — `PreferencesSkeleton` mirrors the real layout — header,
Account card with the two row shapes, Time zone single row,
Notifications tabs + toggle rows, and the Save / Discard buttons.
- **Sidebar** — Renames the entry "Settings" → "Preferences" with
`SlidersHorizontalIcon`. Profile and Creator Dashboard get clearer
affordances (`UserIcon`, `ChartLineUpIcon`).

### Changes 🏗️

- New page: `src/app/(platform)/settings/preferences/page.tsx` and
`usePreferencesPage.ts`
- New components: `AccountCard`, `TimezoneCard`, `NotificationsCard`,
`PreferencesHeader`, `PreferencesSkeleton`, `SaveBar`
- Helpers: `helpers.ts` (timezones list, GMT-offset formatter, dirty
utilities, notification-group definitions)
- Sidebar: rename "Settings" → "Preferences", swap to
`SlidersHorizontalIcon` + cleaner Profile / Creator Dashboard icons
(`SettingsSidebar/helpers.ts`)
- Tests:
- `preferences/__tests__/main.test.tsx` — page render, edit-email dialog
open/cancel, time zone info trigger, Save/Discard disabled-on-clean,
notification toggle → save submission, discard revert
- Updated `SettingsSidebar.test.tsx` and `SettingsMobileNav.test.tsx`
for the "Preferences" label

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [ ] I have tested my changes according to the test plan:
- [ ] Sidebar shows "Preferences" with the new icon; clicking routes to
`/settings/preferences`
- [ ] Account card: pencil button opens a 420px-wide dialog; Update
button stays disabled until the email differs and is valid; Cancel
closes the dialog
- [ ] Password row: "Reset password" button navigates to
`/reset-password`
- [ ] Time zone: changing the select enables Save; clicking Save
persists the value and the form keeps showing the saved value (does not
snap back), the inline GMT offset chip updates, info tooltip appears on
hover
- [ ] Auto-detect prompt appears only when saved tz ≠ browser tz;
clicking it sets the select to the browser tz
- [ ] Notifications: toggling any switch enables Save; saving flips the
flag in the request body; switching tabs preserves toggles; Discard
reverts unsaved toggles
- [ ] Save / Discard render below the cards on the right and stay
disabled until any field is dirty
  - [ ] Loading state shows the new skeleton that mirrors the layout
- [ ] `pnpm test:unit` passes (covers the page-level integration tests
above)

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)
2026-04-27 15:13:51 +00:00
Abhimanyu Yadav
599f370206 feat(frontend): add settings v2 profile page (#12924)
### Why / What / How

**Why:** SECRT-2278 — settings v2 needs a Profile page so users can
manage how they appear on the marketplace (display name, handle, bio,
links, avatar) without hopping into the legacy settings UI.

**What:** Adds the `/settings/profile` page end-to-end:
- Form fields for display name, handle, bio, avatar, and up to 5 links —
wired to the `getV2GetUserProfile`, `postV2UpdateUserProfile`, and
`postV2UploadSubmissionMedia` endpoints.
- Bio editor gets a markdown toolbar (bold / italic / strikethrough /
link / bulleted list) with a live preview toggle that renders via
`react-markdown` + `remark-gfm`.
- Save/Discard bar with full validation (handle regex, bio length, dirty
tracking) and toast feedback for success and failure paths.
- Forward refs through the `Input` atom so consumers can target the
underlying `<textarea>` / `<input>` (needed for the toolbar's
selection/cursor manipulation).
- Comprehensive integration tests (Vitest + RTL + MSW) at the page level
plus pure-helper unit tests, in line with the project's
"integration-first" testing strategy. Coverage is reported via
`cobertura` for Codecov.

**How:**
- The toolbar applies markdown syntax by reading `selectionStart` /
`selectionEnd` from a forwarded textarea ref. To avoid the textarea
jumping to the top on click: buttons `preventDefault` on `mousedown` (so
the textarea keeps focus), and the handler captures `scrollTop` before
mutation and restores it (with `focus({ preventScroll: true })`) after
React commits the new value in the next animation frame.
- The preview pane styles markdown elements via Tailwind arbitrary child
selectors (`[&_ul]:list-disc` etc.) instead of pulling in
`@tailwindcss/typography`, since the plugin isn't installed and the
project's `prose` usage was a no-op.
- Profile data hydration tolerates nullish API fields by mapping through
`profileToFormState`, padding `links` to 3 slots so the UI always has
the initial layout.
- Tests use Orval-generated MSW handlers from `store.msw.ts`, mock
`useSupabase` to inject an authenticated user, and assert UI behavior
via Testing Library queries.

### Changes 🏗️

- New: `app/(platform)/settings/profile/__tests__/helpers.test.ts`,
`__tests__/main.test.tsx`
- Updated: `settings/profile/page.tsx`, `useProfilePage.ts`,
`helpers.ts`, plus `ProfileForm`, `ProfileHeader`, `ProfileSkeleton`,
`LinksSection`, `SaveBar`
- Updated: `settings/layout.tsx` (settings v2 chrome adjustments to host
the profile page)
- Atom change: `components/atoms/Input/Input.tsx` now forwards refs
(`HTMLInputElement | HTMLTextAreaElement`) — backward-compatible for
existing consumers

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Open `/settings/profile` and confirm the page hydrates the
existing display name, handle, bio, avatar, and links
- [x] Edit each field and verify validation messages (empty name,
invalid handle with spaces, bio over 280 chars)
- [x] Bio markdown toolbar: select text and click Bold / Italic / Strike
— selection wraps, cursor stays in place, textarea does not scroll to
top
- [x] Bio toolbar with no selection: each button inserts the markdown
placeholder template
- [x] Click Bulleted list twice on the same line — line is prefixed with
`- ` only once
- [x] Toggle Preview — bio renders bullets, bold, italic, strikethrough,
links correctly; toolbar buttons dim and become inert
  - [x] Toggle Edit — textarea returns with the same content
- [x] Add link → 4th and 5th slots appear; the 6th attempt is blocked by
the "Limit of 5 reached" button label
  - [x] Remove link 1 — the rest reorder correctly
- [x] Avatar upload — happy path replaces the avatar; failure path
surfaces a destructive toast
- [x] Save with valid data → success toast, query invalidates, save
button disables until next edit
  - [x] Save with a server 422 → destructive toast, no state corruption
  - [x] Discard reverts every field back to the loaded profile
- [x] `pnpm test:unit` passes locally; `coverage/cobertura-coverage.xml`
shows ≥ 80% line coverage for `src/app/(platform)/settings/profile/**`
2026-04-27 10:34:09 +00:00
Otto
8786c00f9c feat(blocks): add Claude Opus 4.7 model support (#12826)
Requested by @Bentlybro

Anthropic released [Claude Opus
4.7](https://www.anthropic.com/news/claude-opus-4-7) today. This PR adds
it to the platform's supported model list.

## Why

Users and developers need access to `claude-opus-4-7` via the platform's
LLM block and API. The model is available on Anthropic's API today.

## What

- Adds `CLAUDE_4_7_OPUS = "claude-opus-4-7"` to the `LlmModel` enum
- Adds corresponding `ModelMetadata` entry: 200k context, 128k output,
price tier 3 ($5/M input, $25/M output — same as Opus 4.6)

## How

Two lines added to `llm.py`, following the exact same pattern as all
other Anthropic model additions. No migrations, no frontend changes
needed — the frontend reads model metadata from the backend's JSON
schema endpoint automatically.

Closes SECRT-2248

---------

Co-authored-by: Bentlybro <Github@bentlybro.com>
2026-04-27 07:00:00 +00:00
SymbolStar
384cbd3ccd fix(frontend): redirect www to non-www with 308 to preserve request method (#9188) (#12920)
## Summary
Fixes #9188 — redirects `www.` to non-www to prevent cookie/auth domain
mismatch.

Uses **308** (Permanent Redirect) instead of 301, which preserves the
HTTP method and request body. This is important because the middleware
matcher runs on `/auth/authorize` and `/auth/integrations/*`, where
OAuth callbacks may use POST.

Split from #12895 per reviewer request.

## Changes
- Added www→non-www redirect in Next.js middleware using
`NextResponse.redirect(url, 308)`

---------

Co-authored-by: majdyz <zamil.majdy@agpt.co>
2026-04-27 06:35:56 +00:00
SymbolStar
8be9cf70af fix(frontend): filter null query params in buildUrlWithQuery (#11237) (#12921)
## Summary
Fixes #11237 — `buildUrlWithQuery` now filters out `null` values in
addition to `undefined`, preventing them from being serialized as
literal `"null"` strings in URL query parameters.

Split from #12895 per reviewer request.

## Changes
- Added `value !== null` check alongside existing `value !== undefined`
in `buildUrlWithQuery`

---------

Co-authored-by: majdyz <zamil.majdy@agpt.co>
2026-04-27 06:31:34 +00:00
Abhimanyu Yadav
a723966e0b feat(platform): settings v2 integrations page + provider description SDK (#12911)
### Why / What / How

**Why:** The settings v2 integrations surface renders from a hardcoded
`MOCK_PROVIDERS` array — no real user credentials, no delete, no way to
connect a new service. Provider display metadata (descriptions +
supported auth types) was scattered across frontend maps with no backend
source of truth, leaving each new provider to be manually registered in
two places.

**What:** Full-featured settings v2 integrations page driven by live
backend data, plus a backend SDK extension so every provider carries a
description **and** declares its supported auth types. Settings UI uses
both to render the connect-a-service dialog: descriptions in the list,
auth types to pick the right tabs in the detail view.

**How:**
- **Credentials list** — single-fetch via `useGetV1ListCredentials`,
grouped client-side by provider, debounced (250 ms) Unicode-normalized
in-memory search (no roundtrip per keystroke), managed/system creds
filtered via the shared `filterSystemCredentials` helper from
`CredentialsInput`. Loading → skeletons that mirror the real accordion
shape, error → `ErrorCard` with retry, empty → `IntegrationsListEmpty`
with custom marquee illustration.
- **Delete flow** — `useDeleteIntegration` returns per-target `succeeded
/ failed / needsConfirmation` so the UI can name failed items and keep
them selected for one-click retry. Single + bulk both gated by
`DeleteConfirmDialog`. Per-row delete button disables + shows a spinner
via `isDeletingId` so double-clicks can't fire two requests. Success
toast names the credential ("Removed GitHub key").
- **Connect-a-service dialog** — backend-driven (`useGetV1ListProviders`
returns `ProviderMetadata[]` with description + supported_auth_types),
Emil-spec animations (150 ms ease-out step swap, 200 ms ease-out height
resize, 180 ms entry fade+slide+blur on tab swap, all respecting
`prefers-reduced-motion`). Detail view picks tab order via deterministic
`TAB_PRIORITY` (oauth → api_key → user_password → host_scoped) and
remembers last-selected tab per provider for the session.
- **OAuth tab** → `openOAuthPopup` + `getV1InitiateOauthFlow` →
`postV1ExchangeOauthCodeForTokens`
- **API key tab** → zod-validated form with
`autoComplete="new-password"` + `spellCheck=false` so browsers don't
autofill the wrong stored key → `postV1CreateCredentials`
- **Provider metadata SDK** — chainable
`ProviderBuilder.with_description(...)` +
`.with_supported_auth_types(...)` (the latter populated automatically by
`with_oauth` / `with_api_key` / `with_managed_api_key` /
`with_user_password`; explicit form reserved for legacy providers whose
auth lives outside the builder chain). `GET /integrations/providers`
upgraded from `List[str]` → `List[ProviderMetadata]` carrying both
fields.
- **Backward compat** — `BackendAPI.listProviders()` maps the new
`ProviderMetadata[]` shape down to `string[]` so the deprecated
`CredentialsProvider` (used by the builder/library credential pickers)
keeps working without ripple changes.
- **Routing** — page lives at `/settings/integrations` directly. No
feature flag gate (settings v2 layout is already on dev).

### Changes 🏗️

**Backend**
- `backend/sdk/builder.py` — `with_description()` +
`with_supported_auth_types()` chain methods; the latter is
auto-populated by every existing auth-method chain call so explicit
declaration is only needed for legacy providers.
- `backend/sdk/provider.py` — `description` + `supported_auth_types`
fields on `Provider`.
- `backend/api/features/integrations/router.py` — `GET /providers` now
returns `List[ProviderMetadata]`; calls `load_all_blocks()` (cached
`@cached(ttl_seconds=3600)`) before reading `AutoRegistry`.
- `backend/api/features/integrations/models.py` — `ProviderMetadata`
with `name + description + supported_auth_types`;
`get_provider_description` + `get_supported_auth_types` helpers reading
from `AutoRegistry`.
- 13 existing `_config.py` files updated with `.with_description(...)`:
agent_mail, airtable, ayrshare, baas, bannerbear, dataforseo, exa,
firecrawl, linear, stagehand, wordpress, wolfram, generic_webhook.
- 20 new `_config.py` files (one per provider block dir): apollo,
compass, discord, elevenlabs, enrichlayer, fal, github, google, hubspot,
jina, mcp, notion, nvidia, replicate, slant3d, smartlead, telegram,
todoist, twitter, zerobounce. Each declares
`with_supported_auth_types(...)` because their auth handlers live in
`backend/integrations/oauth/` (legacy) or are block-level
`CredentialsMetaInput` declarations — outside the builder chain.
- 1 new `backend/blocks/_static_provider_configs.py` registering
description + auth types for ~24 providers that live in shared files
(openai, anthropic, groq, ollama, open_router, v0, aiml_api, llama_api,
reddit, medium, d_id, e2b, http, ideogram, openweathermap, pinecone,
revid, screenshotone, unreal_speech, webshare_proxy, google_maps, mem0,
smtp, database). Comment documents the migration path (each entry
retires when the provider graduates to its own `_config.py`).

**Frontend**
- `src/app/(platform)/settings/integrations/page.tsx` — replaces mock
page; composes header + list + connect dialog.
-
`src/app/(platform)/settings/integrations/components/IntegrationsList/`
— list + skeleton + selection (Record<string, true> instead of Set) +
delete orchestration hook.
-
`src/app/(platform)/settings/integrations/components/ConnectServiceDialog/`
— split per the 200-line house rule into `ConnectServiceDialog`,
`ListView`, `ProviderRow`, `useMeasuredHeight`. DetailView's nested
helpers extracted to siblings: `MethodPanel`, `UnsupportedNotice`,
`ProviderAvatar`. Tabs render in deterministic priority order;
last-selected tab persisted per provider in module-scope.
-
`src/app/(platform)/settings/integrations/components/DeleteConfirmDialog/`
— new confirm dialog gating single + bulk deletes (shows up to 3 names +
remaining count for bulk).
-
`src/app/(platform)/settings/integrations/components/IntegrationsListEmpty/components/IntegrationsMarquee.tsx`
— switched from `next/image unoptimized` to plain `<img loading="lazy"
decoding="async">` for decorative logos (no LCP candidate, avoids Next
Image runtime overhead).
-
`src/app/(platform)/settings/integrations/components/hooks/useDeleteIntegration.ts`
— bulk delete now returns per-target
`succeeded/failed/needsConfirmation`; failed items stay selected for
retry; per-id pending tracking via `isDeletingId`; toast names the
credential.
-
`src/app/(platform)/settings/integrations/components/hooks/useDebouncedValue.ts`
— small reusable debounce hook (250 ms, used by both list + dialog
search).
- `src/app/(platform)/settings/integrations/helpers.ts` —
`formatProviderName` guarded against non-string input; `filterProviders`
now Unicode-normalized (NFKD + strip combining marks) so accented
queries match.
- `src/providers/agent-credentials/helper.ts` — `toDisplayName` same
`typeof string` guard.
- `src/components/contextual/CredentialsInput/helpers.ts` — loosened
`filterSystemCredentials` / `getSystemCredentials` generic constraint to
accept `title?: string | null` so it consumes `CredentialsMetaResponse`
directly.
- `src/lib/autogpt-server-api/client.ts` — `listProviders()` maps the
new shape to `string[]` for backward compat.
- `src/app/api/openapi.json` — regenerated spec includes
`ProviderMetadata` with `supported_auth_types`.

### PR feedback addressed 🛠️

Round of fixes after the first review pass:
- Bulk delete: per-item names in failure toast, failed items kept
selected for retry.
- Confirmation dialog before any delete (single or bulk) —
`DeleteConfirmDialog`.
- Per-row delete button disabled + spinner while pending (no
double-click double-fire).
- Toast names the credential ("Removed GitHub key") instead of generic
copy.
- API key input: `autoComplete="new-password"` + `spellCheck=false`;
title field `autoComplete="off"`.
- Search debounced 250 ms on both list + dialog; Unicode-normalized so
"Açai" matches "acai".
- `toDisplayName` / `formatProviderName` guarded against non-string
input (`provider.split is not a function` was reproducible).
- Skeleton mirrors the real accordion shape — no layout shift on data
load.
- Selection bar sticky position fixed for <375 px (`top-2 sm:top-0`).
- Last-selected auth tab persisted per provider for the session.
- Tabs ordered deterministically (oauth → api_key → user_password →
host_scoped) instead of insertion order.
- `useMemo` removed from `useIntegrationsList` per project rule (no
measured perf need).
- Selection state migrated from `Set<string>` to `Record<string, true>`
(idiomatic React state shape).
- ConnectServiceDialog 288 LoC → ~130 (extracted `ListView`,
`ProviderRow`, `useMeasuredHeight`); DetailView helpers → siblings.
- `next/image unoptimized` → plain `<img>` for decorative logos in
marquee + provider rows + avatar.
- `with_supported_auth_types(...)` pruned in 11 `_config.py` files where
it was redundant with `with_oauth` / `with_api_key` /
`with_managed_api_key` / `with_user_password`. Kept in legacy ones
(github, discord, google, notion, ...) where the docstring says it's
required because auth handlers live outside the builder chain.
- Tab swap + dialog step animations re-tuned vs Emil Kowalski's
animation rules: ease-out default, under 300 ms,
transform+opacity+filter only, blur-bridge to soften swap, willChange to
dodge the 1px shift, reduced-motion fallbacks via `useReducedMotion`.
- Merged latest `dev` (api-keys SECRT-2273 took dev's version, no
api-keys diff in this PR; settings layout took dev's version, no
`SETTINGS_V2` feature flag in this PR; `scroll-area` took dev's
version).

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [ ] I have tested my changes according to the test plan:
- [ ] Navigate to `/settings/integrations` — loading skeletons appear,
then user's real credentials grouped by provider.
- [ ] Type in the search box — filters client-side after 250 ms, no new
network requests (DevTools → Network stays quiet). Try "açai" with
diacritics — matches "acai" providers.
- [ ] Connect a service → dialog loads provider list with backend
descriptions; search matches name, slug, and description.
- [ ] Click a provider → dialog tweens height smoothly (no jump); header
shows provider avatar + name + description; tabs render in oauth →
api_key priority; last-used tab restored on reopen.
- [ ] Open `linear` (oauth + api_key) — switching tabs animates with a
quick fade+slide+blur entry, no flash.
- [ ] OAuth tab → "Continue with X" opens popup, completes consent,
popup closes, dialog closes, new credential appears with success toast.
- [ ] API key tab → paste a key (browser does NOT offer to autofill any
stored password), Save → toast names the credential, dialog closes.
- [ ] Delete (single) via trash icon → confirmation dialog → button
disables with spinner during the request → toast names the credential.
- [ ] Delete (bulk) via selection bar → confirmation lists up to 3 names
→ if some fail, failed ones stay selected for retry; toast lists which
failed.
  - [ ] Double-click a delete button rapidly — only one request fires.
- [ ] Managed credentials (e.g. "Use Credits for AI/ML API") do **not**
appear in the list.
- [ ] Test on a fresh account (no credentials) — `IntegrationsMarquee`
empty state renders.
- [ ] Throttle network to Slow 3G — skeleton (mirroring real shape)
visible, then list slides in.
  - [ ] Block `/api/integrations/credentials` → `ErrorCard` with retry.
- [ ] `curl /api/integrations/providers` returns `[{ name, description,
supported_auth_types }, ...]` with every provider carrying both fields.
- [ ] `prefers-reduced-motion: reduce` set → all motion collapses to
opacity-only fades.
  - [ ] On <375 px viewport — selection bar clears the mobile nav.
- [ ] `pnpm format && pnpm lint && pnpm types && pnpm test:unit` all
pass.

#### For configuration changes:
- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**) — no flag changes in this PR.
2026-04-27 06:28:31 +00:00
Zamil Majdy
5b1d9763ed fix(backend/copilot): preserve interrupted SDK partial work on final-failure exit (#12918)
## Background

[SECRT-2275](https://linear.app/autogpt/issue/SECRT-2275). User report:
when a copilot ("autopilot") turn is interrupted by a usage-limit,
tool-call-limit, or other run interruption, the user's recent work
disappears. User described it as: "my initial message was lost 3 times
and it disappeared, then when I would say 'continue' it would do a
random old task."

Investigation surfaced two distinct failure modes. This PR addresses
both.

- **Mode 1** — rate-limit (or other pre-stream rejection) at turn start:
the user's text only ever lives in the optimistic `useChat` bubble; the
backend rejects before the message is persisted, so the bubble is a lie
and a refresh / retry would lose the text.
- **Mode 2** — long-running turn interrupted mid-stream: the entire
turn's progress (assistant text, tool calls, reasoning) vanishes on
interruption — what users describe as "the turn is gone."

## Mode 1 — frontend: restore unsent text on 429

Backend can't recover this on its own: `check_rate_limit` raises before
`append_and_save_message`, so by the time the 429 surfaces there is no
DB row to roll forward. See
`autogpt_platform/backend/backend/api/features/chat/routes.py:916-922`
(rate-limit check) and `routes.py:945` (later append-and-save).

Frontend fix in
`autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotStream.ts`:
when `useChat`'s `onError` reports a usage-limit error, we

- drop the optimistic user bubble (DB has no record of it, so leaving it
would be a phantom),
- push `lastSubmittedMsgRef.current` back into the composer via the
existing `setInitialPrompt` slot — the same slot URL pre-fills use, so
`useChatInput`'s `consumeInitialPrompt` effect picks it up
automatically,
- clear `lastSubmittedMsgRef` so the dedup guard doesn't block re-send.

In-memory only; surviving a hard refresh while rate-limited is a
separate follow-up (would need localStorage persistence with TTL).

Test:
`autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/useCopilotStream.test.ts`
— verifies the composer is repopulated and the optimistic bubble is
dropped on a 429.

## Mode 2 — backend: preserve interrupted partial in DB

### Root cause

The SDK retry loop in `stream_chat_completion_sdk` always rolls back
`session.messages` to the pre-attempt watermark on any exception. That
rollback is correct **before a retry** so attempt #2 doesn't duplicate
attempt #1's content. But it runs **before the retry decision is made**,
so when retries are exhausted (or no retry is attempted) the partial
work is discarded too.

Three branches of the retry loop ended in a final-failure state with
side effects worse than just losing the partial:
- `_HandledStreamError` non-transient: rollback then add error marker —
partial gone
- `Exception` with `events_yielded > 0`: rollback then break — **no
error marker added either**, so on refresh the chat looks like nothing
happened even though the user just watched tokens stream live
- `Exception` non-context-non-transient + the while-`else:` exhaustion
path: same, no marker
- Outer except (cancellation, GeneratorExit cleanup): didn't restore
captured partial

### Fix

`autogpt_platform/backend/backend/copilot/sdk/service.py`:

1. **`_InterruptedAttempt` dataclass** — holds the rolled-back `partial:
list[ChatMessage]` + optional `handled_error: _HandledErrorInfo`. Three
methods drive the contract:
- `capture(session, transcript_builder, transcript_snap,
pre_attempt_msg_count)` — slices `session.messages`, restores the
transcript, strips trailing error markers to prevent duplicate markers
after restore.
- `clear()` — drops captured state on a successful retry so outer
cleanup paths don't replay pre-retry content.
- `finalize(session, state, display_msg, retryable=...) ->
list[StreamBaseResponse]` — re-attaches partial, synthesizes
`tool_result` rows for orphan `tool_use` blocks, appends the canonical
error marker, and returns the flushed events so the caller can yield
them to the client (no double-flush).
2. **`_flush_orphan_tool_uses_to_session(session, state) ->
list[StreamBaseResponse]`** — synthesizes `tool_result` rows for any
`tool_use` that never resolved before the error so the next turn's LLM
context stays API-valid (Anthropic rejects orphan tool_use). Uses the
public `adapter.flush_unresolved_tool_calls` and returns the events for
the caller to yield.
3. **`_classify_final_failure(...) -> _FinalFailure | None`** — picks
the display message + stream code + retryable flag for the final-failure
exit. One source of truth for the in-history error marker and the
client-facing `StreamError` SSE yield so they can't drift.
4. **Consolidated post-loop emit**: the former three scattered blocks
(partial restore + redundant re-flush + two separate `yield StreamError`
sites) collapsed to one block driven by `_classify_final_failure` →
`_FinalFailure` → `finalize()` → yield events + single `StreamError`.
5. **Adapter `flush_unresolved_tool_calls`** (renamed from
`_flush_unresolved_tool_calls` to drop the `# noqa: SLF001` suppressors
on cross-module callers).

Each retry-loop rollback site calls `interrupted.capture(...)`; the
success break calls `interrupted.clear()`; the post-loop failure block
calls `interrupted.finalize(...)` exactly once.

The baseline service already preserves partial work via its existing
finally block — no change needed there.

## Tests

Backend (`backend/copilot/sdk/interrupted_partial_test.py`, new, 18
tests):

- `TestInterruptedAttemptCapture` — slice semantics + stale-marker
stripping
- `TestInterruptedAttemptFinalize` — appends partial then marker,
handles empty partial, no-op on `None` session, flushes unresolved tools
between partial and marker, returns flushed events for caller to yield
- `TestFlushOrphanToolUses` — synthesizes `tool_result` rows, returns
events, no-op on None state / no unresolved
- `TestClassifyFinalFailure` — handled_error wins, attempts_exhausted,
transient_exhausted, stream_err fallback, returns None on success path
- `TestRetryRollbackContract` — end-to-end: capture + finalize yields
the exact content the user saw streaming live plus the error marker

1022 total SDK tests pass (baseline + new).

Frontend (`useCopilotStream.test.ts`): 1 new test — `restores the unsent
text and drops the optimistic user bubble on 429 usage-limit`.

## Out of scope

- Frontend rendering tweaks for the interrupted-turn marker (existing
error-marker rendering already works).
- Refresh-survival of the unsent text in Mode 1 (would require
localStorage persistence with TTL) — separate follow-up.
- Hard process-kill / OOM where Python `finally` doesn't run — needs a
different mechanism (pod-level checkpoint sweeper).

## Checklist

- [x] My code follows the style guidelines of this project
(black/isort/ruff via `poetry run format`)
- [x] I have performed a self-review of my own code
- [x] I have added relevant unit tests
- [x] I have run lint and tests locally (1022 SDK tests pass)

## Test plan

- [ ] Verify a long-running turn that hits transient-retry exhaustion
preserves partial assistant text + tool results in chat history after
refresh
- [ ] Verify the next user message after an interrupted turn carries
enough context that the model can continue the prior task instead of
inventing a new one
- [ ] Verify a successful retry (attempt #1 fails, attempt #2 succeeds)
shows ONLY attempt #2's content (no leaked partial from #1)
- [ ] Verify hitting daily usage limit at turn start re-populates the
composer with the unsent text and removes the optimistic user bubble

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-25 14:21:18 +07:00
Zamil Majdy
10ea46663f fix(backend/notifications): atomic upsert + drop eager include (#12919)
## Problem

`create_or_add_to_user_notification_batch` does:
1. `find_unique(..., include={"Notifications": True})` — loads ALL
notifications for the batch (thousands for heavy AGENT_RUN users),
causing Postgres `statement_timeout` in dev.
2. Find-then-update is non-atomic — concurrent invocations either hit
`@@unique([userId, type])` violations or drop notifications.

Real Sentry: `canceling statement due to statement timeout` on this
exact query, traced to `database-manager` pod.

## Fix

Use Prisma `upsert` (atomic) and skip the eager include. Only load
notifications if the caller actually needs them (audited — the sole
caller `NotificationManager._should_batch` ignores the returned DTO and
separately fetches the oldest message via
`get_user_notification_oldest_message_in_batch`).

## Tests

- Single + existing-batch upsert paths
- Concurrent race regression

## Out of scope

Unrelated to PR #12900 (Redis Cluster migration). Separate change.
2026-04-25 13:28:28 +07:00
Zamil Majdy
06188a86a6 refactor(platform/copilot): consolidate 4 model-routing LD flags into 1 JSON flag (#12917)
## What

Replaces 4 string-valued LaunchDarkly flags with a single JSON-valued
flag for copilot model routing:

- ~~`copilot-fast-standard-model`~~
- ~~`copilot-fast-advanced-model`~~
- ~~`copilot-thinking-standard-model`~~
- ~~`copilot-thinking-advanced-model`~~

**New:** `copilot-model-routing` (JSON), keyed `{mode: {tier: model}}`:
```json
{
  "fast":     { "standard": "anthropic/claude-sonnet-4-6", "advanced": "anthropic/claude-opus-4-6" },
  "thinking": { "standard": "moonshotai/kimi-k2.6",         "advanced": "anthropic/claude-opus-4-6" }
}
```

## Why

Same pattern as the sibling consolidation in #12915 (pricing /
cost-limits flags) and the merged #12910 (tier-multipliers):

- One flag per config domain — less LD UI clutter, easier audit trail.
- Atomic updates — rotating fast.standard + thinking.standard is a
single save.
- Fewer LD entities to name, version, target, explain.
- Mirrors the now-uniform copilot-* JSON-flag shape.

## How

- `backend/util/feature_flag.py`: drop the four `COPILOT_*_MODEL` enum
values, add `COPILOT_MODEL_ROUTING`.
- `backend/copilot/model_router.py`: rewrite `resolve_model` to fetch
the JSON flag once per call and walk `payload[mode][tier]`. Missing
mode, missing tier-within-mode, non-string cell value, non-dict payload,
or LD failure all fall back to the corresponding `ChatConfig` default
(same user-visible semantics as before). `_FLAG_BY_CELL` removed
entirely; `_config_default` / `ModelMode` / `ModelTier` unchanged.
- Per-user LD targeting preserved — cohorts can still receive different
routing.
- No caching added (preserves existing uncached behaviour).
- Docstring references in `copilot/config.py` + `copilot/sdk/service.py`
updated to point at the new nested key path; one docstring in
`service_test.py` likewise.

## Operator action required BEFORE merging

This PR removes 4 LD flags and introduces 1 replacement.

1. In LaunchDarkly, create `copilot-model-routing` (type: **JSON**,
server-side only). Default variation = union of the current four string
flags, shaped as:
   ```json
   {
"fast": { "standard": "<current copilot-fast-standard-model>",
"advanced": "<current copilot-fast-advanced-model>" },
"thinking": { "standard": "<current copilot-thinking-standard-model>",
"advanced": "<current copilot-thinking-advanced-model>" }
   }
   ```
Omit any cell that's currently unset (its `ChatConfig` default will be
used).

2. Merge this PR.

3. After deploy + smoke, delete the four legacy flags:
   - `copilot-fast-standard-model`
   - `copilot-fast-advanced-model`
   - `copilot-thinking-standard-model`
   - `copilot-thinking-advanced-model`

## Testing

- `backend/copilot/model_router_test.py` rewritten — 27 tests pass:
  - LD unset / `None` payload → fallback for every cell.
  - Full JSON → each cell maps to its value (parametrized).
- Partial JSON (missing mode, missing tier-within-mode, mode value not a
dict).
  - Non-dict payloads (str / list / int / bool) → fallback + warning.
- Non-string cell values (number, list, bool, dict) → fallback +
'non-string' warning.
- Empty-string cell → fallback + 'empty string' warning (not
'non-string').
  - LD raises → fallback + warning with `exc_info`.
  - `user_id=None` → skip LD entirely.
- Single-LD-call regression guard against re-introducing per-cell flag
fan-out.
- `backend/copilot/sdk/service_test.py`: 61 tests still pass (it mocks
`_resolve_thinking_model_for_user`, so the inner flag change is
transparent).
- `black --check` / `ruff check` / `isort --check` all clean.

## Sibling

- #12915 — same consolidation pattern for stripe-price / cost-limits
flags.

## Checklist

- [x] I have read the project's contributing guide.
- [x] I have clearly described what this PR changes and why.
- [x] My code follows the style guidelines of this project.
- [x] I have added tests that prove my fix is effective or that my
feature works.
- [ ] New and existing unit tests pass locally with my changes (CI will
confirm).
2026-04-25 08:15:36 +07:00
Zamil Majdy
2deac2073e fix(block_cost_config): audit + correct stale LLM/block rates + migrate generic ReplicateModelBlock to COST_USD (#12912)
## Why

PR #12909's pricing refresh was sourced from aggregators (pricepertoken,
blog mirrors) instead of provider pricing pages. Follow-up audit against
**official provider docs** caught **22 stale entries** — 9 LLM token
rates + 12 non-LLM block rates + 1 block that needed a code refactor to
bill dynamically. Also flagged by Sentry: Mistral models were sitting on
the wrong provider's rate table.

Cross-verified JS-rendered pages (docs.x.ai, DeepSeek, Kimi) via
agent-browser.

## Corrections applied

### LLM TOKEN_COST (9 entries)

| Model | Old | New | Reason |
|---|---|---|---|
| `GPT5` | 94/750 | **188/1500** | Was OpenAI Batch API rate; Standard
is $1.25/$10 |
| `DEEPSEEK_CHAT` | 42/63 | **21/42** | Unified to deepseek-v4-flash at
$0.14/$0.28 (Sept 2025) |
| `DEEPSEEK_R1_0528` | 82/329 | **21/42** | Same v4-flash routing |
| `MISTRAL_LARGE_3` | 300/900 | **300/900** (restored after brief 75/225
detour) | Routes via OpenRouter ($2/$6), not Mistral direct |
| `MISTRAL_NEMO` | 3/6 → 23/23 | **5/5** | Routes via OpenRouter
($0.035/$0.035); Mistral-direct $0.15 doesn't apply |
| `KIMI_K2_0905` | 82/330 | **90/375** | Matches K2 family $0.60/$2.50 |
| `KIMI_K2_5` | 90/450 | **66/300** | OpenRouter pass-through $0.44/$2 |
| `KIMI_K2_6` | 143/600 | **112/698** | OpenRouter pass-through
$0.7448/$4.655 |
| `META_LLAMA_4_MAVERICK` | 30/90 | **75/116** | Groq $0.50/$0.77
(deprecated 2026-02-20) |

### Non-LLM BLOCK_COSTS — rate corrections (11 entries)

Under-billing fixes:
- `AIVideoGeneratorBlock` (FAL) SECOND 3 → **15 cr/s**
- `CreateTalkingAvatarVideoBlock` (D-ID) RUN 15 → **100 cr**
- Nano Banana Pro/2 across 3 blocks: RUN 14 → **21 cr**
- `UnrealTextToSpeechBlock` RUN 5 → **COST_USD 150 cr/$** (block now
emits `chars × $0.000016`)

Over-billing fixes:
- `IdeogramModelBlock` default 16 → **12**, V_3 18 → **14**
- `AIImageEditorBlock` FLUX_KONTEXT_MAX 20 → **12**
- `ValidateEmailsBlock` 250 → **150 cr/$**
- `SearchTheWebBlock` 100 → **150 cr/$**
- `GetLinkedinProfilePictureBlock` 3 → **1 cr**

### Non-LLM BLOCK_COSTS — block refactored for dynamic billing (1 entry)

- **`ReplicateModelBlock`** (the generic "run any Replicate model"
wrapper) migrated from flat RUN 10 cr → **COST_USD 150 cr/$**. Block now
uses `client.predictions.async_create + async_wait` instead of
`async_run(wait=False)` so it can read `prediction.metrics.predict_time`
and bill `predict_time × $0.0014/s` (Nvidia L40S mid-tier, where most
popular public models run).

Additionally (addressing CodeRabbit's critical review on this refactor):
`async_wait()` returns normally regardless of terminal status — it
doesn't raise on `failed`/`canceled` like the old `async_run` did. The
block now explicitly checks `prediction.status` after `async_wait()` and
raises `RuntimeError` on `failed` (with `prediction.error` as context)
or `canceled` **before** `merge_stats`, so failed runs are never billed
for partial compute time.

**Why this matters:** flat 10 cr was 10–500× under-billing long
video/LLM runs (users could wire in a $50/hr A100 Llama inference and
pay us $0.10). It was also 20× over-billing trivial SDXL runs. Now
scales with real compute time AND no longer bills failed predictions.

### Documentation-only

- **Grok legacy models** (grok-3, grok-4-0709, grok-4-fast,
grok-code-fast-1): dropped from docs.x.ai's public pricing page but
still callable via the API. Added inline comment noting this; rates kept
at their verified launch pricing.
- **Mistral routing**: added comment explaining why TOKEN_COST for
MISTRAL_* is the OpenRouter safety floor (not Mistral-direct) since
`ModelMetadata.provider = "open_router"` for all Mistral entries.

## How

- For each entry, opened the **official provider pricing page** directly
and computed `our_cr = round(1.5 × provider_usd × 100)`.
- For JS-rendered pages (docs.x.ai, api-docs.deepseek.com), used
agent-browser headless to render + extract rates from the DOM.
- Migrated 2 blocks (`UnrealTextToSpeechBlock`, `ReplicateModelBlock`)
from flat RUN to COST_USD — the Replicate migration touched the block's
SDK interaction.
- Updated 2 FAL-video unit tests that asserted the old `3 cr/s` rate.
- Updated 3 stale test assertions: 2 for Unreal TTS (still on
`characters` cost_type) + 1 for ZeroBounce (old 250 cr).

## Known remaining risk (explicitly out of scope)

- **`ReplicateFluxAdvancedModelBlock`** not migrated — bounded to Flux
models ($0.04–$0.08), flat 10 cr stays within 1.25–2.5× margin. Separate
PR if desired.
- **AgentMail** on free tier (1 RUN). When paid pricing publishes,
revisit.
- **Live Replicate API verification**: mitigated via 9 unit tests
covering the refactored path (`async_create` version-vs-model branching,
metrics-based billing emission, failed/canceled raises,
zero/missing-metrics no-emission, `async_wait` ordering), and SDK
signature confirmed via `inspect.signature` — but no real API call
executed. A smoke test on a cheap model before merge is still
recommended.

## Test plan

- [x] `poetry run pytest backend/data/block_cost_config_test.py
backend/executor/block_usage_cost_test.py
backend/blocks/claude_code_cost_test.py
backend/blocks/cost_leak_fixes_test.py
backend/blocks/block_cost_tracking_test.py
backend/copilot/tools/helpers_test.py
backend/blocks/replicate/replicate_block_cost_test.py -q` — all passing
(80+ tests).
- [x] Sources: openai.com/api/pricing, claude.com/pricing,
api-docs.deepseek.com, mistral.ai/pricing,
platform.kimi.ai/docs/pricing, docs.x.ai, groq.com/pricing,
replicate.com, fal.ai, d-id.com, ideogram.ai, zerobounce.net, jina.ai,
unrealspeech.com, enrichlayer.com.
- [ ] Live Replicate API call to verify `predictions.async_create +
async_wait + metrics.predict_time` path.
2026-04-25 06:10:16 +07:00
Zamil Majdy
24406dfcec refactor(platform): consolidate 6 LD flags into 2 JSON flags (#12915)
## What

Consolidates two groups of LaunchDarkly flags into single JSON-valued
flags, matching the pattern established by `copilot-tier-multipliers`
(merged in #12910):

**Stripe prices** — 4 string flags → 1 JSON flag:
- ~~`stripe-price-id-basic`~~ / ~~`-pro`~~ / ~~`-max`~~ /
~~`-business`~~
- **New:** `copilot-tier-stripe-prices` (JSON)
  ```json
  { "PRO": "price_xxx", "MAX": "price_yyy" }
  ```

**Cost limits** — 2 number flags → 1 JSON flag:
- ~~`copilot-daily-cost-limit-microdollars`~~ /
~~`copilot-weekly-cost-limit-microdollars`~~
- **New:** `copilot-cost-limits` (JSON)
  ```json
  { "daily": 625000, "weekly": 3125000 }
  ```

## Why

- One flag to manage per config domain (LD UI less cluttered, easier
audit trail).
- Atomic updates — e.g., rotating Pro + Max prices happens in a single
save.
- Fewer LD entities to name, version, target, and explain.
- Mirrors the just-merged `copilot-tier-multipliers` shape so the whole
pricing/limits config is uniform.

## How

- `get_subscription_price_id(tier)` now parses
`copilot-tier-stripe-prices` and looks up `tier.value` — returns `None`
when the flag is unset, non-dict, tier key missing, or value isn't a
non-empty string.
- `get_global_rate_limits` uses a new sibling
`_fetch_cost_limits_flag()` helper (60s cache, `cache_none=False`) that
extracts `daily` / `weekly` int keys independently and falls back to the
existing `ChatConfig` defaults when any key is missing / non-int /
negative. A broken `daily` doesn't wipe out `weekly` (or vice versa).
- Tests rewritten to mock the new JSON shapes + cover partial / invalid
/ missing-key fallbacks.

## ⚠️ Operator action required BEFORE merging

This PR **removes 6 LD flags** and introduces 2 replacements. To avoid a
pricing/rate-limit outage, do this in LaunchDarkly first:

1. Create `copilot-tier-stripe-prices` (type: **JSON**). Default
variation = union of the current `stripe-price-id-*` values:
   ```json
{ "PRO": "<current stripe-price-id-pro>", "MAX": "<current
stripe-price-id-max>" }
   ```
   Omit BASIC / BUSINESS if those flags are unset today.

2. Create `copilot-cost-limits` (type: **JSON**). Default variation =
the current two flags' values:
   ```json
{ "daily": <current daily microdollars>, "weekly": <current weekly
microdollars> }
   ```

3. Merge this PR.

4. After deploy + smoke test, delete the six legacy flags:
   - `stripe-price-id-{basic,pro,max,business}`
   - `copilot-daily-cost-limit-microdollars`
   - `copilot-weekly-cost-limit-microdollars`

## Testing

- Backend unit tests: `pytest backend/copilot/rate_limit_test.py
backend/data/credit_subscription_test.py
backend/api/features/subscription_routes_test.py` — rewritten to
exercise the JSON flag shapes + fallback paths; passes locally.
- `black --check` / `ruff check` / `isort --check` — all clean.

## Checklist

- [x] I have read the project's contributing guide.
- [x] I have clearly described what this PR changes and why.
- [x] My code follows the style guidelines of this project.
- [x] I have added tests that prove my fix is effective or that my
feature works.
- [ ] New and existing unit tests pass locally with my changes (CI will
confirm).
2026-04-25 06:08:09 +07:00
Zamil Majdy
000ddb007a dx: use $REPO_ROOT in pr-test skill instead of hardcoded absolute path (#12914)
## Summary
- `.claude/skills/pr-test/SKILL.md` referenced
`/Users/majdyz/Code/AutoGPT/.ign.testing.{lock,log}` in 5 places, which
breaks the skill for anyone else who clones the repo.
- Replaced with `$REPO_ROOT`, which is already defined in Step 0 as `git
-C "$WORKTREE_PATH" worktree list | head -1 | awk '{print $1}'`. That
resolves to the main/primary worktree from any sibling worktree,
preserving the original "always pin the lock to the root checkout so all
siblings see the same file" semantics.
- No behavior change for the existing user; repo becomes portable for
everyone else.

## Test plan
- [x] `grep -n "/Users/majdyz" .claude/skills/pr-test/SKILL.md` returns
only the two intentional mentions in the "never paste absolute paths
into PR comments" warning.
- [x] `$REPO_ROOT` is defined in Step 0 before any Step 3.0 usage.
2026-04-24 23:10:20 +07:00
Zamil Majdy
408b205515 feat(platform): LD-configurable rate-limit multipliers + relative UI display (#12910)
## Summary

- **Backend (`copilot/rate_limit`)** — ``TIER_MULTIPLIERS`` is now
float-typed and resolvable through a new LaunchDarkly flag
``copilot-tier-multipliers``. The integer defaults live on as
``_DEFAULT_TIER_MULTIPLIERS`` and are merged with whatever LD returns
(missing / invalid keys inherit defaults; LD failures fall back to
defaults without raising). ``get_global_rate_limits`` now honours the
flag per-user and casts ``int(base * multiplier)`` so downstream
microdollar math stays integer even when LD hands back a fractional
multiplier (e.g. 8.5×). Cached for 60 s via ``@cached(ttl_seconds=60,
maxsize=8, cache_none=False)`` to match the pattern in
``get_subscription_price_id``.
- **Backend (`api/features/v1`)** — ``SubscriptionStatusResponse`` gains
``tier_multipliers: dict[str, float]``, populated for the same set of
tiers that make it into ``tier_costs`` so hidden tiers never get a
rendered badge.
- **Frontend (`SubscriptionTierSection`)** — drops the hard-coded ``"5x"
/ "20x"`` strings from ``TIERS`` and introduces
``formatRelativeMultiplier(tierKey, tierMultipliers)``: the lowest
*visible* multiplier becomes the baseline (no badge), every other tier
renders ``"N.Nx rate limits"`` relative to it. Fractional LD values like
8.5× round to one decimal.

The admin rate-limit page (``/admin/rate-limits``) keeps the static
``TIER_MULTIPLIERS`` defaults — it's admin-facing, infrequently viewed,
and fine to lag the LD value until next deploy (noted in-code).

Related upstream: this PR stacks logically after #12903 (which added the
``MAX`` tier + LD-configurable prices) but does **not** require it —
each PR can merge in either order. No schema changes, no migration.

## Test plan

- [x] ``poetry run black backend/... --check`` + ``poetry run ruff check
backend/...`` pass
- [x] ``pnpm format`` pass (modified files unchanged)
- [x] New backend tests: ``TestGetTierMultipliers`` (defaults, LD
override, invalid JSON, unknown tier / non-positive values, LD failure)
— **5 / 5 pass**
- [x] New backend test:
``TestGetGlobalRateLimitsWithTiers::test_ld_override_applies_fractional_multiplier``
— **pass**
- [x] ``backend/copilot/rate_limit_test.py`` — non-DB subset **72 / 72
pass**; ``TestGetUserTier`` / ``TestSetUserTier`` require the full
test-server fixture (Redis + Prisma) and are not run in this worktree —
same behaviour on clean ``dev``
- [x] ``backend/api/features/subscription_routes_test.py`` — **40 / 40
pass** (includes new
``test_get_subscription_status_tier_multipliers_ld_override``)
- [x] Frontend vitest targeted suite — **51 / 51 pass**
- ``helpers.test.ts`` — new ``formatRelativeMultiplier`` cases
(lowest-tier null, integer ratio, fractional ratio, hidden-tier null,
fractional LD)
- ``SubscriptionTierSection.test.tsx`` — three new cases for relative
badges, rebasing when the lowest tier is hidden, fractional LD overrides
2026-04-24 22:05:55 +07:00
Zamil Majdy
f8c123a8c3 feat(blocks): dynamic COST_USD billing + close 8 cost-leak surfaces (#12909)
## Why

`ClaudeCodeBlock` was a flat `RUN, 100 cr/run` entry when real cost is
**$0.02–$1.50/run**. Plugging that leak surfaced the question "are other
blocks doing the same?" — an audit found **7 more cost-leak surfaces**.
This PR closes all of them atomically so the cost pipeline is uniform
post-#12894.

## What

### 1. ClaudeCodeBlock → COST_USD 150 cr/$ (the headline)

Claude Code CLI's `--output-format json` already returns
`total_cost_usd` on every call, rolling up Anthropic LLM + internal
tool-call spend. Block now emits it via `merge_stats`:
```python
total_cost_usd = output_data.get("total_cost_usd")
if total_cost_usd is not None:
    self.merge_stats(NodeExecutionStats(
        provider_cost=float(total_cost_usd),
        provider_cost_type="cost_usd",
    ))
```
Registered as `COST_USD, 150 cr/$` — matches the 1.5× margin baked into
every `TOKEN_COST` entry.

### 2. Exa websets — ~40 blocks instrumented

Registered as `COST_USD 100 cr/$` but **never emitted `provider_cost`**
→ ran wallet-free. Added `extract_exa_cost_usd` + `merge_exa_cost`
helpers in `exa/helpers.py` and threaded `merge_exa_cost(self,
response)` through every Exa SDK call across 14 files (59 call sites).
Future-proof: lights up as soon as `exa_py` surfaces `cost_dollars` on
webset response types.

### 3. AIConditionBlock — registered under LLM_COST

Full LLM block with token-count instrumentation already in place, but
**no `BLOCK_COSTS` entry at all** → wallet-free. One-line fix: added to
the LLM_COST group next to AIConversationBlock.

### 4. Pinecone × 3 — added BLOCK_COSTS

- `PineconeInitBlock` + `PineconeQueryBlock`: 1 cr/run RUN (platform
overhead; user pays Pinecone directly).
- `PineconeInsertBlock`: ITEMS scaling with `len(vectors)` emitted via
`merge_stats`.

### 5. Perplexity Sonar (all 3 tiers) → COST_USD 150 cr/$

Block already extracted OpenRouter's `x-total-cost` header into
`execution_stats.provider_cost`; just tagged it `cost_usd` and flipped
the registry. **Deep Research was under-billing up to 30×** ($0.20–$2.00
real vs flat 10 cr).

### 6. CodeGenerationBlock (Codex / GPT-5.1-Codex) → COST_USD 150 cr/$

Block computes USD from `response.usage.input_tokens / output_tokens`
using GPT-5.1-Codex rates ($1.25/M in + $10/M out) and emits `cost_usd`.
Was flat 5 cr for arbitrary-length generations.

### 7. VideoNarrationBlock (ElevenLabs) → COST_USD 150 cr/$

Block computes USD from `len(script) × $0.000167` (Starter tier per-char
price) and emits `cost_usd`. **Was under-billing ~25–30× on long
scripts** (5K-char narration: flat 5 cr vs ~$0.83 real = 125 cr).

### 8. Meeting BaaS FetchMeetingData → COST_USD 150 cr/$

Join block keeps its flat 30 cr commit. FetchMeetingData now extracts
`duration_seconds` from the response metadata, computes USD via
`duration × $0.000192/sec`, and emits `cost_usd`. Long meetings (hours)
no longer fit inside the 30 cr deposit.

## Why 150 cr/$

Matches the **1.5× margin already baked into `TOKEN_COST` for every
direct LLM block**:

| Model | Real | Our rate (per 1M) | Markup |
|---|---|---|---|
| Claude Sonnet 4 | $3/$15 | 450/2250 cr | 1.5× |
| GPT-5 | $2.50/$10 | 375/1500 cr | 1.5× |
| Gemini 2.5 Pro | $1.25/$5 | 187/750 cr | 1.5× |

Applying the same ratio to `total_cost_usd` ≡ `cost_amount=150` (1 cr ≈
$0.01 → 100 cr/$ pass-through × 1.5× = 150).

## Test plan

- [x] **Unit**: new `claude_code_cost_test.py` (9 tests) + existing
`exa/cost_tracking_test.py` (16 tests) + full cost pipeline. **119/119
pass**.
- [x] `poetry run ruff format` + `poetry run ruff check backend/` —
clean.
- [ ] Live E2E: real ClaudeCode / Perplexity Deep Research / Codex run
with balance delta verification (post-merge).

## Follow-ups (not in this PR)

- `exa_py` SDK update to surface `cost_dollars` on Webset response types
(upstream) — unlocks real billing for the 40 webset blocks.
- Replicate suite: migrate per-model RUN entries to COST_USD via
`prediction.metrics["predict_time"] × per-model $/sec`.
2026-04-24 22:05:42 +07:00
Abhimanyu Yadav
34374dfd55 feat(frontend): Settings v2 API keys page (SECRT-2273) (#12907)
### Why / What / How

**Why:** The Settings v2 API keys page was a UI-only stub with 100 mock
rows, a noop "Create Key" button, noop delete buttons, and no
empty/loading states. Users couldn't actually manage their keys from the
new Settings UI. Ships SECRT-2273.

**What:** Replaces the mock with a working page: paginated list
(15/page) with infinite scroll, create flow with one-time plaintext
reveal, single + batch revoke with confirmation dialogs, per-key details
dialog, skeleton loader, animated empty state, toast + mutation-loading
feedback, and responsive header.



https://github.com/user-attachments/assets/bc576de3-0369-4e73-b945-c66c142ebfe5

<img width="397" height="860" alt="Screenshot 2026-04-24 at 11 26 53 AM"
src="https://github.com/user-attachments/assets/ed8681ea-7d16-40cc-96f7-72d798857229"
/>


**How:**
- **Backend** adds a new `GET /api/api-keys/paginated` route returning
`{ items, total_count, page, page_size, has_more }`. The legacy `GET
/api/api-keys` is untouched so the existing profile page keeps working.
The list fn runs `find_many` + `count` in parallel and filters to
`ACTIVE` status by default so revoked keys stay hidden.
- **Frontend** fetches via TanStack Query. Right now the hook consumes
the legacy endpoint with client-side slicing (15/page) so the page works
against staging today; once the paginated route ships we swap to the
generated `useGetV1ListUserApiKeysPaginatedInfinite` hook that's already
in the regenerated client.
- All new UI lives in `src/app/(platform)/settings/api-keys/components/`
— no legacy components reused. Shared primitives (Dialog, Form, Toast,
Skeleton, InfiniteScroll, BaseTooltip) come from the atoms/molecules
design system.
- Empty state uses a vertical marquee of ghost key-cards (framer-motion,
translateY 0→-50% on a duplicated stack, linear easing, symmetric mask
fade). Respects `prefers-reduced-motion`.
- Settings layout ScrollArea switched to `h-full` on mobile and
`md:h-[calc(100vh-60px)]` on desktop to remove a double scrollbar that
appeared when the mobile nav took space above the fixed-height scroll
region.

### Changes 🏗️

**Backend**
- `GET /api/api-keys/paginated` — new route, page + page_size query
params, `ListAPIKeysPaginatedResponse`.
- `list_user_api_keys_paginated` — new data fn, gathers find_many +
count, default ACTIVE-only filter.
- Existing `/api/api-keys` routes untouched.

**Frontend (settings/api-keys)**
- `page.tsx` + `components/APIKeyList/`, `APIKeyRow/`, `APIKeysHeader/`,
`APIKeySelectionBar/` — real-data wiring, drop mock array.
- `components/hooks/` — `useAPIKeysList`, `useCreateAPIKey`,
`useRevokeAPIKey`.
- `components/CreateAPIKeyDialog/` — zod-validated form + success view
with copy.
- `components/DeleteAPIKeyDialog/` — confirm with loading state; single
+ batch.
- `components/APIKeyInfoDialog/` — shows masked key, scopes,
description, created/last_used.
- `components/APIKeyListEmpty/` +
`APIKeyListEmpty/components/APIKeyMarquee.tsx` — animated empty state.
- `components/APIKeyListSkeleton/` — 6-row skeleton.

**Other**
- `settings/layout.tsx` — responsive ScrollArea height (fixes
double-scrollbar on mobile).
- `components/ui/scroll-area.tsx` — optional `showScrollToTop` FAB.
- `__tests__/placeholder-pages.test.tsx` — drop api-keys from
placeholder list.
- `AGENTS.md` — Phosphor `-Icon` suffix convention note.
- `api/openapi.json` — regenerated with new paginated endpoint.

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [ ] I have tested my changes according to the test plan:
  - [ ] Page loads → skeleton → list with real keys
- [ ] Empty state renders with the vertical marquee (and stays static
with `prefers-reduced-motion`)
- [ ] Create key dialog: name + description + permissions validates;
success view shows plaintext once + copy works; closing resets state
- [ ] Revoke single key via row trash icon → confirm dialog → toast on
success → row disappears
  - [ ] Batch-revoke via selection bar → confirm dialog → all revoked
- [ ] Info icon next to each key opens the details dialog (scopes,
timestamps, masked key)
- [ ] Infinite scroll loads more rows when scrolling past page 1 (≥16
keys)
- [ ] Mobile (<640px): single scrollbar, Create Key button below title
at size=small
- [ ] Desktop (md+): same layout as before, scroll-to-top FAB appears
after scrolling

#### For configuration changes:
- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)
2026-04-24 14:08:18 +00:00
441 changed files with 39910 additions and 3874 deletions

View File

@@ -186,7 +186,7 @@ Multiple worktrees share the same host — Docker infra (postgres, redis, clamav
### Lock file contract
Path (**always** the root worktree so all siblings see it): `/Users/majdyz/Code/AutoGPT/.ign.testing.lock`
Path (**always** the root worktree so all siblings see it): `$REPO_ROOT/.ign.testing.lock`
Body (one `key=value` per line):
```
@@ -202,7 +202,7 @@ intent=<one-line description + rough duration>
### Claim
```bash
LOCK=/Users/majdyz/Code/AutoGPT/.ign.testing.lock
LOCK=$REPO_ROOT/.ign.testing.lock
NOW=$(date -u +%Y-%m-%dT%H:%MZ)
STALE_AFTER_MIN=5
@@ -252,7 +252,7 @@ echo "$HEARTBEAT_PID" > /tmp/pr-test-heartbeat.pid
kill "$HEARTBEAT_PID" 2>/dev/null
rm -f "$LOCK" /tmp/pr-test-heartbeat.pid
echo "$(date -u +%Y-%m-%dT%H:%MZ) [pr-${PR_NUMBER}] released lock" \
>> /Users/majdyz/Code/AutoGPT/.ign.testing.log
>> $REPO_ROOT/.ign.testing.log
```
Use a `trap` so release runs even on `exit 1`:
@@ -278,7 +278,7 @@ Concretely, the sequence at the end of every `/pr-test` run (success or failure)
kill "$HEARTBEAT_PID" 2>/dev/null
rm -f "$LOCK" /tmp/pr-test-heartbeat.pid
echo "$(date -u +%Y-%m-%dT%H:%MZ) [pr-${PR_NUMBER}] released lock (app may still be running)" \
>> /Users/majdyz/Code/AutoGPT/.ign.testing.log
>> $REPO_ROOT/.ign.testing.log
# 3. Optionally leave the app running and note it so the user knows:
echo "Native stack still running on :3000 / :8006 for manual poking. Kill with:"
echo " pkill -9 -f 'poetry run app'; pkill -9 -f 'next-server|next dev'"
@@ -288,10 +288,10 @@ If a sibling agent's `/pr-test` needs to take over, it'll do the kill+rebuild da
### Shared status log
`/Users/majdyz/Code/AutoGPT/.ign.testing.log` is an append-only channel any agent can read/write. Use it for "I'm waiting", "I'm done, resources free", or post-run notes:
`$REPO_ROOT/.ign.testing.log` is an append-only channel any agent can read/write. Use it for "I'm waiting", "I'm done, resources free", or post-run notes:
```bash
echo "$(date -u +%Y-%m-%dT%H:%MZ) [pr-${PR_NUMBER}] <message>" \
>> /Users/majdyz/Code/AutoGPT/.ign.testing.log
>> $REPO_ROOT/.ign.testing.log
```
## Step 3: Environment setup

View File

@@ -119,10 +119,12 @@ jobs:
runs-on: ubuntu-latest
services:
redis:
image: redis:latest
ports:
- 6379:6379
# Redis is provisioned as a real 3-shard cluster below via docker
# run (see the "Start Redis Cluster" step). GHA services can't
# override the image CMD or stand up multi-container clusters, so
# that setup is inlined — it mirrors the topology of the local dev
# compose stack (autogpt_platform/docker-compose.platform.yml) and
# prod helm chart.
rabbitmq:
image: rabbitmq:4.1.4
ports:
@@ -166,6 +168,68 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
- name: Start Redis Cluster (3 shards)
run: |
# 3-master Redis Cluster matching the local compose stack
# (autogpt_platform/docker-compose.platform.yml) and prod. Each
# shard runs in its own container on a dedicated bridge network,
# announces its compose-style hostname for intra-network clients,
# and publishes 1700N on the GHA host so tests can reach every
# shard via localhost. The backend's ``_address_remap`` rewrites
# every CLUSTER SLOTS reply to localhost:<announced-port>, which
# picks the right published port per shard.
#
# Not reusing docker-compose.platform.yml directly because compose
# validates the full file even when only some services are ``up``,
# and that file references services (db/kong/...) defined in a
# sibling compose file — pulling both in would needlessly couple
# CI to the full local-dev stack.
docker network create redis-cluster-ci
for i in 0 1 2; do
port=$((17000 + i))
bus=$((27000 + i))
docker run -d --name redis-$i --network redis-cluster-ci \
--network-alias redis-$i \
-p $port:$port \
redis:7 \
redis-server --port $port \
--cluster-enabled yes \
--cluster-config-file nodes.conf \
--cluster-node-timeout 5000 \
--cluster-require-full-coverage no \
--cluster-announce-hostname redis-$i \
--cluster-announce-port $port \
--cluster-announce-bus-port $bus \
--cluster-preferred-endpoint-type hostname
done
# Wait for each shard to accept commands.
for i in 0 1 2; do
port=$((17000 + i))
for _ in $(seq 1 30); do
docker exec redis-$i redis-cli -p $port ping 2>/dev/null | grep -q PONG && break
sleep 1
done
done
# Form the cluster from an init container on the same network so
# --cluster-preferred-endpoint-type hostname resolves redis-0/1/2.
docker run --rm --network redis-cluster-ci redis:7 \
redis-cli --cluster create \
redis-0:17000 redis-1:17001 redis-2:17002 \
--cluster-replicas 0 --cluster-yes
# Confirm convergence.
for _ in $(seq 1 30); do
state=$(docker exec redis-0 redis-cli -p 17000 cluster info | awk -F: '/^cluster_state:/ {print $2}' | tr -d '[:cntrl:]')
if [ "$state" = "ok" ]; then
echo "Redis Cluster ready (3 shards, state=ok)"
docker exec redis-0 redis-cli -p 17000 cluster nodes
exit 0
fi
sleep 1
done
echo "Redis Cluster failed to reach ok state" >&2
docker exec redis-0 redis-cli -p 17000 cluster info >&2 || true
exit 1
- name: Setup Supabase
uses: supabase/setup-cli@v1
with:
@@ -286,8 +350,13 @@ jobs:
SUPABASE_SERVICE_ROLE_KEY: ${{ steps.supabase.outputs.SERVICE_ROLE_KEY }}
JWT_VERIFY_KEY: ${{ steps.supabase.outputs.JWT_SECRET }}
REDIS_HOST: "localhost"
REDIS_PORT: "6379"
REDIS_PORT: "17000"
ENCRYPTION_KEY: "dvziYgz0KSK8FENhju0ZYi8-fRTfAdlz6YLhdB_jhNw=" # DO NOT USE IN PRODUCTION!!
# Opt-in: lets backend/data/e2e_redis_restart_test.py spin up its
# own isolated 3-shard cluster (ports 2711027112) and exercise
# ``docker restart <shard>`` mid-stream. Off locally so a
# contributor's ``poetry run test`` doesn't pay the ~15s cost.
E2E_RESTART_ISOLATED: "1"
- name: Upload coverage reports to Codecov
if: ${{ !cancelled() }}

4
.gitignore vendored
View File

@@ -196,3 +196,7 @@ test.db
plans/
.claude/worktrees/
test-results/
# Playwright MCP / local browser-testing artifacts
.playwright-mcp/
copilot-session-switch-qa/

View File

@@ -267,7 +267,7 @@
"filename": "autogpt_platform/backend/backend/blocks/replicate/replicate_block.py",
"hashed_secret": "8bbdd6f26368f58ea4011d13d7f763cb662e66f0",
"is_verified": false,
"line_number": 55
"line_number": 67
}
],
"autogpt_platform/backend/backend/blocks/slant3d/webhook.py": [
@@ -467,5 +467,5 @@
}
]
},
"generated_at": "2026-04-09T14:20:23Z"
"generated_at": "2026-04-24T16:42:44Z"
}

View File

@@ -1,33 +0,0 @@
from typing import Optional
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class RateLimitSettings(BaseSettings):
redis_host: str = Field(
default="redis://localhost:6379",
description="Redis host",
validation_alias="REDIS_HOST",
)
redis_port: str = Field(
default="6379", description="Redis port", validation_alias="REDIS_PORT"
)
redis_password: Optional[str] = Field(
default=None,
description="Redis password",
validation_alias="REDIS_PASSWORD",
)
requests_per_minute: int = Field(
default=60,
description="Maximum number of requests allowed per minute per API key",
validation_alias="RATE_LIMIT_REQUESTS_PER_MINUTE",
)
model_config = SettingsConfigDict(case_sensitive=True, extra="ignore")
RATE_LIMIT_SETTINGS = RateLimitSettings()

View File

@@ -1,51 +0,0 @@
import time
from typing import Tuple
from redis import Redis
from .config import RATE_LIMIT_SETTINGS
class RateLimiter:
def __init__(
self,
redis_host: str = RATE_LIMIT_SETTINGS.redis_host,
redis_port: str = RATE_LIMIT_SETTINGS.redis_port,
redis_password: str | None = RATE_LIMIT_SETTINGS.redis_password,
requests_per_minute: int = RATE_LIMIT_SETTINGS.requests_per_minute,
):
self.redis = Redis(
host=redis_host,
port=int(redis_port),
password=redis_password,
decode_responses=True,
)
self.window = 60
self.max_requests = requests_per_minute
async def check_rate_limit(self, api_key_id: str) -> Tuple[bool, int, int]:
"""
Check if request is within rate limits.
Args:
api_key_id: The API key identifier to check
Returns:
Tuple of (is_allowed, remaining_requests, reset_time)
"""
now = time.time()
window_start = now - self.window
key = f"ratelimit:{api_key_id}:1min"
pipe = self.redis.pipeline()
pipe.zremrangebyscore(key, 0, window_start)
pipe.zadd(key, {str(now): now})
pipe.zcount(key, window_start, now)
pipe.expire(key, self.window)
_, _, request_count, _ = pipe.execute()
remaining = max(0, self.max_requests - request_count)
reset_time = int(now + self.window)
return request_count <= self.max_requests, remaining, reset_time

View File

@@ -1,32 +0,0 @@
from fastapi import HTTPException, Request
from starlette.middleware.base import RequestResponseEndpoint
from .limiter import RateLimiter
async def rate_limit_middleware(request: Request, call_next: RequestResponseEndpoint):
"""FastAPI middleware for rate limiting API requests."""
limiter = RateLimiter()
if not request.url.path.startswith("/api"):
return await call_next(request)
api_key = request.headers.get("Authorization")
if not api_key:
return await call_next(request)
api_key = api_key.replace("Bearer ", "")
is_allowed, remaining, reset_time = await limiter.check_rate_limit(api_key)
if not is_allowed:
raise HTTPException(
status_code=429, detail="Rate limit exceeded. Please try again later."
)
response = await call_next(request)
response.headers["X-RateLimit-Limit"] = str(limiter.max_requests)
response.headers["X-RateLimit-Remaining"] = str(remaining)
response.headers["X-RateLimit-Reset"] = str(reset_time)
return response

View File

@@ -1,13 +1,16 @@
import asyncio
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Union
from expiringdict import ExpiringDict
if TYPE_CHECKING:
from redis.asyncio import Redis as AsyncRedis
from redis.asyncio.cluster import RedisCluster as AsyncRedisCluster
from redis.asyncio.lock import Lock as AsyncRedisLock
AsyncRedisLike = Union[AsyncRedis, AsyncRedisCluster]
class AsyncRedisKeyedMutex:
"""
@@ -17,7 +20,7 @@ class AsyncRedisKeyedMutex:
in case the key is not unlocked for a specified duration, to prevent memory leaks.
"""
def __init__(self, redis: "AsyncRedis", timeout: int | None = 60):
def __init__(self, redis: "AsyncRedisLike", timeout: int | None = 60):
self.redis = redis
self.timeout = timeout
self.locks: dict[Any, "AsyncRedisLock"] = ExpiringDict(

View File

@@ -37,6 +37,23 @@ JWT_VERIFY_KEY=your-super-secret-jwt-token-with-at-least-32-characters-long
ENCRYPTION_KEY=dvziYgz0KSK8FENhju0ZYi8-fRTfAdlz6YLhdB_jhNw=
UNSUBSCRIBE_SECRET_KEY=HlP8ivStJjmbf6NKi78m_3FnOogut0t5ckzjsIqeaio=
# Web Push (VAPID) — generate with: poetry run python -c "
# from py_vapid import Vapid; import base64
# from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
# v = Vapid(); v.generate_keys()
# raw_priv = v.private_key.private_numbers().private_value.to_bytes(32, 'big')
# print('VAPID_PRIVATE_KEY=' + base64.urlsafe_b64encode(raw_priv).rstrip(b'=').decode())
# raw_pub = v.public_key.public_bytes(Encoding.X962, PublicFormat.UncompressedPoint)
# print('VAPID_PUBLIC_KEY=' + base64.urlsafe_b64encode(raw_pub).rstrip(b'=').decode())
# "
# Dev-only keypair below — DO NOT use in staging/production. Regenerate
# your own with the snippet above before any non-local deployment.
VAPID_PRIVATE_KEY=17hBPdSdn6TR_yAgQxA0TjTcvRj3Lf6znHnASZ4rOKc
VAPID_PUBLIC_KEY=BBg49iVTWthVbRYphwmZNvZyiSJDqtSO4nmLxDzLKe3Oo9jbtu0Usa14xX4HQQNLUeiEfzD42zWSlrvY1PR12bs
# Per RFC 8292 push services use this in 410 Gone reports; set to a real
# mailbox in production. Defaults to a placeholder for local dev.
VAPID_CLAIM_EMAIL=mailto:dev@example.com
## ===== IMPORTANT OPTIONAL CONFIGURATION ===== ##
# Platform URLs (set these for webhooks and OAuth to work)
PLATFORM_BASE_URL=http://localhost:8000
@@ -182,6 +199,10 @@ GOOGLE_MAPS_API_KEY=
# Platform Bot Linking
PLATFORM_LINK_BASE_URL=http://localhost:3000/link
# CoPilot chat-platform bridge (Discord/Telegram/Slack)
# Uses FRONTEND_BASE_URL (above) for link confirmation pages.
AUTOPILOT_BOT_DISCORD_TOKEN=
# Communication Services
DISCORD_BOT_TOKEN=
MEDIUM_API_KEY=

View File

@@ -1,14 +1,44 @@
import asyncio
from typing import Dict, Set
import json
import logging
import time
from typing import Awaitable, Callable, Dict, Optional, Set
from fastapi import WebSocket
from fastapi import WebSocket, WebSocketDisconnect
from redis.asyncio import Redis as AsyncRedis
from redis.asyncio.client import PubSub as AsyncPubSub
from redis.exceptions import MovedError, RedisError, ResponseError
from starlette.websockets import WebSocketState
from backend.api.model import NotificationPayload, WSMessage, WSMethod
from backend.api.model import WSMessage, WSMethod
from backend.data import redis_client as redis
from backend.data.event_bus import _assert_no_wildcard
from backend.data.execution import (
ExecutionEventType,
GraphExecutionEvent,
NodeExecutionEvent,
exec_channel,
get_graph_execution_meta,
graph_all_channel,
)
from backend.data.notification_bus import NotificationEvent
from backend.util.settings import Settings
logger = logging.getLogger(__name__)
_settings = Settings()
def _is_ws_close_race(exc: BaseException, websocket: WebSocket) -> bool:
"""A SPUBLISH→WS send racing with WS close — benign, drop quietly."""
if isinstance(exc, WebSocketDisconnect):
return True
if (
getattr(websocket, "application_state", None) == WebSocketState.DISCONNECTED
or getattr(websocket, "client_state", None) == WebSocketState.DISCONNECTED
):
return True
if isinstance(exc, RuntimeError) and "close message has been sent" in str(exc):
return True
return False
_EVENT_TYPE_TO_METHOD_MAP: dict[ExecutionEventType, WSMethod] = {
ExecutionEventType.GRAPH_EXEC_UPDATE: WSMethod.GRAPH_EXECUTION_EVENT,
@@ -16,128 +46,379 @@ _EVENT_TYPE_TO_METHOD_MAP: dict[ExecutionEventType, WSMethod] = {
}
def event_bus_channel(channel_key: str) -> str:
"""Prefix a channel key with the execution event bus name."""
return f"{_settings.config.execution_event_bus_name}/{channel_key}"
def _notification_bus_channel(user_id: str) -> str:
"""Return the full sharded channel name for a user's notifications."""
return f"{_settings.config.notification_event_bus_name}/{user_id}"
MessageHandler = Callable[[Optional[bytes | str]], Awaitable[None]]
def _is_moved_error(exc: BaseException) -> bool:
"""A MOVED redirect — slot migration mid-stream; pump should reconnect."""
if isinstance(exc, MovedError):
return True
if isinstance(exc, ResponseError) and str(exc).startswith("MOVED "):
return True
return False
# Reconnect tunables for shard-failover during pubsub.listen().
_PUMP_RECONNECT_DEADLINE_S = 60.0
_PUMP_RECONNECT_BACKOFF_INITIAL_S = 0.5
_PUMP_RECONNECT_BACKOFF_MAX_S = 8.0
class _Subscription:
"""One SSUBSCRIBE lifecycle bound to a WebSocket, pinned to the owning shard."""
def __init__(self, full_channel: str) -> None:
_assert_no_wildcard(full_channel)
self.full_channel = full_channel
self._client: AsyncRedis | None = None
self._pubsub: AsyncPubSub | None = None
self._task: asyncio.Task | None = None
async def start(self, on_message: MessageHandler) -> None:
await self._open_pubsub()
self._task = asyncio.create_task(self._pump(on_message))
async def _open_pubsub(self) -> None:
"""(Re)establish the sharded pubsub connection + SSUBSCRIBE."""
self._client = await redis.connect_sharded_pubsub_async(self.full_channel)
self._pubsub = self._client.pubsub()
await self._pubsub.execute_command("SSUBSCRIBE", self.full_channel)
# redis-py 6.x async PubSub.listen() exits when ``channels`` is
# empty; raw SSUBSCRIBE doesn't populate it, so do it ourselves.
self._pubsub.channels[self.full_channel] = None # type: ignore[index]
async def _close_pubsub_quietly(self) -> None:
"""Best-effort teardown before reconnect — never raises."""
if self._pubsub is not None:
try:
await self._pubsub.aclose()
except Exception:
pass
self._pubsub = None
if self._client is not None:
try:
await self._client.aclose()
except Exception:
pass
self._client = None
async def _pump(self, on_message: MessageHandler) -> None:
if self._pubsub is None:
return
backoff = _PUMP_RECONNECT_BACKOFF_INITIAL_S
deadline = time.monotonic() + _PUMP_RECONNECT_DEADLINE_S
while True:
pubsub = self._pubsub
if pubsub is None:
return
needs_reconnect = False
try:
async for message in pubsub.listen():
msg_type = message.get("type")
# Server-pushed sunsubscribe: slot ownership changed and
# Redis revoked our SSUBSCRIBE without dropping the TCP.
# Treat as a reconnect trigger so we re-resolve the shard.
if msg_type == "sunsubscribe":
needs_reconnect = True
break
if msg_type not in ("smessage", "message", "pmessage"):
continue
# Successful read resets the reconnect budget.
backoff = _PUMP_RECONNECT_BACKOFF_INITIAL_S
deadline = time.monotonic() + _PUMP_RECONNECT_DEADLINE_S
try:
await on_message(message.get("data"))
except Exception:
logger.exception(
"Websocket message-handler failed for channel %s",
self.full_channel,
)
if not needs_reconnect:
# listen() exited cleanly (channels emptied) — pump is done.
return
except asyncio.CancelledError:
raise
except (ConnectionError, RedisError) as exc:
if isinstance(exc, ResponseError) and not _is_moved_error(exc):
logger.exception(
"Pubsub pump crashed on non-retryable ResponseError for %s",
self.full_channel,
)
return
if time.monotonic() > deadline:
logger.exception(
"Pubsub pump giving up after reconnect deadline for %s",
self.full_channel,
)
return
logger.warning(
"Pubsub pump reconnecting for %s after %s: %s",
self.full_channel,
type(exc).__name__,
exc,
)
except Exception:
logger.exception("Pubsub pump crashed for %s", self.full_channel)
return
# Either a retryable error was raised, or the server pushed a
# sunsubscribe — close the stale pubsub and reopen against the
# (possibly migrated) shard.
await self._close_pubsub_quietly()
await asyncio.sleep(backoff)
backoff = min(backoff * 2, _PUMP_RECONNECT_BACKOFF_MAX_S)
try:
await self._open_pubsub()
except (ConnectionError, RedisError) as reopen_exc:
logger.warning(
"Pubsub pump reopen failed for %s: %s",
self.full_channel,
reopen_exc,
)
# Loop again — deadline check will eventually exit.
continue
async def stop(self) -> None:
if self._task is not None:
self._task.cancel()
try:
await self._task
except (asyncio.CancelledError, Exception):
pass
self._task = None
if self._pubsub is not None:
try:
await self._pubsub.execute_command("SUNSUBSCRIBE", self.full_channel)
except Exception:
logger.warning(
"SUNSUBSCRIBE failed for %s", self.full_channel, exc_info=True
)
try:
await self._pubsub.aclose()
except Exception:
pass
self._pubsub = None
if self._client is not None:
try:
await self._client.aclose()
except Exception:
pass
self._client = None
class ConnectionManager:
def __init__(self):
self.active_connections: Set[WebSocket] = set()
# channel_key → sockets subscribed (public channel keys, not raw Redis channels)
self.subscriptions: Dict[str, Set[WebSocket]] = {}
self.user_connections: Dict[str, Set[WebSocket]] = {}
# websocket → {channel_key: _Subscription}
self._ws_subs: Dict[WebSocket, Dict[str, _Subscription]] = {}
# websocket → notification subscription
self._ws_notifications: Dict[WebSocket, _Subscription] = {}
async def connect_socket(self, websocket: WebSocket, *, user_id: str):
await websocket.accept()
self.active_connections.add(websocket)
if user_id not in self.user_connections:
self.user_connections[user_id] = set()
self.user_connections[user_id].add(websocket)
self._ws_subs.setdefault(websocket, {})
await self._start_notification_subscription(websocket, user_id=user_id)
def disconnect_socket(self, websocket: WebSocket, *, user_id: str):
async def disconnect_socket(self, websocket: WebSocket, *, user_id: str):
self.active_connections.discard(websocket)
for subscribers in self.subscriptions.values():
# Stop SSUBSCRIBE pumps before dropping bookkeeping to avoid leaks.
subs = self._ws_subs.pop(websocket, {})
for sub in subs.values():
await sub.stop()
notif_sub = self._ws_notifications.pop(websocket, None)
if notif_sub is not None:
await notif_sub.stop()
for channel_key, subscribers in list(self.subscriptions.items()):
subscribers.discard(websocket)
user_conns = self.user_connections.get(user_id)
if user_conns is not None:
user_conns.discard(websocket)
if not user_conns:
self.user_connections.pop(user_id, None)
if not subscribers:
self.subscriptions.pop(channel_key, None)
async def subscribe_graph_exec(
self, *, user_id: str, graph_exec_id: str, websocket: WebSocket
) -> str:
return await self._subscribe(
_graph_exec_channel_key(user_id, graph_exec_id=graph_exec_id), websocket
# Hash-tagged channel needs graph_id; resolve once per subscribe.
meta = await get_graph_execution_meta(user_id, graph_exec_id)
if meta is None:
raise ValueError(
f"graph_exec #{graph_exec_id} not found for user #{user_id}"
)
channel_key = graph_exec_channel_key(user_id, graph_exec_id=graph_exec_id)
full_channel = event_bus_channel(
exec_channel(user_id, meta.graph_id, graph_exec_id)
)
await self._open_subscription(websocket, channel_key, full_channel)
return channel_key
async def subscribe_graph_execs(
self, *, user_id: str, graph_id: str, websocket: WebSocket
) -> str:
return await self._subscribe(
_graph_execs_channel_key(user_id, graph_id=graph_id), websocket
)
channel_key = _graph_execs_channel_key(user_id, graph_id=graph_id)
full_channel = event_bus_channel(graph_all_channel(user_id, graph_id))
await self._open_subscription(websocket, channel_key, full_channel)
return channel_key
async def unsubscribe_graph_exec(
self, *, user_id: str, graph_exec_id: str, websocket: WebSocket
) -> str | None:
return await self._unsubscribe(
_graph_exec_channel_key(user_id, graph_exec_id=graph_exec_id), websocket
)
channel_key = graph_exec_channel_key(user_id, graph_exec_id=graph_exec_id)
return await self._close_subscription(websocket, channel_key)
async def unsubscribe_graph_execs(
self, *, user_id: str, graph_id: str, websocket: WebSocket
) -> str | None:
return await self._unsubscribe(
_graph_execs_channel_key(user_id, graph_id=graph_id), websocket
)
channel_key = _graph_execs_channel_key(user_id, graph_id=graph_id)
return await self._close_subscription(websocket, channel_key)
async def send_execution_update(
self, exec_event: GraphExecutionEvent | NodeExecutionEvent
) -> int:
graph_exec_id = (
exec_event.id
if isinstance(exec_event, GraphExecutionEvent)
else exec_event.graph_exec_id
)
async def _open_subscription(
self, websocket: WebSocket, channel_key: str, full_channel: str
) -> None:
self.subscriptions.setdefault(channel_key, set()).add(websocket)
per_ws = self._ws_subs.setdefault(websocket, {})
if channel_key in per_ws:
return
sub = _Subscription(full_channel)
n_sent = 0
async def on_message(data: Optional[bytes | str]) -> None:
await self._forward_exec_event(websocket, channel_key, data)
channels: set[str] = {
# Send update to listeners for this graph execution
_graph_exec_channel_key(exec_event.user_id, graph_exec_id=graph_exec_id)
}
if isinstance(exec_event, GraphExecutionEvent):
# Send update to listeners for all executions of this graph
channels.add(
_graph_execs_channel_key(
exec_event.user_id, graph_id=exec_event.graph_id
)
)
await sub.start(on_message)
per_ws[channel_key] = sub
for channel in channels.intersection(self.subscriptions.keys()):
message = WSMessage(
method=_EVENT_TYPE_TO_METHOD_MAP[exec_event.event_type],
channel=channel,
data=exec_event.model_dump(),
).model_dump_json()
for connection in self.subscriptions[channel]:
await connection.send_text(message)
n_sent += 1
return n_sent
async def send_notification(
self, *, user_id: str, payload: NotificationPayload
) -> int:
"""Send a notification to all websocket connections belonging to a user."""
message = WSMessage(
method=WSMethod.NOTIFICATION,
data=payload.model_dump(),
).model_dump_json()
connections = tuple(self.user_connections.get(user_id, set()))
if not connections:
return 0
await asyncio.gather(
*(connection.send_text(message) for connection in connections),
return_exceptions=True,
)
return len(connections)
async def _subscribe(self, channel_key: str, websocket: WebSocket) -> str:
if channel_key not in self.subscriptions:
self.subscriptions[channel_key] = set()
self.subscriptions[channel_key].add(websocket)
async def _close_subscription(
self, websocket: WebSocket, channel_key: str
) -> str | None:
subscribers = self.subscriptions.get(channel_key)
if subscribers is None:
return None
subscribers.discard(websocket)
if not subscribers:
self.subscriptions.pop(channel_key, None)
per_ws = self._ws_subs.get(websocket)
if per_ws and channel_key in per_ws:
sub = per_ws.pop(channel_key)
await sub.stop()
return channel_key
async def _unsubscribe(self, channel_key: str, websocket: WebSocket) -> str | None:
if channel_key in self.subscriptions:
self.subscriptions[channel_key].discard(websocket)
if not self.subscriptions[channel_key]:
del self.subscriptions[channel_key]
return channel_key
return None
async def _forward_exec_event(
self,
websocket: WebSocket,
channel_key: str,
raw_payload: Optional[bytes | str],
) -> None:
if raw_payload is None:
return
# Unwrap the `_EventPayloadWrapper` envelope, then re-wrap as a WS message.
try:
wrapper = (
raw_payload.decode()
if isinstance(raw_payload, (bytes, bytearray))
else raw_payload
)
except Exception:
logger.warning(
"Failed to decode pubsub payload on %s", channel_key, exc_info=True
)
return
try:
parsed = json.loads(wrapper)
event_data = parsed.get("payload")
if not isinstance(event_data, dict):
return
event_type = event_data.get("event_type")
method = _EVENT_TYPE_TO_METHOD_MAP.get(ExecutionEventType(event_type))
if method is None:
return
message = WSMessage(
method=method,
channel=channel_key,
data=event_data,
).model_dump_json()
await websocket.send_text(message)
except Exception as e:
if _is_ws_close_race(e, websocket):
logger.debug("Dropped exec event on closed WS for %s", channel_key)
return
logger.exception("Failed to forward exec event on %s", channel_key)
async def _start_notification_subscription(
self, websocket: WebSocket, *, user_id: str
) -> None:
full_channel = _notification_bus_channel(user_id)
sub = _Subscription(full_channel)
async def on_message(data: Optional[bytes | str]) -> None:
await self._forward_notification(websocket, user_id, data)
try:
await sub.start(on_message)
except Exception:
logger.exception(
"Failed to open notification SSUBSCRIBE for user=%s", user_id
)
return
self._ws_notifications[websocket] = sub
async def _forward_notification(
self,
websocket: WebSocket,
user_id: str,
raw_payload: Optional[bytes | str],
) -> None:
if raw_payload is None:
return
try:
wrapper_json = (
raw_payload.decode()
if isinstance(raw_payload, (bytes, bytearray))
else raw_payload
)
parsed = json.loads(wrapper_json)
inner = parsed.get("payload") if isinstance(parsed, dict) else None
if not isinstance(inner, dict):
return
event = NotificationEvent.model_validate(inner)
except Exception:
logger.warning(
"Failed to parse notification payload for user=%s",
user_id,
exc_info=True,
)
return
# Defense in depth against cross-user payloads.
if event.user_id != user_id:
return
message = WSMessage(
method=WSMethod.NOTIFICATION,
data=event.payload.model_dump(),
).model_dump_json()
try:
await websocket.send_text(message)
except Exception as e:
if _is_ws_close_race(e, websocket):
logger.debug("Dropped notification on closed WS for user=%s", user_id)
return
logger.warning(
"Failed to deliver notification to WS for user=%s",
user_id,
exc_info=True,
)
def _graph_exec_channel_key(user_id: str, *, graph_exec_id: str) -> str:
def graph_exec_channel_key(user_id: str, *, graph_exec_id: str) -> str:
return f"{user_id}|graph_exec#{graph_exec_id}"

View File

@@ -0,0 +1,386 @@
"""ConnectionManager integration over the live 3-shard Redis cluster:
SSUBSCRIBE → SPUBLISH → WebSocket forwarding with no Redis mocks. Skips
when the cluster is unreachable."""
import asyncio
import json
from datetime import datetime, timezone
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from fastapi import WebSocket
import backend.data.redis_client as redis_client
from backend.api.conn_manager import (
ConnectionManager,
_graph_execs_channel_key,
event_bus_channel,
graph_exec_channel_key,
)
from backend.api.model import WSMethod
from backend.data.execution import (
ExecutionStatus,
GraphExecutionEvent,
GraphExecutionMeta,
NodeExecutionEvent,
exec_channel,
graph_all_channel,
)
def _has_live_cluster() -> bool:
try:
c = redis_client.connect()
except Exception: # noqa: BLE001 — any connect failure → skip
return False
try:
c.close()
except Exception:
pass
return True
pytestmark = pytest.mark.skipif(
not _has_live_cluster(),
reason="local redis cluster not reachable; skip conn_manager integration",
)
def _meta(user_id: str, graph_id: str, graph_exec_id: str) -> GraphExecutionMeta:
"""Build a minimal GraphExecutionMeta for ``subscribe_graph_exec`` to use."""
return GraphExecutionMeta(
id=graph_exec_id,
user_id=user_id,
graph_id=graph_id,
graph_version=1,
inputs=None,
credential_inputs=None,
nodes_input_masks=None,
preset_id=None,
status=ExecutionStatus.RUNNING,
started_at=datetime.now(tz=timezone.utc),
ended_at=None,
stats=GraphExecutionMeta.Stats(),
)
def _node_event_payload(
*, user_id: str, graph_id: str, graph_exec_id: str, marker: str
) -> bytes:
"""Wire-format a NodeExecutionEvent the way RedisExecutionEventBus would."""
inner = NodeExecutionEvent(
user_id=user_id,
graph_id=graph_id,
graph_version=1,
graph_exec_id=graph_exec_id,
node_exec_id=f"node-exec-{marker}",
node_id="node-1",
block_id="block-1",
status=ExecutionStatus.COMPLETED,
input_data={"in": marker},
output_data={"out": [marker]},
add_time=datetime.now(tz=timezone.utc),
queue_time=None,
start_time=datetime.now(tz=timezone.utc),
end_time=datetime.now(tz=timezone.utc),
).model_dump(mode="json")
return json.dumps({"payload": inner}).encode()
def _graph_event_payload(
*, user_id: str, graph_id: str, graph_exec_id: str, marker: str
) -> bytes:
inner = GraphExecutionEvent(
id=graph_exec_id,
user_id=user_id,
graph_id=graph_id,
graph_version=1,
preset_id=None,
status=ExecutionStatus.COMPLETED,
started_at=datetime.now(tz=timezone.utc),
ended_at=datetime.now(tz=timezone.utc),
stats=GraphExecutionEvent.Stats(
cost=0,
duration=1.0,
node_exec_time=0.5,
node_exec_count=1,
),
inputs={"x": marker},
credential_inputs=None,
nodes_input_masks=None,
outputs={"y": [marker]},
).model_dump(mode="json")
return json.dumps({"payload": inner}).encode()
async def _wait_until(predicate, timeout: float = 5.0, interval: float = 0.05) -> bool:
"""Poll ``predicate()`` until truthy or timeout — used to wait for pubsub."""
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
if predicate():
return True
await asyncio.sleep(interval)
return False
@pytest.mark.asyncio
async def test_two_clients_get_independent_ssubscribes_on_right_shards(
monkeypatch,
) -> None:
"""Two WS clients on different graph_exec_ids each receive ONLY their
own publish, even when the channels land on different shards."""
user_id = "user-conn-int-1"
graph_a = f"graph-a-{uuid4().hex[:8]}"
graph_b = f"graph-b-{uuid4().hex[:8]}"
exec_a = f"exec-a-{uuid4().hex[:8]}"
exec_b = f"exec-b-{uuid4().hex[:8]}"
# Stub Prisma lookup so tests don't need a DB.
async def _fake_meta(_uid, gex_id):
return _meta(user_id, graph_a if gex_id == exec_a else graph_b, gex_id)
monkeypatch.setattr("backend.api.conn_manager.get_graph_execution_meta", _fake_meta)
cm = ConnectionManager()
ws_a: AsyncMock = AsyncMock(spec=WebSocket)
ws_b: AsyncMock = AsyncMock(spec=WebSocket)
sent_a: list[str] = []
sent_b: list[str] = []
ws_a.send_text = AsyncMock(side_effect=lambda m: sent_a.append(m))
ws_b.send_text = AsyncMock(side_effect=lambda m: sent_b.append(m))
redis_client.get_redis.cache_clear()
cluster = redis_client.get_redis()
try:
await cm.subscribe_graph_exec(
user_id=user_id, graph_exec_id=exec_a, websocket=ws_a
)
await cm.subscribe_graph_exec(
user_id=user_id, graph_exec_id=exec_b, websocket=ws_b
)
# Let SSUBSCRIBE settle on each shard.
await asyncio.sleep(0.2)
# Publish to each per-exec channel.
chan_a = event_bus_channel(exec_channel(user_id, graph_a, exec_a))
chan_b = event_bus_channel(exec_channel(user_id, graph_b, exec_b))
cluster.spublish(
chan_a,
_node_event_payload(
user_id=user_id,
graph_id=graph_a,
graph_exec_id=exec_a,
marker="A",
).decode(),
)
cluster.spublish(
chan_b,
_node_event_payload(
user_id=user_id,
graph_id=graph_b,
graph_exec_id=exec_b,
marker="B",
).decode(),
)
delivered = await _wait_until(lambda: sent_a and sent_b, timeout=5.0)
assert delivered, f"timeout: sent_a={sent_a!r} sent_b={sent_b!r}"
msg_a = json.loads(sent_a[0])
msg_b = json.loads(sent_b[0])
assert msg_a["channel"] == graph_exec_channel_key(user_id, graph_exec_id=exec_a)
assert msg_b["channel"] == graph_exec_channel_key(user_id, graph_exec_id=exec_b)
assert msg_a["data"]["graph_exec_id"] == exec_a
assert msg_b["data"]["graph_exec_id"] == exec_b
# No cross-talk: each socket got exactly one message.
assert len(sent_a) == 1 and len(sent_b) == 1
finally:
await cm.disconnect_socket(ws_a, user_id=user_id)
await cm.disconnect_socket(ws_b, user_id=user_id)
redis_client.disconnect()
@pytest.mark.asyncio
async def test_aggregate_channel_receives_per_exec_publishes(monkeypatch) -> None:
"""A subscriber on the ``graph_execs`` aggregate channel must receive the
GraphExecutionEvent published to the ``/all`` channel — even though
per-exec events go to a different channel."""
user_id = "user-conn-int-2"
graph_id = f"graph-{uuid4().hex[:8]}"
exec_id = f"exec-{uuid4().hex[:8]}"
async def _fake_meta(_uid, gex_id):
return _meta(user_id, graph_id, gex_id)
monkeypatch.setattr("backend.api.conn_manager.get_graph_execution_meta", _fake_meta)
cm = ConnectionManager()
ws_agg: AsyncMock = AsyncMock(spec=WebSocket)
ws_per: AsyncMock = AsyncMock(spec=WebSocket)
sent_agg: list[str] = []
sent_per: list[str] = []
ws_agg.send_text = AsyncMock(side_effect=lambda m: sent_agg.append(m))
ws_per.send_text = AsyncMock(side_effect=lambda m: sent_per.append(m))
redis_client.get_redis.cache_clear()
cluster = redis_client.get_redis()
try:
await cm.subscribe_graph_execs(
user_id=user_id, graph_id=graph_id, websocket=ws_agg
)
await cm.subscribe_graph_exec(
user_id=user_id, graph_exec_id=exec_id, websocket=ws_per
)
await asyncio.sleep(0.2)
# The eventbus publishes the same event to both channels — replicate.
chan_per = event_bus_channel(exec_channel(user_id, graph_id, exec_id))
chan_all = event_bus_channel(graph_all_channel(user_id, graph_id))
payload = _graph_event_payload(
user_id=user_id,
graph_id=graph_id,
graph_exec_id=exec_id,
marker="agg",
).decode()
cluster.spublish(chan_per, payload)
cluster.spublish(chan_all, payload)
delivered = await _wait_until(lambda: sent_agg and sent_per, timeout=5.0)
assert delivered, f"sent_agg={sent_agg!r} sent_per={sent_per!r}"
agg_msg = json.loads(sent_agg[0])
per_msg = json.loads(sent_per[0])
# Aggregate subscriber's channel key is the per-graph executions key.
assert agg_msg["channel"] == _graph_execs_channel_key(
user_id, graph_id=graph_id
)
assert per_msg["channel"] == graph_exec_channel_key(
user_id, graph_exec_id=exec_id
)
assert agg_msg["method"] == WSMethod.GRAPH_EXECUTION_EVENT.value
finally:
await cm.disconnect_socket(ws_agg, user_id=user_id)
await cm.disconnect_socket(ws_per, user_id=user_id)
redis_client.disconnect()
@pytest.mark.asyncio
async def test_disconnect_unsubscribes_and_drops_future_publishes(monkeypatch) -> None:
"""After ``disconnect_socket`` runs, a subsequent SPUBLISH must NOT reach
the dead websocket — exercises the SUNSUBSCRIBE + bookkeeping cleanup."""
user_id = "user-conn-int-3"
graph_id = f"graph-{uuid4().hex[:8]}"
exec_id = f"exec-{uuid4().hex[:8]}"
async def _fake_meta(_uid, gex_id):
return _meta(user_id, graph_id, gex_id)
monkeypatch.setattr("backend.api.conn_manager.get_graph_execution_meta", _fake_meta)
cm = ConnectionManager()
ws: AsyncMock = AsyncMock(spec=WebSocket)
sent: list[str] = []
ws.send_text = AsyncMock(side_effect=lambda m: sent.append(m))
redis_client.get_redis.cache_clear()
cluster = redis_client.get_redis()
chan = event_bus_channel(exec_channel(user_id, graph_id, exec_id))
payload = _node_event_payload(
user_id=user_id, graph_id=graph_id, graph_exec_id=exec_id, marker="live"
).decode()
try:
await cm.subscribe_graph_exec(
user_id=user_id, graph_exec_id=exec_id, websocket=ws
)
await asyncio.sleep(0.15)
# First publish — must reach the socket.
cluster.spublish(chan, payload)
delivered = await _wait_until(lambda: bool(sent), timeout=5.0)
assert delivered
assert len(sent) == 1
# Disconnect → SUNSUBSCRIBE + bookkeeping cleared.
await cm.disconnect_socket(ws, user_id=user_id)
# Pump cancellation may drain in-flight messages; wait for it.
await asyncio.sleep(0.2)
# Channel bookkeeping must be gone.
assert (
graph_exec_channel_key(user_id, graph_exec_id=exec_id)
not in cm.subscriptions
)
assert ws not in cm._ws_subs
# Second publish — must NOT reach the (already-disconnected) socket.
cluster.spublish(
chan,
_node_event_payload(
user_id=user_id,
graph_id=graph_id,
graph_exec_id=exec_id,
marker="post-disconnect",
).decode(),
)
await asyncio.sleep(0.5)
# Still only the one pre-disconnect message.
assert len(sent) == 1
finally:
redis_client.disconnect()
@pytest.mark.asyncio
async def test_slow_consumer_receives_all_events_without_loss(monkeypatch) -> None:
"""Burst-publish many SPUBLISHes; assert every one reaches the subscriber
in order — guards against drops/reorderings in the pubsub pump."""
user_id = "user-conn-int-4"
graph_id = f"graph-{uuid4().hex[:8]}"
exec_id = f"exec-{uuid4().hex[:8]}"
n_events = 100
async def _fake_meta(_uid, gex_id):
return _meta(user_id, graph_id, gex_id)
monkeypatch.setattr("backend.api.conn_manager.get_graph_execution_meta", _fake_meta)
cm = ConnectionManager()
ws: AsyncMock = AsyncMock(spec=WebSocket)
sent: list[str] = []
ws.send_text = AsyncMock(side_effect=lambda m: sent.append(m))
redis_client.get_redis.cache_clear()
cluster = redis_client.get_redis()
chan = event_bus_channel(exec_channel(user_id, graph_id, exec_id))
try:
await cm.subscribe_graph_exec(
user_id=user_id, graph_exec_id=exec_id, websocket=ws
)
await asyncio.sleep(0.2)
# Burst-publish n_events without yielding to the pump.
for i in range(n_events):
cluster.spublish(
chan,
_node_event_payload(
user_id=user_id,
graph_id=graph_id,
graph_exec_id=exec_id,
marker=f"m{i}",
).decode(),
)
delivered = await _wait_until(
lambda: len(sent) >= n_events, timeout=15.0, interval=0.1
)
assert delivered, f"only delivered {len(sent)}/{n_events}"
# Validate ordering — Redis pub/sub is FIFO per channel.
markers = [json.loads(m)["data"]["input_data"]["in"] for m in sent[:n_events]]
assert markers == [f"m{i}" for i in range(n_events)]
finally:
await cm.disconnect_socket(ws, user_id=user_id)
redis_client.disconnect()

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ from uuid import uuid4
from autogpt_libs import auth
from fastapi import APIRouter, HTTPException, Query, Response, Security
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict, Field, field_validator
from backend.copilot import service as chat_service
@@ -47,7 +47,14 @@ from backend.copilot.rate_limit import (
release_reset_lock,
reset_daily_usage,
)
from backend.copilot.response_model import StreamError, StreamFinish, StreamHeartbeat
from backend.copilot.response_model import (
StreamError,
StreamFinish,
StreamFinishStep,
StreamHeartbeat,
StreamStart,
StreamStartStep,
)
from backend.copilot.service import strip_injected_context_for_display
from backend.copilot.tools.e2b_sandbox import kill_sandbox
from backend.copilot.tools.models import (
@@ -154,6 +161,14 @@ class StreamChatRequest(BaseModel):
)
class QueuePendingMessageRequest(BaseModel):
"""Request model for queueing a follow-up while a turn is running."""
message: str = Field(max_length=64_000)
context: dict[str, str] | None = None
file_ids: list[str] | None = Field(default=None, max_length=20)
class PeekPendingMessagesResponse(BaseModel):
"""Response for the pending-message peek (GET) endpoint.
@@ -209,6 +224,11 @@ class ActiveStreamInfo(BaseModel):
turn_id: str
last_message_id: str # Redis Stream message ID for resumption
# ISO-8601 timestamp (UTC) marking when the backend registered the turn
# as running. Lets the frontend seed its elapsed-time counter so restored
# turns show honest "time since turn started" instead of the misleading
# "time since this mount resumed the SSE".
started_at: str | None = None
class SessionDetailResponse(BaseModel):
@@ -300,8 +320,11 @@ async def list_sessions(
redis = await get_redis_async()
pipe = redis.pipeline(transaction=False)
for session in sessions:
# Use the canonical helper so the hash-tag braces match every
# other writer; building the key inline drops the braces and
# silently misses every running session on cluster mode.
pipe.hget(
f"{config.session_meta_prefix}{session.session_id}",
stream_registry.get_session_meta_key(session.session_id),
"status",
)
statuses = await pipe.execute()
@@ -529,6 +552,7 @@ async def get_session(
active_stream_info = ActiveStreamInfo(
turn_id=active_session.turn_id,
last_message_id=last_message_id,
started_at=active_session.created_at.isoformat(),
)
# Skip session metadata on "load more" — frontend only needs messages
@@ -816,17 +840,45 @@ async def cancel_session_task(
return CancelSessionResponse(cancelled=True)
def _ui_message_stream_headers() -> dict[str, str]:
return {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
"x-vercel-ai-ui-message-stream": "v1",
}
def _empty_ui_message_stream_response() -> StreamingResponse:
# Stable placeholder messageId for the empty queued-mid-turn stream.
# Real turns generate per-message UUIDs via the executor; this stream
# has no message to attach to, but the AI SDK parser still requires a
# non-empty ``messageId`` field on ``StreamStart``.
message_id = uuid4().hex
async def event_generator() -> AsyncGenerator[str, None]:
# Vercel AI SDK's UI-message-stream parser expects symmetric
# start/finish framing at both stream and step level — every
# non-empty turn emits the pair. Without an opener, today's parser
# tolerates the closer (no active parts to flush) but a future SDK
# tightening would silently break the queue-mid-turn UX. Emit the
# full empty pair so the contract stays correct.
yield StreamStart(messageId=message_id).to_sse()
yield StreamStartStep().to_sse()
yield StreamFinishStep().to_sse()
yield StreamFinish().to_sse()
yield "data: [DONE]\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers=_ui_message_stream_headers(),
)
@router.post(
"/sessions/{session_id}/stream",
responses={
202: {
"model": QueuePendingMessageResponse,
"description": (
"Session has a turn in flight — message queued into the pending "
"buffer and will be picked up between tool-call rounds by the "
"executor currently processing the turn."
),
},
404: {"description": "Session not found or access denied"},
429: {"description": "Cost rate-limit or call-frequency cap exceeded"},
},
@@ -836,19 +888,18 @@ async def stream_chat_post(
request: StreamChatRequest,
user_id: str = Security(auth.get_user_id),
):
"""Start a new turn OR queue a follow-up — decided server-side.
"""Start a new turn and return an AI SDK UI message stream.
- **Session idle**: starts a turn. Returns an SSE stream (``text/event-stream``)
with Vercel AI SDK chunks (text fragments, tool-call UI, tool results).
The generation runs in a background task that survives client disconnects;
reconnect via ``GET /sessions/{session_id}/stream`` to resume.
Returns an SSE stream (``text/event-stream``) with Vercel AI SDK chunks
(text fragments, tool-call UI, tool results). The generation runs in a
background task that survives client disconnects; reconnect via
``GET /sessions/{session_id}/stream`` to resume.
- **Session has a turn in flight**: pushes the message into the per-session
pending buffer and returns ``202 application/json`` with
``QueuePendingMessageResponse``. The executor running the current turn
drains the buffer between tool-call rounds (baseline) or at the start of
the next turn (SDK). Clients should detect the 202 and surface the
message as a queued-chip in the UI.
Follow-up messages typed while a turn is already running should use
``POST /sessions/{session_id}/messages/pending``. If an older client still
posts that follow-up here, we queue it defensively but still return a valid
empty UI-message stream so AI SDK transports never receive a JSON body from
the stream endpoint.
Args:
session_id: The chat session identifier.
@@ -872,26 +923,29 @@ async def stream_chat_post(
extra={"json_fields": log_meta},
)
session = await _validate_and_get_session(session_id, user_id)
builder_permissions = resolve_session_permissions(session)
# Self-defensive queue-fallback: if a turn is already running, don't race
# it on the cluster lock — drop the message into the pending buffer and
# return 202 so the caller can render a chip. Both UI chips and autopilot
# block follow-ups route through this path; keeping the decision on the
# server means every caller gets uniform behaviour.
if (
request.is_user_message
and request.message
and await is_turn_in_flight(session_id)
):
response = await queue_pending_for_http(
session_id=session_id,
user_id=user_id,
message=request.message,
context=request.context,
file_ids=request.file_ids,
)
return JSONResponse(status_code=202, content=response.model_dump())
try:
await queue_pending_for_http(
session_id=session_id,
user_id=user_id,
message=request.message,
context=request.context,
file_ids=request.file_ids,
)
return _empty_ui_message_stream_response()
except HTTPException as exc:
if exc.status_code != 409:
raise
# Permission resolution is only needed below for the actual turn — keep
# it after the queue-fall-through so a queued mid-turn request returns
# without paying the work.
builder_permissions = resolve_session_permissions(session)
logger.info(
f"[TIMING] session validated in {(time.perf_counter() - stream_start_time) * 1000:.1f}ms",
@@ -1130,12 +1184,37 @@ async def stream_chat_post(
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # Disable nginx buffering
"x-vercel-ai-ui-message-stream": "v1", # AI SDK protocol header
},
headers=_ui_message_stream_headers(),
)
@router.post(
"/sessions/{session_id}/messages/pending",
response_model=QueuePendingMessageResponse,
responses={
404: {"description": "Session not found or access denied"},
409: {"description": "Session has no active turn to receive pending messages"},
429: {"description": "Call-frequency cap exceeded"},
},
)
async def queue_pending_message(
session_id: str,
request: QueuePendingMessageRequest,
user_id: str = Security(auth.get_user_id),
):
"""Queue a follow-up message while the session has an active turn."""
await _validate_and_get_session(session_id, user_id)
if not await is_turn_in_flight(session_id):
raise HTTPException(
status_code=409,
detail="Session has no active turn. Start a new turn with POST /stream.",
)
return await queue_pending_for_http(
session_id=session_id,
user_id=user_id,
message=request.message,
context=request.context,
file_ids=request.file_ids,
)
@@ -1169,6 +1248,7 @@ async def get_pending_messages(
)
async def resume_session_stream(
session_id: str,
last_chunk_id: str | None = Query(default=None, include_in_schema=False),
user_id: str = Security(auth.get_user_id),
):
"""
@@ -1178,27 +1258,26 @@ async def resume_session_stream(
Checks for an active (in-progress) task on the session and either replays
the full SSE stream or returns 204 No Content if nothing is running.
Args:
session_id: The chat session identifier.
user_id: Optional authenticated user ID.
Returns:
StreamingResponse (SSE) when an active stream exists,
or 204 No Content when there is nothing to resume.
Always replays the active turn from ``0-0``. The AI SDK UI-message parser
keeps text/reasoning part state inside a single parser instance; resuming
from a Redis cursor can skip the ``*-start`` events required by later
``*-delta`` chunks.
"""
import asyncio
active_session, last_message_id = await stream_registry.get_active_session(
active_session, _latest_backend_id = await stream_registry.get_active_session(
session_id, user_id
)
if not active_session:
return Response(status_code=204)
# Always replay from the beginning ("0-0") on resume.
# We can't use last_message_id because it's the latest ID in the backend
# stream, not the latest the frontend received — the gap causes lost
# messages. The frontend deduplicates replayed content.
if last_chunk_id:
logger.info(
"Ignoring deprecated last_chunk_id on stream resume",
extra={"session_id": session_id, "last_chunk_id": last_chunk_id},
)
subscriber_queue = await stream_registry.subscribe_to_session(
session_id=session_id,
user_id=user_id,
@@ -1259,12 +1338,7 @@ async def resume_session_stream(
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
"x-vercel-ai-ui-message-stream": "v1",
},
headers=_ui_message_stream_headers(),
)

View File

@@ -157,6 +157,11 @@ def _mock_stream_internals(mocker: pytest_mock.MockerFixture):
"backend.api.features.chat.routes._validate_and_get_session",
return_value=None,
)
mocker.patch(
"backend.api.features.chat.routes.is_turn_in_flight",
new_callable=AsyncMock,
return_value=False,
)
mock_save = mocker.patch(
"backend.api.features.chat.routes.append_and_save_message",
return_value=MagicMock(), # non-None = message was saved (not a duplicate)
@@ -637,7 +642,7 @@ class TestStreamChatRequestModeValidation:
assert req.mode is None
# ─── POST /stream queue-fallback (when a turn is already in flight) ──
# ─── Pending message queue (when a turn is already in flight) ─────────
def _mock_stream_queue_internals(
@@ -646,11 +651,9 @@ def _mock_stream_queue_internals(
session_exists: bool = True,
turn_in_flight: bool = True,
call_count: int = 1,
push_length: int | None = 1,
):
"""Mock dependencies for the POST /stream queue-fallback path.
When ``turn_in_flight`` is True the handler takes the 202 queue branch.
"""
"""Mock dependencies for the pending-message queue path."""
if session_exists:
mock_session = mocker.MagicMock()
mock_session.id = "sess-1"
@@ -692,12 +695,10 @@ def _mock_stream_queue_internals(
return_value=call_count,
)
mocker.patch(
"backend.copilot.pending_message_helpers.push_pending_message",
"backend.copilot.pending_message_helpers.push_pending_message_if_session_running",
new_callable=AsyncMock,
return_value=1,
return_value=push_length,
)
# queue_user_message re-runs is_turn_in_flight via the helper module —
# stub that path out too so we don't need a fake stream_registry.
mocker.patch(
"backend.copilot.pending_message_helpers.get_active_session_meta",
new_callable=AsyncMock,
@@ -705,37 +706,65 @@ def _mock_stream_queue_internals(
)
def test_stream_queue_returns_202_when_turn_in_flight(
def test_queue_pending_message_returns_200_when_turn_in_flight(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Happy path: POST /stream to a session with a live turn → 202 queue."""
"""Happy path: POST /messages/pending to a live turn queues the message."""
_mock_stream_queue_internals(mocker)
response = client.post(
"/sessions/sess-1/stream",
json={"message": "follow-up", "is_user_message": True},
"/sessions/sess-1/messages/pending",
json={"message": "follow-up"},
)
assert response.status_code == 202
assert response.status_code == 200
data = response.json()
assert data["buffer_length"] == 1
assert "turn_in_flight" in data
def test_stream_queue_session_not_found_returns_404(
def test_queue_pending_message_session_not_found_returns_404(
mocker: pytest_mock.MockerFixture,
) -> None:
"""If the session doesn't exist or belong to the user, returns 404."""
_mock_stream_queue_internals(mocker, session_exists=False)
response = client.post(
"/sessions/bad-sess/stream",
json={"message": "hi", "is_user_message": True},
"/sessions/bad-sess/messages/pending",
json={"message": "hi"},
)
assert response.status_code == 404
def test_stream_queue_call_frequency_limit_returns_429(
def test_queue_pending_message_without_active_turn_returns_409(
mocker: pytest_mock.MockerFixture,
) -> None:
"""A pending-message push needs an active turn to consume it."""
_mock_stream_queue_internals(mocker, turn_in_flight=False)
response = client.post(
"/sessions/sess-1/messages/pending",
json={"message": "hi"},
)
assert response.status_code == 409
def test_queue_pending_message_race_after_active_check_returns_409(
mocker: pytest_mock.MockerFixture,
) -> None:
"""If the active turn ends before the atomic push, the message is not queued."""
_mock_stream_queue_internals(mocker, push_length=None)
response = client.post(
"/sessions/sess-1/messages/pending",
json={"message": "hi"},
)
assert response.status_code == 409
def test_queue_pending_message_call_frequency_limit_returns_429(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Per-user call-frequency cap rejects rapid-fire queued pushes."""
@@ -744,14 +773,14 @@ def test_stream_queue_call_frequency_limit_returns_429(
_mock_stream_queue_internals(mocker, call_count=PENDING_CALL_LIMIT + 1)
response = client.post(
"/sessions/sess-1/stream",
json={"message": "hi", "is_user_message": True},
"/sessions/sess-1/messages/pending",
json={"message": "hi"},
)
assert response.status_code == 429
assert "Too many queued message requests this minute" in response.json()["detail"]
def test_stream_queue_converts_context_dict_to_pending_context(
def test_queue_pending_message_converts_context_dict_to_pending_context(
mocker: pytest_mock.MockerFixture,
) -> None:
"""StreamChatRequest.context is a raw dict; must be coerced to the
@@ -768,15 +797,14 @@ def test_stream_queue_converts_context_dict_to_pending_context(
)
response = client.post(
"/sessions/sess-1/stream",
"/sessions/sess-1/messages/pending",
json={
"message": "hi",
"is_user_message": True,
"context": {"url": "https://example.test", "content": "body"},
},
)
assert response.status_code == 202
assert response.status_code == 200
queue_spy.assert_awaited_once()
kwargs = queue_spy.await_args.kwargs
from backend.copilot.pending_messages import PendingMessageContext
@@ -786,7 +814,7 @@ def test_stream_queue_converts_context_dict_to_pending_context(
assert kwargs["context"].content == "body"
def test_stream_queue_passes_none_context_when_omitted(
def test_queue_pending_message_passes_none_context_when_omitted(
mocker: pytest_mock.MockerFixture,
) -> None:
"""When request.context is omitted, the queue call receives context=None."""
@@ -802,15 +830,31 @@ def test_stream_queue_passes_none_context_when_omitted(
)
response = client.post(
"/sessions/sess-1/stream",
json={"message": "hi", "is_user_message": True},
"/sessions/sess-1/messages/pending",
json={"message": "hi"},
)
assert response.status_code == 202
assert response.status_code == 200
queue_spy.assert_awaited_once()
assert queue_spy.await_args.kwargs["context"] is None
def test_stream_chat_queues_legacy_inflight_post_but_returns_sse(
mocker: pytest_mock.MockerFixture,
) -> None:
"""POST /stream must not return JSON to an AI SDK transport."""
_mock_stream_queue_internals(mocker)
response = client.post(
"/sessions/sess-1/stream",
json={"message": "follow-up", "is_user_message": True},
)
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/event-stream")
assert '"type":"finish"' in response.text
# ─── get_pending_messages (GET /sessions/{session_id}/messages/pending) ─────
@@ -1581,9 +1625,14 @@ def test_resume_session_stream_no_subscriber_queue(
mock_registry.subscribe_to_session = AsyncMock(return_value=None)
mocker.patch("backend.api.features.chat.routes.stream_registry", mock_registry)
response = client.get("/sessions/sess-1/stream")
response = client.get("/sessions/sess-1/stream?last_chunk_id=9999-9")
assert response.status_code == 204
mock_registry.subscribe_to_session.assert_awaited_once_with(
session_id="sess-1",
user_id=TEST_USER_ID,
last_message_id="0-0",
)
# ─── DELETE /sessions/{id}/stream — disconnect listeners ──────────────

View File

@@ -7,6 +7,7 @@ allowing frontend code generators like Orval to create corresponding TypeScript
from pydantic import BaseModel, Field
from backend.data.model import CredentialsType
from backend.integrations.providers import ProviderName
from backend.sdk.registry import AutoRegistry
@@ -47,6 +48,57 @@ class ProviderNamesResponse(BaseModel):
)
class ProviderMetadata(BaseModel):
"""Display metadata for a provider, shown in the settings integrations UI."""
name: str = Field(description="Provider slug (e.g. ``github``)")
description: str | None = Field(
default=None,
description=(
"One-line human-readable summary of what the provider does. "
"Declared via ``ProviderBuilder.with_description(...)`` in the "
"provider's ``_config.py``. ``None`` if not set."
),
)
supported_auth_types: list[CredentialsType] = Field(
default_factory=list,
description=(
"Credential types this provider accepts. Drives which connection "
"tabs the settings UI renders for the provider. Empty list means "
"no auth types declared."
),
)
def get_supported_auth_types(name: str) -> list[CredentialsType]:
"""Return the provider's supported credential types from :class:`AutoRegistry`.
Populated by :meth:`ProviderBuilder.with_supported_auth_types` (or by
``with_oauth`` / ``with_api_key`` / ``with_user_password`` when the provider
uses the full builder chain). Returns an empty list for providers with no
auth types declared.
"""
provider = AutoRegistry.get_provider(name)
if provider is None:
return []
return sorted(provider.supported_auth_types)
def get_provider_description(name: str) -> str | None:
"""Return the provider's description from :class:`AutoRegistry`.
Descriptions are declared via ``ProviderBuilder.with_description(...)`` in
the provider's ``_config.py`` (SDK path) or in
``blocks/_static_provider_configs.py`` (for providers that don't yet have
their own directory). Returns ``None`` for providers with no registered
description.
"""
provider = AutoRegistry.get_provider(name)
if provider is None:
return None
return provider.description
class ProviderConstants(BaseModel):
"""
Model that exposes all provider names as a constant in the OpenAPI schema.

View File

@@ -66,7 +66,14 @@ from backend.util.exceptions import (
)
from backend.util.settings import Settings
from .models import ProviderConstants, ProviderNamesResponse, get_all_provider_names
from .models import (
ProviderConstants,
ProviderMetadata,
ProviderNamesResponse,
get_all_provider_names,
get_provider_description,
get_supported_auth_types,
)
if TYPE_CHECKING:
from backend.integrations.oauth import BaseOAuthHandler
@@ -1204,20 +1211,37 @@ async def get_ayrshare_sso_url(
# === PROVIDER DISCOVERY ENDPOINTS ===
@router.get("/providers", response_model=List[str])
async def list_providers() -> List[str]:
@router.get("/providers", response_model=List[ProviderMetadata])
async def list_providers() -> List[ProviderMetadata]:
"""
Get a list of all available provider names.
Get metadata for every available provider.
Returns both statically defined providers (from ProviderName enum)
and dynamically registered providers (from SDK decorators).
Returns both statically defined providers (from ``ProviderName`` enum) and
dynamically registered providers (from SDK decorators). Each entry includes
a ``description`` declared via ``ProviderBuilder.with_description(...)`` in
the provider's ``_config.py``.
Note: The complete list of provider names is also available as a constant
in the generated TypeScript client via PROVIDER_NAMES.
"""
# Get all providers at runtime
# Ensure all block modules (and therefore every provider's _config.py) are
# imported before we read from AutoRegistry. Cached on first call.
try:
from backend.blocks import load_all_blocks
load_all_blocks()
except Exception as e:
logger.warning(f"Failed to load blocks for provider metadata: {e}")
all_providers = get_all_provider_names()
return all_providers
return [
ProviderMetadata(
name=name,
description=get_provider_description(name),
supported_auth_types=get_supported_auth_types(name),
)
for name in all_providers
]
@router.get("/providers/system", response_model=List[str])

View File

@@ -0,0 +1,20 @@
import pydantic
class PushSubscriptionKeys(pydantic.BaseModel):
p256dh: str = pydantic.Field(min_length=1, max_length=512)
auth: str = pydantic.Field(min_length=1, max_length=512)
class PushSubscribeRequest(pydantic.BaseModel):
endpoint: str = pydantic.Field(min_length=1, max_length=2048)
keys: PushSubscriptionKeys
user_agent: str | None = pydantic.Field(default=None, max_length=512)
class PushUnsubscribeRequest(pydantic.BaseModel):
endpoint: str = pydantic.Field(min_length=1, max_length=2048)
class VapidPublicKeyResponse(pydantic.BaseModel):
public_key: str

View File

@@ -0,0 +1,64 @@
from typing import Annotated
from autogpt_libs.auth import get_user_id, requires_user
from fastapi import APIRouter, HTTPException, Security
from starlette.status import HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST
from backend.api.features.push.model import (
PushSubscribeRequest,
PushUnsubscribeRequest,
VapidPublicKeyResponse,
)
from backend.data.push_subscription import (
delete_push_subscription,
upsert_push_subscription,
validate_push_endpoint,
)
from backend.util.settings import Settings
router = APIRouter()
_settings = Settings()
@router.get(
"/vapid-key",
summary="Get VAPID public key for push subscription",
)
async def get_vapid_public_key() -> VapidPublicKeyResponse:
return VapidPublicKeyResponse(public_key=_settings.secrets.vapid_public_key)
@router.post(
"/subscribe",
summary="Register a push subscription for the current user",
status_code=HTTP_204_NO_CONTENT,
dependencies=[Security(requires_user)],
)
async def subscribe_push(
user_id: Annotated[str, Security(get_user_id)],
body: PushSubscribeRequest,
) -> None:
try:
await validate_push_endpoint(body.endpoint)
await upsert_push_subscription(
user_id=user_id,
endpoint=body.endpoint,
p256dh=body.keys.p256dh,
auth=body.keys.auth,
user_agent=body.user_agent,
)
except ValueError as e:
raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail=str(e))
@router.post(
"/unsubscribe",
summary="Remove a push subscription",
status_code=HTTP_204_NO_CONTENT,
dependencies=[Security(requires_user)],
)
async def unsubscribe_push(
user_id: Annotated[str, Security(get_user_id)],
body: PushUnsubscribeRequest,
) -> None:
await delete_push_subscription(user_id, body.endpoint)

View File

@@ -0,0 +1,240 @@
"""Tests for push notification routes."""
from unittest.mock import AsyncMock, MagicMock
import fastapi
import fastapi.testclient
import pytest
from backend.api.features.push.routes import router
app = fastapi.FastAPI()
app.include_router(router)
client = fastapi.testclient.TestClient(app)
@pytest.fixture(autouse=True)
def setup_app_auth(mock_jwt_user):
from autogpt_libs.auth.jwt_utils import get_jwt_payload
app.dependency_overrides[get_jwt_payload] = mock_jwt_user["get_jwt_payload"]
yield
app.dependency_overrides.clear()
def test_get_vapid_public_key(mocker):
mock_settings = MagicMock()
mock_settings.secrets.vapid_public_key = "test-vapid-public-key-base64url"
mocker.patch(
"backend.api.features.push.routes._settings",
mock_settings,
)
response = client.get("/vapid-key")
assert response.status_code == 200
data = response.json()
assert data["public_key"] == "test-vapid-public-key-base64url"
def test_get_vapid_public_key_empty(mocker):
mock_settings = MagicMock()
mock_settings.secrets.vapid_public_key = ""
mocker.patch(
"backend.api.features.push.routes._settings",
mock_settings,
)
response = client.get("/vapid-key")
assert response.status_code == 200
data = response.json()
assert data["public_key"] == ""
def test_subscribe_push(mocker, test_user_id):
mock_upsert = mocker.patch(
"backend.api.features.push.routes.upsert_push_subscription",
new_callable=AsyncMock,
)
response = client.post(
"/subscribe",
json={
"endpoint": "https://fcm.googleapis.com/fcm/send/abc123",
"keys": {
"p256dh": "test-p256dh-key",
"auth": "test-auth-key",
},
"user_agent": "Mozilla/5.0 Test",
},
)
assert response.status_code == 204
mock_upsert.assert_awaited_once_with(
user_id=test_user_id,
endpoint="https://fcm.googleapis.com/fcm/send/abc123",
p256dh="test-p256dh-key",
auth="test-auth-key",
user_agent="Mozilla/5.0 Test",
)
def test_subscribe_push_without_user_agent(mocker, test_user_id):
mock_upsert = mocker.patch(
"backend.api.features.push.routes.upsert_push_subscription",
new_callable=AsyncMock,
)
response = client.post(
"/subscribe",
json={
"endpoint": "https://fcm.googleapis.com/fcm/send/abc123",
"keys": {
"p256dh": "test-p256dh-key",
"auth": "test-auth-key",
},
},
)
assert response.status_code == 204
mock_upsert.assert_awaited_once_with(
user_id=test_user_id,
endpoint="https://fcm.googleapis.com/fcm/send/abc123",
p256dh="test-p256dh-key",
auth="test-auth-key",
user_agent=None,
)
def test_subscribe_push_missing_keys():
response = client.post(
"/subscribe",
json={
"endpoint": "https://fcm.googleapis.com/fcm/send/abc123",
},
)
assert response.status_code == 422
def test_subscribe_push_missing_endpoint():
response = client.post(
"/subscribe",
json={
"keys": {
"p256dh": "test-p256dh-key",
"auth": "test-auth-key",
},
},
)
assert response.status_code == 422
def test_subscribe_push_rejects_empty_crypto_keys():
response = client.post(
"/subscribe",
json={
"endpoint": "https://fcm.googleapis.com/fcm/send/abc123",
"keys": {"p256dh": "", "auth": ""},
},
)
assert response.status_code == 422
def test_subscribe_push_rejects_oversized_endpoint():
response = client.post(
"/subscribe",
json={
"endpoint": "https://fcm.googleapis.com/fcm/send/" + "x" * 3000,
"keys": {"p256dh": "k", "auth": "a"},
},
)
assert response.status_code == 422
def test_unsubscribe_push(mocker, test_user_id):
mock_delete = mocker.patch(
"backend.api.features.push.routes.delete_push_subscription",
new_callable=AsyncMock,
)
response = client.post(
"/unsubscribe",
json={
"endpoint": "https://fcm.googleapis.com/fcm/send/abc123",
},
)
assert response.status_code == 204
mock_delete.assert_awaited_once_with(
test_user_id,
"https://fcm.googleapis.com/fcm/send/abc123",
)
def test_unsubscribe_push_missing_endpoint():
response = client.post(
"/unsubscribe",
json={},
)
assert response.status_code == 422
@pytest.mark.parametrize(
"untrusted_endpoint",
[
"https://localhost/evil",
"https://127.0.0.1/evil",
"https://169.254.169.254/latest/meta-data/",
"https://internal-service.local/api",
"https://attacker.example.com/push",
"http://fcm.googleapis.com/fcm/send/abc",
"file:///etc/passwd",
],
)
def test_subscribe_push_rejects_untrusted_endpoints(mocker, untrusted_endpoint):
mock_upsert = mocker.patch(
"backend.api.features.push.routes.upsert_push_subscription",
new_callable=AsyncMock,
)
response = client.post(
"/subscribe",
json={
"endpoint": untrusted_endpoint,
"keys": {
"p256dh": "test-p256dh-key",
"auth": "test-auth-key",
},
},
)
assert response.status_code == 400
mock_upsert.assert_not_awaited()
def test_subscribe_push_surfaces_cap_as_400(mocker):
mocker.patch(
"backend.api.features.push.routes.upsert_push_subscription",
new_callable=AsyncMock,
side_effect=ValueError("Subscription limit of 20 per user reached"),
)
response = client.post(
"/subscribe",
json={
"endpoint": "https://fcm.googleapis.com/fcm/send/abc123",
"keys": {
"p256dh": "test-p256dh-key",
"auth": "test-auth-key",
},
},
)
assert response.status_code == 400
assert "Subscription limit" in response.json()["detail"]

View File

@@ -490,6 +490,9 @@ async def get_store_creators(
# Build where clause with sanitized inputs
where = {}
# Only return creators with approved agents
where["num_agents"] = {"gt": 0}
if featured:
where["is_featured"] = featured

View File

@@ -1,4 +1,5 @@
from datetime import datetime
from unittest.mock import AsyncMock
import prisma.enums
import prisma.errors
@@ -50,8 +51,8 @@ async def test_get_store_agents(mocker):
# Mock prisma calls
mock_store_agent = mocker.patch("prisma.models.StoreAgent.prisma")
mock_store_agent.return_value.find_many = mocker.AsyncMock(return_value=mock_agents)
mock_store_agent.return_value.count = mocker.AsyncMock(return_value=1)
mock_store_agent.return_value.find_many = AsyncMock(return_value=mock_agents)
mock_store_agent.return_value.count = AsyncMock(return_value=1)
# Call function
result = await db.get_store_agents()
@@ -94,7 +95,7 @@ async def test_get_store_agent_details(mocker):
# Mock StoreAgent prisma call
mock_store_agent = mocker.patch("prisma.models.StoreAgent.prisma")
mock_store_agent.return_value.find_first = mocker.AsyncMock(return_value=mock_agent)
mock_store_agent.return_value.find_first = AsyncMock(return_value=mock_agent)
# Call function
result = await db.get_store_agent_details("creator", "test-agent")
@@ -133,7 +134,7 @@ async def test_get_store_creator(mocker):
# Mock prisma call
mock_creator = mocker.patch("prisma.models.Creator.prisma")
mock_creator.return_value.find_unique = mocker.AsyncMock()
mock_creator.return_value.find_unique = AsyncMock()
# Configure the mock to return values that will pass validation
mock_creator.return_value.find_unique.return_value = mock_creator_data
@@ -236,23 +237,23 @@ async def test_create_store_submission(mocker):
# Mock prisma calls
mock_agent_graph = mocker.patch("prisma.models.AgentGraph.prisma")
mock_agent_graph.return_value.find_first = mocker.AsyncMock(return_value=mock_agent)
mock_agent_graph.return_value.find_first = AsyncMock(return_value=mock_agent)
# Mock transaction context manager
mock_tx = mocker.MagicMock()
mocker.patch(
"backend.api.features.store.db.transaction",
return_value=mocker.AsyncMock(
__aenter__=mocker.AsyncMock(return_value=mock_tx),
__aexit__=mocker.AsyncMock(return_value=False),
return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_tx),
__aexit__=AsyncMock(return_value=False),
),
)
mock_sl = mocker.patch("prisma.models.StoreListing.prisma")
mock_sl.return_value.find_unique = mocker.AsyncMock(return_value=None)
mock_sl.return_value.find_unique = AsyncMock(return_value=None)
mock_slv = mocker.patch("prisma.models.StoreListingVersion.prisma")
mock_slv.return_value.create = mocker.AsyncMock(return_value=mock_version)
mock_slv.return_value.create = AsyncMock(return_value=mock_version)
# Call function
result = await db.create_store_submission(
@@ -292,10 +293,8 @@ async def test_update_profile(mocker):
# Mock prisma calls
mock_profile_db = mocker.patch("prisma.models.Profile.prisma")
mock_profile_db.return_value.find_first = mocker.AsyncMock(
return_value=mock_profile
)
mock_profile_db.return_value.update = mocker.AsyncMock(return_value=mock_profile)
mock_profile_db.return_value.find_first = AsyncMock(return_value=mock_profile)
mock_profile_db.return_value.update = AsyncMock(return_value=mock_profile)
# Test data
profile = Profile(
@@ -336,9 +335,7 @@ async def test_get_user_profile(mocker):
# Mock prisma calls
mock_profile_db = mocker.patch("prisma.models.Profile.prisma")
mock_profile_db.return_value.find_first = mocker.AsyncMock(
return_value=mock_profile
)
mock_profile_db.return_value.find_first = AsyncMock(return_value=mock_profile)
# Call function
result = await db.get_user_profile("user-id")
@@ -396,3 +393,38 @@ async def test_get_store_agents_search_category_array_injection():
# Verify the query executed without error
# Category should be parameterized, preventing SQL injection
assert isinstance(result.agents, list)
@pytest.mark.asyncio(loop_scope="session")
async def test_get_store_creators_only_returns_approved(mocker):
mock_creators = [
prisma.models.Creator(
name="Creator One",
username="creator1",
description="desc",
links=["link1"],
avatar_url="avatar.jpg",
num_agents=1,
agent_rating=4.5,
agent_runs=10,
top_categories=["test"],
is_featured=False,
)
]
mock_creator = mocker.patch("prisma.models.Creator.prisma")
mock_creator.return_value.find_many = AsyncMock(return_value=mock_creators)
mock_creator.return_value.count = AsyncMock(return_value=1)
result = await db.get_store_creators()
assert len(result.creators) == 1
assert result.creators[0].username == "creator1"
mock_creator.return_value.find_many.assert_called_once()
mock_creator.return_value.count.assert_called_once()
_, find_kwargs = mock_creator.return_value.find_many.call_args
_, count_kwargs = mock_creator.return_value.count.call_args
assert find_kwargs["where"]["num_agents"] == {"gt": 0}
assert count_kwargs["where"]["num_agents"] == {"gt": 0}

View File

@@ -70,7 +70,8 @@ _DEFAULT_TIER_PRICES: dict[SubscriptionTier, str | None] = {
@pytest.fixture(autouse=True)
def _stub_subscription_status_lookups(mocker: pytest_mock.MockFixture) -> None:
"""Stub Stripe price + proration lookups used by get_subscription_status.
"""Stub Stripe price + proration + tier-multiplier lookups used by
get_subscription_status.
The POST /credits/subscription handler now returns the full subscription
status payload from every branch (same-tier, BASIC downgrade, paid→paid
@@ -90,6 +91,16 @@ def _stub_subscription_status_lookups(mocker: pytest_mock.MockFixture) -> None:
new_callable=AsyncMock,
return_value=0,
)
# Default tier-multiplier resolver to the backend defaults so the endpoint
# never reaches LaunchDarkly during tests. Individual tests override for
# LD-override scenarios.
from backend.copilot.rate_limit import _DEFAULT_TIER_MULTIPLIERS
mocker.patch(
"backend.api.features.v1.get_tier_multipliers",
new_callable=AsyncMock,
return_value=dict(_DEFAULT_TIER_MULTIPLIERS),
)
@pytest.mark.parametrize(
@@ -187,13 +198,59 @@ def test_get_subscription_status_pro(
assert data["tier_costs"]["BASIC"] == 0
assert "ENTERPRISE" not in data["tier_costs"]
assert data["proration_credit_cents"] == 500
# tier_multipliers mirrors the same set of tiers that land in tier_costs,
# so the frontend never renders a multiplier badge for a hidden row.
assert set(data["tier_multipliers"].keys()) == set(data["tier_costs"].keys())
assert data["tier_multipliers"]["BASIC"] == 1.0
assert data["tier_multipliers"]["PRO"] == 5.0
assert data["tier_multipliers"]["MAX"] == 20.0
assert data["tier_multipliers"]["BUSINESS"] == 60.0
def test_get_subscription_status_defaults_to_basic(
def test_get_subscription_status_tier_multipliers_ld_override(
client: fastapi.testclient.TestClient,
mocker: pytest_mock.MockFixture,
) -> None:
"""When all LD price IDs are unset, tier_costs is empty and the caller sees cost=0."""
"""A LaunchDarkly-overridden tier multiplier flows through the response."""
mock_user = Mock()
mock_user.subscription_tier = SubscriptionTier.BASIC
mocker.patch(
"backend.api.features.v1.get_user_by_id",
new_callable=AsyncMock,
return_value=mock_user,
)
# LD says PRO is 7.5× (instead of the 5× default); other tiers unchanged.
mocker.patch(
"backend.api.features.v1.get_tier_multipliers",
new_callable=AsyncMock,
return_value={
SubscriptionTier.BASIC: 1.0,
SubscriptionTier.PRO: 7.5,
SubscriptionTier.MAX: 20.0,
SubscriptionTier.BUSINESS: 60.0,
SubscriptionTier.ENTERPRISE: 60.0,
},
)
response = client.get("/credits/subscription")
assert response.status_code == 200
data = response.json()
# Only tiers that made it into tier_costs get a multiplier (default stub
# exposes PRO + MAX via _DEFAULT_TIER_PRICES).
assert data["tier_multipliers"]["PRO"] == 7.5
assert data["tier_multipliers"]["MAX"] == 20.0
# BUSINESS has no price configured → hidden from both maps.
assert "BUSINESS" not in data["tier_multipliers"]
def test_get_subscription_status_defaults_to_no_tier(
client: fastapi.testclient.TestClient,
mocker: pytest_mock.MockFixture,
) -> None:
"""When user has no subscription_tier, defaults to NO_TIER (the explicit
no-active-subscription state)."""
mock_user = Mock()
mock_user.subscription_tier = None
@@ -217,7 +274,7 @@ def test_get_subscription_status_defaults_to_basic(
assert response.status_code == 200
data = response.json()
assert data["tier"] == SubscriptionTier.BASIC.value
assert data["tier"] == SubscriptionTier.NO_TIER.value
assert data["monthly_cost"] == 0
assert data["tier_costs"] == {}
assert data["proration_credit_cents"] == 0
@@ -270,11 +327,11 @@ def test_get_subscription_status_stripe_error_falls_back_to_zero(
assert data["tier_costs"]["PRO"] == 0
def test_update_subscription_tier_basic_no_payment(
def test_update_subscription_tier_no_tier_no_payment(
client: fastapi.testclient.TestClient,
mocker: pytest_mock.MockFixture,
) -> None:
"""POST /credits/subscription to BASIC tier when payment disabled skips Stripe."""
"""POST /credits/subscription to NO_TIER (cancel) when payment disabled skips Stripe."""
mock_user = Mock()
mock_user.subscription_tier = SubscriptionTier.PRO
@@ -295,7 +352,7 @@ def test_update_subscription_tier_basic_no_payment(
new_callable=AsyncMock,
)
response = client.post("/credits/subscription", json={"tier": "BASIC"})
response = client.post("/credits/subscription", json={"tier": "NO_TIER"})
assert response.status_code == 200
assert response.json()["url"] == ""
@@ -348,12 +405,109 @@ def test_update_subscription_tier_paid_requires_urls(
"backend.api.features.v1.is_feature_enabled",
side_effect=mock_feature_enabled,
)
mocker.patch(
"backend.api.features.v1.modify_stripe_subscription_for_tier",
new_callable=AsyncMock,
return_value=False,
)
response = client.post("/credits/subscription", json={"tier": "PRO"})
assert response.status_code == 422
def test_update_subscription_tier_currency_mismatch_returns_422(
client: fastapi.testclient.TestClient,
mocker: pytest_mock.MockFixture,
) -> None:
"""Stripe rejects a SubscriptionSchedule whose phases mix currencies (e.g.
GBP-checkout sub trying to schedule a USD-only target Price). The handler
must convert that into a specific 422 instead of the generic 502 so the
caller can tell the difference between a currency-config bug and a Stripe
outage."""
mock_user = Mock()
mock_user.subscription_tier = SubscriptionTier.MAX
async def mock_feature_enabled(*args, **kwargs):
return True
mocker.patch(
"backend.api.features.v1.get_user_by_id",
new_callable=AsyncMock,
return_value=mock_user,
)
mocker.patch(
"backend.api.features.v1.is_feature_enabled",
side_effect=mock_feature_enabled,
)
mocker.patch(
"backend.api.features.v1.modify_stripe_subscription_for_tier",
side_effect=stripe.InvalidRequestError(
"The price specified only supports `usd`. This doesn't match the"
" expected currency: `gbp`.",
param="phases",
),
)
response = client.post(
"/credits/subscription",
json={
"tier": "PRO",
"success_url": f"{TEST_FRONTEND_ORIGIN}/success",
"cancel_url": f"{TEST_FRONTEND_ORIGIN}/cancel",
},
)
assert response.status_code == 422
detail = response.json()["detail"]
assert "billing currency" in detail.lower()
assert "contact support" in detail.lower()
def test_update_subscription_tier_non_currency_invalid_request_returns_502(
client: fastapi.testclient.TestClient,
mocker: pytest_mock.MockFixture,
) -> None:
"""Locks the contract that *only* currency-mismatch InvalidRequestErrors
translate to 422 — every other Stripe InvalidRequestError must still
surface as the generic 502 so that widening the conditional later is
caught by the suite."""
mock_user = Mock()
mock_user.subscription_tier = SubscriptionTier.MAX
async def mock_feature_enabled(*args, **kwargs):
return True
mocker.patch(
"backend.api.features.v1.get_user_by_id",
new_callable=AsyncMock,
return_value=mock_user,
)
mocker.patch(
"backend.api.features.v1.is_feature_enabled",
side_effect=mock_feature_enabled,
)
mocker.patch(
"backend.api.features.v1.modify_stripe_subscription_for_tier",
side_effect=stripe.InvalidRequestError(
"No such price: 'price_does_not_exist'",
param="items[0][price]",
),
)
response = client.post(
"/credits/subscription",
json={
"tier": "PRO",
"success_url": f"{TEST_FRONTEND_ORIGIN}/success",
"cancel_url": f"{TEST_FRONTEND_ORIGIN}/cancel",
},
)
assert response.status_code == 502
assert "billing currency" not in response.json()["detail"].lower()
def test_update_subscription_tier_creates_checkout(
client: fastapi.testclient.TestClient,
mocker: pytest_mock.MockFixture,
@@ -374,6 +528,11 @@ def test_update_subscription_tier_creates_checkout(
"backend.api.features.v1.is_feature_enabled",
side_effect=mock_feature_enabled,
)
mocker.patch(
"backend.api.features.v1.modify_stripe_subscription_for_tier",
new_callable=AsyncMock,
return_value=False,
)
mocker.patch(
"backend.api.features.v1.create_subscription_checkout",
new_callable=AsyncMock,
@@ -413,6 +572,11 @@ def test_update_subscription_tier_rejects_open_redirect(
"backend.api.features.v1.is_feature_enabled",
side_effect=mock_feature_enabled,
)
mocker.patch(
"backend.api.features.v1.modify_stripe_subscription_for_tier",
new_callable=AsyncMock,
return_value=False,
)
checkout_mock = mocker.patch(
"backend.api.features.v1.create_subscription_checkout",
new_callable=AsyncMock,
@@ -593,14 +757,14 @@ def test_update_subscription_tier_same_tier_stripe_error_returns_502(
assert "contact support" in response.json()["detail"].lower()
def test_update_subscription_tier_basic_with_payment_schedules_cancel_and_does_not_update_db(
def test_update_subscription_tier_no_tier_with_payment_schedules_cancel_and_does_not_update_db(
client: fastapi.testclient.TestClient,
mocker: pytest_mock.MockFixture,
) -> None:
"""Downgrading to BASIC schedules Stripe cancellation at period end.
"""Cancelling to NO_TIER schedules Stripe cancellation at period end.
The DB tier must NOT be updated immediately — the customer.subscription.deleted
webhook fires at period end and downgrades to BASIC then.
webhook fires at period end and downgrades to NO_TIER then.
"""
mock_user = Mock()
mock_user.subscription_tier = SubscriptionTier.PRO
@@ -626,18 +790,18 @@ def test_update_subscription_tier_basic_with_payment_schedules_cancel_and_does_n
side_effect=mock_feature_enabled,
)
response = client.post("/credits/subscription", json={"tier": "BASIC"})
response = client.post("/credits/subscription", json={"tier": "NO_TIER"})
assert response.status_code == 200
mock_cancel.assert_awaited_once()
mock_set_tier.assert_not_awaited()
def test_update_subscription_tier_basic_cancel_failure_returns_502(
def test_update_subscription_tier_no_tier_cancel_failure_returns_502(
client: fastapi.testclient.TestClient,
mocker: pytest_mock.MockFixture,
) -> None:
"""Downgrading to BASIC returns 502 with a generic error (no Stripe detail leakage)."""
"""Cancelling to NO_TIER returns 502 with a generic error (no Stripe detail leakage)."""
mock_user = Mock()
mock_user.subscription_tier = SubscriptionTier.PRO
@@ -660,7 +824,7 @@ def test_update_subscription_tier_basic_cancel_failure_returns_502(
side_effect=mock_feature_enabled,
)
response = client.post("/credits/subscription", json={"tier": "BASIC"})
response = client.post("/credits/subscription", json={"tier": "NO_TIER"})
assert response.status_code == 502
detail = response.json()["detail"]
@@ -865,29 +1029,20 @@ def test_update_subscription_tier_max_checkout(
checkout_mock.assert_not_awaited()
def test_update_subscription_tier_admin_granted_paid_to_paid_updates_db_directly(
def test_update_subscription_tier_no_active_sub_falls_through_to_checkout(
client: fastapi.testclient.TestClient,
mocker: pytest_mock.MockFixture,
) -> None:
"""Admin-granted paid tier users are NOT sent to Stripe checkout for paid→paid changes.
"""Any tier change from a user with no active Stripe sub goes through Checkout.
When modify_stripe_subscription_for_tier returns False (no Stripe subscription
found — admin-granted tier), the endpoint must update the DB tier directly and
return 200 with url="", rather than falling through to Checkout Session creation.
Admin-granted users (no Stripe sub yet) and never-paid users follow the
exact same path: modify returns False → Checkout to set up payment. The
endpoint has no admin-specific branch — admin tier grants happen out-of-band
via the admin portal, not this user-facing route.
"""
mock_user = Mock()
mock_user.subscription_tier = SubscriptionTier.PRO
async def price_id_with_business(tier: SubscriptionTier) -> str | None:
return {
**_DEFAULT_TIER_PRICES,
SubscriptionTier.BUSINESS: "price_business",
}.get(tier)
mocker.patch(
"backend.api.features.v1.get_subscription_price_id",
side_effect=price_id_with_business,
)
mocker.patch(
"backend.api.features.v1.get_user_by_id",
new_callable=AsyncMock,
@@ -898,7 +1053,6 @@ def test_update_subscription_tier_admin_granted_paid_to_paid_updates_db_directly
new_callable=AsyncMock,
return_value=True,
)
# Return False = no Stripe subscription (admin-granted tier)
modify_mock = mocker.patch(
"backend.api.features.v1.modify_stripe_subscription_for_tier",
new_callable=AsyncMock,
@@ -911,23 +1065,24 @@ def test_update_subscription_tier_admin_granted_paid_to_paid_updates_db_directly
checkout_mock = mocker.patch(
"backend.api.features.v1.create_subscription_checkout",
new_callable=AsyncMock,
return_value="https://checkout.stripe.com/pay/cs_test_no_sub",
)
response = client.post(
"/credits/subscription",
json={
"tier": "BUSINESS",
"tier": "MAX",
"success_url": f"{TEST_FRONTEND_ORIGIN}/success",
"cancel_url": f"{TEST_FRONTEND_ORIGIN}/cancel",
},
)
assert response.status_code == 200
assert response.json()["url"] == ""
modify_mock.assert_awaited_once_with(TEST_USER_ID, SubscriptionTier.BUSINESS)
# DB tier updated directly — no Stripe Checkout Session created
set_tier_mock.assert_awaited_once_with(TEST_USER_ID, SubscriptionTier.BUSINESS)
checkout_mock.assert_not_awaited()
assert response.json()["url"] == "https://checkout.stripe.com/pay/cs_test_no_sub"
modify_mock.assert_awaited_once_with(TEST_USER_ID, SubscriptionTier.MAX)
# No DB-flip — payment must be collected via Checkout regardless of direction.
set_tier_mock.assert_not_awaited()
checkout_mock.assert_awaited_once()
def test_update_subscription_tier_priced_basic_no_sub_falls_through_to_checkout(
@@ -1098,14 +1253,14 @@ def test_update_subscription_tier_paid_to_paid_stripe_error_returns_502(
assert response.status_code == 502
def test_update_subscription_tier_basic_no_stripe_subscription(
def test_update_subscription_tier_no_tier_no_stripe_subscription(
client: fastapi.testclient.TestClient,
mocker: pytest_mock.MockFixture,
) -> None:
"""Downgrading to BASIC when no Stripe subscription exists updates DB tier directly.
"""Cancelling to NO_TIER when no Stripe subscription exists updates DB tier directly.
Admin-granted paid tiers have no associated Stripe subscription. When such a
user requests a self-service downgrade, cancel_stripe_subscription returns False
user requests a self-service cancel, cancel_stripe_subscription returns False
(nothing to cancel), so the endpoint must immediately call set_subscription_tier
rather than waiting for a webhook that will never arrive.
"""
@@ -1133,13 +1288,13 @@ def test_update_subscription_tier_basic_no_stripe_subscription(
new_callable=AsyncMock,
)
response = client.post("/credits/subscription", json={"tier": "BASIC"})
response = client.post("/credits/subscription", json={"tier": "NO_TIER"})
assert response.status_code == 200
assert response.json()["url"] == ""
cancel_mock.assert_awaited_once_with(TEST_USER_ID)
# DB tier must be updated immediately — no webhook will fire for a missing sub
set_tier_mock.assert_awaited_once_with(TEST_USER_ID, SubscriptionTier.BASIC)
set_tier_mock.assert_awaited_once_with(TEST_USER_ID, SubscriptionTier.NO_TIER)
def test_get_subscription_status_includes_pending_tier(

View File

@@ -44,6 +44,7 @@ from backend.api.model import (
UploadFileResponse,
)
from backend.blocks import get_block, get_blocks
from backend.copilot.rate_limit import get_tier_multipliers
from backend.data import execution as execution_db
from backend.data import graph as graph_db
from backend.data.auth import api_key as api_key_db
@@ -56,12 +57,14 @@ from backend.data.credit import (
UserCredit,
cancel_stripe_subscription,
create_subscription_checkout,
get_active_subscription_period_end,
get_auto_top_up,
get_pending_subscription_change,
get_proration_credit_cents,
get_subscription_price_id,
get_user_credit_model,
handle_subscription_payment_failure,
handle_subscription_payment_success,
modify_stripe_subscription_for_tier,
release_pending_subscription_schedule,
set_auto_top_up,
@@ -699,17 +702,42 @@ async def get_user_auto_top_up(
class SubscriptionTierRequest(BaseModel):
tier: Literal["BASIC", "PRO", "MAX", "BUSINESS"]
tier: Literal["NO_TIER", "BASIC", "PRO", "MAX", "BUSINESS"]
success_url: str = ""
cancel_url: str = ""
class SubscriptionStatusResponse(BaseModel):
tier: Literal["BASIC", "PRO", "MAX", "BUSINESS", "ENTERPRISE"]
tier: Literal["NO_TIER", "BASIC", "PRO", "MAX", "BUSINESS", "ENTERPRISE"]
monthly_cost: int # amount in cents (Stripe convention)
tier_costs: dict[str, int] # tier name -> amount in cents
tier_multipliers: dict[str, float] = Field(
default_factory=dict,
description=(
"Tier → rate-limit multiplier. Covers the same tiers listed in"
" ``tier_costs`` so the frontend can render rate-limit badges"
" relative to the lowest visible tier without knowing backend"
" defaults."
),
)
proration_credit_cents: int # unused portion of current sub to convert on upgrade
pending_tier: Optional[Literal["BASIC", "PRO", "MAX", "BUSINESS"]] = None
has_active_stripe_subscription: bool = Field(
default=False,
description=(
"True when the user has an active/trialing Stripe subscription. The"
" frontend uses this to branch upgrade UX: modify-in-place + saved-card"
" auto-charge when True, redirect to Stripe Checkout when False."
),
)
current_period_end: Optional[int] = Field(
default=None,
description=(
"Unix timestamp of the active subscription's current_period_end. Used"
" to show the date Stripe will issue the next invoice (with prorated"
" upgrade charges, if any). None when no active sub."
),
)
pending_tier: Optional[Literal["NO_TIER", "BASIC", "PRO", "MAX", "BUSINESS"]] = None
pending_tier_effective_at: Optional[datetime] = None
url: str = Field(
default="",
@@ -794,8 +822,11 @@ async def get_subscription_status(
user_id: Annotated[str, Security(get_user_id)],
) -> SubscriptionStatusResponse:
user = await get_user_by_id(user_id)
tier = user.subscription_tier or SubscriptionTier.BASIC
tier = user.subscription_tier or SubscriptionTier.NO_TIER
# Tiers that *can* have a Stripe price configured (and therefore appear
# in the tier picker if the LD flag exposes a price-id). NO_TIER is not
# priceable — it's the implicit "no active subscription" state.
priceable_tiers = [
SubscriptionTier.BASIC,
SubscriptionTier.PRO,
@@ -816,8 +847,23 @@ async def get_subscription_status(
if pid:
tier_costs[t.value] = cost
# Expose the effective rate-limit multipliers alongside prices so the
# frontend can render "Nx rate limits" relative to the lowest visible
# tier without hard-coding backend defaults. Only emit entries for tiers
# that land in ``tier_costs`` — rows hidden at the price layer must stay
# hidden in the multiplier layer too.
multipliers = await get_tier_multipliers()
tier_multipliers: dict[str, float] = {
t.value: multipliers.get(t, 1.0)
for t in priceable_tiers
if t.value in tier_costs
}
current_monthly_cost = tier_costs.get(tier.value, 0)
proration_credit = await get_proration_credit_cents(user_id, current_monthly_cost)
proration_credit, current_period_end = await asyncio.gather(
get_proration_credit_cents(user_id, current_monthly_cost),
get_active_subscription_period_end(user_id),
)
try:
pending = await get_pending_subscription_change(user_id)
@@ -837,11 +883,15 @@ async def get_subscription_status(
tier=tier.value,
monthly_cost=current_monthly_cost,
tier_costs=tier_costs,
tier_multipliers=tier_multipliers,
proration_credit_cents=proration_credit,
has_active_stripe_subscription=current_period_end is not None,
current_period_end=current_period_end,
)
if pending is not None:
pending_tier_enum, pending_effective_at = pending
if pending_tier_enum in (
SubscriptionTier.NO_TIER,
SubscriptionTier.BASIC,
SubscriptionTier.PRO,
SubscriptionTier.MAX,
@@ -869,7 +919,7 @@ async def update_subscription_tier(
# ENTERPRISE tier is admin-managed — block self-service changes from ENTERPRISE users.
user = await get_user_by_id(user_id)
if (
user.subscription_tier or SubscriptionTier.BASIC
user.subscription_tier or SubscriptionTier.NO_TIER
) == SubscriptionTier.ENTERPRISE:
raise HTTPException(
status_code=403,
@@ -881,7 +931,7 @@ async def update_subscription_tier(
# collapsed behaviour that replaces the old /credits/subscription/cancel-pending
# route. Safe when no pending change exists: release_pending_subscription_schedule
# returns False and we simply return the current status.
if (user.subscription_tier or SubscriptionTier.BASIC) == tier:
if (user.subscription_tier or SubscriptionTier.NO_TIER) == tier:
try:
await release_pending_subscription_schedule(user_id)
except stripe.StripeError as e:
@@ -903,18 +953,14 @@ async def update_subscription_tier(
Flag.ENABLE_PLATFORM_PAYMENT, user_id, default=False
)
current_tier = user.subscription_tier or SubscriptionTier.BASIC
target_price_id, current_tier_price_id = await asyncio.gather(
get_subscription_price_id(tier),
get_subscription_price_id(current_tier),
)
target_price_id = await get_subscription_price_id(tier)
# Legacy cancel: target BASIC + stripe-price-id-basic unset. Schedule Stripe
# cancellation at period end; cancel_at_period_end=True lets the webhook flip
# the DB tier. No active sub (admin-granted) or payment disabled → DB flip.
# Once stripe-price-id-basic is configured, BASIC becomes a real sub and falls
# through to the modify/checkout flow below.
if tier == SubscriptionTier.BASIC and target_price_id is None:
# Cancel: target NO_TIER. Schedule Stripe cancellation at period end;
# cancel_at_period_end=True lets the webhook flip the DB tier. No active
# sub (admin-granted or never-paid) or payment disabled → DB flip.
# NO_TIER is never priceable, so this branch always fires for cancel
# requests regardless of LD config.
if tier == SubscriptionTier.NO_TIER:
if payment_enabled:
try:
had_subscription = await cancel_stripe_subscription(user_id)
@@ -950,32 +996,53 @@ async def update_subscription_tier(
detail=f"Subscription not available for tier {tier.value}",
)
# User has an active Stripe subscription (current tier has an LD price):
# modify it in-place. modify_stripe_subscription_for_tier returns False when no
# active sub exists — that's only a "DB-only flip is OK" signal for admin-granted
# paid tiers (PRO/BUSINESS with no Stripe record). Priced-BASIC users without a
# sub must still go through Checkout so they set up payment.
if current_tier_price_id is not None:
try:
modified = await modify_stripe_subscription_for_tier(user_id, tier)
if modified:
return await get_subscription_status(user_id)
if current_tier != SubscriptionTier.BASIC:
await set_subscription_tier(user_id, tier)
return await get_subscription_status(user_id)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
except stripe.StripeError as e:
logger.exception(
"Stripe error modifying subscription for user %s: %s", user_id, e
# Modify in place if there's a sub; else fall through to Checkout below.
try:
modified = await modify_stripe_subscription_for_tier(user_id, tier)
if modified:
return await get_subscription_status(user_id)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
except stripe.InvalidRequestError as e:
# Stripe rejects schedule modify when phases mix currencies, e.g. the
# active sub was checked out in GBP but the target tier's Price is
# USD-only. 502 reads as outage; surface a 422 with a specific message
# so the user/admin can see what to fix in Stripe.
msg = str(e)
if "currency" in msg.lower():
logger.warning(
"Currency mismatch on tier change for user %s: %s", user_id, msg
)
raise HTTPException(
status_code=502,
status_code=422,
detail=(
"Unable to update your subscription right now. "
"Please try again or contact support."
"Tier change unavailable for your current billing currency."
" Please contact support — the target tier needs to be"
" configured for your currency in Stripe before this"
" change can go through."
),
)
logger.exception(
"Stripe error modifying subscription for user %s: %s", user_id, e
)
raise HTTPException(
status_code=502,
detail=(
"Unable to update your subscription right now. "
"Please try again or contact support."
),
)
except stripe.StripeError as e:
logger.exception(
"Stripe error modifying subscription for user %s: %s", user_id, e
)
raise HTTPException(
status_code=502,
detail=(
"Unable to update your subscription right now. "
"Please try again or contact support."
),
)
# No active Stripe subscription → create Stripe Checkout Session.
if not request.success_url or not request.cancel_url:
@@ -1111,6 +1178,9 @@ async def stripe_webhook(request: Request):
):
await sync_subscription_schedule_from_stripe(data_object)
if event_type == "invoice.payment_succeeded":
await handle_subscription_payment_success(data_object)
if event_type == "invoice.payment_failed":
await handle_subscription_payment_failure(data_object)

View File

@@ -34,6 +34,7 @@ import backend.api.features.oauth
import backend.api.features.otto.routes
import backend.api.features.platform_linking.routes
import backend.api.features.postmark.postmark
import backend.api.features.push.routes as push_routes
import backend.api.features.store.model
import backend.api.features.store.routes
import backend.api.features.v1
@@ -41,6 +42,7 @@ import backend.api.features.workspace.routes as workspace_routes
import backend.data.block
import backend.data.db
import backend.data.graph
import backend.data.redis_client
import backend.data.user
import backend.integrations.webhooks.utils
import backend.util.service
@@ -95,6 +97,8 @@ async def lifespan_context(app: fastapi.FastAPI):
verify_auth_settings()
await backend.data.db.connect()
# Eager connect to fail-fast if Redis is unreachable.
await backend.data.redis_client.get_redis_async()
# Configure thread pool for FastAPI sync operation performance
# CRITICAL: FastAPI automatically runs ALL sync functions in this thread pool:
@@ -146,7 +150,18 @@ async def lifespan_context(app: fastapi.FastAPI):
except Exception as e:
logger.warning(f"Error shutting down workspace storage: {e}")
await backend.data.db.disconnect()
# Each cleanup is wrapped so one failure doesn't block the rest. The
# Redis close in particular silences asyncio's "Unclosed ClusterNode"
# GC warning at interpreter shutdown.
try:
await backend.data.redis_client.disconnect_async()
except Exception:
logger.warning("redis_client.disconnect_async failed", exc_info=True)
try:
await backend.data.db.disconnect()
except Exception:
logger.warning("db.disconnect failed", exc_info=True)
def custom_generate_unique_id(route: APIRoute):
@@ -379,6 +394,11 @@ app.include_router(
tags=["oauth"],
prefix="/api/oauth",
)
app.include_router(
push_routes.router,
tags=["push"],
prefix="/api/push",
)
app.include_router(
backend.api.features.platform_linking.routes.router,
tags=["platform-linking"],

View File

@@ -1,4 +1,3 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from typing import Protocol
@@ -17,14 +16,12 @@ from backend.api.model import (
WSSubscribeGraphExecutionsRequest,
)
from backend.api.utils.cors import build_cors_params
from backend.data.execution import AsyncRedisExecutionEventBus
from backend.data.notification_bus import AsyncRedisNotificationEventBus
from backend.data import db, redis_client
from backend.data.user import DEFAULT_USER_ID
from backend.monitoring.instrumentation import (
instrument_fastapi,
update_websocket_connections,
)
from backend.util.retry import continuous_retry
from backend.util.service import AppProcess
from backend.util.settings import AppEnvironment, Config, Settings
@@ -34,10 +31,24 @@ settings = Settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
manager = get_connection_manager()
fut = asyncio.create_task(event_broadcaster(manager))
fut.add_done_callback(lambda _: logger.info("Event broadcaster stopped"))
yield
# Prisma is needed to resolve graph_id from graph_exec_id on subscribe.
await db.connect()
# Eager connect to fail-fast if Redis is unreachable.
await redis_client.get_redis_async()
try:
yield
finally:
# Each cleanup is wrapped so one failure doesn't block the rest. The
# Redis close silences asyncio's "Unclosed ClusterNode" GC warning at
# interpreter shutdown.
try:
await redis_client.disconnect_async()
except Exception:
logger.warning("redis_client.disconnect_async failed", exc_info=True)
try:
await db.disconnect()
except Exception:
logger.warning("db.disconnect failed", exc_info=True)
docs_url = "/docs" if settings.config.app_env == AppEnvironment.LOCAL else None
@@ -61,31 +72,6 @@ def get_connection_manager():
return _connection_manager
@continuous_retry()
async def event_broadcaster(manager: ConnectionManager):
execution_bus = AsyncRedisExecutionEventBus()
notification_bus = AsyncRedisNotificationEventBus()
try:
async def execution_worker():
async for event in execution_bus.listen("*"):
await manager.send_execution_update(event)
async def notification_worker():
async for notification in notification_bus.listen("*"):
await manager.send_notification(
user_id=notification.user_id,
payload=notification.payload,
)
await asyncio.gather(execution_worker(), notification_worker())
finally:
# Ensure PubSub connections are closed on any exit to prevent leaks
await execution_bus.close()
await notification_bus.close()
async def authenticate_websocket(websocket: WebSocket) -> str:
if not settings.config.enable_auth:
return DEFAULT_USER_ID
@@ -297,6 +283,21 @@ async def websocket_router(
).model_dump_json()
)
continue
except ValueError as e:
logger.warning(
"Subscription rejected for user #%s on '%s': %s",
user_id,
message.method.value,
e,
)
await websocket.send_text(
WSMessage(
method=WSMethod.ERROR,
success=False,
error=str(e),
).model_dump_json()
)
continue
except Exception as e:
logger.error(
f"Error while handling '{message.method.value}' message "
@@ -321,9 +322,13 @@ async def websocket_router(
)
except WebSocketDisconnect:
manager.disconnect_socket(websocket, user_id=user_id)
logger.debug("WebSocket client disconnected")
except Exception:
logger.exception(f"Unexpected error in websocket_router for user #{user_id}")
finally:
# Always release subscription pumps + Redis connections, regardless of how
# the loop exited — otherwise non-WebSocketDisconnect failures leak both.
await manager.disconnect_socket(websocket, user_id=user_id)
update_websocket_connections(user_id, -1)

View File

@@ -44,9 +44,12 @@ def test_websocket_server_uses_cors_helper(mocker) -> None:
"backend.api.ws_api.build_cors_params", return_value=cors_params
)
with override_config(
settings, "backend_cors_allow_origins", cors_params["allow_origins"]
), override_config(settings, "app_env", AppEnvironment.LOCAL):
with (
override_config(
settings, "backend_cors_allow_origins", cors_params["allow_origins"]
),
override_config(settings, "app_env", AppEnvironment.LOCAL),
):
WebsocketServer().run()
build_cors.assert_called_once_with(
@@ -65,9 +68,12 @@ def test_websocket_server_uses_cors_helper(mocker) -> None:
def test_websocket_server_blocks_localhost_in_production(mocker) -> None:
mocker.patch("backend.api.ws_api.uvicorn.run")
with override_config(
settings, "backend_cors_allow_origins", ["http://localhost:3000"]
), override_config(settings, "app_env", AppEnvironment.PRODUCTION):
with (
override_config(
settings, "backend_cors_allow_origins", ["http://localhost:3000"]
),
override_config(settings, "app_env", AppEnvironment.PRODUCTION),
):
with pytest.raises(ValueError):
WebsocketServer().run()
@@ -290,7 +296,232 @@ async def test_handle_unsubscribe_missing_data(
message=message,
)
mock_manager._unsubscribe.assert_not_called()
mock_manager.unsubscribe_graph_exec.assert_not_called()
mock_websocket.send_text.assert_called_once()
assert '"method":"error"' in mock_websocket.send_text.call_args[0][0]
assert '"success":false' in mock_websocket.send_text.call_args[0][0]
# ---------- Per-graph subscribe branch ----------
@pytest.mark.asyncio
async def test_handle_subscribe_graph_execs_branch(
mock_websocket: AsyncMock, mock_manager: AsyncMock
) -> None:
"""The SUBSCRIBE_GRAPH_EXECS branch must route to subscribe_graph_execs,
not subscribe_graph_exec — regression guard for the aggregate channel."""
message = WSMessage(
method=WSMethod.SUBSCRIBE_GRAPH_EXECS,
data={"graph_id": "graph-abc"},
)
mock_manager.subscribe_graph_execs.return_value = (
"user-1|graph#graph-abc|executions"
)
await handle_subscribe(
connection_manager=cast(ConnectionManager, mock_manager),
websocket=cast(WebSocket, mock_websocket),
user_id="user-1",
message=message,
)
mock_manager.subscribe_graph_execs.assert_called_once_with(
user_id="user-1",
graph_id="graph-abc",
websocket=mock_websocket,
)
mock_manager.subscribe_graph_exec.assert_not_called()
mock_websocket.send_text.assert_called_once()
assert (
'"method":"subscribe_graph_executions"'
in mock_websocket.send_text.call_args[0][0]
)
assert '"success":true' in mock_websocket.send_text.call_args[0][0]
@pytest.mark.asyncio
async def test_handle_subscribe_rejects_unrelated_method(
mock_websocket: AsyncMock, mock_manager: AsyncMock
) -> None:
"""handle_subscribe must raise for methods that aren't SUBSCRIBE_*."""
import pytest as _pytest
message = WSMessage(
method=WSMethod.HEARTBEAT,
data={"graph_exec_id": "x"},
)
with _pytest.raises(ValueError):
await handle_subscribe(
connection_manager=cast(ConnectionManager, mock_manager),
websocket=cast(WebSocket, mock_websocket),
user_id="user-1",
message=message,
)
# ---------- authenticate_websocket branches ----------
@pytest.mark.asyncio
async def test_authenticate_websocket_missing_token_closes_4001(mocker) -> None:
from backend.api.ws_api import authenticate_websocket
mocker.patch.object(settings.config, "enable_auth", True)
ws = AsyncMock(spec=WebSocket)
ws.query_params = {}
user_id = await authenticate_websocket(ws)
ws.close.assert_awaited_once()
assert ws.close.call_args.kwargs["code"] == 4001
assert user_id == ""
@pytest.mark.asyncio
async def test_authenticate_websocket_invalid_token_closes_4003(mocker) -> None:
from backend.api.ws_api import authenticate_websocket
mocker.patch.object(settings.config, "enable_auth", True)
mocker.patch(
"backend.api.ws_api.parse_jwt_token", side_effect=ValueError("bad token")
)
ws = AsyncMock(spec=WebSocket)
ws.query_params = {"token": "abc"}
user_id = await authenticate_websocket(ws)
ws.close.assert_awaited_once()
assert ws.close.call_args.kwargs["code"] == 4003
assert user_id == ""
@pytest.mark.asyncio
async def test_authenticate_websocket_missing_sub_closes_4002(mocker) -> None:
from backend.api.ws_api import authenticate_websocket
mocker.patch.object(settings.config, "enable_auth", True)
mocker.patch("backend.api.ws_api.parse_jwt_token", return_value={"not_sub": "x"})
ws = AsyncMock(spec=WebSocket)
ws.query_params = {"token": "abc"}
user_id = await authenticate_websocket(ws)
ws.close.assert_awaited_once()
assert ws.close.call_args.kwargs["code"] == 4002
assert user_id == ""
@pytest.mark.asyncio
async def test_authenticate_websocket_happy_path_returns_sub(mocker) -> None:
from backend.api.ws_api import authenticate_websocket
mocker.patch.object(settings.config, "enable_auth", True)
mocker.patch("backend.api.ws_api.parse_jwt_token", return_value={"sub": "user-X"})
ws = AsyncMock(spec=WebSocket)
ws.query_params = {"token": "abc"}
user_id = await authenticate_websocket(ws)
assert user_id == "user-X"
@pytest.mark.asyncio
async def test_authenticate_websocket_auth_disabled_returns_default(mocker) -> None:
from backend.api.ws_api import authenticate_websocket
mocker.patch.object(settings.config, "enable_auth", False)
ws = AsyncMock(spec=WebSocket)
ws.query_params = {}
user_id = await authenticate_websocket(ws)
assert user_id == DEFAULT_USER_ID
# ---------- get_connection_manager singleton ----------
def test_get_connection_manager_singleton() -> None:
"""Repeated calls must return the same ConnectionManager — the WS router
depends on a single process-wide subscription table."""
import backend.api.ws_api as ws_api
ws_api._connection_manager = None
a = ws_api.get_connection_manager()
b = ws_api.get_connection_manager()
assert a is b
assert isinstance(a, ConnectionManager)
# ---------- Lifespan: Prisma connect/disconnect ----------
@pytest.mark.asyncio
async def test_lifespan_connects_and_disconnects_prisma(mocker) -> None:
"""Lifespan must both connect() and disconnect() db — the subscribe path
resolves graph_id via Prisma so a missing connect() is the regression bug."""
from fastapi import FastAPI
from backend.api.ws_api import lifespan
mock_db = mocker.patch("backend.api.ws_api.db")
mock_db.connect = AsyncMock()
mock_db.disconnect = AsyncMock()
dummy_app = FastAPI()
async with lifespan(dummy_app):
mock_db.connect.assert_awaited_once()
mock_db.disconnect.assert_not_called()
mock_db.disconnect.assert_awaited_once()
@pytest.mark.asyncio
async def test_lifespan_still_disconnects_on_exception(mocker) -> None:
"""If the app raises inside the yield, Prisma must still disconnect."""
from fastapi import FastAPI
from backend.api.ws_api import lifespan
mock_db = mocker.patch("backend.api.ws_api.db")
mock_db.connect = AsyncMock()
mock_db.disconnect = AsyncMock()
dummy_app = FastAPI()
class _Boom(Exception):
pass
with pytest.raises(_Boom):
async with lifespan(dummy_app):
raise _Boom()
mock_db.disconnect.assert_awaited_once()
# ---------- Health endpoint ----------
def test_health_endpoint_returns_ok() -> None:
# TestClient triggers lifespan — stub it out so Prisma isn't hit.
from contextlib import asynccontextmanager
from fastapi.testclient import TestClient
import backend.api.ws_api as ws_api
@asynccontextmanager
async def _noop_lifespan(app):
yield
# Replace the app-level lifespan temporarily.
real_router_lifespan = ws_api.app.router.lifespan_context
ws_api.app.router.lifespan_context = _noop_lifespan
try:
with TestClient(ws_api.app) as client:
r = client.get("/")
assert r.status_code == 200
assert r.json() == {"status": "healthy"}
finally:
ws_api.app.router.lifespan_context = real_router_lifespan

View File

@@ -38,6 +38,7 @@ def main(**kwargs):
from backend.api.rest_api import AgentServer
from backend.api.ws_api import WebsocketServer
from backend.copilot.bot.app import CoPilotChatBridge
from backend.copilot.executor.manager import CoPilotExecutor
from backend.data.db_manager import DatabaseManager
from backend.executor import ExecutionManager, Scheduler
@@ -52,6 +53,7 @@ def main(**kwargs):
WebsocketServer(),
AgentServer(),
ExecutionManager(),
CoPilotChatBridge(),
CoPilotExecutor(),
**kwargs,
)

View File

@@ -0,0 +1,56 @@
"""Provider descriptions for services that don't yet have their own ``_config.py``.
Every provider in ``_STATIC_PROVIDER_CONFIGS`` below is declared here because
its block code currently lives either in a single shared file (e.g. the 8 LLM
providers in ``blocks/llm.py``) or in a single-file block that has no dedicated
directory (e.g. ``blocks/reddit.py``).
This file gets loaded by the block auto-loader in ``blocks/__init__.py``
(``rglob("*.py")`` picks it up) so the ``ProviderBuilder(...).build()`` calls
run at startup and populate ``AutoRegistry`` before the first API request.
**Migration path:** when a provider graduates into its own directory with a
proper ``_config.py`` (following the SDK pattern, e.g. ``blocks/linear/_config.py``),
delete its entry here. The metadata will still be served by
``GET /integrations/providers`` — it just moves to live next to the provider's
auth and webhook config.
"""
from backend.data.model import CredentialsType
from backend.sdk import ProviderBuilder
_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",)),
"anthropic": ("Claude language models", ("api_key",)),
"groq": ("Fast LLM inference", ("api_key",)),
"llama_api": ("Llama model hosting", ("api_key",)),
"ollama": ("Run open-source LLMs locally", ("api_key",)),
"open_router": ("One API for every LLM", ("api_key",)),
"openai": ("GPT models and embeddings", ("api_key",)),
"v0": ("AI-generated UI components", ("api_key",)),
# Single-file providers (one provider per standalone blocks/*.py file)
"d_id": ("AI avatar and video generation", ("api_key",)),
"e2b": ("Sandboxed code execution", ("api_key",)),
"google_maps": ("Places, directions, geocoding", ("api_key",)),
"http": ("Generic HTTP requests", ("api_key", "host_scoped")),
"ideogram": ("Text-to-image generation", ("api_key",)),
"medium": ("Publish stories and posts", ("api_key",)),
"mem0": ("Long-term memory for agents", ("api_key",)),
"openweathermap": ("Weather data and forecasts", ("api_key",)),
"pinecone": ("Managed vector database", ("api_key",)),
"reddit": ("Subreddits, posts, and comments", ("oauth2",)),
"revid": ("AI-generated short-form video", ("api_key",)),
"screenshotone": ("Automated website screenshots", ("api_key",)),
"smtp": ("Send email via SMTP", ("user_password",)),
"unreal_speech": ("Low-cost text-to-speech", ("api_key",)),
"webshare_proxy": ("Rotating proxies for scraping", ("api_key",)),
}
for _name, (_description, _auth_types) in _STATIC_PROVIDER_CONFIGS.items():
(
ProviderBuilder(_name)
.with_description(_description)
.with_supported_auth_types(*_auth_types)
.build()
)

View File

@@ -12,6 +12,7 @@ from backend.sdk import APIKeyCredentials, BlockCostType, ProviderBuilder, Secre
# past billing. Revisit once AgentMail publishes usage-based pricing.
agent_mail = (
ProviderBuilder("agent_mail")
.with_description("Managed email accounts for agents")
.with_api_key("AGENTMAIL_API_KEY", "AgentMail API Key")
.with_base_cost(1, BlockCostType.RUN)
.build()

View File

@@ -10,6 +10,7 @@ from ._webhook import AirtableWebhookManager
# Configure the Airtable provider with API key authentication
airtable = (
ProviderBuilder("airtable")
.with_description("Bases, tables, and records")
.with_api_key("AIRTABLE_API_KEY", "Airtable Personal Access Token")
.with_webhook_manager(AirtableWebhookManager)
.with_base_cost(1, BlockCostType.RUN)

View File

@@ -0,0 +1,15 @@
"""Provider registration for Apollo.
Registers the provider description shown in the settings integrations UI.
Apollo doesn't use a full :class:`ProviderBuilder` chain (auth is set up in
``_auth.py``), so this file only declares metadata.
"""
from backend.sdk import ProviderBuilder
apollo = (
ProviderBuilder("apollo")
.with_description("Sales intelligence and prospecting")
.with_supported_auth_types("api_key")
.build()
)

View File

@@ -7,6 +7,7 @@ import logging
import uuid
from typing import TYPE_CHECKING, Any
from pydantic import field_validator
from typing_extensions import TypedDict # Needed for Python <3.12 compatibility
from backend.blocks._base import (
@@ -17,6 +18,7 @@ from backend.blocks._base import (
BlockSchemaOutput,
)
from backend.copilot.permissions import (
DISABLED_LEGACY_TOOL_NAMES,
CopilotPermissions,
ToolName,
all_known_tool_names,
@@ -198,6 +200,13 @@ class AutoPilotBlock(Block):
# timeouts internally; wrapping with asyncio.timeout corrupts the
# SDK's internal stream (see service.py CRITICAL comment).
@field_validator("tools", mode="before")
@classmethod
def strip_disabled_legacy_tools(cls, tools: Any) -> Any:
if not isinstance(tools, list):
return tools
return [tool for tool in tools if tool not in DISABLED_LEGACY_TOOL_NAMES]
class Output(BlockSchemaOutput):
"""Output schema for the AutoPilot block."""

View File

@@ -62,6 +62,14 @@ class TestBuildAndValidatePermissions:
with pytest.raises(ValidationError, match="not_a_real_tool"):
_make_input(tools=["not_a_real_tool"])
async def test_disabled_legacy_tool_is_accepted_and_removed(self):
inp = _make_input(tools=["ask_question", "run_block"])
result = await _build_and_validate_permissions(inp)
assert inp.tools == ["run_block"]
assert isinstance(result, CopilotPermissions)
assert result.tools == ["run_block"]
async def test_valid_block_name_accepted(self):
mock_block_cls = MagicMock()
mock_block_cls.return_value.name = "HTTP Request"

View File

@@ -18,4 +18,9 @@ reach a block as a "profile key".
from backend.sdk import ProviderBuilder
ayrshare = ProviderBuilder("ayrshare").with_managed_api_key().build()
ayrshare = (
ProviderBuilder("ayrshare")
.with_description("Post to every social network")
.with_managed_api_key()
.build()
)

View File

@@ -7,6 +7,7 @@ from backend.sdk import BlockCostType, ProviderBuilder
# Configure the Meeting BaaS provider with API key authentication
baas = (
ProviderBuilder("baas")
.with_description("Meeting recording and transcription")
.with_api_key("MEETING_BAAS_API_KEY", "Meeting BaaS API Key")
.with_base_cost(5, BlockCostType.RUN) # Higher cost for meeting recording service
.build()

View File

@@ -4,6 +4,7 @@ Meeting BaaS bot (recording) blocks.
from typing import Optional
from backend.data.model import NodeExecutionStats
from backend.sdk import (
APIKeyCredentials,
Block,
@@ -21,13 +22,15 @@ from backend.sdk import (
from ._api import MeetingBaasAPI
from ._config import baas
# Meeting BaaS recording rate: $0.69 per hour.
_MEETING_BAAS_USD_PER_SECOND = 0.69 / 3600
# Join bills a flat 30 cr commit (covers median short meeting);
# FetchMeetingData bills the duration-scaled remainder from the
# `duration_seconds` field on the API response. Long meetings no
# longer under-bill.
# Meeting BaaS charges $0.69/hour of recording. The Join block is the
# trigger that starts the recording session; the meeting itself runs out
# of band (we don't get duration back from the FetchMeetingData response
# we use). 30 cr ≈ $0.30 covers a median 30-minute meeting with margin.
# Interim until FetchMeetingData surfaces duration for post-flight
# reconciliation.
@cost(BlockCost(cost_type=BlockCostType.RUN, cost_amount=30))
class BaasBotJoinMeetingBlock(Block):
"""
@@ -144,6 +147,7 @@ class BaasBotLeaveMeetingBlock(Block):
yield "left", left
@cost(BlockCost(cost_type=BlockCostType.COST_USD, cost_amount=150))
class BaasBotFetchMeetingDataBlock(Block):
"""
Pull MP4 URL, transcript & metadata for a completed meeting.
@@ -186,9 +190,21 @@ class BaasBotFetchMeetingDataBlock(Block):
include_transcripts=input_data.include_transcripts,
)
bot_meta = data.get("bot_data", {}).get("bot", {}) or {}
# Bill recording duration via COST_USD so multi-hour meetings
# scale past the Join block's flat 30 cr deposit.
duration_seconds = float(bot_meta.get("duration_seconds") or 0)
if duration_seconds > 0:
self.merge_stats(
NodeExecutionStats(
provider_cost=duration_seconds * _MEETING_BAAS_USD_PER_SECOND,
provider_cost_type="cost_usd",
)
)
yield "mp4_url", data.get("mp4", "")
yield "transcript", data.get("bot_data", {}).get("transcripts", [])
yield "metadata", data.get("bot_data", {}).get("bot", {})
yield "metadata", bot_meta
class BaasBotDeleteRecordingBlock(Block):

View File

@@ -0,0 +1,86 @@
"""Unit tests for Meeting BaaS duration-based cost emission."""
from unittest.mock import AsyncMock, patch
import pytest
from pydantic import SecretStr
from backend.blocks.baas.bots import (
_MEETING_BAAS_USD_PER_SECOND,
BaasBotFetchMeetingDataBlock,
)
from backend.data.model import APIKeyCredentials, NodeExecutionStats
TEST_CREDENTIALS = APIKeyCredentials(
id="01234567-89ab-cdef-0123-456789abcdef",
provider="baas",
title="Mock BaaS API Key",
api_key=SecretStr("mock-baas-api-key"),
expires_at=None,
)
def test_usd_per_second_derives_from_published_rate():
"""$0.69/hour published rate → ~$0.000192/second."""
assert _MEETING_BAAS_USD_PER_SECOND == pytest.approx(0.69 / 3600)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"duration_seconds, expected_usd",
[
(3600, 0.69), # 1 hour
(1800, 0.345), # 30 min
(0, None), # no recording → no emission
(None, None), # missing duration field → no emission
],
)
async def test_fetch_meeting_data_emits_duration_cost_usd(
duration_seconds, expected_usd
):
"""FetchMeetingData extracts duration_seconds from bot metadata and
emits provider_cost / cost_usd scaled by the published $0.69/hr rate.
Emission is skipped when duration is 0 or missing.
"""
block = BaasBotFetchMeetingDataBlock()
bot_meta = {"id": "bot-xyz"}
if duration_seconds is not None:
bot_meta["duration_seconds"] = duration_seconds
mock_api = AsyncMock()
mock_api.get_meeting_data.return_value = {
"mp4": "https://example/recording.mp4",
"bot_data": {"bot": bot_meta, "transcripts": []},
}
captured: list[NodeExecutionStats] = []
with (
patch("backend.blocks.baas.bots.MeetingBaasAPI", return_value=mock_api),
patch.object(block, "merge_stats", side_effect=captured.append),
):
outputs = []
async for name, val in block.run(
block.input_schema(
credentials={
"id": TEST_CREDENTIALS.id,
"provider": TEST_CREDENTIALS.provider,
"type": TEST_CREDENTIALS.type,
},
bot_id="bot-xyz",
include_transcripts=False,
),
credentials=TEST_CREDENTIALS,
):
outputs.append((name, val))
# Always yields the 3 outputs regardless of duration.
names = [n for n, _ in outputs]
assert "mp4_url" in names and "metadata" in names
if expected_usd is None:
assert captured == []
else:
assert len(captured) == 1
assert captured[0].provider_cost == pytest.approx(expected_usd)
assert captured[0].provider_cost_type == "cost_usd"

View File

@@ -2,6 +2,7 @@ from backend.sdk import BlockCostType, ProviderBuilder
bannerbear = (
ProviderBuilder("bannerbear")
.with_description("Auto-generate images and videos")
.with_api_key("BANNERBEAR_API_KEY", "Bannerbear API Key")
.with_base_cost(3, BlockCostType.RUN)
.build()

View File

@@ -433,7 +433,7 @@ class TestJinaEmbeddingBlockCostTracking:
class TestUnrealTextToSpeechBlockCostTracking:
@pytest.mark.asyncio
async def test_merge_stats_called_with_character_count(self):
"""provider_cost equals len(text) with type='characters'."""
"""provider_cost = len(text) * $0.000016 with type='cost_usd'."""
from backend.blocks.text_to_speech_block import TEST_CREDENTIALS as TTS_CREDS
from backend.blocks.text_to_speech_block import (
TEST_CREDENTIALS_INPUT as TTS_CREDS_INPUT,
@@ -461,12 +461,12 @@ class TestUnrealTextToSpeechBlockCostTracking:
mock_merge.assert_called_once()
stats = mock_merge.call_args[0][0]
assert stats.provider_cost == float(len(test_text))
assert stats.provider_cost_type == "characters"
assert stats.provider_cost == pytest.approx(len(test_text) * 0.000016)
assert stats.provider_cost_type == "cost_usd"
@pytest.mark.asyncio
async def test_empty_text_gives_zero_characters(self):
"""An empty text string results in provider_cost=0.0."""
"""An empty text string results in provider_cost=0.0 (cost_usd)."""
from backend.blocks.text_to_speech_block import TEST_CREDENTIALS as TTS_CREDS
from backend.blocks.text_to_speech_block import (
TEST_CREDENTIALS_INPUT as TTS_CREDS_INPUT,
@@ -494,7 +494,7 @@ class TestUnrealTextToSpeechBlockCostTracking:
mock_merge.assert_called_once()
stats = mock_merge.call_args[0][0]
assert stats.provider_cost == 0.0
assert stats.provider_cost_type == "characters"
assert stats.provider_cost_type == "cost_usd"
# ---------------------------------------------------------------------------

View File

@@ -17,6 +17,7 @@ from backend.data.model import (
APIKeyCredentials,
CredentialsField,
CredentialsMetaInput,
NodeExecutionStats,
SchemaField,
)
from backend.integrations.providers import ProviderName
@@ -431,6 +432,7 @@ class ClaudeCodeBlock(Block):
# The JSON output contains the result
output_data = json.loads(raw_output)
response = output_data.get("result", raw_output)
self._record_cli_cost(output_data)
# Build conversation history entry
turn_entry = f"User: {prompt}\nClaude: {response}"
@@ -484,6 +486,23 @@ class ClaudeCodeBlock(Block):
escaped = prompt.replace("'", "'\"'\"'")
return f"'{escaped}'"
def _record_cli_cost(self, output_data: dict) -> None:
"""Feed Claude Code CLI's `total_cost_usd` to the COST_USD resolver.
The CLI rolls up Anthropic LLM + internal tool-call spend into
``total_cost_usd`` on its JSON response; piping it through
``merge_stats`` lets the wallet reflect real spend.
"""
total_cost_usd = output_data.get("total_cost_usd")
if total_cost_usd is None:
return
self.merge_stats(
NodeExecutionStats(
provider_cost=float(total_cost_usd),
provider_cost_type="cost_usd",
)
)
async def run(
self,
input_data: Input,

View File

@@ -0,0 +1,106 @@
"""Unit tests for ClaudeCodeBlock COST_USD billing migration.
Verifies:
- Block emits provider_cost / cost_usd when Claude Code CLI returns
total_cost_usd.
- block_usage_cost resolves the COST_USD entry to the expected ceil(usd *
cost_amount) credit charge.
- Missing total_cost_usd gracefully produces provider_cost=None (no bill).
"""
from unittest.mock import MagicMock, patch
import pytest
from backend.blocks._base import BlockCostType
from backend.blocks.claude_code import ClaudeCodeBlock
from backend.data.block_cost_config import BLOCK_COSTS
from backend.data.model import NodeExecutionStats
from backend.executor.utils import block_usage_cost
def test_claude_code_registered_as_cost_usd_150():
"""Sanity: BLOCK_COSTS holds the COST_USD, 150 cr/$ entry."""
entries = BLOCK_COSTS[ClaudeCodeBlock]
assert len(entries) == 1
entry = entries[0]
assert entry.cost_type == BlockCostType.COST_USD
assert entry.cost_amount == 150
@pytest.mark.parametrize(
"total_cost_usd, expected_credits",
[
(0.50, 75), # $0.50 × 150 = 75 cr
(1.00, 150), # $1.00 × 150 = 150 cr
(0.0134, 3), # ceil(0.0134 × 150) = ceil(2.01) = 3
(2.00, 300), # $2 × 150 = 300 cr
(0.001, 1), # ceil(0.001 × 150) = ceil(0.15) = 1 — no 0-cr leak on
# sub-cent runs
],
)
def test_cost_usd_resolver_applies_150_multiplier(total_cost_usd, expected_credits):
"""block_usage_cost with cost_usd stats returns ceil(usd * 150)."""
block = ClaudeCodeBlock()
# cost_filter requires matching e2b_credentials; supply the ones the
# registration uses so _is_cost_filter_match accepts the input.
entry = BLOCK_COSTS[ClaudeCodeBlock][0]
input_data = {"e2b_credentials": entry.cost_filter["e2b_credentials"]}
stats = NodeExecutionStats(
provider_cost=total_cost_usd,
provider_cost_type="cost_usd",
)
cost, matching_filter = block_usage_cost(
block=block, input_data=input_data, stats=stats
)
assert cost == expected_credits
assert matching_filter == entry.cost_filter
def test_cost_usd_resolver_returns_zero_when_stats_missing_cost():
"""Pre-flight (no stats) or unbilled run (provider_cost None) → 0."""
block = ClaudeCodeBlock()
entry = BLOCK_COSTS[ClaudeCodeBlock][0]
input_data = {"e2b_credentials": entry.cost_filter["e2b_credentials"]}
# No stats at all → pre-flight path, returns 0.
pre_cost, _ = block_usage_cost(block=block, input_data=input_data)
assert pre_cost == 0
# Stats present but no provider_cost → resolver can't bill.
stats = NodeExecutionStats()
post_cost, _ = block_usage_cost(block=block, input_data=input_data, stats=stats)
assert post_cost == 0
def test_record_cli_cost_emits_provider_cost_when_total_cost_present():
"""``_record_cli_cost`` (the helper called from ``execute_claude_code``)
must emit a single ``merge_stats`` with provider_cost + cost_usd tag
when the CLI JSON payload carries ``total_cost_usd``.
"""
block = ClaudeCodeBlock()
captured: list[NodeExecutionStats] = []
with patch.object(block, "merge_stats", side_effect=captured.append):
block._record_cli_cost(
{
"result": "hello from claude",
"total_cost_usd": 0.0421,
"usage": {"input_tokens": 1234, "output_tokens": 56},
}
)
assert len(captured) == 1
stats = captured[0]
assert stats.provider_cost == pytest.approx(0.0421)
assert stats.provider_cost_type == "cost_usd"
def test_record_cli_cost_skips_merge_when_total_cost_absent():
"""If the CLI payload lacks ``total_cost_usd`` (legacy / non-JSON
output), ``_record_cli_cost`` must not call ``merge_stats`` — otherwise
we'd pollute telemetry with a ``cost_usd`` emission that has no real
cost attached.
"""
block = ClaudeCodeBlock()
mock = MagicMock()
with patch.object(block, "merge_stats", mock):
block._record_cli_cost({"result": "hello"})
mock.assert_not_called()

View File

@@ -151,6 +151,17 @@ class CodeGenerationBlock(Block):
)
self.execution_stats = NodeExecutionStats()
# GPT-5.1-Codex published pricing: $1.25 / 1M input, $10 / 1M output.
_INPUT_USD_PER_1M = 1.25
_OUTPUT_USD_PER_1M = 10.0
@staticmethod
def _compute_token_usd(input_tokens: int, output_tokens: int) -> float:
return (
input_tokens * CodeGenerationBlock._INPUT_USD_PER_1M
+ output_tokens * CodeGenerationBlock._OUTPUT_USD_PER_1M
) / 1_000_000
async def call_codex(
self,
*,
@@ -189,13 +200,15 @@ class CodeGenerationBlock(Block):
response_id = response.id or ""
# Update usage stats
self.execution_stats.input_token_count = (
response.usage.input_tokens if response.usage else 0
)
self.execution_stats.output_token_count = (
response.usage.output_tokens if response.usage else 0
)
input_tokens = response.usage.input_tokens if response.usage else 0
output_tokens = response.usage.output_tokens if response.usage else 0
self.execution_stats.input_token_count = input_tokens
self.execution_stats.output_token_count = output_tokens
self.execution_stats.llm_call_count += 1
self.execution_stats.provider_cost = self._compute_token_usd(
input_tokens, output_tokens
)
self.execution_stats.provider_cost_type = "cost_usd"
return CodexCallResult(
response=text_output,

View File

@@ -0,0 +1,10 @@
"""Provider registration for Compass — metadata only (auth lives elsewhere)."""
from backend.sdk import ProviderBuilder
compass = (
ProviderBuilder("compass")
.with_description("Geospatial context for agents")
.with_supported_auth_types("api_key")
.build()
)

View File

@@ -0,0 +1,226 @@
"""Coverage tests for the cost-leak fixes in this PR.
Each block's ``run()`` / helper emits provider_cost + cost_usd (or items)
via merge_stats so the post-flight resolver bills real provider spend.
Tests here drive that emission path directly so a regression on any one
block surfaces immediately.
"""
from unittest.mock import patch
import pytest
from pydantic import SecretStr
from backend.blocks._base import BlockCostType
from backend.blocks.ai_condition import AIConditionBlock
from backend.data.block_cost_config import BLOCK_COSTS, LLM_COST
from backend.data.model import APIKeyCredentials, NodeExecutionStats
# -------- AIConditionBlock registration --------
def test_ai_condition_registered_under_llm_cost():
"""AIConditionBlock was running wallet-free before this PR; verify it
now resolves through the same per-model LLM_COST table as every other
LLM block.
"""
assert BLOCK_COSTS[AIConditionBlock] is LLM_COST
# -------- Pinecone insert ITEMS emission --------
@pytest.mark.asyncio
async def test_pinecone_insert_emits_items_provider_cost():
from backend.blocks.pinecone import PineconeInsertBlock
block = PineconeInsertBlock()
captured: list[NodeExecutionStats] = []
class _FakeIndex:
def upsert(self, **_):
return None
class _FakePinecone:
def __init__(self, *_, **__):
pass
def Index(self, _name):
return _FakeIndex()
with (
patch("backend.blocks.pinecone.Pinecone", _FakePinecone),
patch.object(block, "merge_stats", side_effect=captured.append),
):
input_data = block.input_schema(
credentials={
"id": "00000000-0000-0000-0000-000000000000",
"provider": "pinecone",
"type": "api_key",
},
index="my-index",
chunks=["alpha", "beta", "gamma"],
embeddings=[[0.1] * 4, [0.2] * 4, [0.3] * 4],
namespace="",
metadata={},
)
creds = APIKeyCredentials(
id="00000000-0000-0000-0000-000000000000",
provider="pinecone",
title="mock",
api_key=SecretStr("mock-key"),
expires_at=None,
)
outputs = [(n, v) async for n, v in block.run(input_data, credentials=creds)]
assert any(name == "upsert_response" for name, _ in outputs)
assert len(captured) == 1
stats = captured[0]
assert stats.provider_cost == pytest.approx(3.0)
assert stats.provider_cost_type == "items"
# -------- Narration model-aware per-char rate --------
@pytest.mark.parametrize(
"model_id, expected_rate_per_char",
[
("eleven_flash_v2_5", 0.000167 * 0.5),
("eleven_turbo_v2_5", 0.000167 * 0.5),
("eleven_multilingual_v2", 0.000167 * 1.0),
("eleven_turbo_v2", 0.000167 * 1.0),
],
)
def test_narration_per_char_rate_scales_with_model(model_id, expected_rate_per_char):
"""Drive VideoNarrationBlock._record_script_cost directly so a regression
that drops the model-aware branching (e.g. hardcoding 1.0 cr/char for
all models) makes this test fail.
"""
from backend.blocks.video.narration import VideoNarrationBlock
block = VideoNarrationBlock()
captured: list[NodeExecutionStats] = []
with patch.object(block, "merge_stats", side_effect=captured.append):
block._record_script_cost("x" * 5000, model_id)
assert len(captured) == 1
stats = captured[0]
assert stats.provider_cost == pytest.approx(5000 * expected_rate_per_char)
assert stats.provider_cost_type == "cost_usd"
# -------- Perplexity None-guard on x-total-cost --------
@pytest.mark.parametrize(
"openrouter_cost, expect_type",
[
(0.0421, "cost_usd"), # concrete positive USD → tagged
(None, None), # header missing → no tag (keeps gap observable)
(0.0, None), # zero → no tag (wouldn't bill anything anyway)
],
)
def test_perplexity_record_openrouter_cost_tags_only_on_concrete_value(
openrouter_cost, expect_type
):
"""Drive PerplexityBlock._record_openrouter_cost directly to verify the
None/0 guard. A regression that tags cost_usd unconditionally would
silently floor the user's bill to 0 via the resolver — this test
would catch it.
"""
from backend.blocks.perplexity import PerplexityBlock
block = PerplexityBlock()
with patch(
"backend.blocks.perplexity.extract_openrouter_cost",
return_value=openrouter_cost,
):
block._record_openrouter_cost(response=object())
assert block.execution_stats.provider_cost == openrouter_cost
assert block.execution_stats.provider_cost_type == expect_type
# -------- Codex COST_USD registration --------
def test_codex_registered_as_cost_usd_150():
from backend.blocks.codex import CodeGenerationBlock
entries = BLOCK_COSTS[CodeGenerationBlock]
assert len(entries) == 1
entry = entries[0]
assert entry.cost_type == BlockCostType.COST_USD
assert entry.cost_amount == 150
@pytest.mark.parametrize(
"input_tokens, output_tokens, expected_usd",
[
# GPT-5.1-Codex: $1.25 / 1M input, $10 / 1M output.
(1_000_000, 0, 1.25),
(0, 1_000_000, 10.0),
(100_000, 10_000, 0.225), # 0.125 + 0.100
(0, 0, 0.0),
],
)
def test_codex_computes_provider_cost_usd_from_token_counts(
input_tokens, output_tokens, expected_usd
):
"""Drive CodeGenerationBlock._compute_token_usd directly. A regression
to the wrong rate constants (e.g. swapping the $1.25 input rate for
GPT-4o's $2.50) would fail this test.
"""
from backend.blocks.codex import CodeGenerationBlock
assert CodeGenerationBlock._compute_token_usd(
input_tokens, output_tokens
) == pytest.approx(expected_usd)
# -------- ClaudeCode COST_USD registration sanity (already tested in claude_code_cost_test.py) --------
# -------- Perplexity COST_USD registration for all 3 tiers --------
def test_perplexity_sonar_all_tiers_registered_as_cost_usd_150():
from backend.blocks.perplexity import PerplexityBlock
entries = BLOCK_COSTS[PerplexityBlock]
# 3 tiers (SONAR, SONAR_PRO, SONAR_DEEP_RESEARCH) all COST_USD 150.
assert len(entries) == 3
for entry in entries:
assert entry.cost_type == BlockCostType.COST_USD
assert entry.cost_amount == 150
# -------- Narration COST_USD registration --------
def test_narration_registered_as_cost_usd_150():
from backend.blocks.video.narration import VideoNarrationBlock
entries = BLOCK_COSTS[VideoNarrationBlock]
assert len(entries) == 1
assert entries[0].cost_type == BlockCostType.COST_USD
assert entries[0].cost_amount == 150
# -------- Pinecone registrations --------
def test_pinecone_registrations():
from backend.blocks.pinecone import (
PineconeInitBlock,
PineconeInsertBlock,
PineconeQueryBlock,
)
assert BLOCK_COSTS[PineconeInitBlock][0].cost_type == BlockCostType.RUN
assert BLOCK_COSTS[PineconeQueryBlock][0].cost_type == BlockCostType.RUN
# Insert scales with item count.
assert BLOCK_COSTS[PineconeInsertBlock][0].cost_type == BlockCostType.ITEMS
assert BLOCK_COSTS[PineconeInsertBlock][0].cost_amount == 1

View File

@@ -7,6 +7,7 @@ from backend.sdk import BlockCostType, ProviderBuilder
# Build the DataForSEO provider with username/password authentication
dataforseo = (
ProviderBuilder("dataforseo")
.with_description("SEO and SERP data")
.with_user_password(
username_env_var="DATAFORSEO_USERNAME",
password_env_var="DATAFORSEO_PASSWORD",

View File

@@ -0,0 +1,10 @@
"""Provider registration for Discord — metadata only (auth lives in ``_auth.py``)."""
from backend.sdk import ProviderBuilder
discord = (
ProviderBuilder("discord")
.with_description("Messages, channels, and servers")
.with_supported_auth_types("api_key", "oauth2")
.build()
)

View File

@@ -0,0 +1,10 @@
"""Provider registration for ElevenLabs — metadata only (auth lives in ``_auth.py``)."""
from backend.sdk import ProviderBuilder
elevenlabs = (
ProviderBuilder("elevenlabs")
.with_description("Realistic AI voice synthesis")
.with_supported_auth_types("api_key")
.build()
)

View File

@@ -0,0 +1,10 @@
"""Provider registration for Enrichlayer — metadata only (auth lives in ``_auth.py``)."""
from backend.sdk import ProviderBuilder
enrichlayer = (
ProviderBuilder("enrichlayer")
.with_description("Enrich leads with company data")
.with_supported_auth_types("api_key")
.build()
)

View File

@@ -9,6 +9,7 @@ from ._webhook import ExaWebhookManager
# Configure the Exa provider once for all blocks
exa = (
ProviderBuilder("exa")
.with_description("Neural web search")
.with_api_key("EXA_API_KEY", "Exa API Key")
.with_webhook_manager(ExaWebhookManager)
# Exa returns `cost_dollars.total` on every response and ExaSearchBlock

View File

@@ -17,6 +17,7 @@ from backend.sdk import (
)
from ._config import exa
from .helpers import merge_exa_cost
class AnswerCitation(BaseModel):
@@ -111,3 +112,7 @@ class ExaAnswerBlock(Block):
yield "citations", citations
for citation in citations:
yield "citation", citation
# Current SDK AnswerResponse dataclass omits cost_dollars; helper
# no-ops today, but keeps billing wired when exa_py adds the field.
merge_exa_cost(self, response)

View File

@@ -9,7 +9,6 @@ from typing import Union
from pydantic import BaseModel
from backend.data.model import NodeExecutionStats
from backend.sdk import (
APIKeyCredentials,
Block,
@@ -23,6 +22,7 @@ from backend.sdk import (
)
from ._config import exa
from .helpers import merge_exa_cost
class CodeContextResponse(BaseModel):
@@ -118,9 +118,5 @@ class ExaCodeContextBlock(Block):
yield "search_time", context.search_time
yield "output_tokens", context.output_tokens
# Parse cost_dollars (API returns as string, e.g. "0.005")
try:
cost_usd = float(context.cost_dollars)
self.merge_stats(NodeExecutionStats(provider_cost=cost_usd))
except (ValueError, TypeError):
pass
# API returns costDollars as a bare numeric string like "0.005".
merge_exa_cost(self, data)

View File

@@ -4,7 +4,6 @@ from typing import Optional
from exa_py import AsyncExa
from pydantic import BaseModel
from backend.data.model import NodeExecutionStats
from backend.sdk import (
APIKeyCredentials,
Block,
@@ -24,6 +23,7 @@ from .helpers import (
HighlightSettings,
LivecrawlTypes,
SummarySettings,
merge_exa_cost,
)
@@ -224,6 +224,4 @@ class ExaContentsBlock(Block):
if response.cost_dollars:
yield "cost_dollars", response.cost_dollars
self.merge_stats(
NodeExecutionStats(provider_cost=response.cost_dollars.total)
)
merge_exa_cost(self, response)

View File

@@ -143,7 +143,9 @@ class TestExaContentsCostTracking:
mock_exa_cls.return_value = mock_exa
async for _ in block.run(
block.Input(urls=["https://example.com"], credentials=TEST_CREDENTIALS_INPUT), # type: ignore[arg-type]
block.Input(
urls=["https://example.com"], credentials=TEST_CREDENTIALS_INPUT
), # type: ignore[arg-type]
credentials=TEST_CREDENTIALS,
):
pass
@@ -172,7 +174,9 @@ class TestExaContentsCostTracking:
mock_exa_cls.return_value = mock_exa
async for _ in block.run(
block.Input(urls=["https://example.com"], credentials=TEST_CREDENTIALS_INPUT), # type: ignore[arg-type]
block.Input(
urls=["https://example.com"], credentials=TEST_CREDENTIALS_INPUT
), # type: ignore[arg-type]
credentials=TEST_CREDENTIALS,
):
pass
@@ -201,7 +205,9 @@ class TestExaContentsCostTracking:
mock_exa_cls.return_value = mock_exa
async for _ in block.run(
block.Input(urls=["https://example.com"], credentials=TEST_CREDENTIALS_INPUT), # type: ignore[arg-type]
block.Input(
urls=["https://example.com"], credentials=TEST_CREDENTIALS_INPUT
), # type: ignore[arg-type]
credentials=TEST_CREDENTIALS,
):
pass
@@ -297,7 +303,9 @@ class TestExaSimilarCostTracking:
mock_exa_cls.return_value = mock_exa
async for _ in block.run(
block.Input(url="https://example.com", credentials=TEST_CREDENTIALS_INPUT), # type: ignore[arg-type]
block.Input(
url="https://example.com", credentials=TEST_CREDENTIALS_INPUT
), # type: ignore[arg-type]
credentials=TEST_CREDENTIALS,
):
pass
@@ -326,7 +334,9 @@ class TestExaSimilarCostTracking:
mock_exa_cls.return_value = mock_exa
async for _ in block.run(
block.Input(url="https://example.com", credentials=TEST_CREDENTIALS_INPUT), # type: ignore[arg-type]
block.Input(
url="https://example.com", credentials=TEST_CREDENTIALS_INPUT
), # type: ignore[arg-type]
credentials=TEST_CREDENTIALS,
):
pass

View File

@@ -1,7 +1,8 @@
from enum import Enum
from typing import Any, Dict, Literal, Optional, Union
from backend.sdk import BaseModel, MediaFileType, SchemaField
from backend.data.model import NodeExecutionStats
from backend.sdk import BaseModel, Block, MediaFileType, SchemaField
class LivecrawlTypes(str, Enum):
@@ -319,7 +320,7 @@ class CostDollars(BaseModel):
# Helper functions for payload processing
def process_text_field(
text: Union[bool, TextEnabled, TextDisabled, TextAdvanced, None]
text: Union[bool, TextEnabled, TextDisabled, TextAdvanced, None],
) -> Optional[Union[bool, Dict[str, Any]]]:
"""Process text field for API payload."""
if text is None:
@@ -400,7 +401,7 @@ def process_contents_settings(contents: Optional[ContentSettings]) -> Dict[str,
def process_context_field(
context: Union[bool, dict, ContextEnabled, ContextDisabled, ContextAdvanced, None]
context: Union[bool, dict, ContextEnabled, ContextDisabled, ContextAdvanced, None],
) -> Optional[Union[bool, Dict[str, int]]]:
"""Process context field for API payload."""
if context is None:
@@ -448,3 +449,65 @@ def add_optional_fields(
payload[api_field] = value.value
else:
payload[api_field] = value
def extract_exa_cost_usd(response: Any) -> Optional[float]:
"""Return ``cost_dollars.total`` (USD) from an Exa SDK response, or None.
Handles dataclass/pydantic responses (``response.cost_dollars.total``),
dicts with camelCase keys (``response["costDollars"]["total"]``), dicts
with snake_case keys, and bare numeric strings. Returns None whenever the
shape is missing cost info — the caller then skips merge_stats.
"""
if response is None:
return None
# Dataclass / pydantic: response.cost_dollars
cost_obj = getattr(response, "cost_dollars", None)
# Dict payloads: try both camelCase and snake_case
if cost_obj is None and isinstance(response, dict):
cost_obj = response.get("costDollars") or response.get("cost_dollars")
if cost_obj is None:
return None
# Already a scalar (code_context endpoint returns a string)
if isinstance(cost_obj, (int, float)):
return max(0.0, float(cost_obj))
if isinstance(cost_obj, str):
try:
return max(0.0, float(cost_obj))
except ValueError:
return None
# Nested object/dict: grab the `total` field
total = getattr(cost_obj, "total", None)
if total is None and isinstance(cost_obj, dict):
total = cost_obj.get("total")
if total is None:
return None
try:
return max(0.0, float(total))
except (TypeError, ValueError):
return None
def merge_exa_cost(block: Block, response: Any) -> None:
"""Pull ``cost_dollars.total`` off an Exa response and merge it into stats.
No-op when the response shape has no cost info (e.g. webset CRUD where
the SDK does not expose per-call pricing) — emission happens only when
Exa actually reports a USD amount.
"""
cost_usd = extract_exa_cost_usd(response)
if cost_usd is None:
return
block.merge_stats(
NodeExecutionStats(
provider_cost=cost_usd,
provider_cost_type="cost_usd",
)
)

View File

@@ -0,0 +1,65 @@
"""Unit tests for exa/helpers cost-extraction + merge helpers."""
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from backend.blocks.exa.helpers import extract_exa_cost_usd, merge_exa_cost
from backend.data.model import NodeExecutionStats
@pytest.mark.parametrize(
"response, expected",
[
# Dataclass / SimpleNamespace with cost_dollars.total
(SimpleNamespace(cost_dollars=SimpleNamespace(total=0.05)), 0.05),
# Dict camelCase
({"costDollars": {"total": 0.10}}, 0.10),
# Dict snake_case
({"cost_dollars": {"total": 0.07}}, 0.07),
# code_context endpoint shape: plain numeric string
(SimpleNamespace(cost_dollars="0.005"), 0.005),
# Scalar float on cost_dollars directly
(SimpleNamespace(cost_dollars=0.02), 0.02),
# Scalar int on cost_dollars
(SimpleNamespace(cost_dollars=3), 3.0),
# Missing cost info — returns None
({}, None),
(SimpleNamespace(other="foo"), None),
(None, None),
# Nested total=None
(SimpleNamespace(cost_dollars=SimpleNamespace(total=None)), None),
# Invalid numeric string
(SimpleNamespace(cost_dollars="not-a-number"), None),
# Negative values clamp to 0
(SimpleNamespace(cost_dollars=SimpleNamespace(total=-1.0)), 0.0),
],
)
def test_extract_exa_cost_usd_handles_all_shapes(response, expected):
assert extract_exa_cost_usd(response) == expected
def test_merge_exa_cost_emits_stats_when_cost_present():
block = MagicMock()
response = SimpleNamespace(cost_dollars=SimpleNamespace(total=0.0421))
merge_exa_cost(block, response)
block.merge_stats.assert_called_once()
stats: NodeExecutionStats = block.merge_stats.call_args.args[0]
assert stats.provider_cost == pytest.approx(0.0421)
assert stats.provider_cost_type == "cost_usd"
def test_merge_exa_cost_noops_when_no_cost():
"""Webset CRUD endpoints don't surface cost_dollars today — the helper
must silently skip instead of emitting a 0-cost telemetry record."""
block = MagicMock()
merge_exa_cost(block, SimpleNamespace(other_field="nothing"))
block.merge_stats.assert_not_called()
def test_merge_exa_cost_noops_when_response_is_none():
block = MagicMock()
merge_exa_cost(block, None)
block.merge_stats.assert_not_called()

View File

@@ -12,7 +12,6 @@ from typing import Any, Dict, List, Optional
from pydantic import BaseModel
from backend.data.model import NodeExecutionStats
from backend.sdk import (
APIKeyCredentials,
Block,
@@ -26,6 +25,7 @@ from backend.sdk import (
)
from ._config import exa
from .helpers import merge_exa_cost
class ResearchModel(str, Enum):
@@ -233,11 +233,7 @@ class ExaCreateResearchBlock(Block):
if research.cost_dollars:
yield "cost_total", research.cost_dollars.total
self.merge_stats(
NodeExecutionStats(
provider_cost=research.cost_dollars.total
)
)
merge_exa_cost(self, research)
return
await asyncio.sleep(check_interval)
@@ -352,9 +348,7 @@ class ExaGetResearchBlock(Block):
yield "cost_searches", research.cost_dollars.num_searches
yield "cost_pages", research.cost_dollars.num_pages
yield "cost_reasoning_tokens", research.cost_dollars.reasoning_tokens
self.merge_stats(
NodeExecutionStats(provider_cost=research.cost_dollars.total)
)
merge_exa_cost(self, research)
yield "error_message", research.error
@@ -441,9 +435,7 @@ class ExaWaitForResearchBlock(Block):
if research.cost_dollars:
yield "cost_total", research.cost_dollars.total
self.merge_stats(
NodeExecutionStats(provider_cost=research.cost_dollars.total)
)
merge_exa_cost(self, research)
return

View File

@@ -4,7 +4,6 @@ from typing import Optional
from exa_py import AsyncExa
from backend.data.model import NodeExecutionStats
from backend.sdk import (
APIKeyCredentials,
Block,
@@ -21,6 +20,7 @@ from .helpers import (
ContentSettings,
CostDollars,
ExaSearchResults,
merge_exa_cost,
process_contents_settings,
)
@@ -207,6 +207,4 @@ class ExaSearchBlock(Block):
if response.cost_dollars:
yield "cost_dollars", response.cost_dollars
self.merge_stats(
NodeExecutionStats(provider_cost=response.cost_dollars.total)
)
merge_exa_cost(self, response)

View File

@@ -3,7 +3,6 @@ from typing import Optional
from exa_py import AsyncExa
from backend.data.model import NodeExecutionStats
from backend.sdk import (
APIKeyCredentials,
Block,
@@ -20,6 +19,7 @@ from .helpers import (
ContentSettings,
CostDollars,
ExaSearchResults,
merge_exa_cost,
process_contents_settings,
)
@@ -168,6 +168,4 @@ class ExaFindSimilarBlock(Block):
if response.cost_dollars:
yield "cost_dollars", response.cost_dollars
self.merge_stats(
NodeExecutionStats(provider_cost=response.cost_dollars.total)
)
merge_exa_cost(self, response)

View File

@@ -39,6 +39,7 @@ from backend.sdk import (
)
from ._config import exa
from .helpers import merge_exa_cost
class SearchEntityType(str, Enum):
@@ -394,6 +395,7 @@ class ExaCreateWebsetBlock(Block):
metadata=input_data.metadata,
)
)
merge_exa_cost(self, webset)
webset_result = Webset.model_validate(webset.model_dump(by_alias=True))
@@ -404,6 +406,7 @@ class ExaCreateWebsetBlock(Block):
timeout=input_data.polling_timeout,
poll_interval=5,
)
merge_exa_cost(self, final_webset)
completion_time = time.time() - start_time
item_count = 0
@@ -479,6 +482,7 @@ class ExaCreateOrFindWebsetBlock(Block):
try:
webset = await aexa.websets.get(id=input_data.external_id)
merge_exa_cost(self, webset)
webset_result = Webset.model_validate(webset.model_dump(by_alias=True))
yield "webset", webset_result
@@ -501,6 +505,7 @@ class ExaCreateOrFindWebsetBlock(Block):
metadata=input_data.metadata,
)
)
merge_exa_cost(self, webset)
webset_result = Webset.model_validate(webset.model_dump(by_alias=True))
@@ -555,6 +560,7 @@ class ExaUpdateWebsetBlock(Block):
payload["metadata"] = input_data.metadata
sdk_webset = await aexa.websets.update(id=input_data.webset_id, params=payload)
merge_exa_cost(self, sdk_webset)
status_str = (
sdk_webset.status.value
@@ -566,8 +572,9 @@ class ExaUpdateWebsetBlock(Block):
yield "status", status_str
yield "external_id", sdk_webset.external_id
yield "metadata", sdk_webset.metadata or {}
yield "updated_at", (
sdk_webset.updated_at.isoformat() if sdk_webset.updated_at else ""
yield (
"updated_at",
(sdk_webset.updated_at.isoformat() if sdk_webset.updated_at else ""),
)
@@ -621,6 +628,7 @@ class ExaListWebsetsBlock(Block):
cursor=input_data.cursor,
limit=input_data.limit,
)
merge_exa_cost(self, response)
websets_data = [
w.model_dump(by_alias=True, exclude_none=True) for w in response.data
@@ -679,6 +687,7 @@ class ExaGetWebsetBlock(Block):
aexa = AsyncExa(api_key=credentials.api_key.get_secret_value())
sdk_webset = await aexa.websets.get(id=input_data.webset_id)
merge_exa_cost(self, sdk_webset)
status_str = (
sdk_webset.status.value
@@ -706,11 +715,13 @@ class ExaGetWebsetBlock(Block):
yield "enrichments", enrichments_data
yield "monitors", monitors_data
yield "metadata", sdk_webset.metadata or {}
yield "created_at", (
sdk_webset.created_at.isoformat() if sdk_webset.created_at else ""
yield (
"created_at",
(sdk_webset.created_at.isoformat() if sdk_webset.created_at else ""),
)
yield "updated_at", (
sdk_webset.updated_at.isoformat() if sdk_webset.updated_at else ""
yield (
"updated_at",
(sdk_webset.updated_at.isoformat() if sdk_webset.updated_at else ""),
)
@@ -749,6 +760,7 @@ class ExaDeleteWebsetBlock(Block):
aexa = AsyncExa(api_key=credentials.api_key.get_secret_value())
deleted_webset = await aexa.websets.delete(id=input_data.webset_id)
merge_exa_cost(self, deleted_webset)
status_str = (
deleted_webset.status.value
@@ -799,6 +811,7 @@ class ExaCancelWebsetBlock(Block):
aexa = AsyncExa(api_key=credentials.api_key.get_secret_value())
canceled_webset = await aexa.websets.cancel(id=input_data.webset_id)
merge_exa_cost(self, canceled_webset)
status_str = (
canceled_webset.status.value
@@ -969,6 +982,7 @@ class ExaPreviewWebsetBlock(Block):
payload["entity"] = entity
sdk_preview = await aexa.websets.preview(params=payload)
merge_exa_cost(self, sdk_preview)
preview = PreviewWebsetModel.from_sdk(sdk_preview)
@@ -1052,6 +1066,7 @@ class ExaWebsetStatusBlock(Block):
aexa = AsyncExa(api_key=credentials.api_key.get_secret_value())
webset = await aexa.websets.get(id=input_data.webset_id)
merge_exa_cost(self, webset)
status = (
webset.status.value
@@ -1186,6 +1201,7 @@ class ExaWebsetSummaryBlock(Block):
aexa = AsyncExa(api_key=credentials.api_key.get_secret_value())
webset = await aexa.websets.get(id=input_data.webset_id)
merge_exa_cost(self, webset)
# Extract basic info
webset_id = webset.id
@@ -1214,6 +1230,7 @@ class ExaWebsetSummaryBlock(Block):
items_response = await aexa.websets.items.list(
webset_id=input_data.webset_id, limit=input_data.sample_size
)
merge_exa_cost(self, items_response)
sample_items_data = [
item.model_dump(by_alias=True, exclude_none=True)
for item in items_response.data
@@ -1363,6 +1380,7 @@ class ExaWebsetReadyCheckBlock(Block):
# Get webset details
webset = await aexa.websets.get(id=input_data.webset_id)
merge_exa_cost(self, webset)
status = (
webset.status.value

View File

@@ -25,6 +25,7 @@ from backend.sdk import (
)
from ._config import exa
from .helpers import merge_exa_cost
# Mirrored model for stability
@@ -205,6 +206,7 @@ class ExaCreateEnrichmentBlock(Block):
sdk_enrichment = await aexa.websets.enrichments.create(
webset_id=input_data.webset_id, params=payload
)
merge_exa_cost(self, sdk_enrichment)
enrichment_id = sdk_enrichment.id
status = (
@@ -226,6 +228,7 @@ class ExaCreateEnrichmentBlock(Block):
current_enrich = await aexa.websets.enrichments.get(
webset_id=input_data.webset_id, id=enrichment_id
)
merge_exa_cost(self, current_enrich)
current_status = (
current_enrich.status.value
if hasattr(current_enrich.status, "value")
@@ -235,6 +238,7 @@ class ExaCreateEnrichmentBlock(Block):
if current_status in ["completed", "failed", "cancelled"]:
# Estimate items from webset searches
webset = await aexa.websets.get(id=input_data.webset_id)
merge_exa_cost(self, webset)
if webset.searches:
for search in webset.searches:
if search.progress:
@@ -332,6 +336,7 @@ class ExaGetEnrichmentBlock(Block):
sdk_enrichment = await aexa.websets.enrichments.get(
webset_id=input_data.webset_id, id=input_data.enrichment_id
)
merge_exa_cost(self, sdk_enrichment)
enrichment = WebsetEnrichmentModel.from_sdk(sdk_enrichment)
@@ -425,6 +430,7 @@ class ExaUpdateEnrichmentBlock(Block):
try:
response = await Requests().patch(url, headers=headers, json=payload)
data = response.json()
# PATCH /websets/{id}/enrichments/{id} doesn't return costDollars.
yield "enrichment_id", data.get("id", "")
yield "status", data.get("status", "")
@@ -477,6 +483,7 @@ class ExaDeleteEnrichmentBlock(Block):
deleted_enrichment = await aexa.websets.enrichments.delete(
webset_id=input_data.webset_id, id=input_data.enrichment_id
)
merge_exa_cost(self, deleted_enrichment)
yield "enrichment_id", deleted_enrichment.id
yield "success", "true"
@@ -528,12 +535,14 @@ class ExaCancelEnrichmentBlock(Block):
canceled_enrichment = await aexa.websets.enrichments.cancel(
webset_id=input_data.webset_id, id=input_data.enrichment_id
)
merge_exa_cost(self, canceled_enrichment)
# Try to estimate how many items were enriched before cancellation
items_enriched = 0
items_response = await aexa.websets.items.list(
webset_id=input_data.webset_id, limit=100
)
merge_exa_cost(self, items_response)
for sdk_item in items_response.data:
# Check if this enrichment is present

View File

@@ -29,6 +29,7 @@ from backend.sdk import (
from ._config import exa
from ._test import TEST_CREDENTIALS, TEST_CREDENTIALS_INPUT
from .helpers import merge_exa_cost
# Mirrored model for stability - don't use SDK types directly in block outputs
@@ -297,6 +298,7 @@ class ExaCreateImportBlock(Block):
sdk_import = await aexa.websets.imports.create(
params=payload, csv_data=input_data.csv_data
)
merge_exa_cost(self, sdk_import)
import_obj = ImportModel.from_sdk(sdk_import)
@@ -361,6 +363,7 @@ class ExaGetImportBlock(Block):
aexa = AsyncExa(api_key=credentials.api_key.get_secret_value())
sdk_import = await aexa.websets.imports.get(import_id=input_data.import_id)
merge_exa_cost(self, sdk_import)
import_obj = ImportModel.from_sdk(sdk_import)
@@ -430,6 +433,7 @@ class ExaListImportsBlock(Block):
cursor=input_data.cursor,
limit=input_data.limit,
)
merge_exa_cost(self, response)
# Convert SDK imports to our stable models
imports = [ImportModel.from_sdk(i) for i in response.data]
@@ -477,6 +481,7 @@ class ExaDeleteImportBlock(Block):
deleted_import = await aexa.websets.imports.delete(
import_id=input_data.import_id
)
merge_exa_cost(self, deleted_import)
yield "import_id", deleted_import.id
yield "success", "true"
@@ -599,7 +604,7 @@ class ExaExportWebsetBlock(Block):
try:
all_items = []
# Use SDK's list_all iterator to fetch items
# list_all paginates internally; cost_dollars is not surfaced per-page
item_iterator = aexa.websets.items.list_all(
webset_id=input_data.webset_id, limit=input_data.max_items
)

View File

@@ -30,6 +30,7 @@ from backend.sdk import (
)
from ._config import exa
from .helpers import merge_exa_cost
# Mirrored model for enrichment results
@@ -181,6 +182,7 @@ class ExaGetWebsetItemBlock(Block):
sdk_item = await aexa.websets.items.get(
webset_id=input_data.webset_id, id=input_data.item_id
)
merge_exa_cost(self, sdk_item)
item = WebsetItemModel.from_sdk(sdk_item)
@@ -293,6 +295,7 @@ class ExaListWebsetItemsBlock(Block):
cursor=input_data.cursor,
limit=input_data.limit,
)
merge_exa_cost(self, response)
items = [WebsetItemModel.from_sdk(item) for item in response.data]
@@ -343,6 +346,7 @@ class ExaDeleteWebsetItemBlock(Block):
deleted_item = await aexa.websets.items.delete(
webset_id=input_data.webset_id, id=input_data.item_id
)
merge_exa_cost(self, deleted_item)
yield "item_id", deleted_item.id
yield "success", "true"
@@ -404,6 +408,7 @@ class ExaBulkWebsetItemsBlock(Block):
aexa = AsyncExa(api_key=credentials.api_key.get_secret_value())
all_items: List[WebsetItemModel] = []
# list_all paginates internally; cost_dollars is not surfaced per-page
item_iterator = aexa.websets.items.list_all(
webset_id=input_data.webset_id, limit=input_data.max_items
)
@@ -476,6 +481,7 @@ class ExaWebsetItemsSummaryBlock(Block):
aexa = AsyncExa(api_key=credentials.api_key.get_secret_value())
webset = await aexa.websets.get(id=input_data.webset_id)
merge_exa_cost(self, webset)
entity_type = "unknown"
if webset.searches:
@@ -498,6 +504,7 @@ class ExaWebsetItemsSummaryBlock(Block):
items_response = await aexa.websets.items.list(
webset_id=input_data.webset_id, limit=input_data.sample_size
)
merge_exa_cost(self, items_response)
# Convert to our stable models
sample_items = [
WebsetItemModel.from_sdk(item) for item in items_response.data
@@ -574,6 +581,7 @@ class ExaGetNewItemsBlock(Block):
cursor=input_data.since_cursor,
limit=input_data.max_items,
)
merge_exa_cost(self, response)
# Convert SDK items to our stable models
new_items = [WebsetItemModel.from_sdk(item) for item in response.data]

View File

@@ -25,6 +25,7 @@ from backend.sdk import (
from ._config import exa
from ._test import TEST_CREDENTIALS, TEST_CREDENTIALS_INPUT
from .helpers import merge_exa_cost
# Mirrored model for stability - don't use SDK types directly in block outputs
@@ -321,6 +322,7 @@ class ExaCreateMonitorBlock(Block):
payload["metadata"] = input_data.metadata
sdk_monitor = await aexa.websets.monitors.create(params=payload)
merge_exa_cost(self, sdk_monitor)
monitor = MonitorModel.from_sdk(sdk_monitor)
@@ -385,6 +387,7 @@ class ExaGetMonitorBlock(Block):
aexa = AsyncExa(api_key=credentials.api_key.get_secret_value())
sdk_monitor = await aexa.websets.monitors.get(monitor_id=input_data.monitor_id)
merge_exa_cost(self, sdk_monitor)
monitor = MonitorModel.from_sdk(sdk_monitor)
@@ -479,6 +482,7 @@ class ExaUpdateMonitorBlock(Block):
sdk_monitor = await aexa.websets.monitors.update(
monitor_id=input_data.monitor_id, params=payload
)
merge_exa_cost(self, sdk_monitor)
# Convert to our stable model
monitor = MonitorModel.from_sdk(sdk_monitor)
@@ -525,6 +529,7 @@ class ExaDeleteMonitorBlock(Block):
deleted_monitor = await aexa.websets.monitors.delete(
monitor_id=input_data.monitor_id
)
merge_exa_cost(self, deleted_monitor)
yield "monitor_id", deleted_monitor.id
yield "success", "true"
@@ -586,6 +591,7 @@ class ExaListMonitorsBlock(Block):
limit=input_data.limit,
webset_id=input_data.webset_id,
)
merge_exa_cost(self, response)
# Convert SDK monitors to our stable models
monitors = [MonitorModel.from_sdk(m) for m in response.data]

View File

@@ -25,6 +25,7 @@ from backend.sdk import (
)
from ._config import exa
from .helpers import merge_exa_cost
# Import WebsetItemModel for use in enrichment samples
# This is safe as websets_items doesn't import from websets_polling
@@ -126,6 +127,7 @@ class ExaWaitForWebsetBlock(Block):
timeout=input_data.timeout,
poll_interval=input_data.check_interval,
)
merge_exa_cost(self, final_webset)
elapsed = time.time() - start_time
@@ -165,6 +167,7 @@ class ExaWaitForWebsetBlock(Block):
while time.time() - start_time < input_data.timeout:
# Get current webset status
webset = await aexa.websets.get(id=input_data.webset_id)
merge_exa_cost(self, webset)
current_status = (
webset.status.value
if hasattr(webset.status, "value")
@@ -210,6 +213,7 @@ class ExaWaitForWebsetBlock(Block):
# Timeout reached
elapsed = time.time() - start_time
webset = await aexa.websets.get(id=input_data.webset_id)
merge_exa_cost(self, webset)
final_status = (
webset.status.value
if hasattr(webset.status, "value")
@@ -348,6 +352,7 @@ class ExaWaitForSearchBlock(Block):
search = await aexa.websets.searches.get(
webset_id=input_data.webset_id, id=input_data.search_id
)
merge_exa_cost(self, search)
# Extract status
status = (
@@ -404,6 +409,7 @@ class ExaWaitForSearchBlock(Block):
search = await aexa.websets.searches.get(
webset_id=input_data.webset_id, id=input_data.search_id
)
merge_exa_cost(self, search)
final_status = (
search.status.value
if hasattr(search.status, "value")
@@ -506,6 +512,7 @@ class ExaWaitForEnrichmentBlock(Block):
enrichment = await aexa.websets.enrichments.get(
webset_id=input_data.webset_id, id=input_data.enrichment_id
)
merge_exa_cost(self, enrichment)
# Extract status
status = (
@@ -523,16 +530,20 @@ class ExaWaitForEnrichmentBlock(Block):
items_enriched = 0
if input_data.sample_results and status == "completed":
sample_data, items_enriched = (
await self._get_sample_enrichments(
input_data.webset_id, input_data.enrichment_id, aexa
)
(
sample_data,
items_enriched,
) = await self._get_sample_enrichments(
input_data.webset_id, input_data.enrichment_id, aexa
)
yield "enrichment_id", input_data.enrichment_id
yield "final_status", status
yield "items_enriched", items_enriched
yield "enrichment_title", enrichment.title or enrichment.description or ""
yield (
"enrichment_title",
enrichment.title or enrichment.description or "",
)
yield "elapsed_time", elapsed
if input_data.sample_results:
yield "sample_data", sample_data
@@ -551,6 +562,7 @@ class ExaWaitForEnrichmentBlock(Block):
enrichment = await aexa.websets.enrichments.get(
webset_id=input_data.webset_id, id=input_data.enrichment_id
)
merge_exa_cost(self, enrichment)
final_status = (
enrichment.status.value
if hasattr(enrichment.status, "value")
@@ -576,6 +588,7 @@ class ExaWaitForEnrichmentBlock(Block):
"""Get sample enriched data and count."""
# Get a few items to see enrichment results using SDK
response = await aexa.websets.items.list(webset_id=webset_id, limit=5)
merge_exa_cost(self, response)
sample_data: list[SampleEnrichmentModel] = []
enriched_count = 0

View File

@@ -24,6 +24,7 @@ from backend.sdk import (
)
from ._config import exa
from .helpers import merge_exa_cost
# Mirrored model for stability
@@ -320,6 +321,7 @@ class ExaCreateWebsetSearchBlock(Block):
sdk_search = await aexa.websets.searches.create(
webset_id=input_data.webset_id, params=payload
)
merge_exa_cost(self, sdk_search)
search_id = sdk_search.id
status = (
@@ -353,6 +355,7 @@ class ExaCreateWebsetSearchBlock(Block):
current_search = await aexa.websets.searches.get(
webset_id=input_data.webset_id, id=search_id
)
merge_exa_cost(self, current_search)
current_status = (
current_search.status.value
if hasattr(current_search.status, "value")
@@ -445,6 +448,7 @@ class ExaGetWebsetSearchBlock(Block):
sdk_search = await aexa.websets.searches.get(
webset_id=input_data.webset_id, id=input_data.search_id
)
merge_exa_cost(self, sdk_search)
search = WebsetSearchModel.from_sdk(sdk_search)
@@ -526,6 +530,7 @@ class ExaCancelWebsetSearchBlock(Block):
canceled_search = await aexa.websets.searches.cancel(
webset_id=input_data.webset_id, id=input_data.search_id
)
merge_exa_cost(self, canceled_search)
# Extract items found before cancellation
items_found = 0
@@ -605,6 +610,7 @@ class ExaFindOrCreateSearchBlock(Block):
# Get webset to check existing searches
webset = await aexa.websets.get(id=input_data.webset_id)
merge_exa_cost(self, webset)
# Look for existing search with same query
existing_search = None
@@ -639,6 +645,7 @@ class ExaFindOrCreateSearchBlock(Block):
sdk_search = await aexa.websets.searches.create(
webset_id=input_data.webset_id, params=payload
)
merge_exa_cost(self, sdk_search)
search = WebsetSearchModel.from_sdk(sdk_search)

View File

@@ -0,0 +1,10 @@
"""Provider registration for fal — metadata only (auth lives in ``_auth.py``)."""
from backend.sdk import ProviderBuilder
fal = (
ProviderBuilder("fal")
.with_description("Hosted model inference")
.with_supported_auth_types("api_key")
.build()
)

View File

@@ -8,6 +8,7 @@ from backend.sdk import BlockCostType, ProviderBuilder
# — roughly matches our existing per-call tier for single-page scrape.
firecrawl = (
ProviderBuilder("firecrawl")
.with_description("Web scraping and crawling")
.with_api_key("FIRECRAWL_API_KEY", "Firecrawl API Key")
.with_base_cost(1000, BlockCostType.COST_USD)
.build()

View File

@@ -14,6 +14,7 @@ from ._webhook import GenericWebhooksManager, GenericWebhookType
generic_webhook = (
ProviderBuilder("generic_webhook")
.with_description("Inbound webhook trigger")
.with_webhook_manager(GenericWebhooksManager)
.build()
)

View File

@@ -0,0 +1,10 @@
"""Provider registration for GitHub — metadata only (auth lives in ``_auth.py``)."""
from backend.sdk import ProviderBuilder
github = (
ProviderBuilder("github")
.with_description("Issues, pull requests, repositories")
.with_supported_auth_types("api_key", "oauth2")
.build()
)

View File

@@ -0,0 +1,10 @@
"""Provider registration for Google — metadata only (auth lives in ``_auth.py``)."""
from backend.sdk import ProviderBuilder
google = (
ProviderBuilder("google")
.with_description("Gmail, Drive, Calendar, Sheets")
.with_supported_auth_types("oauth2")
.build()
)

View File

@@ -0,0 +1,10 @@
"""Provider registration for HubSpot — metadata only (auth lives in ``_auth.py``)."""
from backend.sdk import ProviderBuilder
hubspot = (
ProviderBuilder("hubspot")
.with_description("CRM, contacts, and deals")
.with_supported_auth_types("api_key", "oauth2")
.build()
)

View File

@@ -0,0 +1,10 @@
"""Provider registration for Jina — metadata only (auth lives in ``_auth.py``)."""
from backend.sdk import ProviderBuilder
jina = (
ProviderBuilder("jina")
.with_description("Embeddings and reranking")
.with_supported_auth_types("api_key")
.build()
)

View File

@@ -39,6 +39,7 @@ class LinearScope(str, Enum):
linear = (
ProviderBuilder("linear")
.with_description("Issues and project tracking")
.with_api_key(env_var_name="LINEAR_API_KEY", title="Linear API Key")
.with_base_cost(1, BlockCostType.RUN)
.with_oauth(

View File

@@ -142,6 +142,7 @@ class LlmModel(str, Enum, metaclass=LlmModelMeta):
CLAUDE_4_5_SONNET = "claude-sonnet-4-5-20250929"
CLAUDE_4_5_HAIKU = "claude-haiku-4-5-20251001"
CLAUDE_4_6_OPUS = "claude-opus-4-6"
CLAUDE_4_7_OPUS = "claude-opus-4-7"
CLAUDE_4_6_SONNET = "claude-sonnet-4-6"
CLAUDE_3_HAIKU = "claude-3-haiku-20240307"
# AI/ML API models
@@ -331,6 +332,9 @@ MODEL_METADATA = {
LlmModel.CLAUDE_4_6_OPUS: ModelMetadata(
"anthropic", 200000, 128000, "Claude Opus 4.6", "Anthropic", "Anthropic", 3
), # claude-opus-4-6
LlmModel.CLAUDE_4_7_OPUS: ModelMetadata(
"anthropic", 200000, 128000, "Claude Opus 4.7", "Anthropic", "Anthropic", 3
), # claude-opus-4-7
LlmModel.CLAUDE_4_6_SONNET: ModelMetadata(
"anthropic", 200000, 64000, "Claude Sonnet 4.6", "Anthropic", "Anthropic", 3
), # claude-sonnet-4-6
@@ -1624,6 +1628,11 @@ class AIStructuredResponseGeneratorBlock(AIBlockBase):
llm_call_count=retry_count + 1,
llm_retry_count=retry_count,
provider_cost=total_provider_cost,
provider_cost_type=(
"cost_usd"
if total_provider_cost is not None
else None
),
)
)
yield "response", response_obj
@@ -1645,6 +1654,9 @@ class AIStructuredResponseGeneratorBlock(AIBlockBase):
llm_call_count=retry_count + 1,
llm_retry_count=retry_count,
provider_cost=total_provider_cost,
provider_cost_type=(
"cost_usd" if total_provider_cost is not None else None
),
)
)
yield "response", {"response": response_text}
@@ -1679,7 +1691,12 @@ class AIStructuredResponseGeneratorBlock(AIBlockBase):
# All retries exhausted or user-error break: persist accumulated cost so
# the executor can still charge/report the spend even on failure.
if total_provider_cost is not None:
self.merge_stats(NodeExecutionStats(provider_cost=total_provider_cost))
self.merge_stats(
NodeExecutionStats(
provider_cost=total_provider_cost,
provider_cost_type="cost_usd",
)
)
raise RuntimeError(error_feedback_message)
def response_format_instructions(

View File

@@ -0,0 +1,5 @@
"""Provider registration for MCP — metadata only."""
from backend.sdk import ProviderBuilder
mcp = ProviderBuilder("mcp").with_description("Model Context Protocol servers").build()

View File

@@ -0,0 +1,10 @@
"""Provider registration for Notion — metadata only (auth lives in ``_auth.py``)."""
from backend.sdk import ProviderBuilder
notion = (
ProviderBuilder("notion")
.with_description("Pages, databases, and blocks")
.with_supported_auth_types("oauth2")
.build()
)

View File

@@ -0,0 +1,10 @@
"""Provider registration for Nvidia — metadata only (auth lives in ``_auth.py``)."""
from backend.sdk import ProviderBuilder
nvidia = (
ProviderBuilder("nvidia")
.with_description("NIM-hosted foundation models")
.with_supported_auth_types("api_key")
.build()
)

View File

@@ -250,14 +250,7 @@ class PerplexityBlock(Block):
self.execution_stats.output_token_count = (
response.usage.completion_tokens
)
# OpenRouter's ``x-total-cost`` response header carries the real
# per-request USD cost. Piping it into ``provider_cost`` lets the
# direct-run ``PlatformCostLog`` flow
# (``executor.cost_tracking::log_system_credential_cost``) record
# the actual operator-side spend instead of inferring from tokens.
# Always overwrite — ``execution_stats`` is instance state, so a
# response without the header must not reuse a previous run's cost.
self.execution_stats.provider_cost = extract_openrouter_cost(response)
self._record_openrouter_cost(response)
return {"response": response_content, "annotations": annotations or []}
@@ -265,6 +258,17 @@ class PerplexityBlock(Block):
logger.error(f"Error calling Perplexity: {e}")
raise
def _record_openrouter_cost(self, response: Any) -> None:
"""Feed OpenRouter's ``x-total-cost`` USD into execution stats for
the COST_USD resolver. Tag as ``cost_usd`` only when the value is
concrete and positive — leaving it unset on None/0 keeps the
billing gap observable instead of silently floored to 0.
"""
cost_usd = extract_openrouter_cost(response)
self.execution_stats.provider_cost = cost_usd
if cost_usd is not None and cost_usd > 0:
self.execution_stats.provider_cost_type = "cost_usd"
async def run(
self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs
) -> BlockOutput:

View File

@@ -14,6 +14,7 @@ from backend.data.model import (
APIKeyCredentials,
CredentialsField,
CredentialsMetaInput,
NodeExecutionStats,
SchemaField,
)
from backend.integrations.providers import ProviderName
@@ -160,10 +161,13 @@ class PineconeQueryBlock(Block):
combined_text = "\n\n".join(texts)
# Return both the raw matches and combined text
yield "results", {
"matches": results["matches"],
"combined_text": combined_text,
}
yield (
"results",
{
"matches": results["matches"],
"combined_text": combined_text,
},
)
yield "combined_results", combined_text
except Exception as e:
@@ -228,6 +232,13 @@ class PineconeInsertBlock(Block):
)
idx.upsert(vectors=vectors, namespace=input_data.namespace)
self.merge_stats(
NodeExecutionStats(
provider_cost=float(len(vectors)),
provider_cost_type="items",
)
)
yield "upsert_response", "successfully upserted"
except Exception as e:

View File

@@ -0,0 +1,10 @@
"""Provider registration for Replicate — metadata only."""
from backend.sdk import ProviderBuilder
replicate = (
ProviderBuilder("replicate")
.with_description("Run and host open-source models")
.with_supported_auth_types("api_key")
.build()
)

View File

@@ -16,12 +16,24 @@ from backend.blocks.replicate._auth import (
TEST_CREDENTIALS_INPUT,
ReplicateCredentialsInput,
)
from backend.blocks.replicate._helper import ReplicateOutputs, extract_result
from backend.data.model import APIKeyCredentials, CredentialsField, SchemaField
from backend.blocks.replicate._helper import extract_result
from backend.data.model import (
APIKeyCredentials,
CredentialsField,
NodeExecutionStats,
SchemaField,
)
from backend.util.exceptions import BlockExecutionError, BlockInputError
logger = logging.getLogger(__name__)
# Replicate hardware tier cost — most popular public models (Flux, SDXL,
# Llama 70B etc.) run on Nvidia L40S at $0.001400/sec. Using a single
# conservative mid-tier estimate is much better than a flat RUN charge,
# which under-bills long-running models by 10-500×. Heavier models run
# on A100 at $0.001400/sec; cheaper ones on L4 at $0.000275/sec.
_REPLICATE_USD_PER_SEC = 0.001400
class ReplicateModelBlock(Block):
"""
@@ -138,20 +150,54 @@ class ReplicateModelBlock(Block):
"""
Run the Replicate model. This method can be mocked for testing.
Uses predictions.async_create + async_wait instead of async_run so
we can read ``prediction.metrics.predict_time`` after completion
and emit it as ``provider_cost`` for the COST_USD resolver.
Args:
model_ref: The model reference (e.g., "owner/model-name:version")
model_inputs: The inputs to pass to the model
api_key: The Replicate API key as SecretStr
Returns:
Tuple of (result, prediction_id)
Model output (same shape as previous async_run path)
"""
api_key_str = api_key.get_secret_value()
client = ReplicateClient(api_token=api_key_str)
output: ReplicateOutputs = await client.async_run(
model_ref, input=model_inputs, wait=False
) # type: ignore they suck at typing
result = extract_result(output)
# Replicate SDK: version-pinned refs use `version=`; unpinned use
# `model=`. Matches the `owner/name[:version]` contract above.
if ":" in model_ref:
model_name, version = model_ref.split(":", 1)
prediction = await client.predictions.async_create(
version=version, input=model_inputs
)
else:
prediction = await client.predictions.async_create(
model=model_ref, input=model_inputs
)
return result
await prediction.async_wait()
# async_wait returns normally on "failed"/"canceled" — only async_run
# raises. Without this check we'd bill partial compute time on a
# failed run and silently yield empty output.
if prediction.status == "failed":
raise RuntimeError(
f"Replicate prediction failed: {prediction.error or 'unknown error'}"
)
if prediction.status == "canceled":
raise RuntimeError("Replicate prediction was canceled")
if prediction.metrics and prediction.metrics.get("predict_time"):
predict_time = float(prediction.metrics["predict_time"])
self.merge_stats(
NodeExecutionStats(
provider_cost=predict_time * _REPLICATE_USD_PER_SEC,
provider_cost_type="cost_usd",
)
)
if prediction.output is None:
raise RuntimeError("Replicate prediction returned no output")
return extract_result(prediction.output)

View File

@@ -0,0 +1,233 @@
"""Unit tests for ReplicateModelBlock's predictions.async_create billing path.
Verifies the refactored run_model correctly:
1. Uses predictions.async_create (version= vs model= based on ":" in model_ref)
2. Awaits async_wait() for metrics to be populated
3. Reads prediction.metrics["predict_time"] and emits provider_cost/cost_usd
4. Returns extract_result(prediction.output) with the same shape as the old
async_run path
5. Gracefully skips merge_stats when metrics is missing (protects against a
silent wallet-free leak on SDK quirks)
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import SecretStr
from backend.blocks._base import BlockCostType
from backend.blocks.replicate.replicate_block import (
_REPLICATE_USD_PER_SEC,
ReplicateModelBlock,
)
from backend.data.block_cost_config import BLOCK_COSTS
from backend.data.model import NodeExecutionStats
def test_registered_as_cost_usd_150():
entries = BLOCK_COSTS[ReplicateModelBlock]
assert len(entries) == 1
assert entries[0].cost_type == BlockCostType.COST_USD
assert entries[0].cost_amount == 150
def test_hardware_rate_constant_in_range():
"""$0.0014/s is Nvidia L40S tier. Sanity-check we haven't accidentally
shipped a rate that's off by an order of magnitude (e.g. $0.014 would
10x over-bill every run).
"""
# Replicate's public hardware tiers: L4 $0.000275, A10G $0.000575,
# L40S $0.000975, A100 $0.001400, A100-80GB $0.001725. L40S @
# $0.0014/s covers most popular models with mild over-bill margin.
assert 0.0005 <= _REPLICATE_USD_PER_SEC <= 0.002
def _make_fake_prediction(output, predict_time=None, status="succeeded", error=None):
"""Build a stand-in for replicate's Prediction with the attrs we touch."""
pred = MagicMock()
pred.output = output
pred.status = status
pred.error = error
pred.metrics = {"predict_time": predict_time} if predict_time is not None else None
pred.async_wait = AsyncMock(return_value=None)
return pred
@pytest.mark.asyncio
async def test_run_model_uses_version_keyword_when_ref_has_colon():
"""`"owner/name:sha"` → predictions.async_create(version=sha, ...)."""
block = ReplicateModelBlock()
prediction = _make_fake_prediction(output="hello", predict_time=3.2)
client = MagicMock()
client.predictions.async_create = AsyncMock(return_value=prediction)
with patch(
"backend.blocks.replicate.replicate_block.ReplicateClient",
return_value=client,
):
await block.run_model(
"owner/model:abc123", {"prompt": "hi"}, SecretStr("fake-key")
)
client.predictions.async_create.assert_awaited_once_with(
version="abc123", input={"prompt": "hi"}
)
@pytest.mark.asyncio
async def test_run_model_uses_model_keyword_when_ref_is_unpinned():
"""`"owner/name"` (no `:version`) → predictions.async_create(model=ref, ...)."""
block = ReplicateModelBlock()
prediction = _make_fake_prediction(output="hello", predict_time=1.0)
client = MagicMock()
client.predictions.async_create = AsyncMock(return_value=prediction)
with patch(
"backend.blocks.replicate.replicate_block.ReplicateClient",
return_value=client,
):
await block.run_model(
"owner/flux-schnell", {"prompt": "cat"}, SecretStr("fake-key")
)
client.predictions.async_create.assert_awaited_once_with(
model="owner/flux-schnell", input={"prompt": "cat"}
)
@pytest.mark.asyncio
async def test_run_model_emits_provider_cost_from_predict_time():
"""Core contract: provider_cost = predict_time * $0.0014/s, cost_usd."""
block = ReplicateModelBlock()
# 5-second run → 5 * 0.0014 = $0.007 → 150 cr/$ * 0.007 ceil = 2 cr
prediction = _make_fake_prediction(output="result-data", predict_time=5.0)
client = MagicMock()
client.predictions.async_create = AsyncMock(return_value=prediction)
captured: list[NodeExecutionStats] = []
with (
patch(
"backend.blocks.replicate.replicate_block.ReplicateClient",
return_value=client,
),
patch.object(block, "merge_stats", side_effect=captured.append),
):
result = await block.run_model("owner/model", {}, SecretStr("fake-key"))
assert len(captured) == 1
stats = captured[0]
assert stats.provider_cost == pytest.approx(5.0 * _REPLICATE_USD_PER_SEC)
assert stats.provider_cost_type == "cost_usd"
assert result == "result-data"
# async_wait MUST be called before reading metrics — otherwise metrics
# is None on in-flight predictions.
prediction.async_wait.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_model_skips_merge_stats_when_metrics_missing():
"""Protect against the nightmare scenario: if the SDK stops populating
metrics (or we hit a prediction that completes without metrics),
merge_stats must NOT fire. Otherwise we'd emit a zero provider_cost
that the resolver treats as 0 credits — a silent wallet-free leak.
The block's run() path relies on the flat 0 fallback via
charge_reconciled_usage's pre-flight balance guard.
"""
block = ReplicateModelBlock()
prediction = _make_fake_prediction(output="x", predict_time=None)
client = MagicMock()
client.predictions.async_create = AsyncMock(return_value=prediction)
captured: list[NodeExecutionStats] = []
with (
patch(
"backend.blocks.replicate.replicate_block.ReplicateClient",
return_value=client,
),
patch.object(block, "merge_stats", side_effect=captured.append),
):
await block.run_model("owner/model", {}, SecretStr("fake-key"))
# No merge_stats call → no provider_cost emission → COST_USD resolver
# returns 0 → run is effectively free post-flight, but pre-flight
# balance guard still blocks zero-balance wallets per PR #12894.
assert captured == []
@pytest.mark.asyncio
async def test_run_model_skips_merge_when_predict_time_is_zero():
"""A 0-second predict_time would emit provider_cost=0, which is useless
telemetry. Treat 0 same as missing (no emission)."""
block = ReplicateModelBlock()
prediction = _make_fake_prediction(output="x", predict_time=0)
client = MagicMock()
client.predictions.async_create = AsyncMock(return_value=prediction)
captured: list[NodeExecutionStats] = []
with (
patch(
"backend.blocks.replicate.replicate_block.ReplicateClient",
return_value=client,
),
patch.object(block, "merge_stats", side_effect=captured.append),
):
await block.run_model("owner/model", {}, SecretStr("fake-key"))
assert captured == []
@pytest.mark.asyncio
async def test_run_model_raises_on_failed_status_and_does_not_bill():
"""async_wait returns normally on 'failed' — without an explicit status
check we'd bill partial compute time AND yield 'status: succeeded' with
empty output. Verify we raise BEFORE merge_stats so the failed run is
not billed."""
block = ReplicateModelBlock()
prediction = _make_fake_prediction(
output=None, predict_time=2.5, status="failed", error="CUDA OOM"
)
client = MagicMock()
client.predictions.async_create = AsyncMock(return_value=prediction)
captured: list[NodeExecutionStats] = []
with (
patch(
"backend.blocks.replicate.replicate_block.ReplicateClient",
return_value=client,
),
patch.object(block, "merge_stats", side_effect=captured.append),
):
with pytest.raises(RuntimeError, match="CUDA OOM"):
await block.run_model("owner/model", {}, SecretStr("fake-key"))
assert captured == []
@pytest.mark.asyncio
async def test_run_model_raises_on_canceled_status_and_does_not_bill():
"""Canceled predictions — same guarantees as failed: don't bill, surface
the cancellation."""
block = ReplicateModelBlock()
prediction = _make_fake_prediction(output=None, predict_time=1.0, status="canceled")
client = MagicMock()
client.predictions.async_create = AsyncMock(return_value=prediction)
captured: list[NodeExecutionStats] = []
with (
patch(
"backend.blocks.replicate.replicate_block.ReplicateClient",
return_value=client,
),
patch.object(block, "merge_stats", side_effect=captured.append),
):
with pytest.raises(RuntimeError, match="canceled"):
await block.run_model("owner/model", {}, SecretStr("fake-key"))
assert captured == []

View File

@@ -0,0 +1,10 @@
"""Provider registration for Slant 3D — metadata only (auth lives in ``_api.py``)."""
from backend.sdk import ProviderBuilder
slant3d = (
ProviderBuilder("slant3d")
.with_description("On-demand 3D printing")
.with_supported_auth_types("api_key")
.build()
)

View File

@@ -0,0 +1,10 @@
"""Provider registration for Smartlead — metadata only (auth lives in ``_auth.py``)."""
from backend.sdk import ProviderBuilder
smartlead = (
ProviderBuilder("smartlead")
.with_description("Cold email outreach at scale")
.with_supported_auth_types("api_key")
.build()
)

View File

@@ -6,6 +6,7 @@ from backend.sdk import BlockCostType, ProviderBuilder
# to COST_USD.
stagehand = (
ProviderBuilder("stagehand")
.with_description("AI browser automation")
.with_api_key("STAGEHAND_API_KEY", "Stagehand API Key")
.with_base_cost(1, BlockCostType.SECOND, cost_divisor=3)
.build()

View File

@@ -0,0 +1,10 @@
"""Provider registration for Telegram — metadata only."""
from backend.sdk import ProviderBuilder
telegram = (
ProviderBuilder("telegram")
.with_description("Bot messaging and groups")
.with_supported_auth_types("api_key")
.build()
)

View File

@@ -105,10 +105,13 @@ class UnrealTextToSpeechBlock(Block):
input_data.text,
input_data.voice_id,
)
# Unreal Speech: $16 / 1M chars = $0.000016/char. Emit USD so the
# COST_USD resolver (150 cr/$ via BLOCK_COSTS) bills proportionally
# instead of the old flat 5 cr.
self.merge_stats(
NodeExecutionStats(
provider_cost=float(len(input_data.text)),
provider_cost_type="characters",
provider_cost=len(input_data.text) * 0.000016,
provider_cost_type="cost_usd",
)
)
yield "mp3_url", api_response["OutputUri"]

View File

@@ -0,0 +1,10 @@
"""Provider registration for Todoist — metadata only (auth lives in ``_auth.py``)."""
from backend.sdk import ProviderBuilder
todoist = (
ProviderBuilder("todoist")
.with_description("Tasks and projects")
.with_supported_auth_types("oauth2")
.build()
)

View File

@@ -0,0 +1,10 @@
"""Provider registration for X (Twitter) — metadata only (auth lives in ``_auth.py``)."""
from backend.sdk import ProviderBuilder
twitter = (
ProviderBuilder("twitter")
.with_description("Tweets, timelines, and DMs")
.with_supported_auth_types("oauth2")
.build()
)

View File

@@ -27,7 +27,7 @@ from backend.blocks.video._utils import (
strip_chapters_inplace,
)
from backend.data.execution import ExecutionContext
from backend.data.model import CredentialsField, SchemaField
from backend.data.model import CredentialsField, NodeExecutionStats, SchemaField
from backend.util.exceptions import BlockExecutionError
from backend.util.file import MediaFileType, get_exec_file_path, store_media_file
@@ -44,7 +44,8 @@ class VideoNarrationBlock(Block):
)
script: str = SchemaField(description="Narration script text")
voice_id: str = SchemaField(
description="ElevenLabs voice ID", default="21m00Tcm4TlvDq8ikWAM" # Rachel
description="ElevenLabs voice ID",
default="21m00Tcm4TlvDq8ikWAM", # Rachel
)
model_id: Literal[
"eleven_multilingual_v2",
@@ -124,6 +125,26 @@ class VideoNarrationBlock(Block):
return_format="for_block_output",
)
# Models that consume 0.5 credits per character (v2.5 tier). All other
# models default to 1.0 credit per character.
_HALF_RATE_MODELS = {"eleven_flash_v2_5", "eleven_turbo_v2_5"}
# ElevenLabs Starter plan: $5 / 30K credits = $0.000167 / credit.
_USD_PER_CREDIT = 0.000167
def _record_script_cost(self, script: str, model_id: str) -> None:
"""Emit provider_cost (USD) for the narration run so the COST_USD
resolver can bill real ElevenLabs spend. Flash/Turbo v2.5 bill at
half the char rate of Multilingual/Turbo v2.
"""
credits_per_char = 0.5 if model_id in self._HALF_RATE_MODELS else 1.0
script_usd = len(script) * self._USD_PER_CREDIT * credits_per_char
self.merge_stats(
NodeExecutionStats(
provider_cost=script_usd,
provider_cost_type="cost_usd",
)
)
def _generate_narration_audio(
self, api_key: str, script: str, voice_id: str, model_id: str
) -> bytes:
@@ -223,6 +244,8 @@ class VideoNarrationBlock(Block):
input_data.model_id,
)
self._record_script_cost(input_data.script, input_data.model_id)
# Save audio to exec file path
audio_filename = MediaFileType(f"{node_exec_id}_narration.mp3")
audio_abspath = get_exec_file_path(

View File

@@ -15,6 +15,7 @@ from ._api import llm_api_call
wolfram = (
ProviderBuilder("wolfram")
.with_description("Computational knowledge engine")
.with_api_key("WOLFRAM_APP_ID", "Wolfram Alpha App ID")
.with_base_cost(1, BlockCostType.RUN)
.build()

View File

@@ -4,6 +4,7 @@ from ._oauth import WordPressOAuthHandler, WordPressScope
wordpress = (
ProviderBuilder("wordpress")
.with_description("Posts, pages, and media")
.with_base_cost(1, BlockCostType.RUN)
.with_oauth(
WordPressOAuthHandler,

View File

@@ -0,0 +1,10 @@
"""Provider registration for ZeroBounce — metadata only (auth lives in ``_auth.py``)."""
from backend.sdk import ProviderBuilder
zerobounce = (
ProviderBuilder("zerobounce")
.with_description("Email address verification")
.with_supported_auth_types("api_key")
.build()
)

View File

@@ -0,0 +1 @@
@README.md

View File

@@ -0,0 +1 @@
@README.md

View File

@@ -0,0 +1,79 @@
# CoPilot Bot
Multi-platform chat bot that bridges AutoPilot to Discord (and later Telegram, Slack, etc).
## Running
```bash
# As a standalone service
poetry run copilot-bot
# Or auto-start alongside the rest of the platform
poetry run app # starts the bot too if AUTOPILOT_BOT_DISCORD_TOKEN is set
```
## Required environment variables
See `backend/.env.default` for the full list with documentation. Minimum setup:
| Variable | Purpose |
|----------|---------|
| `AUTOPILOT_BOT_DISCORD_TOKEN` | Discord bot token — enables the Discord adapter |
| `FRONTEND_BASE_URL` | Frontend base URL for link confirmation pages (shared with the rest of the backend) |
| `REDIS_HOST` / `REDIS_PORT` | Session + thread subscription state + copilot stream subscription (inherited from the shared backend config) |
| `PLATFORMLINKINGMANAGER_HOST` | DNS name of the `PlatformLinkingManager` service pod (cluster-internal RPC) |
## Architecture
```
bot/
├── app.py # CoPilotChatBridge(AppService), adapter factory, outbound @expose RPC
├── config.py # Shared (platform-agnostic) config
├── handler.py # Core logic: routing, linking, batched streaming
├── bot_backend.py # Thin facade over PlatformLinkingManagerClient + stream_registry
├── text.py # Text splitting + batch formatting
├── threads.py # Redis-backed thread subscription tracking
└── adapters/
├── base.py # PlatformAdapter interface + MessageContext
└── discord/
├── adapter.py # Gateway connection, events, sends, thread creation
├── commands.py # Slash commands (/setup, /help, /unlink)
└── config.py # Discord token + platform limits
```
**Locality rule:** anything platform-specific lives under `adapters/<platform>/`.
The only file that names specific platforms is `app.py`, which is the factory
that decides which adapters to instantiate based on which tokens are set.
## How messaging works
1. User mentions the bot in a channel
2. Adapter's `on_message` handler fires, constructs a `MessageContext`, passes
it to the shared `MessageHandler`
3. Handler:
- Checks if the user/server is linked (via `bot_backend`)
- If not linked → sends a "Link Account" button prompt
- If linked → creates a thread (for channels) or uses the existing thread/DM
- Marks the thread as subscribed in Redis (7-day TTL)
- Streams the AutoPilot response back, chunked at the adapter's
`chunk_flush_at` boundary
4. Messages that arrive while a stream is running get batched and sent as a
single follow-up turn once the current stream ends
## Adding a new platform
1. Create `adapters/<platform>/` with `adapter.py`, `commands.py` (if the
platform has commands), and `config.py`
2. `adapter.py` subclasses `PlatformAdapter` and implements all its abstract
methods — `max_message_length`, `chunk_flush_at`, `send_message`,
`send_link`, `create_thread`, etc.
3. `config.py` declares the platform's env vars and any platform-specific
numbers (message limits, token name, etc.)
4. Add two lines to `app.py::_build_adapters`:
```python
if <platform>_config.BOT_TOKEN:
adapters.append(<Platform>Adapter(api))
```
The core handler, text utilities, thread tracking, and platform API all stay
untouched.

View File

@@ -0,0 +1,19 @@
"""Entry point for running the CoPilot Chat Bridge service.
Usage:
poetry run copilot-bot
python -m backend.copilot.bot
"""
from backend.app import run_processes
from .app import CoPilotChatBridge
def main():
"""Run the CoPilot Chat Bridge service."""
run_processes(CoPilotChatBridge())
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show More