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
9 changes: 5 additions & 4 deletions src/xagent/core/model/chat/basic/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -656,10 +656,11 @@ def __init__(
self.context_window = context_window
ability_source = downstream if router.uses_configured_candidates else router
abilities = list(getattr(ability_source, "abilities", router.abilities))
for modality in input_modalities:
ability = _MODALITY_ABILITIES.get(modality)
if ability is not None and ability not in abilities:
abilities.append(ability)
if not router.uses_configured_candidates:
for modality in input_modalities:
ability = _MODALITY_ABILITIES.get(modality)
if ability is not None and ability not in abilities:
abilities.append(ability)
self._abilities = abilities

@property
Expand Down
11 changes: 10 additions & 1 deletion src/xagent/web/api/a2a.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,13 @@
task_state,
task_to_a2a,
)
from ..services.client_error_messages import CLIENT_SAFE_AUTO_MODEL_UNAVAILABLE
from ..services.db_runtime import (
cancel_and_drain_async_task,
drain_async_task_cancellation_safe,
run_db_io_cancellation_safe,
)
from ..services.llm_utils import AutoModelUnavailableError
from ..services.task_command_transport import (
COMMAND_COMPLETED,
COMMAND_FAILED,
Expand Down Expand Up @@ -614,10 +616,17 @@ async def inject_user_message() -> tuple[Any, UserMessageInjectionOutcome]:
status_code=503,
details={"taskId": task_id},
) from exc
except BaseException:
except BaseException as exc:
if not ownership_transferred and not prelease_cleanup_done:
cleanup_task = asyncio.create_task(stop_and_restore_prelease())
await drain_async_task_cancellation_safe(cleanup_task)
if isinstance(exc, AutoModelUnavailableError):
raise a2a_error(
"unsupported_operation",
CLIENT_SAFE_AUTO_MODEL_UNAVAILABLE,
status_code=409,
details={"code": "auto_model_unavailable"},
) from exc
raise
finally:
if not ownership_transferred and not prelease_cleanup_done:
Expand Down
32 changes: 30 additions & 2 deletions src/xagent/web/api/admin_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,9 +213,11 @@ def _record_settled_bindings_sync(
def _delete_user_rows_sync(*, user_id: int) -> bool:
"""Delete one user and every row it owns in an operation-local session."""

from ..models.auto_model import AutoModelConfig
from ..models.auto_model import AutoModelCandidate, AutoModelConfig
from ..models.mcp import UserMCPServer
from ..models.model import Model as DBModel
from ..models.user import UserModel
from ..services.model_service import _is_model_visible_to_user

session_factory = get_session_local()
delete_db = session_factory()
Expand Down Expand Up @@ -245,8 +247,34 @@ def _delete_user_rows_sync(*, user_id: int) -> bool:
synchronize_session=False
)

# Delete the user (UserModel and UserDefaultModel have cascade delete)
affected_candidates = (
delete_db.query(AutoModelCandidate)
.join(AutoModelConfig)
.filter(
AutoModelCandidate.target_model_id.in_(
select(UserModel.model_id).where(UserModel.user_id == user_id)
),
AutoModelConfig.user_id != user_id,
)
.all()
)
Comment thread
rogercloud marked this conversation as resolved.

# Delete the user, then evaluate the surviving grants. Other owners or
# shared grants can still make a target available to a bound Auto.
delete_db.delete(user)
delete_db.flush()
changed_configs = set()
for candidate in affected_candidates:
config = candidate.config
if _is_model_visible_to_user(
delete_db, candidate.target_model_id, int(config.user_id)
):
continue
if config.fallback_model_id == candidate.target_model_id:
config.fallback_model_id = None
changed_configs.add(int(config.id))
delete_db.delete(candidate)
ModelStore(delete_db).refresh_auto_model_abilities(list(changed_configs))
delete_db.commit()
ModelStore(delete_db).invalidate_after_user_delete()
return True
Expand Down
5 changes: 4 additions & 1 deletion src/xagent/web/api/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@
owned_agent_clause,
)
from ..services.api_keys import AgentApiKeyService, KeyRotationConflict
from ..services.llm_utils import UserAwareModelStorage
from ..services.client_error_messages import CLIENT_SAFE_AUTO_MODEL_UNAVAILABLE
from ..services.llm_utils import AutoModelUnavailableError, UserAwareModelStorage
from ..services.workforce_access import get_visible_agent_ids
from ..tools.config import WebToolConfig
from ..user_isolated_memory import UserContext
Expand Down Expand Up @@ -592,6 +593,8 @@ async def optimize_instructions(

return {"optimized_instructions": content}

except AutoModelUnavailableError as exc:
raise HTTPException(409, detail=CLIENT_SAFE_AUTO_MODEL_UNAVAILABLE) from exc
except Exception as e:
logger.error(f"Failed to optimize instructions: {e}")
raise HTTPException(status_code=500, detail=str(e))
Expand Down
41 changes: 38 additions & 3 deletions src/xagent/web/api/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -2032,8 +2032,14 @@ async def update_model(
effective_model_name = update_data.get("model_name", db_model.model_name)
_validate_provider_model_name(effective_provider, effective_model_name)
effective_category = update_data.get("category", db_model.category)
incompatible_with_auto = effective_category != "llm" or is_auto_router_model(
effective_provider, effective_model_name
identity_changed = any(
field in update_data and update_data[field] != getattr(db_model, field)
for field in ("model_provider", "model_name", "base_url")
)
incompatible_with_auto = (
identity_changed
or effective_category != "llm"
or is_auto_router_model(effective_provider, effective_model_name)
)
if incompatible_with_auto:
own_auto_reference = (
Expand All @@ -2051,13 +2057,37 @@ async def update_model(
if own_auto_reference is not None:
raise HTTPException(
409,
detail="Cannot change this model into a non-LLM or Auto model while an Auto configuration uses it.",
detail="Remove this model from your Auto configuration before changing its identity or category.",
)
model_store.prune_external_auto_references(
model_id=int(db_model.id),
owner_user_id=int(user.id),
)

auto_candidates = (
db.query(AutoModelCandidate)
.filter(AutoModelCandidate.target_model_id == db_model.id)
.all()
)
if auto_candidates and "abilities" in update_data:
from ..services.auto_model_service import (
load_router_profile_catalog,
validate_candidate_modalities,
)
Comment thread
rogercloud marked this conversation as resolved.

try:
catalog = load_router_profile_catalog()
for candidate in auto_candidates:
validate_candidate_modalities(
catalog,
str(candidate.routing_model_id),
update_data["abilities"] or [],
)
except AutoModelConfigurationError as exc:
raise HTTPException(409, detail=str(exc)) from exc
except AutoModelDependencyError as exc:
raise HTTPException(503, detail=str(exc)) from exc

for field, value in update_data.items():
# Don't update api_key with empty string
if field == "api_key" and value == "":
Expand All @@ -2069,6 +2099,11 @@ async def update_model(
if hasattr(db_model, field):
setattr(db_model, field, value)

if auto_candidates:
model_store.refresh_auto_model_abilities(
[int(candidate.config_id) for candidate in auto_candidates]
)

if share_with_users is not None:
try:
model_store.set_model_sharing(
Expand Down
4 changes: 4 additions & 0 deletions src/xagent/web/api/v1/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
from fastapi import Request
from fastapi.responses import JSONResponse

from ...services.client_error_messages import CLIENT_SAFE_AUTO_MODEL_UNAVAILABLE


class V1ErrorCode(str, Enum):
"""Stable error codes for ``/v1/*`` responses.
Expand Down Expand Up @@ -110,6 +112,7 @@ class V1ErrorCode(str, Enum):
# Server-side bug. Detail is sanitized; the raw exception stays in
# the server log.
INTERNAL_ERROR = "internal_error"
AUTO_MODEL_UNAVAILABLE = "auto_model_unavailable"

CONNECTOR_NOT_FOUND = "connector_not_found"
INVALID_RUNTIME_CONTEXT = "invalid_runtime_context"
Expand Down Expand Up @@ -178,6 +181,7 @@ class V1ErrorCode(str, Enum):
"Monthly execution quota exceeded for this client application."
),
V1ErrorCode.INTERNAL_ERROR: "Internal server error.",
V1ErrorCode.AUTO_MODEL_UNAVAILABLE: CLIENT_SAFE_AUTO_MODEL_UNAVAILABLE,
V1ErrorCode.CONNECTOR_NOT_FOUND: "Connector not found or not accessible.",
V1ErrorCode.INVALID_RUNTIME_CONTEXT: "Invalid connector runtime context.",
V1ErrorCode.MISSING_RUNTIME_CONTEXT: "Required connector runtime context is missing.",
Expand Down
5 changes: 4 additions & 1 deletion src/xagent/web/api/v1/task_reply.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
drain_async_task_cancellation_safe,
run_db_io_cancellation_safe,
)
from ...services.llm_utils import AutoModelUnavailableError
from ...services.task_execution_controller import TaskControlState
from ...services.task_interaction_close import (
active_interaction_id_sync,
Expand Down Expand Up @@ -599,10 +600,12 @@ async def inject_user_message() -> tuple[Any, bool]:
# failure, so an unrecognized failure mode never silently
# collapses into a data-losing branch.
raise V1ApiError(V1ErrorCode.TEMPORARILY_UNAVAILABLE, 503) from exc
except BaseException:
except BaseException as exc:
if not ownership_transferred and not prelease_cleanup_done:
cleanup_task = asyncio.create_task(stop_and_restore_prelease())
await drain_async_task_cancellation_safe(cleanup_task)
if isinstance(exc, AutoModelUnavailableError):
raise V1ApiError(V1ErrorCode.AUTO_MODEL_UNAVAILABLE, 409) from exc
raise
finally:
if not ownership_transferred and not prelease_cleanup_done:
Expand Down
21 changes: 18 additions & 3 deletions src/xagent/web/api/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ def create_terminal_task_error_event(
failure this path exists to remove. A bad optional argument costs that
argument and nothing else. The rejection is logged with its stack.

``code`` must be a member of ``CONNECTOR_RUNTIME_CLIENT_ERROR_CODES``.
``code`` must be a connector-runtime code or ``AUTO_MODEL_UNAVAILABLE``.
"""

# Python annotations are not enforced at run time, so the mypy gate on the
Expand All @@ -370,7 +370,11 @@ def create_terminal_task_error_event(
# and an unhashable value would raise inside the membership test on a
# path whose whole point is that it never raises.
if code is not None and (
not isinstance(code, str) or code not in CONNECTOR_RUNTIME_CLIENT_ERROR_CODES
not isinstance(code, str)
or (
code not in CONNECTOR_RUNTIME_CLIENT_ERROR_CODES
and code != ClientErrorCode.AUTO_MODEL_UNAVAILABLE.value
)
):
logger.error(
"task_id=%s component=terminal-error-frame dropped=code "
Expand Down Expand Up @@ -473,6 +477,8 @@ def client_safe_error_message(
Read a passing sweep as "the recognized egress shapes are clean", never
as "arbitrary Python data flow cannot reach a client raw".
"""
if isinstance(error, AutoModelUnavailableError):
Comment thread
qinxuye marked this conversation as resolved.
return client_error_message(ClientErrorCode.AUTO_MODEL_UNAVAILABLE)
if not isinstance(error, ClientVisibleError):
return fallback
message = str(error)
Expand Down Expand Up @@ -10514,8 +10520,17 @@ async def send_builder_outbound_message(payload: Dict[str, Any]) -> None:

except Exception as e:
logger.error("Error handling builder chat: %s", e, exc_info=True)
error_metadata = {}
if isinstance(e, AutoModelUnavailableError):
error_metadata["error_code"] = ClientErrorCode.AUTO_MODEL_UNAVAILABLE.value
await websocket.send_text(
json.dumps({"type": "error", "message": client_safe_error_message(e)})
json.dumps(
{
**error_metadata,
"type": "error",
"message": client_safe_error_message(e),
}
)
)


Expand Down
7 changes: 6 additions & 1 deletion src/xagent/web/api/workforces.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@
get_agent_team_scope,
owns_agent,
)
from ..services.client_error_messages import CLIENT_SAFE_AUTO_MODEL_UNAVAILABLE
from ..services.deployments import (
get_deployment,
get_or_create_deployment,
new_share_token,
new_widget_key,
)
from ..services.llm_utils import AutoModelUnavailableError
from ..services.trace_event_types import GENERAL_ERROR_EVENT_TYPES
from ..services.trace_message_storage import decode_trace_events_data
from ..services.triggers import unregister_deleted_trigger_bindings
Expand Down Expand Up @@ -622,7 +624,10 @@ async def create_workforce_from_prompt_endpoint(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
result = await create_workforce_from_prompt(db, user, prompt=request.prompt)
try:
result = await create_workforce_from_prompt(db, user, prompt=request.prompt)
except AutoModelUnavailableError as exc:
raise HTTPException(409, detail=CLIENT_SAFE_AUTO_MODEL_UNAVAILABLE) from exc
workforce = _reload_workforce(db, result.workforce)
return _serialize_workforce_detail(
workforce, user, get_agent_team_scope(db, int(user.id))
Expand Down
7 changes: 6 additions & 1 deletion src/xagent/web/channels/feishu/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@
register_channel_uploaded_files,
update_channel_task_fields,
)
from ...services.client_error_messages import CLIENT_SAFE_AUTO_MODEL_UNAVAILABLE
from ...services.db_runtime import (
cancel_and_drain_async_task,
drain_async_task_cancellation_safe,
run_db_io_cancellation_safe,
)
from ...services.execution_result_projection import project_execution_result_for_channel
from ...services.file_turn import normalize_attachments_for_persistence
from ...services.llm_utils import AutoModelUnavailableError
from ...services.managed_task_lease import ManagedTaskLease
from ...services.task_execution_context_service import (
materialize_task_execution_recovery_state,
Expand Down Expand Up @@ -429,7 +431,10 @@ async def _process_messages_batch(
)
return
await self._send_text(
chat_id, "Sorry, an error occurred while processing your request."
chat_id,
CLIENT_SAFE_AUTO_MODEL_UNAVAILABLE
if isinstance(e, AutoModelUnavailableError)
else "Sorry, an error occurred while processing your request.",
)
finally:
if managed_lease is not None:
Expand Down
6 changes: 5 additions & 1 deletion src/xagent/web/channels/slack/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
register_channel_uploaded_files,
update_channel_task_fields,
)
from ...services.client_error_messages import CLIENT_SAFE_AUTO_MODEL_UNAVAILABLE
from ...services.db_runtime import (
cancel_and_drain_async_task,
drain_async_task_cancellation_safe,
Expand All @@ -47,6 +48,7 @@
build_uploaded_files_context,
normalize_attachments_for_persistence,
)
from ...services.llm_utils import AutoModelUnavailableError
from ...services.managed_task_lease import ManagedTaskLease
from ...services.task_execution_context_service import (
materialize_task_execution_recovery_state,
Expand Down Expand Up @@ -672,7 +674,9 @@ async def _process_event(
claimed_task_id,
)
return
if isinstance(error, SlackFileDownloadError):
if isinstance(error, AutoModelUnavailableError):
error_text = CLIENT_SAFE_AUTO_MODEL_UNAVAILABLE
elif isinstance(error, SlackFileDownloadError):
error_text = (
"I couldn't download the attached Slack file(s). "
"Please try uploading them again."
Expand Down
Loading