diff --git a/py/packages/genkit/src/genkit/ai/__init__.py b/py/packages/genkit/src/genkit/ai/__init__.py index c0225c3bfd..aeb0268c14 100644 --- a/py/packages/genkit/src/genkit/ai/__init__.py +++ b/py/packages/genkit/src/genkit/ai/__init__.py @@ -37,7 +37,7 @@ from genkit.core.action.types import ActionKind from ._aio import Genkit -from ._plugin import Plugin +from ._plugin import Plugin, PluginV2 from ._registry import FlowWrapper, GenkitRegistry __all__ = [ @@ -47,6 +47,7 @@ GenkitRegistry.__name__, Genkit.__name__, Plugin.__name__, + PluginV2.__name__, ToolRunContext.__name__, tool_response.__name__, FlowWrapper.__name__, diff --git a/py/packages/genkit/src/genkit/ai/_base.py b/py/packages/genkit/src/genkit/ai/_base.py index edd39e2919..e2a2dcb1d1 100644 --- a/py/packages/genkit/src/genkit/ai/_base.py +++ b/py/packages/genkit/src/genkit/ai/_base.py @@ -17,6 +17,7 @@ """Base/shared implementation for Genkit user-facing API.""" import asyncio +import inspect import os import threading from collections.abc import Coroutine @@ -28,11 +29,13 @@ from genkit.aio.loop import create_loop, run_async from genkit.blocks.formats import built_in_formats from genkit.blocks.generate import define_generate_action +from genkit.core.action import Action from genkit.core.environment import is_dev_environment from genkit.core.reflection import make_reflection_server +from genkit.core.registry import ActionKind from genkit.web.manager import find_free_port_sync -from ._plugin import Plugin +from ._plugin import Plugin, PluginV2, is_plugin_v2 from ._registry import GenkitRegistry from ._server import ServerSpec, init_default_runtime @@ -120,7 +123,9 @@ def _initialize_registry(self, model: str | None, plugins: list[Plugin] | None) logger.warning('No plugins provided to Genkit') else: for plugin in plugins: - if isinstance(plugin, Plugin): + if is_plugin_v2(plugin): + self._initialize_v2_plugin(plugin) + elif isinstance(plugin, Plugin): plugin.initialize(ai=self) def resolver(kind, name, plugin=plugin): @@ -135,7 +140,57 @@ def action_resolver(plugin=plugin): self.registry.register_action_resolver(plugin.plugin_name(), resolver) self.registry.register_list_actions_resolver(plugin.plugin_name(), action_resolver) else: - raise ValueError(f'Invalid {plugin=} provided to Genkit: must be of type `genkit.ai.Plugin`') + raise ValueError(f'Invalid {plugin=} provided to Genkit: must be of type `genkit.ai.Plugin` or `genkit.ai.PluginV2`') + + def _initialize_v2_plugin(self, plugin: PluginV2) -> None: + """Register a v2 plugin by calling its methods and registering returned actions. + + Steps: + 1. Call plugin.init() to get resolved actions + 2. Register each action with automatic namespacing + 3. Set up lazy resolver for on-demand actions + + Args: + plugin: V2 plugin instance to register. + """ + if inspect.iscoroutinefunction(plugin.init): + resolved_actions = asyncio.run(plugin.init()) + else: + resolved_actions = plugin.init() + + for action in resolved_actions: + self._register_action(action, plugin) + + def resolver(kind: ActionKind, name: str) -> None: + """Lazy resolver for v2 plugin. + + Called when framework needs an action not returned from init(). + """ + if inspect.iscoroutinefunction(plugin.resolve): + action = asyncio.run(plugin.resolve(kind, name)) + else: + action = plugin.resolve(kind, name) + + if action: + self._register_action(action, plugin) + + self.registry.register_action_resolver(plugin.name, resolver) + + def _register_action(self, action: Any, plugin: PluginV2) -> None: + """Register a single action from a v2 plugin. + + Responsibilities: + 1. Add plugin namespace to action name (if not already present) + 2. Register action in the registry + + Args: + action: Action instance from the plugin. + plugin: The v2 plugin that created this action. + """ + # Register the pre-constructed action instance and let the registry apply + # namespacing for v2 plugins. + self.registry.register_action_instance(action, namespace=plugin.name) + def _initialize_server(self, reflection_server_spec: ServerSpec | None) -> None: """Initialize the server for the Genkit instance. diff --git a/py/packages/genkit/src/genkit/ai/_base_async.py b/py/packages/genkit/src/genkit/ai/_base_async.py index 7229c54642..df803d3780 100644 --- a/py/packages/genkit/src/genkit/ai/_base_async.py +++ b/py/packages/genkit/src/genkit/ai/_base_async.py @@ -16,6 +16,8 @@ """Asynchronous server gateway interface implementation for Genkit.""" +import asyncio +import inspect from collections.abc import Coroutine from typing import Any, TypeVar @@ -25,11 +27,13 @@ from genkit.aio.loop import run_loop from genkit.blocks.formats import built_in_formats +from genkit.core.action import Action from genkit.core.environment import is_dev_environment from genkit.core.reflection import create_reflection_asgi_app +from genkit.core.registry import ActionKind from genkit.web.manager import find_free_port_sync -from ._plugin import Plugin +from ._plugin import Plugin, PluginV2, is_plugin_v1, is_plugin_v2 from ._registry import GenkitRegistry from ._runtime import RuntimeManager from ._server import ServerSpec @@ -44,14 +48,14 @@ class GenkitBase(GenkitRegistry): def __init__( self, - plugins: list[Plugin] | None = None, + plugins: list[Plugin | PluginV2] | None = None, model: str | None = None, reflection_server_spec: ServerSpec | None = None, ) -> None: """Initialize a new Genkit instance. Args: - plugins: List of plugins to initialize. + plugins: List of plugins to initialize (v1 or v2). model: Model name to use. reflection_server_spec: Server spec for the reflection server. If not provided in dev mode, a default will be used. @@ -60,12 +64,15 @@ def __init__( self._reflection_server_spec = reflection_server_spec self._initialize_registry(model, plugins) - def _initialize_registry(self, model: str | None, plugins: list[Plugin] | None) -> None: + def _initialize_registry(self, model: str | None, plugins: list[Plugin | PluginV2] | None) -> None: """Initialize the registry for the Genkit instance. + Supports both v1 (Plugin) and v2 (PluginV2) plugins. Detection is done + at runtime via is_plugin_v2(). + Args: model: Model name to use. - plugins: List of plugins to initialize. + plugins: List of plugins to initialize (v1 or v2). Raises: ValueError: If an invalid plugin is provided. @@ -81,7 +88,11 @@ def _initialize_registry(self, model: str | None, plugins: list[Plugin] | None) logger.warning('No plugins provided to Genkit') else: for plugin in plugins: - if isinstance(plugin, Plugin): + if is_plugin_v2(plugin): + logger.debug(f'Registering v2 plugin: {plugin.name}') + self._register_v2_plugin(plugin) + elif is_plugin_v1(plugin): + logger.debug(f'Registering v1 plugin: {plugin.plugin_name()}') plugin.initialize(ai=self) def resolver(kind, name, plugin=plugin): @@ -89,7 +100,62 @@ def resolver(kind, name, plugin=plugin): self.registry.register_action_resolver(plugin.plugin_name(), resolver) else: - raise ValueError(f'Invalid {plugin=} provided to Genkit: must be of type `genkit.ai.Plugin`') + raise ValueError( + f'Invalid {plugin=} provided to Genkit: ' + f'must implement either Plugin or PluginV2 interface' + ) + + def _register_v2_plugin(self, plugin: PluginV2) -> None: + """Register a v2 plugin by calling its methods and registering returned actions. + + Steps: + 1. Call plugin.init() to get resolved actions + 2. Register each action with automatic namespacing + 3. Set up lazy resolver for on-demand actions + + Args: + plugin: V2 plugin instance to register. + """ + if inspect.iscoroutinefunction(plugin.init): + resolved_actions = asyncio.run(plugin.init()) + else: + resolved_actions = plugin.init() + + for action in resolved_actions: + self._register_action_v2(action, plugin) + + def resolver(kind: ActionKind, name: str) -> None: + """Lazy resolver for v2 plugin. + + Called when framework needs an action not returned from init(). + """ + # Check if resolve method is async + if inspect.iscoroutinefunction(plugin.resolve): + action = asyncio.run(plugin.resolve(kind, name)) + else: + action = plugin.resolve(kind, name) + + if action: + self._register_action_v2(action, plugin) + + self.registry.register_action_resolver(plugin.name, resolver) + + def _register_action_v2(self, action: Action, plugin: PluginV2) -> None: + """Register a single action from a v2 plugin. + + Responsibilities: + 1. Add plugin namespace to action name (if not already present) + 2. Register action in the registry + + Args: + action: Action instance from the plugin. + plugin: The v2 plugin that created this action. + """ + # Register the pre-constructed action instance and let the registry apply + # namespacing for v2 plugins. + self.registry.register_action_instance(action, namespace=plugin.name) + + logger.debug(f'Registered v2 action: {action.name}') def run_main(self, coro: Coroutine[Any, Any, T]) -> T: """Run the user's main coroutine. diff --git a/py/packages/genkit/src/genkit/ai/_plugin.py b/py/packages/genkit/src/genkit/ai/_plugin.py index 691657ca20..875fcc89d3 100644 --- a/py/packages/genkit/src/genkit/ai/_plugin.py +++ b/py/packages/genkit/src/genkit/ai/_plugin.py @@ -21,10 +21,13 @@ """ import abc +import inspect +from collections.abc import Awaitable +from typing import Any, Literal from genkit.core.registry import ActionKind -from ..core.action import ActionMetadata +from ..core.action import Action, ActionMetadata from ._registry import GenkitRegistry @@ -87,3 +90,153 @@ def list_actions(self) -> list[ActionMetadata]: - config_schema (type): The schema class used for validating the model's configuration. """ return [] + + +class PluginV2(abc.ABC): + """Base class for v2 plugins that return actions instead of mutating registry. + + V2 plugins are decoupled from the registry - they create and return Action + objects which the framework then registers. This enables: + - Standalone usage (use plugins without framework) + - Better testability (test plugins in isolation) + + Plugin authors should inherit from this class and implement the required methods. + The version marker is set automatically. + + Example: + >>> class MyPlugin(PluginV2): + ... name = "myplugin" + ... + ... def init(self): + ... return [model(name="my-model", fn=self._generate)] + ... + ... def resolve(self, action_type, name): + ... return model(name=name, fn=self._generate) + """ + + version: Literal["v2"] = "v2" + """Version marker - set automatically by base class.""" + + name: str + """Plugin name (e.g., 'anthropic', 'openai'). Must be set by subclass.""" + + @abc.abstractmethod + def init(self) -> list[Action] | Awaitable[list[Action]]: + """Return eagerly-initialized actions. + + Called once during Genkit initialization. Return actions you want + created immediately (common models, frequently used tools, etc.). + + Can be sync or async. + + Returns: + List of Action objects (not yet registered with any registry). + + Example: + >>> def init(self): + ... from genkit.blocks.model import model + ... return [ + ... model(name="gpt-4", fn=self._generate), + ... model(name="gpt-4o", fn=self._generate), + ... ] + """ + ... + + @abc.abstractmethod + def resolve( + self, + action_type: ActionKind, + name: str, + ) -> Action | None | Awaitable[Action | None]: + """Resolve a specific action on-demand (lazy loading). + + Called when the framework needs an action that wasn't returned from init(). + Enables lazy loading of less-common models or actions. + + Can be sync or async. + + Args: + action_type: Type of action requested (MODEL, EMBEDDER, TOOL, etc.). + name: Name of the action (WITHOUT plugin prefix - framework strips it). + + Returns: + Action object if this plugin can provide it, None if it cannot. + + Example: + >>> def resolve(self, action_type, name): + ... if action_type == ActionKind.MODEL: + ... if name in SUPPORTED_MODELS: + ... from genkit.blocks.model import model + ... return model(name=name, fn=self._generate) + ... return None + """ + ... + + def list(self) -> list[ActionMetadata] | Awaitable[list[ActionMetadata]]: + """List all actions this plugin can provide. + + Used for discovery, developer tools, and documentation. + Should return metadata for ALL actions the plugin supports, + not just those returned from init(). + + Can be sync or async. + + Returns: + List of ActionMetadata objects (lightweight descriptions). + + Example: + >>> def list(self): + ... return [ + ... ActionMetadata( + ... name="gpt-4", + ... kind=ActionKind.MODEL, + ... info={"supports": {"vision": True}} + ... ), + ... # ... more models + ... ] + """ + # Default implementation returns empty (can override) + return [] + + async def model(self, name: str) -> Action: + """Convenience method to get a specific model action. + + Enables clean standalone usage: + plugin = SomePlugin() + model = await plugin.model('model-name') + response = await model.arun(...) + + Args: + name: Model name (without plugin prefix). + + Returns: + Action for the specified model. + + Raises: + ValueError: If the model is not supported by this plugin. + + Example: + >>> async def model(self, name: str) -> Action: + ... action = self.resolve(ActionKind.MODEL, name) + ... if not action: + ... raise ValueError(f\"Model {name} not found\") + ... return action + """ + # Default implementation - plugins can override if needed + if inspect.iscoroutinefunction(self.resolve): + action = await self.resolve(ActionKind.MODEL, name) + else: + action = self.resolve(ActionKind.MODEL, name) + + if not action: + raise ValueError( + f"Model '{name}' not found in plugin '{self.name}'" + ) + return action + + +def is_plugin_v2(plugin: Any) -> bool: + return hasattr(plugin, "version") and getattr(plugin, "version") == "v2" + +def is_plugin_v1(plugin: Any) -> bool: + return isinstance(plugin, Plugin) diff --git a/py/packages/genkit/src/genkit/blocks/embedding.py b/py/packages/genkit/src/genkit/blocks/embedding.py index 95bca321a2..8ac473b152 100644 --- a/py/packages/genkit/src/genkit/blocks/embedding.py +++ b/py/packages/genkit/src/genkit/blocks/embedding.py @@ -21,9 +21,9 @@ from pydantic import BaseModel, ConfigDict, Field -from genkit.core.action import ActionMetadata +from genkit.core.action import Action, ActionMetadata from genkit.core.action.types import ActionKind -from genkit.core.schema import to_json_schema +from genkit.core.schema import get_func_description, to_json_schema from genkit.core.typing import EmbedRequest, EmbedResponse @@ -60,6 +60,64 @@ class EmbedderRef(BaseModel): EmbedderFn = Callable[[EmbedRequest], EmbedResponse] +def embedder( + name: str, + fn: EmbedderFn, + options: EmbedderOptions | None = None, + metadata: dict[str, Any] | None = None, + description: str | None = None, +) -> 'Action': + """Create an embedder action WITHOUT registering it. + + This is the v2 API for creating embedders. Returns an Action instance + that can be used standalone or registered by the framework. + + Args: + name: Embedder name (without plugin prefix). + fn: Function that implements embedding (takes EmbedRequest, returns EmbedResponse). + options: Optional embedder options (dimensions, supports, etc.). + metadata: Optional metadata dictionary. + description: Optional human-readable description. + + Returns: + Action instance (not registered). + + Example: + >>> from genkit.blocks.embedding import embedder + >>> + >>> def my_embed(request: EmbedRequest) -> EmbedResponse: + ... return EmbedResponse(...) + >>> + >>> action = embedder(name="my-embedder", fn=my_embed) + >>> response = await action.arun({"input": [...]}) + """ + embedder_meta = metadata if metadata else {} + + if 'embedder' not in embedder_meta: + embedder_meta['embedder'] = {} + + if 'label' not in embedder_meta['embedder'] or not embedder_meta['embedder']['label']: + embedder_meta['embedder']['label'] = name + + if options: + if options.dimensions: + embedder_meta['embedder']['dimensions'] = options.dimensions + if options.config_schema: + embedder_meta['embedder']['customOptions'] = options.config_schema + if options.supports: + embedder_meta['embedder']['supports'] = options.supports.model_dump(exclude_none=True, by_alias=True) + + final_description = description if description else get_func_description(fn) + + return Action( + name=name, + kind=ActionKind.EMBEDDER, + fn=fn, + metadata=embedder_meta, + description=final_description, + ) + + def embedder_action_metadata( name: str, options: EmbedderOptions | None = None, diff --git a/py/packages/genkit/src/genkit/blocks/model.py b/py/packages/genkit/src/genkit/blocks/model.py index b78ee6455e..62dff9eb82 100644 --- a/py/packages/genkit/src/genkit/blocks/model.py +++ b/py/packages/genkit/src/genkit/blocks/model.py @@ -36,10 +36,11 @@ def my_model(request: GenerateRequest) -> GenerateResponse: from pydantic import BaseModel, Field -from genkit.core.action import ActionMetadata, ActionRunContext +from genkit.codec import dump_dict +from genkit.core.action import Action, ActionMetadata, ActionRunContext from genkit.core.action.types import ActionKind from genkit.core.extract import extract_json -from genkit.core.schema import to_json_schema +from genkit.core.schema import get_func_description, to_json_schema from genkit.core.typing import ( Candidate, DocumentPart, @@ -64,6 +65,81 @@ def my_model(request: GenerateRequest) -> GenerateResponse: ChunkParser = Callable[['GenerateResponseChunkWrapper'], T] +def model( + name: str, + fn: ModelFn, + config_schema: type[BaseModel] | None = None, + metadata: dict[str, Any] | None = None, + info: ModelInfo | None = None, + description: str | None = None, +) -> 'Action': + """Create a model action WITHOUT registering it. + + This is the v2 API for creating models. Unlike ai.define_model(), + this function does NOT register the action in any registry - it just + creates and returns an Action object. + + This enables: + 1. V2 plugins to create actions without needing a registry + 2. Standalone usage (call the action directly without framework) + 3. Framework to register actions from v2 plugins when needed + + Args: + name: Model name (without plugin prefix - framework adds it automatically). + fn: Function that implements the model. Takes GenerateRequest and + ActionRunContext, returns GenerateResponse. + config_schema: Optional Pydantic model for config validation. + metadata: Optional metadata dictionary. + info: Optional ModelInfo describing model capabilities (vision, tools, etc.). + description: Optional human-readable description. + + Returns: + Action instance (not registered anywhere). + + Example: + >>> from genkit.blocks.model import model + >>> + >>> def my_generate(request: GenerateRequest, ctx: ActionRunContext): + ... return GenerateResponse(...) + >>> + >>> action = model(name="my-model", fn=my_generate) + >>> response = await action.arun({"messages": [...]}) + + Note: + This function extracts the "create action" logic from + GenkitRegistry.define_model() but skips the registration step. + """ + model_meta: dict[str, Any] = metadata if metadata else {} + + if info: + model_meta['model'] = dump_dict(info) + + if 'model' not in model_meta: + model_meta['model'] = {} + + if 'label' not in model_meta['model'] or not model_meta['model']['label']: + model_meta['model']['label'] = name + + if config_schema: + model_meta['model']['customOptions'] = to_json_schema(config_schema) + + final_description = description if description else get_func_description(fn) + + action = Action( + name=name, + kind=ActionKind.MODEL, + fn=fn, + metadata=model_meta, + description=final_description, + ) + + # NOTE: We do NOT call registry.register_action() here! + # That's the key difference from define_model(). + # The action is created but not registered anywhere. + + return action + + # type ModelMiddlewareNext = Callable[[GenerateRequest, ActionRunContext], Awaitable[GenerateResponse]] ModelMiddlewareNext = Callable[[GenerateRequest, ActionRunContext], Awaitable[GenerateResponse]] # type ModelMiddleware = Callable[ diff --git a/py/packages/genkit/src/genkit/blocks/retriever.py b/py/packages/genkit/src/genkit/blocks/retriever.py index 6564bc92ff..99548b641d 100644 --- a/py/packages/genkit/src/genkit/blocks/retriever.py +++ b/py/packages/genkit/src/genkit/blocks/retriever.py @@ -28,9 +28,9 @@ from pydantic import BaseModel, ConfigDict, Field from genkit.blocks.document import Document -from genkit.core.action import ActionMetadata +from genkit.core.action import Action, ActionMetadata from genkit.core.action.types import ActionKind -from genkit.core.schema import to_json_schema +from genkit.core.schema import get_func_description, to_json_schema from genkit.core.typing import DocumentData, RetrieverResponse T = TypeVar('T') @@ -38,6 +38,45 @@ RetrieverFn = Callable[[Document, T], RetrieverResponse] +def retriever( + name: str, + fn: RetrieverFn, + config_schema: type[BaseModel] | None = None, + metadata: dict[str, Any] | None = None, + description: str | None = None, +) -> 'Action': + """Create a retriever action WITHOUT registering it. + + V2 API for creating retrievers. Returns an Action instance that can be + used standalone or registered by the framework. + + Args: + name: Retriever name (without plugin prefix). + fn: Function implementing retriever behavior. + config_schema: Optional schema for retriever configuration. + metadata: Optional metadata dictionary. + description: Optional description. + + Returns: + Action instance (not registered). + """ + retriever_meta = metadata if metadata else {} + if 'retriever' not in retriever_meta: + retriever_meta['retriever'] = {} + if 'label' not in retriever_meta['retriever']: + retriever_meta['retriever']['label'] = name + if config_schema: + retriever_meta['retriever']['customOptions'] = to_json_schema(config_schema) + + return Action( + name=name, + kind=ActionKind.RETRIEVER, + fn=fn, + metadata=retriever_meta, + description=get_func_description(fn, description), + ) + + class Retriever(Generic[T]): def __init__( self, diff --git a/py/packages/genkit/src/genkit/core/registry.py b/py/packages/genkit/src/genkit/core/registry.py index 316c8c0ba2..a690f11214 100644 --- a/py/packages/genkit/src/genkit/core/registry.py +++ b/py/packages/genkit/src/genkit/core/registry.py @@ -162,6 +162,26 @@ def register_action( self._entries[kind][name] = action return action + def register_action_instance(self, action: Action, *, namespace: str | None = None) -> None: + """Registers a pre-constructed Action instance. + + Note: If a namespace is provided and the action name is not already + prefixed, this method updates the action's name in-place. + + Args: + action: The Action instance to register. + namespace: Optional namespace prefix (e.g. plugin name). + """ + name = action.name + if namespace and not name.startswith(f'{namespace}/'): + name = f'{namespace}/{name}' + action._name = name + + with self._lock: + if action.kind not in self._entries: + self._entries[action.kind] = {} + self._entries[action.kind][name] = action + def lookup_action(self, kind: ActionKind, name: str) -> Action | None: """Look up an action by its kind and name. @@ -179,7 +199,9 @@ def lookup_action(self, kind: ActionKind, name: str) -> Action | None: if kind not in self._entries or name not in self._entries[kind]: plugin_name = parse_plugin_name_from_action_name(name) if plugin_name and plugin_name in self._action_resolvers: - self._action_resolvers[plugin_name](kind, name) + # Strip plugin prefix before calling resolver + action_name = name.removeprefix(f"{plugin_name}/") + self._action_resolvers[plugin_name](kind, action_name) if kind in self._entries and name in self._entries[kind]: return self._entries[kind][name] diff --git a/py/packages/genkit/src/genkit/core/schema.py b/py/packages/genkit/src/genkit/core/schema.py index 3f5946a98f..1d201d3ddc 100644 --- a/py/packages/genkit/src/genkit/core/schema.py +++ b/py/packages/genkit/src/genkit/core/schema.py @@ -16,11 +16,29 @@ """Functions for working with schema.""" -from typing import Any +from typing import Any, Callable from pydantic import TypeAdapter +def get_func_description(func: Callable, description: str | None = None) -> str: + """Get the description of a function. + + Args: + func: The function to get the description of. + description: The description to use if the function docstring is + empty. + + Returns: + The description of the function. + """ + if description is not None: + return description + if func.__doc__ is not None: + return func.__doc__ + return '' + + def to_json_schema(schema: type | dict[str, Any]) -> dict[str, Any]: """Converts a Python type to a JSON schema. diff --git a/py/packages/genkit/tests/genkit/ai/plugin_v2_test.py b/py/packages/genkit/tests/genkit/ai/plugin_v2_test.py new file mode 100644 index 0000000000..5c7f02f87b --- /dev/null +++ b/py/packages/genkit/tests/genkit/ai/plugin_v2_test.py @@ -0,0 +1,252 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for v2 plugin support. + +Focused on key product requirements, not code coverage. +Tests verify that v2 plugins work standalone and with framework, +and that v1 plugins continue working (backward compatibility). +""" + +import pytest + +from genkit.ai import Genkit +from genkit.ai._plugin import PluginV2, is_plugin_v2 +from genkit.core.action import Action, ActionMetadata +from genkit.core.registry import ActionKind +from genkit.types import ( + Candidate, + GenerateRequest, + GenerateResponse, + Message, + Role, + TextPart, +) + + +# Helper: Simple v2 test plugin +class SimpleV2Plugin(PluginV2): + """Minimal v2 plugin for testing.""" + + name = "test-v2" + + def __init__(self, models: list[str] | None = None): + self._models = models or ["model-1"] + + def init(self): + from genkit.blocks.model import model + + return [ + model( + name=m, + fn=self._generate, + ) + for m in self._models + ] + + def resolve(self, action_type, name): + from genkit.blocks.model import model + + # Framework passes unprefixed name + if action_type == ActionKind.MODEL and name in ["model-1", "model-2", "lazy-model"]: + return model(name=name, fn=self._generate) + return None + + def list_actions(self): + return [ + ActionMetadata(name=m, kind=ActionKind.MODEL, info={}) + for m in ["model-1", "model-2"] + ] + + # model() method inherited from PluginV2 base class + + def _generate(self, request: GenerateRequest, ctx): + """Simple test model that echoes input.""" + input_text = request.messages[0].content[0].text if request.messages else "empty" + return GenerateResponse( + candidates=[ + Candidate( + message=Message( + role=Role.MODEL, content=[TextPart(text=f"TEST: {input_text}")] + ) + ) + ] + ) + + +# Test 1: V2 plugins return actions +def test_v2_plugin_init_returns_actions(): + """V2 plugin init() should return list of Action objects.""" + plugin = SimpleV2Plugin(models=["model-1", "model-2"]) + + actions = plugin.init() + + assert isinstance(actions, list) + assert len(actions) == 2 + assert all(isinstance(a, Action) for a in actions) + assert actions[0].name == "model-1" + assert actions[0].kind == ActionKind.MODEL + + +# Test 2: V2 plugins work standalone +@pytest.mark.asyncio +async def test_v2_plugin_works_standalone(): + """V2 plugin should work WITHOUT Genkit framework.""" + # Create plugin - NO Genkit instance + plugin = SimpleV2Plugin() + + # Get an action + action = plugin.resolve(ActionKind.MODEL, "model-1") + + # Call it directly + response = await action.arun({"messages": [{"role": "user", "content": [{"text": "hello"}]}]}) + + assert response is not None + assert response.response.candidates[0].message.content[0].text == "TEST: hello" + + +# Test 3: V2 plugins work with framework +@pytest.mark.asyncio +async def test_v2_plugin_works_with_framework(): + """V2 plugin should work WITH Genkit framework.""" + plugin = SimpleV2Plugin() + + ai = Genkit(plugins=[plugin]) + + response = await ai.generate("test-v2/model-1", prompt="framework test") + + assert response.text is not None + assert "TEST:" in response.text + + +# Test 4: Framework supports both v1 and v2 +@pytest.mark.asyncio +async def test_framework_accepts_v2_plugin(): + """Framework should accept v2 plugins.""" + plugin = SimpleV2Plugin() + + ai = Genkit(plugins=[plugin]) + + response = await ai.generate("test-v2/model-1", prompt="test") + + assert response.text is not None + + +# Test 5: Lazy loading +@pytest.mark.asyncio +async def test_v2_lazy_loading(): + """V2 plugin should support lazy loading via resolve().""" + # Plugin with NO eager models + plugin = SimpleV2Plugin(models=[]) + + ai = Genkit(plugins=[plugin]) + + # init() returned empty, but resolve() should work + response = await ai.generate("test-v2/lazy-model", prompt="test") + + assert response.text is not None + assert "TEST:" in response.text + + +# Test 6: Automatic namespacing +@pytest.mark.asyncio +async def test_v2_automatic_namespacing(): + """Framework should add namespace automatically.""" + plugin = SimpleV2Plugin() + + # Plugin returns action WITHOUT namespace + actions = plugin.init() + assert actions[0].name == "model-1" # No prefix + + # Framework adds namespace + ai = Genkit(plugins=[plugin]) + + # Must use namespaced name + response = await ai.generate("test-v2/model-1", prompt="test") + assert response.text is not None + + +# Test 7: List actions +def test_v2_list_actions(): + """V2 plugin list_actions() should return metadata.""" + plugin = SimpleV2Plugin() + + metadata = plugin.list_actions() + + assert isinstance(metadata, list) + assert len(metadata) == 2 + assert all(isinstance(m, ActionMetadata) for m in metadata) + + +# Test 8: Detection function +def test_is_plugin_v2_detection(): + """is_plugin_v2() should correctly detect v2 plugins.""" + from genkit.ai._plugin import Plugin + + v2_plugin = SimpleV2Plugin() + + # Create a simple v1 plugin for testing + class SimpleV1Plugin(Plugin): + name = "test-v1" + + def initialize(self, ai): + pass + + v1_plugin = SimpleV1Plugin() + + assert is_plugin_v2(v2_plugin) is True + assert is_plugin_v2(v1_plugin) is False + assert is_plugin_v2("not a plugin") is False + + +# Test 9: model() factory +def test_model_factory_creates_action(): + """model() factory should create Action without registry.""" + from genkit.blocks.model import model + + def dummy_fn(request, ctx): + return GenerateResponse( + candidates=[ + Candidate( + message=Message(role=Role.MODEL, content=[TextPart(text="test")]) + ) + ] + ) + + action = model(name="test-model", fn=dummy_fn) + + assert isinstance(action, Action) + assert action.name == "test-model" + assert action.kind == ActionKind.MODEL + + +# Test 10: Convenience method +@pytest.mark.asyncio +async def test_v2_plugin_model_convenience_method(): + """V2 plugin.model() should provide convenient access.""" + plugin = SimpleV2Plugin() + + # Get model via convenience method + action = await plugin.model("model-1") + + assert isinstance(action, Action) + assert action.name == "model-1" + + # Should raise for non-existent model + with pytest.raises(ValueError, match="not found"): + await plugin.model("nonexistent-model") + + diff --git a/py/plugins/anthropic/src/genkit/plugins/anthropic/plugin.py b/py/plugins/anthropic/src/genkit/plugins/anthropic/plugin.py index 9a71c6dd69..7461c74790 100644 --- a/py/plugins/anthropic/src/genkit/plugins/anthropic/plugin.py +++ b/py/plugins/anthropic/src/genkit/plugins/anthropic/plugin.py @@ -17,7 +17,9 @@ """Anthropic plugin for Genkit.""" from anthropic import AsyncAnthropic -from genkit.ai import GenkitRegistry, Plugin +from genkit.ai import PluginV2 +from genkit.blocks.model import model +from genkit.core.action import Action, ActionMetadata from genkit.core.registry import ActionKind from genkit.plugins.anthropic.model_info import SUPPORTED_ANTHROPIC_MODELS, get_model_info from genkit.plugins.anthropic.models import AnthropicModel @@ -38,85 +40,107 @@ def anthropic_name(name: str) -> str: return f'{ANTHROPIC_PLUGIN_NAME}/{name}' -class Anthropic(Plugin): - """Anthropic plugin for Genkit. +class Anthropic(PluginV2): + """Anthropic plugin for Genkit (v2). This plugin adds Anthropic models to Genkit for generative AI applications. + Can be used standalone (without framework) or with Genkit framework. + + Example (standalone): + >>> plugin = Anthropic(api_key="...") + >>> claude = await plugin.model("claude-3-5-sonnet") + >>> response = await claude.arun({"messages": [...]}) + + Example (with framework): + >>> ai = Genkit(plugins=[Anthropic(api_key="...")]) + >>> response = await ai.generate("anthropic/claude-3-5-sonnet", prompt="Hi") """ name = ANTHROPIC_PLUGIN_NAME def __init__( self, - models: list[str] | None = None, **anthropic_params: str, ) -> None: """Initializes Anthropic plugin with given configuration. Args: - models: List of model names to register. Defaults to all supported models. **anthropic_params: Additional parameters passed to the AsyncAnthropic client. This may include api_key, base_url, timeout, and other configuration settings required by Anthropic's API. """ - self.models = models or list(SUPPORTED_ANTHROPIC_MODELS.keys()) self._anthropic_params = anthropic_params self._anthropic_client = AsyncAnthropic(**anthropic_params) - def initialize(self, ai: GenkitRegistry) -> None: - """Initialize plugin by registering models. + def init(self) -> list[Action]: + """Return eagerly-initialized model actions. - Args: - ai: The AI registry to initialize the plugin with. + Called once during Genkit initialization. Loads ALL supported + Anthropic models (same behavior as JavaScript). + + Returns: + List of Action objects for all supported models. """ - for model_name in self.models: - self._define_model(ai, model_name) + return [ + self._create_model_action(model_name) + for model_name in SUPPORTED_ANTHROPIC_MODELS.keys() + ] - def resolve_action( - self, - ai: GenkitRegistry, - kind: ActionKind, - name: str, - ) -> None: - """Resolve an action. + def resolve(self, action_type: ActionKind, name: str) -> Action | None: + """Resolve a specific model action on-demand. + + Called when framework needs an action not from init(). + Enables lazy loading of Anthropic models. Args: - ai: Genkit registry. - kind: Action kind. - name: Action name. + action_type: Type of action requested. + name: Name of action (unprefixed - framework strips plugin prefix). + + Returns: + Action if this plugin can provide it, None otherwise. """ - if kind == ActionKind.MODEL: - self._resolve_model(ai=ai, name=name) + if action_type == ActionKind.MODEL: + # Check if we support this model + if name in SUPPORTED_ANTHROPIC_MODELS: + return self._create_model_action(name) - def _resolve_model(self, ai: GenkitRegistry, name: str) -> None: - """Resolve and define an Anthropic model. + return None - Args: - ai: Genkit registry. - name: Model name (may include plugin prefix). + def list(self) -> list[ActionMetadata]: + """Return metadata for all supported Anthropic models. + + Used for discovery and developer tools. + + Returns: + List of ActionMetadata for all supported models. """ - clean_name = name.replace(f'{ANTHROPIC_PLUGIN_NAME}/', '') if name.startswith(ANTHROPIC_PLUGIN_NAME) else name - self._define_model(ai, clean_name) + return [ + ActionMetadata( + name=model_name, + kind=ActionKind.MODEL, + info=get_model_info(model_name).model_dump(), + ) + for model_name in SUPPORTED_ANTHROPIC_MODELS.keys() + ] + - def _define_model(self, ai: GenkitRegistry, model_name: str) -> None: - """Define and register a model. + def _create_model_action(self, model_name: str) -> Action: + """Create an Action for an Anthropic model (doesn't register). Args: - ai: Genkit registry. - model_name: Model name. + model_name: Name of the Anthropic model (without plugin prefix). + + Returns: + Action instance. """ - model = AnthropicModel(model_name=model_name, client=self._anthropic_client) model_info = get_model_info(model_name) + anthropic_model = AnthropicModel(model_name=model_name, client=self._anthropic_client) - metadata = { - 'model': { - 'supports': model_info.supports.model_dump(), - } - } + metadata = {'model': {'supports': model_info.supports.model_dump()}} - ai.define_model( - name=anthropic_name(model_name), - fn=model.generate, + return model( + name=model_name, + fn=anthropic_model.generate, config_schema=GenerationCommonConfig, metadata=metadata, )