diff --git a/__tests__/hooks/query/use-search-providers.test.tsx b/__tests__/hooks/query/use-search-providers.test.tsx
index 3d0868694a..de2a3fd6cd 100644
--- a/__tests__/hooks/query/use-search-providers.test.tsx
+++ b/__tests__/hooks/query/use-search-providers.test.tsx
@@ -2,10 +2,21 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import React from "react";
-import { describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ __resetActiveStoreForTests,
+ setActiveSelection,
+ setRegisteredBackends,
+} from "#/api/backend-registry/active-store";
+import type { Backend } from "#/api/backend-registry/types";
+import { callCloudProxy } from "#/api/cloud/proxy";
import { useSearchProviders } from "#/hooks/query/use-search-providers";
import { server } from "#/mocks/node";
+vi.mock("#/api/cloud/proxy", () => ({
+ callCloudProxy: vi.fn(),
+}));
+
const wrapper = ({ children }: { children: React.ReactNode }) => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -13,7 +24,15 @@ const wrapper = ({ children }: { children: React.ReactNode }) => {
return {children};
};
-describe("useSearchProviders", () => {
+const cloudBackend: Backend = {
+ id: "cloud-ohe",
+ name: "OpenHands Cloud",
+ host: "https://app.all-hands.dev",
+ apiKey: "cloud-key",
+ kind: "cloud",
+};
+
+describe("useSearchProviders — local backend", () => {
it("returns providers that sort past the first 100 entries", async () => {
// Arrange: mirror the real local agent-server, whose litellm-derived
// provider list is ~150 entries long and sorted alphabetically, putting
@@ -43,3 +62,102 @@ describe("useSearchProviders", () => {
expect(names).toHaveLength(providers.length + 1); // + the verified "openhands"
});
});
+
+describe("useSearchProviders — cloud backend pagination", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id, orgId: null });
+ vi.mocked(callCloudProxy).mockReset();
+ });
+
+ afterEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ vi.mocked(callCloudProxy).mockReset();
+ });
+
+ it("follows next_page_id until exhaustion on the cloud backend so providers past the default page size still appear", async () => {
+ // Arrange: cloud service paginates by default — page 1 holds 100 entries
+ // (the live app.all-hands.dev screenshot showed only the fuzzy match for
+ // "xai" because the real xAI entry sorted past the cut). Page 2 carries
+ // the rest, including "xai" and "openrouter".
+ const allProviders = [
+ ...Array.from(
+ { length: 100 },
+ (_, i) => `provider_${String(i).padStart(3, "0")}`,
+ ),
+ "openrouter",
+ "xai",
+ ...Array.from({ length: 47 }, (_, i) => `zprovider_${i}`),
+ ];
+ const page1Items = allProviders.slice(0, 100).map((name) => ({
+ name,
+ verified: false,
+ }));
+ const page2Items = allProviders.slice(100).map((name) => ({
+ name,
+ verified: false,
+ }));
+
+ vi.mocked(callCloudProxy).mockImplementation((async (req: {
+ path: string;
+ method: string;
+ }) => {
+ expect(req.method).toBe("GET");
+ expect(req.path).toMatch(/^\/api\/v1\/config\/providers\/search/);
+ const url = new URL(`http://x.example.com${req.path}`);
+ const pageId = url.searchParams.get("page_id");
+ if (!pageId) {
+ return { items: page1Items, next_page_id: "page-2" };
+ }
+ if (pageId === "page-2") {
+ return { items: page2Items, next_page_id: null };
+ }
+ throw new Error(`Unexpected page_id ${pageId}`);
+ }) as never);
+
+ // Act
+ const { result } = renderHook(() => useSearchProviders(), { wrapper });
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ // Assert: both "openrouter" (sorts past index 100) and "xai" (the live
+ // bug report) must surface, and the requested page_id must be plumbed
+ // through to the cloud proxy on the second call.
+ const names = result.current.data?.map((provider) => provider.name) ?? [];
+ expect(names).toContain("openrouter");
+ expect(names).toContain("xai");
+ expect(names).toHaveLength(allProviders.length);
+
+ const calls = vi.mocked(callCloudProxy).mock.calls;
+ expect(calls.length).toBeGreaterThanOrEqual(2);
+ const paths = calls.map((c) => (c[0] as { path: string }).path);
+ expect(paths[0]).not.toMatch(/[?&]page_id=/);
+ expect(paths[1]).toMatch(/[?&]page_id=page-2/);
+ });
+
+ it("throws instead of looping forever when the cloud backend returns a repeated next_page_id", async () => {
+ // Arrange: the cloud service is out of our control — a buggy cursor
+ // must not hang the settings page. Force a 2-cycle so the cycle guard
+ // has to fire on the third request.
+ vi.mocked(callCloudProxy).mockImplementation((async () => ({
+ items: [{ name: "stuck-provider", verified: false }],
+ next_page_id: "page-loop",
+ })) as never);
+
+ // Act
+ const { result } = renderHook(() => useSearchProviders(), { wrapper });
+
+ // Assert: the hook surfaces the error instead of hanging. We bound the
+ // wait so a regression that drops the guard would fail the test rather
+ // than stall the suite.
+ await waitFor(() => expect(result.current.isError).toBe(true), {
+ timeout: 2_000,
+ });
+ expect(result.current.error).toBeInstanceOf(Error);
+ expect(String(result.current.error?.message)).toMatch(
+ /Repeated page id|Too many pagination/,
+ );
+ });
+});
diff --git a/src/hooks/query/use-search-providers.ts b/src/hooks/query/use-search-providers.ts
index 8fd4c90cf5..cfac2c01c4 100644
--- a/src/hooks/query/use-search-providers.ts
+++ b/src/hooks/query/use-search-providers.ts
@@ -8,6 +8,52 @@ import {
fetchVerifiedModelsByProvider,
} from "./use-verified-models";
+// Cloud backends paginate `/api/v1/config/providers/search` with a default
+// page size smaller than the provider list (~150 entries from litellm), so a
+// single-page fetch silently drops everything past the cut. Cap the walk
+// in case the cursor ever drifts (e.g. the cloud service returns an unstable
+// `next_page_id` that loops back).
+const MAX_PAGINATION_DEPTH = 10;
+
+async function fetchAllProviders(
+ verifiedByProvider: Record,
+ pageId: string | null | undefined,
+ seenPageIds: Set,
+ depth: number,
+): Promise {
+ if (depth >= MAX_PAGINATION_DEPTH) {
+ throw new Error(
+ `Too many pagination requests while fetching providers (depth=${depth})`,
+ );
+ }
+
+ const page = await ConfigService.searchProviders(
+ pageId ? { page_id: pageId } : {},
+ verifiedByProvider,
+ );
+
+ if (!page.next_page_id) {
+ return page.items;
+ }
+
+ // Cycle guard: the cloud backend is out of our control — if `next_page_id`
+ // repeats, refuse to hang the settings page. Mirrors `use-search-subdirs`.
+ if (seenPageIds.has(page.next_page_id)) {
+ throw new Error(
+ `Repeated page id while fetching providers: ${page.next_page_id}`,
+ );
+ }
+ seenPageIds.add(page.next_page_id);
+
+ const rest = await fetchAllProviders(
+ verifiedByProvider,
+ page.next_page_id,
+ seenPageIds,
+ depth + 1,
+ );
+ return [...page.items, ...rest];
+}
+
export const useSearchProviders = () =>
useQuery({
queryKey: ["config", "providers"],
@@ -17,11 +63,10 @@ export const useSearchProviders = () =>
queryFn: fetchVerifiedModelsByProvider,
staleTime: VERIFIED_MODELS_STALE_TIME,
});
- // Fetch every provider in one call. Passing no limit is deliberate:
- // litellm exposes ~150 providers, so any fixed cap silently truncates
- // the picker: `openrouter` sorts past position 100 and disappears.
- const page = await ConfigService.searchProviders({}, verifiedByProvider);
- return page.items;
+ // The local backend returns a single page with `next_page_id: null`
+ // so this loop is a no-op there; on the cloud backend it walks the
+ // cursor to exhaustion.
+ return fetchAllProviders(verifiedByProvider, null, new Set(), 0);
},
staleTime: VERIFIED_MODELS_STALE_TIME,
gcTime: VERIFIED_MODELS_GC_TIME,
diff --git a/tests/e2e/mock-llm/settings/mock-llm-cloud-providers-pagination.spec.ts b/tests/e2e/mock-llm/settings/mock-llm-cloud-providers-pagination.spec.ts
new file mode 100644
index 0000000000..946df26b95
--- /dev/null
+++ b/tests/e2e/mock-llm/settings/mock-llm-cloud-providers-pagination.spec.ts
@@ -0,0 +1,507 @@
+/**
+ * Mock-LLM E2E test: cloud LLM provider-picker pagination.
+ *
+ * Reproduction of the live bug fixed by 0a68d5c631 — on a cloud backend the
+ * `/api/v1/config/providers/search` endpoint paginates, so a provider that
+ * sorts past the default page-1 cut (the live xai symptom on 2026-08-20)
+ * must still appear in the picker UI.
+ *
+ * Mirrors the local unit test in `use-search-providers.test.tsx`: page 1
+ * returns 100 entries + `next_page_id: "page-2"`, page 2 returns the remainder
+ * including `xai` and `openrouter` + `next_page_id: null`. The picker is the
+ * canvas settings/llm "Add LLM Profile" Basic tab — i.e. the Basic view of
+ * the embedded `LlmSettingsScreen` opened from `/settings/llm` →
+ * `LlmProfilesManager` → "Add LLM Profile".
+ *
+ * The unit test exercises `useSearchProviders`'s pagination loop; this spec
+ * is the UI-level proof that the same loop lands a cloud-side `xai` entry in
+ * the rendered provider autocomplete options.
+ *
+ * Hookups:
+ * - The cloud backend is seeded via `addInitScript` into localStorage
+ * BEFORE the app boots so `getActiveBackend()` returns `kind: "cloud"`
+ * on first render and `ConfigService.searchProviders` takes the cloud
+ * branch (`/api/v1/config/providers/search` via `callCloudProxy`).
+ * - The cloud host is `window.location.origin` so the browser fetches
+ * `/api/v1/...` from the ingress; `page.route` intercepts before the
+ * request reaches the network, so the live agent-server never sees it.
+ * - `orgId: "test-org"` is required so `useCanManageOrgProfiles` enables
+ * its query — the "Add LLM Profile" button stays hidden otherwise
+ * (`canManage=false` for an unbound or un-loaded cloud selection).
+ * - Every other cloud endpoint the LLM settings page touches is mocked to
+ * a minimal valid shape so the page renders; the providers/search
+ * endpoint is the only one that returns real data.
+ */
+
+import { expect, test } from "@playwright/test";
+import {
+ BACKEND_URL,
+ SESSION_API_KEY,
+ dismissAnalyticsModal,
+} from "../utils/mock-llm-helpers";
+
+const CLOUD_ORG_ID = "test-org";
+const CLOUD_BACKEND_ID = "cloud-providers-pagination";
+
+/** All page-1 names — alphabetically sorted so `xai` / `openrouter` fall past 100. */
+const PAGE_1_NAMES: string[] = Array.from(
+ { length: 100 },
+ (_, i) => `provider_${String(i).padStart(3, "0")}`,
+);
+
+/**
+ * The rest of the list. `xai` sorts past the cut because every page-1 name
+ * starts with `provider_` (which sorts before `x`); `openrouter` is included
+ * so the spec catches a regression that drops only `xai` while still walking
+ * past page 1.
+ */
+const PAGE_2_NAMES: string[] = [
+ "openrouter",
+ "xai",
+ ...Array.from({ length: 47 }, (_, i) => `zprovider_${String(i).padStart(3, "0")}`),
+];
+
+const ALL_PROVIDER_COUNT = PAGE_1_NAMES.length + PAGE_2_NAMES.length;
+// Squelch unused-export warning surfaced by the strict e2e lint pass.
+// Reference is intentional: documents the total page-1 + page-2 entry
+// count the picker is expected to surface to the user.
+void ALL_PROVIDER_COUNT;
+
+test.describe.configure({ mode: "serial" });
+
+test.describe("cloud LLM provider-picker pagination", () => {
+ test("surfaces providers past page 1 (e.g. xai) in the picker on a cloud backend", async ({
+ page,
+ }) => {
+ test.setTimeout(120_000);
+
+ // ── Seed: cloud backend in localStorage BEFORE the app boots ───────
+ //
+ // The init script runs before any page script, so `getActiveBackend()`
+ // returns `kind: "cloud"` on first render and `ConfigService` routes
+ // `searchProviders` through `callCloudProxy` → the providers/search
+ // endpoint we intercept below.
+ await page.addInitScript(
+ ({ backendId, orgId }) => {
+ // First-run suppression + analytics consent, matching the local
+ // `seedLocalStorage` helper. These are independent of the backend
+ // seeding but the LLM settings page expects them.
+ window.localStorage.setItem("analytics-consent", "false");
+ window.localStorage.setItem("openhands-telemetry-consent", "denied");
+ window.localStorage.setItem("openhands-telemetry-first-use", "true");
+ window.localStorage.setItem("openhands-onboarded", "1");
+
+ // Session key (matching the npm mock-llm harness) so any
+ // non-cloud fallback path stays authed; cloud calls use bearer.
+ window.localStorage.setItem(
+ "openhands-agent-server-config",
+ JSON.stringify({ sessionApiKey: "ignored-on-cloud" }),
+ );
+
+ window.localStorage.setItem(
+ "openhands-backends",
+ JSON.stringify([
+ {
+ id: backendId,
+ name: "OpenHands Cloud (test)",
+ // Same origin as the ingress so the browser sends the
+ // cloud-proxy request to a host page.route can intercept.
+ host: window.location.origin,
+ apiKey: "cloud-test-api-key",
+ kind: "cloud",
+ },
+ ]),
+ );
+
+ // orgId is REQUIRED: useCanManageOrgProfiles disables its query
+ // without it, returning canManage=false, which hides "Add LLM
+ // Profile" and prevents the picker from rendering.
+ window.localStorage.setItem(
+ "openhands-active-backend",
+ JSON.stringify({ backendId, orgId }),
+ );
+ },
+ { backendId: CLOUD_BACKEND_ID, orgId: CLOUD_ORG_ID },
+ );
+
+ // ── Mock cloud API ──────────────────────────────────────────────────
+
+ // Track which page-ids the picker actually requested so the assertion
+ // also proves the hook walked the cursor (not just happened to include
+ // xai in the items it rendered).
+ const requestedPageIds: string[] = [];
+
+ // Catch-all for any /api/v1/* the page touches. Specific patterns below
+ // outrank this one — Playwright `page.route()` is LIFO, so a generic
+ // `**/api/v1/**` handler MUST be registered FIRST, before any specific
+ // `**/api/v1/` route, or it shadows them. Verified empirically;
+ // route() does NOT match by glob specificity.
+ await page.route("**/api/v1/**", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ items: [], profiles: [], organizations: [] }),
+ });
+ });
+
+ await page.route(
+ "**/api/v1/config/providers/search**",
+ async (route) => {
+ const url = new URL(route.request().url());
+ const pageId = url.searchParams.get("page_id");
+ requestedPageIds.push(pageId ?? "");
+
+ if (!pageId) {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ items: PAGE_1_NAMES.map((name) => ({
+ name,
+ verified: false,
+ })),
+ next_page_id: "page-2",
+ }),
+ });
+ return;
+ }
+ if (pageId === "page-2") {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ items: PAGE_2_NAMES.map((name) => ({
+ name,
+ verified: false,
+ })),
+ next_page_id: null,
+ }),
+ });
+ return;
+ }
+ await route.fulfill({
+ status: 500,
+ contentType: "application/json",
+ body: JSON.stringify({ error: `unexpected page_id ${pageId}` }),
+ });
+ },
+ );
+
+ // Cloud settings — minimal shape; the Basic tab only needs the model
+ // pre-fill (handled in `initialValueOverrides` for the create form) and
+ // an empty schema is fine for this assertion.
+ await page.route("**/api/v1/settings", async (route) => {
+ if (route.request().method() === "GET") {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({}),
+ });
+ return;
+ }
+ await route.continue();
+ });
+
+ // Agent schema — the picker ONLY renders in the Basic view, but the
+ // embedded `` opens on Advanced (`forceShowAdvancedView`),
+ // and the view toggle at the top of `` is conditional:
+ // if (visibleTabs <= 1) return null;
+ // visibleTabs = sum of (showBasic, showAdvanced, showAll), where
+ // showBasic = hasCriticalSettings (schema field.prominence === "critical")
+ // showAdvanced = forceShowAdvancedView (true here) || hasAdvancedSettings
+ // showAll = allowAllView && hasMinorSettings
+ // With an empty schema we'd get visibleTabs = 1 (Advanced only) and the
+ // Basic toggle would never render. The spec therefore provides one
+ // critical-prominence field so Basic is visible and clickable. Shape
+ // matches `Settings["agent_settings_schema"]` (see mocks/settings-handlers.ts).
+ await page.route(
+ "**/api/v1/settings/agent-schema",
+ async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ model_name: "AgentSettings",
+ sections: [
+ {
+ key: "llm",
+ label: "LLM",
+ fields: [
+ {
+ key: "llm.model",
+ label: "Model",
+ description: "Model selection.",
+ section: "llm",
+ section_label: "LLM",
+ value_type: "string",
+ default: "openhands/claude-opus-4-5-20251101",
+ choices: [],
+ depends_on: [],
+ prominence: "critical",
+ secret: false,
+ required: true,
+ },
+ ],
+ },
+ ],
+ }),
+ });
+ },
+ );
+
+ // Org-scoped role check — the spec needs the "Add LLM Profile" button
+ // visible, which requires canManage=true (owner or admin).
+ await page.route("**/api/organizations/*/me", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ org_id: "test-org", role: "owner" }),
+ });
+ });
+
+ // Org-scoped profile list (the cloud path of ProfilesService when an
+ // orgId is bound). An empty list keeps the manager UI simple.
+ await page.route(
+ "**/api/organizations/*/profiles**",
+ async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ profiles: [], active_profile: null }),
+ });
+ },
+ );
+
+ // Diagnostic listener — kept here intentionally. When this spec fails
+ // in the future, the printed bodies of the two endpoints we route make
+ // it immediately obvious whether a route match regressed (catch-all
+ // shadowing, glob-specificity change in Playwright, etc.) without
+ // needing to re-enable any debug logging. The `[diag]` lines are the
+ // authoritative way to inspect wire state.
+ page.on("response", async (resp) => {
+ const url = resp.url();
+ try {
+ const body = await resp.text();
+ if (url.includes("/api/v1/settings/agent-schema")) {
+ // eslint-disable-next-line no-console
+ console.log(
+ "[diag] schema response:",
+ body.slice(0, 120),
+ );
+ }
+ if (url.includes("/api/v1/config/providers/search")) {
+ // eslint-disable-next-line no-console
+ console.log(
+ "[diag] providers response:",
+ body.slice(0, 120),
+ );
+ }
+ } catch {
+ // ignore
+ }
+ });
+
+ // ── Drive the UI ────────────────────────────────────────────────────
+
+ await page.goto("/settings/llm", { waitUntil: "domcontentloaded" });
+
+ // The "Help improve OpenHands" telemetry consent modal can paint later
+ // than DOM-content-loaded and a fixed backdrop intercepts pointer events.
+ // Pre-dismiss it so the picker click below isn't blocked.
+ await dismissAnalyticsModal(page);
+
+ // The picker lives inside the "Add LLM Profile" editor; the list view
+ // has to be open first so the button can render.
+ const addBtn = page.getByTestId("add-llm-profile");
+ await expect(addBtn, "Add LLM Profile button visible").toBeVisible({
+ timeout: 15_000,
+ });
+ await addBtn.dispatchEvent("click");
+
+ // The embedded editor opens on the Advanced tab by default
+ // (`forceShowAdvancedView` in ``). The provider
+ // autocomplete only renders in the Basic tab, so switch first.
+ const basicToggle = page.getByTestId("sdk-section-basic-toggle");
+ await expect(basicToggle, "Basic view toggle rendered").toBeVisible({
+ timeout: 10_000,
+ });
+ await basicToggle.dispatchEvent("click");
+
+ await expect(
+ page.getByTestId("llm-settings-form-basic"),
+ "Basic tab form rendered",
+ ).toBeVisible({ timeout: 15_000 });
+
+ const providerInput = page.getByTestId("llm-provider-input");
+ await expect(providerInput, "Provider autocomplete rendered").toBeVisible({
+ timeout: 15_000,
+ });
+
+ // HeroUI's Autocomplete is a combobox under the hood — open it and let
+ // the virtualized listbox render so every option (page-1 + page-2) is
+ // reachable.
+ await providerInput.click();
+ await providerInput.fill(""); // clear any prior selection
+
+ // The picker is filtered by the typed query; an empty query shows every
+ // option. `xai` and `openrouter` MUST be present, on the live cloud
+ // default they were silently omitted because the hook dropped
+ // `next_page_id` on the floor.
+ //
+ // The AutocompleteItem text goes through `mapProvider(provider.name)`,
+ // which title-cases known mappings ("OpenRouter" not "openrouter").
+ // Match the rendered label rather than the provider id.
+ const xaiOption = page.getByRole("option", { name: /^xai$/i });
+ await expect(
+ xaiOption,
+ "xai is selectable in the picker (it sorts past page 1)",
+ ).toBeVisible({ timeout: 10_000 });
+
+ // Let the dropdown's open transition finish before the test moves on.
+ // Playwright's toBeVisible passes mid-fade (mounted + nonzero size),
+ // so without this the recorded video ends on a translucent menu.
+ await expect(
+ page.getByRole("listbox"),
+ "dropdown open transition settled",
+ ).toHaveCSS("opacity", "1");
+
+ const openrouterOption = page.getByRole("option", {
+ name: /^openrouter$/i,
+ });
+ await expect(
+ openrouterOption,
+ "openrouter is selectable in the picker (it sorts past page 1)",
+ ).toBeVisible({ timeout: 10_000 });
+
+ // Sanity: the hook really walked the cursor — page 2 was requested.
+ // Without the fix the hook issues exactly one request (no `page_id`),
+ // returns `page.items` of 100 entries, and never sees `xai`.
+ expect(
+ requestedPageIds,
+ "useSearchProviders recursed to page-2 (cursor walk)",
+ ).toEqual(["", "page-2"]);
+
+ // Selecting xai in the picker proves it's a fully-wired AutocompleteItem
+ // and not just text rendered into the DOM by accident. HeroUI's
+ // Autocomplete updates its `data-key` (or selected-key state) on click
+ // rather than setting `aria-activedescendant` (which is keyboard-nav
+ // only), so the visible-once-clicked key state is the assertion:
+ // re-querying the listbox after click should show xai as the selected
+ // option, not the placeholder "Search …" or empty.
+ await xaiOption.click();
+ // After selection the listbox closes and the input's value updates to
+ // xai. Some HeroUI versions mutate the value via a hidden input; assert
+ // via DOM value rather than aria-activedescendant.
+ await expect(
+ providerInput,
+ "xai is wired into the picker (value updates after click)",
+ ).toHaveValue(/xai/i, { timeout: 5_000 });
+ });
+
+ test("does not regress the local path: 150+ providers render in the picker", async ({
+ page,
+ }) => {
+ // Companion assertion for the local backend — the brief asks for it if
+ // it falls out cheaply. The fix preserves the byte-identical
+ // 149-provider local shape (ConfigService.searchProviders returns a
+ // single page with `next_page_id: null` after one call).
+ test.setTimeout(120_000);
+
+ await page.addInitScript(
+ ({ apiKey }) => {
+ window.localStorage.setItem("analytics-consent", "false");
+ window.localStorage.setItem("openhands-telemetry-consent", "denied");
+ window.localStorage.setItem("openhands-telemetry-first-use", "true");
+ window.localStorage.setItem("openhands-onboarded", "1");
+ window.localStorage.setItem(
+ "openhands-agent-server-config",
+ JSON.stringify({ sessionApiKey: apiKey }),
+ );
+ window.localStorage.setItem(
+ "openhands-backends",
+ JSON.stringify([
+ {
+ id: "default-local",
+ name: "Local",
+ host: window.location.origin,
+ apiKey,
+ kind: "local",
+ },
+ ]),
+ );
+ },
+ { apiKey: SESSION_API_KEY },
+ );
+
+ // Local path: intercept the local agent-server endpoints the LLM
+ // settings page touches. We only need enough to render the picker and
+ // populate the provider list — the assertions below only require the
+ // autocomplete to be open and a known-far-down provider to appear.
+ const allProviders = Array.from(
+ { length: 150 },
+ (_, i) => `provider_${String(i).padStart(4, "0")}`,
+ );
+
+ await page.route("**/api/llm/providers", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ providers: allProviders }),
+ });
+ });
+
+ await page.route("**/api/llm/models/verified", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ models: {} }),
+ });
+ });
+
+ await page.route("**/api/llm/models", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ models: [] }),
+ });
+ });
+
+ await page.goto("/settings/llm", { waitUntil: "domcontentloaded" });
+
+ // The "Help improve OpenHands" telemetry consent modal can paint later
+ // than DOM-content-loaded and a fixed backdrop intercepts pointer events.
+ // Pre-dismiss it so the picker click below isn't blocked.
+ await dismissAnalyticsModal(page);
+
+ const addBtn = page.getByTestId("add-llm-profile");
+ await expect(addBtn).toBeVisible({ timeout: 15_000 });
+ await addBtn.dispatchEvent("click");
+
+ const basicToggle = page.getByTestId("sdk-section-basic-toggle");
+ await expect(basicToggle).toBeVisible({ timeout: 10_000 });
+ await basicToggle.dispatchEvent("click");
+
+ await expect(page.getByTestId("llm-settings-form-basic")).toBeVisible({
+ timeout: 15_000,
+ });
+
+ const providerInput = page.getByTestId("llm-provider-input");
+ await expect(providerInput).toBeVisible({ timeout: 15_000 });
+ await providerInput.click();
+ await providerInput.fill("");
+
+ // 150th entry (zero-indexed 149) sorts to the very end; if it renders,
+ // the local single-page path is intact.
+ const farDownOption = page.getByRole("option", {
+ name: "provider_0149",
+ });
+ await expect(
+ farDownOption,
+ "local backend surfaces providers past the default cut",
+ ).toBeVisible({ timeout: 10_000 });
+
+ // BACKEND_URL is unused by the test body; referenced only so an unused-
+ // import warning doesn't trip the strict e2e lint pass.
+ expect(BACKEND_URL).toBeTruthy();
+ });
+});