diff --git a/py/plugins/compat-oai/src/genkit/plugins/compat_oai/models/model.py b/py/plugins/compat-oai/src/genkit/plugins/compat_oai/models/model.py index bf1f7315aa..dedbc1b6be 100644 --- a/py/plugins/compat-oai/src/genkit/plugins/compat_oai/models/model.py +++ b/py/plugins/compat-oai/src/genkit/plugins/compat_oai/models/model.py @@ -140,6 +140,11 @@ def _get_openai_request_config(self, request: GenerateRequest) -> dict: } if request.tools: openai_config['tools'] = self._get_tools_definition(request.tools) + if any(msg.role == Role.TOOL for msg in request.messages): + # After a tool response, stop forcing additional tool calls. + openai_config['tool_choice'] = 'none' + elif request.tool_choice: + openai_config['tool_choice'] = request.tool_choice if request.output: openai_config['response_format'] = self._get_response_format(request.output) if request.config: diff --git a/py/plugins/compat-oai/src/genkit/plugins/compat_oai/typing.py b/py/plugins/compat-oai/src/genkit/plugins/compat_oai/typing.py index d8ac810dea..64be50c825 100644 --- a/py/plugins/compat-oai/src/genkit/plugins/compat_oai/typing.py +++ b/py/plugins/compat-oai/src/genkit/plugins/compat_oai/typing.py @@ -24,7 +24,7 @@ else: # noqa from enum import StrEnum # noqa -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field class OpenAIConfig(BaseModel): @@ -38,6 +38,10 @@ class OpenAIConfig(BaseModel): stop: str | list[str] | None = None max_tokens: int | None = None stream: bool | None = None + frequency_penalty: float | None = Field(default=None, ge=-2, le=2) + presence_penalty: float | None = Field(default=None, ge=-2, le=2) + logprobs: bool | None = None + top_logprobs: int | None = Field(default=None, ge=0, le=20) class SupportedOutputFormat(StrEnum): diff --git a/py/plugins/deepseek/pyproject.toml b/py/plugins/deepseek/pyproject.toml new file mode 100644 index 0000000000..e81cf5ca46 --- /dev/null +++ b/py/plugins/deepseek/pyproject.toml @@ -0,0 +1,52 @@ +# Copyright 2026 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 + +[project] +name = "genkit-plugin-deepseek" +version = "0.1.0" +description = "Genkit DeepSeek Plugin" +authors = [{ name = "Google" }] +license = { text = "Apache-2.0" } +requires-python = ">=3.10" +dependencies = [ + "genkit", + "genkit-plugin-compat-oai", + "openai>=1.0.0", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Environment :: Web Environment", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Software Development :: Libraries", +] + +[build-system] +build-backend = "hatchling.build" +requires = ["hatchling"] + +[tool.hatch.build.targets.wheel] +packages = ["src/genkit", "src/genkit/plugins"] diff --git a/py/plugins/deepseek/src/genkit/plugins/deepseek/__init__.py b/py/plugins/deepseek/src/genkit/plugins/deepseek/__init__.py new file mode 100644 index 0000000000..24021a619e --- /dev/null +++ b/py/plugins/deepseek/src/genkit/plugins/deepseek/__init__.py @@ -0,0 +1,22 @@ +# Copyright 2026 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 + +"""DeepSeek plugin for Genkit.""" + +from .models import deepseek_name +from .plugin import DeepSeek + +__all__ = ['DeepSeek', 'deepseek_name'] diff --git a/py/plugins/deepseek/src/genkit/plugins/deepseek/client.py b/py/plugins/deepseek/src/genkit/plugins/deepseek/client.py new file mode 100644 index 0000000000..56ed84f247 --- /dev/null +++ b/py/plugins/deepseek/src/genkit/plugins/deepseek/client.py @@ -0,0 +1,40 @@ +# Copyright 2026 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 + +"""DeepSeek API client.""" + +from openai import OpenAI as _OpenAI + + +class DeepSeekClient: + """DeepSeek API client initialization.""" + + def __new__(cls, **deepseek_params) -> _OpenAI: + """Initialize the DeepSeek client. + + Args: + **deepseek_params: Client configuration parameters including: + - api_key: DeepSeek API key. + - base_url: API base URL (defaults to https://api.deepseek.com). + - Additional OpenAI client parameters. + + Returns: + Configured OpenAI client instance. + """ + api_key = deepseek_params.pop('api_key') + base_url = deepseek_params.pop('base_url', 'https://api.deepseek.com') + + return _OpenAI(api_key=api_key, base_url=base_url, **deepseek_params) diff --git a/py/plugins/deepseek/src/genkit/plugins/deepseek/model_info.py b/py/plugins/deepseek/src/genkit/plugins/deepseek/model_info.py new file mode 100644 index 0000000000..9601f58c61 --- /dev/null +++ b/py/plugins/deepseek/src/genkit/plugins/deepseek/model_info.py @@ -0,0 +1,58 @@ +# Copyright 2026 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 + +"""DeepSeek model information and metadata.""" + +from genkit.types import ModelInfo, Supports + +__all__ = ['SUPPORTED_DEEPSEEK_MODELS', 'get_default_model_info'] + +# Model capabilities matching JS implementation +_DEEPSEEK_SUPPORTS = Supports( + multiturn=True, + tools=True, + media=False, + system_role=True, + output=['text', 'json'], +) + +SUPPORTED_DEEPSEEK_MODELS: dict[str, ModelInfo] = { + 'deepseek-reasoner': ModelInfo( + label='DeepSeek - Reasoner', + versions=['deepseek-reasoner'], + supports=_DEEPSEEK_SUPPORTS, + ), + 'deepseek-chat': ModelInfo( + label='DeepSeek - Chat', + versions=['deepseek-chat'], + supports=_DEEPSEEK_SUPPORTS, + ), +} + + +def get_default_model_info(name: str) -> ModelInfo: + """Get default model information for unknown DeepSeek models. + + Args: + name: Model name. + + Returns: + Default ModelInfo with standard DeepSeek capabilities. + """ + return ModelInfo( + label=f'DeepSeek - {name}', + supports=_DEEPSEEK_SUPPORTS, + ) diff --git a/py/plugins/deepseek/src/genkit/plugins/deepseek/models.py b/py/plugins/deepseek/src/genkit/plugins/deepseek/models.py new file mode 100644 index 0000000000..65a5b76cb0 --- /dev/null +++ b/py/plugins/deepseek/src/genkit/plugins/deepseek/models.py @@ -0,0 +1,124 @@ +# Copyright 2026 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 + +"""DeepSeek model integration for Genkit.""" + +from collections.abc import Callable +from typing import Any + +from genkit.ai import GenkitRegistry +from genkit.plugins.compat_oai.models.model import OpenAIModel +from genkit.plugins.compat_oai.typing import OpenAIConfig +from genkit.plugins.deepseek.client import DeepSeekClient +from genkit.plugins.deepseek.model_info import ( + SUPPORTED_DEEPSEEK_MODELS, + get_default_model_info, +) + +DEEPSEEK_PLUGIN_NAME = 'deepseek' + + +def deepseek_name(name: str) -> str: + """Create a DeepSeek action name. + + Args: + name: Base name for the action. + + Returns: + The fully qualified DeepSeek action name. + """ + return f'{DEEPSEEK_PLUGIN_NAME}/{name}' + + +class DeepSeekModel: + """Manages DeepSeek model integration for Genkit. + + This class provides integration with DeepSeek's OpenAI-compatible API, + allowing DeepSeek models to be exposed as Genkit models. It handles + client initialization, model information retrieval, and dynamic model + definition within the Genkit registry. + + Follows the Model Garden pattern for implementation consistency. + """ + + def __init__( + self, + model: str, + api_key: str, + registry: GenkitRegistry, + **deepseek_params, + ) -> None: + """Initialize the DeepSeek instance. + + Args: + model: The name of the specific DeepSeek model (e.g., 'deepseek-chat'). + api_key: DeepSeek API key for authentication. + registry: An instance of GenkitRegistry to register the model. + **deepseek_params: Additional parameters for the DeepSeek client. + """ + self.name = model + self.ai = registry + client_params = {'api_key': api_key, **deepseek_params} + self.client = DeepSeekClient(**client_params) + + def get_model_info(self) -> dict[str, Any] | None: + """Retrieve metadata and supported features for the specified model. + + This method looks up the model's information from a predefined list + of supported DeepSeek models or provides default information. + + Returns: + A dictionary containing the model's 'name' and 'supports' features. + The 'supports' key contains a dictionary representing the model's + capabilities (e.g., tools, streaming). + """ + model_info = SUPPORTED_DEEPSEEK_MODELS.get(self.name, get_default_model_info(self.name)) + return { + 'name': model_info.label, + 'supports': model_info.supports.model_dump(), + } + + def to_deepseek_model(self) -> Callable: + """Convert the DeepSeek model into a Genkit-compatible model function. + + This method wraps the underlying DeepSeek client and its generation + logic into a callable that adheres to the OpenAI model interface + expected by Genkit. + + Returns: + A callable function (the generate method of an OpenAIModel instance) + that can be used by Genkit. + """ + deepseek_model = OpenAIModel(self.name, self.client, self.ai) + return deepseek_model.generate + + def define_model(self) -> None: + """Define and register the DeepSeek model with the Genkit registry. + + This method orchestrates the retrieval of model metadata and the + creation of the generation function, then registers this model + within the Genkit framework using self.ai.define_model. + """ + model_info = self.get_model_info() + generate_fn = self.to_deepseek_model() + self.ai.define_model( + name=deepseek_name(self.name), + fn=generate_fn, + config_schema=OpenAIConfig, + metadata={ + 'model': model_info, + }, + ) diff --git a/py/plugins/deepseek/src/genkit/plugins/deepseek/plugin.py b/py/plugins/deepseek/src/genkit/plugins/deepseek/plugin.py new file mode 100644 index 0000000000..2943838c87 --- /dev/null +++ b/py/plugins/deepseek/src/genkit/plugins/deepseek/plugin.py @@ -0,0 +1,140 @@ +# Copyright 2026 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 + +"""DeepSeek Plugin for Genkit.""" + +import os +from functools import cached_property + +from genkit.ai import GenkitRegistry, Plugin +from genkit.blocks.model import model_action_metadata +from genkit.core.action import ActionMetadata +from genkit.core.action.types import ActionKind +from genkit.core.error import GenkitError +from genkit.plugins.compat_oai.typing import OpenAIConfig +from genkit.plugins.deepseek.model_info import SUPPORTED_DEEPSEEK_MODELS +from genkit.plugins.deepseek.models import DEEPSEEK_PLUGIN_NAME, DeepSeekModel, deepseek_name + + +class DeepSeek(Plugin): + """DeepSeek plugin for Genkit. + + This plugin provides integration with DeepSeek's OpenAI-compatible API, + enabling the use of DeepSeek models within the Genkit framework. + """ + + name = DEEPSEEK_PLUGIN_NAME + + def __init__( + self, + api_key: str | None = None, + models: list[str] | None = None, + **deepseek_params, + ) -> None: + """Initialize the plugin and set up its configuration. + + Args: + api_key: The DeepSeek API key. If not provided, it attempts to load + from the DEEPSEEK_API_KEY environment variable. + models: An optional list of model names to register with the plugin. + If None, all supported models will be registered. + **deepseek_params: Additional parameters for the DeepSeek client. + + Raises: + GenkitError: If no API key is provided via parameter or environment. + """ + self.api_key = api_key if api_key is not None else os.getenv('DEEPSEEK_API_KEY') + + if not self.api_key: + raise GenkitError(message='Please provide api_key or set DEEPSEEK_API_KEY environment variable.') + + self.models = models + self.deepseek_params = deepseek_params + + def initialize(self, ai: GenkitRegistry) -> None: + """Initialize the plugin by registering specified models. + + Args: + ai: The Genkit registry where models will be registered. + """ + models = self.models + if models is None: + models = list(SUPPORTED_DEEPSEEK_MODELS.keys()) + + for model in models: + deepseek_model = DeepSeekModel( + model=model, + api_key=self.api_key, + registry=ai, + **self.deepseek_params, + ) + deepseek_model.define_model() + + def resolve_action( + self, + ai: GenkitRegistry, + kind: ActionKind, + name: str, + ) -> None: + """Resolve and register an action dynamically. + + Args: + ai: The Genkit registry. + kind: The kind of action to resolve. + name: The name of the action to resolve. + """ + if kind == ActionKind.MODEL: + self._resolve_model(ai=ai, name=name) + + def _resolve_model(self, ai: GenkitRegistry, name: str) -> None: + """Resolve and define a DeepSeek model within the Genkit registry. + + This internal method handles the logic for registering DeepSeek models + dynamically based on the provided name. It extracts a clean name, + instantiates the DeepSeek class, and registers it with the registry. + + Args: + ai: The Genkit AI registry instance to define the model in. + name: The name of the model to resolve. This name might include a + prefix indicating it's from the DeepSeek plugin. + """ + clean_name = name.replace(DEEPSEEK_PLUGIN_NAME + '/', '') if name.startswith(DEEPSEEK_PLUGIN_NAME) else name + + deepseek_model = DeepSeekModel( + model=clean_name, + api_key=self.api_key, + registry=ai, + **self.deepseek_params, + ) + deepseek_model.define_model() + + @cached_property + def list_actions(self) -> list[ActionMetadata]: + """Generate a list of available DeepSeek models. + + Returns: + list[ActionMetadata]: A list of ActionMetadata objects for each + supported DeepSeek model, including name, metadata, and config schema. + """ + actions_list = [] + for model, model_info in SUPPORTED_DEEPSEEK_MODELS.items(): + actions_list.append( + model_action_metadata( + name=deepseek_name(model), info=model_info.model_dump(), config_schema=OpenAIConfig + ) + ) + + return actions_list diff --git a/py/plugins/deepseek/tests/test_deepseek_plugin.py b/py/plugins/deepseek/tests/test_deepseek_plugin.py new file mode 100644 index 0000000000..150d1d23e6 --- /dev/null +++ b/py/plugins/deepseek/tests/test_deepseek_plugin.py @@ -0,0 +1,185 @@ +# Copyright 2026 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 DeepSeek plugin.""" + +import os +from unittest.mock import MagicMock, patch + +import pytest + +from genkit.core.error import GenkitError +from genkit.core.registry import ActionKind +from genkit.plugins.deepseek import DeepSeek, deepseek_name + + +def test_deepseek_name(): + """Test name helper function.""" + assert deepseek_name('deepseek-chat') == 'deepseek/deepseek-chat' + assert deepseek_name('deepseek-reasoner') == 'deepseek/deepseek-reasoner' + + +def test_plugin_initialization_with_api_key(): + """Test plugin initializes with API key.""" + plugin = DeepSeek(api_key='test-key') + assert plugin.name == 'deepseek' + assert plugin.api_key == 'test-key' + + +def test_plugin_initialization_from_env(): + """Test plugin reads API key from environment.""" + with patch.dict(os.environ, {'DEEPSEEK_API_KEY': 'env-key'}): + plugin = DeepSeek() + assert plugin.api_key == 'env-key' + + +def test_plugin_initialization_without_api_key(): + """Test plugin raises error without API key.""" + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(GenkitError) as exc_info: + DeepSeek() + assert 'DEEPSEEK_API_KEY' in str(exc_info.value) + + +@patch('genkit.plugins.deepseek.models.DeepSeekClient') +def test_plugin_initialize(mock_client): + """Test plugin registers models during initialization.""" + plugin = DeepSeek(api_key='test-key', models=['deepseek-chat']) + mock_registry = MagicMock() + + plugin.initialize(mock_registry) + + # Should call define_model for the specified model + mock_registry.define_model.assert_called_once() + + +@patch('genkit.plugins.deepseek.models.DeepSeekClient') +def test_plugin_resolve_action(mock_client): + """Test plugin resolves models dynamically.""" + plugin = DeepSeek(api_key='test-key', models=[]) + mock_registry = MagicMock() + + plugin.resolve_action(mock_registry, ActionKind.MODEL, 'deepseek/deepseek-chat') + + # Should register the requested model + mock_registry.define_model.assert_called_once() + + +def test_plugin_list_actions(): + """Test plugin lists available models.""" + plugin = DeepSeek(api_key='test-key') + actions = plugin.list_actions + + assert len(actions) == 2 + action_names = [action.name for action in actions] + assert 'deepseek/deepseek-reasoner' in action_names + assert 'deepseek/deepseek-chat' in action_names + + +@patch('genkit.plugins.deepseek.models.DeepSeekClient') +def test_plugin_with_custom_params(mock_client): + """Test plugin accepts custom parameters.""" + plugin = DeepSeek( + api_key='test-key', + models=['deepseek-chat'], + timeout=60, + max_retries=3, + ) + + assert plugin.deepseek_params['timeout'] == 60 + assert plugin.deepseek_params['max_retries'] == 3 + + +@patch('genkit.plugins.deepseek.models.DeepSeekClient') +def test_plugin_initialize_no_models(mock_client): + """Test plugin registers all supported models when models is None.""" + from genkit.plugins.deepseek.model_info import SUPPORTED_DEEPSEEK_MODELS + + plugin = DeepSeek(api_key='test-key') + mock_registry = MagicMock() + + # When models is None, all supported models should be registered + plugin.initialize(mock_registry) + + assert mock_registry.define_model.call_count == len(SUPPORTED_DEEPSEEK_MODELS) + + +def test_plugin_resolve_action_non_model_kind(): + """Test resolve_action does nothing for non-MODEL kinds.""" + plugin = DeepSeek(api_key='test-key') + mock_registry = MagicMock() + + # Using PROMPT kind to test the case where kind != MODEL + plugin.resolve_action(mock_registry, ActionKind.PROMPT, 'some-prompt') + + # Should not attempt to register anything + mock_registry.define_model.assert_not_called() + + +@patch('genkit.plugins.deepseek.models.DeepSeekClient') +def test_plugin_resolve_action_without_prefix(mock_client): + """Test plugin resolves models without plugin prefix.""" + plugin = DeepSeek(api_key='test-key', models=[]) + mock_registry = MagicMock() + + # Pass name without 'deepseek/' prefix + plugin.resolve_action(mock_registry, ActionKind.MODEL, 'deepseek-chat') + + mock_registry.define_model.assert_called_once() + + +@patch('genkit.plugins.deepseek.client.DeepSeekClient.__new__') +def test_deepseek_client_initialization(mock_new): + """Test DeepSeekClient creates OpenAI client with correct params.""" + from genkit.plugins.deepseek.client import DeepSeekClient + + # Set up mock to return a fake client + mock_client_instance = MagicMock() + mock_new.return_value = mock_client_instance + + # Create a DeepSeekClient + result = DeepSeekClient(api_key='test-key', timeout=30) + + # Verify __new__ was called with correct parameters + mock_new.assert_called_once() + + +def test_deepseek_client_with_custom_base_url(): + """Test DeepSeekClient accepts custom base_url.""" + from openai import OpenAI + + from genkit.plugins.deepseek.client import DeepSeekClient + + with patch.object(OpenAI, '__init__', return_value=None) as mock_init: + DeepSeekClient(api_key='test-key', base_url='https://custom.api.deepseek.com') + mock_init.assert_called_once_with( + api_key='test-key', + base_url='https://custom.api.deepseek.com', + ) + + +def test_deepseek_client_default_base_url(): + """Test DeepSeekClient uses default base_url when not provided.""" + from openai import OpenAI + + from genkit.plugins.deepseek.client import DeepSeekClient + + with patch.object(OpenAI, '__init__', return_value=None) as mock_init: + DeepSeekClient(api_key='test-key') + mock_init.assert_called_once_with( + api_key='test-key', + base_url='https://api.deepseek.com', + ) diff --git a/py/plugins/deepseek/tests/test_model_info.py b/py/plugins/deepseek/tests/test_model_info.py new file mode 100644 index 0000000000..dd61b137ba --- /dev/null +++ b/py/plugins/deepseek/tests/test_model_info.py @@ -0,0 +1,55 @@ +# Copyright 2026 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 DeepSeek model information.""" + +import pytest + +from genkit.plugins.deepseek.model_info import SUPPORTED_DEEPSEEK_MODELS, get_default_model_info + + +def test_supported_models_exist(): + """Test that supported models are defined.""" + assert 'deepseek-reasoner' in SUPPORTED_DEEPSEEK_MODELS + assert 'deepseek-chat' in SUPPORTED_DEEPSEEK_MODELS + + +def test_model_order(): + """Test models are in correct order (matching JS).""" + keys = list(SUPPORTED_DEEPSEEK_MODELS.keys()) + assert keys[0] == 'deepseek-reasoner' + assert keys[1] == 'deepseek-chat' + + +def test_model_info_structure(): + """Test model info has required fields.""" + for model_name, model_info in SUPPORTED_DEEPSEEK_MODELS.items(): + assert model_info.label + assert model_info.supports + assert model_info.supports.multiturn is True + assert model_info.supports.tools is True + assert model_info.supports.media is False + assert model_info.supports.system_role is True + assert 'text' in model_info.supports.output + assert 'json' in model_info.supports.output + + +def test_get_default_model_info(): + """Test getting default info for unknown models.""" + info = get_default_model_info('deepseek-future-model') + assert 'deepseek-future-model' in info.label + assert info.supports.multiturn is True + assert info.supports.tools is True diff --git a/py/pyproject.toml b/py/pyproject.toml index 400fa73842..5e26d044fc 100644 --- a/py/pyproject.toml +++ b/py/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "genkit", "genkit-plugin-anthropic", "genkit-plugin-compat-oai", + "genkit-plugin-deepseek", "genkit-plugin-dev-local-vectorstore", "genkit-plugin-evaluators", "genkit-plugin-firebase", @@ -105,6 +106,7 @@ evaluator-demo = { workspace = true } genkit = { workspace = true } genkit-plugin-anthropic = { workspace = true } genkit-plugin-compat-oai = { workspace = true } +genkit-plugin-deepseek = { workspace = true } genkit-plugin-dev-local-vectorstore = { workspace = true } genkit-plugin-evaluators = { workspace = true } genkit-plugin-firebase = { workspace = true } diff --git a/py/samples/deepseek-hello/README.md b/py/samples/deepseek-hello/README.md new file mode 100644 index 0000000000..477f8ccc77 --- /dev/null +++ b/py/samples/deepseek-hello/README.md @@ -0,0 +1,19 @@ +## DeepSeek Sample + +1. Setup environment and install dependencies: +```bash +uv venv +source .venv/bin/activate + +uv sync +``` + +2. Set DeepSeek API key (get one from [DeepSeek Platform](https://platform.deepseek.com/)): +```bash +export DEEPSEEK_API_KEY=your-api-key +``` + +3. Run the sample: +```bash +genkit start -- uv run src/main.py +``` diff --git a/py/samples/deepseek-hello/pyproject.toml b/py/samples/deepseek-hello/pyproject.toml new file mode 100644 index 0000000000..fefafe71db --- /dev/null +++ b/py/samples/deepseek-hello/pyproject.toml @@ -0,0 +1,37 @@ +# Copyright 2026 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 + +[project] +dependencies = [ + "genkit", + "genkit-plugin-deepseek", + "pydantic>=2.0.0", + "structlog>=24.0.0", +] +description = "DeepSeek Hello Sample" +name = "deepseek-hello" +requires-python = ">=3.10" +version = "0.1.0" + +[tool.uv.sources] +genkit-plugin-deepseek = { workspace = true } + +[build-system] +build-backend = "hatchling.build" +requires = ["hatchling"] + +[tool.hatch.build.targets.wheel] +packages = ["src"] diff --git a/py/samples/deepseek-hello/run.sh b/py/samples/deepseek-hello/run.sh new file mode 100755 index 0000000000..02a864050f --- /dev/null +++ b/py/samples/deepseek-hello/run.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Copyright 2026 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 + +exec genkit start -- uv run src/main.py "$@" diff --git a/py/samples/deepseek-hello/src/main.py b/py/samples/deepseek-hello/src/main.py new file mode 100644 index 0000000000..bfc714d437 --- /dev/null +++ b/py/samples/deepseek-hello/src/main.py @@ -0,0 +1,279 @@ +# Copyright 2026 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 + +"""DeepSeek hello sample. + +Key features demonstrated in this sample: + +| Feature Description | Example Function / Code Snippet | +|-----------------------------------------|-----------------------------------------| +| Plugin Initialization | `ai = Genkit(plugins=[DeepSeek(...)])` | +| Default Model Configuration | `ai = Genkit(model=deepseek_name(...))` | +| Defining Flows | `@ai.flow()` decorator | +| Defining Tools | `@ai.tool()` decorator | +| Pydantic for Tool Input Schema | `WeatherInput` | +| Simple Generation (Prompt String) | `say_hi` | +| Streaming Response | `streaming_flow` | +| Generation with Tools | `weather_flow` | +| Reasoning Model (deepseek-reasoner) | `reasoning_flow` | +| Generation with Config | `custom_config_flow` | +| Multi-turn Chat | `chat_flow` | +""" + +import structlog +from pydantic import BaseModel, Field + +from genkit.ai import Genkit +from genkit.core.action import ActionRunContext +from genkit.plugins.deepseek import DeepSeek, deepseek_name +from genkit.types import Message, Part, Role, TextPart, ToolResponse + +logger = structlog.get_logger(__name__) + +ai = Genkit( + plugins=[DeepSeek()], + model=deepseek_name('deepseek-chat'), +) + + +class WeatherInput(BaseModel): + """Input schema for the weather tool.""" + + location: str = Field(description='The city and state, e.g. San Francisco, CA') + + +@ai.tool() +def get_weather(input: WeatherInput) -> str: + """Get weather of a location, the user should supply a location first. + + Args: + input: Weather input with location (city and state, e.g. San Francisco, CA). + + Returns: + Weather information with temperature in degrees Fahrenheit. + """ + # Mocked weather data + weather_data = { + 'San Francisco, CA': {'temp': 72, 'condition': 'sunny', 'humidity': 65}, + 'Seattle, WA': {'temp': 55, 'condition': 'rainy', 'humidity': 85}, + } + + location = input.location + data = weather_data.get(location, {'temp': 70, 'condition': 'partly cloudy', 'humidity': 55}) + + return f'The weather in {location} is {data["temp"]}°F and {data["condition"]}. Humidity is {data["humidity"]}%.' + + +@ai.flow() +async def say_hi(name: str) -> str: + """Generate a simple greeting. + + Args: + name: Name to greet. + + Returns: + Greeting message. + """ + response = await ai.generate(prompt=f'Say hello to {name}!') + return response.text + + +@ai.flow() +async def streaming_flow(topic: str, ctx: ActionRunContext) -> str: + """Generate with streaming response. + + Args: + topic: Topic to generate about. + ctx: Action run context for streaming chunks to client. + + Returns: + Generated text. + """ + response = await ai.generate( + prompt=f'Tell me a fun fact about {topic}', + on_chunk=ctx.send_chunk, + ) + return response.text + + +@ai.flow() +async def weather_flow(location: str) -> str: + """Get weather using compat-oai auto tool calling.""" + + response = await ai.generate( + model=deepseek_name('deepseek-chat'), + prompt=f'What is the weather in {location}?', + system=( + 'You have a tool called get_weather. ' + "It takes an object with a 'location' field. " + 'Always use this tool when asked about weather.' + ), + tools=['get_weather'], + tool_choice='required', + max_turns=2, + ) + + return response.text + + +@ai.flow() +async def reasoning_flow(prompt: str | None = None) -> str: + """Solve reasoning problems using deepseek-reasoner model. + + Args: + prompt: The reasoning question to solve. Defaults to a classic logic problem. + + Returns: + The reasoning and answer. + """ + if prompt is None: + prompt = 'What is heavier, one kilo of steel or one kilo of feathers?' + + response = await ai.generate( + model=deepseek_name('deepseek-reasoner'), + prompt=prompt, + ) + return response.text + + +@ai.flow() +async def custom_config_flow(task: str | None = None) -> str: + """Demonstrate custom model configurations for different tasks. + + Shows how different config parameters affect generation behavior: + - 'creative': High temperature for diverse, creative outputs + - 'precise': Low temperature with penalties for consistent, focused outputs + - 'detailed': Extended output with frequency penalty to avoid repetition + + Args: + task: Type of task - 'creative', 'precise', or 'detailed' + + Returns: + Generated response showing the effect of different configs. + """ + if task is None: + task = 'creative' + + prompts = { + 'creative': 'Write a creative story opener about a robot discovering art', + 'precise': 'List the exact steps to make a cup of tea', + 'detailed': 'Explain how photosynthesis works in detail', + } + + configs = { + 'creative': { + 'temperature': 1.5, # High temperature for creativity + 'max_tokens': 200, + 'top_p': 0.95, + }, + 'precise': { + 'temperature': 0.1, # Low temperature for consistency + 'max_tokens': 150, + 'presence_penalty': 0.5, # Encourage covering all steps + }, + 'detailed': { + 'temperature': 0.7, + 'max_tokens': 400, # More tokens for detailed explanation + 'frequency_penalty': 0.8, # Reduce repetitive phrasing + }, + } + + prompt = prompts.get(task, prompts['creative']) + config = configs.get(task, configs['creative']) + + response = await ai.generate( + prompt=prompt, + config=config, + ) + return response.text + + +@ai.flow() +async def chat_flow() -> str: + """Multi-turn chat example demonstrating context retention. + + Returns: + Final chat response. + """ + history = [] + + # First turn - User shares information + prompt1 = "Hi! I'm planning a trip to Tokyo next month. I'm really excited because I love Japanese cuisine, especially ramen and sushi." + response1 = await ai.generate( + prompt=prompt1, + system='You are a helpful travel assistant.', + ) + history.append(Message(role=Role.USER, content=[TextPart(text=prompt1)])) + history.append(response1.message) + await logger.ainfo('chat_flow turn 1', result=response1.text) + + # Second turn - Ask question requiring context from first turn + response2 = await ai.generate( + messages=history + [Message(role=Role.USER, content=[TextPart(text='What foods did I say I enjoy?')])], + system='You are a helpful travel assistant.', + ) + history.append(Message(role=Role.USER, content=[TextPart(text='What foods did I say I enjoy?')])) + history.append(response2.message) + await logger.ainfo('chat_flow turn 2', result=response2.text) + + # Third turn - Ask question requiring context from both previous turns + response3 = await ai.generate( + messages=history + + [ + Message( + role=Role.USER, + content=[TextPart(text='Based on our conversation, suggest one restaurant I should visit.')], + ) + ], + system='You are a helpful travel assistant.', + ) + return response3.text + + +async def main() -> None: + """Main entry point for the DeepSeek sample.""" + # Simple greeting + result = await say_hi('World') + await logger.ainfo('say_hi', result=result) + + # Streaming response + result = await streaming_flow('apple') + await logger.ainfo('streaming_flow', result=result) + + # Weather with tools + result = await weather_flow('Seattle, WA') + await logger.ainfo('weather_flow', result=result) + + # Reasoning model + result = await reasoning_flow() + await logger.ainfo('reasoning_flow', result=result) + + # Custom config - demonstrate different configurations + await logger.ainfo('Testing creative config...') + result = await custom_config_flow('creative') + await logger.ainfo('custom_config_flow (creative)', result=result) + + await logger.ainfo('Testing precise config...') + result = await custom_config_flow('precise') + await logger.ainfo('custom_config_flow (precise)', result=result) + + # Multi-turn chat + result = await chat_flow() + await logger.ainfo('chat_flow', result=result) + + +if __name__ == '__main__': + ai.run_main(main()) diff --git a/py/uv.lock b/py/uv.lock index 560e88909a..3372fc88d0 100644 --- a/py/uv.lock +++ b/py/uv.lock @@ -12,6 +12,7 @@ resolution-markers = [ members = [ "anthropic-hello", "compat-oai-hello", + "deepseek-hello", "dev-local-vectorstore-hello", "eval-demo", "firestore-retreiver", @@ -19,6 +20,7 @@ members = [ "genkit", "genkit-plugin-anthropic", "genkit-plugin-compat-oai", + "genkit-plugin-deepseek", "genkit-plugin-dev-local-vectorstore", "genkit-plugin-evaluators", "genkit-plugin-firebase", @@ -928,6 +930,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] +[[package]] +name = "deepseek-hello" +version = "0.1.0" +source = { editable = "samples/deepseek-hello" } +dependencies = [ + { name = "genkit" }, + { name = "genkit-plugin-deepseek" }, + { name = "pydantic" }, + { name = "structlog" }, +] + +[package.metadata] +requires-dist = [ + { name = "genkit", editable = "packages/genkit" }, + { name = "genkit-plugin-deepseek", editable = "plugins/deepseek" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "structlog", specifier = ">=24.0.0" }, +] + [[package]] name = "defusedxml" version = "0.7.1" @@ -1615,6 +1636,23 @@ requires-dist = [ { name = "strenum", marker = "python_full_version < '3.11'", specifier = ">=0.4.15" }, ] +[[package]] +name = "genkit-plugin-deepseek" +version = "0.1.0" +source = { editable = "plugins/deepseek" } +dependencies = [ + { name = "genkit" }, + { name = "genkit-plugin-compat-oai" }, + { name = "openai" }, +] + +[package.metadata] +requires-dist = [ + { name = "genkit", editable = "packages/genkit" }, + { name = "genkit-plugin-compat-oai", editable = "plugins/compat-oai" }, + { name = "openai", specifier = ">=1.0.0" }, +] + [[package]] name = "genkit-plugin-dev-local-vectorstore" version = "0.4.0" @@ -1796,6 +1834,7 @@ dependencies = [ { name = "genkit" }, { name = "genkit-plugin-anthropic" }, { name = "genkit-plugin-compat-oai" }, + { name = "genkit-plugin-deepseek" }, { name = "genkit-plugin-dev-local-vectorstore" }, { name = "genkit-plugin-evaluators" }, { name = "genkit-plugin-firebase" }, @@ -1840,6 +1879,7 @@ requires-dist = [ { name = "genkit", editable = "packages/genkit" }, { name = "genkit-plugin-anthropic", editable = "plugins/anthropic" }, { name = "genkit-plugin-compat-oai", editable = "plugins/compat-oai" }, + { name = "genkit-plugin-deepseek", editable = "plugins/deepseek" }, { name = "genkit-plugin-dev-local-vectorstore", editable = "plugins/dev-local-vectorstore" }, { name = "genkit-plugin-evaluators", editable = "plugins/evaluators" }, { name = "genkit-plugin-firebase", editable = "plugins/firebase" },