Repository: griptape-ai/griptape

Issue:
Griptape Cloud driver base_url's do not respect routes
For example, a base_url like `https://kindel.com/griptape-cloud/` will result in a url like `https://kindel.com/api/threads` [here](https://github.com/griptape-ai/griptape/blob/125997a658100e3904595cb1209c019a7e18a589/griptape/drivers/assistant/griptape_cloud_assistant_driver.py#L75) instead of the correct `https://kindel.com/griptape-cloud/api/threads`

Hints from issue comments:

Solution patch:
diff --git a/griptape/drivers/assistant/griptape_cloud_assistant_driver.py b/griptape/drivers/assistant/griptape_cloud_assistant_driver.py
index 1179f1ba..e6e6f335 100644
--- a/griptape/drivers/assistant/griptape_cloud_assistant_driver.py
+++ b/griptape/drivers/assistant/griptape_cloud_assistant_driver.py
@@ -5,7 +5,6 @@ import logging
 import os
 import uuid
 from typing import TYPE_CHECKING, Optional
-from urllib.parse import urljoin
 
 import requests
 from attrs import Factory, define, field
@@ -14,6 +13,7 @@ from griptape.artifacts import BaseArtifact, TextArtifact
 from griptape.configs.defaults_config import Defaults
 from griptape.drivers.assistant import BaseAssistantDriver
 from griptape.events import BaseEvent, EventBus
+from griptape.utils.griptape_cloud import griptape_cloud_url
 
 if TYPE_CHECKING:
     from collections.abc import Iterator
@@ -72,7 +72,7 @@ class GriptapeCloudAssistantDriver(BaseAssistantDriver):
                 self.thread_id = thread["thread_id"]
 
     def _create_thread(self, thread_alias: Optional[str] = None) -> str:
-        url = urljoin(self.base_url.strip("/"), "/api/threads")
+        url = griptape_cloud_url(self.base_url, "api/threads")
 
         body = {"name": uuid.uuid4().hex}
         if thread_alias is not None:
@@ -83,7 +83,7 @@ class GriptapeCloudAssistantDriver(BaseAssistantDriver):
         return response.json()["thread_id"]
 
     def _create_run(self, *args: BaseArtifact) -> str:
-        url = urljoin(self.base_url.strip("/"), f"/api/assistants/{self.assistant_id}/runs")
+        url = griptape_cloud_url(self.base_url, f"api/assistants/{self.assistant_id}/runs")
 
         response = requests.post(
             url,
@@ -126,7 +126,7 @@ class GriptapeCloudAssistantDriver(BaseAssistantDriver):
         return output
 
     def _get_run_events(self, assistant_run_id: str) -> Iterator[dict]:
-        url = urljoin(self.base_url.strip("/"), f"/api/assistant-runs/{assistant_run_id}/events/stream")
+        url = griptape_cloud_url(self.base_url, f"api/assistant-runs/{assistant_run_id}/events/stream")
         with requests.get(url, headers=self.headers, stream=True) as response:
             response.raise_for_status()
             for line in response.iter_lines():
@@ -136,7 +136,7 @@ class GriptapeCloudAssistantDriver(BaseAssistantDriver):
                         yield json.loads(decoded_line.removeprefix("data:").strip())
 
     def _find_thread_by_alias(self, thread_alias: str) -> Optional[dict]:
-        url = urljoin(self.base_url.strip("/"), "/api/threads")
+        url = griptape_cloud_url(self.base_url, "api/threads")
         response = requests.get(url, params={"alias": thread_alias}, headers=self.headers)
         response.raise_for_status()
 
diff --git a/griptape/drivers/event_listener/griptape_cloud_event_listener_driver.py b/griptape/drivers/event_listener/griptape_cloud_event_listener_driver.py
index f740b235..aa62d347 100644
--- a/griptape/drivers/event_listener/griptape_cloud_event_listener_driver.py
+++ b/griptape/drivers/event_listener/griptape_cloud_event_listener_driver.py
@@ -3,13 +3,13 @@ from __future__ import annotations
 import os
 import time
 from typing import Optional
-from urllib.parse import urljoin
 
 import requests
 from attrs import Attribute, Factory, define, field
 
 from griptape.drivers.event_listener.base_event_listener_driver import BaseEventListenerDriver
 from griptape.events.base_event import BaseEvent
+from griptape.utils.griptape_cloud import griptape_cloud_url
 
 
 @define
@@ -79,7 +79,7 @@ class GriptapeCloudEventListenerDriver(BaseEventListenerDriver):
 
     def _post_event(self, json: list[dict] | dict) -> None:
         requests.post(
-            url=urljoin(self.base_url.strip("/"), f"/api/structure-runs/{self.structure_run_id}/events"),
+            url=griptape_cloud_url(self.base_url, f"api/structure-runs/{self.structure_run_id}/events"),
             json=json,
             headers=self.headers,
         ).raise_for_status()
diff --git a/griptape/drivers/file_manager/griptape_cloud_file_manager_driver.py b/griptape/drivers/file_manager/griptape_cloud_file_manager_driver.py
index fdf92d75..3926f7a7 100644
--- a/griptape/drivers/file_manager/griptape_cloud_file_manager_driver.py
+++ b/griptape/drivers/file_manager/griptape_cloud_file_manager_driver.py
@@ -3,12 +3,12 @@ from __future__ import annotations
 import logging
 import os
 from typing import Optional
-from urllib.parse import urljoin
 
 import requests
 from attrs import Attribute, Factory, define, field
 
 from griptape.drivers.file_manager import BaseFileManagerDriver
+from griptape.utils.griptape_cloud import griptape_cloud_url
 
 logger = logging.getLogger(__name__)
 
@@ -120,7 +120,7 @@ class GriptapeCloudFileManagerDriver(BaseFileManagerDriver):
 
     def _get_url(self, path: str) -> str:
         path = path.lstrip("/")
-        return urljoin(self.base_url, f"/api/{path}")
+        return griptape_cloud_url(self.base_url, f"api/{path}")
 
     def _call_api(
         self,
diff --git a/griptape/drivers/image_generation/griptape_cloud_image_generation_driver.py b/griptape/drivers/image_generation/griptape_cloud_image_generation_driver.py
index f4922d59..34428952 100644
--- a/griptape/drivers/image_generation/griptape_cloud_image_generation_driver.py
+++ b/griptape/drivers/image_generation/griptape_cloud_image_generation_driver.py
@@ -2,13 +2,13 @@ from __future__ import annotations
 
 import os
 from typing import Literal, Optional
-from urllib.parse import urljoin
 
 import requests
 from attrs import Factory, define, field
 
 from griptape.artifacts import ImageArtifact
 from griptape.drivers.image_generation import BaseImageGenerationDriver
+from griptape.utils.griptape_cloud import griptape_cloud_url
 
 
 @define
@@ -28,7 +28,7 @@ class GriptapeCloudImageGenerationDriver(BaseImageGenerationDriver):
     )
 
     def try_text_to_image(self, prompts: list[str], negative_prompts: Optional[list[str]] = None) -> ImageArtifact:
-        url = urljoin(self.base_url.strip("/"), "/api/images/generations")
+        url = griptape_cloud_url(self.base_url, "api/images/generations")
 
         response = requests.post(
             url,
diff --git a/griptape/drivers/memory/conversation/griptape_cloud_conversation_memory_driver.py b/griptape/drivers/memory/conversation/griptape_cloud_conversation_memory_driver.py
index 5af56ddf..af8c3aa4 100644
--- a/griptape/drivers/memory/conversation/griptape_cloud_conversation_memory_driver.py
+++ b/griptape/drivers/memory/conversation/griptape_cloud_conversation_memory_driver.py
@@ -3,7 +3,6 @@ from __future__ import annotations
 import os
 import uuid
 from typing import TYPE_CHECKING, Any, Optional
-from urllib.parse import urljoin
 
 import requests
 from attrs import Attribute, Factory, define, field
@@ -11,6 +10,7 @@ from attrs import Attribute, Factory, define, field
 from griptape.artifacts import BaseArtifact
 from griptape.drivers.memory.conversation import BaseConversationMemoryDriver
 from griptape.utils import dict_merge
+from griptape.utils.griptape_cloud import griptape_cloud_url
 
 if TYPE_CHECKING:
     from griptape.memory.structure import Run
@@ -140,7 +140,7 @@ class GriptapeCloudConversationMemoryDriver(BaseConversationMemoryDriver):
 
     def _get_url(self, path: str) -> str:
         path = path.lstrip("/")
-        return urljoin(self.base_url, f"/api/{path}")
+        return griptape_cloud_url(self.base_url, f"api/{path}")
 
     def _call_api(
         self, method: str, path: str, json: Optional[dict] = None, *, raise_for_status: bool = True
diff --git a/griptape/drivers/observability/griptape_cloud_observability_driver.py b/griptape/drivers/observability/griptape_cloud_observability_driver.py
index 3b341a84..593a1b04 100644
--- a/griptape/drivers/observability/griptape_cloud_observability_driver.py
+++ b/griptape/drivers/observability/griptape_cloud_observability_driver.py
@@ -2,13 +2,13 @@ from __future__ import annotations
 
 import os
 from typing import TYPE_CHECKING, Optional
-from urllib.parse import urljoin
 from uuid import UUID
 
 import requests
 from attrs import Attribute, Factory, define, field
 
 from griptape.drivers.observability.open_telemetry_observability_driver import OpenTelemetryObservabilityDriver
+from griptape.utils.griptape_cloud import griptape_cloud_url
 from griptape.utils.import_utils import import_optional_dependency
 
 if TYPE_CHECKING:
@@ -73,7 +73,7 @@ class GriptapeCloudObservabilityDriver(OpenTelemetryObservabilityDriver):
                 opentelemetry_util = import_optional_dependency("opentelemetry.sdk.util")
                 opentelemetry_trace_export = import_optional_dependency("opentelemetry.sdk.trace.export")
 
-                url = urljoin(self.base_url.strip("/"), f"/api/structure-runs/{self.structure_run_id}/spans")
+                url = griptape_cloud_url(self.base_url, f"api/structure-runs/{self.structure_run_id}/spans")
                 payload = [
                     {
                         "trace_id": GriptapeCloudObservabilityDriver.format_trace_id(span.context.trace_id),
diff --git a/griptape/drivers/prompt/griptape_cloud_prompt_driver.py b/griptape/drivers/prompt/griptape_cloud_prompt_driver.py
index 0923ed1a..322aad62 100644
--- a/griptape/drivers/prompt/griptape_cloud_prompt_driver.py
+++ b/griptape/drivers/prompt/griptape_cloud_prompt_driver.py
@@ -3,7 +3,6 @@ from __future__ import annotations
 import logging
 import os
 from typing import TYPE_CHECKING, Optional
-from urllib.parse import urljoin
 
 import requests
 from attrs import Factory, define, field
@@ -12,6 +11,7 @@ from griptape.common import DeltaMessage, Message, PromptStack, observable
 from griptape.configs.defaults_config import Defaults
 from griptape.drivers.prompt import BasePromptDriver
 from griptape.tokenizers import BaseTokenizer, SimpleTokenizer
+from griptape.utils.griptape_cloud import griptape_cloud_url
 
 if TYPE_CHECKING:
     from collections.abc import Iterator
@@ -51,7 +51,7 @@ class GriptapeCloudPromptDriver(BasePromptDriver):
 
     @observable
     def try_run(self, prompt_stack: PromptStack) -> Message:
-        url = urljoin(self.base_url.strip("/"), "/api/chat/messages")
+        url = griptape_cloud_url(self.base_url, "api/chat/messages")
 
         params = self._base_params(prompt_stack)
         logger.debug(params)
@@ -64,7 +64,7 @@ class GriptapeCloudPromptDriver(BasePromptDriver):
 
     @observable
     def try_stream(self, prompt_stack: PromptStack) -> Iterator[DeltaMessage]:
-        url = urljoin(self.base_url.strip("/"), "/api/chat/messages/stream")
+        url = griptape_cloud_url(self.base_url, "api/chat/messages/stream")
         params = self._base_params(prompt_stack)
         logger.debug(params)
         with requests.post(url, headers=self.headers, json=params, stream=True) as response:
diff --git a/griptape/drivers/ruleset/griptape_cloud_ruleset_driver.py b/griptape/drivers/ruleset/griptape_cloud_ruleset_driver.py
index a7ddd4a7..1027230c 100644
--- a/griptape/drivers/ruleset/griptape_cloud_ruleset_driver.py
+++ b/griptape/drivers/ruleset/griptape_cloud_ruleset_driver.py
@@ -2,13 +2,13 @@ from __future__ import annotations
 
 import os
 from typing import TYPE_CHECKING, Any, Optional
-from urllib.parse import urljoin
 
 import requests
 from attrs import Attribute, Factory, define, field
 
 from griptape.drivers.ruleset import BaseRulesetDriver
 from griptape.utils import dict_merge
+from griptape.utils.griptape_cloud import griptape_cloud_url
 
 if TYPE_CHECKING:
     from griptape.rules import BaseRule
@@ -80,7 +80,7 @@ class GriptapeCloudRulesetDriver(BaseRulesetDriver):
 
     def _get_url(self, path: str) -> str:
         path = path.lstrip("/")
-        return urljoin(self.base_url, f"/api/{path}")
+        return griptape_cloud_url(self.base_url, f"api/{path}")
 
     def _call_api(self, method: str, path: str, *, raise_for_status: bool = True) -> requests.Response:
         res = requests.request(method, self._get_url(path), headers=self.headers)
diff --git a/griptape/drivers/structure_run/griptape_cloud_structure_run_driver.py b/griptape/drivers/structure_run/griptape_cloud_structure_run_driver.py
index e092db0a..de97eb42 100644
--- a/griptape/drivers/structure_run/griptape_cloud_structure_run_driver.py
+++ b/griptape/drivers/structure_run/griptape_cloud_structure_run_driver.py
@@ -3,7 +3,6 @@ from __future__ import annotations
 import json
 import logging
 from typing import TYPE_CHECKING
-from urllib.parse import urljoin
 
 import requests
 from attrs import Factory, define, field
@@ -12,6 +11,7 @@ from griptape.artifacts import BaseArtifact, ErrorArtifact, InfoArtifact
 from griptape.configs.defaults_config import Defaults
 from griptape.drivers.structure_run import BaseStructureRunDriver
 from griptape.events import BaseEvent, EventBus
+from griptape.utils.griptape_cloud import griptape_cloud_url
 
 if TYPE_CHECKING:
     from collections.abc import Iterator
@@ -40,7 +40,7 @@ class GriptapeCloudStructureRunDriver(BaseStructureRunDriver):
         return self._get_run_result(structure_run_id)
 
     def _create_run(self, *args: BaseArtifact) -> str:
-        url = urljoin(self.base_url.strip("/"), f"/api/structures/{self.structure_id}/runs")
+        url = griptape_cloud_url(self.base_url, f"api/structures/{self.structure_id}/runs")
 
         env_vars = [{"name": key, "value": value, "source": "manual"} for key, value in self.env.items()]
 
@@ -84,7 +84,7 @@ class GriptapeCloudStructureRunDriver(BaseStructureRunDriver):
         return output
 
     def _get_run_events(self, structure_run_id: str) -> Iterator[dict]:
-        url = urljoin(self.base_url.strip("/"), f"/api/structure-runs/{structure_run_id}/events/stream")
+        url = griptape_cloud_url(self.base_url, f"api/structure-runs/{structure_run_id}/events/stream")
         with requests.get(url, headers=self.headers, stream=True) as response:
             response.raise_for_status()
             for line in response.iter_lines():
diff --git a/griptape/drivers/vector/griptape_cloud_vector_store_driver.py b/griptape/drivers/vector/griptape_cloud_vector_store_driver.py
index afdb7eb5..7f094f9b 100644
--- a/griptape/drivers/vector/griptape_cloud_vector_store_driver.py
+++ b/griptape/drivers/vector/griptape_cloud_vector_store_driver.py
@@ -2,7 +2,6 @@ from __future__ import annotations
 
 import os
 from typing import TYPE_CHECKING, Any, NoReturn, Optional
-from urllib.parse import urljoin
 
 import requests
 from attrs import Factory, define, field
@@ -10,6 +9,7 @@ from attrs import Factory, define, field
 from griptape.artifacts.image_artifact import ImageArtifact
 from griptape.drivers.embedding.dummy import DummyEmbeddingDriver
 from griptape.drivers.vector import BaseVectorStoreDriver
+from griptape.utils.griptape_cloud import griptape_cloud_url
 
 if TYPE_CHECKING:
     from griptape.artifacts import ListArtifact, TextArtifact
@@ -100,7 +100,7 @@ class GriptapeCloudVectorStoreDriver(BaseVectorStoreDriver):
         """
         if isinstance(query, ImageArtifact):
             raise ValueError(f"{self.__class__.__name__} does not support querying with Image Artifacts.")
-        url = urljoin(self.base_url.strip("/"), f"/api/knowledge-bases/{self.knowledge_base_id}/query")
+        url = griptape_cloud_url(self.base_url, f"api/knowledge-bases/{self.knowledge_base_id}/query")
 
         query_args = {
             "count": count,
diff --git a/griptape/utils/griptape_cloud.py b/griptape/utils/griptape_cloud.py
index 3554c24b..3d5bc7ca 100644
--- a/griptape/utils/griptape_cloud.py
+++ b/griptape/utils/griptape_cloud.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 import os
 from typing import TYPE_CHECKING, Any, Optional, TypeVar
+from urllib.parse import urljoin
 
 from attrs import define, field
 from typing_extensions import ParamSpec, Self
@@ -27,6 +28,12 @@ if TYPE_CHECKING:
     from griptape.observability.observability import Observability
 
 
+def griptape_cloud_url(base_url: str, route: str) -> str:
+    if not base_url.endswith("/"):
+        base_url += "/"
+    return urljoin(base_url, route.lstrip("/"))
+
+
 @define()
 class GriptapeCloudStructure:
     """Utility for working with Griptape Cloud Structures.


Test patch:
diff --git a/tests/unit/utils/test_griptape_cloud_utils.py b/tests/unit/utils/test_griptape_cloud_utils.py
index 6a0f9730..c4f6488a 100644
--- a/tests/unit/utils/test_griptape_cloud_utils.py
+++ b/tests/unit/utils/test_griptape_cloud_utils.py
@@ -9,6 +9,7 @@ from griptape.events import EventListener
 from griptape.events.event_bus import EventBus
 from griptape.observability.observability import Observability
 from griptape.utils import GriptapeCloudStructure
+from griptape.utils.griptape_cloud import griptape_cloud_url
 from tests.mocks.mock_event_listener_driver import MockEventListenerDriver
 
 
@@ -83,3 +84,37 @@ class TestGriptapeCloudUtils:
             context.output = Mock(foo="bar")
             assert isinstance(context.output, GenericArtifact)
             assert context.output.value.foo == "bar"
+
+    def test_griptape_cloud_url(self):
+        assert (
+            griptape_cloud_url("https://cloud.griptape.ai", "api/chat/messages")
+            == "https://cloud.griptape.ai/api/chat/messages"
+        )
+        assert (
+            griptape_cloud_url("https://cloud.griptape.ai/", "api/chat/messages")
+            == "https://cloud.griptape.ai/api/chat/messages"
+        )
+        assert (
+            griptape_cloud_url("https://cloud.griptape.ai/", "/api/chat/messages")
+            == "https://cloud.griptape.ai/api/chat/messages"
+        )
+        assert (
+            griptape_cloud_url("https://cloud.griptape.ai", "/api/chat/messages")
+            == "https://cloud.griptape.ai/api/chat/messages"
+        )
+        assert (
+            griptape_cloud_url("https://cloud.griptape.ai/foo/bar", "api/chat/messages")
+            == "https://cloud.griptape.ai/foo/bar/api/chat/messages"
+        )
+        assert (
+            griptape_cloud_url("https://cloud.griptape.ai/foo/bar/", "api/chat/messages")
+            == "https://cloud.griptape.ai/foo/bar/api/chat/messages"
+        )
+        assert (
+            griptape_cloud_url("https://cloud.griptape.ai/foo/bar/", "/api/chat/messages")
+            == "https://cloud.griptape.ai/foo/bar/api/chat/messages"
+        )
+        assert (
+            griptape_cloud_url("https://cloud.griptape.ai/foo/bar", "/api/chat/messages")
+            == "https://cloud.griptape.ai/foo/bar/api/chat/messages"
+        )