mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
feat: set API key from settings modal (#1319)
* feat: set API key from settings modal * feat: init with api key * test * fix * fixes * fix api key reference * test * minor fixes * fix settings update * combine settings call --------- Co-authored-by: Robert Brennan <accounts@rbren.io> Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import { WorkspaceFile, getWorkspace } from "#/services/fileService";
|
||||
import React from "react";
|
||||
import {
|
||||
IoIosArrowBack,
|
||||
@@ -6,6 +5,7 @@ import {
|
||||
IoIosRefresh,
|
||||
} from "react-icons/io";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { WorkspaceFile, getWorkspace } from "#/services/fileService";
|
||||
import IconButton from "../IconButton";
|
||||
import ExplorerTree from "./ExplorerTree";
|
||||
import { removeEmptyNodes } from "./utils";
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Settings } from "#/services/settings";
|
||||
import AgentTaskState from "#/types/AgentTaskState";
|
||||
import { act, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import AgentTaskState from "#/types/AgentTaskState";
|
||||
import SettingsForm from "./SettingsForm";
|
||||
import { Settings } from "#/services/settings";
|
||||
|
||||
const onModelChangeMock = vi.fn();
|
||||
const onAgentChangeMock = vi.fn();
|
||||
const onLanguageChangeMock = vi.fn();
|
||||
const onAPIKeyChangeMock = vi.fn();
|
||||
|
||||
const renderSettingsForm = (settings?: Settings) => {
|
||||
renderWithProviders(
|
||||
@@ -18,6 +19,7 @@ const renderSettingsForm = (settings?: Settings) => {
|
||||
LLM_MODEL: "model1",
|
||||
AGENT: "agent1",
|
||||
LANGUAGE: "en",
|
||||
LLM_API_KEY: "sk-...",
|
||||
}
|
||||
}
|
||||
models={["model1", "model2", "model3"]}
|
||||
@@ -25,6 +27,7 @@ const renderSettingsForm = (settings?: Settings) => {
|
||||
onModelChange={onModelChangeMock}
|
||||
onAgentChange={onAgentChangeMock}
|
||||
onLanguageChange={onLanguageChangeMock}
|
||||
onAPIKeyChange={onAPIKeyChangeMock}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
@@ -36,10 +39,12 @@ describe("SettingsForm", () => {
|
||||
const modelInput = screen.getByRole("combobox", { name: "model" });
|
||||
const agentInput = screen.getByRole("combobox", { name: "agent" });
|
||||
const languageInput = screen.getByRole("combobox", { name: "language" });
|
||||
const apiKeyInput = screen.getByTestId("apikey");
|
||||
|
||||
expect(modelInput).toHaveValue("model1");
|
||||
expect(agentInput).toHaveValue("agent1");
|
||||
expect(languageInput).toHaveValue("English");
|
||||
expect(apiKeyInput).toHaveValue("sk-...");
|
||||
});
|
||||
|
||||
it("should display the existing values if it they are present", () => {
|
||||
@@ -47,6 +52,7 @@ describe("SettingsForm", () => {
|
||||
LLM_MODEL: "model2",
|
||||
AGENT: "agent2",
|
||||
LANGUAGE: "es",
|
||||
LLM_API_KEY: "sk-...",
|
||||
});
|
||||
|
||||
const modelInput = screen.getByRole("combobox", { name: "model" });
|
||||
@@ -65,12 +71,14 @@ describe("SettingsForm", () => {
|
||||
LLM_MODEL: "model1",
|
||||
AGENT: "agent1",
|
||||
LANGUAGE: "en",
|
||||
LLM_API_KEY: "sk-...",
|
||||
}}
|
||||
models={["model1", "model2", "model3"]}
|
||||
agents={["agent1", "agent2", "agent3"]}
|
||||
onModelChange={onModelChangeMock}
|
||||
onAgentChange={onAgentChangeMock}
|
||||
onLanguageChange={onLanguageChangeMock}
|
||||
onAPIKeyChange={onAPIKeyChangeMock}
|
||||
/>,
|
||||
{ preloadedState: { agent: { curTaskState: AgentTaskState.RUNNING } } },
|
||||
);
|
||||
@@ -98,6 +106,7 @@ describe("SettingsForm", () => {
|
||||
});
|
||||
|
||||
expect(onModelChangeMock).toHaveBeenCalledWith("model3");
|
||||
expect(onAPIKeyChangeMock).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("should call the onAgentChange handler when the agent changes", () => {
|
||||
@@ -131,5 +140,16 @@ describe("SettingsForm", () => {
|
||||
|
||||
expect(onLanguageChangeMock).toHaveBeenCalledWith("Français");
|
||||
});
|
||||
|
||||
it("should call the onAPIKeyChange handler when the API key changes", () => {
|
||||
renderSettingsForm();
|
||||
|
||||
const apiKeyInput = screen.getByTestId("apikey");
|
||||
act(() => {
|
||||
userEvent.type(apiKeyInput, "x");
|
||||
});
|
||||
|
||||
expect(onAPIKeyChangeMock).toHaveBeenCalledWith("sk-...x");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Input, useDisclosure } from "@nextui-org/react";
|
||||
import React, { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaEye, FaEyeSlash } from "react-icons/fa";
|
||||
import { useSelector } from "react-redux";
|
||||
import { AvailableLanguages } from "../../../i18n";
|
||||
import { I18nKey } from "../../../i18n/declaration";
|
||||
@@ -14,6 +16,7 @@ interface SettingsFormProps {
|
||||
agents: string[];
|
||||
|
||||
onModelChange: (model: string) => void;
|
||||
onAPIKeyChange: (apiKey: string) => void;
|
||||
onAgentChange: (agent: string) => void;
|
||||
onLanguageChange: (language: string) => void;
|
||||
}
|
||||
@@ -23,12 +26,14 @@ function SettingsForm({
|
||||
models,
|
||||
agents,
|
||||
onModelChange,
|
||||
onAPIKeyChange,
|
||||
onAgentChange,
|
||||
onLanguageChange,
|
||||
}: SettingsFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const { curTaskState } = useSelector((state: RootState) => state.agent);
|
||||
const [disabled, setDisabled] = React.useState<boolean>(false);
|
||||
const { isOpen: isVisible, onOpenChange: onVisibleChange } = useDisclosure();
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -47,11 +52,38 @@ function SettingsForm({
|
||||
ariaLabel="model"
|
||||
items={models.map((model) => ({ value: model, label: model }))}
|
||||
defaultKey={settings.LLM_MODEL || models[0]}
|
||||
onChange={onModelChange}
|
||||
onChange={(e) => {
|
||||
onModelChange(e);
|
||||
}}
|
||||
tooltip={t(I18nKey.SETTINGS$MODEL_TOOLTIP)}
|
||||
allowCustomValue // user can type in a custom LLM model that is not in the list
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
label="API Key"
|
||||
disabled={disabled}
|
||||
aria-label="apikey"
|
||||
data-testid="apikey"
|
||||
placeholder={t(I18nKey.SETTINGS$API_KEY_PLACEHOLDER)}
|
||||
type={isVisible ? "text" : "password"}
|
||||
value={settings.LLM_API_KEY || ""}
|
||||
onChange={(e) => {
|
||||
onAPIKeyChange(e.target.value);
|
||||
}}
|
||||
endContent={
|
||||
<button
|
||||
className="focus:outline-none"
|
||||
type="button"
|
||||
onClick={onVisibleChange}
|
||||
>
|
||||
{isVisible ? (
|
||||
<FaEye className="text-2xl text-default-400 pointer-events-none" />
|
||||
) : (
|
||||
<FaEyeSlash className="text-2xl text-default-400 pointer-events-none" />
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<AutocompleteCombobox
|
||||
ariaLabel="agent"
|
||||
items={agents.map((agent) => ({ value: agent, label: agent }))}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { fetchAgents, fetchModels } from "#/api";
|
||||
import { initializeAgent } from "#/services/agent";
|
||||
import { Settings, getSettings, saveSettings } from "#/services/settings";
|
||||
import toast from "#/utils/toast";
|
||||
import { act, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import i18next from "i18next";
|
||||
import React from "react";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { Mock } from "vitest";
|
||||
import i18next from "i18next";
|
||||
import SettingsModal from "./SettingsModal";
|
||||
import { Settings, getSettings, saveSettings } from "#/services/settings";
|
||||
import { initializeAgent } from "#/services/agent";
|
||||
import toast from "#/utils/toast";
|
||||
import { fetchAgents, fetchModels } from "#/api";
|
||||
|
||||
const toastSpy = vi.spyOn(toast, "settingsChanged");
|
||||
const i18nSpy = vi.spyOn(i18next, "changeLanguage");
|
||||
@@ -98,6 +98,7 @@ describe("SettingsModal", () => {
|
||||
LLM_MODEL: "gpt-3.5-turbo",
|
||||
AGENT: "MonologueAgent",
|
||||
LANGUAGE: "en",
|
||||
LLM_API_KEY: "sk-...",
|
||||
};
|
||||
|
||||
it("should save the settings", async () => {
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { Spinner } from "@nextui-org/react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18next from "i18next";
|
||||
import { fetchAgents, fetchModels } from "#/api";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { fetchAgents, fetchModels } from "#/api";
|
||||
import BaseModal from "../base-modal/BaseModal";
|
||||
import SettingsForm from "./SettingsForm";
|
||||
import { initializeAgent } from "#/services/agent";
|
||||
import {
|
||||
Settings,
|
||||
saveSettings,
|
||||
getSettings,
|
||||
getSettingsDifference,
|
||||
saveSettings,
|
||||
} from "#/services/settings";
|
||||
import toast from "#/utils/toast";
|
||||
import { initializeAgent } from "#/services/agent";
|
||||
import { Spinner } from "@nextui-org/react";
|
||||
import i18next from "i18next";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import BaseModal from "../base-modal/BaseModal";
|
||||
import SettingsForm from "./SettingsForm";
|
||||
|
||||
interface SettingsProps {
|
||||
isOpen: boolean;
|
||||
@@ -45,7 +45,13 @@ function SettingsModal({ isOpen, onOpenChange }: SettingsProps) {
|
||||
}, []);
|
||||
|
||||
const handleModelChange = (model: string) => {
|
||||
setSettings((prev) => ({ ...prev, LLM_MODEL: model }));
|
||||
// Needs to also reset the API key.
|
||||
const key = localStorage.getItem(`API_KEY_${model}`);
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
LLM_MODEL: model,
|
||||
LLM_API_KEY: key || "",
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAgentChange = (agent: string) => {
|
||||
@@ -60,6 +66,10 @@ function SettingsModal({ isOpen, onOpenChange }: SettingsProps) {
|
||||
if (key) setSettings((prev) => ({ ...prev, LANGUAGE: key }));
|
||||
};
|
||||
|
||||
const handleAPIKeyChange = (key: string) => {
|
||||
setSettings((prev) => ({ ...prev, LLM_API_KEY: key }));
|
||||
};
|
||||
|
||||
const handleSaveSettings = () => {
|
||||
const updatedSettings = getSettingsDifference(settings);
|
||||
saveSettings(settings);
|
||||
@@ -69,6 +79,11 @@ function SettingsModal({ isOpen, onOpenChange }: SettingsProps) {
|
||||
Object.entries(updatedSettings).forEach(([key, value]) => {
|
||||
toast.settingsChanged(`${key} set to "${value}"`);
|
||||
});
|
||||
|
||||
localStorage.setItem(
|
||||
`API_KEY_${settings.LLM_MODEL || models[0]}`,
|
||||
settings.LLM_API_KEY,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -106,6 +121,7 @@ function SettingsModal({ isOpen, onOpenChange }: SettingsProps) {
|
||||
onModelChange={handleModelChange}
|
||||
onAgentChange={handleAgentChange}
|
||||
onLanguageChange={handleLanguageChange}
|
||||
onAPIKeyChange={handleAPIKeyChange}
|
||||
/>
|
||||
)}
|
||||
</BaseModal>
|
||||
|
||||
@@ -324,5 +324,9 @@
|
||||
"SETTINGS$DISABLED_RUNNING": {
|
||||
"en": "Cannot be changed while the agent is running.",
|
||||
"de": "Kann nicht geändert werden während ein Task ausgeführt wird."
|
||||
},
|
||||
"SETTINGS$API_KEY_PLACEHOLDER": {
|
||||
"en": "Enter your API key.",
|
||||
"de": "Model API key."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import ActionType from "#/types/ActionType";
|
||||
import { Settings } from "./settings";
|
||||
import { initializeAgent } from "./agent";
|
||||
import Socket from "./socket";
|
||||
import store from "#/store";
|
||||
import { setInitialized } from "#/state/taskSlice";
|
||||
import store from "#/store";
|
||||
import ActionType from "#/types/ActionType";
|
||||
import { initializeAgent } from "./agent";
|
||||
import { Settings } from "./settings";
|
||||
import Socket from "./socket";
|
||||
|
||||
const sendSpy = vi.spyOn(Socket, "send");
|
||||
const dispatchSpy = vi.spyOn(store, "dispatch");
|
||||
@@ -16,6 +16,7 @@ describe("initializeAgent", () => {
|
||||
LLM_MODEL: "llm_value",
|
||||
AGENT: "agent_value",
|
||||
LANGUAGE: "language_value",
|
||||
LLM_API_KEY: "sk-...",
|
||||
};
|
||||
|
||||
const event = {
|
||||
|
||||
@@ -2,12 +2,14 @@ export type Settings = {
|
||||
LLM_MODEL: string;
|
||||
AGENT: string;
|
||||
LANGUAGE: string;
|
||||
LLM_API_KEY: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
LLM_MODEL: "gpt-3.5-turbo",
|
||||
AGENT: "MonologueAgent",
|
||||
LANGUAGE: "en",
|
||||
LLM_API_KEY: "",
|
||||
};
|
||||
|
||||
const validKeys = Object.keys(DEFAULT_SETTINGS) as (keyof Settings)[];
|
||||
@@ -19,6 +21,10 @@ export const getSettings = (): Settings => ({
|
||||
LLM_MODEL: localStorage.getItem("LLM_MODEL") || DEFAULT_SETTINGS.LLM_MODEL,
|
||||
AGENT: localStorage.getItem("AGENT") || DEFAULT_SETTINGS.AGENT,
|
||||
LANGUAGE: localStorage.getItem("LANGUAGE") || DEFAULT_SETTINGS.LANGUAGE,
|
||||
LLM_API_KEY:
|
||||
localStorage.getItem(
|
||||
`API_KEY_${localStorage.getItem("LLM_MODEL") || DEFAULT_SETTINGS.LLM_MODEL}`,
|
||||
) || DEFAULT_SETTINGS.LLM_API_KEY,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,12 +2,14 @@ enum ArgConfigType {
|
||||
LLM_MODEL = "LLM_MODEL",
|
||||
AGENT = "AGENT",
|
||||
LANGUAGE = "LANGUAGE",
|
||||
LLM_API_KEY = "LLM_API_KEY",
|
||||
}
|
||||
|
||||
const SupportedSettings: string[] = [
|
||||
ArgConfigType.LLM_MODEL,
|
||||
ArgConfigType.AGENT,
|
||||
ArgConfigType.LANGUAGE,
|
||||
ArgConfigType.LLM_API_KEY,
|
||||
];
|
||||
|
||||
export { ArgConfigType, SupportedSettings };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import asyncio
|
||||
from typing import Optional, Dict, List
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from opendevin import config
|
||||
from opendevin.action import (
|
||||
@@ -136,7 +136,7 @@ class AgentUnit:
|
||||
} # remove empty values, prevent FE from sending empty strings
|
||||
agent_cls = self.get_arg_or_default(args, ConfigType.AGENT)
|
||||
model = self.get_arg_or_default(args, ConfigType.LLM_MODEL)
|
||||
api_key = config.get(ConfigType.LLM_API_KEY)
|
||||
api_key = self.get_arg_or_default(args, ConfigType.LLM_API_KEY)
|
||||
api_base = config.get(ConfigType.LLM_BASE_URL)
|
||||
max_iterations = self.get_arg_or_default(args, ConfigType.MAX_ITERATIONS)
|
||||
max_chars = self.get_arg_or_default(args, ConfigType.MAX_CHARS)
|
||||
|
||||
Reference in New Issue
Block a user