Files
agentic-mobile-control/packages/cloud-platform/cloud/internal_api/api.py
T
q792602257 25ebc10a8a
Tests / Test passed: 789
feat: downgrade Python baseline to 3.13 for PaddleOCR compatibility
paddlepaddle has no Python 3.14 (cp314) wheel on PyPI, so host-agent
deployments on 3.14 can never install it, causing OCR to fail at
runtime with RuntimeError. Pin the workspace to Python 3.13 across
all pyproject.toml files, the Docker base image, and the Jenkins CI
image; regenerate uv.lock against 3.13.

Also fixes a pre-existing Python-2-style `except X, Y:` syntax error
(invalid in all Python 3.x) in runtime/task.py and
packages/cloud-platform/cloud/{sql_repository,internal_api/api}.py,
introduced in 22d37ca9 and unrelated to this change's scope, which
blocked the full test suite from collecting on any interpreter
version.

openspec change: downgrade-python-3-13-paddleocr
2026-07-14 18:05:49 +08:00

629 lines
22 KiB
Python

from __future__ import annotations
import asyncio
import base64
import json
import logging
from collections.abc import Awaitable, Callable
from datetime import timedelta
from time import monotonic
from typing import TYPE_CHECKING
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request, status
from fastapi.responses import JSONResponse
from cloud.auth import (
AuthProvider,
HostAuthorizationError,
digest_token,
)
from cloud.internal_api.models import (
AssignmentModel,
ClaimRequest,
ClaimResponse,
DeviceEnrollmentRequest,
DeviceEnrollmentResponse,
HeartbeatRequest,
HeartbeatResponse,
HostGovernancePolicyModel,
HostEnrollmentRequest,
HostEnrollmentResponse,
HostTaskSubmissionRequest,
HostTaskSubmissionResponse,
LeaseRenewalRequest,
LeaseRenewalResponse,
PlannerDecisionError,
PlannerDecisionRequest,
PlannerDecisionResponse,
StaleLeaseConflict,
TerminalResultRequest,
TerminalResultResponse,
)
from cloud.llm_providers import LlmProviderResolutionError, LlmProviderService
from cloud.planner_config import build_cloud_planner_client
from cloud.provider_secrets import ProviderSecretConfigurationError
from cloud.repository import (
AssignmentProgressSnapshot,
DeviceEnrollmentConflictError,
HostEnrollmentConflictError,
)
from cloud.governance import TokenBudgetExceededError
from core.models import Device, utc_now
from runtime.tool_calling_client import ToolCallingClient, ToolCallUnavailable
from runtime.tool_specs import ToolSpec
if TYPE_CHECKING:
from cloud.pool import DevicePool
from cloud.scheduler import TaskScheduler
logger = logging.getLogger(__name__)
def create_internal_router(
*,
pool: DevicePool,
auth_provider: AuthProvider,
version_prefix: str = "/internal/v1",
claim_poll_interval_seconds: float = 0.1,
lease_duration_seconds: float = 60.0,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
planner_client_factory: Callable[[], ToolCallingClient] | None = None,
planner_provider_service: LlmProviderService | None = None,
scheduler: TaskScheduler | None = None,
planner_token_reservation_ceiling: int = 4096,
planner_token_reservation_ttl_seconds: float = 300.0,
) -> APIRouter:
if claim_poll_interval_seconds <= 0:
raise ValueError("claim_poll_interval_seconds must be greater than zero")
if lease_duration_seconds <= 0:
raise ValueError("lease_duration_seconds must be greater than zero")
if planner_token_reservation_ceiling <= 0:
raise ValueError("planner_token_reservation_ceiling must be greater than zero")
if planner_token_reservation_ttl_seconds <= 0:
raise ValueError(
"planner_token_reservation_ttl_seconds must be greater than zero"
)
router = APIRouter(prefix=version_prefix, tags=["host-agent"])
def authorize_host(request: Request, host_id: str) -> None:
principal = auth_provider.authenticate(request)
if principal is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="unauthorized",
headers={"WWW-Authenticate": "Bearer"},
)
try:
principal.require_host(host_id)
except HostAuthorizationError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(exc),
) from exc
@router.post(
"/enrollments",
response_model=HostEnrollmentResponse,
status_code=status.HTTP_201_CREATED,
)
def enroll_host(
payload: HostEnrollmentRequest,
) -> HostEnrollmentResponse:
try:
enrollment = pool.store.enroll_host(
host_id=f"host-{uuid4().hex}",
agent_instance_id=payload.agent_instance_id,
credential_digest=digest_token(payload.host_token),
enrollment_token_digest=None,
display_name=payload.display_name,
enrolled_at=utc_now(),
)
except HostEnrollmentConflictError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
return HostEnrollmentResponse(host_id=enrollment.host_id)
@router.post(
"/hosts/{host_id}/devices/enroll",
response_model=DeviceEnrollmentResponse,
status_code=status.HTTP_201_CREATED,
)
def enroll_device(
host_id: str,
payload: DeviceEnrollmentRequest,
request: Request,
) -> DeviceEnrollmentResponse:
authorize_host(request, host_id)
try:
enrollment = pool.store.enroll_device(
device_id=f"device-{uuid4().hex}",
host_id=host_id,
local_device_id=payload.local_device_id,
driver_type=payload.driver_type,
name=payload.name,
capability_tags=list(payload.capability_tags),
enrolled_at=utc_now(),
)
except DeviceEnrollmentConflictError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
return DeviceEnrollmentResponse(device_id=enrollment.device_id)
@router.put(
"/hosts/{host_id}/heartbeat",
response_model=HeartbeatResponse,
)
def heartbeat(
host_id: str,
payload: HeartbeatRequest,
request: Request,
) -> HeartbeatResponse:
authorize_host(request, host_id)
allow_device_takeover = _validate_snapshot(
pool,
host_id=host_id,
payload=payload,
)
devices = [
Device(
id=device.device_id,
driver_type=device.driver_type,
status=device.status,
capability_tags=list(device.capability_tags),
)
for device in payload.devices
]
pool.sync_host_devices(
host_id,
devices,
address=payload.address,
allow_device_takeover=allow_device_takeover,
planner_transport=payload.planner_transport,
)
policy = pool.store.get_host_governance_policy(host_id)
policy_revision = policy.revision if policy is not None else 0
return HeartbeatResponse(
host_id=host_id,
accepted_devices=len(devices),
received_at=utc_now(),
policy_revision=policy_revision,
policy=(
HostGovernancePolicyModel(
revision=policy.revision,
self_submission_enabled=policy.self_submission_enabled,
max_active_tasks=policy.max_active_tasks,
daily_token_budget=policy.daily_token_budget,
)
if policy is not None and payload.policy_revision != policy.revision
else None
),
)
@router.post(
"/hosts/{host_id}/tasks",
response_model=HostTaskSubmissionResponse,
status_code=status.HTTP_201_CREATED,
)
def submit_host_task(
host_id: str,
payload: HostTaskSubmissionRequest,
request: Request,
) -> HostTaskSubmissionResponse:
authorize_host(request, host_id)
if payload.host_id != host_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="task host_id must match the request path",
)
if scheduler is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="task submission is unavailable",
)
policy = pool.store.get_host_governance_policy(host_id)
if policy is not None and not policy.self_submission_enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Host self-submission is disabled",
)
if payload.device_id is not None and not any(
device.host_id == host_id and device.device_id == payload.device_id
for device in pool.list_devices()
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="target device is not owned by this Host",
)
from cloud.scheduler import TaskConstraints
task_id = scheduler.submit(
goal=payload.goal,
constraints=TaskConstraints(
target_host_id=host_id,
target_device_id=payload.device_id,
),
)
return HostTaskSubmissionResponse(task_id=task_id)
@router.post(
"/hosts/{host_id}/assignments/claim",
response_model=ClaimResponse,
)
async def claim_assignment(
host_id: str,
payload: ClaimRequest,
request: Request,
) -> ClaimResponse:
authorize_host(request, host_id)
if payload.host_id != host_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="claim host_id must match the request path",
)
deadline = monotonic() + payload.timeout_seconds
while True:
assignment = pool.store.claim_assignment(host_id=host_id, now=utc_now())
if assignment is not None:
return ClaimResponse(
assignment=AssignmentModel(
task_id=assignment.task_id,
attempt=assignment.attempt,
lease_id=assignment.lease_id,
lease_expires_at=assignment.lease_expires_at,
host_id=assignment.host_id,
device_id=assignment.device_id,
goal=assignment.goal,
workflow_definition_id=assignment.workflow_definition_id,
)
)
remaining = deadline - monotonic()
if remaining <= 0:
return ClaimResponse(timed_out=True)
await sleep(min(claim_poll_interval_seconds, remaining))
@router.post(
"/hosts/{host_id}/assignments/{task_id}/renew",
response_model=LeaseRenewalResponse,
responses={status.HTTP_409_CONFLICT: {"model": StaleLeaseConflict}},
)
def renew_assignment(
host_id: str,
task_id: str,
payload: LeaseRenewalRequest,
request: Request,
):
authorize_host(request, host_id)
_validate_assignment_identity(
host_id=host_id,
task_id=task_id,
payload_host_id=payload.host_id,
payload_task_id=payload.task_id,
)
now = utc_now()
lease_expires_at = now + timedelta(seconds=lease_duration_seconds)
progress_snapshot: AssignmentProgressSnapshot | None = None
if payload.progress is not None:
progress_snapshot = AssignmentProgressSnapshot(
step_index=payload.progress.step_index,
step_status=payload.progress.step_status,
summary=payload.progress.summary[:500],
updated_at=now,
)
renewal_status = pool.store.renew_lease(
task_id=task_id,
attempt=payload.attempt,
lease_id=payload.lease_id,
host_id=host_id,
lease_expires_at=lease_expires_at,
now=now,
progress=progress_snapshot,
)
if renewal_status == "not_found":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="assignment not found",
)
if renewal_status != "renewed":
return _stale_lease_conflict("assignment lease is stale or expired")
return LeaseRenewalResponse(
status="renewed",
lease_expires_at=lease_expires_at,
)
@router.post(
"/hosts/{host_id}/assignments/{task_id}/result",
response_model=TerminalResultResponse,
responses={status.HTTP_409_CONFLICT: {"model": StaleLeaseConflict}},
)
def report_result(
host_id: str,
task_id: str,
payload: TerminalResultRequest,
request: Request,
):
authorize_host(request, host_id)
_validate_assignment_identity(
host_id=host_id,
task_id=task_id,
payload_host_id=payload.host_id,
payload_task_id=payload.task_id,
)
result_status = pool.store.record_task_result(
task_id=task_id,
attempt=payload.attempt,
lease_id=payload.lease_id,
host_id=host_id,
status=payload.status,
failure_reason=payload.failure_reason,
terminal_result=payload.result,
completed_at=utc_now(),
)
if result_status == "conflict":
return _stale_lease_conflict("assignment lease is stale or superseded")
return TerminalResultResponse(status=result_status)
@router.post(
"/hosts/{host_id}/planner/decide",
response_model=PlannerDecisionResponse,
response_model_exclude_none=True,
responses={
status.HTTP_502_BAD_GATEWAY: {"model": PlannerDecisionError},
status.HTTP_429_TOO_MANY_REQUESTS: {"model": PlannerDecisionError},
},
)
def decide_planner_call(
host_id: str,
payload: PlannerDecisionRequest,
request: Request,
):
authorize_host(request, host_id)
if payload.host_id != host_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="planner-decision host_id must match the request path",
)
_validate_planner_context(pool, host_id=host_id, payload=payload)
screenshot: bytes | None = None
if payload.screenshot_base64 is not None:
try:
screenshot = base64.b64decode(payload.screenshot_base64)
except (ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="screenshot_base64 is not valid base64",
) from None
tools = [
ToolSpec(
name=tool.name,
description=tool.description,
parameters=tool.parameters,
)
for tool in payload.tools
]
try:
if planner_client_factory is not None:
client = planner_client_factory()
resolved_provider = "test"
resolved_model = "test"
elif planner_provider_service is not None:
resolved = planner_provider_service.resolve_active_profile()
client = build_cloud_planner_client(resolved)
resolved_provider = resolved.profile.provider_type
resolved_model = resolved.profile.model
else:
raise LlmProviderResolutionError(
"no database Provider resolver configured"
)
except (LlmProviderResolutionError, ProviderSecretConfigurationError) as exc:
logger.info(
"planner-decision request failed",
extra={"host_id": host_id, "error_class": type(exc).__name__},
)
return JSONResponse(
status_code=status.HTTP_502_BAD_GATEWAY,
content=PlannerDecisionError(detail=str(exc)).model_dump(),
)
now = utc_now()
reservation = None
try:
reservation = pool.store.reserve_host_token_budget(
reservation_id=uuid4().hex,
host_id=host_id,
usage_day=now.date().isoformat(),
reserved_tokens=planner_token_reservation_ceiling,
task_id=payload.task_id,
attempt=payload.attempt,
created_at=now,
expires_at=now
+ timedelta(seconds=planner_token_reservation_ttl_seconds),
)
except TokenBudgetExceededError as exc:
return JSONResponse(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
content=PlannerDecisionError(detail=str(exc)).model_dump(),
)
started_at = monotonic()
try:
decision = client.decide(
system_prompt=payload.system_prompt,
user_prompt=payload.user_prompt,
screenshot=screenshot,
tools=tools,
timeout=payload.timeout_seconds,
)
except ToolCallUnavailable as exc:
logger.info(
"planner-decision request failed",
extra={
"host_id": host_id,
"latency_seconds": monotonic() - started_at,
"error_class": type(exc).__name__,
},
)
return JSONResponse(
status_code=status.HTTP_502_BAD_GATEWAY,
content=PlannerDecisionError(detail=str(exc)).model_dump(),
)
usage = decision.usage
if (
reservation is not None
and usage is not None
and usage.total_tokens is not None
):
pool.store.settle_host_token_reservation(
reservation_id=reservation.id,
event_id=uuid4().hex,
provider=resolved_provider,
model=resolved_model,
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
total_tokens=usage.total_tokens,
occurred_at=utc_now(),
)
if payload.task_id and payload.attempt:
pool.store.record_planner_decision(
host_id=host_id,
task_id=payload.task_id,
attempt=payload.attempt,
system_prompt=payload.system_prompt,
user_prompt=payload.user_prompt,
tool_name=decision.tool_name,
arguments_json=json.dumps(decision.arguments),
now=utc_now(),
)
logger.info(
"planner-decision request resolved",
extra={
"host_id": host_id,
"tool_name": decision.tool_name,
"latency_seconds": monotonic() - started_at,
},
)
return PlannerDecisionResponse(
tool_name=decision.tool_name,
arguments=dict(decision.arguments),
input_tokens=(decision.usage.input_tokens if decision.usage else None),
output_tokens=(decision.usage.output_tokens if decision.usage else None),
total_tokens=(decision.usage.total_tokens if decision.usage else None),
)
return router
def _validate_assignment_identity(
*,
host_id: str,
task_id: str,
payload_host_id: str,
payload_task_id: str,
) -> None:
if payload_host_id != host_id or payload_task_id != task_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="assignment identity must match the request path",
)
def _validate_planner_context(
pool, *, host_id: str, payload: PlannerDecisionRequest
) -> None:
context_values = (payload.task_id, payload.attempt, payload.lease_id)
if not any(value is not None for value in context_values):
return
if any(value is None for value in context_values):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="planner context requires task_id, attempt, and lease_id together",
)
attempts = pool.store.list_task_attempts(payload.task_id or "")
if not any(
attempt.attempt == payload.attempt
and attempt.host_id == host_id
and attempt.lease_id == payload.lease_id
for attempt in attempts
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="planner context does not match a Host assignment",
)
def _stale_lease_conflict(detail: str) -> JSONResponse:
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content=StaleLeaseConflict(detail=detail).model_dump(),
)
def _validate_snapshot(
pool: DevicePool,
*,
host_id: str,
payload: HeartbeatRequest,
) -> bool:
if payload.host_id != host_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="heartbeat host_id must match the request path",
)
device_ids = [device.device_id for device in payload.devices]
if len(device_ids) != len(set(device_ids)):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="heartbeat snapshot contains duplicate device ids",
)
if pool.store.is_enrollment_managed_host(host_id):
enrollments = {
enrollment.device_id: enrollment
for enrollment in pool.store.list_device_enrollments(host_id)
if enrollment.revoked_at is None
}
invalid = [
device.device_id
for device in payload.devices
if device.device_id not in enrollments
or enrollments[device.device_id].driver_type != device.driver_type
]
if invalid:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"device enrollment conflict: {sorted(set(invalid))}",
)
return False
now = utc_now()
hosts = {host.host_id: host for host in pool.store.list_hosts()}
conflicts: list[str] = []
stale_owner_found = False
requested_ids = set(device_ids)
for device in pool.store.list_devices():
if device.device_id not in requested_ids or device.host_id == host_id:
continue
owner = hosts.get(device.host_id)
if owner is None:
continue
age_seconds = (now - owner.last_seen_at).total_seconds()
if age_seconds <= pool.config.stale_after_seconds:
conflicts.append(device.device_id)
else:
stale_owner_found = True
if conflicts:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"device ownership conflict: {sorted(set(conflicts))}",
)
return stale_owner_found