Repository: agno-agi/agno
Pull request: Stateless models
## Description

- Make the models stateless
- [Notes here](https://www.notion.so/Models-refactor-18d733793ba280a99069d7104f9813a3?pvs=4)

Fixes: #2054 
---

## Type of change

Please check the options that are relevant:

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [x] Model update (Addition or modification of models)
- [ ] Other (please describe):


Problem statement:
[Bug]
# Description
Re-using a model across multiple agents, each with different a `response_model` causes the first agent's `response_model` to persist to the following agents.

## Steps to Reproduce
```python
from agno.agent import Agent
from pydantic import BaseModel

model_client = OpenAIChat(id="gpt-4o")

class AgentResponseOne(BaseModel):
    hello: str

agent_one = Agent(
    name="HelloWorldAgent",
    description="Returns hello world in a structured output",
    model=model_client,
    response_model=AgentResponseOne,
    structured_outputs=True,
)

response = agent_one.run("Return hello world")
print(response.content)

class AgentResponseTwo(BaseModel):
    foo: str

agent_two = Agent(
    name="FooBarAgent",
    description="Returns foo bar in a structured output",
    model=model_client,
    response_model=AgentResponseTwo,
    structured_outputs=True,
)

response = agent_two.run("Return foo bar")
print(response.content)
```

## Expected Behavior
Each agent would use its assigned `response_model`.

## Actual Behavior
The second agent re-used the first agent's `response_model`

## Screenshots or Logs (if applicable)
```log
WARNING  Something went wrong. Run response content is not a string                                                
hello='foo bar'
```

## Environment
- OS: macOS
- Agno Version: 1.0.8
- External Dependency Versions: None
- Additional Environment Details: Python 3.13

## Possible Solutions (optional)
I believe the issue stems from `and self.model.response_format is None` preventing `self.model.response_format` from being re-set.

I managed to hack a fix for this by changing this code in `agno/agent/agent.py` on line `1357`

```python
        if self.response_model is not None and self.model.response_format is None:
            if self.structured_outputs and self.model.supports_structured_outputs:
                logger.debug("Setting Model.response_format to Agent.response_model")
                self.model.response_format = self.response_model
                self.model.structured_outputs = True
            else:
                self.model.response_format = {"type": "json_object"}
```

To this:

```python
        if self.response_model is not None:
            if self.structured_outputs and self.model.supports_structured_outputs:
                logger.debug("Setting Model.response_format to Agent.response_model")
                self.model.response_format = self.response_model
                self.model.structured_outputs = True
            else:
                self.model.response_format = {"type": "json_object"}
        else:
            self.model.response_format = None
```

## Additional Context
I have not checked to see if this "fix" impacts any other functionality.

Hints from issue discussion:

Commit message: Use SessionMetrics

Patch to review:
diff --git a/cookbook/agent_concepts/other/agent_metrics.py b/cookbook/agent_concepts/other/agent_metrics.py
index 69cda42ea7..185e95fa36 100644
--- a/cookbook/agent_concepts/other/agent_metrics.py
+++ b/cookbook/agent_concepts/other/agent_metrics.py
@@ -33,3 +33,6 @@
 # Print the metrics
 print("---" * 5, "Aggregated Metrics", "---" * 5)
 pprint(agent.run_response.metrics)
+# Print the session metrics
+print("---" * 5, "Session Metrics", "---" * 5)
+pprint(agent.session_metrics)
diff --git a/cookbook/examples/apps/agentic_rag/app.py b/cookbook/examples/apps/agentic_rag/app.py
index 943551819f..f7f7cd0226 100644
--- a/cookbook/examples/apps/agentic_rag/app.py
+++ b/cookbook/examples/apps/agentic_rag/app.py
@@ -18,7 +18,6 @@
     about_widget,
     add_message,
     display_tool_calls,
-
     export_chat_history,
     rename_session_widget,
     session_selector_widget,
diff --git a/cookbook/examples/apps/agentic_rag/utils.py b/cookbook/examples/apps/agentic_rag/utils.py
index e37fb012e8..3822ab301f 100644
--- a/cookbook/examples/apps/agentic_rag/utils.py
+++ b/cookbook/examples/apps/agentic_rag/utils.py
@@ -138,6 +138,7 @@ def session_selector_widget(agent: Agent, model_id: str) -> None:
             )
             st.rerun()
 
+
 def about_widget() -> None:
     """Display an about section in the sidebar"""
     st.sidebar.markdown("---")
diff --git a/cookbook/models/groq/metrics.py b/cookbook/models/groq/metrics.py
new file mode 100644
index 0000000000..eb86f67a27
--- /dev/null
+++ b/cookbook/models/groq/metrics.py
@@ -0,0 +1,38 @@
+from typing import Iterator
+
+from agno.agent import Agent, RunResponse
+from agno.models.groq import Groq
+from agno.tools.yfinance import YFinanceTools
+from agno.utils.pprint import pprint_run_response
+from rich.pretty import pprint
+
+agent = Agent(
+    model=Groq(id="llama-3.3-70b-versatile"),
+    tools=[YFinanceTools(stock_price=True)],
+    markdown=True,
+    show_tool_calls=True,
+)
+
+run_stream: Iterator[RunResponse] = agent.run(
+    "What is the stock price of NVDA", stream=True
+)
+pprint_run_response(run_stream, markdown=True)
+
+# Print metrics per message
+if agent.run_response.messages:
+    for message in agent.run_response.messages:
+        if message.role == "assistant":
+            if message.content:
+                print(f"Message: {message.content}")
+            elif message.tool_calls:
+                print(f"Tool calls: {message.tool_calls}")
+            print("---" * 5, "Metrics", "---" * 5)
+            pprint(message.metrics)
+            print("---" * 20)
+
+# Print the metrics
+print("---" * 5, "Collected Metrics", "---" * 5)
+pprint(agent.run_response.metrics)
+# Print the session metrics
+print("---" * 5, "Session Metrics", "---" * 5)
+pprint(agent.session_metrics)
diff --git a/cookbook/models/openai/metrics.py b/cookbook/models/openai/metrics.py
new file mode 100644
index 0000000000..3904c61406
--- /dev/null
+++ b/cookbook/models/openai/metrics.py
@@ -0,0 +1,38 @@
+from typing import Iterator
+
+from agno.agent import Agent, RunResponse
+from agno.models.openai import OpenAIChat
+from agno.tools.yfinance import YFinanceTools
+from agno.utils.pprint import pprint_run_response
+from rich.pretty import pprint
+
+agent = Agent(
+    model=OpenAIChat(id="gpt-4o"),
+    tools=[YFinanceTools(stock_price=True)],
+    markdown=True,
+    show_tool_calls=True,
+)
+
+run_stream: Iterator[RunResponse] = agent.run(
+    "What is the stock price of NVDA", stream=True
+)
+pprint_run_response(run_stream, markdown=True)
+
+# Print metrics per message
+if agent.run_response.messages:
+    for message in agent.run_response.messages:
+        if message.role == "assistant":
+            if message.content:
+                print(f"Message: {message.content}")
+            elif message.tool_calls:
+                print(f"Tool calls: {message.tool_calls}")
+            print("---" * 5, "Metrics", "---" * 5)
+            pprint(message.metrics)
+            print("---" * 20)
+
+# Print the metrics
+print("---" * 5, "Collected Metrics", "---" * 5)
+pprint(agent.run_response.metrics)
+# Print the session metrics
+print("---" * 5, "Session Metrics", "---" * 5)
+pprint(agent.session_metrics)
diff --git a/cookbook/tools/x_tools.py b/cookbook/tools/x_tools.py
index 96c0077173..2af308ed79 100644
--- a/cookbook/tools/x_tools.py
+++ b/cookbook/tools/x_tools.py
@@ -52,7 +52,9 @@
 )
 
 # Example usage: Get your details
-agent.print_response("Can you return my x profile with my home timeline?", markdown=True)
+agent.print_response(
+    "Can you return my x profile with my home timeline?", markdown=True
+)
 
 # # Example usage: Get information about a user
 # agent.print_response(
@@ -74,5 +76,3 @@
 
 # # Example usage: Create a new post
 # agent.print_response("Create & post content about how 2025 is the year of the AI agent", markdown=True)
-
-
diff --git a/libs/agno/agno/agent/agent.py b/libs/agno/agno/agent/agent.py
index 170ccbfd1f..e4eecec732 100644
--- a/libs/agno/agno/agent/agent.py
+++ b/libs/agno/agno/agent/agent.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
 from collections import ChainMap, defaultdict, deque
-from dataclasses import dataclass
+from dataclasses import asdict, dataclass
 from os import getenv
 from textwrap import dedent
 from typing import (
@@ -23,6 +23,7 @@
 
 from pydantic import BaseModel
 
+from agno.agent.metrics import SessionMetrics
 from agno.exceptions import AgentRunException, StopAgentRun
 from agno.knowledge.agent import AgentKnowledge
 from agno.media import Audio, AudioArtifact, Image, ImageArtifact, Video, VideoArtifact
@@ -64,7 +65,7 @@ class Agent:
     session_id: Optional[str] = None
     # Session name
     session_name: Optional[str] = None
-    # Session state stored in the database
+    # Session state (stored in the database to persist across runs)
     session_state: Optional[Dict[str, Any]] = None
 
     # --- Agent Context ---
@@ -227,22 +228,6 @@ class Agent:
     # This helps us improve the Agent and provide better support
     telemetry: bool = True
 
-    # --- Run Info: DO NOT SET ---
-    run_id: Optional[str] = None
-    run_input: Optional[Union[str, List, Dict, Message]] = None
-    run_messages: Optional[RunMessages] = None
-    run_response: Optional[RunResponse] = None
-    # Images generated during this session
-    images: Optional[List[ImageArtifact]] = None
-    # Videos generated during this session
-    videos: Optional[List[VideoArtifact]] = None
-    # Audio generated during this session
-    audio: Optional[List[AudioArtifact]] = None
-    # Agent session
-    agent_session: Optional[AgentSession] = None
-
-    _formatter: Optional[SafeFormatter] = None
-
     def __init__(
         self,
         *,
@@ -398,16 +383,27 @@ def __init__(
         self.monitoring = monitoring
         self.telemetry = telemetry
 
-        self.run_id = None
-        self.run_input = None
-        self.run_messages = None
-        self.run_response = None
-        self.images = None
-        self.videos = None
-        self.audio = None
+        # --- Params not to be set by user ---
+        self.session_metrics: Optional[SessionMetrics] = None
 
-        self.agent_session = None
-        self._formatter = None
+        self.run_id: Optional[str] = None
+        self.run_input: Optional[Union[str, List, Dict, Message]] = None
+        self.run_messages: Optional[RunMessages] = None
+        self.run_response: Optional[RunResponse] = None
+
+        # Images generated during this session
+        self.images: Optional[List[ImageArtifact]] = None
+        # Videos generated during this session
+        self.videos: Optional[List[VideoArtifact]] = None
+        # Audio generated during this session
+        self.audio: Optional[List[AudioArtifact]] = None
+        # Agent session
+        self.agent_session: Optional[AgentSession] = None
+
+        self.tools_for_model: Optional[List[Dict]] = None
+        self.functions_for_model: Optional[Dict[str, Function]] = None
+
+        self._formatter: Optional[SafeFormatter] = None
 
     def set_agent_id(self) -> str:
         if self.agent_id is None:
@@ -443,10 +439,10 @@ def initialize_agent(self) -> None:
         self.set_debug()
         self.set_agent_id()
         self.set_session_id()
-        if self._formatter is None:
-            self._formatter = SafeFormatter()
         if self.memory is None:
             self.memory = AgentMemory()
+        if self._formatter is None:
+            self._formatter = SafeFormatter()
 
     @property
     def is_streamable(self) -> bool:
@@ -475,13 +471,14 @@ def _run(
         2. Update the Model and resolve context
         3. Read existing session from storage
         4. Prepare run messages
-        5. Prepare run steps
+        5. Reason about the task if reasoning is enabled
         6. Start the Run by yielding a RunStarted event
-        7. Run Agent Steps
+        7. Generate a response from the Model (includes running function calls)
         8. Update RunResponse
         9. Update Agent Memory
-        10. Save session to storage
-        11. Save output to file if save_response_to_file is set
+        10. Calculate session metrics
+        11. Save session to storage
+        12. Save output to file if save_response_to_file is set
         """
         # 1. Prepare the Agent for the run
         # 1.1 Initialize the Agent
@@ -511,7 +508,7 @@ def _run(
         )
         self.run_messages = run_messages
 
-        # 4. Reason about the task if reasoning is enabled
+        # 5. Reason about the task if reasoning is enabled
         if self.reasoning or self.reasoning_model is not None:
             reasoning_generator = self.reason(run_messages=run_messages)
 
@@ -529,7 +526,7 @@ def _run(
         if self.stream_intermediate_steps:
             yield self.create_run_response("Run started", event=RunEvent.run_started)
 
-        # 5. Generate a response from the Model (includes running function calls)
+        # 7. Generate a response from the Model (includes running function calls)
         model_response: ModelResponse
         self.model = cast(Model, self.model)
         if self.stream:
@@ -695,10 +692,13 @@ def _run(
         if self.memory.create_session_summary and self.memory.update_session_summary_after_run:
             self.memory.update_summary()
 
-        # 10. Save session to storage
+        # 10. Calculate session metrics
+        self.session_metrics = self.calculate_session_metrics(self.memory.messages)
+
+        # 11. Save session to storage
         self.write_to_storage()
 
-        # 11. Save output to file if save_response_to_file is set
+        # 12. Save output to file if save_response_to_file is set
         self.save_run_response_to_file(message=message)
 
         # Set run_input
@@ -903,13 +903,14 @@ async def _arun(
         2. Update the Model and resolve context
         3. Read existing session from storage
         4. Prepare run messages
-        5. Prepare run steps
+        5. Reason about the task if reasoning is enabled
         6. Start the Run by yielding a RunStarted event
-        7. Run Agent Steps
+        7. Generate a response from the Model (includes running function calls)
         8. Update RunResponse
         9. Update Agent Memory
-        10. Save session to storage
-        11. Save output to file if save_response_to_file is set
+        10. Calculate session metrics
+        11. Save session to storage
+        12. Save output to file if save_response_to_file is set
         """
 
         # 1. Prepare the Agent for the run
@@ -940,7 +941,7 @@ async def _arun(
         )
         self.run_messages = run_messages
 
-        # 4. Reason about the task if reasoning is enabled
+        # 5. Reason about the task if reasoning is enabled
         if self.reasoning or self.reasoning_model is not None:
             areason_generator = self.areason(run_messages=run_messages)
             if self.stream:
@@ -959,7 +960,7 @@ async def _arun(
         if self.stream_intermediate_steps:
             yield self.create_run_response("Run started", event=RunEvent.run_started)
 
-        # 5. Generate a response from the Model (includes running function calls)
+        # 7. Generate a response from the Model (includes running function calls)
         model_response: ModelResponse
         self.model = cast(Model, self.model)
         if stream and self.is_streamable:
@@ -1123,10 +1124,13 @@ async def _arun(
         if self.memory.create_session_summary and self.memory.update_session_summary_after_run:
             await self.memory.aupdate_summary()
 
-        # 10. Save session to storage
+        # 10. Calculate session metrics
+        self.session_metrics = self.calculate_session_metrics(self.memory.messages)
+
+        # 11. Save session to storage
         self.write_to_storage()
 
-        # 11. Save output to file if save_response_to_file is set
+        # 12. Save output to file if save_response_to_file is set
         self.save_run_response_to_file(message=message)
 
         # Set run_input
@@ -1310,34 +1314,100 @@ def create_run_response(
 
     def get_tools(self) -> Optional[List[Union[Toolkit, Callable, Dict, Function]]]:
         self.memory = cast(AgentMemory, self.memory)
-        tools: List[Union[Toolkit, Callable, Dict, Function]] = []
+        agent_tools: List[Union[Toolkit, Callable, Dict, Function]] = []
 
         # Add provided tools
         if self.tools is not None:
             for tool in self.tools:
-                tools.append(tool)
+                agent_tools.append(tool)
 
         # Add tools for accessing memory
         if self.read_chat_history:
-            tools.append(self.get_chat_history)
+            agent_tools.append(self.get_chat_history)
         if self.read_tool_call_history:
-            tools.append(self.get_tool_call_history)
+            agent_tools.append(self.get_tool_call_history)
         if self.memory and self.memory.create_user_memories:
-            tools.append(self.update_memory)
+            agent_tools.append(self.update_memory)
 
         # Add tools for accessing knowledge
         if self.knowledge is not None:
             if self.search_knowledge:
-                tools.append(self.search_knowledge_base)
+                agent_tools.append(self.search_knowledge_base)
             if self.update_knowledge:
-                tools.append(self.add_to_knowledge)
+                agent_tools.append(self.add_to_knowledge)
 
         # Add transfer tools
         if self.team is not None and len(self.team) > 0:
             for agent_index, agent in enumerate(self.team):
-                tools.append(self.get_transfer_function(agent, agent_index))
+                agent_tools.append(self.get_transfer_function(agent, agent_index))
+
+        return agent_tools
+
+    def add_tools_to_model(self, model: Model) -> None:
+        # Get Agent tools
+        agent_tools = self.get_tools()
+        if agent_tools is not None:
+            logger.debug("Processing tools for model")
+            # Check if we need strict mode for the model
+            strict = False
+            if self.response_model is not None and self.structured_outputs and model.supports_structured_outputs:
+                strict = True
+
+            if self.tools_for_model is None:
+                self.tools_for_model = []
+            if self.functions_for_model is None:
+                self.functions_for_model = {}
+
+            for tool in agent_tools:
+                # If the tool is a Dict, add it directly to the Model
+                if isinstance(tool, Dict):
+                    if tool not in self.tools_for_model:
+                        self.tools_for_model.append(tool)
+                        logger.debug(f"Included tool {tool}")
+
+                # If the tool is a Callable or Toolkit, process and add to the Model
+                elif callable(tool) or isinstance(tool, Toolkit) or isinstance(tool, Function):
+                    if isinstance(tool, Toolkit):
+                        # For each function in the toolkit, process entrypoint and add to self.tools_for_model
+                        for name, func in tool.functions.items():
+                            # If the function does not exist in self.functions, add to self.tools_for_model
+                            if name not in self.functions_for_model:
+                                func._agent = self
+                                func.process_entrypoint(strict=strict)
+                                if strict:
+                                    func.strict = True
+                                self.functions_for_model[name] = func
+                                self.tools_for_model.append({"type": "function", "function": func.to_dict()})
+                                logger.debug(f"Included function {name} from {tool.name}")
+
+                    elif isinstance(tool, Function):
+                        if tool.name not in self.functions_for_model:
+                            tool._agent = self
+                            tool.process_entrypoint(strict=strict)
+                            if strict:
+                                tool.strict = True
+                            self.functions_for_model[tool.name] = tool
+                            self.tools_for_model.append({"type": "function", "function": tool.to_dict()})
+                            logger.debug(f"Included function {tool.name}")
+
+                    elif callable(tool):
+                        try:
+                            function_name = tool.__name__
+                            if function_name not in self.functions_for_model:
+                                func = Function.from_callable(tool, strict=strict)
+                                func._agent = self
+                                if strict:
+                                    func.strict = True
+                                self.functions_for_model[func.name] = func
+                                self.tools_for_model.append({"type": "function", "function": func.to_dict()})
+                                logger.debug(f"Included function {func.name}")
+                        except Exception as e:
+                            logger.warning(f"Could not add function {tool}: {e}")
 
-        return tools
+            # Set tools on the model
+            model.set_tools(tools=self.tools_for_model)
+            # Set functions on the model
+            model.set_functions(functions=self.functions_for_model)
 
     def update_model(self) -> None:
         # Use the default Model (OpenAIChat) if no model is provided
@@ -1353,44 +1423,32 @@ def update_model(self) -> None:
                 exit(1)
             self.model = OpenAIChat(id="gpt-4o")
 
-        # Set response_format if it is not set on the Model
-        if self.response_model is not None and self.model.response_format is None:
+        # Update the response_format on the Model
+        if self.response_model is not None:
             if self.structured_outputs and self.model.supports_structured_outputs:
                 logger.debug("Setting Model.response_format to Agent.response_model")
                 self.model.response_format = self.response_model
                 self.model.structured_outputs = True
             else:
                 self.model.response_format = {"type": "json_object"}
+        else:
+            self.model.response_format = None
 
         # Add tools to the Model
-        agent_tools = self.get_tools()
-        if agent_tools is not None:
-            for tool in agent_tools:
-                if (
-                    self.response_model is not None
-                    and self.structured_outputs
-                    and self.model.supports_structured_outputs
-                ):
-                    self.model.add_tool(tool=tool, strict=True, agent=self)
-                else:
-                    self.model.add_tool(tool=tool, agent=self)
+        self.add_tools_to_model(model=self.model)
 
-        # Set show_tool_calls if it is not set on the Model
-        if self.model.show_tool_calls is None and self.show_tool_calls is not None:
+        # Set show_tool_calls on the Model
+        if self.show_tool_calls is not None:
             self.model.show_tool_calls = self.show_tool_calls
 
-        # Set tool_choice to auto if it is not set on the Model
-        if self.model.tool_choice is None and self.tool_choice is not None:
+        # Set tool_choice on the Model
+        if self.tool_choice is not None:
             self.model.tool_choice = self.tool_choice
 
-        # Set tool_call_limit if set on the agent
+        # Set tool_call_limit on the Model
         if self.tool_call_limit is not None:
             self.model.tool_call_limit = self.tool_call_limit
 
-        # Add session_id to the Model
-        if self.session_id is not None:
-            self.model.session_id = self.session_id
-
     def resolve_run_context(self) -> None:
         from inspect import signature
 
@@ -1440,6 +1498,8 @@ def get_session_data(self) -> Dict[str, Any]:
             session_data["session_name"] = self.session_name
         if self.session_state is not None and len(self.session_state) > 0:
             session_data["session_state"] = self.session_state
+        if self.session_metrics is not None:
+            session_data["session_metrics"] = self.session_metrics
         if self.team_data is not None:
             session_data["team_data"] = self.team_data
         if self.images is not None:
@@ -1485,25 +1545,13 @@ def load_agent_session(self, session: AgentSession):
             if self.name is None and "name" in session.agent_data:
                 self.name = session.agent_data.get("name")
 
-            # Get model data from the database and update the model
-            if "model" in session.agent_data:
-                model_data = session.agent_data.get("model")
-                # Update model metrics from the database
-                if model_data is not None and isinstance(model_data, dict):
-                    model_metrics_from_db = model_data.get("metrics")
-                    if model_metrics_from_db is not None and isinstance(model_metrics_from_db, dict) and self.model:
-                        try:
-                            self.model.metrics = model_metrics_from_db
-                        except Exception as e:
-                            logger.warning(f"Failed to load model from AgentSession: {e}")
-
         # Read session_data from the database
         if session.session_data is not None:
             # Get the session_name from database and update the current session_name if not set
             if self.session_name is None and "session_name" in session.session_data:
                 self.session_name = session.session_data.get("session_name")
 
-            # Get the session_state from database and update the current session_state
+            # Get the session_state from the database and update the current session_state
             if "session_state" in session.session_data:
                 session_state_from_db = session.session_data.get("session_state")
                 if (
@@ -1518,6 +1566,12 @@ def load_agent_session(self, session: AgentSession):
                     # Update the current session_state
                     self.session_state = session_state_from_db
 
+            # Get the session_metrics from the database
+            if "session_metrics" in session.session_data:
+                session_metrics_from_db = session.session_data.get("session_metrics")
+                if session_metrics_from_db is not None and isinstance(session_metrics_from_db, dict):
+                    self.session_metrics = session_metrics_from_db
+
             # Get images, videos, and audios from the database
             if "images" in session.session_data:
                 images_from_db = session.session_data.get("images")
@@ -2490,14 +2544,25 @@ def update_run_response_with_reasoning(
 
     def aggregate_metrics_from_messages(self, messages: List[Message]) -> Dict[str, Any]:
         aggregated_metrics: Dict[str, Any] = defaultdict(list)
-
-        # Use a defaultdict(list) to collect all values for each assisntant message
+        assistant_message_role = self.model.assistant_message_role if self.model is not None else "assistant"
+        # Use a defaultdict(list) to collect all values for each assistant message
         for m in messages:
-            if m.role == "assistant" and m.metrics is not None:
-                for k, v in m.metrics.items():
-                    aggregated_metrics[k].append(v)
+            if m.role == assistant_message_role and m.metrics is not None:
+                for k, v in asdict(m.metrics).items():
+                    if k in ("timer"):
+                        continue
+                    if v is not None:
+                        aggregated_metrics[k].append(v)
         return aggregated_metrics
 
+    def calculate_session_metrics(self, messages: List[Message]) -> SessionMetrics:
+        session_metrics = SessionMetrics()
+        assistant_message_role = self.model.assistant_message_role if self.model is not None else "assistant"
+        for m in messages:
+            if m.role == assistant_message_role and m.metrics is not None:
+                session_metrics += m.metrics
+        return session_metrics
+
     def rename(self, name: str) -> None:
         """Rename the Agent and save to storage"""
 
diff --git a/libs/agno/agno/agent/metrics.py b/libs/agno/agno/agent/metrics.py
new file mode 100644
index 0000000000..fb5a69aa00
--- /dev/null
+++ b/libs/agno/agno/agent/metrics.py
@@ -0,0 +1,95 @@
+from dataclasses import dataclass
+from typing import Optional, Union
+
+from agno.models.message import MessageMetrics
+from agno.utils.timer import Timer
+
+
+@dataclass
+class SessionMetrics:
+    input_tokens: int = 0
+    output_tokens: int = 0
+    total_tokens: int = 0
+
+    prompt_tokens: int = 0
+    completion_tokens: int = 0
+    prompt_tokens_details: Optional[dict] = None
+    completion_tokens_details: Optional[dict] = None
+
+    additional_metrics: Optional[dict] = None
+
+    time: Optional[float] = None
+    time_to_first_token: Optional[float] = None
+
+    timer: Optional[Timer] = None
+
+    def start_timer(self):
+        if self.timer is None:
+            self.timer = Timer()
+        self.timer.start()
+
+    def stop_timer(self, set_time: bool = True):
+        if self.timer is not None:
+            self.timer.stop()
+            if set_time:
+                self.time = self.timer.elapsed
+
+    def set_time_to_first_token(self):
+        if self.timer is not None:
+            self.time_to_first_token = self.timer.elapsed
+
+    def __add__(self, other: Union["SessionMetrics", "MessageMetrics"]) -> "SessionMetrics":
+        # Create new instance with summed basic metrics
+        result = SessionMetrics(
+            input_tokens=self.input_tokens + other.input_tokens,
+            output_tokens=self.output_tokens + other.output_tokens,
+            total_tokens=self.total_tokens + other.total_tokens,
+            prompt_tokens=self.prompt_tokens + other.prompt_tokens,
+            completion_tokens=self.completion_tokens + other.completion_tokens,
+        )
+
+        # Handle prompt_tokens_details
+        if self.prompt_tokens_details or other.prompt_tokens_details:
+            result.prompt_tokens_details = {}
+            # Merge from self
+            if self.prompt_tokens_details:
+                result.prompt_tokens_details.update(self.prompt_tokens_details)
+            # Add values from other
+            if other.prompt_tokens_details:
+                for key, value in other.prompt_tokens_details.items():
+                    result.prompt_tokens_details[key] = result.prompt_tokens_details.get(key, 0) + value
+
+        # Handle completion_tokens_details similarly
+        if self.completion_tokens_details or other.completion_tokens_details:
+            result.completion_tokens_details = {}
+            if self.completion_tokens_details:
+                result.completion_tokens_details.update(self.completion_tokens_details)
+            if other.completion_tokens_details:
+                for key, value in other.completion_tokens_details.items():
+                    result.completion_tokens_details[key] = result.completion_tokens_details.get(key, 0) + value
+
+        # Handle additional metrics
+        if self.additional_metrics or other.additional_metrics:
+            result.additional_metrics = {}
+            if self.additional_metrics:
+                result.additional_metrics.update(self.additional_metrics)
+            if other.additional_metrics:
+                result.additional_metrics.update(other.additional_metrics)
+
+        # Sum times if both exist
+        if self.time is not None and other.time is not None:
+            result.time = self.time + other.time
+        elif self.time is not None:
+            result.time = self.time
+        elif other.time is not None:
+            result.time = other.time
+
+        # Handle time_to_first_token (take the first non-None value)
+        result.time_to_first_token = self.time_to_first_token or other.time_to_first_token
+
+        return result
+
+    def __radd__(self, other: Union["SessionMetrics", "MessageMetrics"]) -> "SessionMetrics":
+        if other == 0:  # Handle sum() starting value
+            return self
+        return self + other
diff --git a/libs/agno/agno/memory/manager.py b/libs/agno/agno/memory/manager.py
index 87d4e51b4a..ca12ef3be0 100644
--- a/libs/agno/agno/memory/manager.py
+++ b/libs/agno/agno/memory/manager.py
@@ -1,4 +1,4 @@
-from typing import Any, List, Optional, cast
+from typing import Any, Dict, List, Optional, cast
 
 from pydantic import BaseModel, ConfigDict
 
@@ -7,6 +7,7 @@
 from agno.memory.row import MemoryRow
 from agno.models.base import Model
 from agno.models.message import Message
+from agno.tools.function import Function
 from agno.utils.log import logger
 
 
@@ -21,10 +22,13 @@ class MemoryManager(BaseModel):
 
     # Do not set the input message here, it will be set by the run method
     input_message: Optional[str] = None
+    _tools_for_model: Optional[List[Dict]] = None
+    _functions_for_model: Optional[Dict[str, Function]] = None
 
     model_config = ConfigDict(arbitrary_types_allowed=True)
 
     def update_model(self) -> None:
+        # Use the default Model (OpenAIChat) if no model is provided
         if self.model is None:
             try:
                 from agno.models.openai import OpenAIChat
@@ -36,10 +40,34 @@ def update_model(self) -> None:
                 exit(1)
             self.model = OpenAIChat(id="gpt-4o")
 
-        self.model.add_tool(self.add_memory)
-        self.model.add_tool(self.update_memory)
-        self.model.add_tool(self.delete_memory)
-        self.model.add_tool(self.clear_memory)
+        # Add tools to the Model
+        self.add_tools_to_model(model=self.model)
+
+    def add_tools_to_model(self, model: Model) -> None:
+        if self._tools_for_model is None:
+            self._tools_for_model = []
+        if self._functions_for_model is None:
+            self._functions_for_model = {}
+
+        for tool in [
+            self.add_memory,
+            self.update_memory,
+            self.delete_memory,
+            self.clear_memory,
+        ]:
+            try:
+                function_name = tool.__name__
+                if function_name not in self._functions_for_model:
+                    func = Function.from_callable(tool)
+                    self._functions_for_model[func.name] = func
+                    self._tools_for_model.append({"type": "function", "function": func.to_dict()})
+                    logger.debug(f"Included function {func.name}")
+            except Exception as e:
+                logger.warning(f"Could not add function {tool}: {e}")
+        # Set tools on the model
+        model.set_tools(tools=self._tools_for_model)
+        # Set functions on the model
+        model.set_functions(functions=self._functions_for_model)
 
     def get_existing_memories(self) -> Optional[List[MemoryRow]]:
         if self.db is None:
diff --git a/libs/agno/agno/models/anthropic/claude.py b/libs/agno/agno/models/anthropic/claude.py
index 398a3e3fe4..fb9f76ac87 100644
--- a/libs/agno/agno/models/anthropic/claude.py
+++ b/libs/agno/agno/models/anthropic/claude.py
@@ -439,7 +439,7 @@ def handle_tool_calls(
             model_response.content = str(response_content)
             model_response.content += "\n\n"
 
-            function_calls_to_run = self._get_function_calls_to_run(assistant_message, messages)
+            function_calls_to_run = self.get_function_calls_to_run(assistant_message, messages)
             function_call_results: List[Message] = []
 
             if self.show_tool_calls:
@@ -532,7 +532,7 @@ def handle_stream_tool_calls(
         """
         if assistant_message.tool_calls is not None and len(assistant_message.tool_calls) > 0:
             yield ModelResponse(content="\n\n")
-            function_calls_to_run = self._get_function_calls_to_run(assistant_message, messages)
+            function_calls_to_run = self.get_function_calls_to_run(assistant_message, messages)
             function_call_results: List[Message] = []
 
             if self.show_tool_calls:
diff --git a/libs/agno/agno/models/base.py b/libs/agno/agno/models/base.py
index 8ae8d3deb5..c7346452e7 100644
--- a/libs/agno/agno/models/base.py
+++ b/libs/agno/agno/models/base.py
@@ -4,65 +4,18 @@
 from dataclasses import dataclass, field
 from pathlib import Path
 from types import GeneratorType
-from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence, Tuple, Union
+from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple, Union
 
 from agno.exceptions import AgentRunException
 from agno.media import Audio, Image
-from agno.models.message import Message
+from agno.models.message import Message, MessageMetrics
 from agno.models.response import ModelResponse, ModelResponseEvent
-from agno.tools import Toolkit
 from agno.tools.function import Function, FunctionCall
 from agno.utils.log import logger
 from agno.utils.timer import Timer
 from agno.utils.tools import get_function_call_for_tool_call
 
 
-@dataclass
-class Metrics:
-    input_tokens: int = 0
-    output_tokens: int = 0
-    total_tokens: int = 0
-
-    prompt_tokens: int = 0
-    completion_tokens: int = 0
-    prompt_tokens_details: Optional[dict] = None
-    completion_tokens_details: Optional[dict] = None
-
-    time_to_first_token: Optional[float] = None
-    response_timer: Timer = field(default_factory=Timer)
-
-    def start_response_timer(self):
-        self.response_timer.start()
-
-    def stop_response_timer(self):
-        self.response_timer.stop()
-
-    def _log(self, metric_lines: list[str]):
-        logger.debug("**************** METRICS START ****************")
-        for line in metric_lines:
-            logger.debug(line)
-        logger.debug("**************** METRICS END ******************")
-
-    def log(self):
-        metric_lines = []
-        if self.time_to_first_token is not None:
-            metric_lines.append(f"* Time to first token:         {self.time_to_first_token:.4f}s")
-        metric_lines.extend(
-            [
-                f"* Time to generate response:   {self.response_timer.elapsed:.4f}s",
-                f"* Tokens per second:           {self.output_tokens / self.response_timer.elapsed:.4f} tokens/s",
-                f"* Input tokens:                {self.input_tokens or self.prompt_tokens}",
-                f"* Output tokens:               {self.output_tokens or self.completion_tokens}",
-                f"* Total tokens:                {self.total_tokens}",
-            ]
-        )
-        if self.prompt_tokens_details is not None:
-            metric_lines.append(f"* Prompt tokens details:       {self.prompt_tokens_details}")
-        if self.completion_tokens_details is not None:
-            metric_lines.append(f"* Completion tokens details:   {self.completion_tokens_details}")
-        self._log(metric_lines=metric_lines)
-
-
 @dataclass
 class StreamData:
     response_content: str = ""
@@ -83,12 +36,19 @@ class Model(ABC):
     name: Optional[str] = None
     # Provider for this Model. This is not sent to the Model API.
     provider: Optional[str] = None
-    # Metrics collected for this Model. This is not sent to the Model API.
-    metrics: Dict[str, Any] = field(default_factory=dict)
-    # Used for structured_outputs
+
+    # -*- Do not set the following attributes directly -*-
+    # -*- Set them on the Agent instead -*-
+
+    # Used for structured_outputs, do not set this directly
+    # Set the response_model attribute on the Agent instead
     response_format: Optional[Any] = None
+    # Whether to use the structured outputs with this Model.
+    structured_outputs: bool = False
+    # True if the Model supports structured outputs natively (e.g. OpenAI)
+    supports_structured_outputs: bool = False
 
-    # A list of tools provided to the Model.
+    # A list of tools sent to the Model.
     # Tools are functions the model may generate JSON inputs for.
     # If you provide a dict, it is not called by the model.
     # Always add tools using the add_tool() method.
@@ -102,13 +62,12 @@ class Model(ABC):
     # "none" is the default when no functions are present. "auto" is the default if functions are present.
     tool_choice: Optional[Union[str, Dict[str, Any]]] = None
 
-    # If True, shows function calls in the response.  Is not compatible with response_model
+    # If True, shows function calls in the response. Disabled when response_model is used.
     show_tool_calls: Optional[bool] = None
-
     # Maximum number of tool calls allowed.
     tool_call_limit: Optional[int] = None
 
-    # -*- Functions available to the Model to call -*-
+    # Functions available to the Model to call
     # Functions extracted from the tools.
     # Note: These are not sent to the Model API and are only used for execution + deduplication.
     _functions: Optional[Dict[str, Function]] = None
@@ -120,13 +79,12 @@ class Model(ABC):
     # Instructions from the model added to the Agent.
     instructions: Optional[List[str]] = None
 
-    # Session ID of the calling Agent or Workflow.
-    session_id: Optional[str] = None
-    # Whether to use the structured outputs with this Model.
-    structured_outputs: Optional[bool] = None
-    # Whether the Model supports native structured outputs.
-    supports_structured_outputs: bool = False
-    # Whether to override the system role.
+    # The role of the tool message.
+    tool_message_role: str = "tool"
+    # The role of the assistant message.
+    assistant_message_role: str = "assistant"
+    # Whether to override the system message role to the the system_message_role.
+    # This is used for OpenAI models to map the "system" role to "developer"
     override_system_role: bool = False
     # The role to map the system message to.
     system_message_role: str = "system"
@@ -136,7 +94,7 @@ def __post_init__(self):
             self.provider = f"{self.name} ({self.id})"
 
     def to_dict(self) -> Dict[str, Any]:
-        fields = {"name", "id", "provider", "metrics"}
+        fields = {"name", "id", "provider"}
         _dict = {field: getattr(self, field) for field in fields if getattr(self, field) is not None}
         # Add functions if they exist
         if self._functions:
@@ -186,33 +144,41 @@ def _log_messages(self, messages: List[Message]) -> None:
         for m in messages:
             m.log()
 
-    @staticmethod
-    def _update_assistant_message_metrics(assistant_message: Message, metrics_for_run: Metrics = Metrics()) -> None:
-        assistant_message.metrics["time"] = metrics_for_run.response_timer.elapsed
-        if metrics_for_run.input_tokens is not None:
-            assistant_message.metrics["input_tokens"] = metrics_for_run.input_tokens
-        if metrics_for_run.output_tokens is not None:
-            assistant_message.metrics["output_tokens"] = metrics_for_run.output_tokens
-        if metrics_for_run.total_tokens is not None:
-            assistant_message.metrics["total_tokens"] = metrics_for_run.total_tokens
-        if metrics_for_run.time_to_first_token is not None:
-            assistant_message.metrics["time_to_first_token"] = metrics_for_run.time_to_first_token
-
-    def _update_model_metrics(
-        self,
-        metrics_for_run: Metrics = Metrics(),
-    ) -> None:
-        self.metrics.setdefault("response_times", []).append(metrics_for_run.response_timer.elapsed)
-        if metrics_for_run.input_tokens is not None:
-            self.metrics["input_tokens"] = self.metrics.get("input_tokens", 0) + metrics_for_run.input_tokens
-        if metrics_for_run.output_tokens is not None:
-            self.metrics["output_tokens"] = self.metrics.get("output_tokens", 0) + metrics_for_run.output_tokens
-        if metrics_for_run.total_tokens is not None:
-            self.metrics["total_tokens"] = self.metrics.get("total_tokens", 0) + metrics_for_run.total_tokens
-        if metrics_for_run.time_to_first_token is not None:
-            self.metrics.setdefault("time_to_first_token", []).append(metrics_for_run.time_to_first_token)
-
-    def _get_function_calls_to_run(
+    def set_tools(self, tools: List[Dict]) -> None:
+        if len(tools) > 0:
+            self.tools = tools
+
+    def set_functions(self, functions: Dict[str, Function]) -> None:
+        if len(functions) > 0:
+            self._functions = functions
+
+    # @staticmethod
+    # def _update_assistant_message_metrics(assistant_message: Message, metrics_for_run: Metrics = Metrics()) -> None:
+    #     assistant_message.metrics["time"] = metrics_for_run.response_timer.elapsed
+    #     if metrics_for_run.input_tokens is not None:
+    #         assistant_message.metrics["input_tokens"] = metrics_for_run.input_tokens
+    #     if metrics_for_run.output_tokens is not None:
+    #         assistant_message.metrics["output_tokens"] = metrics_for_run.output_tokens
+    #     if metrics_for_run.total_tokens is not None:
+    #         assistant_message.metrics["total_tokens"] = metrics_for_run.total_tokens
+    #     if metrics_for_run.time_to_first_token is not None:
+    #         assistant_message.metrics["time_to_first_token"] = metrics_for_run.time_to_first_token
+
+    # def _update_model_metrics(
+    #     self,
+    #     metrics_for_run: Metrics = Metrics(),
+    # ) -> None:
+    #     self.metrics.setdefault("response_times", []).append(metrics_for_run.response_timer.elapsed)
+    #     if metrics_for_run.input_tokens is not None:
+    #         self.metrics["input_tokens"] = self.metrics.get("input_tokens", 0) + metrics_for_run.input_tokens
+    #     if metrics_for_run.output_tokens is not None:
+    #         self.metrics["output_tokens"] = self.metrics.get("output_tokens", 0) + metrics_for_run.output_tokens
+    #     if metrics_for_run.total_tokens is not None:
+    #         self.metrics["total_tokens"] = self.metrics.get("total_tokens", 0) + metrics_for_run.total_tokens
+    #     if metrics_for_run.time_to_first_token is not None:
+    #         self.metrics.setdefault("time_to_first_token", []).append(metrics_for_run.time_to_first_token)
+
+    def get_function_calls_to_run(
         self, assistant_message: Message, messages: List[Message], error_response_role: str = "user"
     ) -> List[FunctionCall]:
         """
@@ -238,60 +204,6 @@ def _get_function_calls_to_run(
                 function_calls_to_run.append(_function_call)
         return function_calls_to_run
 
-    def add_tool(
-        self, tool: Union[Toolkit, Callable, Dict, Function], strict: bool = False, agent: Optional[Any] = None
-    ) -> None:
-        if self.tools is None:
-            self.tools = []
-
-        # If the tool is a Dict, add it directly to the Model
-        if isinstance(tool, Dict):
-            if tool not in self.tools:
-                self.tools.append(tool)
-                logger.debug(f"Added tool {tool} to model.")
-
-        # If the tool is a Callable or Toolkit, process and add to the Model
-        elif callable(tool) or isinstance(tool, Toolkit) or isinstance(tool, Function):
-            if self._functions is None:
-                self._functions = {}
-
-            if isinstance(tool, Toolkit):
-                # For each function in the toolkit, process entrypoint and add to self.tools
-                for name, func in tool.functions.items():
-                    # If the function does not exist in self.functions, add to self.tools
-                    if name not in self._functions:
-                        func._agent = agent
-                        func.process_entrypoint(strict=strict)
-                        if strict and self.supports_structured_outputs:
-                            func.strict = True
-                        self._functions[name] = func
-                        self.tools.append({"type": "function", "function": func.to_dict()})
-                        logger.debug(f"Function {name} from {tool.name} added to model.")
-
-            elif isinstance(tool, Function):
-                if tool.name not in self._functions:
-                    tool._agent = agent
-                    tool.process_entrypoint(strict=strict)
-                    if strict and self.supports_structured_outputs:
-                        tool.strict = True
-                    self._functions[tool.name] = tool
-                    self.tools.append({"type": "function", "function": tool.to_dict()})
-                    logger.debug(f"Function {tool.name} added to model.")
-
-            elif callable(tool):
-                try:
-                    function_name = tool.__name__
-                    if function_name not in self._functions:
-                        func = Function.from_callable(tool, strict=strict)
-                        func._agent = agent
-                        if strict and self.supports_structured_outputs:
-                            func.strict = True
-                        self._functions[func.name] = func
-                        self.tools.append({"type": "function", "function": func.to_dict()})
-                        logger.debug(f"Function {func.name} added to model.")
-                except Exception as e:
-                    logger.warning(f"Could not add function {tool}: {e}")
-
     def _handle_agent_exception(self, a_exc: AgentRunException, additional_messages: List[Message]) -> None:
         """Handle AgentRunException and collect additional messages."""
         if a_exc.user_message is not None:
@@ -336,17 +248,9 @@ def _create_function_call_result(
             tool_args=fc.arguments,
             tool_call_error=not success,
             stop_after_tool_call=fc.function.stop_after_tool_call,
-            metrics={"time": timer.elapsed},
+            metrics=MessageMetrics(time=timer.elapsed),
         )
 
-    def _update_metrics(self, function_name: str, elapsed_time: float) -> None:
-        """Update metrics for function calls."""
-        if "tool_call_times" not in self.metrics:
-            self.metrics["tool_call_times"] = {}
-        if function_name not in self.metrics["tool_call_times"]:
-            self.metrics["tool_call_times"][function_name] = []
-        self.metrics["tool_call_times"][function_name].append(elapsed_time)
-
     def run_function_calls(
         self, function_calls: List[FunctionCall], function_call_results: List[Message], tool_role: str = "tool"
     ) -> Iterator[ModelResponse]:
@@ -426,8 +330,7 @@ def run_function_calls(
                 event=ModelResponseEvent.tool_call_completed.value,
             )
 
-            # Update metrics and function call results
-            self._update_metrics(fc.function.name, function_call_timer.elapsed)
+            # Add function call to function call results
             function_call_results.append(function_call_result)
             self._function_call_stack.append(fc)
 
@@ -544,8 +447,7 @@ async def arun_function_calls(
                 event=ModelResponseEvent.tool_call_completed.value,
             )
 
-            # Update metrics and function call results
-            self._update_metrics(fc.function.name, function_call_timer.elapsed)
+            # Add function call result to function call results
             function_call_results.append(function_call_result)
             self._function_call_stack.append(fc)
 
@@ -1033,7 +935,7 @@ def add_audio_to_message(message: Message, audio: Sequence[Audio]) -> Message:
         return message
 
     @staticmethod
-    def _build_tool_calls(tool_calls_data: List[Any]) -> List[Dict[str, Any]]:
+    def build_tool_calls(tool_calls_data: List[Any]) -> List[Dict[str, Any]]:
         """
         Build tool calls from tool call data.
 
@@ -1081,10 +983,10 @@ def get_instructions_for_model(self) -> Optional[List[str]]:
     def clear(self) -> None:
         """Clears the Model's state."""
 
-        self.metrics = {}
+        self.response_format = None
+        self.tools = None
         self._functions = None
         self._function_call_stack = None
-        self.session_id = None
 
     def __deepcopy__(self, memo):
         """Create a deep copy of the Model instance.
@@ -1104,7 +1006,7 @@ def __deepcopy__(self, memo):
 
         # Deep copy all attributes
         for k, v in self.__dict__.items():
-            if k in {"metrics", "_functions", "_function_call_stack", "session_id"}:
+            if k in {"response_format", "tools", "_functions", "_function_call_stack"}:
                 continue
             setattr(new_model, k, deepcopy(v, memo))
 
diff --git a/libs/agno/agno/models/google/gemini.py b/libs/agno/agno/models/google/gemini.py
index 6ab3fe1839..c11e88ec0c 100644
--- a/libs/agno/agno/models/google/gemini.py
+++ b/libs/agno/agno/models/google/gemini.py
@@ -628,7 +628,7 @@ def handle_tool_calls(self, assistant_message: Message, messages: List[Message],
             if model_response.tool_calls is None:
                 model_response.tool_calls = []
             model_response.content = assistant_message.get_content_string() or ""
-            function_calls_to_run = self._get_function_calls_to_run(
+            function_calls_to_run = self.get_function_calls_to_run(
                 assistant_message, messages, error_response_role="tool"
             )
 
@@ -716,7 +716,7 @@ def handle_stream_tool_calls(self, assistant_message: Message, messages: List[Me
             Iterator[ModelResponse]: Yields model responses during function execution.
         """
         if assistant_message.tool_calls:
-            function_calls_to_run = self._get_function_calls_to_run(
+            function_calls_to_run = self.get_function_calls_to_run(
                 assistant_message, messages, error_response_role="tool"
             )
 
diff --git a/libs/agno/agno/models/groq/groq.py b/libs/agno/agno/models/groq/groq.py
index 3370f98b85..6e4d03221a 100644
--- a/libs/agno/agno/models/groq/groq.py
+++ b/libs/agno/agno/models/groq/groq.py
@@ -4,7 +4,6 @@
 
 import httpx
 
-from agno.models.base import Metrics as BaseMetrics
 from agno.models.base import Model
 from agno.models.message import Message
 from agno.models.response import ModelResponse
@@ -20,38 +19,6 @@
     raise ImportError("`groq` not installed. Please install using `pip install groq`")
 
 
-@dataclass
-class Metrics(BaseMetrics):
-    completion_time: Optional[float] = None
-    prompt_time: Optional[float] = None
-    queue_time: Optional[float] = None
-    total_time: Optional[float] = None
-
-    def log(self):
-        metric_lines = []
-        if self.time_to_first_token is not None:
-            metric_lines.append(f"* Time to first token:         {self.time_to_first_token:.4f}s")
-        metric_lines.extend(
-            [
-                f"* Time to generate response:   {self.response_timer.elapsed:.4f}s",
-                f"* Tokens per second:           {self.output_tokens / self.response_timer.elapsed:.4f} tokens/s",
-                f"* Input tokens:                {self.input_tokens or self.prompt_tokens}",
-                f"* Output tokens:               {self.output_tokens or self.completion_tokens}",
-                f"* Total tokens:                {self.total_tokens}",
-            ]
-        )
-        if self.completion_time is not None:
-            metric_lines.append(f"* Completion time:             {self.completion_time:.4f}s")
-        if self.prompt_time is not None:
-            metric_lines.append(f"* Prompt time:                 {self.prompt_time:.4f}s")
-        if self.queue_time is not None:
-            metric_lines.append(f"* Queue time:                  {self.queue_time:.4f}s")
-        if self.total_time is not None:
-            metric_lines.append(f"* Total time:                  {self.total_time:.4f}s")
-
-        self._log(metric_lines=metric_lines)
-
-
 @dataclass
 class StreamData:
     response_content: str = ""
@@ -66,7 +33,7 @@ class Groq(Model):
     For more information, see: https://console.groq.com/docs/libraries
     """
 
-    id: str = "llama3-groq-70b-8192-tool-use-preview"
+    id: str = "llama-3.3-70b-versatile"
     name: str = "Groq"
     provider: str = "Groq"
 
@@ -101,24 +68,25 @@ class Groq(Model):
     client: Optional[GroqClient] = None
     async_client: Optional[AsyncGroqClient] = None
 
-    def get_client_params(self) -> Dict[str, Any]:
-        self.api_key = self.api_key or getenv("GROQ_API_KEY")
+    def _get_client_params(self) -> Dict[str, Any]:
+        # Fetch API key from env if not already set
         if not self.api_key:
-            logger.error("GROQ_API_KEY not set. Please set the GROQ_API_KEY environment variable.")
-
-        client_params: Dict[str, Any] = {}
-        if self.api_key:
-            client_params["api_key"] = self.api_key
-        if self.base_url:
-            client_params["base_url"] = self.base_url
-        if self.timeout:
-            client_params["timeout"] = self.timeout
-        if self.max_retries:
-            client_params["max_retries"] = self.max_retries
-        if self.default_headers:
-            client_params["default_headers"] = self.default_headers
-        if self.default_query:
-            client_params["default_query"] = self.default_query
+            self.api_key = getenv("GROQ_API_KEY")
+            if not self.api_key:
+                logger.error("GROQ_API_KEY not set. Please set the GROQ_API_KEY environment variable.")
+
+        # Define base client params
+        base_params = {
+            "api_key": self.api_key,
+            "base_url": self.base_url,
+            "timeout": self.timeout,
+            "max_retries": self.max_retries,
+            "default_headers": self.default_headers,
+            "default_query": self.default_query,
+        }
+        # Create client_params dict with non-None values
+        client_params = {k: v for k, v in base_params.items() if v is not None}
+        # Add additional client params if provided
         if self.client_params:
             client_params.update(self.client_params)
         return client_params
@@ -133,7 +101,7 @@ def get_client(self) -> GroqClient:
         if self.client:
             return self.client
 
-        client_params: Dict[str, Any] = self.get_client_params()
+        client_params: Dict[str, Any] = self._get_client_params()
         if self.http_client is not None:
             client_params["http_client"] = self.http_client
 
@@ -150,7 +118,7 @@ def get_async_client(self) -> AsyncGroqClient:
         if self.async_client:
             return self.async_client
 
-        client_params: Dict[str, Any] = self.get_client_params()
+        client_params: Dict[str, Any] = self._get_client_params()
         if self.http_client:
             client_params["http_client"] = self.http_client
         else:
@@ -168,41 +136,31 @@ def request_kwargs(self) -> Dict[str, Any]:
         Returns:
             Dict[str, Any]: A dictionary of keyword arguments for API requests.
         """
-        request_params: Dict[str, Any] = {}
-        if self.frequency_penalty:
-            request_params["frequency_penalty"] = self.frequency_penalty
-        if self.logit_bias:
-            request_params["logit_bias"] = self.logit_bias
-        if self.logprobs:
-            request_params["logprobs"] = self.logprobs
-        if self.max_tokens:
-            request_params["max_tokens"] = self.max_tokens
-        if self.presence_penalty:
-            request_params["presence_penalty"] = self.presence_penalty
-        if self.response_format:
-            request_params["response_format"] = self.response_format
-        if self.seed:
-            request_params["seed"] = self.seed
-        if self.stop:
-            request_params["stop"] = self.stop
-        if self.temperature:
-            request_params["temperature"] = self.temperature
-        if self.top_logprobs:
-            request_params["top_logprobs"] = self.top_logprobs
-        if self.top_p:
-            request_params["top_p"] = self.top_p
-        if self.user:
-            request_params["user"] = self.user
-        if self.extra_headers:
-            request_params["extra_headers"] = self.extra_headers
-        if self.extra_query:
-            request_params["extra_query"] = self.extra_query
-        if self.tools:
+        # Define base request parameters
+        base_params = {
+            "frequency_penalty": self.frequency_penalty,
+            "logit_bias": self.logit_bias,
+            "logprobs": self.logprobs,
+            "max_tokens": self.max_tokens,
+            "presence_penalty": self.presence_penalty,
+            "response_format": self.response_format,
+            "seed": self.seed,
+            "stop": self.stop,
+            "temperature": self.temperature,
+            "top_logprobs": self.top_logprobs,
+            "top_p": self.top_p,
+            "user": self.user,
+            "extra_headers": self.extra_headers,
+            "extra_query": self.extra_query,
+        }
+        # Filter out None values
+        request_params = {k: v for k, v in base_params.items() if v is not None}
+        # Add tools
+        if self.tools is not None:
             request_params["tools"] = self.tools
-            if self.tool_choice is None:
-                request_params["tool_choice"] = "auto"
-            else:
+            if self.tool_choice is not None:
                 request_params["tool_choice"] = self.tool_choice
+        # Add additional request params if provided
         if self.request_params:
             request_params.update(self.request_params)
         return request_params
@@ -214,8 +172,8 @@ def to_dict(self) -> Dict[str, Any]:
         Returns:
             Dict[str, Any]: The dictionary representation of the model.
         """
-        _dict = super().to_dict()
-        _dict.update(
+        model_dict = super().to_dict()
+        model_dict.update(
             {
                 "frequency_penalty": self.frequency_penalty,
                 "logit_bias": self.logit_bias,
@@ -237,12 +195,16 @@ def to_dict(self) -> Dict[str, Any]:
                 else "auto",
             }
         )
-        cleaned_dict = {k: v for k, v in _dict.items() if v is not None}
+        if self.tools is not None:
+            model_dict["tools"] = self.tools
+            if self.tool_choice is not None:
+                model_dict["tool_choice"] = self.tool_choice
+        cleaned_dict = {k: v for k, v in model_dict.items() if v is not None}
         return cleaned_dict
 
     def format_message(self, message: Message) -> Dict[str, Any]:
         """
-        Format a message into the format expected by OpenAI.
+        Format a message into the format expected by Groq.
 
         Args:
             message (Message): The message to format.
@@ -253,9 +215,6 @@ def format_message(self, message: Message) -> Dict[str, Any]:
         if message.role == "user":
             if message.images is not None:
                 message = self.add_images_to_message(message=message, images=message.images)
-            # TODO: Add audio support
-            # if message.audio is not None:
-            #     message = self.add_audio_to_message(message=message, audio=message.audio)
 
         return message.to_dict()
 
@@ -327,91 +286,62 @@ async def ainvoke_stream(self, messages: List[Message]) -> Any:
         async for chunk in async_stream:  # type: ignore
             yield chunk
 
-    def update_usage_metrics(
-        self, assistant_message: Message, metrics: Metrics, response_usage: Optional[CompletionUsage]
-    ) -> None:
-        """
-        Update the usage metrics for the assistant message and the model.
-
-        Args:
-            assistant_message (Message): The assistant message.
-            metrics (Metrics): The metrics.
-            response_usage (Optional[CompletionUsage]): The response usage.
-        """
-        # Update time taken to generate response
-        assistant_message.metrics["time"] = metrics.response_timer.elapsed
-        self.metrics.setdefault("response_times", []).append(metrics.response_timer.elapsed)
-        if response_usage:
-            prompt_tokens = response_usage.prompt_tokens
-            completion_tokens = response_usage.completion_tokens
-            total_tokens = response_usage.total_tokens
-
-            if prompt_tokens is not None:
-                metrics.input_tokens = prompt_tokens
-                metrics.prompt_tokens = prompt_tokens
-                assistant_message.metrics["input_tokens"] = prompt_tokens
-                assistant_message.metrics["prompt_tokens"] = prompt_tokens
-                self.metrics["input_tokens"] = self.metrics.get("input_tokens", 0) + prompt_tokens
-                self.metrics["prompt_tokens"] = self.metrics.get("prompt_tokens", 0) + prompt_tokens
-            if completion_tokens is not None:
-                metrics.output_tokens = completion_tokens
-                metrics.completion_tokens = completion_tokens
-                assistant_message.metrics["output_tokens"] = completion_tokens
-                assistant_message.metrics["completion_tokens"] = completion_tokens
-                self.metrics["output_tokens"] = self.metrics.get("output_tokens", 0) + completion_tokens
-                self.metrics["completion_tokens"] = self.metrics.get("completion_tokens", 0) + completion_tokens
-            if total_tokens is not None:
-                metrics.total_tokens = total_tokens
-                assistant_message.metrics["total_tokens"] = total_tokens
-                self.metrics["total_tokens"] = self.metrics.get("total_tokens", 0) + total_tokens
-            if response_usage.completion_time is not None:
-                metrics.completion_time = response_usage.completion_time
-                assistant_message.metrics["completion_time"] = response_usage.completion_time
-                self.metrics["completion_time"] = (
-                    self.metrics.get("completion_time", 0) + response_usage.completion_time
-                )
-            if response_usage.prompt_time is not None:
-                metrics.prompt_time = response_usage.prompt_time
-                assistant_message.metrics["prompt_time"] = response_usage.prompt_time
-                self.metrics["prompt_time"] = self.metrics.get("prompt_time", 0) + response_usage.prompt_time
-            if response_usage.queue_time is not None:
-                metrics.queue_time = response_usage.queue_time
-                assistant_message.metrics["queue_time"] = response_usage.queue_time
-                self.metrics["queue_time"] = self.metrics.get("queue_time", 0) + response_usage.queue_time
-            if response_usage.total_time is not None:
-                metrics.total_time = response_usage.total_time
-                assistant_message.metrics["total_time"] = response_usage.total_time
-                self.metrics["total_time"] = self.metrics.get("total_time", 0) + response_usage.total_time
-
-    def create_assistant_message(
+    def add_usage_metrics_to_assistant_message(self, assistant_message: Message, response_usage: CompletionUsage):
+        assistant_message.metrics.input_tokens = response_usage.prompt_tokens
+        assistant_message.metrics.output_tokens = response_usage.completion_tokens
+        assistant_message.metrics.total_tokens = response_usage.total_tokens
+
+        assistant_message.metrics.prompt_tokens = response_usage.prompt_tokens
+        assistant_message.metrics.completion_tokens = response_usage.completion_tokens
+
+        if assistant_message.metrics.additional_metrics is None:
+            assistant_message.metrics.additional_metrics = {}
+        if response_usage.prompt_time is not None:
+            assistant_message.metrics.additional_metrics["prompt_time"] = response_usage.prompt_time
+        if response_usage.completion_time is not None:
+            assistant_message.metrics.additional_metrics["completion_time"] = response_usage.completion_time
+        if response_usage.queue_time is not None:
+            assistant_message.metrics.additional_metrics["queue_time"] = response_usage.queue_time
+        if response_usage.total_time is not None:
+            assistant_message.metrics.additional_metrics["total_time"] = response_usage.total_time
+
+    def populate_assistant_message(
         self,
+        assistant_message: Message,
         response_message: ChatCompletionMessage,
-        metrics: Metrics,
         response_usage: Optional[CompletionUsage],
     ) -> Message:
         """
-        Create an assistant message from the response.
+        Populate an assistant message with the response message and usage.
 
         Args:
+            assistant_message (Message): The assistant message to populate.
             response_message (ChatCompletionMessage): The response message.
-            metrics (Metrics): The metrics.
             response_usage (Optional[CompletionUsage]): The response usage.
 
         Returns:
             Message: The assistant message.
         """
-        assistant_message = Message(
-            role=response_message.role or "assistant",
-            content=response_message.content,
-        )
+        # -*- Update role
+        if response_message.role:
+            assistant_message.role = response_message.role
+
+        # -*- Add content to assistant message
+        if response_message.content is not None:
+            assistant_message.content = response_message.content
+
+        # -*- Add tool calls to assistant message
         if response_message.tool_calls is not None and len(response_message.tool_calls) > 0:
             try:
                 assistant_message.tool_calls = [t.model_dump() for t in response_message.tool_calls]
             except Exception as e:
                 logger.warning(f"Error processing tool calls: {e}")
 
-        # Update metrics
-        self.update_usage_metrics(assistant_message, metrics, response_usage)
+        # -*- Add usage metrics to assistant message
+        if response_usage is not None:
+            self.add_usage_metrics_to_assistant_message(
+                assistant_message=assistant_message, response_usage=response_usage
+            )
         return assistant_message
 
     def response(self, messages: List[Message]) -> ModelResponse:
@@ -427,20 +357,20 @@ def response(self, messages: List[Message]) -> ModelResponse:
         logger.debug("---------- Groq Response Start ----------")
         self._log_messages(messages)
         model_response = ModelResponse()
-        metrics = Metrics()
+
+        # -*- Create assistant message
+        assistant_message = Message(role=self.assistant_message_role)
 
         # -*- Generate response
-        metrics.start_response_timer()
+        assistant_message.metrics.start_timer()
         response: ChatCompletion = self.invoke(messages=messages)
-        metrics.stop_response_timer()
-
-        # -*- Parse response
-        response_message: ChatCompletionMessage = response.choices[0].message
-        response_usage: Optional[CompletionUsage] = response.usage
+        assistant_message.metrics.stop_timer()
 
-        # -*- Create assistant message
-        assistant_message = self.create_assistant_message(
-            response_message=response_message, metrics=metrics, response_usage=response_usage
+        # -*- Populate the assistant message with response message and usage
+        self.populate_assistant_message(
+            assistant_message=assistant_message,
+            response_message=response.choices[0].message,
+            response_usage=response.usage,
         )
 
         # -*- Add assistant message to messages
@@ -448,21 +378,19 @@ def response(self, messages: List[Message]) -> ModelResponse:
 
         # -*- Log response and metrics
         assistant_message.log()
-        metrics.log()
 
-        # -*- Update model response with assistant message content and audio
+        # -*- Update model response with assistant message content
         if assistant_message.content is not None:
             # add the content to the model response
             model_response.content = assistant_message.get_content_string()
 
         # -*- Handle tool calls
-        tool_role = "tool"
         if (
             self.handle_tool_calls(
                 assistant_message=assistant_message,
                 messages=messages,
                 model_response=model_response,
-                tool_role=tool_role,
+                tool_role=self.tool_message_role,
             )
             is not None
         ):
@@ -483,20 +411,20 @@ async def aresponse(self, messages: List[Message]) -> ModelResponse:
         logger.debug("---------- Groq Async Response Start ----------")
         self._log_messages(messages)
         model_response = ModelResponse()
-        metrics = Metrics()
+
+        # -*- Create assistant message
+        assistant_message = Message(role=self.assistant_message_role)
 
         # -*- Generate response
-        metrics.start_response_timer()
+        assistant_message.metrics.start_timer()
         response: ChatCompletion = await self.ainvoke(messages=messages)
-        metrics.stop_response_timer()
-
-        # -*- Parse response
-        response_message: ChatCompletionMessage = response.choices[0].message
-        response_usage: Optional[CompletionUsage] = response.usage
+        assistant_message.metrics.stop_timer()
 
-        # -*- Create assistant message
-        assistant_message = self.create_assistant_message(
-            response_message=response_message, metrics=metrics, response_usage=response_usage
+        # -*- Populate the assistant message with response message and usage
+        self.populate_assistant_message(
+            assistant_message=assistant_message,
+            response_message=response.choices[0].message,
+            response_usage=response.usage,
         )
 
         # -*- Add assistant message to messages
@@ -504,7 +432,6 @@ async def aresponse(self, messages: List[Message]) -> ModelResponse:
 
         # -*- Log response and metrics
         assistant_message.log()
-        metrics.log()
 
         # -*- Update model response with assistant message content and audio
         if assistant_message.content is not None:
@@ -512,13 +439,12 @@ async def aresponse(self, messages: List[Message]) -> ModelResponse:
             model_response.content = assistant_message.get_content_string()
 
         # -*- Handle tool calls
-        tool_role = "tool"
         if (
             await self.ahandle_tool_calls(
                 assistant_message=assistant_message,
                 messages=messages,
                 model_response=model_response,
-                tool_role=tool_role,
+                tool_role=self.tool_message_role,
             )
             is not None
         ):
@@ -527,61 +453,6 @@ async def aresponse(self, messages: List[Message]) -> ModelResponse:
         logger.debug("---------- Groq Async Response End ----------")
         return model_response
 
-    def update_stream_metrics(self, assistant_message: Message, metrics: Metrics):
-        """
-        Update the usage metrics for the assistant message and the model.
-
-        Args:
-            assistant_message (Message): The assistant message.
-            metrics (Metrics): The metrics.
-        """
-        # Update time taken to generate response
-        assistant_message.metrics["time"] = metrics.response_timer.elapsed
-        self.metrics.setdefault("response_times", []).append(metrics.response_timer.elapsed)
-
-        if metrics.time_to_first_token is not None:
-            assistant_message.metrics["time_to_first_token"] = metrics.time_to_first_token
-            self.metrics.setdefault("time_to_first_token", []).append(metrics.time_to_first_token)
-
-        if metrics.input_tokens is not None:
-            assistant_message.metrics["input_tokens"] = metrics.input_tokens
-            self.metrics["input_tokens"] = self.metrics.get("input_tokens", 0) + metrics.input_tokens
-        if metrics.output_tokens is not None:
-            assistant_message.metrics["output_tokens"] = metrics.output_tokens
-            self.metrics["output_tokens"] = self.metrics.get("output_tokens", 0) + metrics.output_tokens
-        if metrics.prompt_tokens is not None:
-            assistant_message.metrics["prompt_tokens"] = metrics.prompt_tokens
-            self.metrics["prompt_tokens"] = self.metrics.get("prompt_tokens", 0) + metrics.prompt_tokens
-        if metrics.completion_tokens is not None:
-            assistant_message.metrics["completion_tokens"] = metrics.completion_tokens
-            self.metrics["completion_tokens"] = self.metrics.get("completion_tokens", 0) + metrics.completion_tokens
-        if metrics.total_tokens is not None:
-            assistant_message.metrics["total_tokens"] = metrics.total_tokens
-            self.metrics["total_tokens"] = self.metrics.get("total_tokens", 0) + metrics.total_tokens
-        if metrics.completion_time is not None:
-            assistant_message.metrics["completion_time"] = metrics.completion_time
-            self.metrics["completion_time"] = self.metrics.get("completion_time", 0) + metrics.completion_time
-        if metrics.prompt_time is not None:
-            assistant_message.metrics["prompt_time"] = metrics.prompt_time
-            self.metrics["prompt_time"] = self.metrics.get("prompt_time", 0) + metrics.prompt_time
-        if metrics.queue_time is not None:
-            assistant_message.metrics["queue_time"] = metrics.queue_time
-            self.metrics["queue_time"] = self.metrics.get("queue_time", 0) + metrics.queue_time
-        if metrics.total_time is not None:
-            assistant_message.metrics["total_time"] = metrics.total_time
-            self.metrics["total_time"] = self.metrics.get("total_time", 0) + metrics.total_time
-
-    def add_response_usage_to_metrics(self, metrics: Metrics, response_usage: CompletionUsage):
-        metrics.input_tokens = response_usage.prompt_tokens
-        metrics.prompt_tokens = response_usage.prompt_tokens
-        metrics.output_tokens = response_usage.completion_tokens
-        metrics.completion_tokens = response_usage.completion_tokens
-        metrics.total_tokens = response_usage.total_tokens
-        metrics.completion_time = response_usage.completion_time
-        metrics.prompt_time = response_usage.prompt_time
-        metrics.queue_time = response_usage.queue_time
-        metrics.total_time = response_usage.total_time
-
     def response_stream(self, messages: List[Message]) -> Iterator[ModelResponse]:
         """
         Generate a streaming response from Groq.
@@ -595,15 +466,17 @@ def response_stream(self, messages: List[Message]) -> Iterator[ModelResponse]:
         logger.debug("---------- Groq Response Start ----------")
         self._log_messages(messages)
         stream_data: StreamData = StreamData()
-        metrics: Metrics = Metrics()
+
+        # -*- Create assistant message
+        assistant_message = Message(role="assistant")
 
         # -*- Generate response
-        metrics.start_response_timer()
+        assistant_message.metrics.start_timer()
         for response in self.invoke_stream(messages=messages):
             if len(response.choices) > 0:
-                metrics.completion_tokens += 1
-                if metrics.completion_tokens == 1:
-                    metrics.time_to_first_token = metrics.response_timer.elapsed
+                assistant_message.metrics.completion_tokens += 1
+                if assistant_message.metrics.completion_tokens == 1:
+                    assistant_message.metrics.set_time_to_first_token()
 
                 response_delta: ChoiceDelta = response.choices[0].delta
                 response_content: Optional[str] = response_delta.content
@@ -619,34 +492,31 @@ def response_stream(self, messages: List[Message]) -> Iterator[ModelResponse]:
                     stream_data.response_tool_calls.extend(response_tool_calls)
 
             if response.usage is not None:
-                self.add_response_usage_to_metrics(metrics=metrics, response_usage=response.usage)
-        metrics.stop_response_timer()
+                self.add_usage_metrics_to_assistant_message(
+                    assistant_message=assistant_message, response_usage=response.usage
+                )
+        assistant_message.metrics.stop_timer()
 
-        # -*- Create assistant message
-        assistant_message = Message(role="assistant")
+        # -*- Add response content to assistant message
         if stream_data.response_content != "":
             assistant_message.content = stream_data.response_content
 
+        # -*- Add tool calls to assistant message
         if stream_data.response_tool_calls is not None:
             _tool_calls = self.build_tool_calls(stream_data.response_tool_calls)
             if len(_tool_calls) > 0:
                 assistant_message.tool_calls = _tool_calls
 
-        # -*- Update usage metrics
-        self.update_stream_metrics(assistant_message=assistant_message, metrics=metrics)
-
         # -*- Add assistant message to messages
         messages.append(assistant_message)
 
         # -*- Log response and metrics
-        assistant_message.log()
-        metrics.log()
+        assistant_message.log(metrics=True)
 
         # -*- Handle tool calls
         if assistant_message.tool_calls is not None and len(assistant_message.tool_calls) > 0:
-            tool_role = "tool"
             yield from self.handle_stream_tool_calls(
-                assistant_message=assistant_message, messages=messages, tool_role=tool_role
+                assistant_message=assistant_message, messages=messages, tool_role=self.tool_message_role
             )
             yield from self.handle_post_tool_call_messages_stream(messages=messages)
         logger.debug("---------- Groq Response End ----------")
@@ -664,15 +534,17 @@ async def aresponse_stream(self, messages: List[Message]) -> Any:
         logger.debug("---------- Groq Async Response Start ----------")
         self._log_messages(messages)
         stream_data: StreamData = StreamData()
-        metrics: Metrics = Metrics()
+
+        # -*- Create assistant message
+        assistant_message = Message(role="assistant")
 
         # -*- Generate response
-        metrics.start_response_timer()
+        assistant_message.metrics.start_timer()
         async for response in self.ainvoke_stream(messages=messages):
-            if len(response.choices) > 0:
-                metrics.completion_tokens += 1
-                if metrics.completion_tokens == 1:
-                    metrics.time_to_first_token = metrics.response_timer.elapsed
+            if response.choices and len(response.choices) > 0:
+                assistant_message.metrics.completion_tokens += 1
+                if assistant_message.metrics.completion_tokens == 1:
+                    assistant_message.metrics.set_time_to_first_token()
 
                 response_delta: ChoiceDelta = response.choices[0].delta
                 response_content = response_delta.content
@@ -688,47 +560,33 @@ async def aresponse_stream(self, messages: List[Message]) -> Any:
                     stream_data.response_tool_calls.extend(response_tool_calls)
 
             if response.usage is not None:
-                self.add_response_usage_to_metrics(metrics=metrics, response_usage=response.usage)
-        metrics.stop_response_timer()
+                self.add_usage_metrics_to_assistant_message(
+                    assistant_message=assistant_message, response_usage=response.usage
+                )
+        assistant_message.metrics.stop_timer()
 
-        # -*- Create assistant message
-        assistant_message = Message(role="assistant")
+        # -*- Add response content to assistant message
         if stream_data.response_content != "":
             assistant_message.content = stream_data.response_content
 
+        # -*- Add tool calls to assistant message
         if stream_data.response_tool_calls is not None:
             _tool_calls = self.build_tool_calls(stream_data.response_tool_calls)
             if len(_tool_calls) > 0:
                 assistant_message.tool_calls = _tool_calls
 
-        self.update_stream_metrics(assistant_message=assistant_message, metrics=metrics)
-
         # -*- Add assistant message to messages
         messages.append(assistant_message)
 
         # -*- Log response and metrics
-        assistant_message.log()
-        metrics.log()
+        assistant_message.log(metrics=True)
 
         # -*- Handle tool calls
         if assistant_message.tool_calls is not None and len(assistant_message.tool_calls) > 0:
-            tool_role = "tool"
             async for tool_call_response in self.ahandle_stream_tool_calls(
-                assistant_message=assistant_message, messages=messages, tool_role=tool_role
+                assistant_message=assistant_message, messages=messages, tool_role=self.tool_message_role
             ):
                 yield tool_call_response
             async for post_tool_call_response in self.ahandle_post_tool_call_messages_stream(messages=messages):
                 yield post_tool_call_response
         logger.debug("---------- Groq Async Response End ----------")
-
-    def build_tool_calls(self, tool_calls_data: List[ChoiceDeltaToolCall]) -> List[Dict[str, Any]]:
-        """
-        Build tool calls from tool call data.
-
-        Args:
-            tool_calls_data (List[ChoiceDeltaToolCall]): The tool call data to build from.
-
-        Returns:
-            List[Dict[str, Any]]: The built tool calls.
-        """
-        return self._build_tool_calls(tool_calls_data)
diff --git a/libs/agno/agno/models/huggingface/huggingface.py b/libs/agno/agno/models/huggingface/huggingface.py
index ea3fc1634b..9a8db3f47c 100644
--- a/libs/agno/agno/models/huggingface/huggingface.py
+++ b/libs/agno/agno/models/huggingface/huggingface.py
@@ -697,7 +697,7 @@ def response_stream(self, messages: List[Message]) -> Iterator[ModelResponse]:
             assistant_message.content = stream_data.response_content
 
         if stream_data.response_tool_calls is not None:
-            _tool_calls = self._build_tool_calls(stream_data.response_tool_calls)
+            _tool_calls = self.build_tool_calls(stream_data.response_tool_calls)
             if len(_tool_calls) > 0:
                 assistant_message.tool_calls = _tool_calls
 
@@ -753,7 +753,7 @@ async def aresponse_stream(self, messages: List[Message]) -> Any:
             assistant_message.content = stream_data.response_content
 
         if stream_data.response_tool_calls is not None:
-            _tool_calls = self._build_tool_calls(stream_data.response_tool_calls)
+            _tool_calls = self.build_tool_calls(stream_data.response_tool_calls)
             if len(_tool_calls) > 0:
                 assistant_message.tool_calls = _tool_calls
 
diff --git a/libs/agno/agno/models/message.py b/libs/agno/agno/models/message.py
index f256fa464e..c23be0511d 100644
--- a/libs/agno/agno/models/message.py
+++ b/libs/agno/agno/models/message.py
@@ -1,11 +1,13 @@
 import json
+from dataclasses import dataclass
 from time import time
 from typing import Any, Dict, List, Optional, Sequence, Union
 
-from pydantic import BaseModel, ConfigDict, Field
+from pydantic import BaseModel, ConfigDict, Field, model_serializer
 
 from agno.media import Audio, AudioOutput, Image, Video
 from agno.utils.log import logger
+from agno.utils.timer import Timer
 
 
 class MessageReferences(BaseModel):
@@ -19,6 +21,96 @@ class MessageReferences(BaseModel):
     time: Optional[float] = None
 
 
+@dataclass
+class MessageMetrics:
+    input_tokens: int = 0
+    output_tokens: int = 0
+    total_tokens: int = 0
+
+    prompt_tokens: int = 0
+    completion_tokens: int = 0
+    prompt_tokens_details: Optional[dict] = None
+    completion_tokens_details: Optional[dict] = None
+
+    additional_metrics: Optional[dict] = None
+
+    time: Optional[float] = None
+    time_to_first_token: Optional[float] = None
+
+    timer: Optional[Timer] = None
+
+    def start_timer(self):
+        if self.timer is None:
+            self.timer = Timer()
+        self.timer.start()
+
+    def stop_timer(self, set_time: bool = True):
+        if self.timer is not None:
+            self.timer.stop()
+            if set_time:
+                self.time = self.timer.elapsed
+
+    def set_time_to_first_token(self):
+        if self.timer is not None:
+            self.time_to_first_token = self.timer.elapsed
+
+    def __add__(self, other: "MessageMetrics") -> "MessageMetrics":
+        # Create new instance with summed basic metrics
+        result = MessageMetrics(
+            input_tokens=self.input_tokens + other.input_tokens,
+            output_tokens=self.output_tokens + other.output_tokens,
+            total_tokens=self.total_tokens + other.total_tokens,
+            prompt_tokens=self.prompt_tokens + other.prompt_tokens,
+            completion_tokens=self.completion_tokens + other.completion_tokens,
+        )
+
+        # Handle prompt_tokens_details
+        if self.prompt_tokens_details or other.prompt_tokens_details:
+            result.prompt_tokens_details = {}
+            # Merge from self
+            if self.prompt_tokens_details:
+                result.prompt_tokens_details.update(self.prompt_tokens_details)
+            # Add values from other
+            if other.prompt_tokens_details:
+                for key, value in other.prompt_tokens_details.items():
+                    result.prompt_tokens_details[key] = result.prompt_tokens_details.get(key, 0) + value
+
+        # Handle completion_tokens_details similarly
+        if self.completion_tokens_details or other.completion_tokens_details:
+            result.completion_tokens_details = {}
+            if self.completion_tokens_details:
+                result.completion_tokens_details.update(self.completion_tokens_details)
+            if other.completion_tokens_details:
+                for key, value in other.completion_tokens_details.items():
+                    result.completion_tokens_details[key] = result.completion_tokens_details.get(key, 0) + value
+
+        # Handle additional metrics
+        if self.additional_metrics or other.additional_metrics:
+            result.additional_metrics = {}
+            if self.additional_metrics:
+                result.additional_metrics.update(self.additional_metrics)
+            if other.additional_metrics:
+                result.additional_metrics.update(other.additional_metrics)
+
+        # Sum times if both exist
+        if self.time is not None and other.time is not None:
+            result.time = self.time + other.time
+        elif self.time is not None:
+            result.time = self.time
+        elif other.time is not None:
+            result.time = other.time
+
+        # Handle time_to_first_token (take the first non-None value)
+        result.time_to_first_token = self.time_to_first_token or other.time_to_first_token
+
+        return result
+
+    def __radd__(self, other: "MessageMetrics") -> "MessageMetrics":
+        if other == 0:  # Handle sum() starting value
+            return self
+        return self + other
+
+
 class Message(BaseModel):
     """Message sent to the Model"""
 
@@ -57,13 +149,13 @@ class Message(BaseModel):
     # When True, the message will be added to the agent's memory.
     add_to_agent_memory: bool = True
     # Metrics for the message.
-    metrics: Dict[str, Any] = Field(default_factory=dict)
+    metrics: MessageMetrics = Field(default_factory=MessageMetrics)
     # The references added to the message for RAG
     references: Optional[MessageReferences] = None
     # The Unix timestamp the message was created.
     created_at: int = Field(default_factory=lambda: int(time()))
 
-    model_config = ConfigDict(extra="allow", populate_by_name=True)
+    model_config = ConfigDict(extra="allow", populate_by_name=True, arbitrary_types_allowed=True)
 
     def get_content_string(self) -> str:
         """Returns the content as a string."""
@@ -76,12 +168,19 @@ def get_content_string(self) -> str:
         return ""
 
     def to_dict(self) -> Dict[str, Any]:
-        _dict = self.model_dump(
-            exclude_none=True,
-            include={"role", "content", "audio", "name", "tool_call_id", "tool_calls"},
-        )
-
-        # Add a message's output as now input (for multi-turn audio)
+        _dict: Dict[str, Any] = {
+            "role": self.role,
+            "content": self.content,
+            "name": self.name,
+            "tool_call_id": self.tool_call_id,
+            "tool_calls": self.tool_calls,
+            "audio": self.audio,
+        }
+
+        # Remove None values
+        _dict = {k: v for k, v in _dict.items() if v is not None}
+
+        # Add the message's output as then input (for multi-turn audio)
         if self.audio_output is not None:
             _dict["content"] = None
             _dict["audio"] = {"id": self.audio_output.id}
@@ -92,16 +191,20 @@ def to_dict(self) -> Dict[str, Any]:
 
         return _dict
 
-    def log(self, level: Optional[str] = None):
+    @model_serializer
+    def serialize_model(self):
+        return self.to_dict()
+
+    def log(self, metrics: bool = False, level: Optional[str] = None):
         """Log the message to the console
 
-        @param level: The level to log the message at. One of debug, info, warning, or error.
-            Defaults to debug.
+        Args:
+            metrics (bool): Whether to log the metrics.
+            level (str): The level to log the message at. One of debug, info, warning, or error.
+                Defaults to debug.
         """
         _logger = logger.debug
-        if level == "debug":
-            _logger = logger.debug
-        elif level == "info":
+        if level == "info":
             _logger = logger.info
         elif level == "warning":
             _logger = logger.warning
@@ -127,6 +230,24 @@ def log(self, level: Optional[str] = None):
         if self.audio:
             _logger(f"Audio Files added: {len(self.audio)}")
 
+        if metrics and self.metrics is not None:
+            _logger("**************** METRICS ****************")
+            _logger(f"* Input tokens:                {self.metrics.input_tokens}")
+            _logger(f"* Output tokens:               {self.metrics.output_tokens}")
+            _logger(f"* Total tokens:                {self.metrics.total_tokens}")
+            if self.metrics.prompt_tokens_details:
+                _logger(f"* Prompt tokens details:       {self.metrics.prompt_tokens_details}")
+            if self.metrics.completion_tokens_details:
+                _logger(f"* Completion tokens details:   {self.metrics.completion_tokens_details}")
+            if self.metrics.time is not None:
+                _logger(f"* Time:                        {self.metrics.time:.4f}s")
+                _logger(f"* Tokens per second:           {self.metrics.output_tokens / self.metrics.time:.4f} tokens/s")
+            if self.metrics.time_to_first_token is not None:
+                _logger(f"* Time to first token:         {self.metrics.time_to_first_token:.4f}s")
+            if self.metrics.additional_metrics:
+                _logger(f"* Additional metrics:          {self.metrics.additional_metrics}")
+            _logger("**************** METRICS ******************")
+
     def content_is_valid(self) -> bool:
         """Check if the message content is valid."""
 
diff --git a/libs/agno/agno/models/ollama/chat.py b/libs/agno/agno/models/ollama/chat.py
index 024b95915f..4e0ab73ba0 100644
--- a/libs/agno/agno/models/ollama/chat.py
+++ b/libs/agno/agno/models/ollama/chat.py
@@ -274,7 +274,7 @@ def handle_tool_calls(
 
             model_response.content = assistant_message.get_content_string()
             model_response.content += "\n\n"
-            function_calls_to_run = self._get_function_calls_to_run(assistant_message, messages)
+            function_calls_to_run = self.get_function_calls_to_run(assistant_message, messages)
             function_call_results: List[Message] = []
 
             if self.show_tool_calls:
@@ -521,7 +521,7 @@ def handle_stream_tool_calls(
         """
         if assistant_message.tool_calls is not None and len(assistant_message.tool_calls) > 0:
             yield ModelResponse(content="\n\n")
-            function_calls_to_run = self._get_function_calls_to_run(assistant_message, messages)
+            function_calls_to_run = self.get_function_calls_to_run(assistant_message, messages)
             function_call_results: List[Message] = []
 
             if self.show_tool_calls:
diff --git a/libs/agno/agno/models/ollama/tools.py b/libs/agno/agno/models/ollama/tools.py
index 48066af397..bae31207e9 100644
--- a/libs/agno/agno/models/ollama/tools.py
+++ b/libs/agno/agno/models/ollama/tools.py
@@ -158,7 +158,7 @@ def handle_tool_calls(
         if assistant_message.tool_calls is not None and len(assistant_message.tool_calls) > 0:
             model_response.content = str(remove_tool_calls_from_string(assistant_message.get_content_string()))
             model_response.content += "\n\n"
-            function_calls_to_run = self._get_function_calls_to_run(assistant_message, messages)
+            function_calls_to_run = self.get_function_calls_to_run(assistant_message, messages)
             function_call_results: List[Message] = []
 
             if self.show_tool_calls:
diff --git a/libs/agno/agno/models/openai/chat.py b/libs/agno/agno/models/openai/chat.py
index f55984bec5..955dac9873 100644
--- a/libs/agno/agno/models/openai/chat.py
+++ b/libs/agno/agno/models/openai/chat.py
@@ -6,7 +6,7 @@
 from pydantic import BaseModel
 
 from agno.media import AudioOutput
-from agno.models.base import Metrics, Model
+from agno.models.base import Model
 from agno.models.message import Message
 from agno.models.response import ModelResponse
 from agno.utils.log import logger
@@ -88,35 +88,33 @@ class OpenAIChat(Model):
     # Internal parameters. Not used for API requests
     # Whether to use the structured outputs with this Model.
     structured_outputs: bool = False
-
     # Whether to override the system role.
     override_system_role: bool = True
     # The role to map the system message to.
     system_message_role: str = "developer"
 
     def _get_client_params(self) -> Dict[str, Any]:
-        client_params: Dict[str, Any] = {}
-
-        self.api_key = self.api_key or getenv("OPENAI_API_KEY")
+        # Fetch API key from env if not already set
         if not self.api_key:
-            logger.error("OPENAI_API_KEY not set. Please set the OPENAI_API_KEY environment variable.")
-
-        client_params.update(
-            {
-                "api_key": self.api_key,
-                "organization": self.organization,
-                "base_url": self.base_url,
-                "timeout": self.timeout,
-                "max_retries": self.max_retries,
-                "default_headers": self.default_headers,
-                "default_query": self.default_query,
-            }
-        )
-        if self.client_params is not None:
+            self.api_key = getenv("OPENAI_API_KEY")
+            if not self.api_key:
+                logger.error("OPENAI_API_KEY not set. Please set the OPENAI_API_KEY environment variable.")
+
+        # Define base client params
+        base_params = {
+            "api_key": self.api_key,
+            "organization": self.organization,
+            "base_url": self.base_url,
+            "timeout": self.timeout,
+            "max_retries": self.max_retries,
+            "default_headers": self.default_headers,
+            "default_query": self.default_query,
+        }
+        # Create client_params dict with non-None values
+        client_params = {k: v for k, v in base_params.items() if v is not None}
+        # Add additional client params if provided
+        if self.client_params:
             client_params.update(self.client_params)
-
-        # Remove None
-        client_params = {k: v for k, v in client_params.items() if v is not None}
         return client_params
 
     def get_client(self) -> OpenAIClient:
@@ -164,43 +162,38 @@ def request_kwargs(self) -> Dict[str, Any]:
         Returns:
             Dict[str, Any]: A dictionary of keyword arguments for API requests.
         """
-        request_params: Dict[str, Any] = {}
-
-        request_params.update(
-            {
-                "store": self.store,
-                "reasoning_effort": self.reasoning_effort,
-                "frequency_penalty": self.frequency_penalty,
-                "logit_bias": self.logit_bias,
-                "logprobs": self.logprobs,
-                "top_logprobs": self.top_logprobs,
-                "max_tokens": self.max_tokens,
-                "max_completion_tokens": self.max_completion_tokens,
-                "modalities": self.modalities,
-                "audio": self.audio,
-                "presence_penalty": self.presence_penalty,
-                "response_format": self.response_format,
-                "seed": self.seed,
-                "stop": self.stop,
-                "temperature": self.temperature,
-                "user": self.user,
-                "top_p": self.top_p,
-                "extra_headers": self.extra_headers,
-                "extra_query": self.extra_query,
-            }
-        )
+        # Define base request parameters
+        base_params = {
+            "store": self.store,
+            "reasoning_effort": self.reasoning_effort,
+            "frequency_penalty": self.frequency_penalty,
+            "logit_bias": self.logit_bias,
+            "logprobs": self.logprobs,
+            "top_logprobs": self.top_logprobs,
+            "max_tokens": self.max_tokens,
+            "max_completion_tokens": self.max_completion_tokens,
+            "modalities": self.modalities,
+            "audio": self.audio,
+            "presence_penalty": self.presence_penalty,
+            "response_format": self.response_format,
+            "seed": self.seed,
+            "stop": self.stop,
+            "temperature": self.temperature,
+            "user": self.user,
+            "top_p": self.top_p,
+            "extra_headers": self.extra_headers,
+            "extra_query": self.extra_query,
+        }
+        # Filter out None values
+        request_params = {k: v for k, v in base_params.items() if v is not None}
+        # Add tools
         if self.tools is not None:
             request_params["tools"] = self.tools
-            if self.tool_choice is None:
-                request_params["tool_choice"] = "auto"
-            else:
-                request_params["tool_choice"] = self.tool_choice
-
-        if self.request_params is not None:
+        if self.tool_choice is not None:
+            request_params["tool_choice"] = self.tool_choice
+        # Add additional request params if provided
+        if self.request_params:
             request_params.update(self.request_params)
-
-        # Remove None
-        request_params = {k: v for k, v in request_params.items() if v is not None}
         return request_params
 
     def to_dict(self) -> Dict[str, Any]:
@@ -210,8 +203,8 @@ def to_dict(self) -> Dict[str, Any]:
         Returns:
             Dict[str, Any]: The dictionary representation of the model.
         """
-        _dict = super().to_dict()
-        _dict.update(
+        model_dict = super().to_dict()
+        model_dict.update(
             {
                 "store": self.store,
                 "frequency_penalty": self.frequency_penalty,
@@ -236,12 +229,10 @@ def to_dict(self) -> Dict[str, Any]:
             }
         )
         if self.tools is not None:
-            _dict["tools"] = self.tools
-            if self.tool_choice is None:
-                _dict["tool_choice"] = "auto"
-            else:
-                _dict["tool_choice"] = self.tool_choice
-        cleaned_dict = {k: v for k, v in _dict.items() if v is not None}
+            model_dict["tools"] = self.tools
+            if self.tool_choice is not None:
+                model_dict["tool_choice"] = self.tool_choice
+        cleaned_dict = {k: v for k, v in model_dict.items() if v is not None}
         return cleaned_dict
 
     def format_message(self, message: Message) -> Dict[str, Any]:
@@ -366,90 +357,66 @@ async def ainvoke_stream(self, messages: List[Message]) -> Any:
         async for chunk in async_stream:  # type: ignore
             yield chunk
 
-    def update_usage_metrics(
-        self, assistant_message: Message, metrics: Metrics, response_usage: Optional[CompletionUsage]
-    ) -> None:
-        """
-        Update the usage metrics for the assistant message and the model.
+    def add_usage_metrics_to_assistant_message(self, assistant_message: Message, response_usage: CompletionUsage):
+        assistant_message.metrics.input_tokens = response_usage.prompt_tokens
+        assistant_message.metrics.output_tokens = response_usage.completion_tokens
+        assistant_message.metrics.total_tokens = response_usage.total_tokens
 
-        Args:
-            assistant_message (Message): The assistant message.
-            metrics (Metrics): The metrics.
-            response_usage (Optional[CompletionUsage]): The response usage.
-        """
-        # Update time taken to generate response
-        assistant_message.metrics["time"] = metrics.response_timer.elapsed
-        self.metrics.setdefault("response_times", []).append(metrics.response_timer.elapsed)
-        if response_usage:
-            prompt_tokens = response_usage.prompt_tokens
-            completion_tokens = response_usage.completion_tokens
-            total_tokens = response_usage.total_tokens
-
-            if prompt_tokens is not None:
-                metrics.input_tokens = prompt_tokens
-                metrics.prompt_tokens = prompt_tokens
-                assistant_message.metrics["input_tokens"] = prompt_tokens
-                assistant_message.metrics["prompt_tokens"] = prompt_tokens
-                self.metrics["input_tokens"] = self.metrics.get("input_tokens", 0) + prompt_tokens
-                self.metrics["prompt_tokens"] = self.metrics.get("prompt_tokens", 0) + prompt_tokens
-            if completion_tokens is not None:
-                metrics.output_tokens = completion_tokens
-                metrics.completion_tokens = completion_tokens
-                assistant_message.metrics["output_tokens"] = completion_tokens
-                assistant_message.metrics["completion_tokens"] = completion_tokens
-                self.metrics["output_tokens"] = self.metrics.get("output_tokens", 0) + completion_tokens
-                self.metrics["completion_tokens"] = self.metrics.get("completion_tokens", 0) + completion_tokens
-            if total_tokens is not None:
-                metrics.total_tokens = total_tokens
-                assistant_message.metrics["total_tokens"] = total_tokens
-                self.metrics["total_tokens"] = self.metrics.get("total_tokens", 0) + total_tokens
-            if response_usage.prompt_tokens_details is not None:
-                if isinstance(response_usage.prompt_tokens_details, dict):
-                    metrics.prompt_tokens_details = response_usage.prompt_tokens_details
-                elif isinstance(response_usage.prompt_tokens_details, BaseModel):
-                    metrics.prompt_tokens_details = response_usage.prompt_tokens_details.model_dump(exclude_none=True)
-                assistant_message.metrics["prompt_tokens_details"] = metrics.prompt_tokens_details
-                if metrics.prompt_tokens_details is not None:
-                    for k, v in metrics.prompt_tokens_details.items():
-                        self.metrics.get("prompt_tokens_details", {}).get(k, 0) + v
-            if response_usage.completion_tokens_details is not None:
-                if isinstance(response_usage.completion_tokens_details, dict):
-                    metrics.completion_tokens_details = response_usage.completion_tokens_details
-                elif isinstance(response_usage.completion_tokens_details, BaseModel):
-                    metrics.completion_tokens_details = response_usage.completion_tokens_details.model_dump(
-                        exclude_none=True
-                    )
-                assistant_message.metrics["completion_tokens_details"] = metrics.completion_tokens_details
-                if metrics.completion_tokens_details is not None:
-                    for k, v in metrics.completion_tokens_details.items():
-                        self.metrics.get("completion_tokens_details", {}).get(k, 0) + v
+        assistant_message.metrics.prompt_tokens = response_usage.prompt_tokens
+        assistant_message.metrics.completion_tokens = response_usage.completion_tokens
+        if response_usage.prompt_tokens_details is not None:
+            if isinstance(response_usage.prompt_tokens_details, dict):
+                assistant_message.metrics.prompt_tokens_details = response_usage.prompt_tokens_details
+            elif isinstance(response_usage.prompt_tokens_details, BaseModel):
+                assistant_message.metrics.prompt_tokens_details = response_usage.prompt_tokens_details.model_dump(
+                    exclude_none=True
+                )
+        if response_usage.completion_tokens_details is not None:
+            if isinstance(response_usage.completion_tokens_details, dict):
+                assistant_message.metrics.completion_tokens_details = response_usage.completion_tokens_details
+            elif isinstance(response_usage.completion_tokens_details, BaseModel):
+                assistant_message.metrics.completion_tokens_details = (
+                    response_usage.completion_tokens_details.model_dump(exclude_none=True)
+                )
 
-    def create_assistant_message(
+    def populate_assistant_message(
         self,
+        assistant_message: Message,
         response_message: ChatCompletionMessage,
-        metrics: Metrics,
         response_usage: Optional[CompletionUsage],
     ) -> Message:
         """
-        Create an assistant message from the response.
+        Populate an assistant message with the response message and usage.
 
         Args:
+            assistant_message (Message): The assistant message to populate.
             response_message (ChatCompletionMessage): The response message.
-            metrics (Metrics): The metrics.
             response_usage (Optional[CompletionUsage]): The response usage.
 
         Returns:
             Message: The assistant message.
         """
-        assistant_message = Message(
-            role=response_message.role or "assistant",
-            content=response_message.content,
-        )
+        # -*- Update role
+        if response_message.role:
+            assistant_message.role = response_message.role
+
+        # -*- Add content to assistant message
+        if response_message.content is not None:
+            assistant_message.content = response_message.content
+
+        # -*- Add audio transcript to content if available
+        response_audio: Optional[ChatCompletionAudio] = response_message.audio
+        if response_audio and response_audio.transcript and not assistant_message.content:
+            assistant_message.content = response_audio.transcript
+
+        # -*- Add tool calls to assistant message
         if response_message.tool_calls is not None and len(response_message.tool_calls) > 0:
             try:
                 assistant_message.tool_calls = [t.model_dump() for t in response_message.tool_calls]
             except Exception as e:
                 logger.warning(f"Error processing tool calls: {e}")
+
+        # -*- Add audio to assistant message
         if hasattr(response_message, "audio") and response_message.audio is not None:
             try:
                 assistant_message.audio_output = AudioOutput(
@@ -461,8 +428,11 @@ def create_assistant_message(
             except Exception as e:
                 logger.warning(f"Error processing audio: {e}")
 
-        # Update metrics
-        self.update_usage_metrics(assistant_message, metrics, response_usage)
+        # -*- Add usage metrics to assistant message
+        if response_usage is not None:
+            self.add_usage_metrics_to_assistant_message(
+                assistant_message=assistant_message, response_usage=response_usage
+            )
         return assistant_message
 
     def response(self, messages: List[Message]) -> ModelResponse:
@@ -478,23 +448,17 @@ def response(self, messages: List[Message]) -> ModelResponse:
         logger.debug(f"---------- {self.get_provider()} Response Start ----------")
         self._log_messages(messages)
         model_response = ModelResponse()
-        metrics = Metrics()
+
+        # -*- Create assistant message
+        assistant_message = Message(role=self.assistant_message_role)
 
         # -*- Generate response
-        metrics.start_response_timer()
+        assistant_message.metrics.start_timer()
         response: Union[ChatCompletion, ParsedChatCompletion] = self.invoke(messages=messages)
-        metrics.stop_response_timer()
+        assistant_message.metrics.stop_timer()
 
-        # -*- Parse response
+        # -*- Parse response message
         response_message: ChatCompletionMessage = response.choices[0].message
-        response_usage: Optional[CompletionUsage] = response.usage
-        response_audio: Optional[ChatCompletionAudio] = response_message.audio
-
-        # -*- Parse transcript if available
-        if response_audio:
-            if response_audio.transcript and not response_message.content:
-                response_message.content = response_audio.transcript
-
         # -*- Parse structured outputs
         try:
             if (
@@ -508,9 +472,11 @@ def response(self, messages: List[Message]) -> ModelResponse:
         except Exception as e:
             logger.warning(f"Error retrieving structured outputs: {e}")
 
-        # -*- Create assistant message
-        assistant_message = self.create_assistant_message(
-            response_message=response_message, metrics=metrics, response_usage=response_usage
+        # -*- Populate the assistant message with response message and usage
+        self.populate_assistant_message(
+            assistant_message=assistant_message,
+            response_message=response_message,
+            response_usage=response.usage,
         )
 
         # -*- Add assistant message to messages
@@ -518,7 +484,6 @@ def response(self, messages: List[Message]) -> ModelResponse:
 
         # -*- Log response and metrics
         assistant_message.log()
-        metrics.log()
 
         # -*- Update model response with assistant message content and audio
         if assistant_message.content is not None:
@@ -529,13 +494,12 @@ def response(self, messages: List[Message]) -> ModelResponse:
             model_response.audio = assistant_message.audio_output
 
         # -*- Handle tool calls
-        tool_role = "tool"
         if (
             self.handle_tool_calls(
                 assistant_message=assistant_message,
                 messages=messages,
                 model_response=model_response,
-                tool_role=tool_role,
+                tool_role=self.tool_message_role,
             )
             is not None
         ):
@@ -556,23 +520,17 @@ async def aresponse(self, messages: List[Message]) -> ModelResponse:
         logger.debug(f"---------- {self.get_provider()} Async Response Start ----------")
         self._log_messages(messages)
         model_response = ModelResponse()
-        metrics = Metrics()
+
+        # -*- Create assistant message
+        assistant_message = Message(role=self.assistant_message_role)
 
         # -*- Generate response
-        metrics.start_response_timer()
+        assistant_message.metrics.start_timer()
         response: Union[ChatCompletion, ParsedChatCompletion] = await self.ainvoke(messages=messages)
-        metrics.stop_response_timer()
+        assistant_message.metrics.stop_timer()
 
         # -*- Parse response
         response_message: ChatCompletionMessage = response.choices[0].message
-        response_usage: Optional[CompletionUsage] = response.usage
-        response_audio: Optional[ChatCompletionAudio] = response_message.audio
-
-        # -*- Parse transcript if available
-        if response_audio:
-            if response_audio.transcript and not response_message.content:
-                response_message.content = response_audio.transcript
-
         # -*- Parse structured outputs
         try:
             if (
@@ -586,9 +544,11 @@ async def aresponse(self, messages: List[Message]) -> ModelResponse:
         except Exception as e:
             logger.warning(f"Error retrieving structured outputs: {e}")
 
-        # -*- Create assistant message
-        assistant_message = self.create_assistant_message(
-            response_message=response_message, metrics=metrics, response_usage=response_usage
+        # -*- Populate the assistant message with response message and usage
+        self.populate_assistant_message(
+            assistant_message=assistant_message,
+            response_message=response_message,
+            response_usage=response.usage,
         )
 
         # -*- Add assistant message to messages
@@ -596,7 +556,6 @@ async def aresponse(self, messages: List[Message]) -> ModelResponse:
 
         # -*- Log response and metrics
         assistant_message.log()
-        metrics.log()
 
         # -*- Update model response with assistant message content and audio
         if assistant_message.content is not None:
@@ -607,13 +566,12 @@ async def aresponse(self, messages: List[Message]) -> ModelResponse:
             model_response.audio = assistant_message.audio_output
 
         # -*- Handle tool calls
-        tool_role = "tool"
         if (
             await self.ahandle_tool_calls(
                 assistant_message=assistant_message,
                 messages=messages,
                 model_response=model_response,
-                tool_role=tool_role,
+                tool_role=self.tool_message_role,
             )
             is not None
         ):
@@ -622,65 +580,6 @@ async def aresponse(self, messages: List[Message]) -> ModelResponse:
         logger.debug(f"---------- {self.get_provider()} Async Response End ----------")
         return model_response
 
-    def update_stream_metrics(self, assistant_message: Message, metrics: Metrics):
-        """
-        Update the usage metrics for the assistant message and the model.
-
-        Args:
-            assistant_message (Message): The assistant message.
-            metrics (Metrics): The metrics.
-        """
-        # Update time taken to generate response
-        assistant_message.metrics["time"] = metrics.response_timer.elapsed
-        self.metrics.setdefault("response_times", []).append(metrics.response_timer.elapsed)
-
-        if metrics.time_to_first_token is not None:
-            assistant_message.metrics["time_to_first_token"] = metrics.time_to_first_token
-            self.metrics.setdefault("time_to_first_token", []).append(metrics.time_to_first_token)
-
-        if metrics.input_tokens is not None:
-            assistant_message.metrics["input_tokens"] = metrics.input_tokens
-            self.metrics["input_tokens"] = self.metrics.get("input_tokens", 0) + metrics.input_tokens
-        if metrics.output_tokens is not None:
-            assistant_message.metrics["output_tokens"] = metrics.output_tokens
-            self.metrics["output_tokens"] = self.metrics.get("output_tokens", 0) + metrics.output_tokens
-        if metrics.prompt_tokens is not None:
-            assistant_message.metrics["prompt_tokens"] = metrics.prompt_tokens
-            self.metrics["prompt_tokens"] = self.metrics.get("prompt_tokens", 0) + metrics.prompt_tokens
-        if metrics.completion_tokens is not None:
-            assistant_message.metrics["completion_tokens"] = metrics.completion_tokens
-            self.metrics["completion_tokens"] = self.metrics.get("completion_tokens", 0) + metrics.completion_tokens
-        if metrics.total_tokens is not None:
-            assistant_message.metrics["total_tokens"] = metrics.total_tokens
-            self.metrics["total_tokens"] = self.metrics.get("total_tokens", 0) + metrics.total_tokens
-        if metrics.prompt_tokens_details is not None:
-            assistant_message.metrics["prompt_tokens_details"] = metrics.prompt_tokens_details
-            for k, v in metrics.prompt_tokens_details.items():
-                self.metrics.get("prompt_tokens_details", {}).get(k, 0) + v
-        if metrics.completion_tokens_details is not None:
-            assistant_message.metrics["completion_tokens_details"] = metrics.completion_tokens_details
-            for k, v in metrics.completion_tokens_details.items():
-                self.metrics.get("completion_tokens_details", {}).get(k, 0) + v
-
-    def add_response_usage_to_metrics(self, metrics: Metrics, response_usage: CompletionUsage):
-        metrics.input_tokens = response_usage.prompt_tokens
-        metrics.prompt_tokens = response_usage.prompt_tokens
-        metrics.output_tokens = response_usage.completion_tokens
-        metrics.completion_tokens = response_usage.completion_tokens
-        metrics.total_tokens = response_usage.total_tokens
-        if response_usage.prompt_tokens_details is not None:
-            if isinstance(response_usage.prompt_tokens_details, dict):
-                metrics.prompt_tokens_details = response_usage.prompt_tokens_details
-            elif isinstance(response_usage.prompt_tokens_details, BaseModel):
-                metrics.prompt_tokens_details = response_usage.prompt_tokens_details.model_dump(exclude_none=True)
-        if response_usage.completion_tokens_details is not None:
-            if isinstance(response_usage.completion_tokens_details, dict):
-                metrics.completion_tokens_details = response_usage.completion_tokens_details
-            elif isinstance(response_usage.completion_tokens_details, BaseModel):
-                metrics.completion_tokens_details = response_usage.completion_tokens_details.model_dump(
-                    exclude_none=True
-                )
-
     def response_stream(self, messages: List[Message]) -> Iterator[ModelResponse]:
         """
         Generate a streaming response from OpenAI.
@@ -694,21 +593,30 @@ def response_stream(self, messages: List[Message]) -> Iterator[ModelResponse]:
         logger.debug(f"---------- {self.get_provider()} Response Start ----------")
         self._log_messages(messages)
         stream_data: StreamData = StreamData()
-        metrics: Metrics = Metrics()
+
+        # -*- Create assistant message
+        assistant_message = Message(role="assistant")
 
         # -*- Generate response
-        metrics.start_response_timer()
+        assistant_message.metrics.start_timer()
         for response in self.invoke_stream(messages=messages):
             if len(response.choices) > 0:
-                metrics.completion_tokens += 1
-                if metrics.completion_tokens == 1:
-                    metrics.time_to_first_token = metrics.response_timer.elapsed
+                assistant_message.metrics.completion_tokens += 1
+                if assistant_message.metrics.completion_tokens == 1:
+                    assistant_message.metrics.set_time_to_first_token()
 
                 response_delta: ChoiceDelta = response.choices[0].delta
+                response_content: Optional[str] = response_delta.content
+                response_tool_calls: Optional[List[ChoiceDeltaToolCall]] = response_delta.tool_calls
+
+                if response_content is not None:
+                    stream_data.response_content += response_content
+                    yield ModelResponse(content=response_content)
 
-                if response_delta.content is not None:
-                    stream_data.response_content += response_delta.content
-                    yield ModelResponse(content=response_delta.content)
+                if response_tool_calls is not None:
+                    if stream_data.response_tool_calls is None:
+                        stream_data.response_tool_calls = []
+                    stream_data.response_tool_calls.extend(response_tool_calls)
 
                 if hasattr(response_delta, "audio"):
                     response_audio = response_delta.audio
@@ -723,20 +631,15 @@ def response_stream(self, messages: List[Message]) -> Iterator[ModelResponse]:
                             )
                         )
 
-                if response_delta.tool_calls is not None:
-                    if stream_data.response_tool_calls is None:
-                        stream_data.response_tool_calls = []
-                    stream_data.response_tool_calls.extend(response_delta.tool_calls)
-
             if response.usage is not None:
-                self.add_response_usage_to_metrics(metrics=metrics, response_usage=response.usage)
-        metrics.stop_response_timer()
+                self.add_usage_metrics_to_assistant_message(
+                    assistant_message=assistant_message, response_usage=response.usage
+                )
+        assistant_message.metrics.stop_timer()
 
-        # -*- Create assistant message
-        assistant_message = Message(role="assistant")
+        # -*- Add response content and audio to assistant message
         if stream_data.response_content != "":
             assistant_message.content = stream_data.response_content
-
         if stream_data.response_audio is not None:
             assistant_message.audio_output = AudioOutput(
                 id=stream_data.response_audio.id,
@@ -745,26 +648,22 @@ def response_stream(self, messages: List[Message]) -> Iterator[ModelResponse]:
                 transcript=stream_data.response_audio.transcript,
             )
 
+        # -*- Add tool calls to assistant message
         if stream_data.response_tool_calls is not None:
             _tool_calls = self.build_tool_calls(stream_data.response_tool_calls)
             if len(_tool_calls) > 0:
                 assistant_message.tool_calls = _tool_calls
 
-        # -*- Update usage metrics
-        self.update_stream_metrics(assistant_message=assistant_message, metrics=metrics)
-
         # -*- Add assistant message to messages
         messages.append(assistant_message)
 
         # -*- Log response and metrics
-        assistant_message.log()
-        metrics.log()
+        assistant_message.log(metrics=True)
 
         # -*- Handle tool calls
         if assistant_message.tool_calls is not None and len(assistant_message.tool_calls) > 0:
-            tool_role = "tool"
             yield from self.handle_stream_tool_calls(
-                assistant_message=assistant_message, messages=messages, tool_role=tool_role
+                assistant_message=assistant_message, messages=messages, tool_role=self.tool_message_role
             )
             yield from self.handle_post_tool_call_messages_stream(messages=messages)
         logger.debug(f"---------- {self.get_provider()} Response End ----------")
@@ -782,21 +681,30 @@ async def aresponse_stream(self, messages: List[Message]) -> Any:
         logger.debug(f"---------- {self.get_provider()} Async Response Start ----------")
         self._log_messages(messages)
         stream_data: StreamData = StreamData()
-        metrics: Metrics = Metrics()
+
+        # -*- Create assistant message
+        assistant_message = Message(role="assistant")
 
         # -*- Generate response
-        metrics.start_response_timer()
+        assistant_message.metrics.start_timer()
         async for response in self.ainvoke_stream(messages=messages):
             if response.choices and len(response.choices) > 0:
-                metrics.completion_tokens += 1
-                if metrics.completion_tokens == 1:
-                    metrics.time_to_first_token = metrics.response_timer.elapsed
+                assistant_message.metrics.completion_tokens += 1
+                if assistant_message.metrics.completion_tokens == 1:
+                    assistant_message.metrics.set_time_to_first_token()
 
                 response_delta: ChoiceDelta = response.choices[0].delta
+                response_content = response_delta.content
+                response_tool_calls = response_delta.tool_calls
 
-                if response_delta.content is not None:
-                    stream_data.response_content += response_delta.content
-                    yield ModelResponse(content=response_delta.content)
+                if response_content is not None:
+                    stream_data.response_content += response_content
+                    yield ModelResponse(content=response_content)
+
+                if response_tool_calls is not None:
+                    if stream_data.response_tool_calls is None:
+                        stream_data.response_tool_calls = []
+                    stream_data.response_tool_calls.extend(response_tool_calls)
 
                 if hasattr(response_delta, "audio"):
                     response_audio = response_delta.audio
@@ -811,20 +719,16 @@ async def aresponse_stream(self, messages: List[Message]) -> Any:
                             )
                         )
 
-                if response_delta.tool_calls is not None:
-                    if stream_data.response_tool_calls is None:
-                        stream_data.response_tool_calls = []
-                    stream_data.response_tool_calls.extend(response_delta.tool_calls)
-
             if response.usage is not None:
-                self.add_response_usage_to_metrics(metrics=metrics, response_usage=response.usage)
-        metrics.stop_response_timer()
+                self.add_usage_metrics_to_assistant_message(
+                    assistant_message=assistant_message, response_usage=response.usage
+                )
+        assistant_message.metrics.stop_timer()
 
-        # -*- Create assistant message
+        # -*- Add response content and audio to assistant message
         assistant_message = Message(role="assistant")
         if stream_data.response_content != "":
             assistant_message.content = stream_data.response_content
-
         if stream_data.response_audio is not None:
             assistant_message.audio_output = AudioOutput(
                 id=stream_data.response_audio.id,
@@ -833,40 +737,24 @@ async def aresponse_stream(self, messages: List[Message]) -> Any:
                 transcript=stream_data.response_audio.transcript,
             )
 
+        # -*- Add tool calls to assistant message
         if stream_data.response_tool_calls is not None:
             _tool_calls = self.build_tool_calls(stream_data.response_tool_calls)
             if len(_tool_calls) > 0:
                 assistant_message.tool_calls = _tool_calls
 
-        self.update_stream_metrics(assistant_message=assistant_message, metrics=metrics)
-
         # -*- Add assistant message to messages
         messages.append(assistant_message)
 
         # -*- Log response and metrics
-        assistant_message.log()
-        metrics.log()
+        assistant_message.log(metrics=True)
 
         # -*- Handle tool calls
         if assistant_message.tool_calls is not None and len(assistant_message.tool_calls) > 0:
-            tool_role = "tool"
             async for tool_call_response in self.ahandle_stream_tool_calls(
-                assistant_message=assistant_message, messages=messages, tool_role=tool_role
+                assistant_message=assistant_message, messages=messages, tool_role=self.tool_message_role
             ):
                 yield tool_call_response
             async for post_tool_call_response in self.ahandle_post_tool_call_messages_stream(messages=messages):
                 yield post_tool_call_response
         logger.debug(f"---------- {self.get_provider()} Async Response End ----------")
-
-    def build_tool_calls(self, tool_calls_data: List[ChoiceDeltaToolCall]) -> List[Dict[str, Any]]:
-        """
-        Build tool calls from tool call data.
-
-        Args:
-            tool_calls_data (List[ChoiceDeltaToolCall]): The tool call data to build from.
-
-        Returns:
-            List[Dict[str, Any]]: The built tool calls.
-        """
-
-        return self._build_tool_calls(tool_calls_data)
diff --git a/libs/agno/agno/models/vertexai/gemini.py b/libs/agno/agno/models/vertexai/gemini.py
index 599a7cdb22..3a9111f3f3 100644
--- a/libs/agno/agno/models/vertexai/gemini.py
+++ b/libs/agno/agno/models/vertexai/gemini.py
@@ -382,7 +382,7 @@ def handle_tool_calls(self, assistant_message: Message, messages: List[Message],
                 model_response.tool_calls = []
 
             model_response.content = assistant_message.get_content_string() or ""
-            function_calls_to_run = self._get_function_calls_to_run(
+            function_calls_to_run = self.get_function_calls_to_run(
                 assistant_message, messages, error_response_role="tool"
             )
 
@@ -473,7 +473,7 @@ def handle_stream_tool_calls(self, assistant_message: Message, messages: List[Me
             Iterator[ModelResponse]: Yields model responses during function execution.
         """
         if assistant_message.tool_calls:
-            function_calls_to_run = self._get_function_calls_to_run(
+            function_calls_to_run = self.get_function_calls_to_run(
                 assistant_message, messages, error_response_role="tool"
             )
 
diff --git a/libs/agno/agno/utils/common.py b/libs/agno/agno/utils/common.py
index 3cdb1c568b..dd8ad06ffe 100644
--- a/libs/agno/agno/utils/common.py
+++ b/libs/agno/agno/utils/common.py
@@ -51,6 +51,7 @@ def dataclass_to_dict(dataclass_object, exclude: Optional[set[str]] = None, excl
 
 def nested_model_dump(value):
     from pydantic import BaseModel
+
     if isinstance(value, BaseModel):
         return value.model_dump()
     elif isinstance(value, dict):


Code review comments:
diff_hunk: @@ -238,60 +204,6 @@ def _get_function_calls_to_run(
                 function_calls_to_run.append(_function_call)
         return function_calls_to_run
 
-    def add_tool(
path: libs/agno/agno/models/base.py
text: @user1:
Gemini will need a refactor, since it has a custom add_tools

diff_hunk: @@ -595,15 +466,17 @@ def response_stream(self, messages: List[Message]) -> Iterator[ModelResponse]:
         logger.debug("---------- Groq Response Start ----------")
         self._log_messages(messages)
         stream_data: StreamData = StreamData()
-        metrics: Metrics = Metrics()
+
+        # -*- Create assistant message
+        assistant_message = Message(role="assistant")
 
         # -*- Generate response
-        metrics.start_response_timer()
+        assistant_message.metrics.start_timer()
         for response in self.invoke_stream(messages=messages):
             if len(response.choices) > 0:
-                metrics.completion_tokens += 1
-                if metrics.completion_tokens == 1:
-                    metrics.time_to_first_token = metrics.response_timer.elapsed
+                assistant_message.metrics.completion_tokens += 1
path: libs/agno/agno/models/groq/groq.py
text: @user1:
Should this not use the completion tokens from the response? I always thought this was weird, because a delta isn't necessarily one token right?

diff_hunk: @@ -398,16 +383,27 @@ def __init__(
         self.monitoring = monitoring
         self.telemetry = telemetry
 
-        self.run_id = None
-        self.run_input = None
-        self.run_messages = None
-        self.run_response = None
-        self.images = None
-        self.videos = None
-        self.audio = None
+        # --- Params not to be set by user ---
+        self.session_metrics: Optional[SessionMetrics] = None
 
-        self.agent_session = None
-        self._formatter = None
+        self.run_id: Optional[str] = None
+        self.run_input: Optional[Union[str, List, Dict, Message]] = None
+        self.run_messages: Optional[RunMessages] = None
+        self.run_response: Optional[RunResponse] = None
+
+        # Images generated during this session
+        self.images: Optional[List[ImageArtifact]] = None
+        # Videos generated during this session
+        self.videos: Optional[List[VideoArtifact]] = None
+        # Audio generated during this session
+        self.audio: Optional[List[AudioArtifact]] = None
+        # Agent session
+        self.agent_session: Optional[AgentSession] = None
+
+        self.tools_for_model: Optional[List[Dict]] = None
path: libs/agno/agno/agent/agent.py
text: @user1:
Does this need to be a root-level attribute? I think it can just be local in `add_tools_to_model`

diff_hunk: @@ -1310,34 +1314,100 @@ def create_run_response(
 
     def get_tools(self) -> Optional[List[Union[Toolkit, Callable, Dict, Function]]]:
         self.memory = cast(AgentMemory, self.memory)
-        tools: List[Union[Toolkit, Callable, Dict, Function]] = []
+        agent_tools: List[Union[Toolkit, Callable, Dict, Function]] = []
 
         # Add provided tools
         if self.tools is not None:
             for tool in self.tools:
-                tools.append(tool)
+                agent_tools.append(tool)
 
         # Add tools for accessing memory
         if self.read_chat_history:
-            tools.append(self.get_chat_history)
+            agent_tools.append(self.get_chat_history)
         if self.read_tool_call_history:
-            tools.append(self.get_tool_call_history)
+            agent_tools.append(self.get_tool_call_history)
         if self.memory and self.memory.create_user_memories:
-            tools.append(self.update_memory)
+            agent_tools.append(self.update_memory)
 
         # Add tools for accessing knowledge
         if self.knowledge is not None:
             if self.search_knowledge:
-                tools.append(self.search_knowledge_base)
+                agent_tools.append(self.search_knowledge_base)
             if self.update_knowledge:
-                tools.append(self.add_to_knowledge)
+                agent_tools.append(self.add_to_knowledge)
 
         # Add transfer tools
         if self.team is not None and len(self.team) > 0:
             for agent_index, agent in enumerate(self.team):
-                tools.append(self.get_transfer_function(agent, agent_index))
+                agent_tools.append(self.get_transfer_function(agent, agent_index))
+
+        return agent_tools
+
+    def add_tools_to_model(self, model: Model) -> None:
+        # Get Agent tools
+        agent_tools = self.get_tools()
+        if agent_tools is not None:
+            logger.debug("Processing tools for model")
+            # Check if we need strict mode for the model
+            strict = False
+            if self.response_model is not None and self.structured_outputs and model.supports_structured_outputs:
+                strict = True
+
+            if self.tools_for_model is None:
+                self.tools_for_model = []
+            if self.functions_for_model is None:
+                self.functions_for_model = {}
+
+            for tool in agent_tools:
+                # If the tool is a Dict, add it directly to the Model
+                if isinstance(tool, Dict):
+                    if tool not in self.tools_for_model:
+                        self.tools_for_model.append(tool)
path: libs/agno/agno/agent/agent.py
text: @user1:
Does a function_for_model not need to be set here?