Compare commits

..

2 Commits

Author SHA1 Message Date
Xingyao Wang
1b7900eb60 feat: add CRITIC_API_KEY env var to bypass per-user budget limits
The SDK's build_critic() inherits the user's LLM proxy key, which may
have a /bin/bash budget on the LiteLLM proxy.  This causes all critic requests
to fail with 'budget_exceeded' even though the critic model is self-hosted
and free.

Add a CRITIC_API_KEY deployment env var that, when set, overrides the
critic's api_key after agent creation.  This allows deployments to provide
a service-level key that is not subject to per-user budget limits.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-29 02:09:49 -04:00
Xingyao Wang
73b25de13a feat(frontend): add critic result visualization and deployment config
- Add CriticResult types (CriticResult, CriticFeature, CriticCategorizedFeatures, CriticMetadata)
- Add CriticResultDisplay component with star rating, color-coded scores, expandable feature breakdown
- Integrate into FinishEventMessage and UserAssistantEventMessage
- Add critic_result field to ActionEvent and MessageEvent types
- Add deployment-level critic config (CRITIC_SERVER_URL, CRITIC_MODEL_NAME)
- Fill effective critic defaults in settings API response (no in-place mutation)
- Add agent_settings panel for critic settings on verification page
- Add prominenceOverrides support in SdkSectionPage to promote fields to basic view
- Promote 'Enable Iterative Refinement' to basic view alongside 'Enable Critic'
- Combine verification settings into single panel with manual header rendering
- Add verification section to agent_settings_schema mock
- Add i18n translations for critic labels (15 languages)
- Add 12 frontend unit tests + 3 backend settings API tests

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-28 16:34:52 -04:00
107 changed files with 2699 additions and 203 deletions

View File

@@ -39,9 +39,9 @@ from openhands.app_server.integrations.provider import PROVIDER_TOKEN_TYPE, Prov
from openhands.app_server.integrations.service_types import Comment
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
from openhands.app_server.user_auth.user_auth import UserAuth
from openhands.core.logger import openhands_logger as logger
from openhands.sdk import TextContent
from openhands.server.user_auth.user_auth import UserAuth
from openhands.utils.async_utils import call_sync_from_async
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)

View File

@@ -27,9 +27,9 @@ from openhands.app_server.integrations.provider import PROVIDER_TOKEN_TYPE, Prov
from openhands.app_server.integrations.service_types import Comment
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
from openhands.app_server.user_auth.user_auth import UserAuth
from openhands.core.logger import openhands_logger as logger
from openhands.sdk import TextContent
from openhands.server.user_auth.user_auth import UserAuth
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
CONFIDENTIAL_NOTE = 'confidential_note'

View File

@@ -42,13 +42,13 @@ from storage.jira_integration_store import JiraIntegrationStore
from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
from openhands.app_server.user_auth.user_auth import UserAuth
from openhands.core.logger import openhands_logger as logger
from openhands.server.types import (
LLMAuthenticationError,
MissingSettingsError,
SessionExpiredError,
)
from openhands.server.user_auth.user_auth import UserAuth
from openhands.utils.http_session import httpx_verify_option
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'

View File

@@ -7,7 +7,7 @@ from jinja2 import Environment
from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
from openhands.app_server.user_auth.user_auth import UserAuth
from openhands.server.user_auth.user_auth import UserAuth
if TYPE_CHECKING:
from integrations.jira.jira_payload import JiraWebhookPayload

View File

@@ -41,9 +41,9 @@ from openhands.app_server.config import get_app_conversation_service
from openhands.app_server.integrations.provider import ProviderHandler, ProviderType
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
from openhands.app_server.user_auth.user_auth import UserAuth
from openhands.core.logger import openhands_logger as logger
from openhands.sdk import TextContent
from openhands.server.user_auth.user_auth import UserAuth
from openhands.utils.http_session import httpx_verify_option
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'

View File

@@ -31,7 +31,6 @@ from storage.jira_dc_workspace import JiraDcWorkspace
from openhands.app_server.integrations.provider import ProviderHandler
from openhands.app_server.integrations.service_types import Repository
from openhands.app_server.user_auth.user_auth import UserAuth
from openhands.core.logger import openhands_logger as logger
from openhands.server.shared import server_config
from openhands.server.types import (
@@ -39,6 +38,7 @@ from openhands.server.types import (
MissingSettingsError,
SessionExpiredError,
)
from openhands.server.user_auth.user_auth import UserAuth
from openhands.utils.http_session import httpx_verify_option

View File

@@ -5,7 +5,7 @@ from jinja2 import Environment
from storage.jira_dc_user import JiraDcUser
from storage.jira_dc_workspace import JiraDcWorkspace
from openhands.app_server.user_auth.user_auth import UserAuth
from openhands.server.user_auth.user_auth import UserAuth
class JiraDcViewInterface(ABC):

View File

@@ -33,9 +33,9 @@ from openhands.app_server.config import get_app_conversation_service
from openhands.app_server.integrations.provider import ProviderHandler, ProviderType
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
from openhands.app_server.user_auth.user_auth import UserAuth
from openhands.core.logger import openhands_logger as logger
from openhands.sdk import TextContent
from openhands.server.user_auth.user_auth import UserAuth
integration_store = JiraDcIntegrationStore.get_instance()

View File

@@ -7,8 +7,8 @@ from openhands.app_server.integrations.provider import (
from openhands.app_server.integrations.service_types import ProviderType, UserGitInfo
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.app_server.user_auth.user_auth import UserAuth
from openhands.sdk.secret import SecretSource, StaticSecret
from openhands.server.user_auth.user_auth import UserAuth
class ResolverUserContext(UserContext):

View File

@@ -36,7 +36,6 @@ from openhands.app_server.integrations.service_types import (
ProviderTimeoutError,
Repository,
)
from openhands.app_server.user_auth.user_auth import UserAuth
from openhands.core.logger import openhands_logger as logger
from openhands.server.shared import config, server_config, sio
from openhands.server.types import (
@@ -44,6 +43,7 @@ from openhands.server.types import (
MissingSettingsError,
SessionExpiredError,
)
from openhands.server.user_auth.user_auth import UserAuth
authorize_url_generator = AuthorizeUrlGenerator(
client_id=SLACK_CLIENT_ID,

View File

@@ -5,7 +5,7 @@ from integrations.types import SummaryExtractionTracker
from jinja2 import Environment
from storage.slack_user import SlackUser
from openhands.app_server.user_auth.user_auth import UserAuth
from openhands.server.user_auth.user_auth import UserAuth
@dataclass

View File

@@ -34,9 +34,9 @@ from openhands.app_server.integrations.provider import ProviderHandler
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
from openhands.app_server.user_auth.user_auth import UserAuth
from openhands.core.logger import openhands_logger as logger
from openhands.sdk import TextContent
from openhands.server.user_auth.user_auth import UserAuth
from openhands.utils.async_utils import GENERAL_TIMEOUT
# =================================================

View File

@@ -10,7 +10,7 @@ if TYPE_CHECKING:
from integrations.models import Message
from openhands.app_server.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.app_server.user_auth.user_auth import UserAuth
from openhands.server.user_auth.user_auth import UserAuth
class GitLabResourceType(Enum):

View File

@@ -7,8 +7,8 @@ from pydantic import SecretStr
from server.auth.saas_user_auth import SaasUserAuth
from server.auth.token_manager import TokenManager
from openhands.app_server.user_auth.user_auth import UserAuth
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth.user_auth import UserAuth
def is_budget_exceeded_error(error_message: str) -> bool:

View File

@@ -40,8 +40,8 @@ from storage.org_member_store import OrgMemberStore
from storage.role import Role
from storage.role_store import RoleStore
from openhands.app_server.user_auth import get_user_auth, get_user_id
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_auth, get_user_id
class Permission(str, Enum):

View File

@@ -43,7 +43,7 @@ from openhands.app_server.integrations.provider import (
from openhands.app_server.secrets.secrets_models import Secrets
from openhands.app_server.settings.settings_models import Settings
from openhands.app_server.settings.settings_store import SettingsStore
from openhands.app_server.user_auth.user_auth import AuthType, UserAuth
from openhands.server.user_auth.user_auth import AuthType, UserAuth
token_manager = TokenManager()

View File

@@ -4,8 +4,8 @@ Email domain validation utilities for enterprise endpoints.
from fastapi import Depends, HTTPException, Request, status
from openhands.app_server.user_auth import get_user_auth, get_user_id
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_auth, get_user_id
async def get_admin_user_id(

View File

@@ -15,9 +15,9 @@ from server.auth.saas_user_auth import SaasUserAuth, token_manager
from server.routes.auth import set_response_cookie
from server.utils.url_utils import get_cookie_domain, get_cookie_samesite
from openhands.app_server.user_auth.user_auth import AuthType, UserAuth, get_user_auth
from openhands.core.logger import openhands_logger as logger
from openhands.server.shared import config
from openhands.server.user_auth.user_auth import AuthType, UserAuth, get_user_auth
class SetAuthCookieMiddleware:

View File

@@ -12,9 +12,9 @@ from storage.org_member_store import OrgMemberStore
from storage.org_service import OrgService
from storage.user_store import UserStore
from openhands.app_server.user_auth import get_user_auth, get_user_id
from openhands.app_server.user_auth.user_auth import AuthType
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_auth, get_user_id
from openhands.server.user_auth.user_auth import AuthType
# Helper functions for BYOR API key management

View File

@@ -3,7 +3,6 @@ import json
import uuid
import warnings
from datetime import datetime, timezone
from types import MappingProxyType
from typing import Annotated, Optional, cast
from urllib.parse import quote, urlencode
from uuid import UUID as parse_uuid
@@ -47,16 +46,13 @@ from storage.database import a_session_maker
from storage.user import User
from storage.user_store import UserStore
from openhands.app_server.integrations.provider import (
PROVIDER_TOKEN_TYPE,
ProviderHandler,
ProviderToken,
)
from openhands.app_server.integrations.provider import ProviderHandler
from openhands.app_server.integrations.service_types import ProviderType, TokenResponse
from openhands.app_server.user_auth import get_access_token
from openhands.app_server.user_auth.user_auth import get_user_auth
from openhands.core.logger import openhands_logger as logger
from openhands.server.services.conversation_service import create_provider_tokens_object
from openhands.server.shared import config
from openhands.server.user_auth import get_access_token
from openhands.server.user_auth.user_auth import get_user_auth
with warnings.catch_warnings():
warnings.simplefilter('ignore')
@@ -67,18 +63,6 @@ oauth_router = APIRouter(prefix='/oauth')
token_manager = TokenManager()
def create_provider_tokens_object(
providers_set: list[ProviderType],
) -> PROVIDER_TOKEN_TYPE:
"""Create provider tokens object for the given providers."""
provider_information: dict[ProviderType, ProviderToken] = {}
for provider in providers_set:
provider_information[provider] = ProviderToken(token=None, user_id=None)
return MappingProxyType(provider_information)
def set_response_cookie(
request: Request,
response: Response,

View File

@@ -21,7 +21,7 @@ from storage.subscription_access import SubscriptionAccess
from storage.user_store import UserStore
from openhands.app_server.config import get_global_config
from openhands.app_server.user_auth import get_user_id
from openhands.server.user_auth import get_user_id
stripe.api_key = STRIPE_API_KEY
billing_router = APIRouter(prefix='/api/billing', tags=['Billing'])

View File

@@ -13,9 +13,9 @@ from server.utils.rate_limit_utils import check_rate_limit_by_user_id
from server.utils.url_utils import get_web_url
from storage.user_store import UserStore
from openhands.app_server.user_auth import get_user_id
from openhands.app_server.user_auth.user_auth import get_user_auth
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
from openhands.server.user_auth.user_auth import get_user_auth
# Email validation regex pattern
EMAIL_REGEX = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')

View File

@@ -20,9 +20,9 @@ from storage.gitlab_webhook import GitlabWebhook
from storage.gitlab_webhook_store import GitlabWebhookStore
from openhands.app_server.integrations.gitlab.gitlab_service import GitLabServiceImpl
from openhands.app_server.user_auth import get_user_id
from openhands.core.logger import openhands_logger as logger
from openhands.server.shared import sio
from openhands.server.user_auth import get_user_id
gitlab_integration_router = APIRouter(prefix='/integration')
webhook_store = GitlabWebhookStore()

View File

@@ -20,8 +20,8 @@ from server.auth.token_manager import TokenManager
from storage.jira_workspace import JiraWorkspace
from storage.redis import create_redis_client
from openhands.app_server.user_auth.user_auth import get_user_auth
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth.user_auth import get_user_auth
# Environment variable to disable Jira webhooks
JIRA_WEBHOOKS_ENABLED = os.environ.get('JIRA_WEBHOOKS_ENABLED', '0') in (

View File

@@ -28,8 +28,8 @@ from server.auth.token_manager import TokenManager
from server.constants import WEB_HOST
from storage.redis import create_redis_client
from openhands.app_server.user_auth.user_auth import get_user_auth
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth.user_auth import get_user_auth
# Environment variable to disable Jira DC webhooks
JIRA_DC_WEBHOOKS_ENABLED = os.environ.get('JIRA_DC_WEBHOOKS_ENABLED', '0') in (

View File

@@ -10,8 +10,8 @@ from server.utils.url_utils import get_web_url
from storage.api_key_store import ApiKeyStore
from storage.device_code_store import DeviceCodeStore
from openhands.app_server.user_auth import get_user_id
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
# ---------------------------------------------------------------------------
# Constants

View File

@@ -22,8 +22,8 @@ from server.utils.rate_limit_utils import check_rate_limit_by_user_id
from storage.org_store import OrgStore
from storage.role_store import RoleStore
from openhands.app_server.user_auth import get_user_id
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
# Router for invitation operations on an organization (requires org_id)
invitation_router = APIRouter(prefix='/api/organizations/{org_id}/members')

View File

@@ -50,8 +50,8 @@ from storage.org_service import OrgService
from storage.org_store import OrgStore
from storage.user_store import UserStore
from openhands.app_server.user_auth import get_user_id
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
# Initialize API router
org_router = APIRouter(prefix='/api/organizations', tags=['Orgs'])

View File

@@ -19,7 +19,7 @@ from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
from openhands.app_server.integrations.service_types import ProviderType, Repository
from openhands.app_server.user_auth.user_auth import UserAuth
from openhands.server.user_auth.user_auth import UserAuth
@pytest.fixture

View File

@@ -17,7 +17,7 @@ from storage.jira_dc_user import JiraDcUser
from storage.jira_dc_workspace import JiraDcWorkspace
from openhands.app_server.integrations.service_types import ProviderType, Repository
from openhands.app_server.user_auth.user_auth import UserAuth
from openhands.server.user_auth.user_auth import UserAuth
@pytest.fixture

View File

@@ -17,7 +17,7 @@ from storage.slack_conversation import SlackConversation
from storage.slack_user import SlackUser
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
from openhands.app_server.user_auth.user_auth import UserAuth
from openhands.server.user_auth.user_auth import UserAuth
# ---------------------------------------------------------------------------
# Fixtures

View File

@@ -19,7 +19,7 @@ from server.routes.api_keys import (
)
from storage.lite_llm_manager import LiteLlmManager
from openhands.app_server.user_auth.user_auth import AuthType
from openhands.server.user_auth.user_auth import AuthType
class TestVerifyByorKeyInLitellm:

View File

@@ -22,7 +22,7 @@ from server.routes.orgs import (
from sqlalchemy.exc import IntegrityError
from storage.org_git_claim import OrgGitClaim
from openhands.app_server.user_auth import get_user_id
from openhands.server.user_auth import get_user_id
TEST_USER_ID = str(uuid.uuid4())

View File

@@ -44,8 +44,8 @@ from server.routes.orgs import (
)
from storage.org import Org
from openhands.app_server.user_auth import get_user_id
from openhands.sdk.settings import AgentSettings, ConversationSettings
from openhands.server.user_auth import get_user_id
# Test user ID constant (must be a valid UUID string)
TEST_USER_ID = str(uuid.uuid4())

View File

@@ -16,7 +16,7 @@ from server.routes.user_app_settings_models import (
UserNotFoundError,
)
from openhands.app_server.user_auth import get_user_id
from openhands.server.user_auth import get_user_id
TEST_USER_ID = str(uuid.uuid4())

View File

@@ -144,7 +144,7 @@ class TestGetOrgInfoFromContext:
from server.routes.users_v1 import _get_org_info_from_context
from openhands.app_server.user.auth_user_context import AuthUserContext
from openhands.app_server.user_auth.user_auth import UserAuth
from openhands.server.user_auth.user_auth import UserAuth
# Create AuthUserContext with a non-SaasUserAuth
mock_user_auth = MagicMock(spec=UserAuth)

View File

@@ -13,7 +13,7 @@ from server.auth.auth_error import (
from server.auth.saas_user_auth import SaasUserAuth
from server.middleware import SetAuthCookieMiddleware
from openhands.app_server.user_auth.user_auth import AuthType
from openhands.server.user_auth.user_auth import AuthType
@pytest.fixture

View File

@@ -799,7 +799,7 @@ async def test_logout_without_refresh_token():
with patch('server.routes.auth.token_manager') as mock_token_manager:
with patch(
'openhands.app_server.user_auth.default_user_auth.DefaultUserAuth.get_instance'
'openhands.server.user_auth.default_user_auth.DefaultUserAuth.get_instance'
) as mock_get_instance:
mock_get_instance.side_effect = AuthError()
result = await logout(mock_request)

View File

@@ -68,7 +68,7 @@ class TestAcceptInvitationPostEndpoint:
def auth_app(self):
"""Create a FastAPI app with dependency overrides for authenticated tests."""
from openhands.app_server.user_auth import get_user_id
from openhands.server.user_auth import get_user_id
app = FastAPI()
app.include_router(accept_router)
@@ -200,7 +200,7 @@ class TestCreateInvitationBatchEndpoint:
@pytest.fixture
def batch_app(self):
"""Create a FastAPI app with dependency overrides for batch tests."""
from openhands.app_server.user_auth import get_user_id
from openhands.server.user_auth import get_user_id
app = FastAPI()
app.include_router(invitation_router)

View File

@@ -16,7 +16,7 @@ from openhands.app_server.integrations.service_types import (
ProviderType,
Repository,
)
from openhands.app_server.user_auth.user_auth import UserAuth
from openhands.server.user_auth.user_auth import UserAuth
@pytest.fixture

View File

@@ -39,7 +39,6 @@
"overrides": [
{
"files": ["*.ts", "*.tsx"],
"excludedFiles": ["src/hooks/query/query-keys.ts"],
"rules": {
// Allow state modification in reduce and Redux reducers
"no-param-reassign": [
@@ -49,13 +48,8 @@
"ignorePropertyModificationsFor": ["acc", "state"],
},
],
"no-restricted-syntax": [
"error",
{
"selector": "Property[key.name='queryKey'] > ArrayExpression[elements.0.value='settings']",
"message": "Use SETTINGS_QUERY_KEYS helpers instead of raw settings query key arrays."
}
],
// For https://stackoverflow.com/questions/55844608/stuck-with-eslint-error-i-e-separately-loops-should-be-avoided-in-favor-of-arra
"no-restricted-syntax": "off",
"react/require-default-props": "off",
"import/prefer-default-export": "off",
"no-underscore-dangle": "off",

View File

@@ -0,0 +1,204 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { renderWithProviders } from "test-utils";
import { CriticResultDisplay } from "#/components/v1/chat/event-message-components/critic-result-display";
import type { CriticResult } from "#/types/v1/core/base/critic";
const makeCriticResult = (
overrides: Partial<CriticResult> = {},
): CriticResult => ({
score: 0.85,
message: null,
metadata: null,
...overrides,
});
describe("CriticResultDisplay", () => {
it("renders score as percentage", () => {
renderWithProviders(
<CriticResultDisplay criticResult={makeCriticResult({ score: 0.72 })} />,
);
expect(screen.getByText("(72.0%)")).toBeInTheDocument();
});
it("renders 5 stars for a perfect score", () => {
renderWithProviders(
<CriticResultDisplay criticResult={makeCriticResult({ score: 1.0 })} />,
);
expect(screen.getByText("★★★★★")).toBeInTheDocument();
});
it("renders 0 stars for a zero score", () => {
renderWithProviders(
<CriticResultDisplay criticResult={makeCriticResult({ score: 0 })} />,
);
expect(screen.getByText("☆☆☆☆☆")).toBeInTheDocument();
});
it("renders green color for high score", () => {
renderWithProviders(
<CriticResultDisplay criticResult={makeCriticResult({ score: 0.8 })} />,
);
const stars = screen.getByText("★★★★☆");
expect(stars.className).toContain("text-green-400");
});
it("renders yellow color for medium score", () => {
renderWithProviders(
<CriticResultDisplay criticResult={makeCriticResult({ score: 0.5 })} />,
);
const stars = screen.getByText("★★★☆☆");
expect(stars.className).toContain("text-yellow-400");
});
it("renders red color for low score", () => {
renderWithProviders(
<CriticResultDisplay criticResult={makeCriticResult({ score: 0.2 })} />,
);
const stars = screen.getByText("★☆☆☆☆");
expect(stars.className).toContain("text-red-400");
});
it("renders label text", () => {
renderWithProviders(
<CriticResultDisplay criticResult={makeCriticResult()} />,
);
expect(
screen.getByText("CRITIC$SUCCESS_LIKELIHOOD_LABEL"),
).toBeInTheDocument();
});
it("does not render expand button without features", () => {
renderWithProviders(
<CriticResultDisplay criticResult={makeCriticResult()} />,
);
expect(
screen.queryByLabelText("Expand details"),
).not.toBeInTheDocument();
});
it("renders expand button when features are present", () => {
const result = makeCriticResult({
metadata: {
categorized_features: {
agent_behavioral_issues: [
{
name: "insufficient_testing",
display_name: "Insufficient Testing",
probability: 0.75,
},
],
},
},
});
renderWithProviders(<CriticResultDisplay criticResult={result} />);
expect(screen.getByLabelText("Expand details")).toBeInTheDocument();
});
it("expands features on click", async () => {
const user = userEvent.setup();
const result = makeCriticResult({
metadata: {
categorized_features: {
agent_behavioral_issues: [
{
name: "insufficient_testing",
display_name: "Insufficient Testing",
probability: 0.75,
},
],
},
},
});
renderWithProviders(<CriticResultDisplay criticResult={result} />);
expect(
screen.queryByText("Insufficient Testing"),
).not.toBeInTheDocument();
await user.click(screen.getByLabelText("Expand details"));
expect(screen.getByText("Insufficient Testing")).toBeInTheDocument();
expect(screen.getByText("(75%)")).toBeInTheDocument();
expect(screen.getByText("CRITIC$POTENTIAL_ISSUES")).toBeInTheDocument();
});
it("collapses features on second click", async () => {
const user = userEvent.setup();
const result = makeCriticResult({
metadata: {
categorized_features: {
agent_behavioral_issues: [
{
name: "loop_behavior",
display_name: "Loop Behavior",
probability: 0.6,
},
],
},
},
});
renderWithProviders(<CriticResultDisplay criticResult={result} />);
await user.click(screen.getByLabelText("Expand details"));
expect(screen.getByText("Loop Behavior")).toBeInTheDocument();
await user.click(screen.getByLabelText("Collapse details"));
expect(screen.queryByText("Loop Behavior")).not.toBeInTheDocument();
});
it("renders multiple categories of features", async () => {
const user = userEvent.setup();
const result = makeCriticResult({
metadata: {
categorized_features: {
agent_behavioral_issues: [
{
name: "incomplete_changes",
display_name: "Incomplete Changes",
probability: 0.8,
},
],
infrastructure_issues: [
{
name: "build_failure",
display_name: "Build Failure",
probability: 0.4,
},
],
user_followup_patterns: [
{
name: "will_ask_refinement",
display_name: "Will Ask Refinement",
probability: 0.55,
},
],
},
},
});
renderWithProviders(<CriticResultDisplay criticResult={result} />);
await user.click(screen.getByLabelText("Expand details"));
expect(screen.getByText("CRITIC$POTENTIAL_ISSUES")).toBeInTheDocument();
expect(screen.getByText("Incomplete Changes")).toBeInTheDocument();
expect(screen.getByText("CRITIC$INFRASTRUCTURE")).toBeInTheDocument();
expect(screen.getByText("Build Failure")).toBeInTheDocument();
expect(screen.getByText("CRITIC$LIKELY_FOLLOWUP")).toBeInTheDocument();
expect(screen.getByText("Will Ask Refinement")).toBeInTheDocument();
});
});

View File

@@ -13,10 +13,17 @@ function buildSettings(overrides: Partial<Settings> = {}): Settings {
return {
...MOCK_DEFAULT_USER_SETTINGS,
...overrides,
agent_settings: {
...MOCK_DEFAULT_USER_SETTINGS.agent_settings,
...overrides.agent_settings,
},
conversation_settings: {
...MOCK_DEFAULT_USER_SETTINGS.conversation_settings,
...overrides.conversation_settings,
},
agent_settings_schema:
overrides.agent_settings_schema ??
MOCK_DEFAULT_USER_SETTINGS.agent_settings_schema,
conversation_settings_schema:
overrides.conversation_settings_schema ??
MOCK_DEFAULT_USER_SETTINGS.conversation_settings_schema,
@@ -42,7 +49,7 @@ beforeEach(() => {
});
describe("VerificationSettingsScreen", () => {
it("keeps confirmation mode visible in the basic view", async () => {
it("keeps confirmation mode toggle visible in the header", async () => {
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(buildSettings());
renderVerificationSettingsScreen();
@@ -50,14 +57,15 @@ describe("VerificationSettingsScreen", () => {
await screen.findByTestId("verification-settings-screen");
expect(
screen.getByTestId("sdk-settings-confirmation_mode"),
screen.getByTestId("confirmation-mode-toggle"),
).toBeInTheDocument();
// Security analyzer is hidden when confirmation mode is off
expect(
screen.queryByTestId("sdk-settings-security_analyzer"),
screen.queryByTestId("security-analyzer-input"),
).not.toBeInTheDocument();
});
it("shows the security analyzer only in advanced view", async () => {
it("shows the security analyzer when confirmation mode is enabled", async () => {
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
buildSettings({
conversation_settings: {
@@ -72,17 +80,11 @@ describe("VerificationSettingsScreen", () => {
await screen.findByTestId("verification-settings-screen");
// Security analyzer should appear because confirmation mode is on
expect(
screen.queryByTestId("sdk-settings-security_analyzer"),
).not.toBeInTheDocument();
await userEvent.click(screen.getByTestId("sdk-section-advanced-toggle"));
expect(
await screen.findByTestId("sdk-settings-security_analyzer"),
screen.getByTestId("security-analyzer-input"),
).toBeInTheDocument();
});
});
describe("clientLoader permission checks", () => {

View File

@@ -14,7 +14,12 @@ import { useMe } from "#/hooks/query/use-me";
import { useSettings } from "#/hooks/query/use-settings";
import { I18nKey } from "#/i18n/declaration";
import { Typography } from "#/ui/typography";
import { Settings, SettingsSchema, SettingsScope } from "#/types/settings";
import {
SettingProminence,
Settings,
SettingsSchema,
SettingsScope,
} from "#/types/settings";
import {
displayErrorToast,
displaySuccessToast,
@@ -104,6 +109,7 @@ export function SdkSectionPage({
buildPayload,
onSaveSuccess,
getInitialView,
prominenceOverrides,
forceShowAdvancedView = false,
allowAllView = true,
testId = "sdk-section-settings-screen",
@@ -128,6 +134,8 @@ export function SdkSectionPage({
settings: Settings,
filteredSchema: SettingsSchema,
) => SettingsView;
/** Override the prominence level of specific field keys. */
prominenceOverrides?: Record<string, SettingProminence>;
forceShowAdvancedView?: boolean;
allowAllView?: boolean;
testId?: string;
@@ -175,11 +183,23 @@ export function SdkSectionPage({
const filteredSchema = React.useMemo(() => {
if (!schema) return null;
const sectionSet = new Set(stableSectionKeys);
return {
const filtered = {
...schema,
sections: schema.sections.filter((s) => sectionSet.has(s.key)),
};
}, [schema, stableSectionKeys]);
if (!prominenceOverrides) return filtered;
return {
...filtered,
sections: filtered.sections.map((s) => ({
...s,
fields: s.fields.map((f) =>
prominenceOverrides[f.key]
? { ...f, prominence: prominenceOverrides[f.key] }
: f,
),
})),
};
}, [schema, stableSectionKeys, prominenceOverrides]);
const showAdvanced =
forceShowAdvancedView || hasAdvancedSettings(filteredSchema);

View File

@@ -0,0 +1,192 @@
import React from "react";
import { useTranslation } from "react-i18next";
import ArrowDown from "#/icons/angle-down-solid.svg?react";
import ArrowUp from "#/icons/angle-up-solid.svg?react";
import { I18nKey } from "#/i18n/declaration";
import type {
CriticResult,
CriticFeature,
CriticCategorizedFeatures,
} from "#/types/v1/core/base/critic";
/**
* Convert a score (0-1) to a 5-star rating string.
*/
function getStarRating(score: number): { filled: number; empty: number } {
const filled = Math.round(score * 5);
return { filled, empty: 5 - filled };
}
/**
* Get the color class for the star rating based on score.
*/
function getScoreColorClass(score: number): string {
if (score >= 0.6) return "text-green-400";
if (score >= 0.4) return "text-yellow-400";
return "text-red-400";
}
/**
* Get the color class for an issue probability.
*/
function getIssueColorClass(probability: number): string {
if (probability >= 0.7) return "text-red-400 font-semibold";
if (probability >= 0.5) return "text-yellow-400";
return "text-neutral-400";
}
/**
* Renders a single issue feature with its probability.
*/
function FeatureItem({ feature }: { feature: CriticFeature }) {
const percentage = Math.round(feature.probability * 100);
const colorClass = getIssueColorClass(feature.probability);
return (
<span className="inline-flex items-center gap-1">
<span className="text-neutral-200">{feature.display_name}</span>
<span className={colorClass}>({percentage}%)</span>
</span>
);
}
/**
* Renders a category of features (e.g., "Potential Issues", "Infrastructure").
*/
function FeatureCategory({
label,
features,
}: {
label: string;
features: CriticFeature[];
}) {
if (!features || features.length === 0) return null;
return (
<div className="flex flex-wrap items-center gap-x-1 text-xs">
<span className="font-semibold text-neutral-300">{label}</span>
{features.map((feature, i) => (
<React.Fragment key={feature.name}>
{i > 0 && <span className="text-neutral-500">·</span>}
<FeatureItem feature={feature} />
</React.Fragment>
))}
</div>
);
}
/**
* Renders the categorized features breakdown.
*/
function FeaturesBreakdown({
categorized,
}: {
categorized: CriticCategorizedFeatures;
}) {
const { t } = useTranslation();
const hasAgentIssues =
categorized.agent_behavioral_issues &&
categorized.agent_behavioral_issues.length > 0;
const hasUserPatterns =
categorized.user_followup_patterns &&
categorized.user_followup_patterns.length > 0;
const hasInfra =
categorized.infrastructure_issues &&
categorized.infrastructure_issues.length > 0;
const hasOther = categorized.other && categorized.other.length > 0;
if (!hasAgentIssues && !hasUserPatterns && !hasInfra && !hasOther) {
return null;
}
return (
<div className="flex flex-col gap-1 mt-1.5">
{hasAgentIssues && (
<FeatureCategory
label={t(I18nKey.CRITIC$POTENTIAL_ISSUES)}
features={categorized.agent_behavioral_issues!}
/>
)}
{hasInfra && (
<FeatureCategory
label={t(I18nKey.CRITIC$INFRASTRUCTURE)}
features={categorized.infrastructure_issues!}
/>
)}
{hasUserPatterns && (
<FeatureCategory
label={t(I18nKey.CRITIC$LIKELY_FOLLOWUP)}
features={categorized.user_followup_patterns!}
/>
)}
{hasOther && (
<FeatureCategory
label={t(I18nKey.CRITIC$OTHER)}
features={categorized.other!}
/>
)}
</div>
);
}
interface CriticResultDisplayProps {
criticResult: CriticResult;
}
/**
* Displays a critic evaluation result with star rating, score percentage,
* and expandable categorized feature breakdown.
*/
export function CriticResultDisplay({
criticResult,
}: CriticResultDisplayProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = React.useState(false);
const { filled, empty } = getStarRating(criticResult.score);
const colorClass = getScoreColorClass(criticResult.score);
const percentage = (criticResult.score * 100).toFixed(1);
const categorized = criticResult.metadata?.categorized_features;
const hasDetails =
categorized != null &&
((categorized.agent_behavioral_issues ?? []).length > 0 ||
(categorized.user_followup_patterns ?? []).length > 0 ||
(categorized.infrastructure_issues ?? []).length > 0 ||
(categorized.other ?? []).length > 0);
return (
<div className="border-l-2 border-neutral-600 pl-2 my-2 py-1.5 text-sm">
<div className="flex items-center gap-1">
<span className="font-semibold text-neutral-300 text-xs">
{t(I18nKey.CRITIC$SUCCESS_LIKELIHOOD_LABEL)}
</span>
<span className={`${colorClass} text-xs tracking-wide`}>
{"★".repeat(filled)}
{"☆".repeat(empty)}
</span>
<span className="text-neutral-500 text-xs">({percentage}%)</span>
{hasDetails && (
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
className="cursor-pointer ml-1"
aria-label={expanded ? "Collapse details" : "Expand details"}
>
{expanded ? (
<ArrowUp className="h-3 w-3 inline fill-neutral-400" />
) : (
<ArrowDown className="h-3 w-3 inline fill-neutral-400" />
)}
</button>
)}
</div>
{expanded && hasDetails && (
<FeaturesBreakdown categorized={categorized!} />
)}
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { ActionEvent } from "#/types/v1/core";
import { FinishAction } from "#/types/v1/core/base/action";
import { ChatMessage } from "../../../features/chat/chat-message";
import { getEventContent } from "../event-content-helpers/get-event-content";
import { CriticResultDisplay } from "./critic-result-display";
interface FinishEventMessageProps {
event: ActionEvent<FinishAction>;
@@ -19,10 +20,15 @@ export function FinishEventMessage({
: String(eventContent.details);
return (
<ChatMessage
type="agent"
message={message}
isFromPlanningAgent={isFromPlanningAgent}
/>
<>
<ChatMessage
type="agent"
message={message}
isFromPlanningAgent={isFromPlanningAgent}
/>
{event.critic_result != null && (
<CriticResultDisplay criticResult={event.critic_result} />
)}
</>
);
}

View File

@@ -5,3 +5,4 @@ export { FinishEventMessage } from "./finish-event-message";
export { GenericEventMessageWrapper } from "./generic-event-message-wrapper";
export { ThoughtEventMessage } from "./thought-event-message";
export { HookExecutionEventMessage } from "./hook-execution-event-message";
export { CriticResultDisplay } from "./critic-result-display";

View File

@@ -4,6 +4,7 @@ import { ChatMessage } from "../../../features/chat/chat-message";
import { ImageCarousel } from "../../../features/images/image-carousel";
import { V1ConfirmationButtons } from "#/components/shared/buttons/v1-confirmation-buttons";
import { parseMessageFromEvent } from "../event-content-helpers/parse-message-from-event";
import { CriticResultDisplay } from "./critic-result-display";
interface UserAssistantEventMessageProps {
event: MessageEvent;
@@ -28,15 +29,20 @@ export function UserAssistantEventMessage({
}
return (
<ChatMessage
type={event.source}
message={message}
isFromPlanningAgent={isFromPlanningAgent}
>
{imageUrls.length > 0 && (
<ImageCarousel size="small" images={imageUrls} />
<>
<ChatMessage
type={event.source}
message={message}
isFromPlanningAgent={isFromPlanningAgent}
>
{imageUrls.length > 0 && (
<ImageCarousel size="small" images={imageUrls} />
)}
{isLastMessage && <V1ConfirmationButtons />}
</ChatMessage>
{event.source === "agent" && event.critic_result != null && (
<CriticResultDisplay criticResult={event.critic_result} />
)}
{isLastMessage && <V1ConfirmationButtons />}
</ChatMessage>
</>
);
}

View File

@@ -3,7 +3,6 @@ import { SecretsService } from "#/api/secrets-service";
import { Provider, ProviderToken } from "#/types/settings";
import { useTracking } from "#/hooks/use-tracking";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
import { SETTINGS_QUERY_KEYS } from "#/hooks/query/query-keys";
export const useAddGitProviders = () => {
const queryClient = useQueryClient();
@@ -29,7 +28,7 @@ export const useAddGitProviders = () => {
}
await queryClient.invalidateQueries({
queryKey: SETTINGS_QUERY_KEYS.personal(organizationId),
queryKey: ["settings", "personal", organizationId],
});
},
meta: {

View File

@@ -9,7 +9,6 @@ import {
} from "#/types/settings";
import { parseMcpConfig, toSdkMcpConfig } from "#/utils/mcp-config";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
import { SETTINGS_QUERY_KEYS } from "#/hooks/query/query-keys";
type MCPServerType = "sse" | "stdio" | "shttp";
@@ -70,7 +69,7 @@ export function useAddMcpServer() {
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: SETTINGS_QUERY_KEYS.personal(organizationId),
queryKey: ["settings", "personal", organizationId],
});
},
});

View File

@@ -1,7 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
import { SecretsService } from "#/api/secrets-service";
import { SETTINGS_QUERY_KEYS } from "#/hooks/query/query-keys";
export const useDeleteGitProviders = () => {
const queryClient = useQueryClient();
@@ -11,7 +10,7 @@ export const useDeleteGitProviders = () => {
mutationFn: () => SecretsService.deleteGitProviders(),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: SETTINGS_QUERY_KEYS.personal(organizationId),
queryKey: ["settings", "personal", organizationId],
});
},
meta: {

View File

@@ -4,7 +4,6 @@ import SettingsService from "#/api/settings-service/settings-service.api";
import { MCPConfig } from "#/types/settings";
import { parseMcpConfig, toSdkMcpConfig } from "#/utils/mcp-config";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
import { SETTINGS_QUERY_KEYS } from "#/hooks/query/query-keys";
export function useDeleteMcpServer() {
const queryClient = useQueryClient();
@@ -39,7 +38,7 @@ export function useDeleteMcpServer() {
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: SETTINGS_QUERY_KEYS.personal(organizationId),
queryKey: ["settings", "personal", organizationId],
});
},
});

View File

@@ -1,9 +1,8 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { usePostHog } from "posthog-js/react";
import AuthService from "#/api/auth-service/auth-service.api";
import { clearLoginData } from "#/utils/local-storage";
import { SETTINGS_QUERY_KEYS } from "../query/query-keys";
import { useConfig } from "../query/use-config";
import { clearLoginData } from "#/utils/local-storage";
export const useLogout = () => {
const posthog = usePostHog();
@@ -14,7 +13,7 @@ export const useLogout = () => {
mutationFn: () => AuthService.logout(config?.app_mode ?? "oss"),
onSuccess: async () => {
queryClient.removeQueries({ queryKey: ["tasks"] });
queryClient.removeQueries({ queryKey: SETTINGS_QUERY_KEYS.all });
queryClient.removeQueries({ queryKey: ["settings"] });
queryClient.removeQueries({ queryKey: ["user"] });
queryClient.removeQueries({ queryKey: ["secrets"] });

View File

@@ -10,7 +10,6 @@ import {
SettingsValue,
} from "#/types/settings";
import { useSettings } from "../query/use-settings";
import { SETTINGS_QUERY_KEYS } from "../query/query-keys";
type SettingsUpdate = Partial<Settings> & Record<string, unknown>;
@@ -102,11 +101,11 @@ export const useSaveSettings = (scope: SettingsScope = "personal") => {
},
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: SETTINGS_QUERY_KEYS.byScope(scope, organizationId),
queryKey: ["settings", scope, organizationId],
});
if (scope === "org") {
await queryClient.invalidateQueries({
queryKey: SETTINGS_QUERY_KEYS.personal(organizationId),
queryKey: ["settings", "personal", organizationId],
});
}
},

View File

@@ -1,7 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { openHands } from "#/api/open-hands-axios";
import { SETTINGS_QUERY_KEYS } from "#/hooks/query/query-keys";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
type SubmitOnboardingArgs = {
@@ -19,7 +18,7 @@ export const useSubmitOnboarding = () => {
return { selections };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SETTINGS_QUERY_KEYS.all });
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["onboarding-status"] });
const finalRedirectUrl = "/";

View File

@@ -32,7 +32,7 @@ export const useSwitchOrganization = () => {
queryKey: ["organizations", orgId, "me"],
});
// Update local state - this triggers automatic refetch for all org-scoped queries
// since their query keys include the selected organizationId.
// since their query keys include organizationId (e.g., ["settings", orgId], ["secrets", orgId])
setOrganizationId(orgId);
// Broadcast org change to other apps (e.g. Automations) via localStorage
setSelectedOrg(orgId);

View File

@@ -9,7 +9,6 @@ import {
} from "#/types/settings";
import { parseMcpConfig, toSdkMcpConfig } from "#/utils/mcp-config";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
import { SETTINGS_QUERY_KEYS } from "#/hooks/query/query-keys";
type MCPServerType = "sse" | "stdio" | "shttp";
@@ -78,7 +77,7 @@ export function useUpdateMcpServer() {
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: SETTINGS_QUERY_KEYS.personal(organizationId),
queryKey: ["settings", "personal", organizationId],
});
},
});

View File

@@ -3,21 +3,11 @@
* Using constants ensures type safety and prevents typos.
*/
import { SettingsScope } from "#/types/settings";
export const QUERY_KEYS = {
/** Web client configuration from the server */
WEB_CLIENT_CONFIG: ["web-client-config"] as const,
} as const;
export const SETTINGS_QUERY_KEYS = {
all: ["settings"] as const,
byScope: (scope: SettingsScope, organizationId: string | null | undefined) =>
["settings", scope, organizationId] as const,
personal: (organizationId: string | null | undefined) =>
["settings", "personal", organizationId] as const,
} as const;
/** Cache configuration shared across all config-related queries */
export const CONFIG_CACHE_OPTIONS = {
staleTime: 1000 * 60 * 5, // 5 minutes

View File

@@ -13,7 +13,6 @@ import {
pickNullableString,
} from "#/utils/settings-value-pickers";
import { parseMcpConfig } from "#/utils/mcp-config";
import { SETTINGS_QUERY_KEYS } from "./query-keys";
/** Look up a value in a nested object by dotted key path. */
const lookupNested = (obj: Record<string, unknown>, key: string): unknown => {
@@ -131,7 +130,7 @@ export const useSettings = (scope: SettingsScope = "personal") => {
const isOss = config?.app_mode === "oss";
const query = useQuery({
queryKey: SETTINGS_QUERY_KEYS.byScope(scope, organizationId),
queryKey: ["settings", scope, organizationId],
queryFn: () => getSettingsQueryFn(scope, organizationId),
retry: (_, error) => error.status !== 404,
refetchOnWindowFocus: false,

View File

@@ -42,6 +42,11 @@ export enum I18nKey {
FINISH$TASK_COMPLETED_SUCCESSFULLY = "FINISH$TASK_COMPLETED_SUCCESSFULLY",
FINISH$TASK_NOT_COMPLETED = "FINISH$TASK_NOT_COMPLETED",
FINISH$TASK_COMPLETED_PARTIALLY = "FINISH$TASK_COMPLETED_PARTIALLY",
CRITIC$SUCCESS_LIKELIHOOD_LABEL = "CRITIC$SUCCESS_LIKELIHOOD_LABEL",
CRITIC$POTENTIAL_ISSUES = "CRITIC$POTENTIAL_ISSUES",
CRITIC$INFRASTRUCTURE = "CRITIC$INFRASTRUCTURE",
CRITIC$LIKELY_FOLLOWUP = "CRITIC$LIKELY_FOLLOWUP",
CRITIC$OTHER = "CRITIC$OTHER",
EVENT$UNKNOWN_EVENT = "EVENT$UNKNOWN_EVENT",
OBSERVATION$COMMAND_NO_OUTPUT = "OBSERVATION$COMMAND_NO_OUTPUT",
OBSERVATION$MCP_NO_OUTPUT = "OBSERVATION$MCP_NO_OUTPUT",

View File

@@ -24053,5 +24053,90 @@
"de": "Keine Git-Organisationen gefunden.",
"uk": "Організації Git не знайдено.",
"ca": "No s'han trobat organitzacions Git."
},
"CRITIC$SUCCESS_LIKELIHOOD_LABEL": {
"en": "Critic: agent success likelihood",
"ja": "クリティック: エージェント成功確率",
"zh-CN": "评估: 代理成功概率",
"zh-TW": "評估: 代理成功機率",
"ko-KR": "평가: 에이전트 성공 가능성",
"no": "Kritiker: sannsynlighet for agentsuksess",
"it": "Critico: probabilità di successo dell'agente",
"pt": "Crítico: probabilidade de sucesso do agente",
"es": "Crítico: probabilidad de éxito del agente",
"ar": "الناقد: احتمالية نجاح الوكيل",
"fr": "Critique : probabilité de succès de l'agent",
"tr": "Eleştirmen: ajan başarı olasılığı",
"de": "Kritiker: Erfolgswahrscheinlichkeit des Agenten",
"uk": "Критик: ймовірність успіху агента",
"ca": "Crític: probabilitat d'èxit de l'agent"
},
"CRITIC$POTENTIAL_ISSUES": {
"en": "Potential Issues:",
"ja": "潜在的な問題:",
"zh-CN": "潜在问题:",
"zh-TW": "潛在問題:",
"ko-KR": "잠재적 문제:",
"no": "Potensielle problemer:",
"it": "Problemi potenziali:",
"pt": "Problemas potenciais:",
"es": "Problemas potenciales:",
"ar": "مشاكل محتملة:",
"fr": "Problèmes potentiels :",
"tr": "Olası sorunlar:",
"de": "Mögliche Probleme:",
"uk": "Потенційні проблеми:",
"ca": "Problemes potencials:"
},
"CRITIC$INFRASTRUCTURE": {
"en": "Infrastructure:",
"ja": "インフラストラクチャ:",
"zh-CN": "基础设施:",
"zh-TW": "基礎設施:",
"ko-KR": "인프라:",
"no": "Infrastruktur:",
"it": "Infrastruttura:",
"pt": "Infraestrutura:",
"es": "Infraestructura:",
"ar": "البنية التحتية:",
"fr": "Infrastructure :",
"tr": "Altyapı:",
"de": "Infrastruktur:",
"uk": "Інфраструктура:",
"ca": "Infraestructura:"
},
"CRITIC$LIKELY_FOLLOWUP": {
"en": "Likely Follow-up:",
"ja": "予想されるフォローアップ:",
"zh-CN": "可能的后续操作:",
"zh-TW": "可能的後續操作:",
"ko-KR": "예상 후속 조치:",
"no": "Sannsynlig oppfølging:",
"it": "Probabile seguito:",
"pt": "Provável acompanhamento:",
"es": "Probable seguimiento:",
"ar": "متابعة محتملة:",
"fr": "Suivi probable :",
"tr": "Olası takip:",
"de": "Wahrscheinliche Nachverfolgung:",
"uk": "Ймовірне продовження:",
"ca": "Probable seguiment:"
},
"CRITIC$OTHER": {
"en": "Other:",
"ja": "その他:",
"zh-CN": "其他:",
"zh-TW": "其他:",
"ko-KR": "기타:",
"no": "Annet:",
"it": "Altro:",
"pt": "Outros:",
"es": "Otros:",
"ar": "أخرى:",
"fr": "Autre :",
"tr": "Diğer:",
"de": "Sonstiges:",
"uk": "Інше:",
"ca": "Altres:"
}
}

View File

@@ -185,6 +185,75 @@ const MOCK_AGENT_SETTINGS_SCHEMA: NonNullable<
},
],
},
{
key: "verification",
label: "Verification",
fields: [
{
key: "verification.confirmation_mode",
label: "Confirmation mode",
description:
"Pause for confirmation before the agent performs high-risk actions.",
section: "verification",
section_label: "Verification",
value_type: "boolean",
default: false,
choices: [],
depends_on: [],
prominence: "critical",
secret: false,
required: true,
},
{
key: "verification.security_analyzer",
label: "Security analyzer",
description:
"Choose how OpenHands should analyze actions before asking for confirmation.",
section: "verification",
section_label: "Verification",
value_type: "string",
default: "llm",
choices: [
{ label: "llm", value: "llm" },
{ label: "none", value: "none" },
],
depends_on: ["verification.confirmation_mode"],
prominence: "major",
secret: false,
required: false,
},
{
key: "verification.critic_enabled",
label: "Enable Critic",
description:
"Enable an additional critic pass to review the agent's work.",
section: "verification",
section_label: "Verification",
value_type: "boolean",
default: false,
choices: [],
depends_on: [],
prominence: "critical",
secret: false,
required: false,
},
{
key: "verification.enable_iterative_refinement",
label: "Enable Iterative Refinement",
description:
"Let the critic send the agent back to refine its work when issues are found.",
section: "verification",
section_label: "Verification",
value_type: "boolean",
default: false,
choices: [],
depends_on: ["verification.critic_enabled"],
prominence: "minor",
secret: false,
required: false,
},
],
},
],
};

View File

@@ -2,7 +2,6 @@ import React, { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import { SETTINGS_QUERY_KEYS } from "#/hooks/query/query-keys";
import { openHands } from "#/api/open-hands-axios";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
import { useEmailVerification } from "#/hooks/use-email-verification";
@@ -148,7 +147,7 @@ function UserSettingsScreen() {
displaySuccessToast(t("SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY"));
setTimeout(() => {
queryClient.invalidateQueries({
queryKey: SETTINGS_QUERY_KEYS.personal(organizationId),
queryKey: ["settings", organizationId],
});
}, 2000);
}
@@ -184,7 +183,7 @@ function UserSettingsScreen() {
// Display toast notification instead of setting state
displaySuccessToast(t("SETTINGS$EMAIL_SAVED_SUCCESSFULLY"));
queryClient.invalidateQueries({
queryKey: SETTINGS_QUERY_KEYS.personal(organizationId),
queryKey: ["settings", organizationId],
});
} catch (error) {
// eslint-disable-next-line no-console

View File

@@ -1,15 +1,108 @@
import React from "react";
import { SdkSectionPage } from "#/components/features/settings/sdk-settings/sdk-section-page";
import { useTranslation } from "react-i18next";
import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input";
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
import {
SdkSectionHeaderProps,
SdkSectionPage,
} from "#/components/features/settings/sdk-settings/sdk-section-page";
import { useSettings } from "#/hooks/query/use-settings";
import { I18nKey } from "#/i18n/declaration";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { SettingsScope } from "#/types/settings";
import { createPermissionGuard } from "#/utils/org/permission-guard";
import { requireOrgDefaultsRedirect } from "#/utils/org/saas-redirect-to-org-defaults-guard";
// These fields are rendered manually in the header, so exclude them from
// schema-driven rendering. Agent-settings keys are dot-prefixed with the
// section name (e.g. "verification.confirmation_mode").
const VERIFICATION_SCHEMA_EXCLUDE_KEYS = new Set([
"verification.confirmation_mode",
"verification.security_analyzer",
]);
// Promote iterative refinement to Basic view so it's discoverable alongside "Enable Critic"
const VERIFICATION_PROMINENCE_OVERRIDES: Record<
string,
"critical" | "major" | "minor"
> = {
"verification.enable_iterative_refinement": "critical",
};
function VerificationSettingsHeader({
confirmationMode,
securityAnalyzer,
isConversationSettingsDisabled,
onConfirmationModeChange,
onSecurityAnalyzerChange,
renderTopContent,
}: {
confirmationMode: boolean;
securityAnalyzer: string | null;
isConversationSettingsDisabled: boolean;
onConfirmationModeChange: (value: boolean) => void;
onSecurityAnalyzerChange: (value: string | null) => void;
renderTopContent?: () => React.ReactNode;
}) {
return <div className="flex flex-col gap-6">{renderTopContent?.()}</div>;
const { t } = useTranslation();
const securityAnalyzerItems = React.useMemo(
() => [
{
key: "llm",
label: t(I18nKey.SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT),
},
{
key: "none",
label: t(I18nKey.SETTINGS$SECURITY_ANALYZER_NONE),
},
],
[t],
);
const showSecurityAnalyzer = confirmationMode;
return (
<div className="flex flex-col gap-6">
{renderTopContent?.()}
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-1.5">
<SettingsSwitch
testId="confirmation-mode-toggle"
isToggled={confirmationMode}
onToggle={onConfirmationModeChange}
isDisabled={isConversationSettingsDisabled}
>
{t(I18nKey.SETTINGS_FORM$ENABLE_CONFIRMATION_MODE_LABEL)}
</SettingsSwitch>
<p className="text-tertiary-alt text-xs leading-5">
{t(I18nKey.SETTINGS$CONFIRMATION_MODE_TOOLTIP)}
</p>
</div>
{showSecurityAnalyzer ? (
<div className="flex flex-col gap-1.5">
<SettingsDropdownInput
testId="security-analyzer-input"
name="security_analyzer"
label={t(I18nKey.SETTINGS_FORM$SECURITY_ANALYZER_LABEL)}
items={securityAnalyzerItems}
selectedKey={securityAnalyzer ?? undefined}
placeholder={t(I18nKey.SETTINGS$SECURITY_ANALYZER_PLACEHOLDER)}
isDisabled={isConversationSettingsDisabled}
onSelectionChange={(key) =>
onSecurityAnalyzerChange(key ? String(key) : null)
}
/>
<p className="text-tertiary-alt text-xs leading-5 max-w-[680px] ">
{t(I18nKey.SETTINGS$SECURITY_ANALYZER_DESCRIPTION)}
</p>
</div>
) : null}
</div>
</div>
);
}
export function VerificationSettingsScreen({
@@ -21,17 +114,97 @@ export function VerificationSettingsScreen({
renderTopContent?: () => React.ReactNode;
testId?: string;
}) {
const { data: settings } = useSettings(scope);
const [confirmationMode, setConfirmationMode] = React.useState(
DEFAULT_SETTINGS.confirmation_mode,
);
const [securityAnalyzer, setSecurityAnalyzer] = React.useState<string | null>(
DEFAULT_SETTINGS.security_analyzer,
);
const [confirmationModeDirty, setConfirmationModeDirty] =
React.useState(false);
const [securityAnalyzerDirty, setSecurityAnalyzerDirty] =
React.useState(false);
React.useEffect(() => {
setConfirmationMode(
settings?.confirmation_mode ?? DEFAULT_SETTINGS.confirmation_mode,
);
setSecurityAnalyzer(
settings?.security_analyzer ?? DEFAULT_SETTINGS.security_analyzer,
);
setConfirmationModeDirty(false);
setSecurityAnalyzerDirty(false);
}, [settings?.confirmation_mode, settings?.security_analyzer]);
const buildHeader = React.useCallback(
() => <VerificationSettingsHeader renderTopContent={renderTopContent} />,
[renderTopContent],
({ isDisabled }: SdkSectionHeaderProps) => (
<VerificationSettingsHeader
confirmationMode={confirmationMode}
securityAnalyzer={securityAnalyzer}
isConversationSettingsDisabled={isDisabled}
onConfirmationModeChange={(value) => {
setConfirmationMode(value);
setConfirmationModeDirty(true);
}}
onSecurityAnalyzerChange={(value) => {
setSecurityAnalyzer(value);
setSecurityAnalyzerDirty(true);
}}
renderTopContent={renderTopContent}
/>
),
[confirmationMode, renderTopContent, securityAnalyzer],
);
const buildPayload = React.useCallback(
(basePayload: Record<string, unknown>) => {
// Critic fields go into agent_settings_diff
const result: Record<string, unknown> = {};
if (Object.keys(basePayload).length > 0) {
result.agent_settings_diff = basePayload;
}
// Confirmation mode and security analyzer go into conversation_settings_diff
const conversationDiff: Record<string, unknown> = {};
if (confirmationModeDirty) {
conversationDiff.confirmation_mode = confirmationMode;
}
if (
securityAnalyzerDirty ||
(confirmationMode && settings?.security_analyzer !== securityAnalyzer)
) {
conversationDiff.security_analyzer = securityAnalyzer;
}
if (Object.keys(conversationDiff).length > 0) {
result.conversation_settings_diff = conversationDiff;
}
return result;
},
[
confirmationMode,
confirmationModeDirty,
securityAnalyzer,
securityAnalyzerDirty,
settings?.security_analyzer,
],
);
return (
<SdkSectionPage
scope={scope}
settingsSource="conversation_settings"
settingsSource="agent_settings"
sectionKeys={["verification"]}
excludeKeys={VERIFICATION_SCHEMA_EXCLUDE_KEYS}
prominenceOverrides={VERIFICATION_PROMINENCE_OVERRIDES}
header={buildHeader}
extraDirty={confirmationModeDirty || securityAnalyzerDirty}
buildPayload={buildPayload}
onSaveSuccess={() => {
setConfirmationModeDirty(false);
setSecurityAnalyzerDirty(false);
}}
testId={testId}
/>
);

View File

@@ -0,0 +1,50 @@
/**
* A single feature detected by the critic (e.g., "Insufficient Testing").
*/
export interface CriticFeature {
/** Internal feature name (e.g., "insufficient_testing") */
name: string;
/** Human-readable display name (e.g., "Insufficient Testing") */
display_name: string;
/** Probability of this feature being present (0-1) */
probability: number;
}
/**
* Categorized features from the critic evaluation.
*/
export interface CriticCategorizedFeatures {
/** Agent behavioral issues (e.g., insufficient testing, loop behavior) */
agent_behavioral_issues?: CriticFeature[];
/** Likely user follow-up patterns */
user_followup_patterns?: CriticFeature[];
/** Infrastructure-related issues */
infrastructure_issues?: CriticFeature[];
/** Other uncategorized metrics */
other?: CriticFeature[];
}
/**
* Metadata from a critic evaluation, including categorized features
* and event IDs for reproducibility.
*/
export interface CriticMetadata {
categorized_features?: CriticCategorizedFeatures;
event_ids?: string[];
[key: string]: unknown;
}
/**
* Result of a critic evaluation on an agent's actions.
*
* The critic predicts the probability that the agent has successfully
* completed the task.
*/
export interface CriticResult {
/** Predicted probability of success (0-1) */
score: number;
/** Optional message explaining the score */
message: string | null;
/** Optional metadata with categorized features and event IDs */
metadata: CriticMetadata | null;
}

View File

@@ -2,5 +2,6 @@
export * from "./action";
export * from "./base";
export * from "./common";
export * from "./critic";
export * from "./event";
export * from "./observation";

View File

@@ -1,5 +1,6 @@
import { Action } from "../base/action";
import { EventID, ToolCallID, SecurityRisk, TextContent } from "../base/common";
import { CriticResult } from "../base/critic";
import {
BaseEvent,
ChatCompletionMessageToolCall,
@@ -59,6 +60,11 @@ export interface ActionEvent<T extends Action = Action> extends BaseEvent {
*/
security_risk: SecurityRisk;
/**
* Optional critic evaluation of this action and preceding history.
*/
critic_result?: CriticResult | null;
/**
* Optional LLM-generated summary used to label the tool call in the UI.
*/

View File

@@ -1,4 +1,5 @@
import { TextContent } from "../base/common";
import { CriticResult } from "../base/critic";
import { BaseEvent, Message } from "../base/event";
export interface MessageEvent extends BaseEvent {
@@ -16,4 +17,9 @@ export interface MessageEvent extends BaseEvent {
* List of content added by agent context
*/
extended_content: TextContent[];
/**
* Optional critic evaluation of the agent's work at this point.
*/
critic_result?: CriticResult | null;
}

View File

@@ -181,6 +181,9 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
access_token_hard_timeout: timedelta | None
app_mode: str | None = None
tavily_api_key: str | None = None
critic_server_url: str | None = None
critic_model_name: str | None = None
critic_api_key: str | None = None
async def _get_sandbox_grouping_strategy(self) -> SandboxGroupingStrategy:
"""Get the sandbox grouping strategy from user settings."""
@@ -1407,6 +1410,18 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
# --- build AgentSettings and create agent ---------------------------
from fastmcp.mcp_config import MCPConfig
# Apply deployment-level critic defaults when the user hasn't
# explicitly configured them. This allows SaaS deployments to
# route critic requests through the LiteLLM proxy.
verification = user.agent_settings.verification
critic_updates: dict[str, object] = {}
if verification.critic_server_url is None and self.critic_server_url:
critic_updates['critic_server_url'] = self.critic_server_url
if verification.critic_model_name is None and self.critic_model_name:
critic_updates['critic_model_name'] = self.critic_model_name
if critic_updates:
verification = verification.model_copy(update=critic_updates)
configured_agent_settings = user.agent_settings.model_copy(
update={
'llm': llm,
@@ -1416,9 +1431,16 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
system_message_suffix=effective_suffix,
secrets=secrets,
),
'verification': verification,
}
)
agent = configured_agent_settings.create_agent()
# Override the critic API key with a deployment-level service key
# so that critic requests are not subject to per-user budget limits.
if agent.critic is not None and self.critic_api_key:
agent.critic.api_key = SecretStr(self.critic_api_key)
agent = self._apply_server_agent_overrides(
agent, agent_type, mcp_config, conversation_id, user.id
)
@@ -2177,4 +2199,7 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
access_token_hard_timeout=access_token_hard_timeout,
app_mode=app_mode,
tavily_api_key=tavily_api_key,
critic_server_url=config.critic_server_url,
critic_model_name=config.critic_model_name,
critic_api_key=config.critic_api_key,
)

View File

@@ -112,6 +112,39 @@ def get_openhands_provider_base_url() -> str | None:
return os.getenv('OPENHANDS_PROVIDER_BASE_URL') or os.getenv('LLM_BASE_URL') or None
def get_critic_server_url() -> str | None:
"""Return the deployment-level critic server URL, if configured.
Priority:
1. Explicit ``CRITIC_SERVER_URL`` env var.
2. Derived from ``OPENHANDS_PROVIDER_BASE_URL`` (append ``/vllm``
for the LiteLLM pass-through path).
"""
explicit = os.getenv('CRITIC_SERVER_URL')
if explicit:
return explicit
provider_base = get_openhands_provider_base_url()
if provider_base:
return provider_base.rstrip('/') + '/vllm'
return None
def get_critic_model_name() -> str | None:
"""Return the deployment-level critic model name, if configured."""
return os.getenv('CRITIC_MODEL_NAME') or None
def get_critic_api_key() -> str | None:
"""Return the deployment-level critic API key, if configured.
When set, this key is injected into the critic client *after* agent
creation, overriding the default behaviour of inheriting the user's
LLM proxy key. This allows deployments to provide a service-level
key that is not subject to per-user budget limits.
"""
return os.getenv('CRITIC_API_KEY') or None
# The SDK auto-fills this URL as the default for openhands/ and litellm_proxy/
# models. Deployments (e.g. staging) may use a different LLM proxy, configured
# via OPENHANDS_PROVIDER_BASE_URL.
@@ -182,6 +215,18 @@ class AppServerConfig(OpenHandsModel):
default_factory=get_openhands_provider_base_url,
description='Base URL for the OpenHands provider',
)
critic_server_url: str | None = Field(
default_factory=get_critic_server_url,
description='Deployment-level critic server URL.',
)
critic_model_name: str | None = Field(
default_factory=get_critic_model_name,
description='Deployment-level critic model name.',
)
critic_api_key: str | None = Field(
default_factory=get_critic_api_key,
description='Deployment-level critic API key (bypasses per-user budget).',
)
# Dependency Injection Injectors
event: EventServiceInjector | None = None
event_callback: EventCallbackServiceInjector | None = None

View File

@@ -44,13 +44,13 @@ from openhands.app_server.user.specifiy_user_context import (
USER_CONTEXT_ATTR,
SpecifyUserContext,
)
from openhands.app_server.user_auth.default_user_auth import DefaultUserAuth
from openhands.app_server.user_auth.user_auth import (
get_for_user as get_user_auth_for_user,
)
from openhands.sdk import ConversationExecutionStatus, Event
from openhands.sdk.event import ConversationStateUpdateEvent
from openhands.server.types import AppMode
from openhands.server.user_auth.default_user_auth import DefaultUserAuth
from openhands.server.user_auth.user_auth import (
get_for_user as get_user_auth_for_user,
)
router = APIRouter(prefix='/webhooks', tags=['Webhooks'])
event_service_dependency = depends_event_service()

View File

@@ -30,13 +30,13 @@ from openhands.app_server.user.specifiy_user_context import (
USER_CONTEXT_ATTR,
SpecifyUserContext,
)
from openhands.app_server.user_auth import (
from openhands.core.logger import openhands_logger as logger
from openhands.server.types import AppMode
from openhands.server.user_auth import (
get_access_token,
get_provider_tokens,
get_user_id,
)
from openhands.core.logger import openhands_logger as logger
from openhands.server.types import AppMode
mcp_server = FastMCP('mcp', mask_error_details=True)

View File

@@ -19,10 +19,10 @@ from openhands.app_server.sandbox.sandbox_service import (
)
from openhands.app_server.sandbox.session_auth import validate_session_key
from openhands.app_server.user.auth_user_context import AuthUserContext
from openhands.app_server.user_auth.user_auth import (
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.server.user_auth.user_auth import (
get_for_user as get_user_auth_for_user,
)
from openhands.app_server.utils.dependencies import get_dependencies
_logger = logging.getLogger(__name__)

View File

@@ -22,13 +22,13 @@ from openhands.app_server.secrets.secrets_models import (
)
from openhands.app_server.secrets.secrets_store import SecretsStore
from openhands.app_server.settings.settings_models import POSTProviderModel
from openhands.app_server.user_auth import (
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.app_server.utils.models import EditResponse
from openhands.server.user_auth import (
get_provider_tokens,
get_secrets,
get_secrets_store,
)
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.app_server.utils.models import EditResponse
# Create router with /api/v1/secrets prefix
router = APIRouter(

View File

@@ -12,6 +12,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException, Path, status
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from openhands.app_server.config import get_critic_model_name, get_critic_server_url
from openhands.app_server.integrations.provider import (
PROVIDER_TOKEN_TYPE,
ProviderType,
@@ -29,18 +30,18 @@ from openhands.app_server.settings.settings_models import (
Settings,
)
from openhands.app_server.settings.settings_store import SettingsStore
from openhands.app_server.user_auth import (
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.core.logger import openhands_logger as logger
from openhands.sdk.llm import LLM
from openhands.sdk.settings import ConversationSettings
from openhands.server.shared import config
from openhands.server.user_auth import (
get_provider_tokens,
get_secrets_store,
get_user_id,
get_user_settings,
get_user_settings_store,
)
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.core.logger import openhands_logger as logger
from openhands.sdk.llm import LLM
from openhands.sdk.settings import ConversationSettings
from openhands.server.shared import config
from openhands.utils.llm import (
get_provider_api_base,
is_openhands_model,
@@ -80,10 +81,43 @@ def _post_merge_llm_fixups(settings: Settings) -> None:
)
# NOTE: We use response_model=None for endpoints that return JSONResponse directly.
# This is because FastAPI's response_model expects a Pydantic model, but we're returning
# a response object directly. We document the possible responses using the 'responses'
# parameter and maintain proper type annotations for mypy.
def _get_critic_sdk_defaults() -> tuple[str, str]:
"""Return (server_url, model_name) SDK built-in defaults."""
from openhands.sdk.critic.impl.api.client import CriticClient
return (
CriticClient.model_fields['server_url'].default,
CriticClient.model_fields['model_name'].default,
)
def _fill_critic_defaults(verification: Any) -> Any:
"""Return a copy of *verification* with effective critic defaults filled.
When the user hasn't overridden ``critic_server_url`` or
``critic_model_name``, we fill them with:
1. Deployment env var (``CRITIC_SERVER_URL`` / ``CRITIC_MODEL_NAME``),
2. SDK built-in default from :class:`CriticClient`.
Returns a new object — does not mutate the original.
"""
if verification is None:
return verification
sdk_url, sdk_model = _get_critic_sdk_defaults()
updates: dict[str, object] = {}
if verification.critic_server_url is None:
updates['critic_server_url'] = get_critic_server_url() or sdk_url
if verification.critic_model_name is None:
updates['critic_model_name'] = get_critic_model_name() or sdk_model
if not updates:
return verification
return verification.model_copy(update=updates)
@router.get(
'',
response_model=GETSettingsModel,
@@ -167,6 +201,13 @@ async def load_settings(
resp_llm.api_key = None
settings_with_token_data.search_api_key = None
settings_with_token_data.sandbox_api_key = None
# Fill effective critic defaults so the frontend shows what the
# agent will actually use rather than empty fields.
settings_with_token_data.agent_settings.verification = _fill_critic_defaults(
settings_with_token_data.agent_settings.verification
)
return settings_with_token_data
except Exception as e:
logger.warning(f'Invalid token: {e}')

View File

@@ -15,8 +15,8 @@ from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
from openhands.app_server.user.user_context import UserContext, UserContextInjector
from openhands.app_server.user.user_models import UserInfo
from openhands.app_server.user_auth.user_auth import UserAuth, get_user_auth
from openhands.sdk.secret import SecretSource, StaticSecret
from openhands.server.user_auth.user_auth import UserAuth, get_user_auth
USER_AUTH_ATTR = 'user_auth'

View File

@@ -10,18 +10,18 @@ def import_from(qual_name: str):
This function is a utility to dynamically import any Python value (class,
function, variable) from its fully qualified name. For example,
'openhands.app_server.user_auth.UserAuth' would import the UserAuth class from the
openhands.app_server.user_auth module.
'openhands.server.user_auth.UserAuth' would import the UserAuth class from the
openhands.server.user_auth module.
Args:
qual_name: A fully qualified name in the format 'module.submodule.name'
e.g. 'openhands.app_server.user_auth.UserAuth'
e.g. 'openhands.server.user_auth.UserAuth'
Returns:
The imported value (class, function, or variable)
Example:
>>> UserAuth = import_from('openhands.app_server.user_auth.UserAuth')
>>> UserAuth = import_from('openhands.server.user_auth.UserAuth')
>>> auth = UserAuth()
"""
parts = qual_name.split('.')

View File

@@ -6,6 +6,7 @@ from openhands.core.config.arg_utils import (
from openhands.core.config.config_utils import (
OH_DEFAULT_AGENT,
OH_MAX_ITERATIONS,
get_field_info,
)
from openhands.core.config.extended_config import ExtendedConfig
from openhands.core.config.llm_config import LLMConfig
@@ -44,6 +45,7 @@ __all__ = [
'finalize_config',
'get_agent_config_arg',
'get_llm_config_arg',
'get_field_info',
'get_headless_parser',
'get_evaluation_parser',
'parse_arguments',

View File

@@ -0,0 +1,8 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
TROUBLESHOOTING_URL = 'https://docs.all-hands.dev/usage/troubleshooting'

View File

@@ -0,0 +1,218 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
# ============================================
# Agent Exceptions
# ============================================
class AgentError(Exception):
"""Base class for all agent exceptions."""
pass
class AgentAlreadyRegisteredError(AgentError):
def __init__(self, name: str | None = None) -> None:
if name is not None:
message = f"Agent class already registered under '{name}'"
else:
message = 'Agent class already registered'
super().__init__(message)
class AgentNotRegisteredError(AgentError):
def __init__(self, name: str | None = None) -> None:
if name is not None:
message = f"No agent class registered under '{name}'"
else:
message = 'No agent class registered'
super().__init__(message)
class AgentStuckInLoopError(AgentError):
def __init__(self, message: str = 'Agent got stuck in a loop') -> None:
super().__init__(message)
# ============================================
# LLM Exceptions
# ============================================
# This exception gets sent back to the LLM
# It might be malformed JSON
class LLMMalformedActionError(Exception):
def __init__(self, message: str = 'Malformed response') -> None:
self.message = message
super().__init__(message)
def __str__(self) -> str:
return self.message
# This exception gets sent back to the LLM
# For some reason, the agent did not return an action
class LLMNoActionError(Exception):
def __init__(self, message: str = 'Agent must return an action') -> None:
super().__init__(message)
# This exception gets sent back to the LLM
# The LLM output did not include an action, or the action was not the expected type
class LLMResponseError(Exception):
def __init__(
self, message: str = 'Failed to retrieve action from LLM response'
) -> None:
super().__init__(message)
# This exception should be retried
# Typically, after retry with a non-zero temperature, the LLM will return a response
class LLMNoResponseError(Exception):
def __init__(
self,
message: str = 'LLM did not return a response. This is only seen in Gemini models so far.',
) -> None:
super().__init__(message)
class UserCancelledError(Exception):
def __init__(self, message: str = 'User cancelled the request') -> None:
super().__init__(message)
class OperationCancelled(Exception):
"""Exception raised when an operation is cancelled (e.g. by a keyboard interrupt)."""
def __init__(self, message: str = 'Operation was cancelled') -> None:
super().__init__(message)
class LLMContextWindowExceedError(RuntimeError):
def __init__(
self,
message: str = 'Conversation history longer than LLM context window limit. Consider turning on enable_history_truncation config to avoid this error',
) -> None:
super().__init__(message)
# ============================================
# LLM function calling Exceptions
# ============================================
class FunctionCallConversionError(Exception):
"""Exception raised when FunctionCallingConverter failed to convert a non-function call message to a function call message.
This typically happens when there's a malformed message (e.g., missing <function=...> tags). But not due to LLM output.
"""
def __init__(self, message: str) -> None:
super().__init__(message)
class FunctionCallValidationError(Exception):
"""Exception raised when FunctionCallingConverter failed to validate a function call message.
This typically happens when the LLM outputs unrecognized function call / parameter names / values.
"""
def __init__(self, message: str) -> None:
super().__init__(message)
class FunctionCallNotExistsError(Exception):
"""Exception raised when an LLM call a tool that is not registered."""
def __init__(self, message: str) -> None:
super().__init__(message)
# ============================================
# Agent Runtime Exceptions
# ============================================
class AgentRuntimeError(Exception):
"""Base class for all agent runtime exceptions."""
pass
class AgentRuntimeBuildError(AgentRuntimeError):
"""Exception raised when an agent runtime build operation fails."""
pass
class AgentRuntimeTimeoutError(AgentRuntimeError):
"""Exception raised when an agent runtime operation times out."""
pass
class AgentRuntimeUnavailableError(AgentRuntimeError):
"""Exception raised when an agent runtime is unavailable."""
pass
class AgentRuntimeNotReadyError(AgentRuntimeUnavailableError):
"""Exception raised when an agent runtime is not ready."""
pass
class AgentRuntimeDisconnectedError(AgentRuntimeUnavailableError):
"""Exception raised when an agent runtime is disconnected."""
pass
class AgentRuntimeNotFoundError(AgentRuntimeUnavailableError):
"""Exception raised when an agent runtime is not found."""
pass
# ============================================
# Browser Exceptions
# ============================================
class BrowserInitException(Exception):
def __init__(
self, message: str = 'Failed to initialize browser environment'
) -> None:
super().__init__(message)
class BrowserUnavailableException(Exception):
def __init__(
self,
message: str = 'Browser environment is not available, please check if has been initialized',
) -> None:
super().__init__(message)
# ============================================
# Microagent Exceptions
# ============================================
class MicroagentError(Exception):
"""Base exception for all microagent errors."""
pass
class MicroagentValidationError(MicroagentError):
"""Raised when there's a validation error in microagent metadata."""
def __init__(self, message: str = 'Microagent validation failed') -> None:
super().__init__(message)

165
openhands/core/message.py Normal file
View File

@@ -0,0 +1,165 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
from enum import Enum
from typing import Any, Literal
from litellm import ChatCompletionMessageToolCall
from pydantic import BaseModel, Field, model_serializer
class ContentType(Enum):
TEXT = 'text'
IMAGE_URL = 'image_url'
class Content(BaseModel):
type: str
cache_prompt: bool = False
@model_serializer(mode='plain')
def serialize_model(
self,
) -> dict[str, str | dict[str, str]] | list[dict[str, str | dict[str, str]]]:
raise NotImplementedError('Subclasses should implement this method.')
class TextContent(Content):
type: str = ContentType.TEXT.value
text: str
@model_serializer(mode='plain')
def serialize_model(self) -> dict[str, str | dict[str, str]]:
data: dict[str, str | dict[str, str]] = {
'type': self.type,
'text': self.text,
}
if self.cache_prompt:
data['cache_control'] = {'type': 'ephemeral'}
return data
class ImageContent(Content):
type: str = ContentType.IMAGE_URL.value
image_urls: list[str]
@model_serializer(mode='plain')
def serialize_model(self) -> list[dict[str, str | dict[str, str]]]:
images: list[dict[str, str | dict[str, str]]] = []
for url in self.image_urls:
images.append({'type': self.type, 'image_url': {'url': url}})
if self.cache_prompt and images:
images[-1]['cache_control'] = {'type': 'ephemeral'}
return images
class Message(BaseModel):
# NOTE: this is not the same as EventSource
# These are the roles in the LLM's APIs
role: Literal['user', 'system', 'assistant', 'tool']
content: list[TextContent | ImageContent] = Field(default_factory=list)
cache_enabled: bool = False
vision_enabled: bool = False
# function calling
function_calling_enabled: bool = False
# - tool calls (from LLM)
tool_calls: list[ChatCompletionMessageToolCall] | None = None
# - tool execution result (to LLM)
tool_call_id: str | None = None
name: str | None = None # name of the tool
# force string serializer
force_string_serializer: bool = False
@property
def contains_image(self) -> bool:
return any(isinstance(content, ImageContent) for content in self.content)
@model_serializer(mode='plain')
def serialize_model(self) -> dict[str, Any]:
# We need two kinds of serializations:
# - into a single string: for providers that don't support list of content items (e.g. no vision, no tool calls)
# - into a list of content items: the new APIs of providers with vision/prompt caching/tool calls
# NOTE: remove this when litellm or providers support the new API
if not self.force_string_serializer and (
self.cache_enabled or self.vision_enabled or self.function_calling_enabled
):
return self._list_serializer()
# some providers, like HF and Groq/llama, don't support a list here, but a single string
return self._string_serializer()
def _string_serializer(self) -> dict[str, Any]:
# convert content to a single string
content = '\n'.join(
item.text for item in self.content if isinstance(item, TextContent)
)
message_dict: dict[str, Any] = {'content': content, 'role': self.role}
# add tool call keys if we have a tool call or response
return self._add_tool_call_keys(message_dict)
def _list_serializer(self) -> dict[str, Any]:
content: list[dict[str, Any]] = []
role_tool_with_prompt_caching = False
for item in self.content:
d = item.model_dump()
# We have to remove cache_prompt for tool content and move it up to the message level
# See discussion here for details: https://github.com/BerriAI/litellm/issues/6422#issuecomment-2438765472
if self.role == 'tool' and item.cache_prompt:
role_tool_with_prompt_caching = True
if isinstance(item, TextContent):
d.pop('cache_control', None)
elif isinstance(item, ImageContent):
# ImageContent.model_dump() always returns a list
# We know d is a list of dicts for ImageContent
if hasattr(d, '__iter__'):
for d_item in d:
if hasattr(d_item, 'pop'):
d_item.pop('cache_control', None)
if isinstance(item, TextContent):
content.append(d)
elif isinstance(item, ImageContent) and self.vision_enabled:
# ImageContent.model_dump() always returns a list
# We know d is a list for ImageContent
content.extend([d] if isinstance(d, dict) else d)
message_dict: dict[str, Any] = {'content': content, 'role': self.role}
if role_tool_with_prompt_caching:
message_dict['cache_control'] = {'type': 'ephemeral'}
# add tool call keys if we have a tool call or response
return self._add_tool_call_keys(message_dict)
def _add_tool_call_keys(self, message_dict: dict[str, Any]) -> dict[str, Any]:
"""Add tool call keys if we have a tool call or response.
NOTE: this is necessary for both native and non-native tool calling
"""
# an assistant message calling a tool
if self.tool_calls is not None:
message_dict['tool_calls'] = [
{
'id': tool_call.id,
'type': 'function',
'function': {
'name': tool_call.function.name,
'arguments': tool_call.function.arguments,
},
}
for tool_call in self.tool_calls
]
# an observation message with tool response
if self.tool_call_id is not None:
assert self.name is not None, (
'name is required when tool_call_id is not None'
)
message_dict['tool_call_id'] = self.tool_call_id
message_dict['name'] = self.name
return message_dict

View File

@@ -0,0 +1,96 @@
# OpenHands Message Format and litellm Integration
## Overview
OpenHands uses its own `Message` class (`openhands/core/message.py`) which provides rich content support while maintaining compatibility with litellm's message handling system.
## Class Structure
Our `Message` class (`openhands/core/message.py`):
```python
class Message(BaseModel):
role: Literal['user', 'system', 'assistant', 'tool']
content: list[TextContent | ImageContent] = Field(default_factory=list)
cache_enabled: bool = False
vision_enabled: bool = False
condensable: bool = True
function_calling_enabled: bool = False
tool_calls: list[ChatCompletionMessageToolCall] | None = None
tool_call_id: str | None = None
name: str | None = None
event_id: int = -1
```
litellm's `Message` class (`litellm/types/utils.py`):
```python
class Message(OpenAIObject):
content: str | None
role: Literal["assistant", "user", "system", "tool", "function"]
tool_calls: List[ChatCompletionMessageToolCall] | None
function_call: FunctionCall | None
audio: ChatCompletionAudioResponse | None = None
```
## How It Works
1. **Message Creation**: Our `Message` class is a Pydantic model that supports rich content (text and images) through its `content` field.
2. **Serialization**: The class uses Pydantic's `@model_serializer` to convert messages into dictionaries that litellm can understand. We have two serialization methods:
```python
def _string_serializer(self) -> dict:
# convert content to a single string
content = '\n'.join(item.text for item in self.content if isinstance(item, TextContent))
message_dict: dict = {'content': content, 'role': self.role}
return self._add_tool_call_keys(message_dict)
def _list_serializer(self) -> dict:
content: list[dict] = []
for item in self.content:
d = item.model_dump()
if isinstance(item, TextContent):
content.append(d)
elif isinstance(item, ImageContent) and self.vision_enabled:
content.extend(d)
return {'content': content, 'role': self.role}
```
The appropriate serializer is chosen based on the message's capabilities:
```python
@model_serializer
def serialize_model(self) -> dict:
if self.cache_enabled or self.vision_enabled or self.function_calling_enabled:
return self._list_serializer()
return self._string_serializer()
```
3. **Tool Call Handling**: Tool calls require special attention in serialization because:
- They need to work with litellm's API calls (which accept both dicts and objects)
- They need to be properly serialized for token counting
- They need to maintain compatibility with different LLM providers' formats
4. **litellm Integration**: When we pass our messages to `litellm.completion()`, litellm doesn't care about the message class type - it works with the dictionary representation. This works because:
- litellm's transformation code (e.g., `litellm/llms/anthropic/chat/transformation.py`) processes messages based on their structure, not their type
- our serialization produces dictionaries that match litellm's expected format
- litellm handles rich content by looking at the message structure, supporting both simple string content and lists of content items
5. **Provider-Specific Handling**: litellm then transforms these messages into provider-specific formats (e.g., Anthropic, OpenAI) through its transformation layers, which know how to handle both simple and rich content structures.
### Token Counting
To use litellm's token counter, we need to make sure that all message components (including tool calls) are properly serialized to dictionaries. This is because:
- litellm's token counter expects dictionary structures
- Tool calls need to be included in the token count
- Different providers may count tokens differently for structured content
## Note
- We don't need to inherit from litellm's `Message` class because litellm works with dictionary representations, not class types
- Our rich content model is more sophisticated than litellm's basic string content, but litellm handles it correctly through its transformation layers
- The compatibility is maintained through proper serialization rather than inheritance

View File

@@ -1,5 +1,9 @@
from openhands.core.schema.action import ActionType
from openhands.core.schema.agent import AgentState
from openhands.core.schema.observation import ObservationType
__all__ = [
'ActionType',
'ObservationType',
'AgentState',
]

View File

@@ -0,0 +1,109 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
from enum import Enum
class ActionType(str, Enum):
MESSAGE = 'message'
"""Represents a message.
"""
SYSTEM = 'system'
"""Represents a system message.
"""
START = 'start'
"""Starts a new development task OR send chat from the user. Only sent by the client.
"""
READ = 'read'
"""Reads the content of a file.
"""
WRITE = 'write'
"""Writes the content to a file.
"""
EDIT = 'edit'
"""Edits a file by providing a draft.
"""
RUN = 'run'
"""Runs a command.
"""
RUN_IPYTHON = 'run_ipython'
"""Runs a IPython cell.
"""
BROWSE = 'browse'
"""Opens a web page.
"""
BROWSE_INTERACTIVE = 'browse_interactive'
"""Interact with the browser instance.
"""
MCP = 'call_tool_mcp'
"""Interact with the MCP server.
"""
DELEGATE = 'delegate'
"""Delegates a task to another agent.
"""
THINK = 'think'
"""Logs a thought.
"""
FINISH = 'finish'
"""If you're absolutely certain that you've completed your task and have tested your work,
use the finish action to stop working.
"""
REJECT = 'reject'
"""If you're absolutely certain that you cannot complete the task with given requirements,
use the reject action to stop working.
"""
NULL = 'null'
PAUSE = 'pause'
"""Pauses the task.
"""
RESUME = 'resume'
"""Resumes the task.
"""
STOP = 'stop'
"""Stops the task. Must send a start action to restart a new task.
"""
CHANGE_AGENT_STATE = 'change_agent_state'
PUSH = 'push'
"""Push a branch to github."""
SEND_PR = 'send_pr'
"""Send a PR to github."""
RECALL = 'recall'
"""Retrieves content from a user workspace, microagent, or other source."""
CONDENSATION = 'condensation'
"""Condenses a list of events into a summary."""
CONDENSATION_REQUEST = 'condensation_request'
"""Request for condensation of a list of events."""
TASK_TRACKING = 'task_tracking'
"""Views or updates the task list for task management."""
LOOP_RECOVERY = 'loop_recovery'
"""Recover dead loop."""

View File

@@ -0,0 +1,14 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
from enum import Enum
class ExitReason(Enum):
INTENTIONAL = 'intentional'
INTERRUPTED = 'interrupted'
ERROR = 'error'

View File

@@ -0,0 +1,70 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
from enum import Enum
class ObservationType(str, Enum):
READ = 'read'
"""The content of a file
"""
WRITE = 'write'
EDIT = 'edit'
BROWSE = 'browse'
"""The HTML content of a URL
"""
RUN = 'run'
"""The output of a command
"""
RUN_IPYTHON = 'run_ipython'
"""Runs a IPython cell.
"""
CHAT = 'chat'
"""A message from the user
"""
DELEGATE = 'delegate'
"""The result of a task delegated to another agent
"""
MESSAGE = 'message'
ERROR = 'error'
SUCCESS = 'success'
NULL = 'null'
THINK = 'think'
AGENT_STATE_CHANGED = 'agent_state_changed'
USER_REJECTED = 'user_rejected'
CONDENSE = 'condense'
"""Result of a condensation operation."""
RECALL = 'recall'
"""Result of a recall operation. This can be the workspace context, a microagent, or other types of information."""
MCP = 'mcp'
"""Result of a MCP Server operation"""
DOWNLOAD = 'download'
"""Result of downloading/opening a file via the browser"""
TASK_TRACKING = 'task_tracking'
"""Result of a task tracking operation"""
LOOP_DETECTION = 'loop_detection'
"""Results of a dead-loop detection"""

99
openhands/core/setup.py Normal file
View File

@@ -0,0 +1,99 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import hashlib
import os
import uuid
from pydantic import SecretStr
from openhands.app_server.integrations.provider import (
ProviderToken,
ProviderType,
)
from openhands.app_server.secrets.secrets_models import Secrets
from openhands.core.config import (
OpenHandsConfig,
)
def get_provider_tokens():
"""Retrieve provider tokens from environment variables and return them as a dictionary.
Returns:
A dictionary mapping ProviderType to ProviderToken if tokens are found, otherwise None.
"""
# Collect provider tokens from environment variables if available
provider_tokens = {}
if 'GITHUB_TOKEN' in os.environ:
github_token = SecretStr(os.environ['GITHUB_TOKEN'])
provider_tokens[ProviderType.GITHUB] = ProviderToken(token=github_token)
if 'GITLAB_TOKEN' in os.environ:
gitlab_token = SecretStr(os.environ['GITLAB_TOKEN'])
provider_tokens[ProviderType.GITLAB] = ProviderToken(token=gitlab_token)
if 'BITBUCKET_TOKEN' in os.environ:
bitbucket_token = SecretStr(os.environ['BITBUCKET_TOKEN'])
provider_tokens[ProviderType.BITBUCKET] = ProviderToken(token=bitbucket_token)
# Forgejo support (e.g., Codeberg or self-hosted Forgejo)
if 'FORGEJO_TOKEN' in os.environ:
forgejo_token = SecretStr(os.environ['FORGEJO_TOKEN'])
# If a base URL is provided, extract the domain to use as host override
forgejo_base_url = os.environ.get('FORGEJO_BASE_URL', '').strip()
host: str | None = None
if forgejo_base_url:
# Normalize by stripping protocol and any path (e.g., /api/v1)
url = forgejo_base_url
if url.startswith(('http://', 'https://')):
try:
from urllib.parse import urlparse
parsed = urlparse(url)
host = parsed.netloc or None
except Exception:
pass
if host is None:
host = url.replace('https://', '').replace('http://', '')
host = host.split('/')[0].strip('/') if host else None
provider_tokens[ProviderType.FORGEJO] = ProviderToken(
token=forgejo_token, host=host
)
# Wrap provider tokens in Secrets if any tokens were found
secret_store = (
Secrets(provider_tokens=provider_tokens) if provider_tokens else None # type: ignore[arg-type]
)
return secret_store.provider_tokens if secret_store else None
def generate_sid(config: OpenHandsConfig, session_name: str | None = None) -> str:
"""Generate a session id based on the session name and the jwt secret.
The session ID is kept short to ensure Kubernetes resource names don't exceed
the 63-character limit when prefixed with 'openhands-runtime-' (18 chars).
Total length is limited to 32 characters to allow for suffixes like '-svc', '-pvc'.
"""
session_name = session_name or str(uuid.uuid4())
jwt_secret = config.jwt_secret
hash_str = hashlib.sha256(f'{session_name}{jwt_secret}'.encode('utf-8')).hexdigest()
# Limit total session ID length to 32 characters for Kubernetes compatibility:
# - 'openhands-runtime-' (18 chars) + session_id (32 chars) = 50 chars
# - Leaves 13 chars for suffixes like '-svc' (4), '-pvc' (4), '-ingress-code' (13)
if len(session_name) > 16:
# If session_name is too long, use first 16 chars + 15-char hash for better readability
# e.g., "vscode-extension" -> "vscode-extensio-{15-char-hash}"
session_id = f'{session_name[:16]}-{hash_str[:15]}'
else:
# If session_name is short enough, use it + remaining space for hash
remaining_chars = 32 - len(session_name) - 1 # -1 for the dash
session_id = f'{session_name}-{hash_str[:remaining_chars]}'
return session_id[:32] # Ensure we never exceed 32 characters

View File

@@ -1,28 +1,182 @@
# OpenHands Legacy (V0) Server
# OpenHands Server
> **IMPORTANT**: This is the legacy V0 web server, deprecated since version 1.0.0.
> The V1 application server lives under `openhands/app_server/`.
This is a WebSocket server that executes tasks using an agent.
This package provides the V0 ASGI entry point (`listen.py`) that is still used by
the Makefile and the container `CMD`. It assembles the FastAPI app from
`openhands.server.app`, layers on middleware, Socket.IO, and (optionally) serves
the frontend static build.
## Recommended Prerequisites
## Key modules
- [Initialize the frontend code](../../frontend/README.md)
- Install Python 3.12 (`brew install python` for those using homebrew)
- Install pipx: (`brew install pipx` followed by `pipx ensurepath`)
- Install poetry: (`pipx install poetry`)
| Module | Purpose |
|--------|---------|
| `app.py` | Creates the FastAPI application, mounts MCP and V1 routers |
| `listen.py` | Adds middleware (CORS, caching, rate-limiting), mounts SPA static files, wraps in Socket.IO ASGI |
| `listen_socket.py` | Re-exports the `sio` Socket.IO server for backward compatibility |
| `shared.py` | Module-level singletons (config, server config, Socket.IO, store implementations) |
| `middleware.py` | CORS, cache-control, and rate-limiting middleware |
| `static.py` | `SPAStaticFiles` — serves the frontend with SPA fallback |
| `types.py` | Shared types (`AppMode`, `ServerConfigInterface`, error classes) |
| `config/server_config.py` | OSS `ServerConfig` implementation and loader |
## Install
## Starting the server
First build a distribution of the frontend code (From the project root directory):
```sh
uvicorn openhands.server.listen:app --host 0.0.0.0 --port 3000
cd frontend
npm install
npm run build
cd ..
```
Next run `poetry shell` (So you don't have to repeat `poetry run`)
## Start the Server
```sh
uvicorn openhands.server.listen:app --reload --port 3000
```
## Test the Server
You can use [`websocat`](https://github.com/vi/websocat) to test the server.
```sh
websocat ws://127.0.0.1:3000/ws
{"action": "start", "args": {"task": "write a bash script that prints hello"}}
```
## Supported Environment Variables
```sh
LLM_API_KEY=sk-... # Your Anthropic API Key
LLM_MODEL=claude-3-5-sonnet-20241022 # Default model for the agent to use
SANDBOX_VOLUMES=/path/to/your/workspace:/workspace:rw # Mount paths in format host_path:container_path:mode
```
## API Schema
There are two types of messages that can be sent to, or received from, the server:
* Actions
* Observations
### Actions
An action has three parts:
* `action`: The action to be taken
* `args`: The arguments for the action
* `message`: A friendly message that can be put in the chat log
There are several kinds of actions. Their arguments are listed below.
This list may grow over time.
* `initialize` - initializes the agent. Only sent by client.
* `model` - the name of the model to use
* `directory` - the path to the workspace
* `agent_cls` - the class of the agent to use
* `start` - starts a new development task. Only sent by the client.
* `task` - the task to start
* `read` - reads the content of a file.
* `path` - the path of the file to read
* `write` - writes the content to a file.
* `path` - the path of the file to write
* `content` - the content to write to the file
* `run` - runs a command.
* `command` - the command to run
* `browse` - opens a web page.
* `url` - the URL to open
* `think` - Allows the agent to make a plan, set a goal, or record thoughts
* `thought` - the thought to record
* `finish` - agent signals that the task is completed
### Observations
An observation has four parts:
* `observation`: The observation type
* `content`: A string representing the observed data
* `extras`: additional structured data
* `message`: A friendly message that can be put in the chat log
There are several kinds of observations. Their extras are listed below.
This list may grow over time.
* `read` - the content of a file
* `path` - the path of the file read
* `browse` - the HTML content of a url
* `url` - the URL opened
* `run` - the output of a command
* `command` - the command run
* `exit_code` - the exit code of the command
* `chat` - a message from the user
## Server Components
The following section describes the server-side components of the OpenHands project.
### 1. session/session.py
The `session.py` file defines the `Session` class, which represents a WebSocket session with a client. Key features include:
- Handling WebSocket connections and disconnections
- Initializing and managing the agent session
- Dispatching events between the client and the agent
- Sending messages and errors to the client
### 2. session/agent_session.py
The `agent_session.py` file contains the `AgentSession` class, which manages the lifecycle of an agent within a session. Key features include:
- Creating and managing the runtime environment
- Initializing the agent controller
- Handling security analysis
- Managing the event stream
### 3. session/conversation_manager/conversation_manager.py
The `conversation_manager.py` file defines the `ConversationManager` class, which is responsible for managing multiple client conversations. Key features include:
- Adding and restarting conversations
- Sending messages to specific conversations
- Cleaning up inactive conversations
### 4. listen.py
The `listen.py` file is the main server file that sets up the FastAPI application and defines various API endpoints. Key features include:
- Setting up CORS middleware
- Handling WebSocket connections
- Managing file uploads
- Providing API endpoints for agent interactions, file operations, and security analysis
- Serving static files for the frontend
## Workflow Description
1. **Server Initialization**:
- The FastAPI application is created and configured in `listen.py`.
- CORS middleware and static file serving are set up.
- The `ConversationManager` is initialized.
2. **Client Connection**:
- When a client connects via WebSocket, a new `Session` is created or an existing one is restarted.
- The `Session` initializes an `AgentSession`, which sets up the runtime environment and agent controller.
3. **Agent Initialization**:
- The client sends an initialization request.
- The server creates and configures the agent based on the provided parameters.
- The runtime environment is set up, and the agent controller is initialized.
4. **Event Handling**:
- The `Session` manages the event stream between the client and the agent.
- Events from the client are dispatched to the agent.
- Observations from the agent are sent back to the client.
5. **File Operations**:
- The server handles file uploads, ensuring they meet size and type restrictions.
- File read and write operations are performed through the runtime environment.
6. **Security Analysis**:
- If configured, a security analyzer is initialized for each session.
- Security-related API requests are forwarded to the security analyzer.
7. **Session Management**:
- The `ConversationManager` periodically cleans up inactive sessions.
- It also handles sending messages to specific sessions when needed.
8. **API Endpoints**:
- Various API endpoints are provided for agent interactions, file operations, and retrieving configuration defaults.
This server architecture allows for managing multiple client sessions, each with its own agent instance, runtime environment, and security analyzer. The event-driven design facilitates real-time communication between clients and agents, while the modular structure allows for easy extension and maintenance of different components.

View File

@@ -28,7 +28,7 @@ class ServerConfig(ServerConfigInterface):
'openhands.app_server.secrets.file_secrets_store.FileSecretsStore'
)
user_auth_class: str = (
'openhands.app_server.user_auth.default_user_auth.DefaultUserAuth'
'openhands.server.user_auth.default_user_auth.DefaultUserAuth'
)
enable_v1: bool = os.getenv('ENABLE_V1') != '0'

View File

@@ -0,0 +1,11 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
# This module belongs to the old V0 web server. The V1 application server lives under openhands/app_server/.
"""Server constants."""
ROOM_KEY = 'room:{sid}'

View File

@@ -0,0 +1,147 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
# This module belongs to the old V0 web server. The V1 application server lives under openhands/app_server/.
import os
import re
from openhands.core.config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
from openhands.server.shared import config as shared_config
FILES_TO_IGNORE = [
'.git/',
'.DS_Store',
'node_modules/',
'__pycache__/',
'lost+found/',
'.vscode/',
]
def sanitize_filename(filename: str) -> str:
"""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
def load_file_upload_config(
config: OpenHandsConfig = shared_config,
) -> tuple[int, bool, list[str]]:
"""Load file upload configuration from the config object.
This function retrieves the file upload settings from the global config object.
It handles the following settings:
- Maximum file size for uploads
- Whether to restrict file types
- List of allowed file extensions
It also performs sanity checks on the values to ensure they are valid and safe.
Returns:
tuple: A tuple containing:
- max_file_size_mb (int): Maximum file size in MB. 0 means no limit.
- restrict_file_types (bool): Whether file type restrictions are enabled.
- allowed_extensions (set): Set of allowed file extensions.
"""
# Retrieve values from config
max_file_size_mb = config.file_uploads_max_file_size_mb
restrict_file_types = config.file_uploads_restrict_file_types
allowed_extensions = config.file_uploads_allowed_extensions
# Sanity check for max_file_size_mb
if not isinstance(max_file_size_mb, int) or max_file_size_mb < 0:
logger.warning(
f'Invalid max_file_size_mb: {max_file_size_mb}. Setting to 0 (no limit).'
)
max_file_size_mb = 0
# Sanity check for allowed_extensions
if not isinstance(allowed_extensions, (list, set)) or not allowed_extensions:
logger.warning(
f'Invalid allowed_extensions: {allowed_extensions}. Setting to [".*"].'
)
allowed_extensions = ['.*']
else:
# Ensure all extensions start with a dot and are lowercase
allowed_extensions = [
ext.lower() if ext.startswith('.') else f'.{ext.lower()}'
for ext in allowed_extensions
]
# If restrictions are disabled, allow all
if not restrict_file_types:
allowed_extensions = ['.*']
logger.debug(
f'File upload config: max_size={max_file_size_mb}MB, '
f'restrict_types={restrict_file_types}, '
f'allowed_extensions={allowed_extensions}'
)
return max_file_size_mb, restrict_file_types, allowed_extensions
# Load configuration
MAX_FILE_SIZE_MB, RESTRICT_FILE_TYPES, ALLOWED_EXTENSIONS = load_file_upload_config()
def is_extension_allowed(filename: str) -> bool:
"""Check if the file extension is allowed based on the current configuration.
This function supports wildcards and files without extensions.
The check is case-insensitive for extensions.
Args:
filename (str): The name of the file to check.
Returns:
bool: True if the file extension is allowed, False otherwise.
"""
if not RESTRICT_FILE_TYPES:
return True
file_ext = os.path.splitext(filename)[1].lower() # Convert to lowercase
return (
'.*' in ALLOWED_EXTENSIONS
or file_ext in (ext.lower() for ext in ALLOWED_EXTENSIONS)
or (file_ext == '' and '.' in ALLOWED_EXTENSIONS)
)
def get_unique_filename(filename: str, folder_path: str) -> str:
"""Returns unique filename on given folder_path. By checking if the given
filename exists. If it doesn't, filename is simply returned.
Otherwise, it append copy(#number) until the filename is unique.
Args:
filename (str): The name of the file to check.
folder_path (str): directory path in which file name check is performed.
Returns:
string: unique filename.
"""
name, ext = os.path.splitext(filename)
filename_candidate = filename
copy_index = 0
while os.path.exists(os.path.join(folder_path, filename_candidate)):
if copy_index == 0:
filename_candidate = f'{name} copy{ext}'
else:
filename_candidate = f'{name} copy({copy_index}){ext}'
copy_index += 1
return filename_candidate

View File

@@ -0,0 +1,27 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
# This module belongs to the old V0 web server. The V1 application server lives under openhands/app_server/.
from types import MappingProxyType
from openhands.app_server.integrations.provider import (
PROVIDER_TOKEN_TYPE,
ProviderToken,
)
from openhands.app_server.integrations.service_types import ProviderType
def create_provider_tokens_object(
providers_set: list[ProviderType],
) -> PROVIDER_TOKEN_TYPE:
"""Create provider tokens object for the given providers."""
provider_information: dict[ProviderType, ProviderToken] = {}
for provider in providers_set:
provider_information[provider] = ProviderToken(token=None, user_id=None)
return MappingProxyType(provider_information)

View File

@@ -11,6 +11,8 @@ import os
import socketio
from dotenv import load_dotenv
from openhands.app_server.file_store import get_file_store
from openhands.app_server.file_store.files import FileStore
from openhands.app_server.secrets.secrets_store import SecretsStore
from openhands.app_server.settings.settings_store import SettingsStore
from openhands.core.config import load_openhands_config
@@ -27,6 +29,10 @@ assert isinstance(server_config_interface, ServerConfig), (
'Loaded server config interface is not a ServerConfig, despite this being assumed'
)
server_config: ServerConfig = server_config_interface
file_store: FileStore = get_file_store(
file_store_type=config.file_store,
file_store_path=config.file_store_path,
)
client_manager = None
redis_host = os.environ.get('REDIS_HOST')

View File

@@ -8,15 +8,30 @@
# This module belongs to the old V0 web server. The V1 application server lives under openhands/app_server/.
from abc import ABC, abstractmethod
from enum import Enum
from typing import Any
from typing import Any, ClassVar, Protocol
class AppMode(Enum):
OPENHANDS = 'oss'
SAAS = 'saas'
# Backwards-compatible alias (deprecated): prefer AppMode.OPENHANDS
OSS = 'oss'
class SessionMiddlewareInterface(Protocol):
"""Protocol for session middleware classes."""
pass
class ServerConfigInterface(ABC):
CONFIG_PATH: ClassVar[str | None]
APP_MODE: ClassVar[AppMode]
POSTHOG_CLIENT_KEY: ClassVar[str]
GITHUB_CLIENT_ID: ClassVar[str]
ATTACH_SESSION_MIDDLEWARE_PATH: ClassVar[str]
@abstractmethod
def verify_config(self) -> None:
"""Verify configuration settings."""

View File

@@ -7,7 +7,7 @@ from openhands.app_server.secrets.secrets_models import Secrets
from openhands.app_server.secrets.secrets_store import SecretsStore
from openhands.app_server.settings.settings_models import Settings
from openhands.app_server.settings.settings_store import SettingsStore
from openhands.app_server.user_auth.user_auth import AuthType, get_user_auth
from openhands.server.user_auth.user_auth import AuthType, get_user_auth
async def get_provider_tokens(request: Request) -> PROVIDER_TOKEN_TYPE | None:

View File

@@ -16,8 +16,8 @@ from openhands.app_server.secrets.secrets_models import Secrets
from openhands.app_server.secrets.secrets_store import SecretsStore
from openhands.app_server.settings.settings_models import Settings
from openhands.app_server.settings.settings_store import SettingsStore
from openhands.app_server.user_auth.user_auth import UserAuth
from openhands.server import shared
from openhands.server.user_auth.user_auth import UserAuth
@dataclass

View File

@@ -9,18 +9,18 @@ def import_from(qual_name: str):
"""Import a value from its fully qualified name.
This function is a utility to dynamically import any Python value (class, function, variable)
from its fully qualified name. For example, 'openhands.app_server.user_auth.UserAuth' would
import the UserAuth class from the openhands.app_server.user_auth module.
from its fully qualified name. For example, 'openhands.server.user_auth.UserAuth' would
import the UserAuth class from the openhands.server.user_auth module.
Args:
qual_name: A fully qualified name in the format 'module.submodule.name'
e.g. 'openhands.app_server.user_auth.UserAuth'
e.g. 'openhands.server.user_auth.UserAuth'
Returns:
The imported value (class, function, or variable)
Example:
>>> UserAuth = import_from('openhands.app_server.user_auth.UserAuth')
>>> UserAuth = import_from('openhands.server.user_auth.UserAuth')
>>> auth = UserAuth()
"""
parts = qual_name.split('.')

View File

@@ -25,10 +25,10 @@ from openhands.app_server.settings.llm_profiles import MAX_PROFILES_PER_USER
from openhands.app_server.settings.settings_models import Settings
from openhands.app_server.settings.settings_router import _user_profile_locks
from openhands.app_server.settings.settings_store import SettingsStore
from openhands.app_server.user_auth.user_auth import UserAuth
from openhands.sdk.llm import LLM
from openhands.sdk.settings import AgentSettings
from openhands.server.app import app
from openhands.server.user_auth.user_auth import UserAuth
@pytest.fixture(autouse=True)
@@ -101,7 +101,7 @@ def test_client(settings_store):
),
patch('openhands.app_server.utils.dependencies._SESSION_API_KEY', None),
patch(
'openhands.app_server.user_auth.user_auth.UserAuth.get_instance',
'openhands.server.user_auth.user_auth.UserAuth.get_instance',
return_value=auth,
),
patch(
@@ -149,7 +149,7 @@ def _client_for_user(user_id: str, store: FileSettingsStore):
),
patch('openhands.app_server.utils.dependencies._SESSION_API_KEY', None),
patch(
'openhands.app_server.user_auth.user_auth.UserAuth.get_instance',
'openhands.server.user_auth.user_auth.UserAuth.get_instance',
return_value=auth,
),
patch(

View File

@@ -14,7 +14,6 @@ from openhands.app_server.secrets.secrets_store import SecretsStore
from openhands.app_server.settings.file_settings_store import FileSettingsStore
from openhands.app_server.settings.settings_models import Settings
from openhands.app_server.settings.settings_store import SettingsStore
from openhands.app_server.user_auth.user_auth import UserAuth
from openhands.sdk.llm import LLM
from openhands.sdk.settings import (
AgentSettings,
@@ -22,6 +21,7 @@ from openhands.sdk.settings import (
VerificationSettings,
)
from openhands.server.app import app
from openhands.server.user_auth.user_auth import UserAuth
_EXPOSE = {'expose_secrets': True}
@@ -99,7 +99,7 @@ def test_client():
),
patch('openhands.app_server.utils.dependencies._SESSION_API_KEY', None),
patch(
'openhands.app_server.user_auth.user_auth.UserAuth.get_instance',
'openhands.server.user_auth.user_auth.UserAuth.get_instance',
return_value=MockUserAuth(),
),
patch(
@@ -324,3 +324,116 @@ async def test_disabled_skills_persistence(test_client):
assert response.status_code == 200
data = response.json()
assert data['disabled_skills'] == []
@pytest.mark.asyncio
async def test_critic_deployment_defaults_populated_in_response(test_client):
"""Deployment-level critic defaults should appear in the GET response
so the frontend shows the effective values instead of empty fields."""
response = test_client.post(
'/api/v1/settings',
json=_dump_update(
Settings(
agent_settings=AgentSettings(
llm=LLM(model='test-model'),
verification=VerificationSettings(critic_enabled=True),
),
)
),
)
assert response.status_code == 200
with patch.dict(
os.environ,
{
'OPENHANDS_PROVIDER_BASE_URL': 'https://llm-proxy.example.com',
'CRITIC_MODEL_NAME': 'my-critic-model',
},
):
response = test_client.get('/api/v1/settings')
assert response.status_code == 200
verification = response.json()['agent_settings']['verification']
assert verification['critic_enabled'] is True
assert verification['critic_server_url'] == (
'https://llm-proxy.example.com/vllm'
)
assert verification['critic_model_name'] == 'my-critic-model'
@pytest.mark.asyncio
async def test_critic_user_override_not_clobbered(test_client):
"""When the user explicitly sets critic_server_url, the deployment
default must NOT overwrite it."""
response = test_client.post(
'/api/v1/settings',
json=_dump_update(
Settings(
agent_settings=AgentSettings(
llm=LLM(model='test-model'),
verification=VerificationSettings(
critic_enabled=True,
critic_server_url='https://my-custom-critic.example.com',
critic_model_name='custom-model',
),
),
)
),
)
assert response.status_code == 200
with patch.dict(
os.environ,
{
'OPENHANDS_PROVIDER_BASE_URL': 'https://llm-proxy.example.com',
'CRITIC_MODEL_NAME': 'deployment-model',
},
):
response = test_client.get('/api/v1/settings')
assert response.status_code == 200
verification = response.json()['agent_settings']['verification']
assert (
verification['critic_server_url'] == 'https://my-custom-critic.example.com'
)
assert verification['critic_model_name'] == 'custom-model'
@pytest.mark.asyncio
async def test_critic_sdk_defaults_used_when_no_env_vars(test_client):
"""When no deployment env vars are set, SDK CriticClient defaults
should be used as the fallback for critic_server_url and critic_model_name."""
from openhands.sdk.critic.impl.api.client import CriticClient
response = test_client.post(
'/api/v1/settings',
json=_dump_update(
Settings(
agent_settings=AgentSettings(
llm=LLM(model='test-model'),
verification=VerificationSettings(critic_enabled=True),
),
)
),
)
assert response.status_code == 200
with patch.dict(
os.environ,
{},
clear=False,
):
os.environ.pop('OPENHANDS_PROVIDER_BASE_URL', None)
os.environ.pop('LLM_BASE_URL', None)
os.environ.pop('CRITIC_SERVER_URL', None)
os.environ.pop('CRITIC_MODEL_NAME', None)
response = test_client.get('/api/v1/settings')
assert response.status_code == 200
verification = response.json()['agent_settings']['verification']
assert (
verification['critic_server_url']
== CriticClient.model_fields['server_url'].default
)
assert (
verification['critic_model_name']
== CriticClient.model_fields['model_name'].default
)

Some files were not shown because too many files have changed in this diff Show More