diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
new file mode 100644
index 0000000..551eae6
--- /dev/null
+++ b/.github/workflows/pytest.yml
@@ -0,0 +1,44 @@
+name: Python Tests
+
+on:
+  push:
+    branches: [ main ]
+  pull_request:
+    branches: [ main ]
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: ["3.10", "3.11", "3.12"]
+
+    steps:
+    - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+
+    - name: Install uv
+      uses: astral-sh/setup-uv@22695119d769bdb6f7032ad67b9bca0ef8c4a174 # v5
+
+    - name: Install Python
+      uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0
+      with:
+        enable-cache: true
+        cache-dependency-glob: "uv.lock" # Update cache if uv.lock changes
+
+    - name: Install the project
+      run: |
+        cd python/thirdweb-ai
+        uv sync --all-extras --dev
+
+    - name: Test with pytest
+      env:
+        __THIRDWEB_SECRET_KEY_DEV: ${{ secrets.__THIRDWEB_SECRET_KEY_DEV }}
+      run: |
+        cd python/thirdweb-ai
+        uv run pytest tests --cov=thirdweb_ai --cov-report=xml
+    
+    - name: Upload coverage to Codecov
+      uses: codecov/codecov-action@v3
+      with:
+        file: ./coverage.xml
+        fail_ci_if_error: true
diff --git a/python/thirdweb-ai/pytest.ini b/python/thirdweb-ai/pytest.ini
new file mode 100644
index 0000000..127f7a0
--- /dev/null
+++ b/python/thirdweb-ai/pytest.ini
@@ -0,0 +1,13 @@
+[pytest]
+testpaths = tests
+python_files = test_*.py
+python_classes = Test*
+python_functions = test_*
+
+# Add markers if needed
+markers =
+    unit: Unit tests
+    integration: Integration tests
+
+# Configure verbosity and coverage
+addopts = -v --cov=src/thirdweb_ai --cov-report=term --cov-report=html
\ No newline at end of file
diff --git a/python/thirdweb-ai/src/thirdweb_ai/adapters/coinbase_agentkit/__init__.py b/python/thirdweb-ai/src/thirdweb_ai/adapters/coinbase_agentkit/__init__.py
index d2e86f8..969aa86 100644
--- a/python/thirdweb-ai/src/thirdweb_ai/adapters/coinbase_agentkit/__init__.py
+++ b/python/thirdweb-ai/src/thirdweb_ai/adapters/coinbase_agentkit/__init__.py
@@ -1,3 +1,3 @@
-from .coinbase_agentkit import thirdweb_action_provider
+from .coinbase_agentkit import ThirdwebActionProvider, thirdweb_action_provider
 
-__all__ = ["thirdweb_action_provider"]
+__all__ = ["ThirdwebActionProvider", "thirdweb_action_provider"]
diff --git a/python/thirdweb-ai/src/thirdweb_ai/adapters/openai/__init__.py b/python/thirdweb-ai/src/thirdweb_ai/adapters/openai/__init__.py
index c08a5c1..3d8035f 100644
--- a/python/thirdweb-ai/src/thirdweb_ai/adapters/openai/__init__.py
+++ b/python/thirdweb-ai/src/thirdweb_ai/adapters/openai/__init__.py
@@ -1,3 +1,3 @@
-from .agents import get_agents_tools
+from .agents import get_agents_tools as get_openai_tools
 
-__all__ = ["get_agents_tools"]
+__all__ = ["get_openai_tools"]
diff --git a/python/thirdweb-ai/src/thirdweb_ai/common/utils.py b/python/thirdweb-ai/src/thirdweb_ai/common/utils.py
index a664415..0b04170 100644
--- a/python/thirdweb-ai/src/thirdweb_ai/common/utils.py
+++ b/python/thirdweb-ai/src/thirdweb_ai/common/utils.py
@@ -1,7 +1,13 @@
+import importlib.util
 import re
 from typing import Any
 
 
+def has_module(module_name: str) -> bool:
+    """Check if module is available."""
+    return importlib.util.find_spec(module_name) is not None
+
+
 def extract_digits(value: int | str) -> int:
     value_str = str(value).strip("\"'")
     digit_match = re.search(r"\d+", value_str)
diff --git a/python/thirdweb-ai/tests/adapters/__init__.py b/python/thirdweb-ai/tests/adapters/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/python/thirdweb-ai/tests/adapters/__init__.py
@@ -0,0 +1 @@
+
diff --git a/python/thirdweb-ai/tests/adapters/test_autogen.py b/python/thirdweb-ai/tests/adapters/test_autogen.py
new file mode 100644
index 0000000..c9d0087
--- /dev/null
+++ b/python/thirdweb-ai/tests/adapters/test_autogen.py
@@ -0,0 +1,27 @@
+import pytest
+
+from thirdweb_ai.tools.tool import Tool
+
+
+def test_get_autogen_tools(test_tools: list[Tool]):
+    """Test converting thirdweb tools to AutoGen tools."""
+    pytest.importorskip("autogen_core")
+    from autogen_core.tools import BaseTool as AutogenBaseTool  # type: ignore[import]
+
+    from thirdweb_ai.adapters.autogen import get_autogen_tools
+
+    # Convert tools to AutoGen tools
+    autogen_tools = get_autogen_tools(test_tools)
+
+    # Assert we got the correct number of tools
+    assert len(autogen_tools) == len(test_tools)
+
+    # Check all tools were properly converted
+    assert all(isinstance(tool, AutogenBaseTool) for tool in autogen_tools)
+
+    # Check properties were preserved
+    assert [tool.name for tool in autogen_tools] == [tool.name for tool in test_tools]
+    assert [tool.description for tool in autogen_tools] == [tool.description for tool in test_tools]
+
+    # Check all tools have a run method
+    assert all(callable(getattr(tool, "run", None)) for tool in autogen_tools)
diff --git a/python/thirdweb-ai/tests/adapters/test_coinbase_agentkit.py b/python/thirdweb-ai/tests/adapters/test_coinbase_agentkit.py
new file mode 100644
index 0000000..a3cef08
--- /dev/null
+++ b/python/thirdweb-ai/tests/adapters/test_coinbase_agentkit.py
@@ -0,0 +1,49 @@
+import pytest
+from coinbase_agentkit import (
+    EthAccountWalletProvider,
+    EthAccountWalletProviderConfig,
+)
+from eth_account import Account
+
+from thirdweb_ai.tools.tool import Tool
+
+
+def test_get_coinbase_agentkit_tools(test_tools: list[Tool]):
+    """Test converting thirdweb tools to Coinbase AgentKit tools."""
+    pytest.importorskip("coinbase_agentkit")
+    from coinbase_agentkit import ActionProvider  # type: ignore[import]
+
+    from thirdweb_ai.adapters.coinbase_agentkit import ThirdwebActionProvider, thirdweb_action_provider
+
+    # Convert tools to Coinbase AgentKit provider
+    provider = thirdweb_action_provider(test_tools)
+
+    # Check provider was created with the right type
+    assert isinstance(provider, ThirdwebActionProvider)
+    assert isinstance(provider, ActionProvider)
+
+    # Check provider name
+    assert provider.name == "thirdweb"
+
+    account = Account.create()
+    # Initialize Ethereum Account Wallet Provider
+    wallet_provider = EthAccountWalletProvider(
+        config=EthAccountWalletProviderConfig(
+            account=account,
+            chain_id="8453",  # Base mainnet
+            rpc_url="https://8453.rpc.thirdweb.com",
+        )
+    )
+    actions = provider.get_actions(wallet_provider=wallet_provider)
+    # Check provider has the expected number of actions
+    assert len(actions) == len(test_tools)
+
+    # Check properties were preserved by getting actions and checking names/descriptions
+    assert [action.name for action in actions] == [tool.name for tool in test_tools]
+    assert [action.description for action in actions] == [tool.description for tool in test_tools]
+
+    # Verify that args_schema is set correctly
+    assert [action.args_schema for action in actions] == [tool.args_type() for tool in test_tools]
+
+    # Check all actions have callable invoke functions
+    assert all(callable(action.invoke) for action in actions)
diff --git a/python/thirdweb-ai/tests/adapters/test_goat.py b/python/thirdweb-ai/tests/adapters/test_goat.py
new file mode 100644
index 0000000..6e8e302
--- /dev/null
+++ b/python/thirdweb-ai/tests/adapters/test_goat.py
@@ -0,0 +1,29 @@
+import pytest
+
+from thirdweb_ai.tools.tool import Tool
+
+
+def test_get_goat_tools(test_tools: list[Tool]):
+    """Test converting thirdweb tools to GOAT tools."""
+    # Skip this test if module not fully installed
+    pytest.importorskip("goat.tools")
+
+    from goat.tools import BaseTool as GoatBaseTool  # type: ignore[import]
+
+    from thirdweb_ai.adapters.goat import get_goat_tools
+
+    # Convert tools to GOAT tools
+    goat_tools = get_goat_tools(test_tools)
+
+    # Assert we got the correct number of tools
+    assert len(goat_tools) == len(test_tools)
+
+    # Check all tools were properly converted
+    assert all(isinstance(tool, GoatBaseTool) for tool in goat_tools)
+
+    # Check properties were preserved
+    assert [tool.name for tool in goat_tools] == [tool.name for tool in test_tools]
+    assert [tool.description for tool in goat_tools] == [tool.description for tool in test_tools]
+
+    # Check all tools have a callable run method
+    assert all(callable(getattr(tool, "run", None)) for tool in goat_tools)
diff --git a/python/thirdweb-ai/tests/adapters/test_langchain.py b/python/thirdweb-ai/tests/adapters/test_langchain.py
new file mode 100644
index 0000000..a1ad3a6
--- /dev/null
+++ b/python/thirdweb-ai/tests/adapters/test_langchain.py
@@ -0,0 +1,30 @@
+import pytest
+
+from thirdweb_ai.tools.tool import Tool
+
+
+def test_get_langchain_tools(test_tools: list[Tool]):
+    """Test converting thirdweb tools to LangChain tools."""
+    pytest.importorskip("langchain_core")
+    from langchain_core.tools.structured import StructuredTool  # type: ignore[import]
+
+    from thirdweb_ai.adapters.langchain import get_langchain_tools
+
+    # Convert tools to LangChain tools
+    langchain_tools = get_langchain_tools(test_tools)
+
+    # Assert we got the correct number of tools
+    assert len(langchain_tools) == len(test_tools)
+
+    # Check all tools were properly converted
+    assert all(isinstance(tool, StructuredTool) for tool in langchain_tools)
+
+    # Check properties were preserved
+    assert [tool.name for tool in langchain_tools] == [tool.name for tool in test_tools]
+    assert [tool.description for tool in langchain_tools] == [tool.description for tool in test_tools]
+
+    # Check schemas were preserved
+    assert [tool.args_schema for tool in langchain_tools] == [tool.args_type() for tool in test_tools]
+
+    # Check all tools have callable run methods
+    assert all(callable(getattr(tool, "func", None)) for tool in langchain_tools)
diff --git a/python/thirdweb-ai/tests/adapters/test_llama_index.py b/python/thirdweb-ai/tests/adapters/test_llama_index.py
new file mode 100644
index 0000000..fb8ef75
--- /dev/null
+++ b/python/thirdweb-ai/tests/adapters/test_llama_index.py
@@ -0,0 +1,28 @@
+import pytest
+
+from thirdweb_ai.tools.tool import Tool
+
+
+def test_get_llama_index_tools(test_tools: list[Tool]):
+    """Test converting thirdweb tools to LlamaIndex tools."""
+    pytest.importorskip("llama_index")
+    from llama_index.core.tools import FunctionTool  # type: ignore[import]
+
+    from thirdweb_ai.adapters.llama_index import get_llama_index_tools
+
+    # Convert tools to LlamaIndex tools
+    llama_tools = get_llama_index_tools(test_tools)
+
+    # Assert we got the correct number of tools
+    assert len(llama_tools) == len(test_tools)
+
+    # Check all tools were properly converted
+    assert all(isinstance(tool, FunctionTool) for tool in llama_tools)
+
+    # Check properties were preserved
+    assert [tool.metadata.name for tool in llama_tools] == [tool.name for tool in test_tools]
+    assert [tool.metadata.description for tool in llama_tools] == [tool.description for tool in test_tools]
+    assert [tool.metadata.fn_schema for tool in llama_tools] == [tool.args_type() for tool in test_tools]
+
+    # Check all tools are callable
+    assert all(callable(tool) for tool in llama_tools)
diff --git a/python/thirdweb-ai/tests/adapters/test_mcp.py b/python/thirdweb-ai/tests/adapters/test_mcp.py
new file mode 100644
index 0000000..9e15003
--- /dev/null
+++ b/python/thirdweb-ai/tests/adapters/test_mcp.py
@@ -0,0 +1,29 @@
+import pytest
+
+from thirdweb_ai.tools.tool import Tool
+
+
+def test_get_mcp_tools(test_tools: list[Tool]):
+    """Test converting thirdweb tools to MCP tools."""
+    pytest.importorskip("mcp.types")
+
+    import mcp.types as mcp_types  # type: ignore[import]
+
+    from thirdweb_ai.adapters.mcp import get_mcp_tools
+
+    # Convert tools to MCP tools
+    mcp_tools = get_mcp_tools(test_tools)
+
+    # Assert we got the correct number of tools
+    assert len(mcp_tools) == len(test_tools)
+
+    # Check all tools were properly converted
+    assert all(isinstance(tool, mcp_types.Tool) for tool in mcp_tools)
+
+    # Check properties were preserved
+    assert [tool.name for tool in mcp_tools] == [tool.name for tool in test_tools]
+    assert [tool.description for tool in mcp_tools] == [tool.description for tool in test_tools]
+
+    # Check that input schemas were set correctly
+    for i, tool in enumerate(mcp_tools):
+        assert tool.inputSchema == test_tools[i].schema.get("parameters")
diff --git a/python/thirdweb-ai/tests/adapters/test_openai.py b/python/thirdweb-ai/tests/adapters/test_openai.py
new file mode 100644
index 0000000..de51fa7
--- /dev/null
+++ b/python/thirdweb-ai/tests/adapters/test_openai.py
@@ -0,0 +1,28 @@
+import pytest
+
+from thirdweb_ai.tools.tool import Tool
+
+
+def test_get_openai_tools(test_tools: list[Tool]):
+    """Test converting thirdweb tools to OpenAI tools."""
+    pytest.importorskip("openai")
+    from agents import FunctionTool
+
+    from thirdweb_ai.adapters.openai import get_openai_tools
+
+    # Convert tools to OpenAI tools
+    openai_tools = get_openai_tools(test_tools)
+
+    # Assert we got the correct number of tools
+    assert len(openai_tools) == len(test_tools)
+
+    # Check all required properties exist in the tools
+    for i, tool in enumerate(openai_tools):
+        assert isinstance(tool, FunctionTool)
+        assert hasattr(tool, "name")
+        assert hasattr(tool, "description")
+        assert hasattr(tool, "params_json_schema")
+
+        # Check name and description match
+        assert tool.name == test_tools[i].name
+        assert tool.description == test_tools[i].description
diff --git a/python/thirdweb-ai/tests/adapters/test_pydantic_ai.py b/python/thirdweb-ai/tests/adapters/test_pydantic_ai.py
new file mode 100644
index 0000000..ecec063
--- /dev/null
+++ b/python/thirdweb-ai/tests/adapters/test_pydantic_ai.py
@@ -0,0 +1,28 @@
+import pytest
+
+from thirdweb_ai.tools.tool import Tool
+
+
+def test_get_pydantic_ai_tools(test_tools: list[Tool]):
+    """Test converting thirdweb tools to Pydantic AI tools."""
+    pytest.importorskip("pydantic_ai")
+    from pydantic_ai import Tool as PydanticTool  # type: ignore[import]
+
+    from thirdweb_ai.adapters.pydantic_ai import get_pydantic_ai_tools
+
+    # Convert tools to Pydantic AI tools
+    pydantic_ai_tools = get_pydantic_ai_tools(test_tools)
+
+    # Assert we got the correct number of tools
+    assert len(pydantic_ai_tools) == len(test_tools)
+
+    # Check all tools were properly converted
+    assert all(isinstance(tool, PydanticTool) for tool in pydantic_ai_tools)
+
+    # Check properties were preserved
+    assert [tool.name for tool in pydantic_ai_tools] == [tool.name for tool in test_tools]
+    assert [tool.description for tool in pydantic_ai_tools] == [tool.description for tool in test_tools]
+
+    # Check all tools have function and prepare methods
+    assert all(callable(getattr(tool, "function", None)) for tool in pydantic_ai_tools)
+    assert all(callable(getattr(tool, "prepare", None)) for tool in pydantic_ai_tools)
diff --git a/python/thirdweb-ai/tests/adapters/test_smolagents.py b/python/thirdweb-ai/tests/adapters/test_smolagents.py
new file mode 100644
index 0000000..ff5206c
--- /dev/null
+++ b/python/thirdweb-ai/tests/adapters/test_smolagents.py
@@ -0,0 +1,28 @@
+import pytest
+
+from thirdweb_ai.tools.tool import Tool
+
+
+def test_get_smolagents_tools(test_tools: list[Tool]):
+    """Test converting thirdweb tools to SmolaGents tools."""
+    pytest.importorskip("smolagents")
+
+    from smolagents import Tool as SmolagentTool  # type: ignore[import]
+
+    from thirdweb_ai.adapters.smolagents import get_smolagents_tools
+
+    # Convert tools to SmolaGents tools
+    smolagents_tools = get_smolagents_tools(test_tools)
+
+    # Assert we got the correct number of tools
+    assert len(smolagents_tools) == len(test_tools)
+
+    # Check all tools were properly converted (using duck typing with SmolagentTool)
+    assert all(isinstance(tool, SmolagentTool) for tool in smolagents_tools)
+
+    # Check properties were preserved
+    assert [tool.name for tool in smolagents_tools] == [tool.name for tool in test_tools]
+    assert [tool.description for tool in smolagents_tools] == [tool.description for tool in test_tools]
+
+    # Check all tools have a callable forward method
+    assert all(callable(getattr(tool, "forward", None)) for tool in smolagents_tools)
diff --git a/python/thirdweb-ai/tests/common/test_utils.py b/python/thirdweb-ai/tests/common/test_utils.py
index 09a20e6..7f67afb 100644
--- a/python/thirdweb-ai/tests/common/test_utils.py
+++ b/python/thirdweb-ai/tests/common/test_utils.py
@@ -35,10 +35,3 @@ def test_no_digits(self):
 
         with pytest.raises(ValueError, match="does not contain any digits"):
             normalize_chain_id(["ethereum", "polygon"])
-
-    def test_invalid_digit_string(self):
-        # This test is for completeness, but the current implementation
-        # doesn't trigger this error case since re.search('\d+') always
-        # returns a valid digit string if it matches
-        pass
-
diff --git a/python/thirdweb-ai/tests/conftest.py b/python/thirdweb-ai/tests/conftest.py
new file mode 100644
index 0000000..3f14260
--- /dev/null
+++ b/python/thirdweb-ai/tests/conftest.py
@@ -0,0 +1,62 @@
+import pytest
+from pydantic import BaseModel, Field
+
+from thirdweb_ai.tools.tool import BaseTool, FunctionTool, Tool
+
+
+class TestArgsModel(BaseModel):
+    """Test arguments model."""
+
+    param1: str = Field(description="Test parameter 1")
+    param2: int = Field(description="Test parameter 2")
+
+
+class TestReturnModel(BaseModel):
+    """Test return model."""
+
+    result: str
+
+
+class TestBaseTool(BaseTool[TestArgsModel, TestReturnModel]):
+    """A simple test tool for testing adapters."""
+
+    def __init__(self):
+        super().__init__(
+            args_type=TestArgsModel,
+            return_type=TestReturnModel,
+            name="test_tool",
+            description="A test tool for testing",
+        )
+
+    def run(self, args: TestArgsModel | None = None) -> TestReturnModel:
+        if args is None:
+            raise ValueError("Arguments are required")
+        return TestReturnModel(result=f"Executed with {args.param1} and {args.param2}")
+
+
+@pytest.fixture
+def test_tool() -> TestBaseTool:
+    """Fixture that returns a test tool."""
+    return TestBaseTool()
+
+
+@pytest.fixture
+def test_function_tool() -> FunctionTool:
+    """Fixture that returns a test function tool."""
+
+    def test_func(param1: str, param2: int = 42) -> str:
+        """A test function for the function tool."""
+        return f"Function called with {param1} and {param2}"
+
+    return FunctionTool(
+        func_definition=test_func,
+        func_execute=test_func,
+        description="A test function tool",
+        name="test_function_tool",
+    )
+
+
+@pytest.fixture
+def test_tools(test_tool: TestBaseTool, test_function_tool: FunctionTool) -> list[Tool]:
+    """Fixture that returns a list of test tools."""
+    return [test_tool, test_function_tool]
diff --git a/python/thirdweb-ai/tests/services/__init__.py b/python/thirdweb-ai/tests/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/python/thirdweb-ai/tests/services/test_engine.py b/python/thirdweb-ai/tests/services/test_engine.py
new file mode 100644
index 0000000..42bc5e6
--- /dev/null
+++ b/python/thirdweb-ai/tests/services/test_engine.py
@@ -0,0 +1,47 @@
+import os
+
+import pytest
+
+from thirdweb_ai.services.engine import Engine
+
+
+class MockEngine(Engine):
+    def __init__(
+        self,
+        engine_url: str,
+        engine_auth_jwt: str,
+        chain_id: int | str | None = None,
+        backend_wallet_address: str | None = None,
+        secret_key: str = "",
+    ):
+        super().__init__(
+            engine_url=engine_url,
+            engine_auth_jwt=engine_auth_jwt,
+            chain_id=chain_id,
+            backend_wallet_address=backend_wallet_address,
+            secret_key=secret_key,
+        )
+
+
+@pytest.fixture
+def engine():
+    return MockEngine(
+        engine_url="https://engine.thirdweb-dev.com",
+        engine_auth_jwt="test_jwt",
+        chain_id=84532,
+        backend_wallet_address="0xC22166664e820cdA6bf4cedBdbb4fa1E6A84C440",
+        secret_key=os.getenv("__THIRDWEB_SECRET_KEY_DEV") or "",
+    )
+
+
+class TestEngine:
+    # Constants
+    CHAIN_ID = "84532"
+    TEST_ADDRESS = "0xC22166664e820cdA6bf4cedBdbb4fa1E6A84C440"
+    TEST_QUEUE_ID = "9eb88b00-f04f-409b-9df7-7dcc9003bc35"
+
+    # def test_create_backend_wallet(self, engine: Engine):
+    #     create_backend_wallet = engine.create_backend_wallet.__wrapped__
+    #     result = create_backend_wallet(engine, wallet_type="local", label="Test Wallet")
+    #
+    #     assert isinstance(result, dict)
diff --git a/python/thirdweb-ai/tests/services/test_insight.py b/python/thirdweb-ai/tests/services/test_insight.py
new file mode 100644
index 0000000..29f59f6
--- /dev/null
+++ b/python/thirdweb-ai/tests/services/test_insight.py
@@ -0,0 +1,31 @@
+import os
+
+import pytest
+
+from thirdweb_ai.services.insight import Insight
+
+
+class MockInsight(Insight):
+    def __init__(self, secret_key: str, chain_id: int | str | list[int | str]):
+        super().__init__(secret_key=secret_key, chain_id=chain_id)
+        self.base_url = "https://insight.thirdweb-dev.com/v1"
+
+
+@pytest.fixture
+def insight():
+    return MockInsight(secret_key=os.getenv("__THIRDWEB_SECRET_KEY_DEV") or "", chain_id=84532)
+
+
+class TestInsight:
+    # Constants
+    CHAIN_ID = 1
+    TEST_ADDRESS = "0xdAC17F958D2ee523a2206206994597C13D831ec7"
+    TEST_DOMAIN = "thirdweb.eth"
+    DEFAULT_LIMIT = 5
+
+    def test_get_erc20_tokens(self, insight: Insight):
+        get_erc20_tokens = insight.get_erc20_tokens.__wrapped__
+        result = get_erc20_tokens(insight, chain=self.CHAIN_ID, owner_address=self.TEST_ADDRESS)
+
+        assert isinstance(result, dict)
+        assert "data" in result
diff --git a/python/thirdweb-ai/tests/services/test_nebula.py b/python/thirdweb-ai/tests/services/test_nebula.py
new file mode 100644
index 0000000..c168f86
--- /dev/null
+++ b/python/thirdweb-ai/tests/services/test_nebula.py
@@ -0,0 +1,29 @@
+import os
+import typing
+
+import pytest
+
+from thirdweb_ai.services.nebula import Nebula
+
+
+class MockNebula(Nebula):
+    def __init__(self, secret_key: str):
+        super().__init__(secret_key=secret_key)
+        self.base_url = "https://nebula-api.thirdweb-dev.com"
+
+
+@pytest.fixture
+def nebula():
+    return MockNebula(secret_key=os.getenv("__THIRDWEB_SECRET_KEY_DEV") or "")
+
+
+class TestNebula:
+    TEST_MESSAGE = "What is thirdweb?"
+    TEST_SESSION_ID = "test-session-id"
+    TEST_CONTEXT: typing.ClassVar = {"chainIds": ["1", "137"], "walletAddress": "0x123456789abcdef"}
+
+    def test_chat(self, nebula: Nebula):
+        chat = nebula.chat.__wrapped__
+        result = chat(nebula, message=self.TEST_MESSAGE, session_id=self.TEST_SESSION_ID, context=self.TEST_CONTEXT)
+
+        assert isinstance(result, dict)
diff --git a/python/thirdweb-ai/tests/services/test_storage.py b/python/thirdweb-ai/tests/services/test_storage.py
new file mode 100644
index 0000000..005345a
--- /dev/null
+++ b/python/thirdweb-ai/tests/services/test_storage.py
@@ -0,0 +1,67 @@
+import os
+from typing import ClassVar
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from pydantic import BaseModel
+
+from thirdweb_ai.services.storage import Storage
+
+
+class MockStorage(Storage):
+    def __init__(self, secret_key: str):
+        super().__init__(secret_key=secret_key)
+        self.base_url = "https://storage.thirdweb.com"
+
+
+@pytest.fixture
+def storage():
+    return MockStorage(secret_key=os.getenv("__THIRDWEB_SECRET_KEY_DEV") or "test-key")
+
+
+class TestStorage:
+    # Constants
+    TEST_IPFS_HASH: ClassVar[str] = "ipfs://QmTcHZQ5QEjjbBMJrz7Xaz9AQyVBqsKCS4YQQ71B3gDQ4f"
+    TEST_CONTENT: ClassVar[dict[str, str]] = {"name": "test", "description": "test description"}
+
+    def test_fetch_ipfs_content(self, storage: Storage):
+        fetch_ipfs_content = storage.fetch_ipfs_content.__wrapped__
+
+        # Test invalid IPFS hash
+        result = fetch_ipfs_content(storage, ipfs_hash="invalid-hash")
+        assert "error" in result
+
+        # Mock the _get method to return test content
+        storage._get = MagicMock(return_value=self.TEST_CONTENT)  # type:ignore[assignment] # noqa: SLF001
+
+        # Test valid IPFS hash
+        result = fetch_ipfs_content(storage, ipfs_hash=self.TEST_IPFS_HASH)
+        assert result == self.TEST_CONTENT
+        storage._get.assert_called_once()  # noqa: SLF001 # type:ignore[union-attr]
+
+    @pytest.mark.asyncio
+    async def test_upload_to_ipfs_json(self, storage: Storage):
+        upload_to_ipfs = storage.upload_to_ipfs.__wrapped__
+
+        # Create test data
+        class TestModel(BaseModel):
+            name: str
+            value: int
+
+        test_model = TestModel(name="test", value=123)
+
+        # Mock the _async_post_file method
+        with patch.object(storage, "_async_post_file", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = {"IpfsHash": "QmTest123"}
+
+            # Test with dict
+            result = await upload_to_ipfs(storage, data={"test": "value"})
+            assert result == "ipfs://QmTest123"
+
+            # Test with Pydantic model
+            result = await upload_to_ipfs(storage, data=test_model)
+            assert result == "ipfs://QmTest123"
+
+            # Verify post was called
+            assert mock_post.call_count == 2
+