Skip to content

Commit

Permalink
feat: set API key from settings modal (#1319)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people committed Apr 29, 2024
1 parent 11d48cc commit fa067ed
Show file tree
Hide file tree
Showing 10 changed files with 107 additions and 25 deletions.
2 changes: 1 addition & 1 deletion frontend/src/components/file-explorer/FileExplorer.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { WorkspaceFile, getWorkspace } from "#/services/fileService";
import React from "react";
import {
IoIosArrowBack,
IoIosArrowForward,
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";
Expand Down
24 changes: 22 additions & 2 deletions frontend/src/components/modals/settings/SettingsForm.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -18,13 +19,15 @@ const renderSettingsForm = (settings?: Settings) => {
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}
/>,
);
};
Expand All @@ -36,17 +39,20 @@ 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", () => {
renderSettingsForm({
LLM_MODEL: "model2",
AGENT: "agent2",
LANGUAGE: "es",
LLM_API_KEY: "sk-...",
});

const modelInput = screen.getByRole("combobox", { name: "model" });
Expand All @@ -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 } } },
);
Expand Down Expand Up @@ -98,6 +106,7 @@ describe("SettingsForm", () => {
});

expect(onModelChangeMock).toHaveBeenCalledWith("model3");
expect(onAPIKeyChangeMock).toHaveBeenCalledWith("");
});

it("should call the onAgentChange handler when the agent changes", () => {
Expand Down Expand Up @@ -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");
});
});
});
34 changes: 33 additions & 1 deletion frontend/src/components/modals/settings/SettingsForm.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -14,6 +16,7 @@ interface SettingsFormProps {
agents: string[];

onModelChange: (model: string) => void;
onAPIKeyChange: (apiKey: string) => void;
onAgentChange: (agent: string) => void;
onLanguageChange: (language: string) => void;
}
Expand All @@ -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 (
Expand All @@ -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 }))}
Expand Down
11 changes: 6 additions & 5 deletions frontend/src/components/modals/settings/SettingsModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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 () => {
Expand Down
36 changes: 26 additions & 10 deletions frontend/src/components/modals/settings/SettingsModal.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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) => {
Expand All @@ -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);
Expand All @@ -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 (
Expand Down Expand Up @@ -106,6 +121,7 @@ function SettingsModal({ isOpen, onOpenChange }: SettingsProps) {
onModelChange={handleModelChange}
onAgentChange={handleAgentChange}
onLanguageChange={handleLanguageChange}
onAPIKeyChange={handleAPIKeyChange}
/>
)}
</BaseModal>
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/i18n/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
9 changes: 5 additions & 4 deletions frontend/src/services/agent.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { describe, it, expect, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";

import { setInitialized } from "#/state/taskSlice";
import store from "#/store";
import ActionType from "#/types/ActionType";
import { Settings } from "./settings";
import { initializeAgent } from "./agent";
import { Settings } from "./settings";
import Socket from "./socket";
import store from "#/store";
import { setInitialized } from "#/state/taskSlice";

const sendSpy = vi.spyOn(Socket, "send");
const dispatchSpy = vi.spyOn(store, "dispatch");
Expand All @@ -16,6 +16,7 @@ describe("initializeAgent", () => {
LLM_MODEL: "llm_value",
AGENT: "agent_value",
LANGUAGE: "language_value",
LLM_API_KEY: "sk-...",
};

const event = {
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/services/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)[];
Expand All @@ -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,
});

/**
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/types/ConfigType.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
4 changes: 2 additions & 2 deletions opendevin/server/agent/agent.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -136,7 +136,7 @@ async def create_controller(self, start_event: dict):
} # 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)
Expand Down

0 comments on commit fa067ed

Please sign in to comment.