From bbdc4dd4ae09ce8d6bb60ee3ef0eb4c97618404d Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 16 Jun 2026 15:45:41 -0300 Subject: [PATCH 1/3] Add Model Router (meta-profiles) settings tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a /settings/meta-llm page for managing meta-profiles — declarative model-routing configs consumed by the agent's classify_and_switch_llm tool. A meta-profile names a classifier_model, a default_model and a list of task classes, each referencing a saved LLM profile. - MetaProfilesService over the new /api/meta-profiles endpoints (driven by the SDK's public HttpClient, mirroring ProfilesClient) - useMetaProfiles query + save/delete/activate mutations - List/create/edit view with profile-name dropdowns sourced from saved LLM profiles, plus a delete confirmation modal - Nav entry, route, and i18n strings (all languages filled per repo policy) - Local-backend only (cloud shows an explanatory message) Requires the meta-profile CRUD API from software-agent-sdk #3744. Co-authored-by: openhands --- .../meta-llm-settings-view.test.tsx | 168 +++++++ .../meta-profile-editor.test.tsx | 113 +++++ .../meta-profiles-service.api.ts | 110 ++++ .../delete-meta-profile-modal.tsx | 90 ++++ .../settings/meta-llm-profiles/index.ts | 3 + .../meta-llm-settings-view.tsx | 240 +++++++++ .../meta-llm-profiles/meta-profile-editor.tsx | 273 ++++++++++ src/constants/settings-nav.tsx | 9 +- .../mutation/use-activate-meta-profile.ts | 29 ++ src/hooks/mutation/use-delete-meta-profile.ts | 18 + src/hooks/mutation/use-save-meta-profile.ts | 26 + src/hooks/query/query-keys.ts | 4 + src/hooks/query/use-meta-profiles.ts | 23 + src/i18n/translation.json | 476 ++++++++++++++++++ src/routes.ts | 1 + src/routes/meta-llm-settings.tsx | 32 ++ 16 files changed, 1614 insertions(+), 1 deletion(-) create mode 100644 __tests__/components/settings/meta-llm-profiles/meta-llm-settings-view.test.tsx create mode 100644 __tests__/components/settings/meta-llm-profiles/meta-profile-editor.test.tsx create mode 100644 src/api/meta-profiles-service/meta-profiles-service.api.ts create mode 100644 src/components/features/settings/meta-llm-profiles/delete-meta-profile-modal.tsx create mode 100644 src/components/features/settings/meta-llm-profiles/index.ts create mode 100644 src/components/features/settings/meta-llm-profiles/meta-llm-settings-view.tsx create mode 100644 src/components/features/settings/meta-llm-profiles/meta-profile-editor.tsx create mode 100644 src/hooks/mutation/use-activate-meta-profile.ts create mode 100644 src/hooks/mutation/use-delete-meta-profile.ts create mode 100644 src/hooks/mutation/use-save-meta-profile.ts create mode 100644 src/hooks/query/use-meta-profiles.ts create mode 100644 src/routes/meta-llm-settings.tsx diff --git a/__tests__/components/settings/meta-llm-profiles/meta-llm-settings-view.test.tsx b/__tests__/components/settings/meta-llm-profiles/meta-llm-settings-view.test.tsx new file mode 100644 index 000000000..1f90f112c --- /dev/null +++ b/__tests__/components/settings/meta-llm-profiles/meta-llm-settings-view.test.tsx @@ -0,0 +1,168 @@ +import { describe, expect, it, vi, beforeEach, type Mock } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "test-utils"; +import { MetaLlmSettingsView } from "#/components/features/settings/meta-llm-profiles"; +import * as useMetaProfilesHook from "#/hooks/query/use-meta-profiles"; +import * as useLlmProfilesHook from "#/hooks/query/use-llm-profiles"; +import * as useSaveMetaProfileHook from "#/hooks/mutation/use-save-meta-profile"; +import * as useActivateMetaProfileHook from "#/hooks/mutation/use-activate-meta-profile"; +import * as useDeleteMetaProfileHook from "#/hooks/mutation/use-delete-meta-profile"; +import MetaProfilesService from "#/api/meta-profiles-service/meta-profiles-service.api"; + +vi.mock("#/hooks/query/use-meta-profiles"); +vi.mock("#/hooks/query/use-llm-profiles"); +vi.mock("#/hooks/mutation/use-save-meta-profile"); +vi.mock("#/hooks/mutation/use-activate-meta-profile"); +vi.mock("#/hooks/mutation/use-delete-meta-profile"); +vi.mock("#/api/meta-profiles-service/meta-profiles-service.api"); +vi.mock("#/utils/custom-toast-handlers"); + +const mockMetaProfiles = [ + { + name: "balanced", + classifier_model: "minimax", + default_model: "gpt", + num_classes: 2, + }, + { + name: "cheap", + classifier_model: "minimax", + default_model: "deepseek", + num_classes: 0, + }, +]; + +const mockLlmProfiles = [ + { name: "minimax", model: "m", base_url: null, api_key_set: true }, + { name: "gpt", model: "g", base_url: null, api_key_set: true }, + { name: "deepseek", model: "d", base_url: null, api_key_set: true }, +]; + +function mockMutation(mutateAsync: Mock, overrides: Partial = {}): T { + return { + mutateAsync, + mutate: vi.fn(), + isPending: false, + isError: false, + isSuccess: false, + error: null, + data: undefined, + reset: vi.fn(), + status: "idle", + isIdle: true, + ...overrides, + } as T; +} + +describe("MetaLlmSettingsView", () => { + const activateMutateAsync = vi.fn(); + const saveMutateAsync = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(useMetaProfilesHook.useMetaProfiles).mockReturnValue({ + data: { + meta_profiles: mockMetaProfiles, + active_meta_profile: "balanced", + }, + isLoading: false, + error: null, + } as unknown as ReturnType); + + vi.mocked(useLlmProfilesHook.useLlmProfiles).mockReturnValue({ + data: { profiles: mockLlmProfiles, active_profile: "minimax" }, + isLoading: false, + error: null, + } as unknown as ReturnType); + + vi.mocked(useSaveMetaProfileHook.useSaveMetaProfile).mockReturnValue( + mockMutation(saveMutateAsync), + ); + vi.mocked( + useActivateMetaProfileHook.useActivateMetaProfile, + ).mockReturnValue(mockMutation(activateMutateAsync)); + // The delete hook is consumed by the modal that is always mounted. + vi.mocked(useDeleteMetaProfileHook.useDeleteMetaProfile).mockReturnValue( + mockMutation(vi.fn()), + ); + }); + + it("renders the list of meta-profiles with an active badge", () => { + renderWithProviders(); + + expect(screen.getByTestId("meta-profile-row-balanced")).toBeInTheDocument(); + expect(screen.getByTestId("meta-profile-row-cheap")).toBeInTheDocument(); + // Only the active one shows the badge + expect(screen.getAllByTestId("meta-profile-active-badge")).toHaveLength(1); + }); + + it("shows the empty state when there are no meta-profiles", () => { + vi.mocked(useMetaProfilesHook.useMetaProfiles).mockReturnValue({ + data: { meta_profiles: [], active_meta_profile: null }, + isLoading: false, + error: null, + } as unknown as ReturnType); + + renderWithProviders(); + + expect(screen.getByTestId("meta-profile-empty")).toBeInTheDocument(); + }); + + it("hints when there are no LLM profiles to route between", () => { + vi.mocked(useLlmProfilesHook.useLlmProfiles).mockReturnValue({ + data: { profiles: [], active_profile: null }, + isLoading: false, + error: null, + } as unknown as ReturnType); + + renderWithProviders(); + + expect( + screen.getByTestId("meta-profile-no-llm-profiles"), + ).toBeInTheDocument(); + }); + + it("opens the editor when clicking Add meta-profile", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("add-meta-profile")); + + expect(screen.getByTestId("meta-profile-editor")).toBeInTheDocument(); + expect(screen.getByTestId("meta-profile-name-input")).toBeInTheDocument(); + }); + + it("activates a meta-profile when clicking Set active", async () => { + const user = userEvent.setup(); + activateMutateAsync.mockResolvedValue({ name: "cheap" }); + renderWithProviders(); + + await user.click(screen.getByTestId("activate-meta-profile-cheap")); + + await waitFor(() => + expect(activateMutateAsync).toHaveBeenCalledWith("cheap"), + ); + }); + + it("loads the config and opens the editor when clicking Edit", async () => { + const user = userEvent.setup(); + vi.mocked(MetaProfilesService.getMetaProfile).mockResolvedValue({ + name: "balanced", + config: { + classifier_model: "minimax", + default_model: "gpt", + classes: [{ description: "UI", model: "deepseek" }], + }, + }); + renderWithProviders(); + + await user.click(screen.getByTestId("edit-meta-profile-balanced")); + + await waitFor(() => + expect(screen.getByTestId("meta-profile-editor")).toBeInTheDocument(), + ); + expect(MetaProfilesService.getMetaProfile).toHaveBeenCalledWith("balanced"); + }); +}); diff --git a/__tests__/components/settings/meta-llm-profiles/meta-profile-editor.test.tsx b/__tests__/components/settings/meta-llm-profiles/meta-profile-editor.test.tsx new file mode 100644 index 000000000..3b37cb3a1 --- /dev/null +++ b/__tests__/components/settings/meta-llm-profiles/meta-profile-editor.test.tsx @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "test-utils"; +import { MetaProfileEditor } from "#/components/features/settings/meta-llm-profiles"; +import type { MetaProfile } from "#/api/meta-profiles-service/meta-profiles-service.api"; + +const AVAILABLE = ["minimax", "gpt", "deepseek"]; + +const FILLED: MetaProfile = { + classifier_model: "minimax", + default_model: "gpt", + classes: [{ description: "UI tasks", model: "deepseek" }], +}; + +describe("MetaProfileEditor", () => { + it("disables Save in create mode until required fields are set", () => { + renderWithProviders( + , + ); + + expect(screen.getByTestId("meta-profile-save")).toBeDisabled(); + }); + + it("enables Save in edit mode with a complete config and saves it", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + renderWithProviders( + , + ); + + const save = screen.getByTestId("meta-profile-save"); + expect(save).toBeEnabled(); + + await user.click(save); + + expect(onSave).toHaveBeenCalledWith("balanced", FILLED); + }); + + it("disables the name field in edit mode", () => { + renderWithProviders( + , + ); + + expect(screen.getByTestId("meta-profile-name-input")).toBeDisabled(); + }); + + it("adds and removes task class rows", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + expect( + screen.getByTestId("meta-profile-classes-empty"), + ).toBeInTheDocument(); + + await user.click(screen.getByTestId("meta-profile-add-class")); + expect( + screen.getByTestId("meta-profile-class-description-0"), + ).toBeInTheDocument(); + + await user.click(screen.getByTestId("meta-profile-remove-class-0")); + expect( + screen.queryByTestId("meta-profile-class-description-0"), + ).not.toBeInTheDocument(); + }); + + it("calls onCancel when Cancel is clicked", async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + renderWithProviders( + , + ); + + await user.click(screen.getByTestId("meta-profile-cancel")); + expect(onCancel).toHaveBeenCalled(); + }); +}); diff --git a/src/api/meta-profiles-service/meta-profiles-service.api.ts b/src/api/meta-profiles-service/meta-profiles-service.api.ts new file mode 100644 index 000000000..53fbb8ca2 --- /dev/null +++ b/src/api/meta-profiles-service/meta-profiles-service.api.ts @@ -0,0 +1,110 @@ +/** + * MetaProfilesService wraps the agent-server's ``/api/meta-profiles`` endpoints + * (added in software-agent-sdk PR #3744). A meta-profile is a model-routing + * configuration consumed by the ``classify_and_switch_llm`` tool: it names a + * ``classifier_model``, a ``default_model`` and a list of task ``classes``, + * where every model reference is the name of a saved LLM profile. + * + * The SDK's ``@openhands/typescript-client`` does not (yet) ship a dedicated + * meta-profiles client, so we drive the endpoints with the SDK's public + * ``HttpClient`` — mirroring how ``ProfilesClient`` is implemented — and create + * a client per call to pick up the current backend configuration. + */ +import { HttpClient } from "@openhands/typescript-client"; +import { getAgentServerClientOptions } from "../agent-server-client-options"; + +export interface MetaProfileClass { + description: string; + /** Name of the saved LLM profile to switch to for this class. */ + model: string; +} + +export interface MetaProfile { + /** Name of the saved LLM profile used to classify the task. */ + classifier_model: string; + /** Name of the saved LLM profile to use when no class matches. */ + default_model: string; + classes: MetaProfileClass[]; +} + +export interface MetaProfileInfo { + name: string; + classifier_model: string | null; + default_model: string | null; + num_classes: number; +} + +export interface MetaProfileListResponse { + meta_profiles: MetaProfileInfo[]; + active_meta_profile: string | null; +} + +export interface MetaProfileDetailResponse { + name: string; + config: MetaProfile; +} + +export interface MetaProfileMutationResponse { + name: string; + message: string; +} + +export interface ActivateMetaProfileResponse { + name: string; + message: string; +} + +const BASE_PATH = "/api/meta-profiles"; + +function client(): HttpClient { + const { host, apiKey } = getAgentServerClientOptions(); + return new HttpClient({ baseUrl: host, apiKey, timeout: 60000 }); +} + +class MetaProfilesService { + static async listMetaProfiles(): Promise { + const response = await client().get(BASE_PATH); + return response.data; + } + + static async getMetaProfile( + name: string, + ): Promise { + const response = await client().get( + `${BASE_PATH}/${encodeURIComponent(name)}`, + ); + return response.data; + } + + static async saveMetaProfile( + name: string, + config: MetaProfile, + ): Promise { + const response = await client().post( + `${BASE_PATH}/${encodeURIComponent(name)}`, + config, + ); + return response.data; + } + + static async deleteMetaProfile( + name: string, + ): Promise { + const response = await client().delete( + `${BASE_PATH}/${encodeURIComponent(name)}`, + ); + return response.data; + } + + static async activateMetaProfile( + name: string, + ): Promise { + const response = await client().post( + `${BASE_PATH}/${encodeURIComponent(name)}/activate`, + {}, + ); + return response.data; + } +} + +export default MetaProfilesService; diff --git a/src/components/features/settings/meta-llm-profiles/delete-meta-profile-modal.tsx b/src/components/features/settings/meta-llm-profiles/delete-meta-profile-modal.tsx new file mode 100644 index 000000000..d1d51313a --- /dev/null +++ b/src/components/features/settings/meta-llm-profiles/delete-meta-profile-modal.tsx @@ -0,0 +1,90 @@ +import { useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { ApiKeyModalBase } from "#/components/features/settings/api-key-modal-base"; +import { useDeleteMetaProfile } from "#/hooks/mutation/use-delete-meta-profile"; +import { + displayErrorToast, + displaySuccessToast, +} from "#/utils/custom-toast-handlers"; +import { I18nKey } from "#/i18n/declaration"; + +interface DeleteMetaProfileModalProps { + name: string | null; + onClose: () => void; +} + +export function DeleteMetaProfileModal({ + name, + onClose, +}: DeleteMetaProfileModalProps) { + const { t } = useTranslation("openhands"); + const deleteMetaProfile = useDeleteMetaProfile(); + const cancelButtonRef = useRef(null); + + if (!name) return null; + + const handleDelete = async () => { + try { + await deleteMetaProfile.mutateAsync(name); + displaySuccessToast(t(I18nKey.SETTINGS$META_PROFILE_DELETED, { name })); + onClose(); + } catch (error) { + const message = + error instanceof Error ? error.message : t(I18nKey.ERROR$GENERIC); + displayErrorToast(message); + } + }; + + const handleClose = () => { + if (!deleteMetaProfile.isPending) { + onClose(); + } + }; + + const footer = ( + <> + + {t(I18nKey.BUTTON$CANCEL)} + + + {deleteMetaProfile.isPending ? ( + <> + + {t(I18nKey.BUTTON$DELETE)} + + ) : ( + t(I18nKey.BUTTON$DELETE) + )} + + + ); + + return ( + +

+ {t(I18nKey.SETTINGS$META_PROFILE_DELETE_CONFIRMATION, { name })} +

+
+ ); +} diff --git a/src/components/features/settings/meta-llm-profiles/index.ts b/src/components/features/settings/meta-llm-profiles/index.ts new file mode 100644 index 000000000..d445e4973 --- /dev/null +++ b/src/components/features/settings/meta-llm-profiles/index.ts @@ -0,0 +1,3 @@ +export { MetaLlmSettingsView } from "./meta-llm-settings-view"; +export { MetaProfileEditor } from "./meta-profile-editor"; +export { DeleteMetaProfileModal } from "./delete-meta-profile-modal"; diff --git a/src/components/features/settings/meta-llm-profiles/meta-llm-settings-view.tsx b/src/components/features/settings/meta-llm-profiles/meta-llm-settings-view.tsx new file mode 100644 index 000000000..104c8e01e --- /dev/null +++ b/src/components/features/settings/meta-llm-profiles/meta-llm-settings-view.tsx @@ -0,0 +1,240 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { useMetaProfiles } from "#/hooks/query/use-meta-profiles"; +import { useLlmProfiles } from "#/hooks/query/use-llm-profiles"; +import { useSaveMetaProfile } from "#/hooks/mutation/use-save-meta-profile"; +import { useActivateMetaProfile } from "#/hooks/mutation/use-activate-meta-profile"; +import MetaProfilesService, { + type MetaProfile, + type MetaProfileInfo, +} from "#/api/meta-profiles-service/meta-profiles-service.api"; +import { + displayErrorToast, + displaySuccessToast, +} from "#/utils/custom-toast-handlers"; +import { I18nKey } from "#/i18n/declaration"; +import { cn } from "#/utils/utils"; +import { MetaProfileEditor } from "./meta-profile-editor"; +import { DeleteMetaProfileModal } from "./delete-meta-profile-modal"; + +type ViewMode = "list" | "create" | "edit"; + +interface EditingMetaProfile { + name: string; + config: MetaProfile; +} + +function MetaProfileSummary({ info }: { info: MetaProfileInfo }) { + const { t } = useTranslation("openhands"); + const route = [info.classifier_model, info.default_model] + .filter(Boolean) + .join(" → "); + return ( + + {route} + {route ? " · " : ""} + {`${info.num_classes} ${t(I18nKey.SETTINGS$META_PROFILE_CLASSES)}`} + + ); +} + +export function MetaLlmSettingsView() { + const { t } = useTranslation("openhands"); + const { data, isLoading, error } = useMetaProfiles(); + const { data: llmProfilesData } = useLlmProfiles(); + const saveMetaProfile = useSaveMetaProfile(); + const activateMetaProfile = useActivateMetaProfile(); + + const [view, setView] = useState("list"); + const [editing, setEditing] = useState(null); + const [nameToDelete, setNameToDelete] = useState(null); + + const metaProfiles = data?.meta_profiles ?? []; + const active = data?.active_meta_profile ?? null; + const availableProfiles = (llmProfilesData?.profiles ?? []).map( + (p) => p.name, + ); + + const handleActivate = async (name: string) => { + try { + await activateMetaProfile.mutateAsync(name); + displaySuccessToast(t(I18nKey.SETTINGS$META_PROFILE_ACTIVATED, { name })); + } catch (activateError) { + const message = + activateError instanceof Error + ? activateError.message + : t(I18nKey.ERROR$GENERIC); + displayErrorToast(message); + } + }; + + const handleEdit = async (name: string) => { + try { + const detail = await MetaProfilesService.getMetaProfile(name); + setEditing({ name: detail.name, config: detail.config }); + setView("edit"); + } catch (loadError) { + const message = + loadError instanceof Error + ? loadError.message + : t(I18nKey.ERROR$GENERIC); + displayErrorToast(message); + } + }; + + const handleSave = async (name: string, config: MetaProfile) => { + try { + await saveMetaProfile.mutateAsync({ name, config }); + displaySuccessToast(t(I18nKey.SETTINGS$META_PROFILE_SAVED, { name })); + setView("list"); + setEditing(null); + } catch (saveError) { + const message = + saveError instanceof Error + ? saveError.message + : t(I18nKey.ERROR$GENERIC); + displayErrorToast(message); + } + }; + + const handleCancel = () => { + setView("list"); + setEditing(null); + }; + + if (view === "create" || view === "edit") { + return ( + + ); + } + + return ( + <> +
+ {availableProfiles.length === 0 ? ( +

+ {t(I18nKey.SETTINGS$META_PROFILE_NO_LLM_PROFILES)} +

+ ) : null} + +
+

+ {t(I18nKey.SETTINGS$META_PROFILES_AVAILABLE)} +

+ { + setEditing(null); + setView("create"); + }} + > + {t(I18nKey.SETTINGS$ADD_META_PROFILE)} + +
+ + {isLoading ? ( +
+ +
+ ) : null} + + {error ? ( +

{t(I18nKey.ERROR$GENERIC)}

+ ) : null} + + {!isLoading && !error && metaProfiles.length === 0 ? ( +

+ {t(I18nKey.SETTINGS$META_PROFILE_NO_PROFILES)} +

+ ) : null} + + {metaProfiles.length > 0 ? ( +
    + {metaProfiles.map((info) => { + const isActive = info.name === active; + return ( +
  • +
    +
    + + {info.name} + + {isActive ? ( + + {t(I18nKey.SETTINGS$META_PROFILE_ACTIVE)} + + ) : null} +
    + +
    + +
    + handleActivate(info.name)} + isDisabled={isActive || activateMetaProfile.isPending} + > + {t(I18nKey.SETTINGS$META_PROFILE_ACTIVATE)} + + handleEdit(info.name)} + > + {t(I18nKey.BUTTON$EDIT)} + + setNameToDelete(info.name)} + > + {t(I18nKey.BUTTON$DELETE)} + +
    +
  • + ); + })} +
+ ) : null} +
+ + setNameToDelete(null)} + /> + + ); +} diff --git a/src/components/features/settings/meta-llm-profiles/meta-profile-editor.tsx b/src/components/features/settings/meta-llm-profiles/meta-profile-editor.tsx new file mode 100644 index 000000000..52f7296d6 --- /dev/null +++ b/src/components/features/settings/meta-llm-profiles/meta-profile-editor.tsx @@ -0,0 +1,273 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Trash2 } from "lucide-react"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { SettingsInput } from "#/components/features/settings/settings-input"; +import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input"; +import { ProfileNameInput } from "#/components/features/settings/llm-profiles/profile-name-input"; +import { Typography } from "#/ui/typography"; +import { isProfileNameValid } from "#/utils/derive-profile-name"; +import { I18nKey } from "#/i18n/declaration"; +import type { + MetaProfile, + MetaProfileClass, +} from "#/api/meta-profiles-service/meta-profiles-service.api"; + +interface MetaProfileEditorProps { + mode: "create" | "edit"; + initialName?: string; + initialConfig?: MetaProfile; + /** Names of saved LLM profiles, offered as dropdown options. */ + availableProfiles: string[]; + isSaving: boolean; + onSave: (name: string, config: MetaProfile) => void; + onCancel: () => void; +} + +const EMPTY_CONFIG: MetaProfile = { + classifier_model: "", + default_model: "", + classes: [], +}; + +export function MetaProfileEditor({ + mode, + initialName = "", + initialConfig, + availableProfiles, + isSaving, + onSave, + onCancel, +}: MetaProfileEditorProps) { + const { t } = useTranslation("openhands"); + const [name, setName] = useState(initialName); + const [config, setConfig] = useState( + initialConfig ?? EMPTY_CONFIG, + ); + + const profileItems = useMemo( + () => availableProfiles.map((p) => ({ key: p, label: p })), + [availableProfiles], + ); + + const isEdit = mode === "edit"; + const nameValid = isProfileNameValid(name, { isRequired: true }); + const canSave = + nameValid && + config.classifier_model.trim().length > 0 && + config.default_model.trim().length > 0 && + config.classes.every( + (c) => c.description.trim().length > 0 && c.model.trim().length > 0, + ); + + const updateClass = (index: number, patch: Partial) => { + setConfig((prev) => ({ + ...prev, + classes: prev.classes.map((c, i) => + i === index ? { ...c, ...patch } : c, + ), + })); + }; + + const addClass = () => { + setConfig((prev) => ({ + ...prev, + classes: [...prev.classes, { description: "", model: "" }], + })); + }; + + const removeClass = (index: number) => { + setConfig((prev) => ({ + ...prev, + classes: prev.classes.filter((_, i) => i !== index), + })); + }; + + const handleSave = () => { + if (!canSave || isSaving) return; + onSave(name.trim(), { + classifier_model: config.classifier_model.trim(), + default_model: config.default_model.trim(), + classes: config.classes.map((c) => ({ + description: c.description.trim(), + model: c.model.trim(), + })), + }); + }; + + return ( +
+ + {t( + isEdit + ? I18nKey.SETTINGS$EDIT_META_PROFILE + : I18nKey.SETTINGS$NEW_META_PROFILE, + )} + + + + +
+ + setConfig((prev) => ({ ...prev, classifier_model: value })) + } + onSelectionChange={(key) => + setConfig((prev) => ({ + ...prev, + classifier_model: key ? String(key) : "", + })) + } + /> +

+ {t(I18nKey.SETTINGS$META_PROFILE_CLASSIFIER_HELP)} +

+
+ +
+ + setConfig((prev) => ({ ...prev, default_model: value })) + } + onSelectionChange={(key) => + setConfig((prev) => ({ + ...prev, + default_model: key ? String(key) : "", + })) + } + /> +

+ {t(I18nKey.SETTINGS$META_PROFILE_DEFAULT_HELP)} +

+
+ +
+
+
+

+ {t(I18nKey.SETTINGS$META_PROFILE_CLASSES)} +

+

+ {t(I18nKey.SETTINGS$META_PROFILE_CLASSES_HELP)} +

+
+ + {t(I18nKey.SETTINGS$META_PROFILE_ADD_CLASS)} + +
+ + {config.classes.length === 0 ? ( +

+ {t(I18nKey.SETTINGS$META_PROFILE_CLASSES_EMPTY)} +

+ ) : ( +
    + {config.classes.map((cls, index) => ( +
  • +
    + + updateClass(index, { description: value }) + } + isDisabled={isSaving} + /> +
    +
    + + updateClass(index, { model: value }) + } + onSelectionChange={(key) => + updateClass(index, { model: key ? String(key) : "" }) + } + /> +
    + removeClass(index)} + isDisabled={isSaving} + className="shrink-0" + > + + + {t(I18nKey.SETTINGS$META_PROFILE_REMOVE_CLASS)} + + +
  • + ))} +
+ )} +
+ +
+ + {t(I18nKey.BUTTON$SAVE)} + + + {t(I18nKey.BUTTON$CANCEL)} + +
+
+ ); +} diff --git a/src/constants/settings-nav.tsx b/src/constants/settings-nav.tsx index 84ae8a1b6..ef54c528b 100644 --- a/src/constants/settings-nav.tsx +++ b/src/constants/settings-nav.tsx @@ -1,4 +1,4 @@ -import { AppWindow, Shield } from "lucide-react"; +import { AppWindow, Route as RouteIcon, Shield } from "lucide-react"; import KeyIcon from "#/icons/key.svg?react"; import MemoryIcon from "#/icons/memory_icon.svg?react"; import CircuitIcon from "#/icons/u-circuit.svg?react"; @@ -33,6 +33,13 @@ export const OSS_NAV_ITEMS: SettingsNavItem[] = [ subtitle: "SETTINGS$PAGE_LLM_SUBLINE", disabledByAcp: true, }, + { + icon: , + to: "/settings/meta-llm", + text: "SETTINGS$NAV_META_LLM", + subtitle: "SETTINGS$PAGE_META_LLM_SUBLINE", + disabledByAcp: true, + }, { icon: , to: "/settings/condenser", diff --git a/src/hooks/mutation/use-activate-meta-profile.ts b/src/hooks/mutation/use-activate-meta-profile.ts new file mode 100644 index 000000000..dd8318723 --- /dev/null +++ b/src/hooks/mutation/use-activate-meta-profile.ts @@ -0,0 +1,29 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import MetaProfilesService from "#/api/meta-profiles-service/meta-profiles-service.api"; +import SettingsService from "#/api/settings-service/settings-service.api"; +import { + META_PROFILES_QUERY_KEYS, + SETTINGS_QUERY_KEYS, +} from "#/hooks/query/query-keys"; + +export function useActivateMetaProfile() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (name: string) => MetaProfilesService.activateMetaProfile(name), + onSuccess: async () => { + // Activating a meta-profile sets ``active_meta_profile`` in settings, + // which controls whether the classify_and_switch_llm tool is attached to + // new conversations — so refresh the settings caches too. + SettingsService.invalidateCache(); + await queryClient.invalidateQueries({ + queryKey: META_PROFILES_QUERY_KEYS.all, + }); + await queryClient.invalidateQueries({ + queryKey: SETTINGS_QUERY_KEYS.personal(), + }); + }, + // Consumers handle errors with try-catch and manual toasts; disable global toast + meta: { disableToast: true }, + }); +} diff --git a/src/hooks/mutation/use-delete-meta-profile.ts b/src/hooks/mutation/use-delete-meta-profile.ts new file mode 100644 index 000000000..36844ae6a --- /dev/null +++ b/src/hooks/mutation/use-delete-meta-profile.ts @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import MetaProfilesService from "#/api/meta-profiles-service/meta-profiles-service.api"; +import { META_PROFILES_QUERY_KEYS } from "#/hooks/query/query-keys"; + +export function useDeleteMetaProfile() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (name: string) => MetaProfilesService.deleteMetaProfile(name), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: META_PROFILES_QUERY_KEYS.all, + }); + }, + // Consumers handle errors with try-catch and manual toasts; disable global toast + meta: { disableToast: true }, + }); +} diff --git a/src/hooks/mutation/use-save-meta-profile.ts b/src/hooks/mutation/use-save-meta-profile.ts new file mode 100644 index 000000000..d4fdfa284 --- /dev/null +++ b/src/hooks/mutation/use-save-meta-profile.ts @@ -0,0 +1,26 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import MetaProfilesService, { + type MetaProfile, +} from "#/api/meta-profiles-service/meta-profiles-service.api"; +import { META_PROFILES_QUERY_KEYS } from "#/hooks/query/query-keys"; + +interface SaveMetaProfileVariables { + name: string; + config: MetaProfile; +} + +export function useSaveMetaProfile() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ name, config }: SaveMetaProfileVariables) => + MetaProfilesService.saveMetaProfile(name, config), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: META_PROFILES_QUERY_KEYS.all, + }); + }, + // Consumers handle errors with try-catch and manual toasts; disable global toast + meta: { disableToast: true }, + }); +} diff --git a/src/hooks/query/query-keys.ts b/src/hooks/query/query-keys.ts index 2cf1a5ad0..5141f1925 100644 --- a/src/hooks/query/query-keys.ts +++ b/src/hooks/query/query-keys.ts @@ -20,6 +20,10 @@ export const LLM_PROFILES_QUERY_KEYS = { all: ["llm-profiles"] as const, } as const; +export const META_PROFILES_QUERY_KEYS = { + all: ["meta-profiles"] as const, +} as const; + export const LOCAL_WORKSPACES_QUERY_KEYS = { all: ["local-workspaces"] as const, } as const; diff --git a/src/hooks/query/use-meta-profiles.ts b/src/hooks/query/use-meta-profiles.ts new file mode 100644 index 000000000..2513a7c23 --- /dev/null +++ b/src/hooks/query/use-meta-profiles.ts @@ -0,0 +1,23 @@ +import { useQuery } from "@tanstack/react-query"; +import MetaProfilesService from "#/api/meta-profiles-service/meta-profiles-service.api"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { CONFIG_CACHE_OPTIONS, META_PROFILES_QUERY_KEYS } from "./query-keys"; + +export { META_PROFILES_QUERY_KEYS }; + +interface UseMetaProfilesOptions { + enabled?: boolean; +} + +export function useMetaProfiles(options: UseMetaProfilesOptions = {}) { + const { backend, orgId } = useActiveBackend(); + + return useQuery({ + // Include backend identity to prevent cache pollution when switching backends + queryKey: [...META_PROFILES_QUERY_KEYS.all, backend.id, orgId], + queryFn: MetaProfilesService.listMetaProfiles, + ...CONFIG_CACHE_OPTIONS, + enabled: options.enabled ?? true, + meta: { disableToast: true }, + }); +} diff --git a/src/i18n/translation.json b/src/i18n/translation.json index b6d0099b7..c1fa29cc2 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -5150,6 +5150,482 @@ "uk": "LLM", "ca": "LLM" }, + "SETTINGS$NAV_META_LLM": { + "en": "Model Router", + "ja": "Model Router", + "zh-CN": "Model Router", + "zh-TW": "Model Router", + "ko-KR": "Model Router", + "no": "Model Router", + "it": "Model Router", + "pt": "Model Router", + "es": "Model Router", + "ar": "Model Router", + "fr": "Model Router", + "tr": "Model Router", + "de": "Model Router", + "uk": "Model Router", + "ca": "Model Router" + }, + "SETTINGS$PAGE_META_LLM_SUBLINE": { + "en": "Meta-profiles that route each task to the right LLM profile.", + "ja": "Meta-profiles that route each task to the right LLM profile.", + "zh-CN": "Meta-profiles that route each task to the right LLM profile.", + "zh-TW": "Meta-profiles that route each task to the right LLM profile.", + "ko-KR": "Meta-profiles that route each task to the right LLM profile.", + "no": "Meta-profiles that route each task to the right LLM profile.", + "it": "Meta-profiles that route each task to the right LLM profile.", + "pt": "Meta-profiles that route each task to the right LLM profile.", + "es": "Meta-profiles that route each task to the right LLM profile.", + "ar": "Meta-profiles that route each task to the right LLM profile.", + "fr": "Meta-profiles that route each task to the right LLM profile.", + "tr": "Meta-profiles that route each task to the right LLM profile.", + "de": "Meta-profiles that route each task to the right LLM profile.", + "uk": "Meta-profiles that route each task to the right LLM profile.", + "ca": "Meta-profiles that route each task to the right LLM profile." + }, + "SETTINGS$META_PROFILES_AVAILABLE": { + "en": "Available meta-profiles", + "ja": "Available meta-profiles", + "zh-CN": "Available meta-profiles", + "zh-TW": "Available meta-profiles", + "ko-KR": "Available meta-profiles", + "no": "Available meta-profiles", + "it": "Available meta-profiles", + "pt": "Available meta-profiles", + "es": "Available meta-profiles", + "ar": "Available meta-profiles", + "fr": "Available meta-profiles", + "tr": "Available meta-profiles", + "de": "Available meta-profiles", + "uk": "Available meta-profiles", + "ca": "Available meta-profiles" + }, + "SETTINGS$ADD_META_PROFILE": { + "en": "Add meta-profile", + "ja": "Add meta-profile", + "zh-CN": "Add meta-profile", + "zh-TW": "Add meta-profile", + "ko-KR": "Add meta-profile", + "no": "Add meta-profile", + "it": "Add meta-profile", + "pt": "Add meta-profile", + "es": "Add meta-profile", + "ar": "Add meta-profile", + "fr": "Add meta-profile", + "tr": "Add meta-profile", + "de": "Add meta-profile", + "uk": "Add meta-profile", + "ca": "Add meta-profile" + }, + "SETTINGS$NEW_META_PROFILE": { + "en": "New meta-profile", + "ja": "New meta-profile", + "zh-CN": "New meta-profile", + "zh-TW": "New meta-profile", + "ko-KR": "New meta-profile", + "no": "New meta-profile", + "it": "New meta-profile", + "pt": "New meta-profile", + "es": "New meta-profile", + "ar": "New meta-profile", + "fr": "New meta-profile", + "tr": "New meta-profile", + "de": "New meta-profile", + "uk": "New meta-profile", + "ca": "New meta-profile" + }, + "SETTINGS$EDIT_META_PROFILE": { + "en": "Edit meta-profile", + "ja": "Edit meta-profile", + "zh-CN": "Edit meta-profile", + "zh-TW": "Edit meta-profile", + "ko-KR": "Edit meta-profile", + "no": "Edit meta-profile", + "it": "Edit meta-profile", + "pt": "Edit meta-profile", + "es": "Edit meta-profile", + "ar": "Edit meta-profile", + "fr": "Edit meta-profile", + "tr": "Edit meta-profile", + "de": "Edit meta-profile", + "uk": "Edit meta-profile", + "ca": "Edit meta-profile" + }, + "SETTINGS$META_PROFILE_CLASSIFIER": { + "en": "Classifier model", + "ja": "Classifier model", + "zh-CN": "Classifier model", + "zh-TW": "Classifier model", + "ko-KR": "Classifier model", + "no": "Classifier model", + "it": "Classifier model", + "pt": "Classifier model", + "es": "Classifier model", + "ar": "Classifier model", + "fr": "Classifier model", + "tr": "Classifier model", + "de": "Classifier model", + "uk": "Classifier model", + "ca": "Classifier model" + }, + "SETTINGS$META_PROFILE_CLASSIFIER_HELP": { + "en": "LLM profile used to categorize each task before routing.", + "ja": "LLM profile used to categorize each task before routing.", + "zh-CN": "LLM profile used to categorize each task before routing.", + "zh-TW": "LLM profile used to categorize each task before routing.", + "ko-KR": "LLM profile used to categorize each task before routing.", + "no": "LLM profile used to categorize each task before routing.", + "it": "LLM profile used to categorize each task before routing.", + "pt": "LLM profile used to categorize each task before routing.", + "es": "LLM profile used to categorize each task before routing.", + "ar": "LLM profile used to categorize each task before routing.", + "fr": "LLM profile used to categorize each task before routing.", + "tr": "LLM profile used to categorize each task before routing.", + "de": "LLM profile used to categorize each task before routing.", + "uk": "LLM profile used to categorize each task before routing.", + "ca": "LLM profile used to categorize each task before routing." + }, + "SETTINGS$META_PROFILE_DEFAULT": { + "en": "Default model", + "ja": "Default model", + "zh-CN": "Default model", + "zh-TW": "Default model", + "ko-KR": "Default model", + "no": "Default model", + "it": "Default model", + "pt": "Default model", + "es": "Default model", + "ar": "Default model", + "fr": "Default model", + "tr": "Default model", + "de": "Default model", + "uk": "Default model", + "ca": "Default model" + }, + "SETTINGS$META_PROFILE_DEFAULT_HELP": { + "en": "LLM profile used when no task class matches.", + "ja": "LLM profile used when no task class matches.", + "zh-CN": "LLM profile used when no task class matches.", + "zh-TW": "LLM profile used when no task class matches.", + "ko-KR": "LLM profile used when no task class matches.", + "no": "LLM profile used when no task class matches.", + "it": "LLM profile used when no task class matches.", + "pt": "LLM profile used when no task class matches.", + "es": "LLM profile used when no task class matches.", + "ar": "LLM profile used when no task class matches.", + "fr": "LLM profile used when no task class matches.", + "tr": "LLM profile used when no task class matches.", + "de": "LLM profile used when no task class matches.", + "uk": "LLM profile used when no task class matches.", + "ca": "LLM profile used when no task class matches." + }, + "SETTINGS$META_PROFILE_CLASSES": { + "en": "Task classes", + "ja": "Task classes", + "zh-CN": "Task classes", + "zh-TW": "Task classes", + "ko-KR": "Task classes", + "no": "Task classes", + "it": "Task classes", + "pt": "Task classes", + "es": "Task classes", + "ar": "Task classes", + "fr": "Task classes", + "tr": "Task classes", + "de": "Task classes", + "uk": "Task classes", + "ca": "Task classes" + }, + "SETTINGS$META_PROFILE_CLASSES_HELP": { + "en": "Each class maps a kind of task to the LLM profile that should handle it.", + "ja": "Each class maps a kind of task to the LLM profile that should handle it.", + "zh-CN": "Each class maps a kind of task to the LLM profile that should handle it.", + "zh-TW": "Each class maps a kind of task to the LLM profile that should handle it.", + "ko-KR": "Each class maps a kind of task to the LLM profile that should handle it.", + "no": "Each class maps a kind of task to the LLM profile that should handle it.", + "it": "Each class maps a kind of task to the LLM profile that should handle it.", + "pt": "Each class maps a kind of task to the LLM profile that should handle it.", + "es": "Each class maps a kind of task to the LLM profile that should handle it.", + "ar": "Each class maps a kind of task to the LLM profile that should handle it.", + "fr": "Each class maps a kind of task to the LLM profile that should handle it.", + "tr": "Each class maps a kind of task to the LLM profile that should handle it.", + "de": "Each class maps a kind of task to the LLM profile that should handle it.", + "uk": "Each class maps a kind of task to the LLM profile that should handle it.", + "ca": "Each class maps a kind of task to the LLM profile that should handle it." + }, + "SETTINGS$META_PROFILE_ADD_CLASS": { + "en": "Add class", + "ja": "Add class", + "zh-CN": "Add class", + "zh-TW": "Add class", + "ko-KR": "Add class", + "no": "Add class", + "it": "Add class", + "pt": "Add class", + "es": "Add class", + "ar": "Add class", + "fr": "Add class", + "tr": "Add class", + "de": "Add class", + "uk": "Add class", + "ca": "Add class" + }, + "SETTINGS$META_PROFILE_REMOVE_CLASS": { + "en": "Remove class", + "ja": "Remove class", + "zh-CN": "Remove class", + "zh-TW": "Remove class", + "ko-KR": "Remove class", + "no": "Remove class", + "it": "Remove class", + "pt": "Remove class", + "es": "Remove class", + "ar": "Remove class", + "fr": "Remove class", + "tr": "Remove class", + "de": "Remove class", + "uk": "Remove class", + "ca": "Remove class" + }, + "SETTINGS$META_PROFILE_CLASS_DESCRIPTION": { + "en": "Task description", + "ja": "Task description", + "zh-CN": "Task description", + "zh-TW": "Task description", + "ko-KR": "Task description", + "no": "Task description", + "it": "Task description", + "pt": "Task description", + "es": "Task description", + "ar": "Task description", + "fr": "Task description", + "tr": "Task description", + "de": "Task description", + "uk": "Task description", + "ca": "Task description" + }, + "SETTINGS$META_PROFILE_CLASS_DESCRIPTION_PLACEHOLDER": { + "en": "e.g. task is UI oriented or requires looking at images", + "ja": "e.g. task is UI oriented or requires looking at images", + "zh-CN": "e.g. task is UI oriented or requires looking at images", + "zh-TW": "e.g. task is UI oriented or requires looking at images", + "ko-KR": "e.g. task is UI oriented or requires looking at images", + "no": "e.g. task is UI oriented or requires looking at images", + "it": "e.g. task is UI oriented or requires looking at images", + "pt": "e.g. task is UI oriented or requires looking at images", + "es": "e.g. task is UI oriented or requires looking at images", + "ar": "e.g. task is UI oriented or requires looking at images", + "fr": "e.g. task is UI oriented or requires looking at images", + "tr": "e.g. task is UI oriented or requires looking at images", + "de": "e.g. task is UI oriented or requires looking at images", + "uk": "e.g. task is UI oriented or requires looking at images", + "ca": "e.g. task is UI oriented or requires looking at images" + }, + "SETTINGS$META_PROFILE_CLASS_MODEL": { + "en": "Model", + "ja": "Model", + "zh-CN": "Model", + "zh-TW": "Model", + "ko-KR": "Model", + "no": "Model", + "it": "Model", + "pt": "Model", + "es": "Model", + "ar": "Model", + "fr": "Model", + "tr": "Model", + "de": "Model", + "uk": "Model", + "ca": "Model" + }, + "SETTINGS$META_PROFILE_CLASSES_EMPTY": { + "en": "No classes yet — tasks will always use the default model.", + "ja": "No classes yet — tasks will always use the default model.", + "zh-CN": "No classes yet — tasks will always use the default model.", + "zh-TW": "No classes yet — tasks will always use the default model.", + "ko-KR": "No classes yet — tasks will always use the default model.", + "no": "No classes yet — tasks will always use the default model.", + "it": "No classes yet — tasks will always use the default model.", + "pt": "No classes yet — tasks will always use the default model.", + "es": "No classes yet — tasks will always use the default model.", + "ar": "No classes yet — tasks will always use the default model.", + "fr": "No classes yet — tasks will always use the default model.", + "tr": "No classes yet — tasks will always use the default model.", + "de": "No classes yet — tasks will always use the default model.", + "uk": "No classes yet — tasks will always use the default model.", + "ca": "No classes yet — tasks will always use the default model." + }, + "SETTINGS$META_PROFILE_NO_PROFILES": { + "en": "No meta-profiles yet. Add one to route tasks across your LLM profiles.", + "ja": "No meta-profiles yet. Add one to route tasks across your LLM profiles.", + "zh-CN": "No meta-profiles yet. Add one to route tasks across your LLM profiles.", + "zh-TW": "No meta-profiles yet. Add one to route tasks across your LLM profiles.", + "ko-KR": "No meta-profiles yet. Add one to route tasks across your LLM profiles.", + "no": "No meta-profiles yet. Add one to route tasks across your LLM profiles.", + "it": "No meta-profiles yet. Add one to route tasks across your LLM profiles.", + "pt": "No meta-profiles yet. Add one to route tasks across your LLM profiles.", + "es": "No meta-profiles yet. Add one to route tasks across your LLM profiles.", + "ar": "No meta-profiles yet. Add one to route tasks across your LLM profiles.", + "fr": "No meta-profiles yet. Add one to route tasks across your LLM profiles.", + "tr": "No meta-profiles yet. Add one to route tasks across your LLM profiles.", + "de": "No meta-profiles yet. Add one to route tasks across your LLM profiles.", + "uk": "No meta-profiles yet. Add one to route tasks across your LLM profiles.", + "ca": "No meta-profiles yet. Add one to route tasks across your LLM profiles." + }, + "SETTINGS$META_PROFILE_ACTIVE": { + "en": "Active", + "ja": "Active", + "zh-CN": "Active", + "zh-TW": "Active", + "ko-KR": "Active", + "no": "Active", + "it": "Active", + "pt": "Active", + "es": "Active", + "ar": "Active", + "fr": "Active", + "tr": "Active", + "de": "Active", + "uk": "Active", + "ca": "Active" + }, + "SETTINGS$META_PROFILE_ACTIVATE": { + "en": "Set active", + "ja": "Set active", + "zh-CN": "Set active", + "zh-TW": "Set active", + "ko-KR": "Set active", + "no": "Set active", + "it": "Set active", + "pt": "Set active", + "es": "Set active", + "ar": "Set active", + "fr": "Set active", + "tr": "Set active", + "de": "Set active", + "uk": "Set active", + "ca": "Set active" + }, + "SETTINGS$META_PROFILE_SAVED": { + "en": "Meta-profile \"{{name}}\" saved", + "ja": "Meta-profile \"{{name}}\" saved", + "zh-CN": "Meta-profile \"{{name}}\" saved", + "zh-TW": "Meta-profile \"{{name}}\" saved", + "ko-KR": "Meta-profile \"{{name}}\" saved", + "no": "Meta-profile \"{{name}}\" saved", + "it": "Meta-profile \"{{name}}\" saved", + "pt": "Meta-profile \"{{name}}\" saved", + "es": "Meta-profile \"{{name}}\" saved", + "ar": "Meta-profile \"{{name}}\" saved", + "fr": "Meta-profile \"{{name}}\" saved", + "tr": "Meta-profile \"{{name}}\" saved", + "de": "Meta-profile \"{{name}}\" saved", + "uk": "Meta-profile \"{{name}}\" saved", + "ca": "Meta-profile \"{{name}}\" saved" + }, + "SETTINGS$META_PROFILE_ACTIVATED": { + "en": "Meta-profile \"{{name}}\" activated", + "ja": "Meta-profile \"{{name}}\" activated", + "zh-CN": "Meta-profile \"{{name}}\" activated", + "zh-TW": "Meta-profile \"{{name}}\" activated", + "ko-KR": "Meta-profile \"{{name}}\" activated", + "no": "Meta-profile \"{{name}}\" activated", + "it": "Meta-profile \"{{name}}\" activated", + "pt": "Meta-profile \"{{name}}\" activated", + "es": "Meta-profile \"{{name}}\" activated", + "ar": "Meta-profile \"{{name}}\" activated", + "fr": "Meta-profile \"{{name}}\" activated", + "tr": "Meta-profile \"{{name}}\" activated", + "de": "Meta-profile \"{{name}}\" activated", + "uk": "Meta-profile \"{{name}}\" activated", + "ca": "Meta-profile \"{{name}}\" activated" + }, + "SETTINGS$META_PROFILE_DELETED": { + "en": "Meta-profile \"{{name}}\" deleted", + "ja": "Meta-profile \"{{name}}\" deleted", + "zh-CN": "Meta-profile \"{{name}}\" deleted", + "zh-TW": "Meta-profile \"{{name}}\" deleted", + "ko-KR": "Meta-profile \"{{name}}\" deleted", + "no": "Meta-profile \"{{name}}\" deleted", + "it": "Meta-profile \"{{name}}\" deleted", + "pt": "Meta-profile \"{{name}}\" deleted", + "es": "Meta-profile \"{{name}}\" deleted", + "ar": "Meta-profile \"{{name}}\" deleted", + "fr": "Meta-profile \"{{name}}\" deleted", + "tr": "Meta-profile \"{{name}}\" deleted", + "de": "Meta-profile \"{{name}}\" deleted", + "uk": "Meta-profile \"{{name}}\" deleted", + "ca": "Meta-profile \"{{name}}\" deleted" + }, + "SETTINGS$META_PROFILE_DELETE_TITLE": { + "en": "Delete meta-profile", + "ja": "Delete meta-profile", + "zh-CN": "Delete meta-profile", + "zh-TW": "Delete meta-profile", + "ko-KR": "Delete meta-profile", + "no": "Delete meta-profile", + "it": "Delete meta-profile", + "pt": "Delete meta-profile", + "es": "Delete meta-profile", + "ar": "Delete meta-profile", + "fr": "Delete meta-profile", + "tr": "Delete meta-profile", + "de": "Delete meta-profile", + "uk": "Delete meta-profile", + "ca": "Delete meta-profile" + }, + "SETTINGS$META_PROFILE_DELETE_CONFIRMATION": { + "en": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.", + "ja": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.", + "zh-CN": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.", + "zh-TW": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.", + "ko-KR": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.", + "no": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.", + "it": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.", + "pt": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.", + "es": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.", + "ar": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.", + "fr": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.", + "tr": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.", + "de": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.", + "uk": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.", + "ca": "Are you sure you want to delete \"{{name}}\"? This cannot be undone." + }, + "SETTINGS$META_PROFILE_NO_LLM_PROFILES": { + "en": "Create LLM profiles first — meta-profiles route between your saved LLM profiles.", + "ja": "Create LLM profiles first — meta-profiles route between your saved LLM profiles.", + "zh-CN": "Create LLM profiles first — meta-profiles route between your saved LLM profiles.", + "zh-TW": "Create LLM profiles first — meta-profiles route between your saved LLM profiles.", + "ko-KR": "Create LLM profiles first — meta-profiles route between your saved LLM profiles.", + "no": "Create LLM profiles first — meta-profiles route between your saved LLM profiles.", + "it": "Create LLM profiles first — meta-profiles route between your saved LLM profiles.", + "pt": "Create LLM profiles first — meta-profiles route between your saved LLM profiles.", + "es": "Create LLM profiles first — meta-profiles route between your saved LLM profiles.", + "ar": "Create LLM profiles first — meta-profiles route between your saved LLM profiles.", + "fr": "Create LLM profiles first — meta-profiles route between your saved LLM profiles.", + "tr": "Create LLM profiles first — meta-profiles route between your saved LLM profiles.", + "de": "Create LLM profiles first — meta-profiles route between your saved LLM profiles.", + "uk": "Create LLM profiles first — meta-profiles route between your saved LLM profiles.", + "ca": "Create LLM profiles first — meta-profiles route between your saved LLM profiles." + }, + "SETTINGS$META_PROFILE_CLOUD_UNSUPPORTED": { + "en": "Meta-profiles are only available for local backends.", + "ja": "Meta-profiles are only available for local backends.", + "zh-CN": "Meta-profiles are only available for local backends.", + "zh-TW": "Meta-profiles are only available for local backends.", + "ko-KR": "Meta-profiles are only available for local backends.", + "no": "Meta-profiles are only available for local backends.", + "it": "Meta-profiles are only available for local backends.", + "pt": "Meta-profiles are only available for local backends.", + "es": "Meta-profiles are only available for local backends.", + "ar": "Meta-profiles are only available for local backends.", + "fr": "Meta-profiles are only available for local backends.", + "tr": "Meta-profiles are only available for local backends.", + "de": "Meta-profiles are only available for local backends.", + "uk": "Meta-profiles are only available for local backends.", + "ca": "Meta-profiles are only available for local backends." + }, "SETTINGS$AGENT_PAGE_DESCRIPTION": { "en": "Choose between the built-in OpenHands agent and an external ACP (Agent Client Protocol) subprocess such as Claude Code, Codex, or Gemini CLI.", "ja": "Choose between the built-in OpenHands agent and an external ACP (Agent Client Protocol) subprocess such as Claude Code, Codex, or Gemini CLI.", diff --git a/src/routes.ts b/src/routes.ts index 7a46b252e..95e13bf89 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -22,6 +22,7 @@ export default [ route("settings", "routes/settings.tsx", [ index("routes/settings-index.tsx"), route("llm", "routes/llm-settings.tsx"), + route("meta-llm", "routes/meta-llm-settings.tsx"), route("agent", "routes/agent-settings.tsx"), route("condenser", "routes/condenser-settings.tsx"), route("verification", "routes/verification-settings.tsx"), diff --git a/src/routes/meta-llm-settings.tsx b/src/routes/meta-llm-settings.tsx new file mode 100644 index 000000000..5b5c8cbf5 --- /dev/null +++ b/src/routes/meta-llm-settings.tsx @@ -0,0 +1,32 @@ +import { useTranslation } from "react-i18next"; +import { MetaLlmSettingsView } from "#/components/features/settings/meta-llm-profiles"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { I18nKey } from "#/i18n/declaration"; + +/** + * Settings route for managing *meta-profiles* — declarative model-routing + * configurations consumed by the agent's ``classify_and_switch_llm`` tool. + * + * Meta-profiles (like LLM profiles) are stored on the local agent-server, so + * the management view is only available for local backends. Cloud backends get + * an explanatory message. + * + * Note: This is a route file, only the router should import the default export. + */ +export default function MetaLlmSettingsRoute() { + const { t } = useTranslation("openhands"); + const { backend } = useActiveBackend(); + + if (backend.kind === "cloud") { + return ( +

+ {t(I18nKey.SETTINGS$META_PROFILE_CLOUD_UNSUPPORTED)} +

+ ); + } + + return ; +} From c1c82f1346b2da68525cf860e2e4e175f755e45b Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 16 Jun 2026 16:28:12 -0300 Subject: [PATCH 2/3] Move meta-profile row actions into a '...' overflow menu Match the LLM profiles page: replace the inline Set active / Edit / Delete buttons with an EllipsisButton trigger + portaled actions menu (MetaProfileActionsMenu), reusing the shared settings-list row styling (MetaProfileRow). Set active is disabled for the already-active profile. Co-authored-by: openhands --- .../meta-llm-settings-view.test.tsx | 19 +- .../settings/meta-llm-profiles/index.ts | 2 + .../meta-llm-settings-view.tsx | 90 ++----- .../meta-profile-actions-menu.tsx | 231 ++++++++++++++++++ .../meta-llm-profiles/meta-profile-row.tsx | 91 +++++++ 5 files changed, 353 insertions(+), 80 deletions(-) create mode 100644 src/components/features/settings/meta-llm-profiles/meta-profile-actions-menu.tsx create mode 100644 src/components/features/settings/meta-llm-profiles/meta-profile-row.tsx diff --git a/__tests__/components/settings/meta-llm-profiles/meta-llm-settings-view.test.tsx b/__tests__/components/settings/meta-llm-profiles/meta-llm-settings-view.test.tsx index 1f90f112c..8ad987558 100644 --- a/__tests__/components/settings/meta-llm-profiles/meta-llm-settings-view.test.tsx +++ b/__tests__/components/settings/meta-llm-profiles/meta-llm-settings-view.test.tsx @@ -134,19 +134,20 @@ describe("MetaLlmSettingsView", () => { expect(screen.getByTestId("meta-profile-name-input")).toBeInTheDocument(); }); - it("activates a meta-profile when clicking Set active", async () => { + it("activates a meta-profile via the actions menu", async () => { const user = userEvent.setup(); activateMutateAsync.mockResolvedValue({ name: "cheap" }); renderWithProviders(); - await user.click(screen.getByTestId("activate-meta-profile-cheap")); + await user.click(screen.getByTestId("meta-profile-menu-trigger-cheap")); + await user.click(screen.getByTestId("meta-profile-set-active")); await waitFor(() => expect(activateMutateAsync).toHaveBeenCalledWith("cheap"), ); }); - it("loads the config and opens the editor when clicking Edit", async () => { + it("loads the config and opens the editor via the actions menu", async () => { const user = userEvent.setup(); vi.mocked(MetaProfilesService.getMetaProfile).mockResolvedValue({ name: "balanced", @@ -158,11 +159,21 @@ describe("MetaLlmSettingsView", () => { }); renderWithProviders(); - await user.click(screen.getByTestId("edit-meta-profile-balanced")); + await user.click(screen.getByTestId("meta-profile-menu-trigger-balanced")); + await user.click(screen.getByTestId("meta-profile-edit")); await waitFor(() => expect(screen.getByTestId("meta-profile-editor")).toBeInTheDocument(), ); expect(MetaProfilesService.getMetaProfile).toHaveBeenCalledWith("balanced"); }); + + it("disables Set active in the menu for the already-active profile", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("meta-profile-menu-trigger-balanced")); + + expect(screen.getByTestId("meta-profile-set-active")).toBeDisabled(); + }); }); diff --git a/src/components/features/settings/meta-llm-profiles/index.ts b/src/components/features/settings/meta-llm-profiles/index.ts index d445e4973..a1bd04d8c 100644 --- a/src/components/features/settings/meta-llm-profiles/index.ts +++ b/src/components/features/settings/meta-llm-profiles/index.ts @@ -1,3 +1,5 @@ export { MetaLlmSettingsView } from "./meta-llm-settings-view"; export { MetaProfileEditor } from "./meta-profile-editor"; +export { MetaProfileRow } from "./meta-profile-row"; +export { MetaProfileActionsMenu } from "./meta-profile-actions-menu"; export { DeleteMetaProfileModal } from "./delete-meta-profile-modal"; diff --git a/src/components/features/settings/meta-llm-profiles/meta-llm-settings-view.tsx b/src/components/features/settings/meta-llm-profiles/meta-llm-settings-view.tsx index 104c8e01e..3daa7bacd 100644 --- a/src/components/features/settings/meta-llm-profiles/meta-llm-settings-view.tsx +++ b/src/components/features/settings/meta-llm-profiles/meta-llm-settings-view.tsx @@ -8,15 +8,14 @@ import { useSaveMetaProfile } from "#/hooks/mutation/use-save-meta-profile"; import { useActivateMetaProfile } from "#/hooks/mutation/use-activate-meta-profile"; import MetaProfilesService, { type MetaProfile, - type MetaProfileInfo, } from "#/api/meta-profiles-service/meta-profiles-service.api"; import { displayErrorToast, displaySuccessToast, } from "#/utils/custom-toast-handlers"; import { I18nKey } from "#/i18n/declaration"; -import { cn } from "#/utils/utils"; import { MetaProfileEditor } from "./meta-profile-editor"; +import { MetaProfileRow } from "./meta-profile-row"; import { DeleteMetaProfileModal } from "./delete-meta-profile-modal"; type ViewMode = "list" | "create" | "edit"; @@ -26,20 +25,6 @@ interface EditingMetaProfile { config: MetaProfile; } -function MetaProfileSummary({ info }: { info: MetaProfileInfo }) { - const { t } = useTranslation("openhands"); - const route = [info.classifier_model, info.default_model] - .filter(Boolean) - .join(" → "); - return ( - - {route} - {route ? " · " : ""} - {`${info.num_classes} ${t(I18nKey.SETTINGS$META_PROFILE_CLASSES)}`} - - ); -} - export function MetaLlmSettingsView() { const { t } = useTranslation("openhands"); const { data, isLoading, error } = useMetaProfiles(); @@ -168,66 +153,19 @@ export function MetaLlmSettingsView() { ) : null} {metaProfiles.length > 0 ? ( -
    - {metaProfiles.map((info) => { - const isActive = info.name === active; - return ( -
  • -
    -
    - - {info.name} - - {isActive ? ( - - {t(I18nKey.SETTINGS$META_PROFILE_ACTIVE)} - - ) : null} -
    - -
    - -
    - handleActivate(info.name)} - isDisabled={isActive || activateMetaProfile.isPending} - > - {t(I18nKey.SETTINGS$META_PROFILE_ACTIVATE)} - - handleEdit(info.name)} - > - {t(I18nKey.BUTTON$EDIT)} - - setNameToDelete(info.name)} - > - {t(I18nKey.BUTTON$DELETE)} - -
    -
  • - ); - })} -
+
+ {metaProfiles.map((info) => ( + + ))} +
) : null} diff --git a/src/components/features/settings/meta-llm-profiles/meta-profile-actions-menu.tsx b/src/components/features/settings/meta-llm-profiles/meta-profile-actions-menu.tsx new file mode 100644 index 000000000..7aa95515e --- /dev/null +++ b/src/components/features/settings/meta-llm-profiles/meta-profile-actions-menu.tsx @@ -0,0 +1,231 @@ +import { + useEffect, + useLayoutEffect, + useRef, + useCallback, + useState, +} from "react"; +import ReactDOM from "react-dom"; +import { useTranslation } from "react-i18next"; +import { cn } from "#/utils/utils"; +import { dropdownMenuListClassName } from "#/utils/dropdown-classes"; +import { I18nKey } from "#/i18n/declaration"; +import { ConversationNameContextMenuIconText } from "#/components/features/conversation/conversation-name-context-menu-icon-text"; +import EditIcon from "#/icons/u-edit.svg?react"; +import CheckCircleIcon from "#/icons/u-check-circle.svg?react"; +import DeleteIcon from "#/icons/u-delete.svg?react"; + +interface MenuItemProps { + index: number; + icon: React.ReactNode; + label: string; + onClick: () => void; + onKeyDown: (e: React.KeyboardEvent, index: number) => void; + menuItemsRef: React.MutableRefObject<(HTMLButtonElement | null)[]>; + disabled?: boolean; + testId: string; +} + +function MenuItem({ + index, + icon, + label, + onClick, + onKeyDown, + menuItemsRef, + disabled, + testId, +}: MenuItemProps) { + return ( + + ); +} + +interface MetaProfileActionsMenuProps { + onEdit: () => void; + onSetActive: () => void; + onDelete: () => void; + isActive: boolean; + isActivating: boolean; + onClose: () => void; + /** + * Element the menu should anchor against. When provided, the menu renders + * into a portal at the document body using fixed positioning so it cannot be + * clipped by ancestors with `overflow: auto/hidden` (e.g. the settings + * `
` scroll container). + */ + anchorRef?: React.RefObject; +} + +export function MetaProfileActionsMenu({ + onEdit, + onSetActive, + onDelete, + isActive, + isActivating, + onClose, + anchorRef, +}: MetaProfileActionsMenuProps) { + const { t } = useTranslation("openhands"); + const menuRef = useRef(null); + const menuItemsRef = useRef<(HTMLButtonElement | null)[]>([]); + + const anchorElement = anchorRef?.current ?? null; + const [portalStyle, setPortalStyle] = useState(); + + useLayoutEffect(() => { + if (!anchorElement) return undefined; + + const updatePosition = () => { + const rect = anchorElement.getBoundingClientRect(); + if (!rect) return; + const gap = 8; + setPortalStyle({ + position: "fixed", + zIndex: 9999, + top: rect.bottom + gap, + right: window.innerWidth - rect.right, + width: "max-content", + }); + }; + + updatePosition(); + window.addEventListener("resize", updatePosition); + window.addEventListener("scroll", updatePosition, true); + return () => { + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, true); + }; + }, [anchorElement]); + + // Focus first item when menu opens + useEffect(() => { + menuItemsRef.current[0]?.focus(); + }, []); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + if (menuRef.current && !menuRef.current.contains(target)) { + onClose(); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleEscape); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleEscape); + }; + }, [onClose]); + + const handleAction = (action: () => void) => { + action(); + onClose(); + }; + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent, currentIndex: number) => { + if (e.key === "Tab") { + onClose(); + return; + } + const itemCount = menuItemsRef.current.filter(Boolean).length; + if (e.key === "ArrowDown") { + e.preventDefault(); + const nextIndex = (currentIndex + 1) % itemCount; + menuItemsRef.current[nextIndex]?.focus(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + const prevIndex = (currentIndex - 1 + itemCount) % itemCount; + menuItemsRef.current[prevIndex]?.focus(); + } + }, + [onClose], + ); + + const setActiveDisabled = isActive || isActivating; + const isPortaled = Boolean(anchorElement); + + const menu = ( +
+ } + label={t(I18nKey.BUTTON$EDIT)} + onClick={() => handleAction(onEdit)} + onKeyDown={handleKeyDown} + menuItemsRef={menuItemsRef} + testId="meta-profile-edit" + /> + } + label={t(I18nKey.SETTINGS$META_PROFILE_ACTIVATE)} + onClick={() => handleAction(onSetActive)} + onKeyDown={handleKeyDown} + menuItemsRef={menuItemsRef} + disabled={setActiveDisabled} + testId="meta-profile-set-active" + /> + } + label={t(I18nKey.BUTTON$DELETE)} + onClick={() => handleAction(onDelete)} + onKeyDown={handleKeyDown} + menuItemsRef={menuItemsRef} + testId="meta-profile-delete" + /> +
+ ); + + if (isPortaled) { + if (typeof document === "undefined" || !portalStyle) { + return null; + } + return ReactDOM.createPortal( + // portal position computed from DOM bounding rect at runtime +
{menu}
, + document.body, + ); + } + + return menu; +} diff --git a/src/components/features/settings/meta-llm-profiles/meta-profile-row.tsx b/src/components/features/settings/meta-llm-profiles/meta-profile-row.tsx new file mode 100644 index 000000000..7b707f1eb --- /dev/null +++ b/src/components/features/settings/meta-llm-profiles/meta-profile-row.tsx @@ -0,0 +1,91 @@ +import { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { MetaProfileActionsMenu } from "./meta-profile-actions-menu"; +import { MetaProfileInfo } from "#/api/meta-profiles-service/meta-profiles-service.api"; +import { I18nKey } from "#/i18n/declaration"; +import { EllipsisButton } from "#/components/features/conversation-panel/ellipsis-button"; +import { BrandBadge } from "#/components/shared/badge"; +import { cn } from "#/utils/utils"; +import { + settingsListIconActionButtonClassName, + settingsListRowClassName, +} from "#/utils/settings-list-classes"; + +interface MetaProfileRowProps { + info: MetaProfileInfo; + isActive: boolean; + onActivate: (name: string) => void; + onEdit: (name: string) => void; + onDelete: (name: string) => void; + isActivating: boolean; +} + +export function MetaProfileRow({ + info, + isActive, + onActivate, + onEdit, + onDelete, + isActivating, +}: MetaProfileRowProps) { + const { t } = useTranslation("openhands"); + const [menuOpen, setMenuOpen] = useState(false); + const triggerRef = useRef(null); + + const route = [info.classifier_model, info.default_model] + .filter(Boolean) + .join(" → "); + const summary = `${route}${route ? " · " : ""}${info.num_classes} ${t( + I18nKey.SETTINGS$META_PROFILE_CLASSES, + )}`; + + return ( +
+
+ + {info.name} + + + {summary} + + {isActive && ( + + {t(I18nKey.SETTINGS$META_PROFILE_ACTIVE)} + + )} +
+
+ setMenuOpen((open) => !open)} + ariaLabel={t(I18nKey.SETTINGS$PROFILE_MENU)} + testId={`meta-profile-menu-trigger-${info.name}`} + className={settingsListIconActionButtonClassName} + /> + {menuOpen && ( + onEdit(info.name)} + onSetActive={() => onActivate(info.name)} + onDelete={() => onDelete(info.name)} + isActive={isActive} + isActivating={isActivating} + onClose={() => setMenuOpen(false)} + /> + )} +
+
+ ); +} From ade0bf9aba505e1a9d245a96d7ccc24954bee91b Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 16 Jun 2026 17:35:20 -0300 Subject: [PATCH 3/3] Address review: guard create-overwrite, compat 404, delete cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend review fixes for the Model Router settings tab: 1. Create flow could silently overwrite an existing meta-profile (the backend save is create-or-overwrite). MetaProfileEditor now takes `existingNames` and, in create mode, rejects a name that already exists: Save is disabled and an explicit "name already exists" message is shown. Edit mode is unaffected (the current name stays valid). 2. Unsupported-backend boundary is now explicit. Older agent servers (pre software-agent-sdk #3744) lack /api/meta-profiles and return 404; the view detects that (HttpError.status === 404) and shows a clear "backend doesn't support model routing yet" message instead of a generic error, and hides the Add affordance. Non-404 errors still show the generic error with Add available. 3. Deleting the active meta-profile now invalidates the settings caches (SettingsService.invalidateCache + SETTINGS_QUERY_KEYS.personal), mirroring activation — active_meta_profile controls whether classify_and_switch_llm attaches to new conversations, so stale settings could otherwise linger until reload. Tests: - editor: duplicate-name rejection (create) + unique-name acceptance + edit-mode name allowed. - view: 404 -> explicit unsupported message and no Add; 500 -> generic error with Add. - new hook test: delete invalidates meta-profile AND settings caches. i18n: add SETTINGS$META_PROFILE_NAME_TAKEN and SETTINGS$META_PROFILE_UNSUPPORTED across all locales. Co-authored-by: openhands --- .../meta-llm-settings-view.test.tsx | 33 ++++++ .../meta-profile-editor.test.tsx | 72 ++++++++++++ .../mutation/use-delete-meta-profile.test.tsx | 103 ++++++++++++++++++ .../meta-llm-settings-view.tsx | 19 ++++ .../meta-llm-profiles/meta-profile-editor.tsx | 35 ++++-- src/hooks/mutation/use-delete-meta-profile.ts | 14 ++- src/i18n/translation.json | 34 ++++++ 7 files changed, 302 insertions(+), 8 deletions(-) create mode 100644 __tests__/hooks/mutation/use-delete-meta-profile.test.tsx diff --git a/__tests__/components/settings/meta-llm-profiles/meta-llm-settings-view.test.tsx b/__tests__/components/settings/meta-llm-profiles/meta-llm-settings-view.test.tsx index 8ad987558..74111310b 100644 --- a/__tests__/components/settings/meta-llm-profiles/meta-llm-settings-view.test.tsx +++ b/__tests__/components/settings/meta-llm-profiles/meta-llm-settings-view.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach, type Mock } from "vitest"; import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { HttpError } from "@openhands/typescript-client"; import { renderWithProviders } from "test-utils"; import { MetaLlmSettingsView } from "#/components/features/settings/meta-llm-profiles"; import * as useMetaProfilesHook from "#/hooks/query/use-meta-profiles"; @@ -168,6 +169,38 @@ describe("MetaLlmSettingsView", () => { expect(MetaProfilesService.getMetaProfile).toHaveBeenCalledWith("balanced"); }); + it("shows an explicit unsupported-backend message when the API is missing (404)", () => { + // Older backends (pre software-agent-sdk #3744) have no /api/meta-profiles + // endpoint and return 404; the page must explain that instead of a dead + // generic error, and must not offer Add. + vi.mocked(useMetaProfilesHook.useMetaProfiles).mockReturnValue({ + data: undefined, + isLoading: false, + error: new HttpError(404, "Not Found"), + } as unknown as ReturnType); + + renderWithProviders(); + + expect(screen.getByTestId("meta-profile-unsupported")).toBeInTheDocument(); + expect(screen.queryByTestId("add-meta-profile")).not.toBeInTheDocument(); + }); + + it("shows the generic error for non-404 failures", () => { + vi.mocked(useMetaProfilesHook.useMetaProfiles).mockReturnValue({ + data: undefined, + isLoading: false, + error: new HttpError(500, "Internal Server Error"), + } as unknown as ReturnType); + + renderWithProviders(); + + expect( + screen.queryByTestId("meta-profile-unsupported"), + ).not.toBeInTheDocument(); + // The Add affordance remains for transient/server errors. + expect(screen.getByTestId("add-meta-profile")).toBeInTheDocument(); + }); + it("disables Set active in the menu for the already-active profile", async () => { const user = userEvent.setup(); renderWithProviders(); diff --git a/__tests__/components/settings/meta-llm-profiles/meta-profile-editor.test.tsx b/__tests__/components/settings/meta-llm-profiles/meta-profile-editor.test.tsx index 3b37cb3a1..aa4441789 100644 --- a/__tests__/components/settings/meta-llm-profiles/meta-profile-editor.test.tsx +++ b/__tests__/components/settings/meta-llm-profiles/meta-profile-editor.test.tsx @@ -94,6 +94,78 @@ describe("MetaProfileEditor", () => { ).not.toBeInTheDocument(); }); + it("rejects a duplicate name in create mode and blocks Save", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + renderWithProviders( + , + ); + + await user.type(screen.getByTestId("meta-profile-name-input"), "balanced"); + + expect(screen.getByTestId("meta-profile-name-taken")).toBeInTheDocument(); + const save = screen.getByTestId("meta-profile-save"); + expect(save).toBeDisabled(); + + await user.click(save); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("accepts a unique name in create mode", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + renderWithProviders( + , + ); + + await user.type(screen.getByTestId("meta-profile-name-input"), "fast"); + + expect( + screen.queryByTestId("meta-profile-name-taken"), + ).not.toBeInTheDocument(); + const save = screen.getByTestId("meta-profile-save"); + expect(save).toBeEnabled(); + + await user.click(save); + expect(onSave).toHaveBeenCalledWith("fast", FILLED); + }); + + it("allows the existing name in edit mode (no duplicate warning)", () => { + renderWithProviders( + , + ); + + expect( + screen.queryByTestId("meta-profile-name-taken"), + ).not.toBeInTheDocument(); + expect(screen.getByTestId("meta-profile-save")).toBeEnabled(); + }); + it("calls onCancel when Cancel is clicked", async () => { const user = userEvent.setup(); const onCancel = vi.fn(); diff --git a/__tests__/hooks/mutation/use-delete-meta-profile.test.tsx b/__tests__/hooks/mutation/use-delete-meta-profile.test.tsx new file mode 100644 index 000000000..333d769f6 --- /dev/null +++ b/__tests__/hooks/mutation/use-delete-meta-profile.test.tsx @@ -0,0 +1,103 @@ +import { describe, expect, it, vi, afterEach, beforeEach } from "vitest"; +import React from "react"; +import { renderHook, waitFor, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useDeleteMetaProfile } from "#/hooks/mutation/use-delete-meta-profile"; +import MetaProfilesService from "#/api/meta-profiles-service/meta-profiles-service.api"; +import SettingsService from "#/api/settings-service/settings-service.api"; +import { + META_PROFILES_QUERY_KEYS, + SETTINGS_QUERY_KEYS, +} from "#/hooks/query/query-keys"; + +vi.mock("#/api/meta-profiles-service/meta-profiles-service.api"); +vi.mock("#/api/settings-service/settings-service.api"); + +describe("useDeleteMetaProfile", () => { + let queryClient: QueryClient; + let wrapper: ({ + children, + }: { + children: React.ReactNode; + }) => React.ReactElement; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement( + QueryClientProvider, + { client: queryClient }, + children, + ); + }); + + afterEach(() => { + queryClient.clear(); + vi.clearAllMocks(); + }); + + it("calls MetaProfilesService.deleteMetaProfile with name", async () => { + vi.mocked(MetaProfilesService.deleteMetaProfile).mockResolvedValue({ + name: "balanced", + message: "deleted", + }); + + const { result } = renderHook(() => useDeleteMetaProfile(), { wrapper }); + + await act(async () => { + await result.current.mutateAsync("balanced"); + }); + + expect(MetaProfilesService.deleteMetaProfile).toHaveBeenCalledWith( + "balanced", + ); + }); + + it("invalidates the meta-profile AND settings caches on success", async () => { + // Deleting the active meta-profile clears active_meta_profile in settings, + // so the settings caches must be refreshed too (mirrors activation). + vi.mocked(MetaProfilesService.deleteMetaProfile).mockResolvedValue({ + name: "balanced", + message: "deleted", + }); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + const invalidateCacheSpy = vi.spyOn(SettingsService, "invalidateCache"); + + const { result } = renderHook(() => useDeleteMetaProfile(), { wrapper }); + + await act(async () => { + await result.current.mutateAsync("balanced"); + }); + + expect(invalidateCacheSpy).toHaveBeenCalled(); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: META_PROFILES_QUERY_KEYS.all, + }); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: SETTINGS_QUERY_KEYS.personal(), + }); + }); + + it("propagates delete errors", async () => { + vi.mocked(MetaProfilesService.deleteMetaProfile).mockRejectedValue( + new Error("nope"), + ); + + const { result } = renderHook(() => useDeleteMetaProfile(), { wrapper }); + + await expect( + act(async () => { + await result.current.mutateAsync("balanced"); + }), + ).rejects.toThrow("nope"); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + }); +}); diff --git a/src/components/features/settings/meta-llm-profiles/meta-llm-settings-view.tsx b/src/components/features/settings/meta-llm-profiles/meta-llm-settings-view.tsx index 3daa7bacd..6e3a6e953 100644 --- a/src/components/features/settings/meta-llm-profiles/meta-llm-settings-view.tsx +++ b/src/components/features/settings/meta-llm-profiles/meta-llm-settings-view.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { HttpError } from "@openhands/typescript-client"; import { BrandButton } from "#/components/features/settings/brand-button"; import { LoadingSpinner } from "#/components/shared/loading-spinner"; import { useMetaProfiles } from "#/hooks/query/use-meta-profiles"; @@ -41,6 +42,12 @@ export function MetaLlmSettingsView() { const availableProfiles = (llmProfilesData?.profiles ?? []).map( (p) => p.name, ); + const existingNames = metaProfiles.map((p) => p.name); + // A 404 means the backend predates the /api/meta-profiles endpoints + // (software-agent-sdk #3744). Surface that explicitly instead of a generic + // error so the page isn't a dead end on older backends. + const isUnsupportedBackend = + error instanceof HttpError && error.status === 404; const handleActivate = async (name: string) => { try { @@ -89,6 +96,17 @@ export function MetaLlmSettingsView() { setEditing(null); }; + if (isUnsupportedBackend) { + return ( +

+ {t(I18nKey.SETTINGS$META_PROFILE_UNSUPPORTED)} +

+ ); + } + if (view === "create" || view === "edit") { return ( void; onCancel: () => void; @@ -35,6 +41,7 @@ export function MetaProfileEditor({ initialName = "", initialConfig, availableProfiles, + existingNames = [], isSaving, onSave, onCancel, @@ -52,8 +59,12 @@ export function MetaProfileEditor({ const isEdit = mode === "edit"; const nameValid = isProfileNameValid(name, { isRequired: true }); + // In create mode, a name that already exists would overwrite that profile + // (the backend save is create-or-overwrite), so reject it here. + const isDuplicateName = !isEdit && existingNames.includes(name.trim()); const canSave = nameValid && + !isDuplicateName && config.classifier_model.trim().length > 0 && config.default_model.trim().length > 0 && config.classes.every( @@ -105,13 +116,23 @@ export function MetaProfileEditor({ )} - +
+ + {isDuplicateName ? ( +

+ {t(I18nKey.SETTINGS$META_PROFILE_NAME_TAKEN)} +

+ ) : null} +
MetaProfilesService.deleteMetaProfile(name), onSuccess: async () => { + // Deleting the *active* meta-profile clears ``active_meta_profile`` in + // settings (and detaches the classify_and_switch_llm tool from new + // conversations), so refresh the settings caches too — mirroring + // activation. Without this, settings can stay stale until reload. + SettingsService.invalidateCache(); await queryClient.invalidateQueries({ queryKey: META_PROFILES_QUERY_KEYS.all, }); + await queryClient.invalidateQueries({ + queryKey: SETTINGS_QUERY_KEYS.personal(), + }); }, // Consumers handle errors with try-catch and manual toasts; disable global toast meta: { disableToast: true }, diff --git a/src/i18n/translation.json b/src/i18n/translation.json index c1fa29cc2..588e433b7 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -5473,6 +5473,40 @@ "uk": "No meta-profiles yet. Add one to route tasks across your LLM profiles.", "ca": "No meta-profiles yet. Add one to route tasks across your LLM profiles." }, + "SETTINGS$META_PROFILE_NAME_TAKEN": { + "en": "A meta-profile with this name already exists. Choose a different name, or edit the existing one.", + "ja": "A meta-profile with this name already exists. Choose a different name, or edit the existing one.", + "zh-CN": "A meta-profile with this name already exists. Choose a different name, or edit the existing one.", + "zh-TW": "A meta-profile with this name already exists. Choose a different name, or edit the existing one.", + "ko-KR": "A meta-profile with this name already exists. Choose a different name, or edit the existing one.", + "no": "A meta-profile with this name already exists. Choose a different name, or edit the existing one.", + "it": "A meta-profile with this name already exists. Choose a different name, or edit the existing one.", + "pt": "A meta-profile with this name already exists. Choose a different name, or edit the existing one.", + "es": "A meta-profile with this name already exists. Choose a different name, or edit the existing one.", + "ar": "A meta-profile with this name already exists. Choose a different name, or edit the existing one.", + "fr": "A meta-profile with this name already exists. Choose a different name, or edit the existing one.", + "tr": "A meta-profile with this name already exists. Choose a different name, or edit the existing one.", + "de": "A meta-profile with this name already exists. Choose a different name, or edit the existing one.", + "uk": "A meta-profile with this name already exists. Choose a different name, or edit the existing one.", + "ca": "A meta-profile with this name already exists. Choose a different name, or edit the existing one." + }, + "SETTINGS$META_PROFILE_UNSUPPORTED": { + "en": "This backend doesn't support model routing yet. Update the agent server to manage meta-profiles.", + "ja": "This backend doesn't support model routing yet. Update the agent server to manage meta-profiles.", + "zh-CN": "This backend doesn't support model routing yet. Update the agent server to manage meta-profiles.", + "zh-TW": "This backend doesn't support model routing yet. Update the agent server to manage meta-profiles.", + "ko-KR": "This backend doesn't support model routing yet. Update the agent server to manage meta-profiles.", + "no": "This backend doesn't support model routing yet. Update the agent server to manage meta-profiles.", + "it": "This backend doesn't support model routing yet. Update the agent server to manage meta-profiles.", + "pt": "This backend doesn't support model routing yet. Update the agent server to manage meta-profiles.", + "es": "This backend doesn't support model routing yet. Update the agent server to manage meta-profiles.", + "ar": "This backend doesn't support model routing yet. Update the agent server to manage meta-profiles.", + "fr": "This backend doesn't support model routing yet. Update the agent server to manage meta-profiles.", + "tr": "This backend doesn't support model routing yet. Update the agent server to manage meta-profiles.", + "de": "This backend doesn't support model routing yet. Update the agent server to manage meta-profiles.", + "uk": "This backend doesn't support model routing yet. Update the agent server to manage meta-profiles.", + "ca": "This backend doesn't support model routing yet. Update the agent server to manage meta-profiles." + }, "SETTINGS$META_PROFILE_ACTIVE": { "en": "Active", "ja": "Active",