Skip to content

Commit e709eff

Browse files
shhlifeclaude
andcommitted
feat(describe-image): add provider selection and fix image loading
- Add model_provider dropdown (mirrors agent.py pattern) with refresh button and dynamic model list via ListAgentProvidersRequest / ListProviderModelsRequest; non-GTC providers use GtOpenAiChatPromptDriver and forward provider credentials in the agent wrapper - Fix validate_before_workflow_run: replace buggy driver-param check with _uses_griptape_cloud_driver() helper so non-GTC runs aren't blocked - Flatten nested lists from ParameterList before processing images - Convert string paths/URLs to ImageUrlArtifact so they're loaded as image bytes rather than passed as text to the model Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0686f90 commit e709eff

1 file changed

Lines changed: 212 additions & 28 deletions

File tree

griptape_nodes_library/image/describe_image.py

Lines changed: 212 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,32 @@
11
import json
2-
from typing import Any, cast
2+
from typing import TYPE_CHECKING, Any, cast
33

44
from griptape.artifacts import ImageUrlArtifact, ModelArtifact
55
from griptape.drivers.prompt.base_prompt_driver import BasePromptDriver
66
from griptape.drivers.prompt.griptape_cloud_prompt_driver import GriptapeCloudPromptDriver
7+
from griptape.drivers.prompt.openai import OpenAiChatPromptDriver as GtOpenAiChatPromptDriver
78
from griptape.structures import Structure
89
from 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+
)
1017
from griptape_nodes.exe_types.node_types import AsyncResult, BaseNode, ControlNode
1118
from griptape_nodes.exe_types.param_components.model_access_component import ModelAccessComponent
1219
from griptape_nodes.exe_types.param_types.parameter_bool import ParameterBool
1320
from griptape_nodes.exe_types.param_types.parameter_json import ParameterJson
1421
from griptape_nodes.exe_types.param_types.parameter_string import ParameterString
1522
from griptape_nodes.retained_mode.events.connection_events import CreateConnectionRequest, DeleteConnectionRequest
1623
from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes, logger
24+
from griptape_nodes.traits.button import Button, ButtonDetailsMessagePayload
1725
from griptape_nodes.traits.options import Options
1826
from json_schema_to_pydantic import create_model # pyright: ignore[reportMissingImports]
1927

2028
from griptape_nodes_library.agents.griptape_nodes_agent import GriptapeNodesAgent as GtAgent
29+
from griptape_nodes_library.config.prompt.cloud_models import MODEL_CHOICES_ARGS
2130
from griptape_nodes_library.utils.agent_utils import (
2231
build_rulesets_from_configs,
2332
build_tools,
@@ -28,10 +37,38 @@
2837
from griptape_nodes_library.utils.error_utils import try_throw_error
2938
from 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+
3166
SERVICE = "Griptape"
3267
API_KEY_URL = "https://cloud.griptape.ai/configuration/api-keys"
3368
API_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",
@@ -48,7 +85,7 @@
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

5491
class 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\nOutput 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

Comments
 (0)