Skip to content
Closed
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: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,9 @@ npm run dev

# In a separate terminal, start the backend server
cd eigent/server
docker compose up
docker compose up -d
# Stream the logs if you needed
docker compose logs -f
```

To run the application locally in developer mode:
Expand Down
27 changes: 27 additions & 0 deletions backend/app/service/chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
TaskLock,
delete_task_lock,
set_current_task_id,
validate_model_before_task,
)
from app.utils.event_loop_utils import set_main_event_loop
from app.utils.file_utils import get_working_directory
Expand Down Expand Up @@ -317,6 +318,32 @@ def build_context_for_workforce(task_lock: TaskLock, options: Chat) -> str:

@sync_step
async def step_solve(options: Chat, request: Request, task_lock: TaskLock):
"""Main task execution loop. Called when POST /chat endpoint
is hit to start a new chat session.

Validates model configuration, processes task queue, manages
workforce lifecycle, and streams responses back to the client
via SSE.

Args:
options (Chat): Chat configuration containing task details and
model settings.
request (Request): FastAPI request object for client connection
management.
task_lock (TaskLock): Shared task state and queue for the project.

Yields:
SSE formatted responses for task progress, errors, and results
"""
# Validate model configuration before starting task
is_valid, error_msg = await validate_model_before_task(options)
if not is_valid:
yield sse_json(
"error", {"message": f"Model validation failed: {error_msg}"}
)
task_lock.status = Status.done
return

start_event_loop = True

# Initialize task_lock attributes
Expand Down
51 changes: 51 additions & 0 deletions backend/app/service/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@
from pydantic import BaseModel
from typing_extensions import TypedDict

from app.component.model_validation import create_agent
from app.exception.exception import ProgramException
from app.model.chat import (
AgentModelConfig,
Chat,
McpServers,
SupplementChat,
UpdateData,
Expand Down Expand Up @@ -674,3 +676,52 @@ def set_process_task(process_task_id: str):
yield
finally:
process_task.reset(origin)


async def validate_model_before_task(options: Chat) -> tuple[bool, str | None]:
"""
Validate model configuration before starting a task.
Makes a simple test request to ensure the API key and model are valid.

Args:
options (Chat): Chat options containing model configuration.

Returns:
(is_valid, error_message)
- is_valid: True if validation passed
- error_message: Raw error message if validation failed,
None otherwise
"""
try:
logger.info(
f"Validating model configuration for task {options.task_id}"
)

# Create test agent with same config as task will use
agent = create_agent(
model_platform=options.model_platform,
model_type=options.model_type,
api_key=options.api_key,
url=options.api_url,
model_config_dict=options.model_config,
)

# Make a simple test call in executor to avoid blocking
loop = asyncio.get_event_loop()
Comment on lines +709 to +710
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we change to get_running_loop()? Since get_event_loop() is about deprecation

await loop.run_in_executor(None, lambda: agent.step("test"))

Comment on lines +711 to +712
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should we also add a timeout in external? like:

try:
      await asyncio.wait_for(
          loop.run_in_executor(None, lambda: agent.step("test")),
          timeout=30.0
      )
  except asyncio.TimeoutError:
      return False, "timed out"

logger.info(f"Model validation passed for task {options.task_id}")
return True, None

except Exception as e:
error_msg = str(e)
logger.error(
f"Model validation failed for task {options.task_id}: {error_msg}",
extra={
"project_id": options.project_id,
"task_id": options.task_id,
"error": error_msg,
},
exc_info=True,
)
return False, error_msg
176 changes: 176 additions & 0 deletions backend/tests/app/service/test_task_validate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"""
Unit tests for validate_model_before_task function.

TODO: Rename this file to test_task.py after fixing errors
in backend/tests/unit/service/test_task.py
"""

from unittest.mock import Mock, patch

import pytest
from camel.types import ModelPlatformType

from app.model.chat import Chat
from app.service.task import validate_model_before_task

# Test data constants
TEST_PROJECT_ID = "test_project"
TEST_TASK_ID = "test_task_123"
TEST_QUESTION = "Test question"
TEST_EMAIL = "test@example.com"
TEST_MODEL_PLATFORM = ModelPlatformType.OPENAI
TEST_MODEL_TYPE = "gpt-4o"
TEST_API_URL = "https://api.openai.com/v1"
TEST_VALID_API_KEY = "sk-valid-key"
TEST_INVALID_API_KEY = "sk-invalid-key"


@pytest.mark.asyncio
async def test_validate_model_success():
"""Test successful model validation."""
options = Chat(
project_id=TEST_PROJECT_ID,
task_id=TEST_TASK_ID,
question=TEST_QUESTION,
email=TEST_EMAIL,
model_platform=TEST_MODEL_PLATFORM,
model_type=TEST_MODEL_TYPE,
api_key=TEST_VALID_API_KEY,
api_url=TEST_API_URL,
model_config={},
)

# Mock the create_agent and agent.step
mock_agent = Mock()
mock_agent.step = Mock(return_value="test response")

with patch("app.service.task.create_agent", return_value=mock_agent):
is_valid, error_msg = await validate_model_before_task(options)

assert is_valid is True
assert error_msg is None


@pytest.mark.asyncio
async def test_validate_model_invalid_api_key():
"""Test model validation with invalid API key."""
options = Chat(
project_id=TEST_PROJECT_ID,
task_id=TEST_TASK_ID,
question=TEST_QUESTION,
email=TEST_EMAIL,
model_platform=TEST_MODEL_PLATFORM,
model_type=TEST_MODEL_TYPE,
api_key=TEST_INVALID_API_KEY,
api_url=TEST_API_URL,
model_config={},
)

# Mock the create_agent to raise authentication error
with patch("app.service.task.create_agent") as mock_create:
mock_agent = Mock()
mock_agent.step = Mock(
side_effect=Exception("Error code: 401 - Invalid API key")
)
mock_create.return_value = mock_agent

is_valid, error_msg = await validate_model_before_task(options)

assert is_valid is False
assert error_msg is not None
assert "401" in error_msg or "Invalid API key" in error_msg


@pytest.mark.asyncio
async def test_validate_model_network_error():
"""Test model validation with network error."""
options = Chat(
project_id=TEST_PROJECT_ID,
task_id=TEST_TASK_ID,
question=TEST_QUESTION,
email=TEST_EMAIL,
model_platform=TEST_MODEL_PLATFORM,
model_type=TEST_MODEL_TYPE,
api_key=TEST_VALID_API_KEY,
api_url="https://invalid-url.com",
model_config={},
)

# Mock the create_agent to raise network error
with patch("app.service.task.create_agent") as mock_create:
mock_agent = Mock()
mock_agent.step = Mock(side_effect=Exception("Connection error"))
mock_create.return_value = mock_agent

is_valid, error_msg = await validate_model_before_task(options)

assert is_valid is False
assert error_msg is not None
assert "Connection error" in error_msg


@pytest.mark.asyncio
async def test_validate_model_with_custom_config():
"""Test model validation with custom model configuration."""
custom_config = {"temperature": 0.7, "max_tokens": 1000}

options = Chat(
project_id=TEST_PROJECT_ID,
task_id=TEST_TASK_ID,
question=TEST_QUESTION,
email=TEST_EMAIL,
model_platform=TEST_MODEL_PLATFORM,
model_type=TEST_MODEL_TYPE,
api_key=TEST_VALID_API_KEY,
api_url=TEST_API_URL,
model_config=custom_config,
)

mock_agent = Mock()
mock_agent.step = Mock(return_value="test response")

with patch(
"app.service.task.create_agent", return_value=mock_agent
) as mock_create:
is_valid, error_msg = await validate_model_before_task(options)

# Verify create_agent was called
mock_create.assert_called_once()
call_args = mock_create.call_args
assert call_args.kwargs["model_platform"] == options.model_platform
assert call_args.kwargs["model_type"] == options.model_type
assert call_args.kwargs["api_key"] == options.api_key
assert call_args.kwargs["url"] == options.api_url

assert is_valid is True
assert error_msg is None


@pytest.mark.asyncio
async def test_validate_model_rate_limit_error():
"""Test model validation with rate limit error."""
options = Chat(
project_id=TEST_PROJECT_ID,
task_id=TEST_TASK_ID,
question=TEST_QUESTION,
email=TEST_EMAIL,
model_platform=TEST_MODEL_PLATFORM,
model_type=TEST_MODEL_TYPE,
api_key=TEST_VALID_API_KEY,
api_url=TEST_API_URL,
model_config={},
)

# Mock the create_agent to raise rate limit error
with patch("app.service.task.create_agent") as mock_create:
mock_agent = Mock()
mock_agent.step = Mock(
side_effect=Exception("Error code: 429 - Rate limit exceeded")
Comment on lines +167 to +168
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should we treat rate limit as invalid signal? I think rate limit is temporal

)
mock_create.return_value = mock_agent

is_valid, error_msg = await validate_model_before_task(options)

assert is_valid is False
assert error_msg is not None
assert "429" in error_msg or "Rate limit" in error_msg