11import json
2- from typing import Any , cast
2+ from typing import TYPE_CHECKING , Any , cast
33
44from griptape .artifacts import ImageUrlArtifact , ModelArtifact
55from griptape .drivers .prompt .base_prompt_driver import BasePromptDriver
66from griptape .drivers .prompt .griptape_cloud_prompt_driver import GriptapeCloudPromptDriver
7+ from griptape .drivers .prompt .openai import OpenAiChatPromptDriver as GtOpenAiChatPromptDriver
78from griptape .structures import Structure
89from griptape .tasks import PromptTask
9- from griptape_nodes .exe_types .core_types import Parameter , ParameterList , ParameterMode , ParameterType
10+ from griptape_nodes .exe_types .core_types import (
11+ NodeMessageResult ,
12+ Parameter ,
13+ ParameterList ,
14+ ParameterMode ,
15+ ParameterType ,
16+ )
1017from griptape_nodes .exe_types .node_types import AsyncResult , BaseNode , ControlNode
1118from griptape_nodes .exe_types .param_components .model_access_component import ModelAccessComponent
1219from griptape_nodes .exe_types .param_types .parameter_bool import ParameterBool
1320from griptape_nodes .exe_types .param_types .parameter_json import ParameterJson
1421from griptape_nodes .exe_types .param_types .parameter_string import ParameterString
1522from griptape_nodes .retained_mode .events .connection_events import CreateConnectionRequest , DeleteConnectionRequest
1623from griptape_nodes .retained_mode .griptape_nodes import GriptapeNodes , logger
24+ from griptape_nodes .traits .button import Button , ButtonDetailsMessagePayload
1725from griptape_nodes .traits .options import Options
1826from json_schema_to_pydantic import create_model # pyright: ignore[reportMissingImports]
1927
2028from griptape_nodes_library .agents .griptape_nodes_agent import GriptapeNodesAgent as GtAgent
29+ from griptape_nodes_library .config .prompt .cloud_models import MODEL_CHOICES_ARGS
2130from griptape_nodes_library .utils .agent_utils import (
2231 build_rulesets_from_configs ,
2332 build_tools ,
2837from griptape_nodes_library .utils .error_utils import try_throw_error
2938from griptape_nodes_library .utils .image_utils import load_image_from_url_artifact
3039
40+ if TYPE_CHECKING :
41+ from griptape_nodes .retained_mode .events .agent_events import (
42+ ListAgentProvidersRequest , # pyright: ignore[reportAttributeAccessIssue]
43+ ListAgentProvidersResultSuccess , # pyright: ignore[reportAttributeAccessIssue]
44+ ListProviderModelsRequest , # pyright: ignore[reportAttributeAccessIssue]
45+ ListProviderModelsResultSuccess , # pyright: ignore[reportAttributeAccessIssue]
46+ ProviderConfig , # pyright: ignore[reportAttributeAccessIssue]
47+ )
48+
49+ _AGENT_PROVIDERS_AVAILABLE : bool = False
50+ _GRIPTAPE_CLOUD_PROVIDER : "ProviderConfig" = cast ("ProviderConfig" , None )
51+
52+ try :
53+ from griptape_nodes .retained_mode .events .agent_events import (
54+ ListAgentProvidersRequest , # pyright: ignore[reportAttributeAccessIssue]
55+ ListAgentProvidersResultSuccess , # pyright: ignore[reportAttributeAccessIssue]
56+ ListProviderModelsRequest , # pyright: ignore[reportAttributeAccessIssue]
57+ ListProviderModelsResultSuccess , # pyright: ignore[reportAttributeAccessIssue]
58+ ProviderConfig , # pyright: ignore[reportAttributeAccessIssue]
59+ )
60+
61+ _AGENT_PROVIDERS_AVAILABLE = True
62+ _GRIPTAPE_CLOUD_PROVIDER = ProviderConfig (name = "griptape_cloud" , type = "griptape_cloud" , model = "" )
63+ except ImportError :
64+ pass
65+
3166SERVICE = "Griptape"
3267API_KEY_URL = "https://cloud.griptape.ai/configuration/api-keys"
3368API_KEY_ENV_VAR = "GT_CLOUD_API_KEY"
34- MODEL_CHOICES = [
69+
70+ # Vision-capable models available on Griptape Cloud.
71+ GTC_VISION_MODEL_CHOICES = [
3572 "gpt-5.2" ,
3673 "gpt-5.1" ,
3774 "gpt-5" ,
4885 "gemini-3.1-pro" ,
4986 "gemini-2.5-pro" ,
5087]
51- DEFAULT_MODEL = MODEL_CHOICES [0 ]
88+ DEFAULT_MODEL = GTC_VISION_MODEL_CHOICES [0 ]
5289
5390
5491class DescribeImage (ControlNode ):
@@ -65,6 +102,29 @@ def __init__(self, **kwargs) -> None:
65102 allowed_modes = {ParameterMode .INPUT , ParameterMode .OUTPUT },
66103 )
67104 )
105+
106+ # Provider selector — populated from the engine's configured providers.
107+ provider_names = self ._fetch_provider_names ()
108+ self .add_parameter (
109+ Parameter (
110+ name = "model_provider" ,
111+ type = "str" ,
112+ default_value = provider_names [0 ] if provider_names else "griptape_cloud" ,
113+ allowed_modes = {ParameterMode .INPUT , ParameterMode .PROPERTY },
114+ tooltip = "Choose a provider. Refresh to see all configured providers." ,
115+ traits = {
116+ Options (choices = provider_names ),
117+ Button (
118+ icon = "list-restart" ,
119+ size = "icon" ,
120+ variant = "secondary" ,
121+ on_click = self ._refresh_providers_button ,
122+ ),
123+ },
124+ ui_options = {"display_name" : "provider" },
125+ )
126+ )
127+
68128 model_param = Parameter (
69129 name = "model" ,
70130 input_types = ["str" , "Prompt Model Config" ],
@@ -82,7 +142,7 @@ def __init__(self, **kwargs) -> None:
82142 self ._model_access = ModelAccessComponent (
83143 node = self ,
84144 parameter = model_param ,
85- model_choices = MODEL_CHOICES ,
145+ model_choices = GTC_VISION_MODEL_CHOICES ,
86146 default_model = DEFAULT_MODEL ,
87147 )
88148 self .add_parameter (
@@ -148,6 +208,94 @@ def __init__(self, **kwargs) -> None:
148208 )
149209 )
150210
211+ # --- Provider / model helpers (mirrors agent.py) ---
212+
213+ def _fetch_providers (self ) -> "list[ProviderConfig]" :
214+ _FALLBACK = [_GRIPTAPE_CLOUD_PROVIDER ]
215+ try :
216+ result = GriptapeNodes .handle_request (ListAgentProvidersRequest ())
217+ if not isinstance (result , ListAgentProvidersResultSuccess ):
218+ return _FALLBACK
219+ return cast (ListAgentProvidersResultSuccess , result ).providers or _FALLBACK
220+ except Exception :
221+ return _FALLBACK
222+
223+ def _fetch_provider_names (self ) -> list [str ]:
224+ providers = self ._fetch_providers ()
225+ return [p .name for p in providers ] or ["griptape_cloud" ]
226+
227+ def _resolve_provider_api_key (self , provider_config : "ProviderConfig" ) -> str :
228+ secret_name = provider_config .api_key_secret_name or ""
229+ if secret_name :
230+ return (
231+ GriptapeNodes .SecretsManager ().get_secret (secret_name , should_error_on_not_found = False ) or "not-needed"
232+ )
233+ return "not-needed"
234+
235+ def _fetch_models_for_provider (self , provider_name : str ) -> list [str ]:
236+ if not _AGENT_PROVIDERS_AVAILABLE :
237+ return GTC_VISION_MODEL_CHOICES
238+ try :
239+ providers = self ._fetch_providers ()
240+ provider_config = next ((p for p in providers if p .name == provider_name ), None )
241+ if provider_config is None :
242+ return GTC_VISION_MODEL_CHOICES
243+ result = GriptapeNodes .handle_request (
244+ ListProviderModelsRequest (
245+ provider = provider_config .type ,
246+ base_url = provider_config .base_url or "" ,
247+ api_key = self ._resolve_provider_api_key (provider_config ),
248+ )
249+ )
250+ if isinstance (result , ListProviderModelsResultSuccess ):
251+ return cast (ListProviderModelsResultSuccess , result ).models or GTC_VISION_MODEL_CHOICES
252+ except Exception :
253+ pass
254+ return GTC_VISION_MODEL_CHOICES
255+
256+ def _update_model_choices_for_provider (self , provider_name : str ) -> None :
257+ if provider_name == "griptape_cloud" :
258+ models = GTC_VISION_MODEL_CHOICES
259+ new_data = MODEL_CHOICES_ARGS
260+ else :
261+ models = self ._fetch_models_for_provider (provider_name )
262+ new_data = [{"name" : m , "icon" : "" , "args" : {}} for m in models ]
263+ default = models [0 ] if models else DEFAULT_MODEL
264+ self ._update_option_choices (param = "model" , choices = models , default = default )
265+ param = self .get_parameter_by_name ("model" )
266+ if param :
267+ param .update_ui_options_key ("data" , new_data )
268+
269+ def _refresh_providers_button (
270+ self ,
271+ button : Button ,
272+ button_details : ButtonDetailsMessagePayload , # noqa: ARG002
273+ ) -> NodeMessageResult | None :
274+ provider_names = self ._fetch_provider_names ()
275+ current = self .get_parameter_value ("model_provider" ) or "griptape_cloud"
276+ default = current if current in provider_names else (provider_names [0 ] if provider_names else "griptape_cloud" )
277+ self ._update_option_choices (param = "model_provider" , choices = provider_names , default = default )
278+ return None
279+
280+ def _refresh_models_button (
281+ self ,
282+ button : Button ,
283+ button_details : ButtonDetailsMessagePayload , # noqa: ARG002
284+ ) -> NodeMessageResult | None :
285+ provider_name = self .get_parameter_value ("model_provider" ) or "griptape_cloud"
286+ self ._update_model_choices_for_provider (provider_name )
287+ return None
288+
289+ def _uses_griptape_cloud_driver (self ) -> bool :
290+ if self .get_parameter_value ("agent" ) is not None :
291+ return False
292+ if isinstance (self .get_parameter_value ("model" ), BasePromptDriver ):
293+ return False
294+ provider_name = self .get_parameter_value ("model_provider" ) or "griptape_cloud"
295+ return provider_name == "griptape_cloud"
296+
297+ # --- Connection / UI helpers ---
298+
151299 def _update_output_type_and_validate_connections (self , new_output_type : str ) -> None :
152300 output_param = self .get_parameter_by_name ("output" )
153301 if output_param is None :
@@ -238,12 +386,10 @@ def set_parameter_value(
238386 )
239387
240388 def validate_before_workflow_run (self ) -> list [Exception ] | None :
241- # TODO: https://github.com/griptape-ai/griptape-nodes/issues/871
242389 exceptions = []
243- api_key = GriptapeNodes .SecretsManager ().get_secret (API_KEY_ENV_VAR )
244- # No need for the api key. These exceptions caught on other nodes.
245- if self .parameter_values .get ("agent" , None ) and self .parameter_values .get ("driver" , None ):
390+ if not self ._uses_griptape_cloud_driver ():
246391 return None
392+ api_key = GriptapeNodes .SecretsManager ().get_secret (API_KEY_ENV_VAR )
247393 if not api_key :
248394 msg = f"{ API_KEY_ENV_VAR } is not defined"
249395 exceptions .append (KeyError (msg ))
@@ -258,6 +404,7 @@ def after_incoming_connection(
258404 ) -> None :
259405 if target_parameter .name == "agent" :
260406 self .hide_parameter_by_name ("model" )
407+ self .hide_parameter_by_name ("model_provider" )
261408
262409 if target_parameter .name == "output_schema" :
263410 self ._update_output_type_and_validate_connections ("json" )
@@ -283,6 +430,7 @@ def after_incoming_connection_removed(
283430 ) -> None :
284431 if target_parameter .name == "agent" :
285432 self .show_parameter_by_name ("model" )
433+ self .show_parameter_by_name ("model_provider" )
286434 if target_parameter .name == "output_schema" :
287435 self .set_parameter_value ("output_schema" , None )
288436 self ._update_output_type_and_validate_connections ("str" )
@@ -309,17 +457,18 @@ def after_value_set(self, parameter: Parameter, value: Any) -> None:
309457 super ().after_value_set (parameter , value )
310458 if parameter .name == "model" :
311459 self ._model_access .on_value_changed (value )
460+ elif parameter .name == "model_provider" :
461+ self ._update_model_choices_for_provider (str (value ))
312462
313463 def process (self ) -> AsyncResult [Structure ]: # noqa: C901, PLR0915, PLR0912
314464 # Get the parameters from the node
315465 params = self .parameter_values
316466 model_input = self .get_parameter_value ("model" )
467+ provider_name = self .get_parameter_value ("model_provider" ) or "griptape_cloud"
317468
318- # License-policy runtime gate. Raises RuntimeError if the currently-selected
319- # model is denied, which propagates as an error toast in the workflow.
320- # Bypasses non-string values (e.g. a connected Prompt Model Config driver
321- # carries its own model identity that the helper isn't aware of).
322- self ._model_access .raise_if_denied (model_input )
469+ # License-policy runtime gate — only enforced for Griptape Cloud models.
470+ if provider_name == "griptape_cloud" :
471+ self ._model_access .raise_if_denied (model_input )
323472
324473 agent = None
325474
@@ -366,18 +515,14 @@ def process(self) -> AsyncResult[Structure]: # noqa: C901, PLR0915, PLR0912
366515 logger .error (msg )
367516 raise
368517
369- # If an agent is provided, we'll use and ensure it's using a PromptTask
370- # If a prompt_driver is provided, we'll use that
371- # If neither are provided, we'll create a new one with the selected model.
372- # Otherwise, we'll just use the default model
373518 tool_configs : list = []
374519 ruleset_configs : list = []
520+ non_gtc_provider_config = None
375521 agent_value = self .get_parameter_value ("agent" )
376522 if isinstance (agent_value , dict ):
377523 agent_core_dict , tool_configs , ruleset_configs = unwrap_agent (agent_value )
378524 agent = GtAgent ().from_dict (agent_core_dict )
379525 restore_provider_driver (agent , agent_value )
380- # Rebuild tools and rulesets from configs so they're fresh for this run.
381526 if tool_configs :
382527 live_tools , _ = build_tools (tool_configs )
383528 if live_tools and agent .tasks :
@@ -391,6 +536,19 @@ def process(self) -> AsyncResult[Structure]: # noqa: C901, PLR0915, PLR0912
391536 agent .tasks [0 ].output_schema = pydantic_schema
392537 elif isinstance (model_input , BasePromptDriver ):
393538 agent = GtAgent (prompt_driver = model_input , output_schema = pydantic_schema )
539+ elif provider_name != "griptape_cloud" :
540+ providers = self ._fetch_providers ()
541+ non_gtc_provider_config = next ((p for p in providers if p .name == provider_name ), None )
542+ if non_gtc_provider_config is not None :
543+ prompt_driver = GtOpenAiChatPromptDriver (
544+ model = model_input if isinstance (model_input , str ) else DEFAULT_MODEL ,
545+ base_url = non_gtc_provider_config .base_url or "" ,
546+ api_key = self ._resolve_provider_api_key (non_gtc_provider_config ),
547+ stream = True ,
548+ )
549+ else :
550+ prompt_driver = default_prompt_driver
551+ agent = GtAgent (prompt_driver = prompt_driver , output_schema = pydantic_schema )
394552 elif isinstance (model_input , str ):
395553 if model_input not in self ._model_access .model_choices :
396554 model_input = DEFAULT_MODEL
@@ -401,7 +559,6 @@ def process(self) -> AsyncResult[Structure]: # noqa: C901, PLR0915, PLR0912
401559 )
402560 agent = GtAgent (prompt_driver = prompt_driver , output_schema = pydantic_schema )
403561 else :
404- # If the agent is not provided, we'll create a new one with a default prompt driver
405562 agent = GtAgent (prompt_driver = default_prompt_driver , output_schema = pydantic_schema )
406563
407564 prompt = params .get ("prompt" , "" )
@@ -412,17 +569,32 @@ def process(self) -> AsyncResult[Structure]: # noqa: C901, PLR0915, PLR0912
412569 if get_description_only :
413570 prompt += "\n \n Output image description only."
414571
415- image_artifacts = [
416- load_image_from_url_artifact (img ) if isinstance (img , ImageUrlArtifact ) else img
417- for img in (self .get_parameter_value ("images" ) or [])
418- if img is not None
419- ]
572+ # Flatten nested lists — a ParameterList child may receive a list of artifacts
573+ # when connected to an output that produces List[ImageUrlArtifact].
574+ raw_images = self .get_parameter_value ("images" ) or []
575+ flat_images : list = []
576+ for img in raw_images :
577+ if isinstance (img , list ):
578+ flat_images .extend (img )
579+ else :
580+ flat_images .append (img )
581+
582+ image_artifacts = []
583+ for img in flat_images :
584+ if img is None :
585+ continue
586+ if isinstance (img , ImageUrlArtifact ):
587+ image_artifacts .append (load_image_from_url_artifact (img ))
588+ elif isinstance (img , str ) and img .strip ():
589+ # String path or URL — load as image bytes rather than passing as text.
590+ image_artifacts .append (load_image_from_url_artifact (ImageUrlArtifact (img )))
591+ else :
592+ image_artifacts .append (img )
420593
421594 if not image_artifacts :
422595 self .parameter_output_values ["output" ] = "No image provided"
423596 return
424597
425- # Run the agent
426598 yield lambda : agent .run ([prompt , * image_artifacts ])
427599 agent_output = agent .output
428600 output_value = agent_output .value
@@ -431,7 +603,8 @@ def process(self) -> AsyncResult[Structure]: # noqa: C901, PLR0915, PLR0912
431603
432604 self .parameter_output_values ["output" ] = output_value
433605
434- # Insert a false memory to prevent the base64
606+ # Replace the run's image bytes with text — without this, those bytes get serialized
607+ # into conversation history and resent on every downstream API call.
435608 memory_output = output_value
436609 if isinstance (memory_output , (dict , list )):
437610 memory_output = json .dumps (memory_output , ensure_ascii = False )
@@ -441,9 +614,20 @@ def process(self) -> AsyncResult[Structure]: # noqa: C901, PLR0915, PLR0912
441614 # Clear live tools before serializing, then wrap with configs for downstream nodes.
442615 if agent .tasks :
443616 cast (PromptTask , agent .tasks [0 ]).tools = []
617+
618+ # For non-GTC providers, include provider info so downstream nodes can restore the driver.
619+ incoming_provider = agent_value .get ("provider" ) if isinstance (agent_value , dict ) else None
620+ provider_info = None
621+ if non_gtc_provider_config is not None :
622+ provider_info = {
623+ "name" : provider_name ,
624+ "base_url" : non_gtc_provider_config .base_url or "" ,
625+ "api_key" : self ._resolve_provider_api_key (non_gtc_provider_config ),
626+ }
627+
444628 self .parameter_output_values ["agent" ] = wrap_agent (
445629 agent .to_dict (),
446630 tool_configs ,
447631 ruleset_configs ,
448- provider = agent_value . get ( "provider" ) if isinstance ( agent_value , dict ) else None ,
632+ provider = provider_info or incoming_provider ,
449633 )
0 commit comments