Compare commits

..

6 Commits

Author SHA1 Message Date
openhands
eed35ba34b Move rate limiter to middleware.py and use in-memory store
- Move rate limiter classes to middleware.py
- Use in-memory store instead of Redis
- Keep same rate limits:
  * Default: 2 req/sec
  * Static files: 10 req/sec
  * WebSocket and authenticate endpoints: 1 req/5s
2024-11-12 21:32:03 +00:00
openhands
9ec47bee73 Move rate limiter to middleware.py and use in-memory store
- Move rate limiter classes to middleware.py
- Use in-memory store instead of Redis
- Keep same rate limits:
  * Default: 2 req/sec
  * Static files: 10 req/sec
  * WebSocket and authenticate endpoints: 1 req/5s
2024-11-12 21:27:12 +00:00
openhands
17eedeeba9 Switch to in-memory rate limiter implementation
- Remove Redis dependency
- Implement custom in-memory rate limiter with thread-safe storage
- Keep same rate limits:
  * Default: 2 req/sec
  * Static files: 10 req/sec
  * WebSocket and authenticate endpoints: 1 req/5s
2024-11-12 21:22:32 +00:00
openhands
253e19c66c Add type ignore for redis import 2024-11-12 21:19:34 +00:00
openhands
f6742c5af7 Apply code formatting changes 2024-11-12 21:19:09 +00:00
openhands
ce9963db01 Add rate limiting to FastAPI server
- Default rate limit: 2 req/sec
- Static files: 10 req/sec
- WebSocket endpoint: 1 req/5s
- Authenticate endpoint: 1 req/5s

Uses fastapi-limiter with Redis backend for rate limiting implementation.
2024-11-12 21:13:19 +00:00
47 changed files with 281 additions and 1026 deletions

View File

@@ -1,20 +0,0 @@
# LiteLLM Proxy
OpenHands supports using the [LiteLLM proxy](https://docs.litellm.ai/docs/proxy/quick_start) to access various LLM providers.
## Configuration
To use LiteLLM proxy with OpenHands, you need to:
1. Set up a LiteLLM proxy server (see [LiteLLM documentation](https://docs.litellm.ai/docs/proxy/quick_start))
2. When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings:
* Enable `Advanced Options`
* `Custom Model` to the prefix `litellm_proxy/` + the model you will be using (e.g. `litellm_proxy/anthropic.claude-3-5-sonnet-20241022-v2:0`)
* `Base URL` to your LiteLLM proxy URL (e.g. `https://your-litellm-proxy.com`)
* `API Key` to your LiteLLM proxy API key
## Supported Models
The supported models depend on your LiteLLM proxy configuration. OpenHands supports any model that your LiteLLM proxy is configured to handle.
Refer to your LiteLLM proxy configuration for the list of available models and their names.

View File

@@ -63,7 +63,6 @@ We have a few guides for running OpenHands with specific model providers:
- [Azure](llms/azure-llms)
- [Google](llms/google-llms)
- [Groq](llms/groq)
- [LiteLLM Proxy](llms/litellm-proxy)
- [OpenAI](llms/openai-llms)
- [OpenRouter](llms/openrouter)

View File

@@ -76,11 +76,6 @@ const sidebars: SidebarsConfig = {
label: 'Groq',
id: 'usage/llms/groq',
},
{
type: 'doc',
label: 'LiteLLM Proxy',
id: 'usage/llms/litellm-proxy',
},
{
type: 'doc',
label: 'OpenAI',

View File

@@ -36,8 +36,8 @@ from openhands.events.action import CmdRunAction, MessageAction
from openhands.events.observation import CmdOutputObservation, ErrorObservation
from openhands.events.serialization.event import event_to_dict
from openhands.runtime.base import Runtime
from openhands.runtime.utils.shutdown_listener import sleep_if_should_continue
from openhands.utils.async_utils import call_async_from_sync
from openhands.utils.shutdown_listener import sleep_if_should_continue
USE_HINT_TEXT = os.environ.get('USE_HINT_TEXT', 'false').lower() == 'true'
USE_INSTANCE_IMAGE = os.environ.get('USE_INSTANCE_IMAGE', 'false').lower() == 'true'

View File

@@ -26,7 +26,7 @@
"isbot": "^5.1.17",
"jose": "^5.9.4",
"monaco-editor": "^0.52.0",
"posthog-js": "^1.184.1",
"posthog-js": "^1.176.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-highlight": "^0.15.0",
@@ -19749,9 +19749,9 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
},
"node_modules/posthog-js": {
"version": "1.184.1",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.184.1.tgz",
"integrity": "sha512-q/1Kdard5SZnL2smrzeKcD+RuUi2PnbidiN4D3ThK20bNrhy5Z2heIy9SnRMvEiARY5lcQ7zxmDCAKPBKGSOtQ==",
"version": "1.176.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.176.0.tgz",
"integrity": "sha512-T5XKNtRzp7q6CGb7Vc7wAI76rWap9fiuDUPxPsyPBPDkreKya91x9RIsSapAVFafwD1AEin1QMczCmt9Le9BWw==",
"dependencies": {
"core-js": "^3.38.1",
"fflate": "^0.4.8",

View File

@@ -25,7 +25,7 @@
"isbot": "^5.1.17",
"jose": "^5.9.4",
"monaco-editor": "^0.52.0",
"posthog-js": "^1.184.1",
"posthog-js": "^1.176.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-highlight": "^0.15.0",
@@ -120,4 +120,4 @@
"public"
]
}
}
}

View File

@@ -1,5 +1,4 @@
{
"APP_MODE": "oss",
"GITHUB_CLIENT_ID": "",
"POSTHOG_CLIENT_KEY": "phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA"
}
"GITHUB_CLIENT_ID": ""
}

View File

@@ -8,7 +8,6 @@ import {
GitHubAccessTokenResponse,
ErrorResponse,
GetConfigResponse,
GetVSCodeUrlResponse,
} from "./open-hands.types";
class OpenHands {
@@ -175,14 +174,6 @@ class OpenHands {
true,
);
}
/**
* Get the VSCode URL
* @returns VSCode URL
*/
static async getVSCodeUrl(): Promise<GetVSCodeUrlResponse> {
return request(`/api/vscode-url`, {}, false, false, 1);
}
}
export default OpenHands;

View File

@@ -43,11 +43,5 @@ export interface Feedback {
export interface GetConfigResponse {
APP_MODE: "saas" | "oss";
GITHUB_CLIENT_ID: string;
POSTHOG_CLIENT_KEY: string;
}
export interface GetVSCodeUrlResponse {
vscode_url: string | null;
error?: string;
GITHUB_CLIENT_ID: string | null;
}

View File

@@ -1,57 +0,0 @@
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<g filter="url(#filter0_d)">
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
<path fill-rule="evenodd" clip-rule="evenodd" d="M70.9119 99.5723C72.4869 100.189 74.2828 100.15 75.8725 99.3807L96.4604 89.4231C98.624 88.3771 100 86.1762 100 83.7616V16.2392C100 13.8247 98.624 11.6238 96.4604 10.5774L75.8725 0.619067C73.7862 -0.389991 71.3446 -0.142885 69.5135 1.19527C69.252 1.38636 69.0028 1.59985 68.769 1.83502L29.3551 37.9795L12.1872 24.88C10.5891 23.6607 8.35365 23.7606 6.86938 25.1178L1.36302 30.1525C-0.452603 31.8127 -0.454583 34.6837 1.35854 36.3466L16.2471 50.0001L1.35854 63.6536C-0.454583 65.3164 -0.452603 68.1876 1.36302 69.8477L6.86938 74.8824C8.35365 76.2395 10.5891 76.34 12.1872 75.1201L29.3551 62.0207L68.769 98.1651C69.3925 98.7923 70.1246 99.2645 70.9119 99.5723ZM75.0152 27.1813L45.1092 50.0001L75.0152 72.8189V27.1813Z" fill="white"/>
</mask>
<g mask="url(#mask0)">
<path d="M96.4614 10.593L75.8567 0.62085C73.4717 -0.533437 70.6215 -0.0465506 68.7498 1.83492L1.29834 63.6535C-0.515935 65.3164 -0.513852 68.1875 1.30281 69.8476L6.8125 74.8823C8.29771 76.2395 10.5345 76.339 12.1335 75.1201L93.3604 13.18C96.0854 11.102 100 13.0557 100 16.4939V16.2535C100 13.84 98.6239 11.64 96.4614 10.593Z" fill="#D9D9D9"/>
<g filter="url(#filter1_d)">
<path d="M96.4614 89.4074L75.8567 99.3797C73.4717 100.534 70.6215 100.047 68.7498 98.1651L1.29834 36.3464C-0.515935 34.6837 -0.513852 31.8125 1.30281 30.1524L6.8125 25.1177C8.29771 23.7605 10.5345 23.6606 12.1335 24.88L93.3604 86.8201C96.0854 88.8985 100 86.9447 100 83.5061V83.747C100 86.1604 98.6239 88.3603 96.4614 89.4074Z" fill="#E6E6E6"/>
</g>
<g filter="url(#filter2_d)">
<path d="M75.8578 99.3807C73.4721 100.535 70.6219 100.047 68.75 98.1651C71.0564 100.483 75 98.8415 75 95.5631V4.43709C75 1.15852 71.0565 -0.483493 68.75 1.83492C70.6219 -0.0467614 73.4721 -0.534276 75.8578 0.618963L96.4583 10.5773C98.6229 11.6237 100 13.8246 100 16.2391V83.7616C100 86.1762 98.6229 88.3761 96.4583 89.4231L75.8578 99.3807Z" fill="white"/>
</g>
<g style="mix-blend-mode:overlay" opacity="0.25">
<path style="mix-blend-mode:overlay" opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M70.8508 99.5723C72.4258 100.189 74.2218 100.15 75.8115 99.3807L96.4 89.4231C98.5635 88.3771 99.9386 86.1762 99.9386 83.7616V16.2391C99.9386 13.8247 98.5635 11.6239 96.4 10.5774L75.8115 0.618974C73.7252 -0.390085 71.2835 -0.142871 69.4525 1.19518C69.1909 1.38637 68.9418 1.59976 68.7079 1.83493L29.2941 37.9795L12.1261 24.88C10.528 23.6606 8.2926 23.7605 6.80833 25.1177L1.30198 30.1524C-0.51354 31.8126 -0.515625 34.6837 1.2975 36.3465L16.186 50L1.2975 63.6536C-0.515625 65.3164 -0.51354 68.1875 1.30198 69.8476L6.80833 74.8824C8.2926 76.2395 10.528 76.339 12.1261 75.1201L29.2941 62.0207L68.7079 98.1651C69.3315 98.7923 70.0635 99.2645 70.8508 99.5723ZM74.9542 27.1812L45.0481 50L74.9542 72.8188V27.1812Z" fill="url(#paint0_linear)"/>
</g>
</g>
</g>
</g>
<defs>
<filter id="filter0_d" x="-6.25" y="-4.16667" width="112.5" height="112.5" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="2.08333"/>
<feGaussianBlur stdDeviation="3.125"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<filter id="filter1_d" x="-8.39436" y="15.6951" width="116.728" height="92.6376" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset/>
<feGaussianBlur stdDeviation="4.16667"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<filter id="filter2_d" x="60.4167" y="-8.33346" width="47.9167" height="116.667" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset/>
<feGaussianBlur stdDeviation="4.16667"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<linearGradient id="paint0_linear" x1="49.939" y1="-5.19792e-05" x2="49.939" y2="100.001" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
<clipPath id="clip0">
<rect width="100" height="100" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 5.1 KiB

View File

@@ -14,6 +14,7 @@ import {
} from "#/context/ws-client-provider";
import { ErrorObservation } from "#/types/core/observations";
import { addErrorMessage, addUserMessage } from "#/state/chatSlice";
import { handleAssistantMessage } from "#/services/actions";
import {
getCloneRepoCommand,
getGitHubTokenCommand,
@@ -111,7 +112,9 @@ export function EventHandler({ children }: React.PropsWithChildren) {
message: event.message,
}),
);
return;
}
handleAssistantMessage(event);
}, [events.length]);
React.useEffect(() => {

View File

@@ -12,7 +12,6 @@ import { useTranslation } from "react-i18next";
import { twMerge } from "tailwind-merge";
import AgentState from "#/types/AgentState";
import { setRefreshID } from "#/state/codeSlice";
import { addAssistantMessage } from "#/state/chatSlice";
import IconButton from "../IconButton";
import ExplorerTree from "./ExplorerTree";
import toast from "#/utils/toast";
@@ -21,7 +20,6 @@ import { I18nKey } from "#/i18n/declaration";
import OpenHands from "#/api/open-hands";
import { useFiles } from "#/context/files";
import { isOpenHandsErrorResponse } from "#/api/open-hands.utils";
import VSCodeIcon from "#/assets/vscode-alt.svg?react";
interface ExplorerActionsProps {
onRefresh: () => void;
@@ -170,35 +168,6 @@ function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) {
}
};
const handleVSCodeClick = async (e: React.MouseEvent) => {
e.preventDefault();
try {
const response = await OpenHands.getVSCodeUrl();
if (response.vscode_url) {
dispatch(
addAssistantMessage(
"You opened VS Code. Please inform the agent of any changes you made to the workspace or environment. To avoid conflicts, it's best to pause the agent before making any changes.",
),
);
window.open(response.vscode_url, "_blank");
} else {
toast.error(
`open-vscode-error-${new Date().getTime()}`,
t(I18nKey.EXPLORER$VSCODE_SWITCHING_ERROR_MESSAGE, {
error: response.error,
}),
);
}
} catch (exp_error) {
toast.error(
`open-vscode-error-${new Date().getTime()}`,
t(I18nKey.EXPLORER$VSCODE_SWITCHING_ERROR_MESSAGE, {
error: String(exp_error),
}),
);
}
};
React.useEffect(() => {
refreshWorkspace();
}, [curAgentState]);
@@ -241,7 +210,7 @@ function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) {
!isOpen ? "w-12" : "w-60",
)}
>
<div className="flex flex-col relative h-full px-3 py-2 overflow-hidden">
<div className="flex flex-col relative h-full px-3 py-2">
<div className="sticky top-0 bg-neutral-800">
<div
className={twMerge(
@@ -263,7 +232,7 @@ function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) {
</div>
</div>
{!error && (
<div className="overflow-auto flex-grow min-h-0">
<div className="overflow-auto flex-grow">
<div style={{ display: !isOpen ? "none" : "block" }}>
<ExplorerTree files={paths} />
</div>
@@ -274,27 +243,6 @@ function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) {
<p className="text-neutral-300 text-sm">{error}</p>
</div>
)}
{isOpen && (
<button
type="button"
onClick={handleVSCodeClick}
disabled={
curAgentState === AgentState.INIT ||
curAgentState === AgentState.LOADING
}
className={twMerge(
"mt-auto mb-2 w-full h-10 text-white rounded flex items-center justify-center gap-2 transition-colors",
curAgentState === AgentState.INIT ||
curAgentState === AgentState.LOADING
? "bg-neutral-600 cursor-not-allowed"
: "bg-[#4465DB] hover:bg-[#3451C7]",
)}
aria-label="Open in VS Code"
>
<VSCodeIcon width={20} height={20} />
Open in VS Code
</button>
)}
</div>
<input
data-testid="file-input"

View File

@@ -17,11 +17,6 @@ export function ol({
React.HTMLAttributes<HTMLElement> &
ExtraProps) {
return (
<ol
className="list-decimal ml-5 pl-2 whitespace-normal"
style={{ counterReset: "list-item" }}
>
{children}
</ol>
<ol className="list-decimal ml-5 pl-2 whitespace-normal">{children}</ol>
);
}

View File

@@ -4,9 +4,6 @@ import { Settings } from "#/services/settings";
import ActionType from "#/types/ActionType";
import EventLogger from "#/utils/event-logger";
import AgentState from "#/types/AgentState";
import { handleAssistantMessage } from "#/services/actions";
const RECONNECT_RETRIES = 5;
export enum WsClientProviderStatus {
STOPPED,
@@ -49,7 +46,6 @@ export function WsClientProvider({
const closeRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const [status, setStatus] = React.useState(WsClientProviderStatus.STOPPED);
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
const [retryCount, setRetryCount] = React.useState(RECONNECT_RETRIES);
function send(event: Record<string, unknown>) {
if (!wsRef.current) {
@@ -60,7 +56,6 @@ export function WsClientProvider({
}
function handleOpen() {
setRetryCount(RECONNECT_RETRIES);
setStatus(WsClientProviderStatus.OPENING);
const initEvent = {
action: ActionType.INIT,
@@ -81,19 +76,11 @@ export function WsClientProvider({
) {
setStatus(WsClientProviderStatus.ERROR);
}
handleAssistantMessage(event);
}
function handleClose() {
if (retryCount) {
setTimeout(() => {
setRetryCount(retryCount - 1);
}, 1000);
} else {
setStatus(WsClientProviderStatus.STOPPED);
setEvents([]);
}
setStatus(WsClientProviderStatus.STOPPED);
setEvents([]);
wsRef.current = null;
}
@@ -108,7 +95,7 @@ export function WsClientProvider({
let ws = wsRef.current;
// If disabled close any existing websockets...
if (!enabled || !retryCount) {
if (!enabled) {
if (ws) {
ws.close();
}
@@ -129,11 +116,7 @@ export function WsClientProvider({
const baseUrl =
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
let wsUrl = `${protocol}//${baseUrl}/ws`;
if (events.length) {
wsUrl += `?latest_event_id=${events[events.length - 1].id}`;
}
ws = new WebSocket(wsUrl, [
ws = new WebSocket(`${protocol}//${baseUrl}/ws`, [
"openhands",
token || "NO_JWT",
ghToken || "NO_GITHUB",
@@ -153,7 +136,7 @@ export function WsClientProvider({
ws.removeEventListener("error", handleError);
ws.removeEventListener("close", handleClose);
};
}, [enabled, token, ghToken, retryCount]);
}, [enabled, token, ghToken]);
// Strict mode mounts and unmounts each component twice, so we have to wait in the destructor
// before actually closing the socket and cancel the operation if the component gets remounted.
@@ -165,11 +148,7 @@ export function WsClientProvider({
return () => {
closeRef.current = setTimeout(() => {
const ws = wsRef.current;
if (ws) {
ws.removeEventListener("close", handleClose);
ws.close();
}
wsRef.current?.close();
}, 100);
};
}, []);

View File

@@ -12,26 +12,15 @@ import { Provider } from "react-redux";
import posthog from "posthog-js";
import "./i18n";
import store from "./store";
import OpenHands from "./api/open-hands";
function PosthogInit() {
const [key, setKey] = React.useState<string | null>(null);
React.useEffect(() => {
OpenHands.getConfig().then((config) => {
setKey(config.POSTHOG_CLIENT_KEY);
posthog.init("phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA", {
api_host: "https://us.i.posthog.com",
person_profiles: "identified_only",
});
}, []);
React.useEffect(() => {
if (key) {
posthog.init(key, {
api_host: "https://us.i.posthog.com",
person_profiles: "identified_only",
});
}
}, [key]);
return null;
}

View File

@@ -678,16 +678,6 @@
"tr": "Sunucudan beklenmeyen yanıt yapısı",
"no": "Uventet responsstruktur fra serveren"
},
"EXPLORER$VSCODE_SWITCHING_MESSAGE": {
"en": "Switching to VS Code in 3 seconds...\nImportant: Please inform the agent of any changes you make in VS Code. To avoid conflicts, wait for the assistant to complete its work before making your own changes.",
"zh-CN": "3 秒后切换到 VS Code\n重要提示请告知 OpenHands 您在 VS Code 中进行的任何更改。为了避免冲突,请在 OpenHands 完成工作后再进行自己的更改。",
"zh-TW": "3 秒後切換到 VS Code\n重要提示請告知 OpenHands 您在 VS Code 中進行的任何更改。為避免衝突,請在 OpenHands 完成工作後再進行自己的更改。"
},
"EXPLORER$VSCODE_SWITCHING_ERROR_MESSAGE": {
"en": "Error switching to VS Code: {{error}}",
"zh-CN": "切换到 VS Code 时发生错误: {{error}}",
"zh-TW": "切換到 VS Code 時發生錯誤: {{error}}"
},
"LOAD_SESSION$MODAL_TITLE": {
"en": "Return to existing session?",
"de": "Zurück zu vorhandener Sitzung?",

View File

@@ -10,6 +10,7 @@ export default {
style: {
background: "#ef4444",
color: "#fff",
lineBreak: "anywhere",
},
iconTheme: {
primary: "#ef4444",
@@ -18,20 +19,25 @@ export default {
});
idMap.set(id, toastId);
},
success: (id: string, msg: string, duration: number = 4000) => {
if (idMap.has(id)) return; // prevent duplicate toast
const toastId = toast.success(msg, {
duration,
style: {
background: "#333",
color: "#fff",
},
iconTheme: {
primary: "#333",
secondary: "#fff",
},
});
idMap.set(id, toastId);
success: (id: string, msg: string) => {
const toastId = idMap.get(id);
if (toastId === undefined) return;
if (toastId) {
toast.success(msg, {
id: toastId,
duration: 4000,
style: {
background: "#333",
color: "#fff",
lineBreak: "anywhere",
},
iconTheme: {
primary: "#333",
secondary: "#fff",
},
});
}
idMap.delete(id);
},
settingsChanged: (msg: string) => {
toast(msg, {
@@ -42,6 +48,7 @@ export default {
style: {
background: "#333",
color: "#fff",
lineBreak: "anywhere",
},
});
},

View File

@@ -42,7 +42,7 @@ from openhands.events.observation import (
)
from openhands.events.serialization.event import truncate_content
from openhands.llm.llm import LLM
from openhands.utils.shutdown_listener import should_continue
from openhands.runtime.utils.shutdown_listener import should_continue
# note: RESUME is only available on web GUI
TRAFFIC_CONTROL_REMINDER = (

View File

@@ -116,7 +116,6 @@ async def main():
event_stream=event_stream,
sid=sid,
plugins=agent_cls.sandbox_plugins,
headless_mode=True,
)
controller = AgentController(

View File

@@ -1,4 +1,3 @@
import copy
import logging
import os
import re
@@ -46,29 +45,6 @@ LOG_COLORS: Mapping[str, ColorType] = {
}
class NoColorFormatter(logging.Formatter):
"""Formatter for non-colored logging in files."""
def format(self, record: logging.LogRecord) -> str:
# Create a deep copy of the record to avoid modifying the original
new_record: logging.LogRecord = copy.deepcopy(record)
# Strip ANSI color codes from the message
new_record.msg = strip_ansi(new_record.msg)
return super().format(new_record)
def strip_ansi(s: str) -> str:
"""
Removes ANSI escape sequences from str, as defined by ECMA-048 in
http://www.ecma-international.org/publications/files/ECMA-ST/Ecma-048.pdf
# https://github.com/ewen-lbh/python-strip-ansi/blob/master/strip_ansi/__init__.py
"""
pattern = re.compile(r'\x1B\[\d+(;\d+){0,2}m')
stripped = pattern.sub('', s)
return stripped
class ColoredFormatter(logging.Formatter):
def format(self, record):
msg_type = record.__dict__.get('msg_type')
@@ -94,7 +70,7 @@ class ColoredFormatter(logging.Formatter):
return super().format(record)
file_formatter = NoColorFormatter(
file_formatter = logging.Formatter(
'%(asctime)s - %(name)s:%(levelname)s: %(filename)s:%(lineno)s - %(message)s',
datefmt='%H:%M:%S',
)

View File

@@ -54,14 +54,11 @@ def read_task_from_stdin() -> str:
def create_runtime(
config: AppConfig,
sid: str | None = None,
headless_mode: bool = True,
) -> Runtime:
"""Create a runtime for the agent to run on.
config: The app config.
sid: The session id.
headless_mode: Whether the agent is run in headless mode. `create_runtime` is typically called within evaluation scripts,
where we don't want to have the VSCode UI open, so it defaults to True.
"""
# if sid is provided on the command line, use it as the name of the event stream
# otherwise generate it on the basis of the configured jwt_secret
@@ -83,7 +80,6 @@ def create_runtime(
event_stream=event_stream,
sid=session_id,
plugins=agent_cls.sandbox_plugins,
headless_mode=headless_mode,
)
return runtime
@@ -126,7 +122,7 @@ async def run_controller(
sid = sid or generate_sid(config)
if runtime is None:
runtime = create_runtime(config, sid=sid, headless_mode=headless_mode)
runtime = create_runtime(config, sid=sid)
await runtime.connect()
event_stream = runtime.event_stream

View File

@@ -9,9 +9,9 @@ from openhands.core.logger import openhands_logger as logger
from openhands.core.utils import json
from openhands.events.event import Event, EventSource
from openhands.events.serialization.event import event_from_dict, event_to_dict
from openhands.runtime.utils.shutdown_listener import should_continue
from openhands.storage import FileStore
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.shutdown_listener import should_continue
class EventStreamSubscriber(str, Enum):

View File

@@ -7,7 +7,7 @@ from litellm import acompletion as litellm_acompletion
from openhands.core.exceptions import UserCancelledError
from openhands.core.logger import openhands_logger as logger
from openhands.llm.llm import LLM, LLM_RETRY_EXCEPTIONS
from openhands.utils.shutdown_listener import should_continue
from openhands.runtime.utils.shutdown_listener import should_continue
class AsyncLLM(LLM):

View File

@@ -1,25 +1,31 @@
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.impl.e2b.sandbox import E2BBox
from openhands.runtime.impl.eventstream.eventstream_runtime import (
EventStreamRuntime,
)
from openhands.runtime.impl.modal.modal_runtime import ModalRuntime
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
from openhands.runtime.impl.runloop.runloop_runtime import RunloopRuntime
def get_runtime_cls(name: str):
# Local imports to avoid circular imports
if name == 'eventstream':
from openhands.runtime.impl.eventstream.eventstream_runtime import (
EventStreamRuntime,
)
return EventStreamRuntime
elif name == 'e2b':
return E2BBox
from openhands.runtime.impl.e2b.e2b_runtime import E2BRuntime
return E2BRuntime
elif name == 'remote':
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
return RemoteRuntime
elif name == 'modal':
logger.debug('Using ModalRuntime')
from openhands.runtime.impl.modal.modal_runtime import ModalRuntime
return ModalRuntime
elif name == 'runloop':
from openhands.runtime.impl.runloop.runloop_runtime import RunloopRuntime
return RunloopRuntime
else:
raise ValueError(f'Runtime {name} not supported')
@@ -27,9 +33,5 @@ def get_runtime_cls(name: str):
__all__ = [
'E2BBox',
'RemoteRuntime',
'ModalRuntime',
'RunloopRuntime',
'EventStreamRuntime',
'get_runtime_cls',
]

View File

@@ -47,11 +47,14 @@ from openhands.events.observation import (
from openhands.events.serialization import event_from_dict, event_to_dict
from openhands.runtime.browser import browse
from openhands.runtime.browser.browser_env import BrowserEnv
from openhands.runtime.plugins import ALL_PLUGINS, JupyterPlugin, Plugin, VSCodePlugin
from openhands.runtime.plugins import (
ALL_PLUGINS,
JupyterPlugin,
Plugin,
)
from openhands.runtime.utils.bash import BashSession
from openhands.runtime.utils.files import insert_lines, read_lines
from openhands.runtime.utils.runtime_init import init_user_and_working_directory
from openhands.runtime.utils.system import check_port_available
from openhands.utils.async_utils import wait_all
@@ -113,10 +116,7 @@ class ActionExecutor:
return self._initial_pwd
async def ainit(self):
await wait_all(
(self._init_plugin(plugin) for plugin in self.plugins_to_load),
timeout=30,
)
await wait_all(self._init_plugin(plugin) for plugin in self.plugins_to_load)
# This is a temporary workaround
# TODO: refactor AgentSkills to be part of JupyterPlugin
@@ -345,8 +345,6 @@ if __name__ == '__main__':
)
# example: python client.py 8000 --working-dir /workspace --plugins JupyterRequirement
args = parser.parse_args()
os.environ['VSCODE_PORT'] = str(int(args.port) + 1)
assert check_port_available(int(os.environ['VSCODE_PORT']))
plugins_to_load: list[Plugin] = []
if args.plugins:
@@ -529,19 +527,6 @@ if __name__ == '__main__':
async def alive():
return {'status': 'ok'}
# ================================
# VSCode-specific operations
# ================================
@app.get('/vscode/connection_token')
async def get_vscode_connection_token():
assert client is not None
if 'vscode' in client.plugins:
plugin: VSCodePlugin = client.plugins['vscode'] # type: ignore
return {'token': plugin.vscode_connection_token}
else:
return {'token': None}
# ================================
# File-specific operations for UI
# ================================

View File

@@ -30,11 +30,7 @@ from openhands.events.observation import (
UserRejectObservation,
)
from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS
from openhands.runtime.plugins import (
JupyterRequirement,
PluginRequirement,
VSCodeRequirement,
)
from openhands.runtime.plugins import JupyterRequirement, PluginRequirement
from openhands.runtime.utils.edit import FileEditRuntimeMixin
from openhands.utils.async_utils import call_sync_from_async
@@ -88,20 +84,13 @@ class Runtime(FileEditRuntimeMixin):
env_vars: dict[str, str] | None = None,
status_callback: Callable | None = None,
attach_to_existing: bool = False,
headless_mode: bool = False,
):
self.sid = sid
self.event_stream = event_stream
self.event_stream.subscribe(
EventStreamSubscriber.RUNTIME, self.on_event, self.sid
)
self.plugins = (
copy.deepcopy(plugins) if plugins is not None and len(plugins) > 0 else []
)
# add VSCode plugin if not in headless mode
if not headless_mode:
self.plugins.append(VSCodeRequirement())
self.plugins = plugins if plugins is not None and len(plugins) > 0 else []
self.status_callback = status_callback
self.attach_to_existing = attach_to_existing
@@ -112,10 +101,6 @@ class Runtime(FileEditRuntimeMixin):
if env_vars is not None:
self.initial_env_vars.update(env_vars)
self._vscode_enabled = any(
isinstance(plugin, VSCodeRequirement) for plugin in self.plugins
)
# Load mixins
FileEditRuntimeMixin.__init__(self)
@@ -293,15 +278,3 @@ class Runtime(FileEditRuntimeMixin):
def copy_from(self, path: str) -> Path:
"""Zip all files in the sandbox and return a path in the local filesystem."""
raise NotImplementedError('This method is not implemented in the base class.')
# ====================================================================
# VSCode
# ====================================================================
@property
def vscode_enabled(self) -> bool:
return self._vscode_enabled
@property
def vscode_url(self) -> str | None:
raise NotImplementedError('This method is not implemented in the base class.')

View File

@@ -16,7 +16,7 @@ from PIL import Image
from openhands.core.exceptions import BrowserInitException
from openhands.core.logger import openhands_logger as logger
from openhands.utils.shutdown_listener import should_continue, should_exit
from openhands.runtime.utils.shutdown_listener import should_continue, should_exit
from openhands.utils.tenacity_stop import stop_if_should_exit
BROWSER_EVAL_GET_GOAL_ACTION = 'GET_EVAL_GOAL'

View File

@@ -8,7 +8,7 @@ import requests
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.builder import RuntimeBuilder
from openhands.runtime.utils.request import send_request
from openhands.utils.shutdown_listener import (
from openhands.runtime.utils.shutdown_listener import (
should_continue,
sleep_if_should_continue,
)

View File

@@ -136,7 +136,6 @@ class EventStreamRuntime(Runtime):
env_vars: dict[str, str] | None = None,
status_callback: Callable | None = None,
attach_to_existing: bool = False,
headless_mode: bool = True,
):
super().__init__(
config,
@@ -146,7 +145,6 @@ class EventStreamRuntime(Runtime):
env_vars,
status_callback,
attach_to_existing,
headless_mode,
)
def __init__(
@@ -158,13 +156,10 @@ class EventStreamRuntime(Runtime):
env_vars: dict[str, str] | None = None,
status_callback: Callable | None = None,
attach_to_existing: bool = False,
headless_mode: bool = True,
):
self.config = config
self._host_port = 30000 # initial dummy value
self._container_port = 30001 # initial dummy value
self._vscode_url: str | None = None # initial dummy value
self._runtime_initialized: bool = False
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
self.session = requests.Session()
self.status_callback = status_callback
@@ -195,7 +190,6 @@ class EventStreamRuntime(Runtime):
env_vars,
status_callback,
attach_to_existing,
headless_mode,
)
async def connect(self):
@@ -227,10 +221,7 @@ class EventStreamRuntime(Runtime):
'info', f'Starting runtime with image: {self.runtime_container_image}'
)
await call_sync_from_async(self._init_container)
self.log(
'info',
f'Container started: {self.container_name}. VSCode URL: {self.vscode_url}',
)
self.log('info', f'Container started: {self.container_name}')
if not self.attach_to_existing:
self.log('info', f'Waiting for client to become ready at {self.api_url}...')
@@ -246,11 +237,10 @@ class EventStreamRuntime(Runtime):
self.log(
'debug',
f'Container initialized with plugins: {[plugin.name for plugin in self.plugins]}. VSCode URL: {self.vscode_url}',
f'Container initialized with plugins: {[plugin.name for plugin in self.plugins]}',
)
if not self.attach_to_existing:
self.send_status_message(' ')
self._runtime_initialized = True
@staticmethod
@lru_cache(maxsize=1)
@@ -271,6 +261,7 @@ class EventStreamRuntime(Runtime):
plugin_arg = (
f'--plugins {" ".join([plugin.name for plugin in self.plugins])} '
)
self._host_port = self._find_available_port()
self._container_port = (
self._host_port
@@ -279,7 +270,6 @@ class EventStreamRuntime(Runtime):
use_host_network = self.config.sandbox.use_host_network
network_mode: str | None = 'host' if use_host_network else None
port_mapping: dict[str, list[dict[str, str]]] | None = (
None
if use_host_network
@@ -300,13 +290,6 @@ class EventStreamRuntime(Runtime):
if self.config.debug or DEBUG:
environment['DEBUG'] = 'true'
if self.vscode_enabled:
# vscode is on port +1 from container port
if isinstance(port_mapping, dict):
port_mapping[f'{self._container_port + 1}/tcp'] = [
{'HostPort': str(self._host_port + 1)}
]
self.log('debug', f'Workspace Base: {self.config.workspace_base}')
if (
self.config.workspace_mount_path is not None
@@ -647,30 +630,3 @@ class EventStreamRuntime(Runtime):
return port
# If no port is found after max_attempts, return the last tried port
return port
@property
def vscode_url(self) -> str | None:
if self.vscode_enabled and self._runtime_initialized:
if (
hasattr(self, '_vscode_url') and self._vscode_url is not None
): # cached value
return self._vscode_url
response = send_request(
self.session,
'GET',
f'{self.api_url}/vscode/connection_token',
timeout=10,
)
response_json = response.json()
assert isinstance(response_json, dict)
if response_json['token'] is None:
return None
self._vscode_url = f'http://localhost:{self._host_port + 1}/?tkn={response_json["token"]}&folder={self.config.workspace_mount_path_in_sandbox}'
self.log(
'debug',
f'VSCode URL: {self._vscode_url}',
)
return self._vscode_url
else:
return None

View File

@@ -77,7 +77,6 @@ class ModalRuntime(EventStreamRuntime):
env_vars: dict[str, str] | None = None,
status_callback: Callable | None = None,
attach_to_existing: bool = False,
headless_mode: bool = True,
):
assert config.modal_api_token_id, 'Modal API token id is required'
assert config.modal_api_token_secret, 'Modal API token secret is required'
@@ -125,7 +124,6 @@ class ModalRuntime(EventStreamRuntime):
env_vars,
status_callback,
attach_to_existing,
headless_mode,
)
async def connect(self):

View File

@@ -3,7 +3,6 @@ import tempfile
import threading
from pathlib import Path
from typing import Callable, Optional
from urllib.parse import urlparse
from zipfile import ZipFile
import requests
@@ -58,7 +57,6 @@ class RemoteRuntime(Runtime):
env_vars: dict[str, str] | None = None,
status_callback: Optional[Callable] = None,
attach_to_existing: bool = False,
headless_mode: bool = True,
):
# We need to set session and action_semaphore before the __init__ below, or we get odd errors
self.session = requests.Session()
@@ -72,7 +70,6 @@ class RemoteRuntime(Runtime):
env_vars,
status_callback,
attach_to_existing,
headless_mode,
)
if self.config.sandbox.api_key is None:
raise ValueError(
@@ -92,8 +89,6 @@ class RemoteRuntime(Runtime):
)
self.runtime_id: str | None = None
self.runtime_url: str | None = None
self._runtime_initialized: bool = False
self._vscode_url: str | None = None # initial dummy value
async def connect(self):
try:
@@ -102,7 +97,6 @@ class RemoteRuntime(Runtime):
self.log('error', 'Runtime failed to start, timed out before ready')
raise
await call_sync_from_async(self.setup_initial_env)
self._runtime_initialized = True
def _start_or_attach_to_runtime(self):
existing_runtime = self._check_existing_runtime()
@@ -271,43 +265,6 @@ class RemoteRuntime(Runtime):
{'X-Session-API-Key': start_response['session_api_key']}
)
@property
def vscode_url(self) -> str | None:
if self.vscode_enabled and self._runtime_initialized:
if (
hasattr(self, '_vscode_url') and self._vscode_url is not None
): # cached value
return self._vscode_url
response = self._send_request(
'GET',
f'{self.runtime_url}/vscode/connection_token',
timeout=10,
)
response_json = response.json()
assert isinstance(response_json, dict)
if response_json['token'] is None:
return None
# parse runtime_url to get vscode_url
_parsed_url = urlparse(self.runtime_url)
assert isinstance(_parsed_url.scheme, str) and isinstance(
_parsed_url.netloc, str
)
self._vscode_url = f'{_parsed_url.scheme}://vscode-{_parsed_url.netloc}/?tkn={response_json["token"]}&folder={self.config.workspace_mount_path_in_sandbox}'
self.log(
'debug',
f'VSCode URL: {self._vscode_url}',
)
return self._vscode_url
else:
return None
@tenacity.retry(
stop=tenacity.stop_after_delay(180) | stop_if_should_exit(),
reraise=True,
retry=tenacity.retry_if_exception_type(RuntimeNotReadyError),
wait=tenacity.wait_fixed(2),
)
def _wait_until_alive(self):
retry_decorator = tenacity.retry(
stop=tenacity.stop_after_delay(

View File

@@ -5,7 +5,6 @@ from openhands.runtime.plugins.agent_skills import (
)
from openhands.runtime.plugins.jupyter import JupyterPlugin, JupyterRequirement
from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
from openhands.runtime.plugins.vscode import VSCodePlugin, VSCodeRequirement
__all__ = [
'Plugin',
@@ -14,12 +13,9 @@ __all__ = [
'AgentSkillsPlugin',
'JupyterRequirement',
'JupyterPlugin',
'VSCodeRequirement',
'VSCodePlugin',
]
ALL_PLUGINS = {
'jupyter': JupyterPlugin,
'agent_skills': AgentSkillsPlugin,
'vscode': VSCodePlugin,
}

View File

@@ -8,7 +8,7 @@ from openhands.events.observation import IPythonRunCellObservation
from openhands.runtime.plugins.jupyter.execute_server import JupyterKernel
from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
from openhands.runtime.utils import find_available_tcp_port
from openhands.utils.shutdown_listener import should_continue
from openhands.runtime.utils.shutdown_listener import should_continue
@dataclass

View File

@@ -1,51 +0,0 @@
import os
import subprocess
import time
import uuid
from dataclasses import dataclass
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
from openhands.runtime.utils.system import check_port_available
from openhands.utils.shutdown_listener import should_continue
@dataclass
class VSCodeRequirement(PluginRequirement):
name: str = 'vscode'
class VSCodePlugin(Plugin):
name: str = 'vscode'
async def initialize(self, username: str):
self.vscode_port = int(os.environ['VSCODE_PORT'])
self.vscode_connection_token = str(uuid.uuid4())
assert check_port_available(self.vscode_port)
cmd = (
f"su - {username} -s /bin/bash << 'EOF'\n"
f'sudo chown -R {username}:{username} /openhands/.openvscode-server\n'
'cd /workspace\n'
f'exec /openhands/.openvscode-server/bin/openvscode-server --host 0.0.0.0 --connection-token {self.vscode_connection_token} --port {self.vscode_port}\n'
'EOF'
)
print(cmd)
self.gateway_process = subprocess.Popen(
cmd,
stderr=subprocess.STDOUT,
shell=True,
)
# read stdout until the kernel gateway is ready
output = ''
while should_continue() and self.gateway_process.stdout is not None:
line = self.gateway_process.stdout.readline().decode('utf-8')
print(line)
output += line
if 'at' in line:
break
time.sleep(1)
logger.debug('Waiting for VSCode server to start...')
logger.debug(
f'VSCode server started at port {self.vscode_port}. Output: {output}'
)

View File

@@ -1,71 +1,8 @@
FROM {{ base_image }}
# Shared environment variables
ENV POETRY_VIRTUALENVS_PATH=/openhands/poetry \
MAMBA_ROOT_PREFIX=/openhands/micromamba \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
EDITOR=code \
VISUAL=code \
GIT_EDITOR="code --wait" \
OPENVSCODE_SERVER_ROOT=/openhands/.openvscode-server
{% macro setup_base_system() %}
# Install base system dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \
wget curl sudo apt-utils git \
{% if 'ubuntu' in base_image and (base_image.endswith(':latest') or base_image.endswith(':24.04')) %}
libgl1 \
{% else %}
libgl1-mesa-glx \
{% endif %}
libasound2-plugins libatomic1 curl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Remove UID 1000 if it's called pn--this fixes the nikolaik image for ubuntu users
RUN if getent passwd 1000 | grep -q pn; then userdel pn; fi
# Create necessary directories
RUN mkdir -p /openhands && \
mkdir -p /openhands/logs && \
mkdir -p /openhands/poetry
{% endmacro %}
{% macro setup_vscode_server() %}
# Reference:
# 1. https://github.com/gitpod-io/openvscode-server
# 2. https://github.com/gitpod-io/openvscode-releases
# Setup VSCode Server
ARG RELEASE_TAG="openvscode-server-v1.94.2"
ARG RELEASE_ORG="gitpod-io"
# ARG USERNAME=openvscode-server
# ARG USER_UID=1000
# ARG USER_GID=1000
RUN if [ -z "${RELEASE_TAG}" ]; then \
echo "The RELEASE_TAG build arg must be set." >&2 && \
exit 1; \
fi && \
arch=$(uname -m) && \
if [ "${arch}" = "x86_64" ]; then \
arch="x64"; \
elif [ "${arch}" = "aarch64" ]; then \
arch="arm64"; \
elif [ "${arch}" = "armv7l" ]; then \
arch="armhf"; \
fi && \
wget https://github.com/${RELEASE_ORG}/openvscode-server/releases/download/${RELEASE_TAG}/${RELEASE_TAG}-linux-${arch}.tar.gz && \
tar -xzf ${RELEASE_TAG}-linux-${arch}.tar.gz && \
mv -f ${RELEASE_TAG}-linux-${arch} ${OPENVSCODE_SERVER_ROOT} && \
cp ${OPENVSCODE_SERVER_ROOT}/bin/remote-cli/openvscode-server ${OPENVSCODE_SERVER_ROOT}/bin/remote-cli/code && \
rm -f ${RELEASE_TAG}-linux-${arch}.tar.gz
{% endmacro %}
# Shared environment variables (regardless of init or not)
ENV POETRY_VIRTUALENVS_PATH=/openhands/poetry
ENV MAMBA_ROOT_PREFIX=/openhands/micromamba
{% macro install_dependencies() %}
# Install all dependencies
@@ -91,7 +28,6 @@ RUN \
# Clean up
apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \
/openhands/micromamba/bin/micromamba clean --all
{% endmacro %}
{% if build_from_scratch %}
@@ -101,8 +37,25 @@ RUN \
# This is used in cases where the base image is something more generic like nikolaik/python-nodejs
# rather than the current OpenHands release
{{ setup_base_system() }}
{{ setup_vscode_server() }}
{% if 'ubuntu' in base_image and (base_image.endswith(':latest') or base_image.endswith(':24.04')) %}
{% set LIBGL_MESA = 'libgl1' %}
{% else %}
{% set LIBGL_MESA = 'libgl1-mesa-glx' %}
{% endif %}
# Install necessary packages and clean up in one layer
RUN apt-get update && \
apt-get install -y wget curl sudo apt-utils {{ LIBGL_MESA }} libasound2-plugins git && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Remove UID 1000 if it's called pn--this fixes the nikolaik image for ubuntu users
RUN if getent passwd 1000 | grep -q pn; then userdel pn; fi
# Create necessary directories
RUN mkdir -p /openhands && \
mkdir -p /openhands/logs && \
mkdir -p /openhands/poetry
# Install micromamba
RUN mkdir -p /openhands/micromamba/bin && \
@@ -119,7 +72,6 @@ RUN \
if [ -d /openhands/code ]; then rm -rf /openhands/code; fi && \
mkdir -p /openhands/code/openhands && \
touch /openhands/code/openhands/__init__.py
COPY ./code/pyproject.toml ./code/poetry.lock /openhands/code/
{{ install_dependencies() }}

View File

@@ -3,18 +3,6 @@ import socket
import time
def check_port_available(port: int) -> bool:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.bind(('localhost', port))
return True
except OSError:
time.sleep(0.1) # Short delay to further reduce chance of collisions
return False
finally:
sock.close()
def find_available_tcp_port(min_port=30000, max_port=39999, max_attempts=10) -> int:
"""Find an available TCP port in a specified range.
@@ -31,8 +19,15 @@ def find_available_tcp_port(min_port=30000, max_port=39999, max_attempts=10) ->
rng.shuffle(ports)
for port in ports[:max_attempts]:
if check_port_available(port):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.bind(('localhost', port))
return port
except OSError:
time.sleep(0.1) # Short delay to further reduce chance of collisions
continue
finally:
sock.close()
return -1

View File

@@ -1,7 +1,7 @@
from tenacity import RetryCallState
from tenacity.stop import stop_base
from openhands.utils.shutdown_listener import should_exit
from openhands.runtime.utils.shutdown_listener import should_exit
class stop_if_should_exit(stop_base):

View File

@@ -1,70 +1,36 @@
import asyncio
import os
import re
import tempfile
import time
import uuid
import warnings
import jwt
import requests
from dotenv import load_dotenv
from fastapi import (
Depends,
FastAPI,
Request,
WebSocket,
status,
)
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer
from fastapi.staticfiles import StaticFiles
from pathspec import PathSpec
from pathspec.patterns import GitWildMatchPattern
from openhands.security.options import SecurityAnalyzers
from openhands.server.data_models.feedback import FeedbackDataModel, store_feedback
from openhands.server.github import (
GITHUB_CLIENT_ID,
GITHUB_CLIENT_SECRET,
UserVerifier,
authenticate_github_user,
)
from openhands.storage import get_file_store
from openhands.utils.async_utils import call_sync_from_async
with warnings.catch_warnings():
warnings.simplefilter('ignore')
import litellm
from dotenv import load_dotenv
from fastapi import (
BackgroundTasks,
FastAPI,
HTTPException,
Request,
UploadFile,
WebSocket,
status,
from openhands.security.options import SecurityAnalyzers
from openhands.server.github import (
UserVerifier,
authenticate_github_user,
)
from fastapi.responses import FileResponse, JSONResponse
from fastapi.security import HTTPBearer
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
from openhands.controller.agent import Agent
from openhands.core.config import LLMConfig, load_app_config
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import (
ChangeAgentStateAction,
FileReadAction,
FileWriteAction,
NullAction,
)
from openhands.events.observation import (
AgentStateChangedObservation,
ErrorObservation,
FileReadObservation,
FileWriteObservation,
NullObservation,
)
from openhands.events.serialization import event_to_dict
from openhands.events.stream import AsyncEventStreamWrapper
from openhands.llm import bedrock
from openhands.runtime.base import Runtime
from openhands.server.auth.auth import get_sid_from_token, sign_token
from openhands.server.middleware import LocalhostCORSMiddleware, NoCacheMiddleware
from openhands.server.session import SessionManager
from openhands.server.middleware import RateLimiter
from openhands.storage import get_file_store
from openhands.utils.async_utils import call_sync_from_async
load_dotenv()
@@ -73,7 +39,9 @@ file_store = get_file_store(config.file_store, config.file_store_path)
session_manager = SessionManager(config, file_store)
app = FastAPI()
app = FastAPI(
dependencies=[Depends(lambda: RateLimiter(times=2, seconds=1))]
) # Default 2 req/sec
app.add_middleware(
LocalhostCORSMiddleware,
allow_credentials=True,
@@ -81,7 +49,6 @@ app.add_middleware(
allow_headers=['*'],
)
app.add_middleware(NoCacheMiddleware)
security_scheme = HTTPBearer()
@@ -251,6 +218,7 @@ async def attach_session(request: Request, call_next):
@app.websocket('/ws')
@RateLimiter(times=1, seconds=5) # 1 request per 5 seconds
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint for receiving events from the client (i.e., the browser).
Once connected, the client can send various actions:
@@ -494,393 +462,31 @@ async def list_files(request: Request, path: str | None = None):
file_list = [f for f in file_list if f not in FILES_TO_IGNORE]
async def filter_for_gitignore(file_list, base_path):
gitignore_path = os.path.join(base_path, '.gitignore')
try:
read_action = FileReadAction(gitignore_path)
observation = await call_sync_from_async(runtime.run_action, read_action)
spec = PathSpec.from_lines(
GitWildMatchPattern, observation.content.splitlines()
)
except Exception as e:
logger.warning(e)
return file_list
file_list = [entry for entry in file_list if not spec.match_file(entry)]
return file_list
file_list = await filter_for_gitignore(file_list, '')
# Create a PathSpec object to match gitignore patterns
gitignore_path = os.path.join(runtime.root_dir, '.gitignore')
if os.path.exists(gitignore_path):
with open(gitignore_path, 'r') as f:
gitignore = f.read()
spec = PathSpec.from_lines(GitWildMatchPattern, gitignore.splitlines())
file_list = [f for f in file_list if not spec.match_file(f)]
return file_list
@app.get('/api/select-file')
async def select_file(file: str, request: Request):
"""Retrieve the content of a specified file.
To select a file:
```sh
curl http://localhost:3000/api/select-file?file=<file_path>
```
Args:
file (str): The path of the file to be retrieved.
Expect path to be absolute inside the runtime.
request (Request): The incoming request object.
Returns:
dict: A dictionary containing the file content.
Raises:
HTTPException: If there's an error opening the file.
"""
runtime: Runtime = request.state.conversation.runtime
file = os.path.join(runtime.config.workspace_mount_path_in_sandbox, file)
read_action = FileReadAction(file)
observation = await call_sync_from_async(runtime.run_action, read_action)
if isinstance(observation, FileReadObservation):
content = observation.content
return {'code': content}
elif isinstance(observation, ErrorObservation):
logger.error(f'Error opening file {file}: {observation}', exc_info=False)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'error': f'Error opening file: {observation}'},
)
def sanitize_filename(filename):
"""Sanitize the filename to prevent directory traversal"""
# Remove any directory components
filename = os.path.basename(filename)
# Remove any non-alphanumeric characters except for .-_
filename = re.sub(r'[^\w\-_\.]', '', filename)
# Limit the filename length
max_length = 255
if len(filename) > max_length:
name, ext = os.path.splitext(filename)
filename = name[: max_length - len(ext)] + ext
return filename
@app.post('/api/upload-files')
async def upload_file(request: Request, files: list[UploadFile]):
"""Upload a list of files to the workspace.
To upload a files:
```sh
curl -X POST -F "file=@<file_path1>" -F "file=@<file_path2>" http://localhost:3000/api/upload-files
```
Args:
request (Request): The incoming request object.
files (list[UploadFile]): A list of files to be uploaded.
Returns:
dict: A message indicating the success of the upload operation.
Raises:
HTTPException: If there's an error saving the files.
"""
try:
uploaded_files = []
skipped_files = []
for file in files:
safe_filename = sanitize_filename(file.filename)
file_contents = await file.read()
if (
MAX_FILE_SIZE_MB > 0
and len(file_contents) > MAX_FILE_SIZE_MB * 1024 * 1024
):
skipped_files.append(
{
'name': safe_filename,
'reason': f'Exceeds maximum size limit of {MAX_FILE_SIZE_MB}MB',
}
)
continue
if not is_extension_allowed(safe_filename):
skipped_files.append(
{'name': safe_filename, 'reason': 'File type not allowed'}
)
continue
# copy the file to the runtime
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_file_path = os.path.join(tmp_dir, safe_filename)
with open(tmp_file_path, 'wb') as tmp_file:
tmp_file.write(file_contents)
tmp_file.flush()
runtime: Runtime = request.state.conversation.runtime
runtime.copy_to(
tmp_file_path, runtime.config.workspace_mount_path_in_sandbox
)
uploaded_files.append(safe_filename)
response_content = {
'message': 'File upload process completed',
'uploaded_files': uploaded_files,
'skipped_files': skipped_files,
}
if not uploaded_files and skipped_files:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={
**response_content,
'error': 'No files were uploaded successfully',
},
)
return JSONResponse(status_code=status.HTTP_200_OK, content=response_content)
except Exception as e:
logger.error(f'Error during file upload: {e}', exc_info=True)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
'error': f'Error during file upload: {str(e)}',
'uploaded_files': [],
'skipped_files': [],
},
)
@app.post('/api/submit-feedback')
async def submit_feedback(request: Request):
"""Submit user feedback.
This function stores the provided feedback data.
To submit feedback:
```sh
curl -X POST -d '{"email": "test@example.com"}' -H "Authorization:"
```
Args:
request (Request): The incoming request object.
feedback (FeedbackDataModel): The feedback data to be stored.
Returns:
dict: The stored feedback data.
Raises:
HTTPException: If there's an error submitting the feedback.
"""
# Assuming the storage service is already configured in the backend
# and there is a function to handle the storage.
body = await request.json()
async_stream = AsyncEventStreamWrapper(
request.state.conversation.event_stream, filter_hidden=True
)
trajectory = []
async for event in async_stream:
trajectory.append(event_to_dict(event))
feedback = FeedbackDataModel(
email=body.get('email', ''),
version=body.get('version', ''),
permissions=body.get('permissions', 'private'),
polarity=body.get('polarity', ''),
feedback=body.get('polarity', ''),
trajectory=trajectory,
)
try:
feedback_data = await call_sync_from_async(store_feedback, feedback)
return JSONResponse(status_code=200, content=feedback_data)
except Exception as e:
logger.error(f'Error submitting feedback: {e}')
return JSONResponse(
status_code=500, content={'error': 'Failed to submit feedback'}
)
@app.get('/api/defaults')
async def appconfig_defaults():
"""Retrieve the default configuration settings.
To get the default configurations:
```sh
curl http://localhost:3000/api/defaults
```
Returns:
dict: The default configuration settings.
"""
return config.defaults_dict
@app.post('/api/save-file')
async def save_file(request: Request):
"""Save a file to the agent's runtime file store.
This endpoint allows saving a file when the agent is in a paused, finished,
or awaiting user input state. It checks the agent's state before proceeding
with the file save operation.
Args:
request (Request): The incoming FastAPI request object.
Returns:
JSONResponse: A JSON response indicating the success of the operation.
Raises:
HTTPException:
- 403 error if the agent is not in an allowed state for editing.
- 400 error if the file path or content is missing.
- 500 error if there's an unexpected error during the save operation.
"""
try:
# Extract file path and content from the request
data = await request.json()
file_path = data.get('filePath')
content = data.get('content')
# Validate the presence of required data
if not file_path or content is None:
raise HTTPException(status_code=400, detail='Missing filePath or content')
# Save the file to the agent's runtime file store
runtime: Runtime = request.state.conversation.runtime
file_path = os.path.join(
runtime.config.workspace_mount_path_in_sandbox, file_path
)
write_action = FileWriteAction(file_path, content)
observation = await call_sync_from_async(runtime.run_action, write_action)
if isinstance(observation, FileWriteObservation):
return JSONResponse(
status_code=200, content={'message': 'File saved successfully'}
)
elif isinstance(observation, ErrorObservation):
return JSONResponse(
status_code=500,
content={'error': f'Failed to save file: {observation}'},
)
else:
return JSONResponse(
status_code=500,
content={'error': f'Unexpected observation: {observation}'},
)
except Exception as e:
# Log the error and return a 500 response
logger.error(f'Error saving file: {e}', exc_info=True)
raise HTTPException(status_code=500, detail=f'Error saving file: {e}')
@app.route('/api/security/{path:path}', methods=['GET', 'POST', 'PUT', 'DELETE'])
async def security_api(request: Request):
"""Catch-all route for security analyzer API requests.
Each request is handled directly to the security analyzer.
Args:
request (Request): The incoming FastAPI request object.
Returns:
Any: The response from the security analyzer.
Raises:
HTTPException: If the security analyzer is not initialized.
"""
if not request.state.conversation.security_analyzer:
raise HTTPException(status_code=404, detail='Security analyzer not initialized')
return await request.state.conversation.security_analyzer.handle_api_request(
request
)
@app.get('/api/zip-directory')
async def zip_current_workspace(request: Request, background_tasks: BackgroundTasks):
try:
logger.debug('Zipping workspace')
runtime: Runtime = request.state.conversation.runtime
path = runtime.config.workspace_mount_path_in_sandbox
zip_file = await call_sync_from_async(runtime.copy_from, path)
response = FileResponse(
path=zip_file,
filename='workspace.zip',
media_type='application/x-zip-compressed',
)
# This will execute after the response is sent (So the file is not deleted before being sent)
background_tasks.add_task(zip_file.unlink)
return response
except Exception as e:
logger.error(f'Error zipping workspace: {e}', exc_info=True)
raise HTTPException(
status_code=500,
detail='Failed to zip workspace',
)
class AuthCode(BaseModel):
code: str
@app.post('/api/github/callback')
def github_callback(auth_code: AuthCode):
# Prepare data for the token exchange request
data = {
'client_id': GITHUB_CLIENT_ID,
'client_secret': GITHUB_CLIENT_SECRET,
'code': auth_code.code,
}
logger.debug('Exchanging code for GitHub token')
headers = {'Accept': 'application/json'}
response = requests.post(
'https://github.com/login/oauth/access_token', data=data, headers=headers
)
if response.status_code != 200:
logger.error(f'Failed to exchange code for token: {response.text}')
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={'error': 'Failed to exchange code for token'},
)
token_response = response.json()
if 'access_token' not in token_response:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={'error': 'No access token in response'},
)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'access_token': token_response['access_token']},
)
@app.post('/api/authenticate')
@RateLimiter(times=1, seconds=5) # 1 request per 5 seconds
async def authenticate(request: Request):
token = request.headers.get('X-GitHub-Token')
if not await authenticate_github_user(token):
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Not authorized via GitHub waitlist'},
content={'error': 'Invalid token'},
)
# Create a signed JWT token with 1-hour expiration
cookie_data = {
'github_token': token,
'exp': int(time.time()) + 3600, # 1 hour expiration
}
signed_token = sign_token(cookie_data, config.jwt_secret)
signed_token = sign_token({'token': token}, config.jwt_secret)
response = JSONResponse(
status_code=status.HTTP_200_OK, content={'message': 'User authenticated'}
)
# Set secure cookie with signed token
response.set_cookie(
key='github_auth',
value=signed_token,
@@ -892,35 +498,13 @@ async def authenticate(request: Request):
return response
@app.get('/api/vscode-url')
async def get_vscode_url(request: Request):
"""Get the VSCode URL.
This endpoint allows getting the VSCode URL.
Args:
request (Request): The incoming FastAPI request object.
Returns:
JSONResponse: A JSON response indicating the success of the operation.
"""
try:
runtime: Runtime = request.state.conversation.runtime
logger.debug(f'Runtime type: {type(runtime)}')
logger.debug(f'Runtime VSCode URL: {runtime.vscode_url}')
return JSONResponse(status_code=200, content={'vscode_url': runtime.vscode_url})
except Exception as e:
logger.error(f'Error getting VSCode URL: {e}', exc_info=True)
return JSONResponse(
status_code=500,
content={
'vscode_url': None,
'error': f'Error getting VSCode URL: {e}',
},
)
class SPAStaticFiles(StaticFiles):
"""Static files handler with rate limiting."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.limiter = RateLimiter(times=10, seconds=1) # 10 requests per second
async def get_response(self, path: str, scope):
try:
return await super().get_response(path, scope)
@@ -928,5 +512,11 @@ class SPAStaticFiles(StaticFiles):
# FIXME: just making this HTTPException doesn't work for some reason
return await super().get_response('index.html', scope)
async def __call__(self, scope, receive, send) -> None:
if scope['type'] == 'http':
# Apply rate limiting
await self.limiter(scope, receive, send)
return await super().__call__(scope, receive, send)
app.mount('/', SPAStaticFiles(directory='./frontend/build', html=True), name='dist')

View File

@@ -1,3 +1,7 @@
import asyncio
import collections
import time
from typing import Callable, Dict, Optional
from urllib.parse import urlparse
from fastapi.middleware.cors import CORSMiddleware
@@ -41,3 +45,120 @@ class NoCacheMiddleware(BaseHTTPMiddleware):
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
class InMemoryStore:
"""Thread-safe in-memory store for rate limiting."""
def __init__(self):
self.storage: Dict[str, collections.deque] = {}
self._lock = asyncio.Lock()
async def incr(self, key: str) -> int:
"""Increment the counter for a key and return the new count.
Args:
key (str): The key to increment.
Returns:
int: The new count after incrementing.
"""
async with self._lock:
if key not in self.storage:
self.storage[key] = collections.deque()
now = time.time()
self.storage[key].append(now)
return len(self.storage[key])
async def expire(self, key: str, seconds: int) -> None:
"""Remove expired entries for a key.
Args:
key (str): The key to check.
seconds (int): The expiration time in seconds.
"""
async with self._lock:
if key not in self.storage:
return
now = time.time()
while self.storage[key] and self.storage[key][0] < now - seconds:
self.storage[key].popleft()
async def get(self, key: str) -> Optional[int]:
"""Get the current count for a key.
Args:
key (str): The key to get.
Returns:
Optional[int]: The current count, or None if the key doesn't exist.
"""
async with self._lock:
if key not in self.storage:
return None
return len(self.storage[key])
class RateLimiter:
"""Rate limiter middleware that uses a sliding window algorithm.
This implementation uses an in-memory store to track request counts
per client IP and path. It uses a sliding window to ensure accurate
rate limiting even at window boundaries.
"""
def __init__(self, times: int = 1, seconds: int = 1):
"""Initialize the rate limiter.
Args:
times (int, optional): Number of requests allowed. Defaults to 1.
seconds (int, optional): Time window in seconds. Defaults to 1.
"""
self.times = times
self.seconds = seconds
self.store = store
def _get_key(self, scope: dict) -> str:
"""Generate a unique key for rate limiting based on client IP and path.
Args:
scope (dict): The ASGI scope dictionary.
Returns:
str: A unique key combining client IP and path.
"""
# Use client's IP address as the key
client = scope.get('client', ['127.0.0.1'])[0]
path = scope.get('path', '')
return f'rate_limit:{client}:{path}'
async def __call__(self, scope: dict, receive: Callable, send: Callable) -> None:
"""Apply rate limiting to the request.
Args:
scope (dict): The ASGI scope dictionary.
receive (Callable): The ASGI receive function.
send (Callable): The ASGI send function.
"""
key = self._get_key(scope)
await self.store.expire(key, self.seconds)
requests = await self.store.get(key) or 0
if requests >= self.times:
await send(
{
'type': 'http.response.start',
'status': 429,
'headers': [(b'content-type', b'text/plain')],
}
)
await send(
{
'type': 'http.response.body',
'body': b'Too many requests',
}
)
return
await self.store.incr(key)
store = InMemoryStore()

View File

@@ -3,7 +3,7 @@ from fastapi import FastAPI, WebSocket
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema import ActionType
from openhands.utils.shutdown_listener import should_continue
from openhands.runtime.utils.shutdown_listener import should_continue
app = FastAPI()

View File

@@ -189,7 +189,6 @@ class AgentSession:
sid=self.sid,
plugins=agent.sandbox_plugins,
status_callback=self._status_callback,
headless_mode=False,
)
try:

View File

@@ -36,7 +36,6 @@ class Conversation:
event_stream=self.event_stream,
sid=self.sid,
attach_to_existing=True,
headless_mode=False,
)
async def connect(self):

View File

@@ -21,9 +21,9 @@ from openhands.events.observation.error import ErrorObservation
from openhands.events.serialization import event_from_dict, event_to_dict
from openhands.events.stream import EventStreamSubscriber
from openhands.llm.llm import LLM
from openhands.runtime.utils.shutdown_listener import should_continue
from openhands.server.session.agent_session import AgentSession
from openhands.storage.files import FileStore
from openhands.utils.shutdown_listener import should_continue
class Session:

View File

@@ -1,7 +1,7 @@
from tenacity import RetryCallState
from tenacity.stop import stop_base
from openhands.utils.shutdown_listener import should_exit
from openhands.runtime.utils.shutdown_listener import should_exit
class stop_if_should_exit(stop_base):

View File

@@ -137,7 +137,7 @@ def process_instance(
else:
logger.info(f'Starting evaluation for instance {instance.instance_id}.')
runtime = create_runtime(config, headless_mode=False)
runtime = create_runtime(config)
call_async_from_sync(runtime.connect)
try:

View File

@@ -135,7 +135,7 @@ def test_generate_dockerfile_build_from_scratch():
)
assert base_image in dockerfile_content
assert 'apt-get update' in dockerfile_content
assert 'wget curl sudo apt-utils git' in dockerfile_content
assert 'apt-get install -y wget curl sudo apt-utils' in dockerfile_content
assert 'poetry' in dockerfile_content and '-c conda-forge' in dockerfile_content
assert 'python=3.12' in dockerfile_content
@@ -155,7 +155,7 @@ def test_generate_dockerfile_build_from_lock():
)
# These commands SHOULD NOT include in the dockerfile if build_from_scratch is False
assert 'wget curl sudo apt-utils git' not in dockerfile_content
assert 'RUN apt update && apt install -y wget sudo' not in dockerfile_content
assert '-c conda-forge' not in dockerfile_content
assert 'python=3.12' not in dockerfile_content
assert 'https://micro.mamba.pm/install.sh' not in dockerfile_content
@@ -173,7 +173,7 @@ def test_generate_dockerfile_build_from_versioned():
)
# these commands should not exist when build from versioned
assert 'wget curl sudo apt-utils git' not in dockerfile_content
assert 'RUN apt update && apt install -y wget sudo' not in dockerfile_content
assert '-c conda-forge' not in dockerfile_content
assert 'python=3.12' not in dockerfile_content
assert 'https://micro.mamba.pm/install.sh' not in dockerfile_content