Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions __tests__/components/home/llm-not-configured-banner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,39 @@ describe("LlmNotConfiguredBanner", () => {
).not.toBeInTheDocument();
});

it("shows a specific recovery message when the active profile has no API key", async () => {
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
buildSettings({ llm_api_key_set: false }),
);
vi.spyOn(ProfilesService, "listProfiles").mockResolvedValue({
profiles: [
{
name: "active-profile",
model: "openai/gpt-4.1",
base_url: null,
api_key_set: false,
},
],
active_profile: "active-profile",
});
const user = userEvent.setup();
const { navigate } = renderBanner();

const banner = await screen.findByTestId("home-llm-not-configured-banner");
expect(banner).toHaveTextContent(
"HOME$LLM_PROFILE_MISSING_API_KEY_MESSAGE",
);
expect(
screen.getByTestId("home-llm-not-configured-action"),
).toHaveTextContent("HOME$LLM_PROFILE_MISSING_API_KEY_ACTION");

await user.click(screen.getByTestId("home-llm-not-configured-action"));

expect(navigate).toHaveBeenCalledWith(
"/settings/llm?profile=active-profile",
);
});

it("stays hidden for ACP agents, which own their LLM and need no key", async () => {
// Arrange: ACP agent, no key — must not be nagged.
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
Expand Down
27 changes: 22 additions & 5 deletions src/components/features/home/llm-not-configured-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ import { useTranslation } from "react-i18next";
import { FaTriangleExclamation } from "react-icons/fa6";
import { I18nKey } from "#/i18n/declaration";
import { useNavigation } from "#/context/navigation-context";
import { useLlmConfigured } from "#/hooks/use-llm-configured";
import {
LLM_CONFIGURATION_ISSUES,
useLlmConfigured,
} from "#/hooks/use-llm-configured";
import { BrandButton } from "#/components/features/settings/brand-button";
import { Typography } from "#/ui/typography";
import { buildLlmSettingsRoute } from "#/constants/llm-settings";

/**
* Warns the user on the home screen when the active agent has no usable LLM —
Expand All @@ -19,12 +23,25 @@ import { Typography } from "#/ui/typography";
export function LlmNotConfiguredBanner() {
const { t } = useTranslation("openhands");
const { navigate } = useNavigation();
const { isConfigured, isLoading } = useLlmConfigured();
const { isConfigured, isLoading, issue, activeProfileName } =
useLlmConfigured();

if (isLoading || isConfigured) {
return null;
}

const isActiveProfileMissingApiKey =
issue === LLM_CONFIGURATION_ISSUES.ACTIVE_PROFILE_MISSING_API_KEY;
const messageKey = isActiveProfileMissingApiKey
? I18nKey.HOME$LLM_PROFILE_MISSING_API_KEY_MESSAGE
: I18nKey.HOME$LLM_NOT_CONFIGURED_MESSAGE;
const actionKey = isActiveProfileMissingApiKey
? I18nKey.HOME$LLM_PROFILE_MISSING_API_KEY_ACTION
: I18nKey.HOME$LLM_NOT_CONFIGURED_ACTION;
const route = buildLlmSettingsRoute(
isActiveProfileMissingApiKey ? activeProfileName : null,
);

return (
<div
data-testid="home-llm-not-configured-banner"
Expand All @@ -36,17 +53,17 @@ export function LlmNotConfiguredBanner() {
<FaTriangleExclamation className="text-primary align-middle" />
</div>
<Typography.Text className="ml-3 text-sm font-medium">
{t(I18nKey.HOME$LLM_NOT_CONFIGURED_MESSAGE)}
{t(messageKey, { name: activeProfileName })}
</Typography.Text>
</div>

<BrandButton
testId="home-llm-not-configured-action"
type="button"
variant="primary"
onClick={() => navigate("/settings/llm")}
onClick={() => navigate(route)}
>
{t(I18nKey.HOME$LLM_NOT_CONFIGURED_ACTION)}
{t(actionKey)}
</BrandButton>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,13 @@ export function shouldReapplyProfileAfterSave({
* See PR review feedback for details.
*/

export function LlmSettingsLocalView() {
interface LlmSettingsLocalViewProps {
editProfileName?: string | null;
}

export function LlmSettingsLocalView({
editProfileName = null,
}: LlmSettingsLocalViewProps = {}) {
const { t } = useTranslation("openhands");
const { setHideSectionHeader } = useSettingsSectionHeader();
const saveProfile = useSaveLlmProfile();
Expand Down Expand Up @@ -112,6 +118,7 @@ export function LlmSettingsLocalView() {
null,
);
const [isSaving, setIsSaving] = useState(false);
const requestedEditProfileNameRef = useRef<string | null>(null);

useEffect(() => {
setHideSectionHeader(viewMode !== "list");
Expand Down Expand Up @@ -206,6 +213,19 @@ export function LlmSettingsLocalView() {
[t],
);

useEffect(() => {
if (!editProfileName) return;
if (requestedEditProfileNameRef.current === editProfileName) return;

const profile = profilesData?.profiles.find(
(candidate) => candidate.name === editProfileName,
);
if (!profile) return;

requestedEditProfileNameRef.current = editProfileName;
void handleEditProfile(profile);
}, [editProfileName, handleEditProfile, profilesData?.profiles]);

const handleBackToList = useCallback(() => {
setViewMode("list");
setEditingProfile(null);
Expand Down
11 changes: 11 additions & 0 deletions src/constants/llm-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const LLM_SETTINGS_ROUTE = "/settings/llm";
export const LLM_SETTINGS_EDIT_PROFILE_QUERY_PARAM = "profile";

export function buildLlmSettingsRoute(profileName?: string | null) {
if (!profileName) return LLM_SETTINGS_ROUTE;

const params = new URLSearchParams({
[LLM_SETTINGS_EDIT_PROFILE_QUERY_PARAM]: profileName,
});
return `${LLM_SETTINGS_ROUTE}?${params.toString()}`;
}
30 changes: 28 additions & 2 deletions src/hooks/use-llm-configured.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ import { useConfig } from "#/hooks/query/use-config";
import { useLlmProfiles } from "#/hooks/query/use-llm-profiles";
import { useActiveBackend } from "#/contexts/active-backend-context";
import { isSettingsPageHidden } from "#/utils/settings-utils";
import { LLM_SETTINGS_ROUTE } from "#/constants/llm-settings";

export const LLM_CONFIGURATION_ISSUES = {
NOT_CONFIGURED: "not-configured",
ACTIVE_PROFILE_MISSING_API_KEY: "active-profile-missing-api-key",
} as const;

type LlmConfigurationIssue =
(typeof LLM_CONFIGURATION_ISSUES)[keyof typeof LLM_CONFIGURATION_ISSUES];

interface LlmConfiguredResult {
/**
Expand All @@ -21,6 +30,13 @@ interface LlmConfiguredResult {
* warning doesn't flash before data loads or on a transient network error.
*/
isLoading: boolean;
/**
* Specific recovery issue for the unconfigured state. Used by the UI to
* distinguish a fresh setup gap from an active saved profile whose key is no
* longer usable.
*/
issue: LlmConfigurationIssue | null;
activeProfileName: string | null;
}

/**
Expand Down Expand Up @@ -53,8 +69,10 @@ export function useLlmConfigured(): LlmConfiguredResult {
(profile) => profile.name === profilesData.active_profile,
);
const hasActiveProfileApiKey = activeProfile?.api_key_set === true;
const hasActiveProfileMissingApiKey =
isLocal && Boolean(activeProfile) && activeProfile?.api_key_set === false;
const llmSettingsHidden = isSettingsPageHidden(
"/settings/llm",
LLM_SETTINGS_ROUTE,
config?.feature_flags,
);

Expand All @@ -76,10 +94,18 @@ export function useLlmConfigured(): LlmConfiguredResult {
const configIndeterminate = configLoading || (configError && !config);
const profilesIndeterminate =
profilesLoading || (profilesError && !profilesData);
const isConfigured = isAcpAgent || llmSettingsHidden || hasUsableLlm;
const issue = isConfigured
? null
: hasActiveProfileMissingApiKey
? LLM_CONFIGURATION_ISSUES.ACTIVE_PROFILE_MISSING_API_KEY
: LLM_CONFIGURATION_ISSUES.NOT_CONFIGURED;

return {
isConfigured: isAcpAgent || llmSettingsHidden || hasUsableLlm,
isConfigured,
isLoading:
settingsIndeterminate || configIndeterminate || profilesIndeterminate,
issue,
activeProfileName: activeProfile?.name ?? null,
};
}
34 changes: 34 additions & 0 deletions src/i18n/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -866,6 +866,40 @@
"uk": "Налаштувати LLM",
"ca": "Configura el LLM"
},
"HOME$LLM_PROFILE_MISSING_API_KEY_MESSAGE": {
"en": "Your active LLM profile \"{{name}}\" appears to be missing its API key. Edit the profile and re-enter the key before starting a conversation.",
"ja": "有効な LLM プロファイル「{{name}}」の API キーが見つからないようです。会話を開始する前にプロファイルを編集してキーを再入力してください。",
"zh-CN": "您的活动 LLM 配置文件“{{name}}”似乎缺少 API 密钥。请先编辑该配置文件并重新输入密钥,然后再开始对话。",
"zh-TW": "您的作用中 LLM 設定檔「{{name}}」似乎缺少 API 金鑰。請先編輯該設定檔並重新輸入金鑰,再開始對話。",
"ko-KR": "활성 LLM 프로필 \"{{name}}\"에 API 키가 없는 것 같습니다. 대화를 시작하기 전에 프로필을 편집하고 키를 다시 입력하세요.",
"no": "Den aktive LLM-profilen «{{name}}» ser ut til å mangle API-nøkkelen. Rediger profilen og skriv inn nøkkelen på nytt før du starter en samtale.",
"it": "Il profilo LLM attivo \"{{name}}\" sembra non avere la chiave API. Modifica il profilo e reinserisci la chiave prima di avviare una conversazione.",
"pt": "O perfil LLM ativo \"{{name}}\" parece estar sem a chave de API. Edite o perfil e insira a chave novamente antes de iniciar uma conversa.",
"es": "El perfil LLM activo \"{{name}}\" parece no tener su clave de API. Edita el perfil y vuelve a introducir la clave antes de iniciar una conversación.",
"ar": "يبدو أن ملف تعريف LLM النشط \"{{name}}\" يفتقد مفتاح API. عدّل الملف وأعد إدخال المفتاح قبل بدء محادثة.",
"fr": "Le profil LLM actif « {{name}} » semble ne plus avoir de clé API. Modifiez le profil et saisissez à nouveau la clé avant de démarrer une conversation.",
"tr": "Etkin LLM profili \"{{name}}\" API anahtarını kaybetmiş görünüyor. Bir konuşma başlatmadan önce profili düzenleyip anahtarı yeniden girin.",
"de": "Beim aktiven LLM-Profil „{{name}}“ scheint der API-Schlüssel zu fehlen. Bearbeite das Profil und gib den Schlüssel erneut ein, bevor du eine Konversation startest.",
"uk": "Схоже, в активному профілі LLM «{{name}}» відсутній API-ключ. Відредагуйте профіль і повторно введіть ключ, перш ніж починати розмову.",
"ca": "Sembla que al perfil LLM actiu \"{{name}}\" li falta la clau d'API. Edita el perfil i torna a introduir la clau abans d'iniciar una conversa."
},
"HOME$LLM_PROFILE_MISSING_API_KEY_ACTION": {
"en": "Edit profile",
"ja": "プロファイルを編集",
"zh-CN": "编辑配置文件",
"zh-TW": "編輯設定檔",
"ko-KR": "프로필 편집",
"no": "Rediger profil",
"it": "Modifica profilo",
"pt": "Editar perfil",
"es": "Editar perfil",
"ar": "تعديل الملف التعريفي",
"fr": "Modifier le profil",
"tr": "Profili düzenle",
"de": "Profil bearbeiten",
"uk": "Редагувати профіль",
"ca": "Edita el perfil"
},
"HOME$READ_THIS": {
"en": "Read this",
"ja": "詳細はこちら",
Expand Down
9 changes: 8 additions & 1 deletion src/routes/llm-settings.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from "react";
import { useSearchParams } from "react-router";
import { useTranslation } from "react-i18next";
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
import { useAgentSettingsSchema } from "#/hooks/query/use-agent-settings-schema";
Expand Down Expand Up @@ -32,6 +33,7 @@ import {
OPENAI_SUBSCRIPTION_VENDOR,
resolveLlmAuthType,
} from "#/constants/llm-subscription";
import { LLM_SETTINGS_EDIT_PROFILE_QUERY_PARAM } from "#/constants/llm-settings";
import { useOpenAISubscriptionModels } from "#/hooks/query/use-llm-subscription-models";

const LLM_EXCLUDED_KEYS = new Set([
Expand Down Expand Up @@ -513,6 +515,7 @@ export function LlmSettingsScreen({
*/
export default function LlmSettingsRoute() {
const { backend } = useActiveBackend();
const [searchParams] = useSearchParams();
const isCloud = backend.kind === "cloud";

// Cloud backends use the standard LLM settings form (no profiles support)
Expand All @@ -521,5 +524,9 @@ export default function LlmSettingsRoute() {
}

// Local backends use the profile management view
return <LlmSettingsLocalView />;
return (
<LlmSettingsLocalView
editProfileName={searchParams.get(LLM_SETTINGS_EDIT_PROFILE_QUERY_PARAM)}
/>
);
}
Loading
Loading