Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/docker_buildx_workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4.1.1

- name: Extract version from main.py
- name: Extract version from version.py
id: get_version
run: |
VERSION=$(grep -E '^VERSION *= *' main.py | head -1 | sed -E 's/^VERSION *= *["\x27]?([^"\x27]*)["\x27]?/\1/')
VERSION=$(grep -E '^VERSION *= *' version.py | head -1 | sed -E 's/^VERSION *= *["\x27]?([^"\x27]*)["\x27]?/\1/')
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT

- name: Set up QEMU
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ COPY --from=builder /opt/conda/envs/hummingbot-api /opt/conda/envs/hummingbot-ap
WORKDIR /hummingbot-api

# Copy only necessary application files
COPY main.py config.py deps.py ./
COPY main.py version.py config.py deps.py ./
COPY models ./models
COPY routers ./routers
COPY services ./services
Expand Down
6 changes: 6 additions & 0 deletions deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from services.gateway_service import GatewayService
from services.gateway_swap_service import GatewaySwapService
from services.market_data_service import MarketDataService
from services.self_upgrade import SelfUpgradeService
from services.trading_history_service import TradingHistoryService
from services.trading_service import TradingService
from services.unified_connector_service import UnifiedConnectorService
Expand Down Expand Up @@ -61,6 +62,11 @@ def get_docker_service(request: Request) -> DockerService:
return request.app.state.docker_service


def get_self_upgrade_service(request: Request) -> SelfUpgradeService:
"""Get SelfUpgradeService from app state."""
return request.app.state.self_upgrade_service


def get_gateway_service(request: Request) -> GatewayService:
"""Get GatewayService from app state."""
return request.app.state.gateway_service
Expand Down
45 changes: 20 additions & 25 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# Load environment variables early
load_dotenv()

VERSION = "1.0.1"
from version import VERSION # noqa: E402 (kept in its own module so routers can read it)

# Monkey patch save_to_yml to prevent writes to library directory

Expand All @@ -28,11 +28,10 @@ def patched_save_to_yml(yml_path, cm):

config_helpers.save_to_yml = patched_save_to_yml

from fastapi import Depends, FastAPI, HTTPException, Request, status # noqa: E402
from fastapi import Depends, FastAPI, HTTPException, status # noqa: E402
from fastapi.exceptions import RequestValidationError # noqa: E402
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
from fastapi.middleware.gzip import GZipMiddleware # noqa: E402
from fastapi.responses import JSONResponse # noqa: E402
from fastapi.security import HTTPBasic, HTTPBasicCredentials # noqa: E402
from hummingbot.client.config.client_config_map import GatewayConfigMap # noqa: E402
from hummingbot.client.config.config_crypt import ETHKeyFileSecretManger # noqa: E402
Expand All @@ -45,6 +44,7 @@ def patched_save_to_yml(yml_path, cm):
archived_bots,
backtesting,
bot_orchestration,
bot_rate_oracle,
connectors,
controllers,
docker,
Expand Down Expand Up @@ -73,13 +73,15 @@ def patched_save_to_yml(yml_path, cm):
from services.gateway_service import GatewayService # noqa: E402
from services.gateway_swap_service import GatewaySwapService # noqa: E402
from services.market_data_service import MarketDataService # noqa: E402
from services.self_upgrade import SelfUpgradeService # noqa: E402
from services.trading_history_service import TradingHistoryService # noqa: E402
from services.trading_service import TradingService # noqa: E402
from services.unified_connector_service import UnifiedConnectorService # noqa: E402
from services.websocket_manager import WebSocketManager # noqa: E402
from utils.bot_archiver import BotArchiver # noqa: E402
from utils.core_compatibility import require_core_surface # noqa: E402
from utils.security import BackendAPISecurity # noqa: E402
from utils.validation_errors import validation_exception_handler # noqa: E402

# Set up logging configuration
logging.basicConfig(
Expand Down Expand Up @@ -305,6 +307,18 @@ async def lifespan(app: FastAPI):
else:
logging.info("Gateway container not running; status monitor deferred until it is started")

# An upgrade started by the *previous* API process outlives it: the helper container
# that ran `docker compose up` is still on the box with its exit code and logs, and
# this process is the new container it created. Collect that record now, before
# anything can ask for it, and remove the helper so the next preflight is not blocked
# by its own predecessor. Never raises (FEAT-122).
self_upgrade_service = SelfUpgradeService(
docker_service=docker_service,
executor_service=executor_service,
bots_orchestrator=bots_orchestrator,
)
self_upgrade_service.collect_on_boot()

bot_archiver = BotArchiver(
settings.aws.api_key,
settings.aws.secret_key,
Expand Down Expand Up @@ -354,6 +368,7 @@ async def lifespan(app: FastAPI):
app.state.backtesting_service = backtesting_service
app.state.bots_orchestrator = bots_orchestrator
app.state.docker_service = docker_service
app.state.self_upgrade_service = self_upgrade_service
app.state.gateway_service = gateway_service
app.state.bot_archiver = bot_archiver

Expand Down Expand Up @@ -410,28 +425,7 @@ async def lifespan(app: FastAPI):
app.add_middleware(GZipMiddleware, minimum_size=1000)


@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""
Custom handler for validation errors to log detailed error messages.
"""
# Build a readable error message from validation errors
error_messages = []
for error in exc.errors():
loc = " -> ".join(str(part) for part in error.get("loc", []))
msg = error.get("msg", "Validation error")
error_messages.append(f"{loc}: {msg}")

# Log the validation error with details
logging.warning(
f"Validation error on {request.method} {request.url.path}: {'; '.join(error_messages)}"
)

# Return standard FastAPI validation error response
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={"detail": exc.errors()},
)
app.add_exception_handler(RequestValidationError, validation_exception_handler)

logfire.configure(send_to_logfire="if-token-present", environment=settings.app.logfire_environment,
service_name="hummingbot-api")
Expand Down Expand Up @@ -476,6 +470,7 @@ def auth_user(
app.include_router(controllers.router, dependencies=[Depends(auth_user)])
app.include_router(scripts.router, dependencies=[Depends(auth_user)])
app.include_router(market_data.router, dependencies=[Depends(auth_user)])
app.include_router(bot_rate_oracle.router, dependencies=[Depends(auth_user)])
app.include_router(performance.router, dependencies=[Depends(auth_user)])
app.include_router(backtesting.router, dependencies=[Depends(auth_user)])
app.include_router(archived_bots.router, dependencies=[Depends(auth_user)])
Expand Down
5 changes: 5 additions & 0 deletions models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@
# Script models
from .scripts import Script, ScriptConfig, ScriptConfigResponse, ScriptResponse

# System models
from .system import SelfUpgradeRequest

# Trading models
from .trading import (
AccountBalance,
Expand Down Expand Up @@ -382,4 +385,6 @@
"ExecutorsSummaryResponse",
"OrphanedPositionRecord",
"OrphanedPositionsResponse",
# System models
"SelfUpgradeRequest",
]
88 changes: 88 additions & 0 deletions models/bot_rate_oracle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""
Pydantic models for the bot client defaults endpoints.

The API itself prices through the in-house ticker pool, but deployed bots still run
hummingbot's RateOracle, configured from the conf_client.yml copied out of their
credentials profile. These models describe that persisted configuration, plus the one
other conf_client.yml knob operators tune per profile -- rate_limits_share_pct, the share
of each exchange's API rate limit a bot instance is allowed to spend.
"""

from typing import List, Optional

from pydantic import BaseModel, Field, field_validator


class GlobalTokenConfig(BaseModel):
"""Global token configuration for displaying values."""
global_token_name: Optional[str] = Field(
default=None,
description="The token to use as global quote (e.g., USDT, USD, BTC)"
)
global_token_symbol: Optional[str] = Field(
default=None,
description="Symbol to display for the global token"
)

@field_validator("global_token_name", "global_token_symbol")
@classmethod
def _reject_blank(cls, v: Optional[str], info) -> Optional[str]:
# A blank quote token would make every cross-rate lookup resolve to nothing, and a
# blank symbol renders every value in the bot's status without a unit. Omit the
# field to leave it unchanged.
if v is not None:
v = v.strip()
if not v:
raise ValueError(f"{info.field_name} must not be blank")
Comment on lines +27 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Blank symbols break reads

Extending this validator to global_token_symbol makes the read endpoint fail validation when an existing or manually edited conf_client.yml contains an empty symbol. _to_config passes that persisted value directly into this model, and the GET route does not handle the resulting validation error. This makes the account’s configuration endpoint return a server error instead of exposing or normalizing the configuration.

return v


class RateOracleSourceConfig(BaseModel):
"""Rate oracle source configuration."""
name: str = Field(description="The rate oracle source deployed bots use for price data")


class RateOracleConfig(BaseModel):
"""Bot client defaults persisted in an account's conf_client.yml."""
account_name: str = Field(description="Credentials profile the configuration belongs to")
rate_oracle_source: RateOracleSourceConfig
global_token: GlobalTokenConfig
# Bounds mirror hummingbot's own ClientConfigMap.rate_limits_share_pct (gt=0, le=100):
# the file is read back by hummingbot inside the bot container, so a value it would
# reject must not be writable here.
rate_limits_share_pct: float = Field(
default=100.0,
gt=0,
le=100,
description="Percentage of each exchange's API rate limit allocated to a bot instance"
)


class RateOracleConfigResponse(RateOracleConfig):
"""Response for the rate oracle configuration GET endpoint."""
available_sources: List[str] = Field(description="Rate oracle sources that can be configured")


class RateOracleConfigUpdateRequest(BaseModel):
"""Request model for updating the rate oracle configuration."""
rate_oracle_source: Optional[RateOracleSourceConfig] = Field(
default=None,
description="New rate oracle source configuration (optional)"
)
global_token: Optional[GlobalTokenConfig] = Field(
default=None,
description="New global token configuration (optional)"
)
rate_limits_share_pct: Optional[float] = Field(
default=None,
gt=0,
le=100,
description="New share of each exchange's API rate limit for a bot instance, 1-100 (optional)"
)


class RateOracleConfigUpdateResponse(BaseModel):
"""Response for a bot client defaults update."""
success: bool = Field(description="Whether the update was successful")
message: str = Field(description="Status message")
config: RateOracleConfig = Field(description="Updated configuration")
21 changes: 21 additions & 0 deletions models/system.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""System router models (FEAT-122)."""

from pydantic import BaseModel, Field


class SelfUpgradeRequest(BaseModel):
"""Consent to what a self-upgrade destroys.

Restarting the API closes every RUNNING executor as SYSTEM_CLEANUP and they are not
restored, so the one field here is the acknowledgement of that -- and it defaults to
False, which means a caller that sends an empty body is refused rather than served.
Bot containers are separate and keep running.
"""

acknowledge_executor_loss: bool = Field(
default=False,
description=(
"Confirms that every running executor will be closed as SYSTEM_CLEANUP and not "
"restored. Required when the server has any running executor."
),
)
Loading
Loading