eigent/backend/app/agent/agent_model.py

393 lines
15 KiB
Python

# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import logging
import uuid
from collections.abc import Callable
from typing import Any
from camel.messages import BaseMessage
from camel.models import ModelFactory
from camel.toolkits import FunctionTool, RegisteredAgentToolkit
from camel.types import ModelPlatformType
from app.agent.listen_chat_agent import ListenChatAgent, logger
from app.model.chat import AgentModelConfig, Chat
from app.model.model_platform import (
azure_reasoning_tools_require_responses_api,
is_eigent_cloud_model_endpoint,
patch_azure_cloud_config,
patch_bedrock_cloud_config,
resolve_cloud_model_runtime_platform,
)
from app.model.subscription_runtime import (
apply_subscription_runtime,
is_subscription_auth,
)
from app.run_journal.model_capture import instrument_model_backend
from app.service.task import ActionCreateAgentData, Agents, get_task_lock
from app.utils.event_loop_utils import _schedule_async_task
# OpenAI chat-completions streaming only returns token usage when
# `stream_options.include_usage` is requested. Without it the request-level
# usage callback (on_request_usage) fires with 0 tokens, and because the
# step-level deactivate is zeroed once request-level reporting is active,
# streaming steps end up uncounted. These platforms use native (non-OpenAI)
# SDKs that reject `stream_options` and surface streaming usage on their own,
# so they are excluded from the injection below.
_NATIVE_STREAM_USAGE_PLATFORMS = {
"anthropic",
"aws-bedrock",
"aws-bedrock-converse",
"cohere",
"mistral",
"reka",
"watsonx",
}
def agent_model(
agent_name: str,
system_message: str | BaseMessage,
options: Chat,
tools: list[FunctionTool | Callable] | None = None,
prune_tool_calls_from_memory: bool = False,
tool_names: list[str] | None = None,
toolkits_to_register_agent: list[RegisteredAgentToolkit] | None = None,
enable_snapshot_clean: bool = False,
custom_model_config: AgentModelConfig | None = None,
):
task_lock = get_task_lock(options.project_id)
agent_id = str(uuid.uuid4())
logger.info(
f"Creating agent: {agent_name} with id: {agent_id} "
f"for project: {options.project_id}"
)
# Use thread-safe scheduling to support parallel agent creation
_schedule_async_task(
task_lock.put_queue(
ActionCreateAgentData(
data={
"agent_name": agent_name,
"agent_id": agent_id,
"tools": tool_names or [],
}
)
)
)
# Determine model configuration - use custom config if provided,
# otherwise use task defaults
config_attrs = ["model_platform", "model_type", "api_key", "api_url"]
effective_config = {}
if custom_model_config and custom_model_config.has_custom_config():
for attr in config_attrs:
custom_value = getattr(custom_model_config, attr, None)
effective_config[attr] = (
custom_value
if custom_value is not None
else getattr(options, attr)
)
extra_params = (
custom_model_config.extra_params
if custom_model_config.extra_params is not None
else options.extra_params or {}
)
explicit_model_config = (
custom_model_config.model_config_dict
if custom_model_config.model_config_dict is not None
else options.model_config_dict or {}
)
logger.info(
f"Agent {agent_name} using custom model config: "
f"platform={effective_config['model_platform']}, "
f"type={effective_config['model_type']}"
)
else:
for attr in config_attrs:
effective_config[attr] = getattr(options, attr)
extra_params = options.extra_params or {}
explicit_model_config = options.model_config_dict or {}
has_explicit_custom_api_key = (
custom_model_config is not None
and custom_model_config.has_custom_config()
and custom_model_config.api_key is not None
)
use_subscription_runtime = (
is_subscription_auth(options) and not has_explicit_custom_api_key
)
base_effective_config = dict(effective_config)
base_extra_params = dict(extra_params or {})
base_model_config = dict(explicit_model_config or {})
def build_model(force_refresh: bool = False):
effective_config = dict(base_effective_config)
extra_params = dict(base_extra_params)
explicit_model_config = dict(base_model_config)
if use_subscription_runtime:
effective_config, extra_params = apply_subscription_runtime(
options,
effective_config,
extra_params,
force_refresh=force_refresh,
)
effective_api_url = effective_config.get("api_url")
is_effective_cloud = is_eigent_cloud_model_endpoint(effective_api_url)
# Cloud mode: inject default Bedrock region and adjust URL for proxy.
if (
effective_config.get("model_platform") == "aws-bedrock-converse"
and is_effective_cloud
):
(
effective_config["api_url"],
extra_params,
) = patch_bedrock_cloud_config(
effective_config["api_url"], extra_params
)
# Cloud mode: default api_version for Azure-backed models so AzureOpenAI
# construction does not blow up when the frontend omits extra_params.
if (
effective_config.get("model_platform") == "azure"
and is_effective_cloud
):
extra_params = patch_azure_cloud_config(extra_params)
init_param_keys = {
"api_version",
"azure_ad_token",
"azure_ad_token_provider",
"max_retries",
"timeout",
"client",
"async_client",
"azure_deployment_name",
"region_name",
"aws_access_key_id",
"aws_secret_access_key",
"aws_session_token",
"default_headers",
"api_mode",
}
init_params = {}
model_config: dict[str, Any] = {}
# A nested model_config_dict may arrive inside legacy extra_params
# while stored providers migrate to the explicit top-level field.
# Treat it as less specific than the explicit request field.
nested_model_config = extra_params.pop("model_config_dict", None)
excluded_keys = {"model_platform", "model_type", "api_key", "url"}
# Distribute extra_params between init_params and model_config
for k, v in extra_params.items():
if k in excluded_keys:
continue
# Skip empty values
if v is None or (isinstance(v, str) and not v.strip()):
continue
if k in init_param_keys:
init_params[k] = v
else:
model_config[k] = v
if isinstance(nested_model_config, dict):
model_config.update(nested_model_config)
# The explicit model config is the canonical API and wins over legacy
# flat values from extra_params.
model_config.update(explicit_model_config)
# Auto-inject prompt caching based on model platform
try:
model_platform_enum = ModelPlatformType(
effective_config["model_platform"].lower()
)
if model_platform_enum in {
ModelPlatformType.ANTHROPIC,
ModelPlatformType.AWS_BEDROCK_CONVERSE,
}:
model_config.setdefault("cache_control", "5m")
elif model_platform_enum == ModelPlatformType.OPENAI:
model_config.setdefault(
"prompt_cache_key", str(options.project_id)
)
except (ValueError, AttributeError):
logging.error(
f"Invalid model platform: "
f"{effective_config['model_platform']}",
exc_info=True,
)
# Runtime-owned values are applied after user configuration.
if not (
custom_model_config and custom_model_config.has_custom_config()
):
effort_parameter = getattr(
task_lock,
"provider_effort_parameter_name",
None,
)
effort_value = getattr(
task_lock,
"provider_effort_parameter_value",
None,
)
if (
isinstance(effort_parameter, str)
and effort_parameter
and isinstance(effort_value, str)
and effort_value != "provider_default"
):
model_config[effort_parameter] = effort_value
# Eigent Cloud transports are declared by the server-owned model
# catalog. This lets future models select Responses without a Desktop
# release. User-managed Azure GPT-5.6 endpoints retain the model-family
# fallback because they do not have server capability metadata.
has_function_tools = bool(
tools or tool_names or toolkits_to_register_agent
)
uses_responses_transport = init_params.get("api_mode") == "responses"
should_use_azure_responses_fallback = (
not is_effective_cloud
and has_function_tools
and model_config.get("reasoning_effort")
not in {None, "", "provider_default"}
and azure_reasoning_tools_require_responses_api(
model_platform=str(effective_config["model_platform"]),
model_type=str(effective_config["model_type"]),
)
)
if should_use_azure_responses_fallback:
init_params["api_mode"] = "responses"
uses_responses_transport = True
if uses_responses_transport and model_config.get(
"reasoning_effort"
) not in {None, "", "provider_default"}:
reasoning_effort = model_config.pop("reasoning_effort")
model_config["reasoning"] = {"effort": reasoning_effort}
logger.info(
"Using Responses API reasoning for model %s",
effective_config["model_type"],
)
runtime_model_platform = resolve_cloud_model_runtime_platform(
model_platform=str(effective_config["model_platform"]),
api_url=effective_api_url,
api_mode=init_params.get("api_mode"),
)
if runtime_model_platform != effective_config["model_platform"]:
# These options belong to AzureOpenAI's constructor. Passing
# them to AsyncOpenAI would fail before the request is sent.
for azure_only_param in (
"api_version",
"azure_ad_token",
"azure_ad_token_provider",
"azure_deployment_name",
):
init_params.pop(azure_only_param, None)
if is_effective_cloud:
model_config["user"] = str(options.project_id)
if use_subscription_runtime:
model_config["stream"] = True
model_config["store"] = False
if agent_name == Agents.task_agent:
model_config["stream"] = True
if agent_name == Agents.browser_agent:
try:
model_platform_enum = ModelPlatformType(
effective_config["model_platform"].lower()
)
if model_platform_enum in {
ModelPlatformType.OPENAI,
ModelPlatformType.AZURE,
ModelPlatformType.OPENAI_COMPATIBLE_MODEL,
ModelPlatformType.LITELLM,
ModelPlatformType.OPENROUTER,
}:
model_config["parallel_tool_calls"] = False
except (ValueError, AttributeError):
logging.error(
f"Invalid model platform for browser agent: "
f"{effective_config['model_platform']}",
exc_info=True,
)
model_platform_enum = None
if effective_config["model_platform"].lower() == "anthropic":
if model_config.get("max_tokens") is None:
model_config["max_tokens"] = 128000
# Ensure streaming steps still report token usage. OpenAI-family
# providers omit usage from streamed responses unless include_usage
# is set, which would otherwise make request-level accounting count 0.
# `stream_options: false` in extra_params opts out entirely, for
# endpoints that reject the parameter (e.g. older vLLM/Azure).
if uses_responses_transport:
# stream_options belongs to Chat Completions. Azure Responses
# rejects it, including when a caller supplied it explicitly.
model_config.pop("stream_options", None)
elif model_config.get("stream_options") is False:
model_config.pop("stream_options")
elif model_config.get("stream") and (
effective_config["model_platform"].lower()
not in _NATIVE_STREAM_USAGE_PLATFORMS
):
stream_options = model_config.setdefault("stream_options", {})
if isinstance(stream_options, dict):
stream_options.setdefault("include_usage", True)
model_backend = ModelFactory.create(
model_platform=runtime_model_platform,
model_type=effective_config["model_type"],
api_key=effective_config["api_key"],
url=effective_config["api_url"],
model_config_dict=model_config or None,
timeout=600, # 10 minutes
**init_params,
)
return instrument_model_backend(
model_backend,
agent_id=agent_id,
provider=str(effective_config["model_platform"]),
model_name=str(effective_config["model_type"]),
)
model = build_model()
return ListenChatAgent(
options.project_id,
agent_name,
system_message,
model=model,
tools=tools,
agent_id=agent_id,
prune_tool_calls_from_memory=prune_tool_calls_from_memory,
toolkits_to_register_agent=toolkits_to_register_agent,
enable_snapshot_clean=enable_snapshot_clean,
model_reload_callback=(
(lambda: build_model(force_refresh=True))
if use_subscription_runtime
else None
),
stream_accumulate=False,
)