Compare commits

..

15 Commits

Author SHA1 Message Date
rohitvinodmalhotra@gmail.com
2399174e89 fix settings load 2025-04-28 19:09:27 -04:00
rohitvinodmalhotra@gmail.com
7c3f4891f8 render for saas 2025-04-28 18:02:56 -04:00
rohitvinodmalhotra@gmail.com
49bb7bbaba add base_domain to fe 2025-04-28 17:50:30 -04:00
openhands
10c1252cfe Add base domain fields for GitHub and GitLab in git settings 2025-04-28 20:14:55 +00:00
rohitvinodmalhotra@gmail.com
911867492c fix providertype comp 2025-04-28 16:07:03 -04:00
rohitvinodmalhotra@gmail.com
85a1b47c8d modify new git page 2025-04-28 16:03:58 -04:00
rohitvinodmalhotra@gmail.com
d6011829a3 merge main 2025-04-28 16:02:54 -04:00
rohitvinodmalhotra@gmail.com
9200e1dbd8 fix providertype comparisions 2025-04-28 15:21:03 -04:00
Engel Nyst
06ce12eff4 Update resolver model (#8124) 2025-04-28 21:07:40 +02:00
sp.wack
88fc26d9b0 frontend: Better diff contrast (#8128) 2025-04-28 22:53:37 +04:00
dependabot[bot]
99233ec153 chore(deps): bump the version-all group with 6 updates (#8125)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-28 20:50:39 +02:00
sp.wack
ae9573a503 chore(frontend): Breakdown settings screen (#7929)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-28 22:32:28 +04:00
rohitvinodmalhotra@gmail.com
d1343539ba update save for provider token 2025-04-28 14:30:44 -04:00
rohitvinodmalhotra@gmail.com
8bc206833a restructure fe type 2025-04-28 14:08:18 -04:00
rohitvinodmalhotra@gmail.com
7cf61d8c0e add base domain param to provider token 2025-04-28 13:29:06 -04:00
77 changed files with 3494 additions and 1707 deletions

View File

@@ -24,7 +24,7 @@ on:
LLM_MODEL:
required: false
type: string
default: "anthropic/claude-3-5-sonnet-20241022"
default: "anthropic/claude-3-7-sonnet-20250219"
LLM_API_VERSION:
required: false
type: string

View File

@@ -118,7 +118,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image by
setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.35-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.34-nikolaik`
## Develop inside Docker container

View File

@@ -52,17 +52,17 @@ system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.35
docker.all-hands.dev/all-hands-ai/openhands:0.34
```
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!

View File

@@ -11,7 +11,7 @@ services:
- BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"}
- SANDBOX_API_HOSTNAME=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.35-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.34-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of openhands-state for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -52,7 +52,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -61,7 +61,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.cli
```

View File

@@ -46,7 +46,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -56,6 +56,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```

View File

@@ -13,16 +13,16 @@
La façon la plus simple d'exécuter OpenHands est avec Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.35
docker.all-hands.dev/all-hands-ai/openhands:0.34
```
Vous pouvez également exécuter OpenHands en mode [headless scriptable](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), en tant que [CLI interactive](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), ou en utilisant l'[Action GitHub OpenHands](https://docs.all-hands.dev/modules/usage/how-to/github-action).

View File

@@ -13,7 +13,7 @@ C'est le Runtime par défaut qui est utilisé lorsque vous démarrez OpenHands.
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -34,7 +34,7 @@ Docker で OpenHands を CLI モードで実行するには:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -44,7 +44,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.cli
```

View File

@@ -31,7 +31,7 @@ DockerでOpenHandsをヘッドレスモードで実行するには:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -42,7 +42,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -25,7 +25,7 @@ nikolaik の `SANDBOX_RUNTIME_CONTAINER_IMAGE` は、ランタイムサーバー
```bash
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-v $WORKSPACE_BASE:/opt/workspace_base \
@@ -82,5 +82,5 @@ docker network create openhands-network
# 分離されたネットワークで OpenHands を実行
docker run # ... \
--network openhands-network \
docker.all-hands.dev/all-hands-ai/openhands:0.35
docker.all-hands.dev/all-hands-ai/openhands:0.34
```

View File

@@ -35,7 +35,7 @@ Para executar o OpenHands no modo CLI com Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -45,7 +45,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.cli
```

View File

@@ -32,7 +32,7 @@ Para executar o OpenHands no modo Headless com Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -43,7 +43,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.main -t "escreva um script bash que imprima oi"
```

View File

@@ -58,17 +58,17 @@
A maneira mais fácil de executar o OpenHands é no Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.35
docker.all-hands.dev/all-hands-ai/openhands:0.34
```
Você encontrará o OpenHands em execução em http://localhost:3000!

View File

@@ -13,7 +13,7 @@ Este é o Runtime padrão que é usado quando você inicia o OpenHands. Você po
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -59,7 +59,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.cli
```

View File

@@ -47,7 +47,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -57,6 +57,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```

View File

@@ -11,16 +11,16 @@
在 Docker 中运行 OpenHands 是最简单的方式。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.35
docker.all-hands.dev/all-hands-ai/openhands:0.34
```
你也可以在可脚本化的[无头模式](https://docs.all-hands.dev/modules/usage/how-to/headless-mode)下运行 OpenHands作为[交互式 CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),或使用 [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action)。

View File

@@ -11,7 +11,7 @@
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -35,7 +35,7 @@ To run OpenHands in CLI mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -45,7 +45,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.cli
```

View File

@@ -32,7 +32,7 @@ To run OpenHands in Headless mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -43,7 +43,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -58,17 +58,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
The easiest way to run OpenHands is in Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.35
docker.all-hands.dev/all-hands-ai/openhands:0.34
```
You'll find OpenHands running at http://localhost:3000!

View File

@@ -0,0 +1,291 @@
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import AppSettingsScreen from "#/routes/app-settings";
import OpenHands from "#/api/open-hands";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { AuthProvider } from "#/context/auth-context";
import { AvailableLanguages } from "#/i18n";
import * as CaptureConsent from "#/utils/handle-capture-consent";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
const renderAppSettingsScreen = () =>
render(<AppSettingsScreen />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
),
});
describe("Content", () => {
it("should render the screen", () => {
renderAppSettingsScreen();
screen.getByTestId("app-settings-screen");
});
it("should render the correct default values", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
language: "no",
user_consents_to_analytics: true,
enable_sound_notifications: true,
});
renderAppSettingsScreen();
await waitFor(() => {
const language = screen.getByTestId("language-input");
const analytics = screen.getByTestId("enable-analytics-switch");
const sound = screen.getByTestId("enable-sound-notifications-switch");
expect(language).toHaveValue("Norsk");
expect(analytics).toBeChecked();
expect(sound).toBeChecked();
});
});
it("should render the language options", async () => {
renderAppSettingsScreen();
const language = await screen.findByTestId("language-input");
await userEvent.click(language);
AvailableLanguages.forEach((lang) => {
const option = screen.getByText(lang.label);
expect(option).toBeInTheDocument();
});
});
});
describe("Form submission", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("should submit the form with the correct values", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderAppSettingsScreen();
const language = await screen.findByTestId("language-input");
const analytics = await screen.findByTestId("enable-analytics-switch");
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
expect(language).toHaveValue("English");
expect(analytics).not.toBeChecked();
expect(sound).not.toBeChecked();
// change language
await userEvent.click(language);
const norsk = screen.getByText("Norsk");
await userEvent.click(norsk);
expect(language).toHaveValue("Norsk");
// toggle options
await userEvent.click(analytics);
expect(analytics).toBeChecked();
await userEvent.click(sound);
expect(sound).toBeChecked();
// submit the form
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
language: "no",
user_consents_to_analytics: true,
enable_sound_notifications: true,
}),
);
});
it("should only enable the submit button when there are changes", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderAppSettingsScreen();
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
// Language check
const language = await screen.findByTestId("language-input");
await userEvent.click(language);
const norsk = screen.getByText("Norsk");
await userEvent.click(norsk);
expect(submit).not.toBeDisabled();
await userEvent.click(language);
const english = screen.getByText("English");
await userEvent.click(english);
expect(submit).toBeDisabled();
// Analytics check
const analytics = await screen.findByTestId("enable-analytics-switch");
await userEvent.click(analytics);
expect(submit).not.toBeDisabled();
await userEvent.click(analytics);
expect(submit).toBeDisabled();
// Sound check
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
await userEvent.click(sound);
expect(submit).not.toBeDisabled();
await userEvent.click(sound);
expect(submit).toBeDisabled();
});
it("should call handleCaptureConsents with true when the analytics switch is toggled", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const handleCaptureConsentsSpy = vi.spyOn(
CaptureConsent,
"handleCaptureConsent",
);
renderAppSettingsScreen();
const analytics = await screen.findByTestId("enable-analytics-switch");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(analytics);
await userEvent.click(submit);
await waitFor(() =>
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(true),
);
});
it("should call handleCaptureConsents with false when the analytics switch is toggled", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
user_consents_to_analytics: true,
});
const handleCaptureConsentsSpy = vi.spyOn(
CaptureConsent,
"handleCaptureConsent",
);
renderAppSettingsScreen();
const analytics = await screen.findByTestId("enable-analytics-switch");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(analytics);
await userEvent.click(submit);
await waitFor(() =>
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(false),
);
});
// flaky test
it.skip("should disable the button when submitting changes", async () => {
renderAppSettingsScreen();
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
await userEvent.click(sound);
expect(submit).not.toBeDisabled();
// submit the form
await userEvent.click(submit);
expect(submit).toHaveTextContent("Saving...");
expect(submit).toBeDisabled();
await waitFor(() => expect(submit).toHaveTextContent("Save"));
});
it("should disable the button after submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderAppSettingsScreen();
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
await userEvent.click(sound);
expect(submit).not.toBeDisabled();
// submit the form
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(submit).toBeDisabled());
});
});
describe("Status toasts", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
"displaySuccessToast",
);
renderAppSettingsScreen();
// Toggle setting to change
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
await userEvent.click(sound);
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
renderAppSettingsScreen();
// Toggle setting to change
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
await userEvent.click(sound);
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(displayErrorToastSpy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,461 @@
import { render, screen, waitFor } from "@testing-library/react";
import { createRoutesStub } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import GitSettingsScreen from "#/routes/git-settings";
import OpenHands from "#/api/open-hands";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { AuthProvider } from "#/context/auth-context";
import { GetConfigResponse } from "#/api/open-hands.types";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
const VALID_OSS_CONFIG: GetConfigResponse = {
APP_MODE: "oss",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
};
const VALID_SAAS_CONFIG: GetConfigResponse = {
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
};
const queryClient = new QueryClient();
const GitSettingsRouterStub = createRoutesStub([
{
Component: GitSettingsScreen,
path: "/settings/github",
},
]);
const renderGitSettingsScreen = () => {
const { rerender, ...rest } = render(
<GitSettingsRouterStub initialEntries={["/settings/github"]} />,
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
),
},
);
const rerenderGitSettingsScreen = () =>
rerender(
<QueryClientProvider client={queryClient}>
<AuthProvider>
<GitSettingsRouterStub initialEntries={["/settings/github"]} />
</AuthProvider>
</QueryClientProvider>,
);
return {
...rest,
rerender: rerenderGitSettingsScreen,
};
};
beforeEach(() => {
// Since we don't recreate the query client on every test, we need to
// reset the query client before each test to avoid state leaks
// between tests.
queryClient.invalidateQueries();
});
describe("Content", () => {
it("should render", async () => {
renderGitSettingsScreen();
await screen.findByTestId("git-settings-screen");
});
it("should render the inputs if OSS mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
const { rerender } = renderGitSettingsScreen();
await screen.findByTestId("github-token-input");
await screen.findByTestId("github-token-help-anchor");
await screen.findByTestId("gitlab-token-input");
await screen.findByTestId("gitlab-token-help-anchor");
getConfigSpy.mockResolvedValue(VALID_SAAS_CONFIG);
queryClient.invalidateQueries();
rerender();
await waitFor(() => {
expect(
screen.queryByTestId("github-token-input"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("github-token-help-anchor"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("gitlab-token-input"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("gitlab-token-help-anchor"),
).not.toBeInTheDocument();
});
});
it("should set '<hidden>' placeholder and indicator if the GitHub token is set", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: false,
gitlab: false,
},
});
const { rerender } = renderGitSettingsScreen();
await waitFor(() => {
const githubInput = screen.getByTestId("github-token-input");
expect(githubInput).toHaveProperty("placeholder", "");
expect(
screen.queryByTestId("gh-set-token-indicator"),
).not.toBeInTheDocument();
const gitlabInput = screen.getByTestId("gitlab-token-input");
expect(gitlabInput).toHaveProperty("placeholder", "");
expect(
screen.queryByTestId("gl-set-token-indicator"),
).not.toBeInTheDocument();
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: true,
gitlab: true,
},
});
queryClient.invalidateQueries();
rerender();
await waitFor(() => {
const githubInput = screen.getByTestId("github-token-input");
expect(githubInput).toHaveProperty("placeholder", "<hidden>");
expect(
screen.queryByTestId("gh-set-token-indicator"),
).toBeInTheDocument();
const gitlabInput = screen.getByTestId("gitlab-token-input");
expect(gitlabInput).toHaveProperty("placeholder", "<hidden>");
expect(
screen.queryByTestId("gl-set-token-indicator"),
).toBeInTheDocument();
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: false,
gitlab: true,
},
});
queryClient.invalidateQueries();
rerender();
await waitFor(() => {
const githubInput = screen.getByTestId("github-token-input");
expect(githubInput).toHaveProperty("placeholder", "");
expect(
screen.queryByTestId("gh-set-token-indicator"),
).not.toBeInTheDocument();
const gitlabInput = screen.getByTestId("gitlab-token-input");
expect(gitlabInput).toHaveProperty("placeholder", "<hidden>");
expect(
screen.queryByTestId("gl-set-token-indicator"),
).toBeInTheDocument();
});
});
it("should render the 'Configure GitHub Repositories' button if SaaS mode and app slug exists", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
const { rerender } = renderGitSettingsScreen();
let button = screen.queryByTestId("configure-github-repositories-button");
expect(button).not.toBeInTheDocument();
expect(screen.getByTestId("submit-button")).toBeInTheDocument();
expect(screen.getByTestId("disconnect-tokens-button")).toBeInTheDocument();
getConfigSpy.mockResolvedValue(VALID_SAAS_CONFIG);
queryClient.invalidateQueries();
rerender();
await waitFor(() => {
// wait until queries are resolved
expect(queryClient.isFetching()).toBe(0);
button = screen.queryByTestId("configure-github-repositories-button");
expect(button).not.toBeInTheDocument();
});
getConfigSpy.mockResolvedValue({
...VALID_SAAS_CONFIG,
APP_SLUG: "test-slug",
});
queryClient.invalidateQueries();
rerender();
await waitFor(() => {
button = screen.getByTestId("configure-github-repositories-button");
expect(button).toBeInTheDocument();
expect(screen.queryByTestId("submit-button")).not.toBeInTheDocument();
expect(
screen.queryByTestId("disconnect-tokens-button"),
).not.toBeInTheDocument();
});
});
});
describe("Form submission", () => {
it("should save the GitHub token", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
const githubInput = await screen.findByTestId("github-token-input");
const submit = await screen.findByTestId("submit-button");
await userEvent.type(githubInput, "test-token");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
provider_tokens: {
github: "test-token",
gitlab: "",
},
}),
);
const gitlabInput = await screen.findByTestId("gitlab-token-input");
await userEvent.type(gitlabInput, "test-token");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
provider_tokens: {
github: "",
gitlab: "test-token",
},
}),
);
});
it("should disable the button if there is no input", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
const githubInput = await screen.findByTestId("github-token-input");
await userEvent.type(githubInput, "test-token");
expect(submit).not.toBeDisabled();
await userEvent.clear(githubInput);
expect(submit).toBeDisabled();
const gitlabInput = await screen.findByTestId("gitlab-token-input");
await userEvent.type(gitlabInput, "test-token");
expect(submit).not.toBeDisabled();
await userEvent.clear(gitlabInput);
expect(submit).toBeDisabled();
});
it("should enable a disconnect tokens button if there is at least one token set", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: true,
gitlab: false,
},
});
renderGitSettingsScreen();
await screen.findByTestId("git-settings-screen");
let disconnectButton = await screen.findByTestId(
"disconnect-tokens-button",
);
await waitFor(() => expect(disconnectButton).not.toBeDisabled());
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: false,
gitlab: false,
},
});
queryClient.invalidateQueries();
disconnectButton = await screen.findByTestId("disconnect-tokens-button");
await waitFor(() => expect(disconnectButton).toBeDisabled());
});
it("should call logout when pressing the disconnect tokens button", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const logoutSpy = vi.spyOn(OpenHands, "logout");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: true,
gitlab: false,
},
});
renderGitSettingsScreen();
const disconnectButton = await screen.findByTestId(
"disconnect-tokens-button",
);
await waitFor(() => expect(disconnectButton).not.toBeDisabled());
await userEvent.click(disconnectButton);
expect(logoutSpy).toHaveBeenCalled();
});
// flaky test
it.skip("should disable the button when submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
const githubInput = await screen.findByTestId("github-token-input");
await userEvent.type(githubInput, "test-token");
expect(submit).not.toBeDisabled();
// submit the form
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(submit).toHaveTextContent("Saving...");
expect(submit).toBeDisabled();
await waitFor(() => expect(submit).toHaveTextContent("Save"));
});
it("should disable the button after submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
await screen.findByTestId("git-settings-screen");
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
const githubInput = await screen.findByTestId("github-token-input");
await userEvent.type(githubInput, "test-token");
expect(submit).not.toBeDisabled();
// submit the form
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(submit).toBeDisabled();
const gitlabInput = await screen.findByTestId("gitlab-token-input");
await userEvent.type(gitlabInput, "test-token");
expect(gitlabInput).toHaveValue("test-token");
expect(submit).not.toBeDisabled();
// submit the form
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(submit).toBeDisabled());
});
});
describe("Status toasts", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
"displaySuccessToast",
);
renderGitSettingsScreen();
// Toggle setting to change
const githubInput = await screen.findByTestId("github-token-input");
await userEvent.type(githubInput, "test-token");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
renderGitSettingsScreen();
// Toggle setting to change
const gitlabInput = await screen.findByTestId("gitlab-token-input");
await userEvent.type(gitlabInput, "test-token");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(displayErrorToastSpy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,674 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import LlmSettingsScreen from "#/routes/llm-settings";
import OpenHands from "#/api/open-hands";
import {
MOCK_DEFAULT_USER_SETTINGS,
resetTestHandlersMockSettings,
} from "#/mocks/handlers";
import { AuthProvider } from "#/context/auth-context";
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
const renderLlmSettingsScreen = () =>
render(<LlmSettingsScreen />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
),
});
beforeEach(() => {
vi.resetAllMocks();
resetTestHandlersMockSettings();
});
describe("Content", () => {
describe("Basic form", () => {
it("should render the basic form by default", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const basicFom = screen.getByTestId("llm-settings-form-basic");
within(basicFom).getByTestId("llm-provider-input");
within(basicFom).getByTestId("llm-model-input");
within(basicFom).getByTestId("llm-api-key-input");
within(basicFom).getByTestId("llm-api-key-help-anchor");
});
it("should render the default values if non exist", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const provider = screen.getByTestId("llm-provider-input");
const model = screen.getByTestId("llm-model-input");
const apiKey = screen.getByTestId("llm-api-key-input");
await waitFor(() => {
expect(provider).toHaveValue("Anthropic");
expect(model).toHaveValue("claude-3-5-sonnet-20241022");
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "");
});
});
it("should render the existing settings values", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
llm_api_key_set: true,
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const provider = screen.getByTestId("llm-provider-input");
const model = screen.getByTestId("llm-model-input");
const apiKey = screen.getByTestId("llm-api-key-input");
await waitFor(() => {
expect(provider).toHaveValue("OpenAI");
expect(model).toHaveValue("gpt-4o");
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "<hidden>");
expect(screen.getByTestId("set-indicator")).toBeInTheDocument();
});
});
});
describe("Advanced form", () => {
it("should render the advanced form if the switch is toggled", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
const basicForm = screen.getByTestId("llm-settings-form-basic");
expect(
screen.queryByTestId("llm-settings-form-advanced"),
).not.toBeInTheDocument();
expect(basicForm).toBeInTheDocument();
await userEvent.click(advancedSwitch);
expect(
screen.queryByTestId("llm-settings-form-advanced"),
).toBeInTheDocument();
expect(basicForm).not.toBeInTheDocument();
const advancedForm = screen.getByTestId("llm-settings-form-advanced");
within(advancedForm).getByTestId("llm-custom-model-input");
within(advancedForm).getByTestId("base-url-input");
within(advancedForm).getByTestId("llm-api-key-input");
within(advancedForm).getByTestId("llm-api-key-help-anchor");
within(advancedForm).getByTestId("agent-input");
within(advancedForm).getByTestId("enable-confirmation-mode-switch");
within(advancedForm).getByTestId("enable-memory-condenser-switch");
await userEvent.click(advancedSwitch);
expect(
screen.queryByTestId("llm-settings-form-advanced"),
).not.toBeInTheDocument();
expect(screen.getByTestId("llm-settings-form-basic")).toBeInTheDocument();
});
it("should render the default advanced settings", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
expect(advancedSwitch).not.toBeChecked();
await userEvent.click(advancedSwitch);
const model = screen.getByTestId("llm-custom-model-input");
const baseUrl = screen.getByTestId("base-url-input");
const apiKey = screen.getByTestId("llm-api-key-input");
const agent = screen.getByTestId("agent-input");
const confirmation = screen.getByTestId(
"enable-confirmation-mode-switch",
);
const condensor = screen.getByTestId("enable-memory-condenser-switch");
expect(model).toHaveValue("anthropic/claude-3-5-sonnet-20241022");
expect(baseUrl).toHaveValue("");
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "");
expect(agent).toHaveValue("CodeActAgent");
expect(confirmation).not.toBeChecked();
expect(condensor).toBeChecked();
// check that security analyzer is present
expect(
screen.queryByTestId("security-analyzer-input"),
).not.toBeInTheDocument();
await userEvent.click(confirmation);
screen.getByTestId("security-analyzer-input");
});
it("should render the advanced form if existings settings are advanced", async () => {
const hasAdvancedSettingsSetSpy = vi.spyOn(
AdvancedSettingsUtlls,
"hasAdvancedSettingsSet",
);
hasAdvancedSettingsSetSpy.mockReturnValue(true);
renderLlmSettingsScreen();
await waitFor(() => {
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
expect(advancedSwitch).toBeChecked();
screen.getByTestId("llm-settings-form-advanced");
});
});
it("should render existing advanced settings correctly", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
llm_base_url: "https://api.openai.com/v1/chat/completions",
llm_api_key_set: true,
agent: "CoActAgent",
confirmation_mode: true,
enable_default_condenser: false,
security_analyzer: "mock-invariant",
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const model = screen.getByTestId("llm-custom-model-input");
const baseUrl = screen.getByTestId("base-url-input");
const apiKey = screen.getByTestId("llm-api-key-input");
const agent = screen.getByTestId("agent-input");
const confirmation = screen.getByTestId(
"enable-confirmation-mode-switch",
);
const condensor = screen.getByTestId("enable-memory-condenser-switch");
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
await waitFor(() => {
expect(model).toHaveValue("openai/gpt-4o");
expect(baseUrl).toHaveValue(
"https://api.openai.com/v1/chat/completions",
);
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "<hidden>");
expect(agent).toHaveValue("CoActAgent");
expect(confirmation).toBeChecked();
expect(condensor).not.toBeChecked();
expect(securityAnalyzer).toHaveValue("mock-invariant");
});
});
});
it.todo("should render an indicator if the llm api key is set");
});
describe("Form submission", () => {
it("should submit the basic form with the correct values", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const provider = screen.getByTestId("llm-provider-input");
const model = screen.getByTestId("llm-model-input");
const apiKey = screen.getByTestId("llm-api-key-input");
// select provider
await userEvent.click(provider);
const providerOption = screen.getByText("OpenAI");
await userEvent.click(providerOption);
expect(provider).toHaveValue("OpenAI");
// enter api key
await userEvent.type(apiKey, "test-api-key");
// select model
await userEvent.click(model);
const modelOption = screen.getByText("gpt-4o");
await userEvent.click(modelOption);
expect(model).toHaveValue("gpt-4o");
const submitButton = screen.getByTestId("submit-button");
await userEvent.click(submitButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_model: "openai/gpt-4o",
llm_api_key: "test-api-key",
}),
);
});
it("should submit the advanced form with the correct values", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
const model = screen.getByTestId("llm-custom-model-input");
const baseUrl = screen.getByTestId("base-url-input");
const apiKey = screen.getByTestId("llm-api-key-input");
const agent = screen.getByTestId("agent-input");
const confirmation = screen.getByTestId("enable-confirmation-mode-switch");
const condensor = screen.getByTestId("enable-memory-condenser-switch");
// enter custom model
await userEvent.clear(model);
await userEvent.type(model, "openai/gpt-4o");
expect(model).toHaveValue("openai/gpt-4o");
// enter base url
await userEvent.type(baseUrl, "https://api.openai.com/v1/chat/completions");
expect(baseUrl).toHaveValue("https://api.openai.com/v1/chat/completions");
// enter api key
await userEvent.type(apiKey, "test-api-key");
// toggle confirmation mode
await userEvent.click(confirmation);
expect(confirmation).toBeChecked();
// toggle memory condensor
await userEvent.click(condensor);
expect(condensor).not.toBeChecked();
// select agent
await userEvent.click(agent);
const agentOption = screen.getByText("CoActAgent");
await userEvent.click(agentOption);
expect(agent).toHaveValue("CoActAgent");
// select security analyzer
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
await userEvent.click(securityAnalyzer);
const securityAnalyzerOption = screen.getByText("mock-invariant");
await userEvent.click(securityAnalyzerOption);
const submitButton = screen.getByTestId("submit-button");
await userEvent.click(submitButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_model: "openai/gpt-4o",
llm_base_url: "https://api.openai.com/v1/chat/completions",
agent: "CoActAgent",
confirmation_mode: true,
enable_default_condenser: false,
security_analyzer: "mock-invariant",
}),
);
});
it("should disable the button if there are no changes in the basic form", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
llm_api_key_set: true,
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
screen.getByTestId("llm-settings-form-basic");
const submitButton = screen.getByTestId("submit-button");
expect(submitButton).toBeDisabled();
const model = screen.getByTestId("llm-model-input");
const apiKey = screen.getByTestId("llm-api-key-input");
// select model
await userEvent.click(model);
const modelOption = screen.getByText("gpt-4o-mini");
await userEvent.click(modelOption);
expect(model).toHaveValue("gpt-4o-mini");
expect(submitButton).not.toBeDisabled();
// reset model
await userEvent.click(model);
const modelOption2 = screen.getByText("gpt-4o");
await userEvent.click(modelOption2);
expect(model).toHaveValue("gpt-4o");
expect(submitButton).toBeDisabled();
// set api key
await userEvent.type(apiKey, "test-api-key");
expect(apiKey).toHaveValue("test-api-key");
expect(submitButton).not.toBeDisabled();
// reset api key
await userEvent.clear(apiKey);
expect(apiKey).toHaveValue("");
expect(submitButton).toBeDisabled();
});
it("should disable the button if there are no changes in the advanced form", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
llm_base_url: "https://api.openai.com/v1/chat/completions",
llm_api_key_set: true,
confirmation_mode: true,
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
screen.getByTestId("llm-settings-form-advanced");
const submitButton = screen.getByTestId("submit-button");
expect(submitButton).toBeDisabled();
const model = screen.getByTestId("llm-custom-model-input");
const baseUrl = screen.getByTestId("base-url-input");
const apiKey = screen.getByTestId("llm-api-key-input");
const agent = screen.getByTestId("agent-input");
const confirmation = screen.getByTestId("enable-confirmation-mode-switch");
const condensor = screen.getByTestId("enable-memory-condenser-switch");
// enter custom model
await userEvent.type(model, "-mini");
expect(model).toHaveValue("openai/gpt-4o-mini");
expect(submitButton).not.toBeDisabled();
// reset model
await userEvent.clear(model);
expect(model).toHaveValue("");
expect(submitButton).toBeDisabled();
await userEvent.type(model, "openai/gpt-4o");
expect(model).toHaveValue("openai/gpt-4o");
expect(submitButton).toBeDisabled();
// enter base url
await userEvent.type(baseUrl, "/extra");
expect(baseUrl).toHaveValue(
"https://api.openai.com/v1/chat/completions/extra",
);
expect(submitButton).not.toBeDisabled();
await userEvent.clear(baseUrl);
expect(baseUrl).toHaveValue("");
expect(submitButton).not.toBeDisabled();
await userEvent.type(baseUrl, "https://api.openai.com/v1/chat/completions");
expect(baseUrl).toHaveValue("https://api.openai.com/v1/chat/completions");
expect(submitButton).toBeDisabled();
// set api key
await userEvent.type(apiKey, "test-api-key");
expect(apiKey).toHaveValue("test-api-key");
expect(submitButton).not.toBeDisabled();
// reset api key
await userEvent.clear(apiKey);
expect(apiKey).toHaveValue("");
expect(submitButton).toBeDisabled();
// set agent
await userEvent.clear(agent);
await userEvent.type(agent, "test-agent");
expect(agent).toHaveValue("test-agent");
expect(submitButton).not.toBeDisabled();
// reset agent
await userEvent.clear(agent);
expect(agent).toHaveValue("");
expect(submitButton).toBeDisabled();
await userEvent.type(agent, "CodeActAgent");
expect(agent).toHaveValue("CodeActAgent");
expect(submitButton).toBeDisabled();
// toggle confirmation mode
await userEvent.click(confirmation);
expect(confirmation).not.toBeChecked();
expect(submitButton).not.toBeDisabled();
await userEvent.click(confirmation);
expect(confirmation).toBeChecked();
expect(submitButton).toBeDisabled();
// toggle memory condensor
await userEvent.click(condensor);
expect(condensor).not.toBeChecked();
expect(submitButton).not.toBeDisabled();
await userEvent.click(condensor);
expect(condensor).toBeChecked();
expect(submitButton).toBeDisabled();
// select security analyzer
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
await userEvent.click(securityAnalyzer);
const securityAnalyzerOption = screen.getByText("mock-invariant");
await userEvent.click(securityAnalyzerOption);
expect(securityAnalyzer).toHaveValue("mock-invariant");
expect(submitButton).not.toBeDisabled();
await userEvent.clear(securityAnalyzer);
expect(securityAnalyzer).toHaveValue("");
expect(submitButton).toBeDisabled();
});
it("should reset button state when switching between forms", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
const submitButton = screen.getByTestId("submit-button");
expect(submitButton).toBeDisabled();
// dirty the basic form
const apiKey = screen.getByTestId("llm-api-key-input");
await userEvent.type(apiKey, "test-api-key");
expect(submitButton).not.toBeDisabled();
await userEvent.click(advancedSwitch);
expect(submitButton).toBeDisabled();
// dirty the advanced form
const model = screen.getByTestId("llm-custom-model-input");
await userEvent.type(model, "openai/gpt-4o");
expect(submitButton).not.toBeDisabled();
await userEvent.click(advancedSwitch);
expect(submitButton).toBeDisabled();
});
// flaky test
it.skip("should disable the button when submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const apiKey = screen.getByTestId("llm-api-key-input");
await userEvent.type(apiKey, "test-api-key");
const submitButton = screen.getByTestId("submit-button");
await userEvent.click(submitButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_api_key: "test-api-key",
}),
);
expect(submitButton).toHaveTextContent("Saving...");
expect(submitButton).toBeDisabled();
await waitFor(() => {
expect(submitButton).toHaveTextContent("Save");
expect(submitButton).toBeDisabled();
});
});
});
describe("Status toasts", () => {
describe("Basic form", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
"displaySuccessToast",
);
renderLlmSettingsScreen();
// Toggle setting to change
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
await userEvent.type(apiKeyInput, "test-api-key");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
renderLlmSettingsScreen();
// Toggle setting to change
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
await userEvent.type(apiKeyInput, "test-api-key");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(displayErrorToastSpy).toHaveBeenCalled();
});
});
describe("Advanced form", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
"displaySuccessToast",
);
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
await screen.findByTestId("llm-settings-form-advanced");
// Toggle setting to change
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
await userEvent.type(apiKeyInput, "test-api-key");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
await screen.findByTestId("llm-settings-form-advanced");
// Toggle setting to change
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
await userEvent.type(apiKeyInput, "test-api-key");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(displayErrorToastSpy).toHaveBeenCalled();
});
});
});
describe("SaaS mode", () => {
it("should not render the runtime settings input in oss mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return mode
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
await screen.findByTestId("llm-settings-form-advanced");
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
expect(runtimeSettingsInput).not.toBeInTheDocument();
});
it("should render the runtime settings input in saas mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return mode
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
await screen.findByTestId("llm-settings-form-advanced");
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
expect(runtimeSettingsInput).toBeInTheDocument();
});
it("should always render the runtime settings input as disabled", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return mode
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
await screen.findByTestId("llm-settings-form-advanced");
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
expect(runtimeSettingsInput).toBeInTheDocument();
expect(runtimeSettingsInput).toBeDisabled();
});
});

View File

@@ -1,4 +1,4 @@
import { screen, waitFor, within } from "@testing-library/react";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRoutesStub } from "react-router";
@@ -7,6 +7,30 @@ import OpenHands from "#/api/open-hands";
import SettingsScreen from "#/routes/settings";
import { PaymentForm } from "#/components/features/payment/payment-form";
// Mock the i18next hook
vi.mock("react-i18next", async () => {
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"SETTINGS$NAV_GIT": "Git",
"SETTINGS$NAV_APPLICATION": "Application",
"SETTINGS$NAV_CREDITS": "Credits",
"SETTINGS$NAV_API_KEYS": "API Keys",
"SETTINGS$NAV_LLM": "LLM",
"SETTINGS$TITLE": "Settings"
};
return translations[key] || key;
},
i18n: {
changeLanguage: vi.fn(),
},
}),
};
});
describe("Settings Billing", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
@@ -19,18 +43,22 @@ describe("Settings Billing", () => {
Component: () => <PaymentForm />,
path: "/settings/billing",
},
{
Component: () => <div data-testid="git-settings-screen" />,
path: "/settings/git",
},
],
},
]);
const renderSettingsScreen = () =>
renderWithProviders(<RoutesStub initialEntries={["/settings"]} />);
renderWithProviders(<RoutesStub initialEntries={["/settings/billing"]} />);
afterEach(() => {
vi.clearAllMocks();
});
it("should not render the navbar if OSS mode", async () => {
it("should not render the credits tab if OSS mode", async () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
GITHUB_CLIENT_ID: "123",
@@ -43,15 +71,12 @@ describe("Settings Billing", () => {
renderSettingsScreen();
// Wait for the settings screen to be rendered
await screen.findByTestId("settings-screen");
// Then check that the navbar is not present
const navbar = screen.queryByTestId("settings-navbar");
expect(navbar).not.toBeInTheDocument();
const navbar = await screen.findByTestId("settings-navbar");
const credits = within(navbar).queryByText("Credits");
expect(credits).not.toBeInTheDocument();
});
it("should render the navbar if SaaS mode", async () => {
it("should render the credits tab if SaaS mode and billing is enabled", async () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
@@ -64,11 +89,8 @@ describe("Settings Billing", () => {
renderSettingsScreen();
await waitFor(() => {
const navbar = screen.getByTestId("settings-navbar");
within(navbar).getByText("Account");
within(navbar).getByText("Credits");
});
const navbar = await screen.findByTestId("settings-navbar");
within(navbar).getByText("Credits");
});
it("should render the billing settings if clicking the credits item", async () => {
@@ -90,6 +112,6 @@ describe("Settings Billing", () => {
await user.click(credits);
const billingSection = await screen.findByTestId("billing-settings");
within(billingSection).getByText("PAYMENT$MANAGE_CREDITS");
expect(billingSection).toBeInTheDocument();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,10 @@ describe("hasAdvancedSettingsSet", () => {
expect(hasAdvancedSettingsSet(DEFAULT_SETTINGS)).toBe(false);
});
it("should return false if an empty object", () => {
expect(hasAdvancedSettingsSet({})).toBe(false);
});
describe("should be true if", () => {
test("LLM_BASE_URL is set", () => {
expect(
@@ -26,15 +30,6 @@ describe("hasAdvancedSettingsSet", () => {
).toBe(true);
});
test("REMOTE_RUNTIME_RESOURCE_FACTOR is not default value", () => {
expect(
hasAdvancedSettingsSet({
...DEFAULT_SETTINGS,
REMOTE_RUNTIME_RESOURCE_FACTOR: 999,
}),
).toBe(true);
});
test("CONFIRMATION_MODE is true", () => {
expect(
hasAdvancedSettingsSet({

View File

@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.35.2",
"version": "0.34.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.35.2",
"version": "0.34.0",
"dependencies": {
"@heroui/react": "2.7.6",
"@microlink/react-json-view": "^1.26.1",

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.35.2",
"version": "0.34.0",
"private": true,
"type": "module",
"engines": {

View File

@@ -107,6 +107,10 @@ function isRawTranslationKey(str) {
const EXCLUDED_TECHNICAL_STRINGS = [
"openid email profile", // OAuth scope string - not user-facing
"OPEN_ISSUE", // Task type identifier, not a UI string
"Merge Request", // Git provider specific terminology
"GitLab API", // Git provider specific terminology
"Pull Request", // Git provider specific terminology
"GitHub API", // Git provider specific terminology
];
function isExcludedTechnicalString(str) {

View File

@@ -1,4 +1,4 @@
import { DiffEditor } from "@monaco-editor/react";
import { DiffEditor, Monaco } from "@monaco-editor/react";
import React from "react";
import { editor as editor_t } from "monaco-editor";
import { LuFileDiff, LuFileMinus, LuFilePlus } from "react-icons/lu";
@@ -88,6 +88,29 @@ export function FileDiffViewer({ path, type }: FileDiffViewerProps) {
}
}, []);
const beforeMount = (monaco: Monaco) => {
monaco.editor.defineTheme("custom-diff-theme", {
base: "vs-dark",
inherit: true,
rules: [
{ token: "comment", foreground: "6a9955" },
{ token: "keyword", foreground: "569cd6" },
{ token: "string", foreground: "ce9178" },
{ token: "number", foreground: "b5cea8" },
],
colors: {
"diffEditor.insertedTextBackground": "#014b01AA", // Stronger green background
"diffEditor.removedTextBackground": "#750000AA", // Stronger red background
"diffEditor.insertedLineBackground": "#003f00AA", // Dark green for added lines
"diffEditor.removedLineBackground": "#5a0000AA", // Dark red for removed lines
"diffEditor.border": "#444444", // Border between diff editors
"editorUnnecessaryCode.border": "#00000000", // No border for unnecessary code
"editorUnnecessaryCode.opacity": "#00000077", // Slightly faded
},
});
};
const handleEditorDidMount = (editor: editor_t.IStandaloneDiffEditor) => {
diffEditorRef.current = editor;
updateEditorHeight();
@@ -145,8 +168,9 @@ export function FileDiffViewer({ path, type }: FileDiffViewerProps) {
language={getLanguageFromPath(filePath)}
original={isAdded ? "" : diff.original}
modified={isDeleted ? "" : diff.modified}
theme="vs-dark"
theme="custom-diff-theme"
onMount={handleEditorDidMount}
beforeMount={beforeMount}
options={{
renderValidationDecorations: "off",
readOnly: true,

View File

@@ -0,0 +1,15 @@
import { InputSkeleton } from "../input-skeleton";
import { SwitchSkeleton } from "../switch-skeleton";
export function AppSettingsInputsSkeleton() {
return (
<div
data-testid="app-settings-skeleton"
className="px-11 py-9 flex flex-col gap-6"
>
<InputSkeleton />
<SwitchSkeleton />
<SwitchSkeleton />
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { useTranslation } from "react-i18next";
import { AvailableLanguages } from "#/i18n";
import { I18nKey } from "#/i18n/declaration";
import { SettingsDropdownInput } from "../settings-dropdown-input";
interface LanguageInputProps {
name: string;
onChange: (value: string) => void;
defaultKey: string;
}
export function LanguageInput({
defaultKey,
onChange,
name,
}: LanguageInputProps) {
const { t } = useTranslation();
return (
<SettingsDropdownInput
testId={name}
name={name}
onInputChange={onChange}
label={t(I18nKey.SETTINGS$LANGUAGE)}
items={AvailableLanguages.map((l) => ({
key: l.value,
label: l.label,
}))}
defaultSelectedKey={defaultKey}
isClearable={false}
wrapperClassName="w-[680px]"
/>
);
}

View File

@@ -2,6 +2,7 @@ import { cn } from "#/utils/utils";
interface BrandButtonProps {
testId?: string;
name?: string;
variant: "primary" | "secondary" | "danger";
type: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
isDisabled?: boolean;
@@ -12,6 +13,7 @@ interface BrandButtonProps {
export function BrandButton({
testId,
name,
children,
variant,
type,
@@ -22,6 +24,7 @@ export function BrandButton({
}: React.PropsWithChildren<BrandButtonProps>) {
return (
<button
name={name}
data-testid={testId}
disabled={isDisabled}
// The type is alreadt passed as a prop to the button component

View File

@@ -0,0 +1,27 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../brand-button";
interface ConfigureGitHubRepositoriesAnchorProps {
slug: string;
}
export function ConfigureGitHubRepositoriesAnchor({
slug,
}: ConfigureGitHubRepositoriesAnchorProps) {
const { t } = useTranslation();
return (
<a
data-testid="configure-github-repositories-button"
href={`https://github.com/apps/${slug}/installations/new`}
target="_blank"
rel="noreferrer noopener"
className="px-11 py-9"
>
<BrandButton type="button" variant="secondary">
{t(I18nKey.GITHUB$CONFIGURE_REPOS)}
</BrandButton>
</a>
);
}

View File

@@ -0,0 +1,18 @@
import { InputSkeleton } from "../input-skeleton";
import { SubtextSkeleton } from "../subtext-skeleton";
export function GitSettingInputsSkeleton() {
return (
<div className="px-11 py-9 flex flex-col gap-12">
<div className="flex flex-col gap-6">
<InputSkeleton />
<SubtextSkeleton />
</div>
<div className="flex flex-col gap-6">
<InputSkeleton />
<SubtextSkeleton />
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { Trans } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function GitHubTokenHelpAnchor() {
return (
<p data-testid="github-token-help-anchor" className="text-xs">
<Trans
i18nKey={I18nKey.GITHUB$TOKEN_HELP_TEXT}
components={[
<a
key="github-token-help-anchor-link"
aria-label="GitHub token help link"
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
<a
key="github-token-help-anchor-link-2"
aria-label="GitHub token see more link"
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
]}
/>
</p>
);
}

View File

@@ -0,0 +1,60 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "../settings-input";
import { GitHubTokenHelpAnchor } from "./github-token-help-anchor";
import { KeyStatusIcon } from "../key-status-icon";
interface GitHubTokenInputProps {
onChange: (value: string) => void;
onBaseDomainChange?: (value: string) => void;
isGitHubTokenSet: boolean;
name: string;
baseDomainSet?: string | null;
isSaas: boolean;
}
export function GitHubTokenInput({
onChange,
onBaseDomainChange,
isGitHubTokenSet,
name,
baseDomainSet,
isSaas,
}: GitHubTokenInputProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-6">
{!isSaas && (
<SettingsInput
testId={name}
name={name}
onChange={onChange}
label={t(I18nKey.GITHUB$TOKEN_LABEL)}
type="password"
className="w-[680px]"
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
startContent={
isGitHubTokenSet && (
<KeyStatusIcon
testId="gh-set-token-indicator"
isSet={isGitHubTokenSet}
/>
)
}
/>
)}
<SettingsInput
onChange={onBaseDomainChange || (() => {})}
label={t(I18nKey.GITHUB$BASE_DOMAIN_LABEL)}
type="text"
className="w-[680px]"
placeholder={"github.com"}
defaultValue={baseDomainSet ? baseDomainSet : undefined}
/>
{!isSaas && <GitHubTokenHelpAnchor />}
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { Trans } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function GitLabTokenHelpAnchor() {
return (
<p data-testid="gitlab-token-help-anchor" className="text-xs">
<Trans
i18nKey={I18nKey.GITLAB$TOKEN_HELP_TEXT}
components={[
<a
key="gitlab-token-help-anchor-link"
aria-label="Gitlab token help link"
href="https://gitlab.com/-/user_settings/personal_access_tokens?name=openhands-app&scopes=api,read_user,read_repository,write_repository"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
<a
key="gitlab-token-help-anchor-link-2"
aria-label="GitLab token see more link"
href="https://docs.gitlab.com/user/profile/personal_access_tokens/"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
]}
/>
</p>
);
}

View File

@@ -0,0 +1,60 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "../settings-input";
import { GitLabTokenHelpAnchor } from "./gitlab-token-help-anchor";
import { KeyStatusIcon } from "../key-status-icon";
interface GitLabTokenInputProps {
onChange: (value: string) => void;
onBaseDomainChange?: (value: string) => void;
isGitLabTokenSet: boolean;
name: string;
baseDomainSet?: string | null;
isSaas: boolean;
}
export function GitLabTokenInput({
onChange,
onBaseDomainChange,
isGitLabTokenSet,
name,
baseDomainSet,
isSaas,
}: GitLabTokenInputProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-6">
{!isSaas && (
<SettingsInput
testId={name}
name={name}
onChange={onChange}
label={t(I18nKey.GITLAB$TOKEN_LABEL)}
type="password"
className="w-[680px]"
placeholder={isGitLabTokenSet ? "<hidden>" : ""}
startContent={
isGitLabTokenSet && (
<KeyStatusIcon
testId="gl-set-token-indicator"
isSet={isGitLabTokenSet}
/>
)
}
/>
)}
<SettingsInput
onChange={onBaseDomainChange || (() => {})}
label={t(I18nKey.GITLAB$BASE_DOMAIN_LABEL)}
type="text"
className="w-[680px]"
placeholder={"gitlab.com"}
defaultValue={baseDomainSet ? baseDomainSet : undefined}
/>
{!isSaas && <GitLabTokenHelpAnchor />}
</div>
);
}

View File

@@ -0,0 +1,8 @@
export function InputSkeleton() {
return (
<div className="flex flex-col gap-2.5">
<div className="w-[70px] h-[20px] skeleton" />
<div className="w-[680px] h-[40px] skeleton" />
</div>
);
}

View File

@@ -2,12 +2,13 @@ import SuccessIcon from "#/icons/success.svg?react";
import { cn } from "#/utils/utils";
interface KeyStatusIconProps {
testId?: string;
isSet: boolean;
}
export function KeyStatusIcon({ isSet }: KeyStatusIconProps) {
export function KeyStatusIcon({ testId, isSet }: KeyStatusIconProps) {
return (
<span data-testid={isSet ? "set-indicator" : "unset-indicator"}>
<span data-testid={testId || (isSet ? "set-indicator" : "unset-indicator")}>
<SuccessIcon className={cn(isSet ? "text-success" : "text-danger")} />
</span>
);

View File

@@ -0,0 +1,21 @@
import { InputSkeleton } from "../input-skeleton";
import { SubtextSkeleton } from "../subtext-skeleton";
import { SwitchSkeleton } from "../switch-skeleton";
export function LlmSettingsInputsSkeleton() {
return (
<div
data-testid="app-settings-skeleton"
className="px-11 py-9 flex flex-col gap-6"
>
<SwitchSkeleton />
<InputSkeleton />
<InputSkeleton />
<InputSkeleton />
<SubtextSkeleton />
<SwitchSkeleton />
<SwitchSkeleton />
<InputSkeleton />
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { useTranslation } from "react-i18next";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../brand-button";
interface ResetSettingsModalProps {
onReset: () => void;
}
export function ResetSettingsModal({ onReset }: ResetSettingsModalProps) {
const { t } = useTranslation();
return (
<ModalBackdrop>
<div className="bg-base-secondary p-4 rounded-xl flex flex-col gap-4 border border-tertiary">
<p>{t(I18nKey.SETTINGS$RESET_CONFIRMATION)}</p>
<div className="w-full flex gap-2" data-testid="reset-settings-modal">
<BrandButton
testId="confirm-button"
type="submit"
name="reset-settings"
variant="primary"
className="grow"
>
Reset
</BrandButton>
<BrandButton
testId="cancel-button"
type="button"
variant="secondary"
className="grow"
onClick={onReset}
>
Cancel
</BrandButton>
</div>
</div>
</ModalBackdrop>
);
}

View File

@@ -6,6 +6,7 @@ interface SettingsSwitchProps {
name?: string;
onToggle?: (value: boolean) => void;
defaultIsToggled?: boolean;
isToggled?: boolean;
isBeta?: boolean;
}
@@ -15,6 +16,7 @@ export function SettingsSwitch({
name,
onToggle,
defaultIsToggled,
isToggled: controlledIsToggled,
isBeta,
}: React.PropsWithChildren<SettingsSwitchProps>) {
const [isToggled, setIsToggled] = React.useState(defaultIsToggled ?? false);
@@ -25,17 +27,18 @@ export function SettingsSwitch({
};
return (
<label className="flex items-center gap-2 w-fit">
<label className="flex items-center gap-2 w-fit cursor-pointer">
<input
hidden
data-testid={testId}
name={name}
type="checkbox"
onChange={(e) => handleToggle(e.target.checked)}
checked={controlledIsToggled ?? isToggled}
defaultChecked={defaultIsToggled}
/>
<StyledSwitchComponent isToggled={isToggled} />
<StyledSwitchComponent isToggled={controlledIsToggled ?? isToggled} />
<div className="flex items-center gap-1">
<span className="text-sm">{children}</span>

View File

@@ -0,0 +1,3 @@
export function SubtextSkeleton() {
return <div className="w-[250px] h-[20px] skeleton" />;
}

View File

@@ -0,0 +1,8 @@
export function SwitchSkeleton() {
return (
<div className="flex items-center gap-2">
<div className="w-[48px] h-[24px] skeleton-round" />
<div className="w-[100px] h-[20px] skeleton" />
</div>
);
}

View File

@@ -14,12 +14,14 @@ interface ModelSelectorProps {
isDisabled?: boolean;
models: Record<string, { separator: string; models: string[] }>;
currentModel?: string;
onChange?: (model: string | null) => void;
}
export function ModelSelector({
isDisabled,
models,
currentModel,
onChange,
}: ModelSelectorProps) {
const [, setLitellmId] = React.useState<string | null>(null);
const [selectedProvider, setSelectedProvider] = React.useState<string | null>(
@@ -55,6 +57,7 @@ export function ModelSelector({
}
setLitellmId(fullModel);
setSelectedModel(model);
onChange?.(fullModel);
};
const clear = () => {

View File

@@ -4,8 +4,9 @@ import posthog from "posthog-js";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { Settings } from "#/types/settings";
const getSettingsQueryFn = async () => {
const getSettingsQueryFn = async (): Promise<Settings> => {
const apiSettings = await OpenHands.getSettings();
return {
@@ -53,13 +54,11 @@ export const useSettings = () => {
React.useEffect(() => {
if (query.data?.PROVIDER_TOKENS_SET) {
const providers = query.data.PROVIDER_TOKENS_SET;
const setProviders = (
Object.keys(providers) as Array<keyof typeof providers>
).filter((key) => providers[key]);
const setProviders = Object.keys(providers) as Array<
keyof typeof providers
>;
setProviderTokensSet(setProviders);
const atLeastOneSet = Object.values(query.data.PROVIDER_TOKENS_SET).some(
(value) => value,
);
const atLeastOneSet = setProviders.length > 0;
setProvidersAreSet(atLeastOneSet);
}
}, [query.data?.PROVIDER_TOKENS_SET, query.isFetched]);

View File

@@ -26,6 +26,17 @@ export enum I18nKey {
ANALYTICS$DESCRIPTION = "ANALYTICS$DESCRIPTION",
ANALYTICS$SEND_ANONYMOUS_DATA = "ANALYTICS$SEND_ANONYMOUS_DATA",
ANALYTICS$CONFIRM_PREFERENCES = "ANALYTICS$CONFIRM_PREFERENCES",
SETTINGS$SAVING = "SETTINGS$SAVING",
SETTINGS$SAVE_CHANGES = "SETTINGS$SAVE_CHANGES",
SETTINGS$NAV_GIT = "SETTINGS$NAV_GIT",
SETTINGS$NAV_APPLICATION = "SETTINGS$NAV_APPLICATION",
SETTINGS$NAV_CREDITS = "SETTINGS$NAV_CREDITS",
SETTINGS$NAV_API_KEYS = "SETTINGS$NAV_API_KEYS",
SETTINGS$NAV_LLM = "SETTINGS$NAV_LLM",
GIT$MERGE_REQUEST = "GIT$MERGE_REQUEST",
GIT$GITLAB_API = "GIT$GITLAB_API",
GIT$PULL_REQUEST = "GIT$PULL_REQUEST",
GIT$GITHUB_API = "GIT$GITHUB_API",
BUTTON$COPY = "BUTTON$COPY",
BUTTON$COPIED = "BUTTON$COPIED",
APP$TITLE = "APP$TITLE",
@@ -93,8 +104,12 @@ export enum I18nKey {
EXIT_PROJECT$TITLE = "EXIT_PROJECT$TITLE",
LANGUAGE$LABEL = "LANGUAGE$LABEL",
GITHUB$TOKEN_LABEL = "GITHUB$TOKEN_LABEL",
GITHUB$BASE_DOMAIN_LABEL = "GITHUB$BASE_DOMAIN_LABEL",
GITHUB$TOKEN_OPTIONAL = "GITHUB$TOKEN_OPTIONAL",
GITHUB$GET_TOKEN = "GITHUB$GET_TOKEN",
GITHUB$TOKEN_HELP_TEXT = "GITHUB$TOKEN_HELP_TEXT",
GITHUB$TOKEN_LINK_TEXT = "GITHUB$TOKEN_LINK_TEXT",
GITHUB$INSTRUCTIONS_LINK_TEXT = "GITHUB$INSTRUCTIONS_LINK_TEXT",
COMMON$HERE = "COMMON$HERE",
ANALYTICS$ENABLE = "ANALYTICS$ENABLE",
GITHUB$TOKEN_INVALID = "GITHUB$TOKEN_INVALID",
@@ -436,7 +451,11 @@ export enum I18nKey {
MODEL_SELECTOR$VERIFIED = "MODEL_SELECTOR$VERIFIED",
MODEL_SELECTOR$OTHERS = "MODEL_SELECTOR$OTHERS",
GITLAB$TOKEN_LABEL = "GITLAB$TOKEN_LABEL",
GITLAB$BASE_DOMAIN_LABEL = "GITLAB$BASE_DOMAIN_LABEL",
GITLAB$GET_TOKEN = "GITLAB$GET_TOKEN",
GITLAB$TOKEN_HELP_TEXT = "GITLAB$TOKEN_HELP_TEXT",
GITLAB$TOKEN_LINK_TEXT = "GITLAB$TOKEN_LINK_TEXT",
GITLAB$INSTRUCTIONS_LINK_TEXT = "GITLAB$INSTRUCTIONS_LINK_TEXT",
GITLAB$OR_SEE = "GITLAB$OR_SEE",
COMMON$DOCUMENTATION = "COMMON$DOCUMENTATION",
DIFF_VIEWER$LOADING = "DIFF_VIEWER$LOADING",

View File

@@ -389,6 +389,171 @@
"tr": "Tercihleri Onayla",
"de": "Einstellungen bestätigen"
},
"SETTINGS$SAVING": {
"en": "Saving...",
"ja": "保存中...",
"zh-CN": "保存中...",
"zh-TW": "儲存中...",
"ko-KR": "저장 중...",
"no": "Lagrer...",
"it": "Salvataggio in corso...",
"pt": "Salvando...",
"es": "Guardando...",
"ar": "جار الحفظ...",
"fr": "Enregistrement en cours...",
"tr": "Kayıt yapılıyor...",
"de": "Speichern..."
},
"SETTINGS$SAVE_CHANGES": {
"en": "Save Changes",
"ja": "変更を保存",
"zh-CN": "保存更改",
"zh-TW": "儲存變更",
"ko-KR": "변경 사항 저장",
"no": "Lagre endringer",
"it": "Salva modifiche",
"pt": "Salvar alterações",
"es": "Guardar cambios",
"ar": "حفظ التغييرات",
"fr": "Enregistrer les modifications",
"tr": "Değişiklikleri Kaydet",
"de": "Änderungen speichern"
},
"SETTINGS$NAV_GIT": {
"en": "Git",
"ja": "Git",
"zh-CN": "Git",
"zh-TW": "Git",
"ko-KR": "Git",
"no": "Git",
"it": "Git",
"pt": "Git",
"es": "Git",
"ar": "Git",
"fr": "Git",
"tr": "Git",
"de": "Git"
},
"SETTINGS$NAV_APPLICATION": {
"en": "Application",
"ja": "アプリケーション",
"zh-CN": "应用程序",
"zh-TW": "應用程式",
"ko-KR": "애플리케이션",
"no": "Applikasjon",
"it": "Applicazione",
"pt": "Aplicação",
"es": "Aplicación",
"ar": "التطبيق",
"fr": "Application",
"tr": "Uygulama",
"de": "Anwendung"
},
"SETTINGS$NAV_CREDITS": {
"en": "Credits",
"ja": "クレジット",
"zh-CN": "积分",
"zh-TW": "點數",
"ko-KR": "크레딧",
"no": "Kreditter",
"it": "Crediti",
"pt": "Créditos",
"es": "Créditos",
"ar": "الرصيد",
"fr": "Crédits",
"tr": "Krediler",
"de": "Guthaben"
},
"SETTINGS$NAV_API_KEYS": {
"en": "API Keys",
"ja": "APIキー",
"zh-CN": "API密钥",
"zh-TW": "API金鑰",
"ko-KR": "API 키",
"no": "API-nøkler",
"it": "Chiavi API",
"pt": "Chaves de API",
"es": "Claves API",
"ar": "مفاتيح API",
"fr": "Clés API",
"tr": "API Anahtarları",
"de": "API-Schlüssel"
},
"SETTINGS$NAV_LLM": {
"en": "LLM",
"ja": "LLM",
"zh-CN": "LLM",
"zh-TW": "LLM",
"ko-KR": "LLM",
"no": "LLM",
"it": "LLM",
"pt": "LLM",
"es": "LLM",
"ar": "LLM",
"fr": "LLM",
"tr": "LLM",
"de": "LLM"
},
"GIT$MERGE_REQUEST": {
"en": "Merge Request",
"ja": "マージリクエスト",
"zh-CN": "合并请求",
"zh-TW": "合併請求",
"ko-KR": "머지 요청",
"no": "Fletteforespørsel",
"it": "Richiesta di fusione",
"pt": "Solicitação de mesclagem",
"es": "Solicitud de fusión",
"ar": "طلب الدمج",
"fr": "Demande de fusion",
"tr": "Birleştirme İsteği",
"de": "Merge-Anfrage"
},
"GIT$GITLAB_API": {
"en": "GitLab API",
"ja": "GitLab API",
"zh-CN": "GitLab API",
"zh-TW": "GitLab API",
"ko-KR": "GitLab API",
"no": "GitLab API",
"it": "API GitLab",
"pt": "API do GitLab",
"es": "API de GitLab",
"ar": "واجهة برمجة تطبيقات GitLab",
"fr": "API GitLab",
"tr": "GitLab API",
"de": "GitLab API"
},
"GIT$PULL_REQUEST": {
"en": "Pull Request",
"ja": "プルリクエスト",
"zh-CN": "拉取请求",
"zh-TW": "拉取請求",
"ko-KR": "풀 리퀘스트",
"no": "Trekkforespørsel",
"it": "Richiesta di pull",
"pt": "Solicitação de pull",
"es": "Solicitud de extracción",
"ar": "طلب السحب",
"fr": "Demande de tirage",
"tr": "Çekme İsteği",
"de": "Pull Request"
},
"GIT$GITHUB_API": {
"en": "GitHub API",
"ja": "GitHub API",
"zh-CN": "GitHub API",
"zh-TW": "GitHub API",
"ko-KR": "GitHub API",
"no": "GitHub API",
"it": "API GitHub",
"pt": "API do GitHub",
"es": "API de GitHub",
"ar": "واجهة برمجة تطبيقات GitHub",
"fr": "API GitHub",
"tr": "GitHub API",
"de": "GitHub API"
},
"BUTTON$COPY": {
"en": "Copy to clipboard",
"ja": "クリップボードにコピー",
@@ -1404,6 +1569,21 @@
"tr": "GitHub Jetonu",
"de": "GitHub-Token"
},
"GITHUB$BASE_DOMAIN_LABEL": {
"en": "GitHub Base Domain",
"ja": "GitHub ベースドメイン",
"zh-CN": "GitHub 基础域名",
"zh-TW": "GitHub 基礎網域",
"ko-KR": "GitHub 기본 도메인",
"no": "GitHub Base Domain",
"it": "Dominio Base GitHub",
"pt": "Domínio Base do GitHub",
"es": "Dominio Base de GitHub",
"ar": "نطاق GitHub الأساسي",
"fr": "Domaine de Base GitHub",
"tr": "GitHub Temel Alan Adı",
"de": "GitHub Basis-Domain"
},
"GITHUB$TOKEN_OPTIONAL": {
"en": "GitHub Token (Optional)",
"ja": "GitHubトークン任意",
@@ -1434,6 +1614,51 @@
"tr": "Jetonunuzu alın",
"de": "Token abrufen"
},
"GITHUB$TOKEN_HELP_TEXT": {
"en": "Get your <0>GitHub token</0> or <1>click here for instructions</1>",
"ja": "<0>GitHubトークン</0>を取得するか、<1>手順についてはここをクリック</1>",
"zh-CN": "获取您的<0>GitHub令牌</0>或<1>点击此处获取说明</1>",
"zh-TW": "取得您的<0>GitHub權杖</0>或<1>點擊此處獲取說明</1>",
"ko-KR": "<0>GitHub 토큰</0>을 받거나 <1>지침을 보려면 여기를 클릭</1>",
"no": "Få din <0>GitHub-token</0> eller <1>klikk her for instruksjoner</1>",
"it": "Ottieni il tuo <0>token GitHub</0> o <1>clicca qui per istruzioni</1>",
"pt": "Obtenha seu <0>token GitHub</0> ou <1>clique aqui para instruções</1>",
"es": "Obtenga su <0>token de GitHub</0> o <1>haga clic aquí para obtener instrucciones</1>",
"ar": "احصل على <0>رمز GitHub</0> الخاص بك أو <1>انقر هنا للحصول على تعليمات</1>",
"fr": "Obtenez votre <0>jeton GitHub</0> ou <1>cliquez ici pour les instructions</1>",
"tr": "<0>GitHub jetonu</0> alın veya <1>talimatlar için buraya tıklayın</1>",
"de": "Holen Sie sich Ihren <0>GitHub-Token</0> oder <1>klicken Sie hier für Anweisungen</1>"
},
"GITHUB$TOKEN_LINK_TEXT": {
"en": "GitHub token",
"ja": "GitHubトークン",
"zh-CN": "GitHub令牌",
"zh-TW": "GitHub權杖",
"ko-KR": "GitHub 토큰",
"no": "GitHub-token",
"it": "token GitHub",
"pt": "token GitHub",
"es": "token de GitHub",
"ar": "رمز GitHub",
"fr": "jeton GitHub",
"tr": "GitHub jetonu",
"de": "GitHub-Token"
},
"GITHUB$INSTRUCTIONS_LINK_TEXT": {
"en": "click here for instructions",
"ja": "手順についてはここをクリック",
"zh-CN": "点击此处获取说明",
"zh-TW": "點擊此處獲取說明",
"ko-KR": "지침을 보려면 여기를 클릭",
"no": "klikk her for instruksjoner",
"it": "clicca qui per istruzioni",
"pt": "clique aqui para instruções",
"es": "haga clic aquí para obtener instrucciones",
"ar": "انقر هنا للحصول على تعليمات",
"fr": "cliquez ici pour les instructions",
"tr": "talimatlar için buraya tıklayın",
"de": "klicken Sie hier für Anweisungen"
},
"COMMON$HERE": {
"en": "here",
"ja": "こちら",
@@ -6259,6 +6484,21 @@
"tr": "GitLab Jetonu",
"de": "GitLab-Token"
},
"GITLAB$BASE_DOMAIN_LABEL": {
"en": "GitLab Base Domain",
"ja": "GitLab ベースドメイン",
"zh-CN": "GitLab 基础域名",
"zh-TW": "GitLab 基礎網域",
"ko-KR": "GitLab 기본 도메인",
"no": "GitLab Base Domain",
"it": "Dominio Base GitLab",
"pt": "Domínio Base do GitLab",
"es": "Dominio Base de GitLab",
"ar": "نطاق GitLab الأساسي",
"fr": "Domaine de Base GitLab",
"tr": "GitLab Temel Alan Adı",
"de": "GitLab Basis-Domain"
},
"GITLAB$GET_TOKEN": {
"en": "Generate a token on",
"ja": "トークンを生成する",
@@ -6274,6 +6514,51 @@
"tr": "Üzerinde bir jeton oluştur",
"de": "Token generieren auf"
},
"GITLAB$TOKEN_HELP_TEXT": {
"en": "Get your <0>GitLab token</0> or <1>click here for instructions</1>",
"ja": "<0>GitLabトークン</0>を取得するか、<1>手順についてはここをクリック</1>",
"zh-CN": "获取您的<0>GitLab令牌</0>或<1>点击此处获取说明</1>",
"zh-TW": "取得您的<0>GitLab權杖</0>或<1>點擊此處獲取說明</1>",
"ko-KR": "<0>GitLab 토큰</0>을 받거나 <1>지침을 보려면 여기를 클릭</1>",
"no": "Få din <0>GitLab-token</0> eller <1>klikk her for instruksjoner</1>",
"it": "Ottieni il tuo <0>token GitLab</0> o <1>clicca qui per istruzioni</1>",
"pt": "Obtenha seu <0>token GitLab</0> ou <1>clique aqui para instruções</1>",
"es": "Obtenga su <0>token de GitLab</0> o <1>haga clic aquí para obtener instrucciones</1>",
"ar": "احصل على <0>رمز GitLab</0> الخاص بك أو <1>انقر هنا للحصول على تعليمات</1>",
"fr": "Obtenez votre <0>jeton GitLab</0> ou <1>cliquez ici pour les instructions</1>",
"tr": "<0>GitLab jetonu</0> alın veya <1>talimatlar için buraya tıklayın</1>",
"de": "Holen Sie sich Ihren <0>GitLab-Token</0> oder <1>klicken Sie hier für Anweisungen</1>"
},
"GITLAB$TOKEN_LINK_TEXT": {
"en": "GitLab token",
"ja": "GitLabトークン",
"zh-CN": "GitLab令牌",
"zh-TW": "GitLab權杖",
"ko-KR": "GitLab 토큰",
"no": "GitLab-token",
"it": "token GitLab",
"pt": "token GitLab",
"es": "token de GitLab",
"ar": "رمز GitLab",
"fr": "jeton GitLab",
"tr": "GitLab jetonu",
"de": "GitLab-Token"
},
"GITLAB$INSTRUCTIONS_LINK_TEXT": {
"en": "click here for instructions",
"ja": "手順についてはここをクリック",
"zh-CN": "点击此处获取说明",
"zh-TW": "點擊此處獲取說明",
"ko-KR": "지침을 보려면 여기를 클릭",
"no": "klikk her for instruksjoner",
"it": "clicca qui per istruzioni",
"pt": "clique aqui para instruções",
"es": "haga clic aquí para obtener instrucciones",
"ar": "انقر هنا للحصول على تعليمات",
"fr": "cliquez ici pour les instructions",
"tr": "talimatlar için buraya tıklayın",
"de": "klicken Sie hier für Anweisungen"
},
"GITLAB$OR_SEE": {
"en": "or see the",
"ja": "または参照",

View File

@@ -35,6 +35,15 @@ const MOCK_USER_PREFERENCES: {
settings: null,
};
/**
* Set the user settings to the default settings
*
* Useful for resetting the settings in tests
*/
export const resetTestHandlersMockSettings = () => {
MOCK_USER_PREFERENCES.settings = MOCK_DEFAULT_USER_SETTINGS;
};
const conversations: Conversation[] = [
{
conversation_id: "1",
@@ -80,6 +89,7 @@ const openHandsHandlers = [
HttpResponse.json([
"gpt-3.5-turbo",
"gpt-4o",
"gpt-4o-mini",
"anthropic/claude-3.5",
"anthropic/claude-3-5-sonnet-20241022",
]),
@@ -173,6 +183,7 @@ export const handlers = [
return HttpResponse.json(settings);
}),
http.post("/api/settings", async ({ request }) => {
await delay();
const body = await request.json();
if (body) {

View File

@@ -9,7 +9,9 @@ export default [
layout("routes/root-layout.tsx", [
index("routes/home.tsx"),
route("settings", "routes/settings.tsx", [
index("routes/account-settings.tsx"),
index("routes/llm-settings.tsx"),
route("git", "routes/git-settings.tsx"),
route("app", "routes/app-settings.tsx"),
route("billing", "routes/billing.tsx"),
route("api-keys", "routes/api-keys.tsx"),
]),

View File

@@ -1,531 +0,0 @@
import React from "react";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
import { HelpLink } from "#/components/features/settings/help-link";
import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
import { useConfig } from "#/hooks/query/use-config";
import { useSettings } from "#/hooks/query/use-settings";
import { useAppLogout } from "#/hooks/use-app-logout";
import { AvailableLanguages } from "#/i18n";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set";
import { isCustomModel } from "#/utils/is-custom-model";
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { ProviderOptions } from "#/types/settings";
import { useAuth } from "#/context/auth-context";
// Define REMOTE_RUNTIME_OPTIONS for testing
const REMOTE_RUNTIME_OPTIONS = [
{ key: "1", label: "Standard" },
{ key: "2", label: "Enhanced" },
{ key: "4", label: "Premium" },
];
function AccountSettings() {
const { t } = useTranslation();
const {
data: settings,
isFetching: isFetchingSettings,
isFetched,
isSuccess: isSuccessfulSettings,
} = useSettings();
const { data: config } = useConfig();
const {
data: resources,
isFetching: isFetchingResources,
isSuccess: isSuccessfulResources,
} = useAIConfigOptions();
const { mutate: saveSettings } = useSaveSettings();
const { handleLogout } = useAppLogout();
const { providerTokensSet, providersAreSet } = useAuth();
const isFetching = isFetchingSettings || isFetchingResources;
const isSuccess = isSuccessfulSettings && isSuccessfulResources;
const isSaas = config?.APP_MODE === "saas";
const shouldHandleSpecialSaasCase =
config?.FEATURE_FLAGS.HIDE_LLM_SETTINGS && isSaas;
const determineWhetherToToggleAdvancedSettings = () => {
if (shouldHandleSpecialSaasCase) return true;
if (isSuccess) {
return (
isCustomModel(resources.models, settings.LLM_MODEL) ||
hasAdvancedSettingsSet({
...settings,
})
);
}
return false;
};
const hasAppSlug = !!config?.APP_SLUG;
const isGitHubTokenSet =
providerTokensSet.includes(ProviderOptions.github) || false;
const isGitLabTokenSet =
providerTokensSet.includes(ProviderOptions.gitlab) || false;
const isLLMKeySet = settings?.LLM_API_KEY_SET;
const isAnalyticsEnabled = settings?.USER_CONSENTS_TO_ANALYTICS;
const isAdvancedSettingsSet = determineWhetherToToggleAdvancedSettings();
const modelsAndProviders = organizeModelsAndProviders(
resources?.models || [],
);
const [llmConfigMode, setLlmConfigMode] = React.useState<
"basic" | "advanced"
>(isAdvancedSettingsSet ? "advanced" : "basic");
const [confirmationModeIsEnabled, setConfirmationModeIsEnabled] =
React.useState(!!settings?.SECURITY_ANALYZER);
const formRef = React.useRef<HTMLFormElement>(null);
const onSubmit = async (formData: FormData) => {
const languageLabel = formData.get("language-input")?.toString();
const languageValue = AvailableLanguages.find(
({ label }) => label === languageLabel,
)?.value;
const llmProvider = formData.get("llm-provider-input")?.toString();
const llmModel = formData.get("llm-model-input")?.toString();
const fullLlmModel = `${llmProvider}/${llmModel}`.toLowerCase();
const customLlmModel = formData.get("llm-custom-model-input")?.toString();
const rawRemoteRuntimeResourceFactor = formData
.get("runtime-settings-input")
?.toString();
const remoteRuntimeResourceFactor = REMOTE_RUNTIME_OPTIONS.find(
({ label }) => label === rawRemoteRuntimeResourceFactor,
)?.key;
const userConsentsToAnalytics =
formData.get("enable-analytics-switch")?.toString() === "on";
const enableMemoryCondenser =
formData.get("enable-memory-condenser-switch")?.toString() === "on";
const enableSoundNotifications =
formData.get("enable-sound-notifications-switch")?.toString() === "on";
const llmBaseUrl = formData.get("base-url-input")?.toString().trim() || "";
const inputApiKey = formData.get("llm-api-key-input")?.toString() || "";
const llmApiKey =
inputApiKey === "" && isLLMKeySet
? undefined // don't update if it's already set and input is empty
: inputApiKey; // otherwise use the input value
const githubToken = formData.get("github-token-input")?.toString();
const gitlabToken = formData.get("gitlab-token-input")?.toString();
// we don't want the user to be able to modify these settings in SaaS
const finalLlmModel = shouldHandleSpecialSaasCase
? undefined
: customLlmModel || fullLlmModel;
const finalLlmBaseUrl = shouldHandleSpecialSaasCase
? undefined
: llmBaseUrl;
const finalLlmApiKey = shouldHandleSpecialSaasCase ? undefined : llmApiKey;
const newSettings = {
provider_tokens:
githubToken || gitlabToken
? {
github: githubToken || "",
gitlab: gitlabToken || "",
}
: undefined,
LANGUAGE: languageValue,
user_consents_to_analytics: userConsentsToAnalytics,
ENABLE_DEFAULT_CONDENSER: enableMemoryCondenser,
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
LLM_MODEL: finalLlmModel,
LLM_BASE_URL: finalLlmBaseUrl,
llm_api_key: finalLlmApiKey,
AGENT: formData.get("agent-input")?.toString(),
SECURITY_ANALYZER:
formData.get("security-analyzer-input")?.toString() || "",
REMOTE_RUNTIME_RESOURCE_FACTOR:
remoteRuntimeResourceFactor !== null
? Number(remoteRuntimeResourceFactor)
: DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
CONFIRMATION_MODE: confirmationModeIsEnabled,
};
saveSettings(newSettings, {
onSuccess: () => {
handleCaptureConsent(userConsentsToAnalytics);
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic");
},
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
},
});
};
React.useEffect(() => {
// If settings is still loading by the time the state is set, it will always
// default to basic settings. This is a workaround to ensure the correct
// settings are displayed.
setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic");
}, [isAdvancedSettingsSet]);
if (isFetched && !settings) {
return <div>Failed to fetch settings. Please try reloading.</div>;
}
const onToggleAdvancedMode = (isToggled: boolean) => {
setLlmConfigMode(isToggled ? "advanced" : "basic");
if (!isToggled) {
// reset advanced state
setConfirmationModeIsEnabled(!!settings?.SECURITY_ANALYZER);
}
};
if (isFetching || !settings) {
return (
<div className="flex grow p-4">
<LoadingSpinner size="large" />
</div>
);
}
return (
<>
<form
data-testid="account-settings-form"
ref={formRef}
action={onSubmit}
className="flex flex-col grow overflow-auto"
>
<div className="flex flex-col gap-12 px-11 py-9">
{!shouldHandleSpecialSaasCase && (
<section
data-testid="llm-settings-section"
className="flex flex-col gap-6"
>
<div className="flex items-center gap-7">
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
{t(I18nKey.SETTINGS$LLM_SETTINGS)}
</h2>
{!shouldHandleSpecialSaasCase && (
<SettingsSwitch
testId="advanced-settings-switch"
defaultIsToggled={isAdvancedSettingsSet}
onToggle={onToggleAdvancedMode}
>
{t(I18nKey.SETTINGS$ADVANCED)}
</SettingsSwitch>
)}
</div>
{llmConfigMode === "basic" && !shouldHandleSpecialSaasCase && (
<ModelSelector
models={modelsAndProviders}
currentModel={settings.LLM_MODEL}
/>
)}
{llmConfigMode === "advanced" && !shouldHandleSpecialSaasCase && (
<SettingsInput
testId="llm-custom-model-input"
name="llm-custom-model-input"
label={t(I18nKey.SETTINGS$CUSTOM_MODEL)}
defaultValue={settings.LLM_MODEL}
placeholder="anthropic/claude-3-5-sonnet-20241022"
type="text"
className="w-[680px]"
/>
)}
{llmConfigMode === "advanced" && !shouldHandleSpecialSaasCase && (
<SettingsInput
testId="base-url-input"
name="base-url-input"
label={t(I18nKey.SETTINGS$BASE_URL)}
defaultValue={settings.LLM_BASE_URL}
placeholder="https://api.openai.com"
type="text"
className="w-[680px]"
/>
)}
{!shouldHandleSpecialSaasCase && (
<SettingsInput
testId="llm-api-key-input"
name="llm-api-key-input"
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
type="password"
className="w-[680px]"
placeholder={isLLMKeySet ? "<hidden>" : ""}
startContent={
isLLMKeySet && <KeyStatusIcon isSet={isLLMKeySet} />
}
/>
)}
{!shouldHandleSpecialSaasCase && (
<HelpLink
testId="llm-api-key-help-anchor"
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key"
/>
)}
{llmConfigMode === "advanced" && (
<SettingsDropdownInput
testId="agent-input"
name="agent-input"
label={t(I18nKey.SETTINGS$AGENT)}
items={
resources?.agents.map((agent) => ({
key: agent,
label: agent,
})) || []
}
wrapperClassName="w-[680px]"
defaultSelectedKey={settings.AGENT}
isClearable={false}
/>
)}
{isSaas && llmConfigMode === "advanced" && (
<SettingsDropdownInput
testId="runtime-settings-input"
name="runtime-settings-input"
label={
<>
{t(I18nKey.SETTINGS$RUNTIME_SETTINGS)}
<a href="mailto:contact@all-hands.dev">
{t(I18nKey.SETTINGS$GET_IN_TOUCH)}
</a>
)
</>
}
items={REMOTE_RUNTIME_OPTIONS}
defaultSelectedKey={settings.REMOTE_RUNTIME_RESOURCE_FACTOR?.toString()}
isDisabled
isClearable={false}
/>
)}
{llmConfigMode === "advanced" && (
<SettingsSwitch
testId="enable-confirmation-mode-switch"
onToggle={setConfirmationModeIsEnabled}
defaultIsToggled={!!settings.CONFIRMATION_MODE}
isBeta
>
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
</SettingsSwitch>
)}
{llmConfigMode === "advanced" && (
<SettingsSwitch
testId="enable-memory-condenser-switch"
name="enable-memory-condenser-switch"
defaultIsToggled={!!settings.ENABLE_DEFAULT_CONDENSER}
>
{t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)}
</SettingsSwitch>
)}
{llmConfigMode === "advanced" && confirmationModeIsEnabled && (
<div>
<SettingsDropdownInput
testId="security-analyzer-input"
name="security-analyzer-input"
label={t(I18nKey.SETTINGS$SECURITY_ANALYZER)}
items={
resources?.securityAnalyzers.map((analyzer) => ({
key: analyzer,
label: analyzer,
})) || []
}
defaultSelectedKey={settings.SECURITY_ANALYZER}
isClearable
showOptionalTag
/>
</div>
)}
</section>
)}
<section className="flex flex-col gap-6">
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
{t(I18nKey.SETTINGS$GIT_SETTINGS)}
</h2>
{isSaas && hasAppSlug && (
<Link
to={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
target="_blank"
rel="noreferrer noopener"
>
<BrandButton type="button" variant="secondary">
{t(I18nKey.GITHUB$CONFIGURE_REPOS)}
</BrandButton>
</Link>
)}
{!isSaas && (
<>
<SettingsInput
testId="github-token-input"
name="github-token-input"
label={t(I18nKey.GITHUB$TOKEN_LABEL)}
type="password"
className="w-[680px]"
startContent={
isGitHubTokenSet && (
<KeyStatusIcon isSet={!!isGitHubTokenSet} />
)
}
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
/>
<p data-testid="github-token-help-anchor" className="text-xs">
{" "}
{t(I18nKey.GITHUB$GET_TOKEN)}{" "}
<b>
{" "}
<a
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
GitHub
</a>{" "}
</b>
{t(I18nKey.COMMON$HERE)}{" "}
<b>
<a
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
{t(I18nKey.COMMON$CLICK_FOR_INSTRUCTIONS)}
</a>
</b>
.
</p>
<SettingsInput
testId="gitlab-token-input"
name="gitlab-token-input"
label={t(I18nKey.GITLAB$TOKEN_LABEL)}
type="password"
className="w-[680px]"
startContent={
isGitLabTokenSet && (
<KeyStatusIcon isSet={!!isGitLabTokenSet} />
)
}
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
/>
<p data-testid="gitlab-token-help-anchor" className="text-xs">
{" "}
{t(I18nKey.GITLAB$GET_TOKEN)}{" "}
<b>
{" "}
<a
href="https://gitlab.com/-/user_settings/personal_access_tokens?name=openhands-app&scopes=api,read_user,read_repository,write_repository"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
GitLab
</a>{" "}
</b>
{t(I18nKey.GITLAB$OR_SEE)}{" "}
<b>
<a
href="https://docs.gitlab.com/user/profile/personal_access_tokens/"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
{t(I18nKey.COMMON$DOCUMENTATION)}
</a>
</b>
.
</p>
<BrandButton
type="button"
variant="secondary"
onClick={handleLogout}
isDisabled={!providersAreSet}
>
Disconnect Tokens
</BrandButton>
</>
)}
</section>
<section className="flex flex-col gap-6">
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
{t(I18nKey.ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS)}
</h2>
<SettingsDropdownInput
testId="language-input"
name="language-input"
label={t(I18nKey.SETTINGS$LANGUAGE)}
items={AvailableLanguages.map((language) => ({
key: language.value,
label: language.label,
}))}
defaultSelectedKey={settings.LANGUAGE}
wrapperClassName="w-[680px]"
isClearable={false}
/>
<SettingsSwitch
testId="enable-analytics-switch"
name="enable-analytics-switch"
defaultIsToggled={!!isAnalyticsEnabled}
>
{t(I18nKey.ANALYTICS$ENABLE)}
</SettingsSwitch>
<SettingsSwitch
testId="enable-sound-notifications-switch"
name="enable-sound-notifications-switch"
defaultIsToggled={!!settings.ENABLE_SOUND_NOTIFICATIONS}
>
{t(I18nKey.SETTINGS$SOUND_NOTIFICATIONS)}
</SettingsSwitch>
</section>
</div>
</form>
<footer className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
<BrandButton
type="button"
variant="primary"
onClick={() => {
formRef.current?.requestSubmit();
}}
>
{t(I18nKey.BUTTON$SAVE)}
</BrandButton>
</footer>
</>
);
}
export default AccountSettings;

View File

@@ -3,7 +3,7 @@ import { ApiKeysManager } from "#/components/features/settings/api-keys-manager"
function ApiKeysScreen() {
return (
<div className="flex flex-col grow overflow-auto p-11">
<div className="flex flex-col grow overflow-auto p-9">
<ApiKeysManager />
</div>
);

View File

@@ -0,0 +1,150 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useSettings } from "#/hooks/query/use-settings";
import { AvailableLanguages } from "#/i18n";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { BrandButton } from "#/components/features/settings/brand-button";
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
import { I18nKey } from "#/i18n/declaration";
import { LanguageInput } from "#/components/features/settings/app-settings/language-input";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { AppSettingsInputsSkeleton } from "#/components/features/settings/app-settings/app-settings-inputs-skeleton";
function AppSettingsScreen() {
const { t } = useTranslation();
const { mutate: saveSettings, isPending } = useSaveSettings();
const { data: settings, isLoading } = useSettings();
const [languageInputHasChanged, setLanguageInputHasChanged] =
React.useState(false);
const [analyticsSwitchHasChanged, setAnalyticsSwitchHasChanged] =
React.useState(false);
const [
soundNotificationsSwitchHasChanged,
setSoundNotificationsSwitchHasChanged,
] = React.useState(false);
const formAction = (formData: FormData) => {
const languageLabel = formData.get("language-input")?.toString();
const languageValue = AvailableLanguages.find(
({ label }) => label === languageLabel,
)?.value;
const language = languageValue || DEFAULT_SETTINGS.LANGUAGE;
const enableAnalytics =
formData.get("enable-analytics-switch")?.toString() === "on";
const enableSoundNotifications =
formData.get("enable-sound-notifications-switch")?.toString() === "on";
saveSettings(
{
LANGUAGE: language,
user_consents_to_analytics: enableAnalytics,
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
},
{
onSuccess: () => {
handleCaptureConsent(enableAnalytics);
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
},
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
},
onSettled: () => {
setLanguageInputHasChanged(false);
setAnalyticsSwitchHasChanged(false);
setSoundNotificationsSwitchHasChanged(false);
},
},
);
};
const checkIfLanguageInputHasChanged = (value: string) => {
const selectedLanguage = AvailableLanguages.find(
({ label: langValue }) => langValue === value,
)?.label;
const currentLanguage = AvailableLanguages.find(
({ value: langValue }) => langValue === settings?.LANGUAGE,
)?.label;
setLanguageInputHasChanged(selectedLanguage !== currentLanguage);
};
const checkIfAnalyticsSwitchHasChanged = (checked: boolean) => {
const currentAnalytics = !!settings?.USER_CONSENTS_TO_ANALYTICS;
setAnalyticsSwitchHasChanged(checked !== currentAnalytics);
};
const checkIfSoundNotificationsSwitchHasChanged = (checked: boolean) => {
const currentSoundNotifications = !!settings?.ENABLE_SOUND_NOTIFICATIONS;
setSoundNotificationsSwitchHasChanged(
checked !== currentSoundNotifications,
);
};
const formIsClean =
!languageInputHasChanged &&
!analyticsSwitchHasChanged &&
!soundNotificationsSwitchHasChanged;
const shouldBeLoading = !settings || isLoading || isPending;
return (
<form
data-testid="app-settings-screen"
action={formAction}
className="flex flex-col h-full justify-between"
>
{shouldBeLoading && <AppSettingsInputsSkeleton />}
{!shouldBeLoading && (
<div className="p-9 flex flex-col gap-6">
<LanguageInput
name="language-input"
defaultKey={settings.LANGUAGE}
onChange={checkIfLanguageInputHasChanged}
/>
<SettingsSwitch
testId="enable-analytics-switch"
name="enable-analytics-switch"
defaultIsToggled={!!settings.USER_CONSENTS_TO_ANALYTICS}
onToggle={checkIfAnalyticsSwitchHasChanged}
>
{t(I18nKey.ANALYTICS$ENABLE)}
</SettingsSwitch>
<SettingsSwitch
testId="enable-sound-notifications-switch"
name="enable-sound-notifications-switch"
defaultIsToggled={!!settings.ENABLE_SOUND_NOTIFICATIONS}
onToggle={checkIfSoundNotificationsSwitchHasChanged}
>
{t(I18nKey.SETTINGS$SOUND_NOTIFICATIONS)}
</SettingsSwitch>
</div>
)}
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
<BrandButton
testId="submit-button"
variant="primary"
type="submit"
isDisabled={isPending || formIsClean}
>
{!isPending && t("SETTINGS$SAVE_CHANGES")}
{isPending && t("SETTINGS$SAVING")}
</BrandButton>
</div>
</form>
);
}
export default AppSettingsScreen;

View File

@@ -0,0 +1,170 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useConfig } from "#/hooks/query/use-config";
import { useSettings } from "#/hooks/query/use-settings";
import { BrandButton } from "#/components/features/settings/brand-button";
import { useLogout } from "#/hooks/mutation/use-logout";
import { GitHubTokenInput } from "#/components/features/settings/git-settings/github-token-input";
import { GitLabTokenInput } from "#/components/features/settings/git-settings/gitlab-token-input";
import { ConfigureGitHubRepositoriesAnchor } from "#/components/features/settings/git-settings/configure-github-repositories-anchor";
import { I18nKey } from "#/i18n/declaration";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { GitSettingInputsSkeleton } from "#/components/features/settings/git-settings/github-settings-inputs-skeleton";
import { useAuth } from "#/context/auth-context";
function GitSettingsScreen() {
const { t } = useTranslation();
const { mutate: saveSettings, isPending } = useSaveSettings();
const { providerTokensSet } = useAuth();
const { mutate: disconnectGitTokens } = useLogout();
const { data: settings, isLoading } = useSettings();
const { data: config } = useConfig();
const [githubTokenInputHasValue, setGithubTokenInputHasValue] =
React.useState(false);
const [gitlabTokenInputHasValue, setGitlabTokenInputHasValue] =
React.useState(false);
const [githubBaseDomainInputHasValue, setGithubBaseDomainInputHasValue] =
React.useState(false);
const [gitlabBaseDomainInputHasValue, setGitlabBaseDomainInputHasValue] =
React.useState(false);
const isSaas = config?.APP_MODE === "saas";
const isGitHubTokenSet = providerTokensSet.includes("github");
const isGitLabTokenSet = providerTokensSet.includes("gitlab");
const existingGithubBaseDomain = settings?.PROVIDER_TOKENS_SET["github"];
const existingGitlabBaseDomain = settings?.PROVIDER_TOKENS_SET["gitlab"];
const formAction = async (formData: FormData) => {
const disconnectButtonClicked =
formData.get("disconnect-tokens-button") !== null;
if (disconnectButtonClicked) {
disconnectGitTokens();
return;
}
const githubToken = formData.get("github-token-input")?.toString() || "";
const gitlabToken = formData.get("gitlab-token-input")?.toString() || "";
const githubBaseDomain =
formData.get("github-base-domain-input")?.toString() || "";
const gitlabBaseDomain =
formData.get("gitlab-base-domain-input")?.toString() || "";
saveSettings(
{
provider_tokens: {
github: {
token: githubToken,
base_domain: githubBaseDomain || null,
},
gitlab: {
token: gitlabToken,
base_domain: gitlabBaseDomain || null,
},
},
},
{
onSuccess: () => {
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
},
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
},
onSettled: () => {
setGithubTokenInputHasValue(false);
setGitlabTokenInputHasValue(false);
setGithubBaseDomainInputHasValue(false);
setGitlabBaseDomainInputHasValue(false);
},
},
);
};
const formIsClean =
!githubTokenInputHasValue &&
!gitlabTokenInputHasValue &&
!githubBaseDomainInputHasValue &&
!gitlabBaseDomainInputHasValue;
const shouldRenderExternalConfigureButtons = isSaas && config.APP_SLUG;
return (
<form
data-testid="git-settings-screen"
action={formAction}
className="flex flex-col h-full justify-between"
>
{isLoading && <GitSettingInputsSkeleton />}
{shouldRenderExternalConfigureButtons && !isLoading && (
<ConfigureGitHubRepositoriesAnchor slug={config.APP_SLUG!} />
)}
{!isLoading && (
<div className="p-9 flex flex-col gap-12">
<GitHubTokenInput
name="github-token-input"
baseDomainSet={existingGithubBaseDomain}
isGitHubTokenSet={isGitHubTokenSet}
onChange={(value) => {
setGithubTokenInputHasValue(!!value);
}}
onBaseDomainChange={(value) => {
setGithubBaseDomainInputHasValue(!!value);
}}
isSaas={isSaas}
/>
<GitLabTokenInput
name="gitlab-token-input"
baseDomainSet={existingGitlabBaseDomain}
isGitLabTokenSet={isGitLabTokenSet}
onChange={(value) => {
setGitlabTokenInputHasValue(!!value);
}}
onBaseDomainChange={(value) => {
setGitlabBaseDomainInputHasValue(!!value);
}}
isSaas={isSaas}
/>
</div>
)}
{!shouldRenderExternalConfigureButtons && (
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
<BrandButton
testId="disconnect-tokens-button"
name="disconnect-tokens-button"
type="submit"
variant="secondary"
isDisabled={!isGitHubTokenSet && !isGitLabTokenSet}
>
Disconnect Tokens
</BrandButton>
<BrandButton
testId="submit-button"
type="submit"
variant="primary"
isDisabled={isPending || formIsClean}
>
{!isPending && t("SETTINGS$SAVE_CHANGES")}
{isPending && t("SETTINGS$SAVING")}
</BrandButton>
</div>
)}
</form>
);
}
export default GitSettingsScreen;

View File

@@ -0,0 +1,430 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { AxiosError } from "axios";
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
import { useSettings } from "#/hooks/query/use-settings";
import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { HelpLink } from "#/components/features/settings/help-link";
import { BrandButton } from "#/components/features/settings/brand-button";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input";
import { useConfig } from "#/hooks/query/use-config";
import { isCustomModel } from "#/utils/is-custom-model";
import { LlmSettingsInputsSkeleton } from "#/components/features/settings/llm-settings/llm-settings-inputs-skeleton";
import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
function LlmSettingsScreen() {
const { t } = useTranslation();
const { mutate: saveSettings, isPending } = useSaveSettings();
const { data: resources } = useAIConfigOptions();
const { data: settings, isLoading, isFetching } = useSettings();
const { data: config } = useConfig();
const [view, setView] = React.useState<"basic" | "advanced">("basic");
const [securityAnalyzerInputIsVisible, setSecurityAnalyzerInputIsVisible] =
React.useState(false);
const [dirtyInputs, setDirtyInputs] = React.useState({
model: false,
apiKey: false,
baseUrl: false,
agent: false,
confirmationMode: false,
enableDefaultCondenser: false,
securityAnalyzer: false,
});
const modelsAndProviders = organizeModelsAndProviders(
resources?.models || [],
);
React.useEffect(() => {
const determineWhetherToToggleAdvancedSettings = () => {
if (resources && settings) {
return (
isCustomModel(resources.models, settings.LLM_MODEL) ||
hasAdvancedSettingsSet({
...settings,
})
);
}
return false;
};
const userSettingsIsAdvanced = determineWhetherToToggleAdvancedSettings();
if (settings) setSecurityAnalyzerInputIsVisible(settings.CONFIRMATION_MODE);
if (userSettingsIsAdvanced) setView("advanced");
else setView("basic");
}, [settings, resources]);
const handleSuccessfulMutation = () => {
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
setDirtyInputs({
model: false,
apiKey: false,
baseUrl: false,
agent: false,
confirmationMode: false,
enableDefaultCondenser: false,
securityAnalyzer: false,
});
};
const handleErrorMutation = (error: AxiosError) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
};
const basicFormAction = (formData: FormData) => {
const provider = formData.get("llm-provider-input")?.toString();
const model = formData.get("llm-model-input")?.toString();
const apiKey = formData.get("llm-api-key-input")?.toString();
const fullLlmModel =
provider && model && `${provider}/${model}`.toLowerCase();
saveSettings(
{
LLM_MODEL: fullLlmModel,
llm_api_key: apiKey || null,
},
{
onSuccess: handleSuccessfulMutation,
onError: handleErrorMutation,
},
);
};
const advancedFormAction = (formData: FormData) => {
const model = formData.get("llm-custom-model-input")?.toString();
const baseUrl = formData.get("base-url-input")?.toString();
const apiKey = formData.get("llm-api-key-input")?.toString();
const agent = formData.get("agent-input")?.toString();
const confirmationMode =
formData.get("enable-confirmation-mode-switch")?.toString() === "on";
const enableDefaultCondenser =
formData.get("enable-memory-condenser-switch")?.toString() === "on";
const securityAnalyzer = formData
.get("security-analyzer-input")
?.toString();
saveSettings(
{
LLM_MODEL: model,
LLM_BASE_URL: baseUrl,
llm_api_key: apiKey,
AGENT: agent,
CONFIRMATION_MODE: confirmationMode,
ENABLE_DEFAULT_CONDENSER: enableDefaultCondenser,
SECURITY_ANALYZER: confirmationMode ? securityAnalyzer : undefined,
},
{
onSuccess: handleSuccessfulMutation,
onError: handleErrorMutation,
},
);
};
const formAction = (formData: FormData) => {
if (view === "basic") basicFormAction(formData);
else advancedFormAction(formData);
};
const handleToggleAdvancedSettings = (isToggled: boolean) => {
setSecurityAnalyzerInputIsVisible(!!settings?.CONFIRMATION_MODE);
setView(isToggled ? "advanced" : "basic");
setDirtyInputs({
model: false,
apiKey: false,
baseUrl: false,
agent: false,
confirmationMode: false,
enableDefaultCondenser: false,
securityAnalyzer: false,
});
};
const handleModelIsDirty = (model: string | null) => {
// openai providers are special case; see ModelSelector
// component for details
const modelIsDirty = model !== settings?.LLM_MODEL.replace("openai/", "");
setDirtyInputs((prev) => ({
...prev,
model: modelIsDirty,
}));
};
const handleApiKeyIsDirty = (apiKey: string) => {
const apiKeyIsDirty = apiKey !== "";
setDirtyInputs((prev) => ({
...prev,
apiKey: apiKeyIsDirty,
}));
};
const handleCustomModelIsDirty = (model: string) => {
const modelIsDirty = model !== settings?.LLM_MODEL && model !== "";
setDirtyInputs((prev) => ({
...prev,
model: modelIsDirty,
}));
};
const handleBaseUrlIsDirty = (baseUrl: string) => {
const baseUrlIsDirty = baseUrl !== settings?.LLM_BASE_URL;
setDirtyInputs((prev) => ({
...prev,
baseUrl: baseUrlIsDirty,
}));
};
const handleAgentIsDirty = (agent: string) => {
const agentIsDirty = agent !== settings?.AGENT && agent !== "";
setDirtyInputs((prev) => ({
...prev,
agent: agentIsDirty,
}));
};
const handleConfirmationModeIsDirty = (isToggled: boolean) => {
setSecurityAnalyzerInputIsVisible(isToggled);
const confirmationModeIsDirty = isToggled !== settings?.CONFIRMATION_MODE;
setDirtyInputs((prev) => ({
...prev,
confirmationMode: confirmationModeIsDirty,
}));
};
const handleEnableDefaultCondenserIsDirty = (isToggled: boolean) => {
const enableDefaultCondenserIsDirty =
isToggled !== settings?.ENABLE_DEFAULT_CONDENSER;
setDirtyInputs((prev) => ({
...prev,
enableDefaultCondenser: enableDefaultCondenserIsDirty,
}));
};
const handleSecurityAnalyzerIsDirty = (securityAnalyzer: string) => {
const securityAnalyzerIsDirty =
securityAnalyzer !== settings?.SECURITY_ANALYZER;
setDirtyInputs((prev) => ({
...prev,
securityAnalyzer: securityAnalyzerIsDirty,
}));
};
const formIsDirty = Object.values(dirtyInputs).some((isDirty) => isDirty);
if (!settings || isFetching) return <LlmSettingsInputsSkeleton />;
return (
<div data-testid="llm-settings-screen" className="h-full">
<form
action={formAction}
className="flex flex-col h-full justify-between"
>
<div className="p-9 flex flex-col gap-6">
<SettingsSwitch
testId="advanced-settings-switch"
defaultIsToggled={view === "advanced"}
onToggle={handleToggleAdvancedSettings}
isToggled={view === "advanced"}
>
{t(I18nKey.SETTINGS$ADVANCED)}
</SettingsSwitch>
{view === "basic" && (
<div
data-testid="llm-settings-form-basic"
className="flex flex-col gap-6"
>
{!isLoading && !isFetching && (
<ModelSelector
models={modelsAndProviders}
currentModel={
settings.LLM_MODEL || "anthropic/claude-3-5-sonnet-20241022"
}
onChange={handleModelIsDirty}
/>
)}
<SettingsInput
testId="llm-api-key-input"
name="llm-api-key-input"
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
type="password"
className="w-[680px]"
placeholder={settings.LLM_API_KEY_SET ? "<hidden>" : ""}
onChange={handleApiKeyIsDirty}
startContent={
settings.LLM_API_KEY_SET && (
<KeyStatusIcon isSet={settings.LLM_API_KEY_SET} />
)
}
/>
<HelpLink
testId="llm-api-key-help-anchor"
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key"
/>
</div>
)}
{view === "advanced" && (
<div
data-testid="llm-settings-form-advanced"
className="flex flex-col gap-6"
>
<SettingsInput
testId="llm-custom-model-input"
name="llm-custom-model-input"
label={t(I18nKey.SETTINGS$CUSTOM_MODEL)}
defaultValue={
settings.LLM_MODEL || "anthropic/claude-3-5-sonnet-20241022"
}
placeholder="anthropic/claude-3-5-sonnet-20241022"
type="text"
className="w-[680px]"
onChange={handleCustomModelIsDirty}
/>
<SettingsInput
testId="base-url-input"
name="base-url-input"
label={t(I18nKey.SETTINGS$BASE_URL)}
defaultValue={settings.LLM_BASE_URL}
placeholder="https://api.openai.com"
type="text"
className="w-[680px]"
onChange={handleBaseUrlIsDirty}
/>
<SettingsInput
testId="llm-api-key-input"
name="llm-api-key-input"
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
type="password"
className="w-[680px]"
placeholder={settings.LLM_API_KEY_SET ? "<hidden>" : ""}
onChange={handleApiKeyIsDirty}
startContent={
settings.LLM_API_KEY_SET && (
<KeyStatusIcon isSet={settings.LLM_API_KEY_SET} />
)
}
/>
<HelpLink
testId="llm-api-key-help-anchor"
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key"
/>
<SettingsDropdownInput
testId="agent-input"
name="agent-input"
label={t(I18nKey.SETTINGS$AGENT)}
items={
resources?.agents.map((agent) => ({
key: agent,
label: agent,
})) || []
}
defaultSelectedKey={settings.AGENT}
isClearable={false}
onInputChange={handleAgentIsDirty}
wrapperClassName="w-[680px]"
/>
{config?.APP_MODE === "saas" && (
<SettingsDropdownInput
testId="runtime-settings-input"
name="runtime-settings-input"
label={
<>
{t(I18nKey.SETTINGS$RUNTIME_SETTINGS)}
<a href="mailto:contact@all-hands.dev">
{t(I18nKey.SETTINGS$GET_IN_TOUCH)}
</a>
)
</>
}
items={[]}
isDisabled
/>
)}
<SettingsSwitch
testId="enable-memory-condenser-switch"
name="enable-memory-condenser-switch"
defaultIsToggled={settings.ENABLE_DEFAULT_CONDENSER}
onToggle={handleEnableDefaultCondenserIsDirty}
>
{t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)}
</SettingsSwitch>
<SettingsSwitch
testId="enable-confirmation-mode-switch"
name="enable-confirmation-mode-switch"
onToggle={handleConfirmationModeIsDirty}
defaultIsToggled={settings.CONFIRMATION_MODE}
isBeta
>
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
</SettingsSwitch>
{securityAnalyzerInputIsVisible && (
<SettingsDropdownInput
testId="security-analyzer-input"
name="security-analyzer-input"
label={t(I18nKey.SETTINGS$SECURITY_ANALYZER)}
items={
resources?.securityAnalyzers.map((analyzer) => ({
key: analyzer,
label: analyzer,
})) || []
}
defaultSelectedKey={settings.SECURITY_ANALYZER}
isClearable
showOptionalTag
onInputChange={handleSecurityAnalyzerIsDirty}
wrapperClassName="w-[680px]"
/>
)}
</div>
)}
</div>
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
<BrandButton
testId="submit-button"
type="submit"
variant="primary"
isDisabled={!formIsDirty || isPending}
>
{!isPending && t("SETTINGS$SAVE_CHANGES")}
{isPending && t("SETTINGS$SAVING")}
</BrandButton>
</div>
</form>
</div>
);
}
export default LlmSettingsScreen;

View File

@@ -1,5 +1,6 @@
import { NavLink, Outlet } from "react-router";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import React from "react";
import SettingsIcon from "#/icons/settings.svg?react";
import { cn } from "#/utils/utils";
import { useConfig } from "#/hooks/query/use-config";
@@ -7,9 +8,44 @@ import { I18nKey } from "#/i18n/declaration";
function SettingsScreen() {
const { t } = useTranslation();
const navigate = useNavigate();
const { pathname } = useLocation();
const { data: config } = useConfig();
const isSaas = config?.APP_MODE === "saas";
const saasNavItems = [
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
{ to: "/settings/billing", text: t("SETTINGS$NAV_CREDITS") },
{ to: "/settings/api-keys", text: t("SETTINGS$NAV_API_KEYS") },
];
const ossNavItems = [
{ to: "/settings", text: t("SETTINGS$NAV_LLM") },
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
];
React.useEffect(() => {
if (isSaas) {
if (pathname === "/settings") {
navigate("/settings/git");
}
} else {
const noEnteringPaths = [
"/settings/billing",
"/settings/credits",
"/settings/api-keys",
];
if (noEnteringPaths.includes(pathname)) {
navigate("/settings");
}
}
}, [isSaas, pathname]);
const navItems = isSaas ? saasNavItems : ossNavItems;
return (
<main
data-testid="settings-screen"
@@ -20,32 +56,26 @@ function SettingsScreen() {
<h1 className="text-sm leading-6">{t(I18nKey.SETTINGS$TITLE)}</h1>
</header>
{isSaas && (
<nav
data-testid="settings-navbar"
className="flex items-end gap-12 px-11 border-b border-tertiary"
>
{[
{ to: "/settings", text: "Account" },
{ to: "/settings/billing", text: "Credits" },
{ to: "/settings/api-keys", text: "API Keys" },
].map(({ to, text }) => (
<NavLink
end
key={to}
to={to}
className={({ isActive }) =>
cn(
"border-b-2 border-transparent py-2.5",
isActive && "border-primary",
)
}
>
<ul className="text-[#F9FBFE] text-sm">{text}</ul>
</NavLink>
))}
</nav>
)}
<nav
data-testid="settings-navbar"
className="flex items-end gap-12 px-9 border-b border-tertiary"
>
{navItems.map(({ to, text }) => (
<NavLink
end
key={to}
to={to}
className={({ isActive }) =>
cn(
"border-b-2 border-transparent py-2.5",
isActive && "border-primary",
)
}
>
<ul className="text-[#F9FBFE] text-sm">{text}</ul>
</NavLink>
))}
</nav>
<div className="flex flex-col grow overflow-auto">
<Outlet />

View File

@@ -11,13 +11,13 @@ export const DEFAULT_SETTINGS: Settings = {
CONFIRMATION_MODE: false,
SECURITY_ANALYZER: "",
REMOTE_RUNTIME_RESOURCE_FACTOR: 1,
PROVIDER_TOKENS_SET: { github: false, gitlab: false },
PROVIDER_TOKENS_SET: { github: null, gitlab: null },
ENABLE_DEFAULT_CONDENSER: true,
ENABLE_SOUND_NOTIFICATIONS: false,
USER_CONSENTS_TO_ANALYTICS: false,
PROVIDER_TOKENS: {
github: "",
gitlab: "",
github: { token: "", base_domain: null },
gitlab: { token: "", base_domain: null },
},
IS_NEW_USER: true,
};

View File

@@ -6,10 +6,14 @@
@apply bg-tertiary border border-neutral-600 rounded;
}
.heading {
@apply text-[28px] leading-8 -tracking-[0.02em] font-bold text-content-2;
}
.skeleton {
@apply bg-gray-400 rounded-md animate-pulse;
}
.skeleton-round {
@apply bg-gray-400 rounded-full animate-pulse;
}
.heading {
@apply text-[28px] leading-8 -tracking-[0.02em] font-bold text-content-2;
}

View File

@@ -5,6 +5,11 @@ export const ProviderOptions = {
export type Provider = keyof typeof ProviderOptions;
export type ProviderToken = {
token: string;
base_domain: string | null;
};
export type Settings = {
LLM_MODEL: string;
LLM_BASE_URL: string;
@@ -14,11 +19,11 @@ export type Settings = {
CONFIRMATION_MODE: boolean;
SECURITY_ANALYZER: string;
REMOTE_RUNTIME_RESOURCE_FACTOR: number | null;
PROVIDER_TOKENS_SET: Record<Provider, boolean>;
PROVIDER_TOKENS_SET: Record<Provider, string | null>;
ENABLE_DEFAULT_CONDENSER: boolean;
ENABLE_SOUND_NOTIFICATIONS: boolean;
USER_CONSENTS_TO_ANALYTICS: boolean | null;
PROVIDER_TOKENS: Record<Provider, string>;
PROVIDER_TOKENS: Record<Provider, ProviderToken>;
IS_NEW_USER?: boolean;
};
@@ -35,17 +40,17 @@ export type ApiSettings = {
enable_default_condenser: boolean;
enable_sound_notifications: boolean;
user_consents_to_analytics: boolean | null;
provider_tokens: Record<Provider, string>;
provider_tokens_set: Record<Provider, boolean>;
provider_tokens: Record<Provider, ProviderToken>;
provider_tokens_set: Record<Provider, string | null>;
};
export type PostSettings = Settings & {
provider_tokens: Record<Provider, string>;
provider_tokens: Record<Provider, ProviderToken>;
user_consents_to_analytics: boolean | null;
llm_api_key?: string | null;
};
export type PostApiSettings = ApiSettings & {
provider_tokens: Record<Provider, string>;
provider_tokens: Record<Provider, ProviderToken>;
user_consents_to_analytics: boolean | null;
};

View File

@@ -2,9 +2,8 @@ import { DEFAULT_SETTINGS } from "#/services/settings";
import { Settings } from "#/types/settings";
export const hasAdvancedSettingsSet = (settings: Partial<Settings>): boolean =>
!!settings.LLM_BASE_URL ||
settings.AGENT !== DEFAULT_SETTINGS.AGENT ||
settings.REMOTE_RUNTIME_RESOURCE_FACTOR !==
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR ||
settings.CONFIRMATION_MODE ||
!!settings.SECURITY_ANALYZER;
Object.keys(settings).length > 0 &&
(!!settings.LLM_BASE_URL ||
settings.AGENT !== DEFAULT_SETTINGS.AGENT ||
settings.CONFIRMATION_MODE ||
!!settings.SECURITY_ANALYZER);

View File

@@ -0,0 +1,64 @@
---
name: add_openhands_repo_instruction
version: 1.0.0
author: openhands
agent: CodeActAgent
inputs:
- name: REPO_FOLDER_NAME
description: "Branch for the agent to work on"
required: false
---
Please browse the current repository under /workspace/{{ REPO_FOLDER_NAME }}, look at the documentation and relevant code, and understand the purpose of this repository.
Specifically, I want you to create a `.openhands/microagents/repo.md` file. This file should contain succinct information that summarizes (1) the purpose of this repository, (2) the general setup of this repo, and (3) a brief description of the structure of this repo.
Here's an example:
```markdown
---
name: repo
type: repo
agent: CodeActAgent
---
This repository contains the code for OpenHands, an automated AI software engineer. It has a Python backend
(in the `openhands` directory) and React frontend (in the `frontend` directory).
## General Setup:
To set up the entire repo, including frontend and backend, run `make build`.
You don't need to do this unless the user asks you to, or if you're trying to run the entire application.
Before pushing any changes, you should ensure that any lint errors or simple test errors have been fixed.
* If you've made changes to the backend, you should run `pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml`
* If you've made changes to the frontend, you should run `cd frontend && npm run lint:fix && npm run build ; cd ..`
If either command fails, it may have automatically fixed some issues. You should fix any issues that weren't automatically fixed,
then re-run the command to ensure it passes.
## Repository Structure
Backend:
- Located in the `openhands` directory
- Testing:
- All tests are in `tests/unit/test_*.py`
- To test new code, run `poetry run pytest tests/unit/test_xxx.py` where `xxx` is the appropriate file for the current functionality
- Write all tests with pytest
Frontend:
- Located in the `frontend` directory
- Prerequisites: A recent version of NodeJS / NPM
- Setup: Run `npm install` in the frontend directory
- Testing:
- Run tests: `npm run test`
- To run specific tests: `npm run test -- -t "TestName"`
- Building:
- Build for production: `npm run build`
- Environment Variables:
- Set in `frontend/.env` or as environment variables
- Available variables: VITE_BACKEND_HOST, VITE_USE_TLS, VITE_INSECURE_SKIP_VERIFY, VITE_FRONTEND_PORT
- Internationalization:
- Generate i18n declaration file: `npm run make-i18n`
```
Now, please write a similar markdown for the current repository.
Read all the GitHub workflows under .github/ of the repository (if this folder exists) to understand the CI checks (e.g., linter, pre-commit), and include those in the repo.md file.

View File

@@ -0,0 +1,19 @@
---
name: address_pr_comments
version: 1.0.0
author: openhands
agent: CodeActAgent
inputs:
- name: PR_URL
description: "URL of the pull request"
required: true
- name: BRANCH_NAME
description: "Branch name corresponds to the pull request"
required: true
---
First, check the branch {{ BRANCH_NAME }} and read the diff against the main branch to understand the purpose.
This branch corresponds to this PR {{ PR_URL }}
Next, you should use the GitHub API to read the reviews and comments on this PR and address them.

View File

@@ -1,32 +0,0 @@
---
name: agent_memory
type: knowledge
version: 1.0.0
agent: CodeActAgent
triggers:
- /remember
---
* Repository memory: Use .openhands/microagents/repo.md under each repository root to store and access important information.
- If this file exists, it will be added to your context automatically.
- If missing, you should create it unless the user has explicitly asked you to not do so.
* Store and maintain **general knowledge** that will be helpful for most future tasks:
1. Repository structure
2. Common commands (build, lint, test, pre-commit, etc.)
3. Code style preferences
4. Workflows and best practices
5. Any other repository-specific knowledge you learn
* IMPORTANT: ONLY LOG the information that would be helpful for different future tasks, for example, how to configure the settings, how to setup the repository. Do NOT add issue-specific information (e.g., what specific error you have ran into and how you fix it).
* When adding new information:
- ALWAYS ask for user confirmation first by listing the exact items (numbered 1, 2, 3, etc.) you plan to save to repo.md
- Only save the items the user approves (they may ask you to save a subset)
- Ensure it integrates nicely with existing knowledge in repo.md
- Reorganize the content if needed to maintain clarity and organization
- Group related information together under appropriate sections or headings
- If you've only explored a portion of the codebase, clearly note this limitation in the repository structure documentation
- If you don't know the essential commands for working with the repository, such as lint or typecheck, ask the user and suggest adding them to repo.md for future reference (with permission)
When you receive this message, please review and summarize your recent actions and observations, then present a list of valuable information that should be saved in repo.md to the user.

View File

@@ -0,0 +1,27 @@
---
name: get_test_to_pass
version: 1.0.0
author: openhands
agent: CodeActAgent
inputs:
- name: BRANCH_NAME
description: "Branch for the agent to work on"
required: true
- name: TEST_COMMAND_TO_RUN
description: "The test command you want the agent to work on. For example, `pytest tests/unit/test_bash_parsing.py`"
required: true
- name: FUNCTION_TO_FIX
description: "The name of function to fix"
required: false
- name: FILE_FOR_FUNCTION
description: "The path of the file that contains the function"
required: false
---
Can you check out branch "{{ BRANCH_NAME }}", and run {{ TEST_COMMAND_TO_RUN }}.
{%- if FUNCTION_TO_FIX and FILE_FOR_FUNCTION %}
Help me fix these tests to pass by fixing the {{ FUNCTION_TO_FIX }} function in file {{ FILE_FOR_FUNCTION }}.
{%- endif %}
PLEASE DO NOT modify the tests by yourselves -- Let me know if you think some of the tests are incorrect.

View File

@@ -0,0 +1,21 @@
---
name: update_pr_description
version: 1.0.0
author: openhands
agent: CodeActAgent
inputs:
- name: PR_URL
description: "URL of the pull request"
type: string
required: true
validation:
pattern: "^https://github.com/.+/.+/pull/[0-9]+$"
- name: BRANCH_NAME
description: "Branch name corresponds to the pull request"
type: string
required: true
---
Please check the branch "{{ BRANCH_NAME }}" and look at the diff against the main branch. This branch belongs to this PR "{{ PR_URL }}".
Once you understand the purpose of the diff, please use Github API to read the existing PR description, and update it to be more reflective of the changes we've made when necessary.

View File

@@ -0,0 +1,21 @@
---
name: update_test_for_new_implementation
version: 1.0.0
author: openhands
agent: CodeActAgent
inputs:
- name: BRANCH_NAME
description: "Branch for the agent to work on"
required: true
- name: TEST_COMMAND_TO_RUN
description: "The test command you want the agent to work on. For example, `pytest tests/unit/test_bash_parsing.py`"
required: true
---
Can you check out branch "{{ BRANCH_NAME }}", and run {{ TEST_COMMAND_TO_RUN }}.
{%- if FUNCTION_TO_FIX and FILE_FOR_FUNCTION %}
Help me fix these tests to pass by fixing the {{ FUNCTION_TO_FIX }} function in file {{ FILE_FOR_FUNCTION }}.
{%- endif %}
PLEASE DO NOT modify the tests by yourselves -- Let me know if you think some of the tests are incorrect.

View File

@@ -41,7 +41,7 @@ class GitHubService(BaseGitService, GitService):
if token:
self.token = token
if base_domain:
if base_domain and base_domain != "github.com":
self.BASE_URL = f'https://{base_domain}/api/v3'
@property

View File

@@ -34,6 +34,7 @@ from openhands.server.types import AppMode
class ProviderToken(BaseModel):
token: SecretStr | None = Field(default=None)
user_id: str | None = Field(default=None)
base_domain: str | None = Field(default=None)
model_config = {
'frozen': True, # Makes the entire model immutable
@@ -43,15 +44,20 @@ class ProviderToken(BaseModel):
@classmethod
def from_value(cls, token_value: ProviderToken | dict[str, str]) -> ProviderToken:
"""Factory method to create a ProviderToken from various input types"""
if isinstance(token_value, ProviderToken):
if isinstance(token_value, cls):
return token_value
elif isinstance(token_value, dict):
token_str = token_value.get('token')
user_id = token_value.get('user_id')
return cls(token=SecretStr(token_str), user_id=user_id)
base_domain = token_value.get('base_domain')
return cls(
token=SecretStr(token_str) if token_str is not None else None,
user_id=user_id,
base_domain=base_domain,
)
else:
raise ValueError('Unsupport Provider token type')
raise ValueError('Unsupported Provider token type')
PROVIDER_TOKEN_TYPE = MappingProxyType[ProviderType, ProviderToken]
@@ -98,6 +104,7 @@ class SecretStore(BaseModel):
if expose_secrets
else pydantic_encoder(provider_token.token),
'user_id': provider_token.user_id,
'base_domain': provider_token.base_domain,
}
return tokens

View File

@@ -20,7 +20,6 @@ from openhands.server.shared import config
from openhands.storage.data_models.settings import Settings
from openhands.server.user_auth import (
get_provider_tokens,
get_user_id,
get_user_settings,
get_user_settings_store,
)
@@ -31,7 +30,6 @@ app = APIRouter(prefix='/api')
@app.get('/settings', response_model=GETSettingsModel)
async def load_settings(
user_id: str | None = Depends(get_user_id),
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
settings: Settings | None = Depends(get_user_settings),
) -> GETSettingsModel | JSONResponse:
@@ -43,18 +41,10 @@ async def load_settings(
)
provider_tokens_set = {}
if bool(user_id):
provider_tokens_set[ProviderType.GITHUB.value] = True
if provider_tokens:
all_provider_types = [provider.value for provider in ProviderType]
provider_tokens_types = [provider.value for provider in provider_tokens]
for provider_type in all_provider_types:
if provider_type in provider_tokens_types:
provider_tokens_set[provider_type] = True
else:
provider_tokens_set[provider_type] = False
for provider_type, provider_token in provider_tokens.items():
if provider_token.token or provider_token.user_id:
provider_tokens_set[provider_type] = provider_token.base_domain
settings_with_token_data = GETSettingsModel(
**settings.model_dump(exclude='secrets_store'),
@@ -218,66 +208,80 @@ async def reset_settings() -> JSONResponse:
)
async def check_provider_tokens(settings: POSTSettingsModel) -> str:
async def check_provider_tokens(settings: POSTSettingsModel, existing_settings: Settings | None) -> str:
if settings.provider_tokens:
# Remove extraneous token types
provider_types = [provider.value for provider in ProviderType]
provider_types = [provider for provider in ProviderType]
settings.provider_tokens = {
k: v for k, v in settings.provider_tokens.items() if k in provider_types
}
# Determine whether tokens are valid
for token_type, token_value in settings.provider_tokens.items():
if token_value:
confirmed_token_type = await validate_provider_token(
SecretStr(token_value)
)
if not confirmed_token_type or confirmed_token_type.value != token_type:
return f'Invalid token. Please make sure it is a valid {token_type} token.'
for provider_type, provider_token in settings.provider_tokens.items():
token_value = provider_token
existing_token = existing_settings.secrets_store.provider_tokens.get(provider_type, None) if existing_settings else None
# Use incoming value otherwise default to existing value
token = SecretStr("")
if token_value.token:
token = token_value.token
elif existing_token and existing_token.token:
token = existing_token.token
if not token:
continue
base_domain = provider_token.base_domain # FE should always send latest base_domain param
confirmed_token_type = await validate_provider_token(
token,
base_domain
)
if not confirmed_token_type or confirmed_token_type != provider_type:
return f'Invalid {provider_type.value} token or base domain.'
return ''
async def store_provider_tokens(
settings: POSTSettingsModel, settings_store: SettingsStore
settings: POSTSettingsModel, existing_settings: Settings
):
existing_settings = await settings_store.load()
if existing_settings:
if settings.provider_tokens:
if existing_settings.secrets_store:
existing_providers = [
provider.value
for provider in existing_settings.secrets_store.provider_tokens
]
existing_providers = [
provider
for provider in existing_settings.secrets_store.provider_tokens
]
# Merge incoming settings store with the existing one
for provider, token_value in list(settings.provider_tokens.items()):
if provider in existing_providers and not token_value:
provider_type = ProviderType(provider)
existing_token = (
existing_settings.secrets_store.provider_tokens.get(
provider_type
)
# Merge incoming settings store with the existing one
for provider, token_value in settings.provider_tokens.items():
if provider in existing_providers and not token_value.token:
provider_type = ProviderType(provider)
existing_token = (
existing_settings.secrets_store.provider_tokens.get(
provider_type
)
if existing_token and existing_token.token:
settings.provider_tokens[provider] = (
existing_token.token.get_secret_value()
)
)
if existing_token:
updated_token = ProviderToken(
token=existing_token.token,
user_id=existing_token.user_id,
base_domain=token_value.base_domain
)
settings.provider_tokens[provider] = updated_token
else: # nothing passed in means keep current settings
provider_tokens = existing_settings.secrets_store.provider_tokens
settings.provider_tokens = {
provider.value: data.token.get_secret_value() if data.token else None
for provider, data in provider_tokens.items()
}
settings.provider_tokens = dict(existing_settings.secrets_store.provider_tokens)
return settings
async def store_llm_settings(
settings: POSTSettingsModel, settings_store: SettingsStore
settings: POSTSettingsModel, existing_settings: Settings
) -> POSTSettingsModel:
existing_settings = await settings_store.load()
# Convert to Settings model and merge with existing settings
if existing_settings:
# Keep existing LLM settings if not provided
@@ -295,9 +299,10 @@ async def store_llm_settings(
async def store_settings(
settings: POSTSettingsModel,
settings_store: SettingsStore = Depends(get_user_settings_store),
existing_settings: Settings | None = Depends(get_user_settings),
) -> JSONResponse:
# Check provider tokens are valid
provider_err_msg = await check_provider_tokens(settings)
provider_err_msg = await check_provider_tokens(settings, existing_settings)
if provider_err_msg:
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -305,11 +310,9 @@ async def store_settings(
)
try:
existing_settings = await settings_store.load()
# Convert to Settings model and merge with existing settings
if existing_settings:
settings = await store_llm_settings(settings, settings_store)
settings = await store_llm_settings(settings, existing_settings)
# Keep existing analytics consent if not provided
if settings.user_consents_to_analytics is None:
@@ -317,7 +320,7 @@ async def store_settings(
existing_settings.user_consents_to_analytics
)
settings = await store_provider_tokens(settings, settings_store)
settings = await store_provider_tokens(settings, existing_settings)
# Update sandbox config with new settings
if settings.remote_runtime_resource_factor is not None:
@@ -357,17 +360,9 @@ def convert_to_settings(settings_with_token_data: POSTSettingsModel) -> Settings
# Create new provider tokens immutably
if settings_with_token_data.provider_tokens:
tokens = {}
for token_type, token_value in settings_with_token_data.provider_tokens.items():
if token_value:
provider = ProviderType(token_type)
tokens[provider] = ProviderToken(
token=SecretStr(token_value), user_id=None
)
# Create new SecretStore with tokens
settings = settings.model_copy(
update={'secrets_store': SecretStore(provider_tokens=tokens)}
update={'secrets_store': SecretStore(provider_tokens=settings_with_token_data.provider_tokens)}
)
return settings

View File

@@ -5,6 +5,8 @@ from pydantic import (
SecretStr,
)
from openhands.integrations.provider import ProviderToken
from openhands.integrations.service_types import ProviderType
from openhands.storage.data_models.settings import Settings
@@ -13,7 +15,7 @@ class POSTSettingsModel(Settings):
Settings for POST requests
"""
provider_tokens: dict[str, str] = {}
provider_tokens: dict[ProviderType, ProviderToken] = {}
class POSTSettingsCustomSecrets(BaseModel):
@@ -29,9 +31,14 @@ class GETSettingsModel(Settings):
Settings with additional token data for the frontend
"""
provider_tokens_set: dict[str, bool] | None = None
provider_tokens_set: dict[ProviderType, str | None] | None = (
None # Provider Type and base domain key-value pair
)
llm_api_key_set: bool
class Config:
use_enum_values = True
class GETSettingsCustomSecrets(BaseModel):
"""

147
poetry.lock generated
View File

@@ -496,18 +496,18 @@ files = [
[[package]]
name = "boto3"
version = "1.38.2"
version = "1.38.3"
description = "The AWS SDK for Python"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "boto3-1.38.2-py3-none-any.whl", hash = "sha256:ef3237b169cd906a44a32c03b3229833d923c9e9733355b329ded2151f91ec0b"},
{file = "boto3-1.38.2.tar.gz", hash = "sha256:53c8d44b231251fa9421dd13d968236d59fe2cf0421e077afedbf3821653fb3b"},
{file = "boto3-1.38.3-py3-none-any.whl", hash = "sha256:9218f86e2164e1bddb75d435bbde4fa651aa58687213d7e3e1b50f7eb8868f66"},
{file = "boto3-1.38.3.tar.gz", hash = "sha256:655d51abcd68a40a33c52dbaa2ca73fc63c746b894e2ae22ed8ddc1912ddd93f"},
]
[package.dependencies]
botocore = ">=1.38.2,<1.39.0"
botocore = ">=1.38.3,<1.39.0"
jmespath = ">=0.7.1,<2.0.0"
s3transfer = ">=0.12.0,<0.13.0"
@@ -516,14 +516,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
[[package]]
name = "boto3-stubs"
version = "1.38.2"
description = "Type annotations for boto3 1.38.2 generated with mypy-boto3-builder 8.10.1"
version = "1.38.3"
description = "Type annotations for boto3 1.38.3 generated with mypy-boto3-builder 8.10.1"
optional = false
python-versions = ">=3.8"
groups = ["evaluation"]
files = [
{file = "boto3_stubs-1.38.2-py3-none-any.whl", hash = "sha256:e18f2dc194c4b8a29f61275ba039689d063c4775a78560e35a5ce820ec257fb5"},
{file = "boto3_stubs-1.38.2.tar.gz", hash = "sha256:405cd777d41530cf8ed009d20b04daef1f7d4bd2fd9fd3636ac86eccdb55159c"},
{file = "boto3_stubs-1.38.3-py3-none-any.whl", hash = "sha256:93a2c38987dd0ee19a661e8fd9a77fb4b4a30e56f63115701c307bfc55e2695c"},
{file = "boto3_stubs-1.38.3.tar.gz", hash = "sha256:e406626de8daf537984678355ad0e32d838865c4ea3d223268964d4e6fb44534"},
]
[package.dependencies]
@@ -579,7 +579,7 @@ bedrock-data-automation-runtime = ["mypy-boto3-bedrock-data-automation-runtime (
bedrock-runtime = ["mypy-boto3-bedrock-runtime (>=1.38.0,<1.39.0)"]
billing = ["mypy-boto3-billing (>=1.38.0,<1.39.0)"]
billingconductor = ["mypy-boto3-billingconductor (>=1.38.0,<1.39.0)"]
boto3 = ["boto3 (==1.38.2)"]
boto3 = ["boto3 (==1.38.3)"]
braket = ["mypy-boto3-braket (>=1.38.0,<1.39.0)"]
budgets = ["mypy-boto3-budgets (>=1.38.0,<1.39.0)"]
ce = ["mypy-boto3-ce (>=1.38.0,<1.39.0)"]
@@ -943,14 +943,14 @@ xray = ["mypy-boto3-xray (>=1.38.0,<1.39.0)"]
[[package]]
name = "botocore"
version = "1.38.2"
version = "1.38.3"
description = "Low-level, data-driven core of boto 3."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "botocore-1.38.2-py3-none-any.whl", hash = "sha256:5d9cffedb1c759a058b43793d16647ed44ec87072f98a1bd6cd673ac0ae6b81d"},
{file = "botocore-1.38.2.tar.gz", hash = "sha256:b688a9bd17211a1eaae3a6c965ba9f3973e5435efaaa4fa201f499d3467830e1"},
{file = "botocore-1.38.3-py3-none-any.whl", hash = "sha256:96f823240fe3704b99c17d1d1b2fd2d1679cf56d2a55b095f00255b76087cbf0"},
{file = "botocore-1.38.3.tar.gz", hash = "sha256:790f8f966201781f5fcf486d48b4492e9f734446bbf9d19ef8159d08be854243"},
]
[package.dependencies]
@@ -2663,7 +2663,7 @@ grpcio = {version = ">=1.49.1,<2.0dev", optional = true, markers = "python_versi
grpcio-status = {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
proto-plus = [
{version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0dev"},
{version = ">=1.22.3,<2.0.0dev", markers = "python_version < \"3.13\""},
]
protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0"
requests = ">=2.18.0,<3.0.0.dev0"
@@ -2878,7 +2878,7 @@ google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev"
grpc-google-iam-v1 = ">=0.14.0,<1.0.0dev"
proto-plus = [
{version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0dev", markers = "python_version < \"3.13\""},
{version = ">=1.22.3,<2.0.0dev"},
]
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev"
@@ -3794,14 +3794,14 @@ files = [
[[package]]
name = "json-repair"
version = "0.42.0"
version = "0.43.0"
description = "A package to repair broken json strings"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "json_repair-0.42.0-py3-none-any.whl", hash = "sha256:7b6805162053dfe65722e961bc51b5eecec0582ec8a8e0fd218d33e8de757daf"},
{file = "json_repair-0.42.0.tar.gz", hash = "sha256:1a901f706c5b6b4325f0f79b53b0d998c5b327070e98b530da71cc5a3eda8616"},
{file = "json_repair-0.43.0-py3-none-any.whl", hash = "sha256:3f2b66819c9f5e29edd5dd4851223b72d10ed816b6423b3c92e424090c3ffc1d"},
{file = "json_repair-0.43.0.tar.gz", hash = "sha256:77cc6eda6f407ff5fe9544f962e42b332cca1e8c9f3f9f9dc660327028e0d651"},
]
[[package]]
@@ -4882,14 +4882,14 @@ files = [
[[package]]
name = "modal"
version = "0.74.23"
version = "0.74.30"
description = "Python client library for Modal"
optional = false
python-versions = ">=3.9"
groups = ["main", "evaluation"]
files = [
{file = "modal-0.74.23-py3-none-any.whl", hash = "sha256:96c397487ed5f499ad040b5edf5f378ada8e0676da17523a2d6fadb3f1d384e1"},
{file = "modal-0.74.23.tar.gz", hash = "sha256:3a042cdf482975b43341da0b33fa6a6adae06978ead69a086ca658a7dcb0cd6d"},
{file = "modal-0.74.30-py3-none-any.whl", hash = "sha256:46006cb57309171fe36ee41528a7cc8c0e67c88afd9bf04a9900313c18925aa4"},
{file = "modal-0.74.30.tar.gz", hash = "sha256:14bd2ea0ebc9ab1ebce29ea76ddf12047f23599983725c5f82990ae97bea05c7"},
]
[package.dependencies]
@@ -6343,54 +6343,67 @@ files = [
[[package]]
name = "pyarrow"
version = "19.0.1"
version = "20.0.0"
description = "Python library for Apache Arrow"
optional = false
python-versions = ">=3.9"
groups = ["evaluation"]
files = [
{file = "pyarrow-19.0.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:fc28912a2dc924dddc2087679cc8b7263accc71b9ff025a1362b004711661a69"},
{file = "pyarrow-19.0.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:fca15aabbe9b8355800d923cc2e82c8ef514af321e18b437c3d782aa884eaeec"},
{file = "pyarrow-19.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad76aef7f5f7e4a757fddcdcf010a8290958f09e3470ea458c80d26f4316ae89"},
{file = "pyarrow-19.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d03c9d6f2a3dffbd62671ca070f13fc527bb1867b4ec2b98c7eeed381d4f389a"},
{file = "pyarrow-19.0.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:65cf9feebab489b19cdfcfe4aa82f62147218558d8d3f0fc1e9dea0ab8e7905a"},
{file = "pyarrow-19.0.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:41f9706fbe505e0abc10e84bf3a906a1338905cbbcf1177b71486b03e6ea6608"},
{file = "pyarrow-19.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6cb2335a411b713fdf1e82a752162f72d4a7b5dbc588e32aa18383318b05866"},
{file = "pyarrow-19.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:cc55d71898ea30dc95900297d191377caba257612f384207fe9f8293b5850f90"},
{file = "pyarrow-19.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:7a544ec12de66769612b2d6988c36adc96fb9767ecc8ee0a4d270b10b1c51e00"},
{file = "pyarrow-19.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0148bb4fc158bfbc3d6dfe5001d93ebeed253793fff4435167f6ce1dc4bddeae"},
{file = "pyarrow-19.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f24faab6ed18f216a37870d8c5623f9c044566d75ec586ef884e13a02a9d62c5"},
{file = "pyarrow-19.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:4982f8e2b7afd6dae8608d70ba5bd91699077323f812a0448d8b7abdff6cb5d3"},
{file = "pyarrow-19.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:49a3aecb62c1be1d822f8bf629226d4a96418228a42f5b40835c1f10d42e4db6"},
{file = "pyarrow-19.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:008a4009efdb4ea3d2e18f05cd31f9d43c388aad29c636112c2966605ba33466"},
{file = "pyarrow-19.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:80b2ad2b193e7d19e81008a96e313fbd53157945c7be9ac65f44f8937a55427b"},
{file = "pyarrow-19.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:ee8dec072569f43835932a3b10c55973593abc00936c202707a4ad06af7cb294"},
{file = "pyarrow-19.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d5d1ec7ec5324b98887bdc006f4d2ce534e10e60f7ad995e7875ffa0ff9cb14"},
{file = "pyarrow-19.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3ad4c0eb4e2a9aeb990af6c09e6fa0b195c8c0e7b272ecc8d4d2b6574809d34"},
{file = "pyarrow-19.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d383591f3dcbe545f6cc62daaef9c7cdfe0dff0fb9e1c8121101cabe9098cfa6"},
{file = "pyarrow-19.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b4c4156a625f1e35d6c0b2132635a237708944eb41df5fbe7d50f20d20c17832"},
{file = "pyarrow-19.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:5bd1618ae5e5476b7654c7b55a6364ae87686d4724538c24185bbb2952679960"},
{file = "pyarrow-19.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e45274b20e524ae5c39d7fc1ca2aa923aab494776d2d4b316b49ec7572ca324c"},
{file = "pyarrow-19.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:d9dedeaf19097a143ed6da37f04f4051aba353c95ef507764d344229b2b740ae"},
{file = "pyarrow-19.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ebfb5171bb5f4a52319344ebbbecc731af3f021e49318c74f33d520d31ae0c4"},
{file = "pyarrow-19.0.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a21d39fbdb948857f67eacb5bbaaf36802de044ec36fbef7a1c8f0dd3a4ab2"},
{file = "pyarrow-19.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:99bc1bec6d234359743b01e70d4310d0ab240c3d6b0da7e2a93663b0158616f6"},
{file = "pyarrow-19.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1b93ef2c93e77c442c979b0d596af45e4665d8b96da598db145b0fec014b9136"},
{file = "pyarrow-19.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9d46e06846a41ba906ab25302cf0fd522f81aa2a85a71021826f34639ad31ef"},
{file = "pyarrow-19.0.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:c0fe3dbbf054a00d1f162fda94ce236a899ca01123a798c561ba307ca38af5f0"},
{file = "pyarrow-19.0.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:96606c3ba57944d128e8a8399da4812f56c7f61de8c647e3470b417f795d0ef9"},
{file = "pyarrow-19.0.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f04d49a6b64cf24719c080b3c2029a3a5b16417fd5fd7c4041f94233af732f3"},
{file = "pyarrow-19.0.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a9137cf7e1640dce4c190551ee69d478f7121b5c6f323553b319cac936395f6"},
{file = "pyarrow-19.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7c1bca1897c28013db5e4c83944a2ab53231f541b9e0c3f4791206d0c0de389a"},
{file = "pyarrow-19.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:58d9397b2e273ef76264b45531e9d552d8ec8a6688b7390b5be44c02a37aade8"},
{file = "pyarrow-19.0.1-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:b9766a47a9cb56fefe95cb27f535038b5a195707a08bf61b180e642324963b46"},
{file = "pyarrow-19.0.1-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:6c5941c1aac89a6c2f2b16cd64fe76bcdb94b2b1e99ca6459de4e6f07638d755"},
{file = "pyarrow-19.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd44d66093a239358d07c42a91eebf5015aa54fccba959db899f932218ac9cc8"},
{file = "pyarrow-19.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:335d170e050bcc7da867a1ed8ffb8b44c57aaa6e0843b156a501298657b1e972"},
{file = "pyarrow-19.0.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:1c7556165bd38cf0cd992df2636f8bcdd2d4b26916c6b7e646101aff3c16f76f"},
{file = "pyarrow-19.0.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:699799f9c80bebcf1da0983ba86d7f289c5a2a5c04b945e2f2bcf7e874a91911"},
{file = "pyarrow-19.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:8464c9fbe6d94a7fe1599e7e8965f350fd233532868232ab2596a71586c5a429"},
{file = "pyarrow-19.0.1.tar.gz", hash = "sha256:3bf266b485df66a400f282ac0b6d1b500b9d2ae73314a153dbe97d6d5cc8a99e"},
{file = "pyarrow-20.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:c7dd06fd7d7b410ca5dc839cc9d485d2bc4ae5240851bcd45d85105cc90a47d7"},
{file = "pyarrow-20.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:d5382de8dc34c943249b01c19110783d0d64b207167c728461add1ecc2db88e4"},
{file = "pyarrow-20.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6415a0d0174487456ddc9beaead703d0ded5966129fa4fd3114d76b5d1c5ceae"},
{file = "pyarrow-20.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15aa1b3b2587e74328a730457068dc6c89e6dcbf438d4369f572af9d320a25ee"},
{file = "pyarrow-20.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:5605919fbe67a7948c1f03b9f3727d82846c053cd2ce9303ace791855923fd20"},
{file = "pyarrow-20.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a5704f29a74b81673d266e5ec1fe376f060627c2e42c5c7651288ed4b0db29e9"},
{file = "pyarrow-20.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:00138f79ee1b5aca81e2bdedb91e3739b987245e11fa3c826f9e57c5d102fb75"},
{file = "pyarrow-20.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f2d67ac28f57a362f1a2c1e6fa98bfe2f03230f7e15927aecd067433b1e70ce8"},
{file = "pyarrow-20.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:4a8b029a07956b8d7bd742ffca25374dd3f634b35e46cc7a7c3fa4c75b297191"},
{file = "pyarrow-20.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:24ca380585444cb2a31324c546a9a56abbe87e26069189e14bdba19c86c049f0"},
{file = "pyarrow-20.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:95b330059ddfdc591a3225f2d272123be26c8fa76e8c9ee1a77aad507361cfdb"},
{file = "pyarrow-20.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f0fb1041267e9968c6d0d2ce3ff92e3928b243e2b6d11eeb84d9ac547308232"},
{file = "pyarrow-20.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8ff87cc837601532cc8242d2f7e09b4e02404de1b797aee747dd4ba4bd6313f"},
{file = "pyarrow-20.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:7a3a5dcf54286e6141d5114522cf31dd67a9e7c9133d150799f30ee302a7a1ab"},
{file = "pyarrow-20.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a6ad3e7758ecf559900261a4df985662df54fb7fdb55e8e3b3aa99b23d526b62"},
{file = "pyarrow-20.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6bb830757103a6cb300a04610e08d9636f0cd223d32f388418ea893a3e655f1c"},
{file = "pyarrow-20.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96e37f0766ecb4514a899d9a3554fadda770fb57ddf42b63d80f14bc20aa7db3"},
{file = "pyarrow-20.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:3346babb516f4b6fd790da99b98bed9708e3f02e734c84971faccb20736848dc"},
{file = "pyarrow-20.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:75a51a5b0eef32727a247707d4755322cb970be7e935172b6a3a9f9ae98404ba"},
{file = "pyarrow-20.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:211d5e84cecc640c7a3ab900f930aaff5cd2702177e0d562d426fb7c4f737781"},
{file = "pyarrow-20.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ba3cf4182828be7a896cbd232aa8dd6a31bd1f9e32776cc3796c012855e1199"},
{file = "pyarrow-20.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c3a01f313ffe27ac4126f4c2e5ea0f36a5fc6ab51f8726cf41fee4b256680bd"},
{file = "pyarrow-20.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:a2791f69ad72addd33510fec7bb14ee06c2a448e06b649e264c094c5b5f7ce28"},
{file = "pyarrow-20.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4250e28a22302ce8692d3a0e8ec9d9dde54ec00d237cff4dfa9c1fbf79e472a8"},
{file = "pyarrow-20.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:89e030dc58fc760e4010148e6ff164d2f44441490280ef1e97a542375e41058e"},
{file = "pyarrow-20.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6102b4864d77102dbbb72965618e204e550135a940c2534711d5ffa787df2a5a"},
{file = "pyarrow-20.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:96d6a0a37d9c98be08f5ed6a10831d88d52cac7b13f5287f1e0f625a0de8062b"},
{file = "pyarrow-20.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a15532e77b94c61efadde86d10957950392999503b3616b2ffcef7621a002893"},
{file = "pyarrow-20.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:dd43f58037443af715f34f1322c782ec463a3c8a94a85fdb2d987ceb5658e061"},
{file = "pyarrow-20.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa0d288143a8585806e3cc7c39566407aab646fb9ece164609dac1cfff45f6ae"},
{file = "pyarrow-20.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6953f0114f8d6f3d905d98e987d0924dabce59c3cda380bdfaa25a6201563b4"},
{file = "pyarrow-20.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:991f85b48a8a5e839b2128590ce07611fae48a904cae6cab1f089c5955b57eb5"},
{file = "pyarrow-20.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:97c8dc984ed09cb07d618d57d8d4b67a5100a30c3818c2fb0b04599f0da2de7b"},
{file = "pyarrow-20.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9b71daf534f4745818f96c214dbc1e6124d7daf059167330b610fc69b6f3d3e3"},
{file = "pyarrow-20.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8b88758f9303fa5a83d6c90e176714b2fd3852e776fc2d7e42a22dd6c2fb368"},
{file = "pyarrow-20.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:30b3051b7975801c1e1d387e17c588d8ab05ced9b1e14eec57915f79869b5031"},
{file = "pyarrow-20.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:ca151afa4f9b7bc45bcc791eb9a89e90a9eb2772767d0b1e5389609c7d03db63"},
{file = "pyarrow-20.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:4680f01ecd86e0dd63e39eb5cd59ef9ff24a9d166db328679e36c108dc993d4c"},
{file = "pyarrow-20.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f4c8534e2ff059765647aa69b75d6543f9fef59e2cd4c6d18015192565d2b70"},
{file = "pyarrow-20.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e1f8a47f4b4ae4c69c4d702cfbdfe4d41e18e5c7ef6f1bb1c50918c1e81c57b"},
{file = "pyarrow-20.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:a1f60dc14658efaa927f8214734f6a01a806d7690be4b3232ba526836d216122"},
{file = "pyarrow-20.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:204a846dca751428991346976b914d6d2a82ae5b8316a6ed99789ebf976551e6"},
{file = "pyarrow-20.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f3b117b922af5e4c6b9a9115825726cac7d8b1421c37c2b5e24fbacc8930612c"},
{file = "pyarrow-20.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e724a3fd23ae5b9c010e7be857f4405ed5e679db5c93e66204db1a69f733936a"},
{file = "pyarrow-20.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:82f1ee5133bd8f49d31be1299dc07f585136679666b502540db854968576faf9"},
{file = "pyarrow-20.0.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:1bcbe471ef3349be7714261dea28fe280db574f9d0f77eeccc195a2d161fd861"},
{file = "pyarrow-20.0.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:a18a14baef7d7ae49247e75641fd8bcbb39f44ed49a9fc4ec2f65d5031aa3b96"},
{file = "pyarrow-20.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb497649e505dc36542d0e68eca1a3c94ecbe9799cb67b578b55f2441a247fbc"},
{file = "pyarrow-20.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11529a2283cb1f6271d7c23e4a8f9f8b7fd173f7360776b668e509d712a02eec"},
{file = "pyarrow-20.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:6fc1499ed3b4b57ee4e090e1cea6eb3584793fe3d1b4297bbf53f09b434991a5"},
{file = "pyarrow-20.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:db53390eaf8a4dab4dbd6d93c85c5cf002db24902dbff0ca7d988beb5c9dd15b"},
{file = "pyarrow-20.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:851c6a8260ad387caf82d2bbf54759130534723e37083111d4ed481cb253cc0d"},
{file = "pyarrow-20.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e22f80b97a271f0a7d9cd07394a7d348f80d3ac63ed7cc38b6d1b696ab3b2619"},
{file = "pyarrow-20.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:9965a050048ab02409fb7cbbefeedba04d3d67f2cc899eff505cc084345959ca"},
{file = "pyarrow-20.0.0.tar.gz", hash = "sha256:febc4a913592573c8d5805091a6c2b5064c8bd6e002131f01061797d91c783c1"},
]
[package.extras]
@@ -7977,14 +7990,14 @@ files = [
[[package]]
name = "runloop-api-client"
version = "0.31.0"
version = "0.32.0"
description = "The official Python library for the runloop API"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "runloop_api_client-0.31.0-py3-none-any.whl", hash = "sha256:1eb716a20b268e081bdbcf5b5d1df9ab6eb258a0b929130210a3b643048159c7"},
{file = "runloop_api_client-0.31.0.tar.gz", hash = "sha256:78992595fd34f98470aa73b8f5b92983414e4878218239e531a9371c5570a13d"},
{file = "runloop_api_client-0.32.0-py3-none-any.whl", hash = "sha256:37f156f711b1aa4cef86c0f779cc27afa43ce3d3f6b1976d7f68667466317a6d"},
{file = "runloop_api_client-0.32.0.tar.gz", hash = "sha256:735a967d96b5c3e8a08b89072722adcbe2b10ed904268d3f45785b7cfd5420d1"},
]
[package.dependencies]
@@ -10256,4 +10269,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
[metadata]
lock-version = "2.1"
python-versions = "^3.12"
content-hash = "ce44ae3718979f1878cbdd4e2f9595a6d73c2ffb0b9256e3ec163397a3916b25"
content-hash = "d3f933e9abf6be481ec137e14f8f7ac502afd591a9ba74b315737fd894ca5cfe"

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "openhands-ai"
version = "0.35.2"
version = "0.34.0"
description = "OpenHands: Code Less, Make More"
authors = ["OpenHands"]
license = "MIT"
@@ -58,7 +58,7 @@ protobuf = "^4.21.6,<5.0.0" # chromadb currently fails on 5.0+
opentelemetry-api = "1.25.0"
opentelemetry-exporter-otlp-proto-grpc = "1.25.0"
modal = ">=0.66.26,<0.75.0"
runloop-api-client = "0.31.0"
runloop-api-client = "0.32.0"
libtmux = ">=0.37,<0.40"
pygithub = "^2.5.0"
joblib = "*"
@@ -146,7 +146,7 @@ browsergym-webarena = "0.13.3"
browsergym-miniwob = "0.13.3"
browsergym-visualwebarena = "0.13.3"
boto3-stubs = {extras = ["s3"], version = "^1.37.19"}
pyarrow = "19.0.1" # transitive dependency, pinned here to avoid conflicts
pyarrow = "20.0.0" # transitive dependency, pinned here to avoid conflicts
datasets = "*"
[tool.poetry-dynamic-versioning]