fix: route LLM metadata fetches through cloud proxy for cloud backends (#1109)

* fix: route LLM metadata fetches through cloud proxy for cloud backends

When the active backend is 'cloud', ConfigService.searchProviders,
ConfigService.searchModels, and fetchVerifiedModelsByProvider all called
getAgentServerClientOptions(), which delegates to getEffectiveLocalBackend().
That returns null for cloud backends, so NoBackendAvailableError was thrown,
surfacing as the 'No backend is configured' toast on /settings/llm.

Follow the established cloud-vs-local branching pattern: for cloud backends,
route the three LLM metadata endpoints (/api/llm/providers,
/api/llm/models, /api/llm/models/verified) through callCloudProxy so
correct Bearer auth is used and CORS is handled; for local backends keep
the existing LLMMetadataClient path unchanged.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: extract named fields from raw cloud proxy LLM responses

callCloudProxy returns the full JSON response body, while
LLMMetadataClient extracts named sub-fields:
  getProviders()      → response.data.providers  (raw: { providers: string[] })
  getModels()         → response.data.models      (raw: { models: string[] })
  getVerifiedModels() → response.data.models      (raw: { models: Record<string, string[]> })

The cloud proxy calls were treating the whole response object as the
array/record directly, causing '(models ?? []).filter is not a function'
and garbled provider/model dropdowns.

Use typed raw-response wrappers and chain .then(raw => raw?.field ?? null)
to mirror exactly what LLMMetadataClient does internally.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: use cloud API search endpoints for providers and models

For cloud backends, /api/llm/providers and /api/llm/models are wrong —
those are local agent-server endpoints. The cloud API exposes:
  GET /api/v1/config/providers/search → ProviderPage
  GET /api/v1/config/models/search    → LLMModelPage

Both return the same shape the local ConfigService types already define,
so the cloud path calls callCloudProxy directly and returns the response
as-is, with no reconstruction logic needed.

The intermediate fetchVerifiedModelsByProvider step is skipped for cloud
entirely — the cloud search endpoints embed verified status natively.

Co-authored-by: openhands <openhands@all-hands.dev>

* style: fix prettier formatting on buildCloudQueryString signature

Co-authored-by: openhands <openhands@all-hands.dev>

* docs: address review bot suggestions on cloud LLM metadata fix

- Add JSDoc to ConfigService.searchModels and searchProviders noting that
  verifiedByProvider is ignored for cloud backends (cloud API embeds
  verified status directly on each returned item)
- Expand comment on fetchVerifiedModelsByProvider's cloud early-return to
  clarify it is safe to treat as a no-op for cloud callers

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Tim O'Farrell
2026-06-03 17:53:28 -06:00
committed by GitHub
co-authored by openhands
parent e096779809
commit 17db6dac05
2 changed files with 73 additions and 8 deletions
+64 -8
View File
@@ -1,5 +1,7 @@
import { LLMMetadataClient } from "@openhands/typescript-client/clients";
import { getAgentServerClientOptions } from "../agent-server-client-options";
import { getActiveBackend } from "../backend-registry/active-store";
import { callCloudProxy } from "../cloud/proxy";
import type {
LLMModel,
LLMModelPage,
@@ -42,11 +44,47 @@ function limitItems<T>(items: T[], limit?: number): T[] {
return items.slice(0, limit);
}
function buildCloudQueryString(
params: Record<string, string | number | boolean | undefined>,
): string {
const qs = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) qs.set(key, String(value));
}
const str = qs.toString();
return str ? `?${str}` : "";
}
class ConfigService {
/**
* @param verifiedByProvider - Pre-fetched verified-models map used by the
* local reconstruction path. Ignored for cloud backends, which call
* `/api/v1/config/models/search` directly (verified status is embedded in
* each returned item).
*/
static async searchModels(
params: SearchModelsParams = {},
verifiedByProvider?: Record<string, string[]>,
): Promise<LLMModelPage> {
const active = getActiveBackend();
if (active.backend.kind === "cloud") {
// Cloud exposes /api/v1/config/models/search which returns LLMModelPage directly.
// verifiedByProvider is not needed — the cloud API embeds verified status natively.
const qs = buildCloudQueryString({
page_id: params.page_id,
limit: params.limit,
query: params.query,
verified__eq: params.verified__eq,
provider__eq: params.provider__eq,
});
return callCloudProxy<LLMModelPage>({
backend: active.backend,
method: "GET",
path: `/api/v1/config/models/search${qs}`,
});
}
const llmClient = new LLMMetadataClient(getAgentServerClientOptions());
const verifiedFetch =
verifiedByProvider !== undefined
@@ -87,16 +125,37 @@ class ConfigService {
params.limit,
);
return {
items,
next_page_id: null,
};
return { items, next_page_id: null };
}
/**
* @param verifiedByProvider - Pre-fetched verified-models map used by the
* local reconstruction path. Ignored for cloud backends, which call
* `/api/v1/config/providers/search` directly (verified status is embedded in
* each returned item).
*/
static async searchProviders(
params: SearchProvidersParams = {},
verifiedByProvider?: Record<string, string[]>,
): Promise<ProviderPage> {
const active = getActiveBackend();
if (active.backend.kind === "cloud") {
// Cloud exposes /api/v1/config/providers/search which returns ProviderPage directly.
// verifiedByProvider is not needed — the cloud API embeds verified status natively.
const qs = buildCloudQueryString({
page_id: params.page_id,
limit: params.limit,
query: params.query,
verified__eq: params.verified__eq,
});
return callCloudProxy<ProviderPage>({
backend: active.backend,
method: "GET",
path: `/api/v1/config/providers/search${qs}`,
});
}
const llmClient = new LLMMetadataClient(getAgentServerClientOptions());
const verifiedFetch =
verifiedByProvider !== undefined
@@ -122,10 +181,7 @@ class ConfigService {
params.limit,
);
return {
items,
next_page_id: null,
};
return { items, next_page_id: null };
}
}
+9
View File
@@ -1,5 +1,6 @@
import { LLMMetadataClient } from "@openhands/typescript-client/clients";
import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
import { getActiveBackend } from "#/api/backend-registry/active-store";
export const VERIFIED_MODELS_QUERY_KEY = ["config", "verified-models"] as const;
export const VERIFIED_MODELS_STALE_TIME = 1000 * 60 * 5;
@@ -8,6 +9,14 @@ export const VERIFIED_MODELS_GC_TIME = 1000 * 60 * 15;
export async function fetchVerifiedModelsByProvider(): Promise<
Record<string, string[]>
> {
const active = getActiveBackend();
if (active.backend.kind === "cloud") {
// Cloud backends use /api/v1/config/providers/search and /api/v1/config/models/search,
// which return verified status directly on each item. The intermediate
// verifiedByProvider map is only used by the local ConfigService reconstruction
// logic, so callers can safely treat this empty object as a no-op for cloud.
return {};
}
const client = new LLMMetadataClient(getAgentServerClientOptions());
return (await client.getVerifiedModels()) ?? {};
}