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..74111310b --- /dev/null +++ b/__tests__/components/settings/meta-llm-profiles/meta-llm-settings-view.test.tsx @@ -0,0 +1,212 @@ +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"; +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 via the actions menu", async () => { + const user = userEvent.setup(); + activateMutateAsync.mockResolvedValue({ name: "cheap" }); + renderWithProviders(); + + 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 via the actions menu", 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("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("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(); + + await user.click(screen.getByTestId("meta-profile-menu-trigger-balanced")); + + expect(screen.getByTestId("meta-profile-set-active")).toBeDisabled(); + }); +}); 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..aa4441789 --- /dev/null +++ b/__tests__/components/settings/meta-llm-profiles/meta-profile-editor.test.tsx @@ -0,0 +1,185 @@ +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("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(); + renderWithProviders( + , + ); + + await user.click(screen.getByTestId("meta-profile-cancel")); + expect(onCancel).toHaveBeenCalled(); + }); +}); 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/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..a1bd04d8c --- /dev/null +++ b/src/components/features/settings/meta-llm-profiles/index.ts @@ -0,0 +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 new file mode 100644 index 000000000..6e3a6e953 --- /dev/null +++ b/src/components/features/settings/meta-llm-profiles/meta-llm-settings-view.tsx @@ -0,0 +1,197 @@ +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"; +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, +} from "#/api/meta-profiles-service/meta-profiles-service.api"; +import { + displayErrorToast, + displaySuccessToast, +} from "#/utils/custom-toast-handlers"; +import { I18nKey } from "#/i18n/declaration"; +import { MetaProfileEditor } from "./meta-profile-editor"; +import { MetaProfileRow } from "./meta-profile-row"; +import { DeleteMetaProfileModal } from "./delete-meta-profile-modal"; + +type ViewMode = "list" | "create" | "edit"; + +interface EditingMetaProfile { + name: string; + config: MetaProfile; +} + +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 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 { + 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 (isUnsupportedBackend) { + return ( +

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

+ ); + } + + 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) => ( + + ))} +
+ ) : null} +
+ + setNameToDelete(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-editor.tsx b/src/components/features/settings/meta-llm-profiles/meta-profile-editor.tsx new file mode 100644 index 000000000..aed96885d --- /dev/null +++ b/src/components/features/settings/meta-llm-profiles/meta-profile-editor.tsx @@ -0,0 +1,294 @@ +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[]; + /** + * Names of existing meta-profiles. In create mode a name already present + * here is rejected, so "Add" cannot silently overwrite an existing profile + * (the backend save contract is create-or-overwrite). + */ + existingNames?: 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, + existingNames = [], + 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 }); + // 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( + (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, + )} + + +
+ + {isDuplicateName ? ( +

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

+ ) : null} +
+ +
+ + 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/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)} + /> + )} +
+
+ ); +} 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..19a49c9c8 --- /dev/null +++ b/src/hooks/mutation/use-delete-meta-profile.ts @@ -0,0 +1,30 @@ +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 useDeleteMetaProfile() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (name: string) => 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/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..588e433b7 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -5150,6 +5150,516 @@ "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_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", + "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 ; +}