diff --git a/features/google_provider.feature b/features/google_provider.feature new file mode 100644 index 000000000..983b76605 --- /dev/null +++ b/features/google_provider.feature @@ -0,0 +1,38 @@ +Feature: Google provider adapter coverage + As a maintainer validating real provider adapters + I want Behave scenarios for the Google chat provider + So that constructor guards and LangChain handoffs stay regression tested + + @unit @providers @google + Scenario: Google provider instantiates ChatGoogleGenerativeAI with provided credentials + Given I have sample provider domain inputs + When I create a Google chat provider with API key "sk-google-unit" and model "gemini-2.0-flash" + And I request plan generation from the Google provider + Then the Google provider should construct ChatGoogleGenerativeAI with api key "sk-google-unit" and model "gemini-2.0-flash" + And the Google provider metadata should report name "google" and model "gemini-2.0-flash" + + @unit @providers @google + Scenario: Google provider forwards extra kwargs to ChatGoogleGenerativeAI + Given I have sample provider domain inputs + And I set the Google provider extra kwargs "temperature=0.25,max_output_tokens=2048" + When I create a Google chat provider with API key "sk-google-unit" and model "gemini-2.0-pro" + And I request plan generation from the Google provider + Then the Google provider should construct ChatGoogleGenerativeAI with api key "sk-google-unit" and model "gemini-2.0-pro" + And the Google provider should include kwargs "temperature=0.25,max_output_tokens=2048" in the ChatGoogleGenerativeAI call + And the Google provider metadata should report name "google" and model "gemini-2.0-pro" + + @unit @providers @google + Scenario: Google provider returns generated changes with token count + Given I have sample provider domain inputs + And the plan generation graph returns a generated change for "app/google_provider.py" + And the Google provider token estimator returns 256 tokens + When I create a Google chat provider with API key "sk-google-unit" and model "gemini-2.0-flash" + And I request plan generation from the Google provider + Then the Google provider response should include 1 generated change for "app/google_provider.py" + And the Google provider response token count should equal 256 + + @unit @providers @google + Scenario: Google provider rejects missing API key + Given I have sample provider domain inputs + When I attempt to create a Google chat provider without an API key + Then the Google provider creation should fail with error "Google API key is required" diff --git a/features/provider_registry_coverage.feature b/features/provider_registry_coverage.feature index 597de8252..a97e93052 100644 --- a/features/provider_registry_coverage.feature +++ b/features/provider_registry_coverage.feature @@ -359,6 +359,14 @@ Feature: Provider Registry Coverage And the stubbed OpenRouter client headers should include "HTTP-Referer" value "ExampleApp" And the stubbed OpenRouter client headers should include "X-Title" value "ExampleApp" + @unit @providers @registry + Scenario: _create_provider_llm coerces OpenRouter default header mappings to strings + Given I have a ProviderRegistry with "openrouter" API key set + And the "OpenRouter" LangChain client is stubbed + And the OpenRouter default headers include numeric entries + When I call _create_provider_llm for provider "openrouter" with model "router-coerce" and custom default headers + Then the stubbed OpenRouter client headers should include "123" value "456" + @unit @providers @registry Scenario: _create_provider_llm raises error for unsupported provider Given I have a ProviderRegistry instance @@ -379,6 +387,13 @@ Feature: Provider Registry Coverage Then a provider registry ValueError should be raised And the provider registry error should mention unknown provider type + @unit @providers @registry + Scenario: Create AI provider raises error when Google API key is missing + Given I have a ProviderRegistry with no API keys + When I try to create an AI provider for provider "google" + Then a provider registry ValueError should be raised + And the provider registry error should mention missing "GOOGLE_API_KEY" environment variable + @unit @providers @registry Scenario: Create AI provider succeeds for configured provider string Given I have a ProviderRegistry with OpenAI API key set @@ -388,6 +403,15 @@ Feature: Provider Registry Coverage And the provider registry AI provider name should be "openai" And the provider registry AI provider model_id should be "gpt-4o" + @unit @providers @registry + Scenario: Create AI provider returns Google adapter + Given I have a ProviderRegistry with "google" API key set + And CLEVERAGENTS_DEFAULT_MODEL is not set + When I create an AI provider for provider "google" + Then the provider registry AI provider should be a GoogleChatProvider + And the provider registry AI provider name should be "google" + And the provider registry AI provider model_id should be "gemini-2.0-flash" + @unit @providers @registry Scenario: Create AI provider without specifying provider uses fallback Given I have a ProviderRegistry with OpenAI API key set @@ -397,6 +421,7 @@ Feature: Provider Registry Coverage Then the provider registry AI provider should be a LangChainChatProvider And the provider registry AI provider name should be "openai" + @unit @providers @registry Scenario: Create AI provider honors explicit model overrides Given I have a ProviderRegistry with OpenAI API key set diff --git a/features/steps/google_provider_steps.py b/features/steps/google_provider_steps.py new file mode 100644 index 000000000..cad7974e3 --- /dev/null +++ b/features/steps/google_provider_steps.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +import ast +from typing import Any +from unittest.mock import MagicMock, patch + +from behave import given, then, when + +from cleveragents.providers.llm.google_provider import GoogleChatProvider + + +def _register_cleanup(context, cleanup): + if hasattr(context, "add_cleanup"): + context.add_cleanup(cleanup) + else: + cleanup_handlers = getattr(context, "_cleanup_handlers", []) + cleanup_handlers.append(cleanup) + context._cleanup_handlers = cleanup_handlers + + +def _parse_kwargs_string(kwargs_string: str) -> dict[str, Any]: + if not kwargs_string: + return {} + parsed: dict[str, Any] = {} + for entry in kwargs_string.split(","): + entry = entry.strip() + if not entry: + continue + if "=" not in entry: + parsed[entry] = True + continue + key, value = entry.split("=", 1) + key = key.strip() + value = value.strip() + try: + parsed_value = ast.literal_eval(value) + except Exception: + parsed_value = value + parsed[key] = parsed_value + return parsed + + +def _setup_plan_generation_graph(context) -> MagicMock: + patcher = patch( + "cleveragents.providers.llm.langchain_chat_provider.PlanGenerationGraph" + ) + context.plan_generation_patcher = patcher + mock_graph_class = patcher.start() + _register_cleanup(context, patcher.stop) + mock_graph_instance = MagicMock(name="PlanGenerationGraphInstance") + + default_state = { + "generated_changes": [], + "validation_result": {"status": "PASS"}, + "error": None, + } + state_override = getattr(context, "plan_generation_state_override", None) + mock_graph_instance.invoke.return_value = state_override or default_state + + invoke_side_effect = getattr(context, "plan_generation_invoke_side_effect", None) + if invoke_side_effect is not None: + mock_graph_instance.invoke.side_effect = invoke_side_effect + + stream_events = getattr(context, "plan_generation_stream_events", None) + if stream_events is None: + mock_graph_instance.stream.return_value = iter(()) + else: + events_copy = list(stream_events) + + def _stream(*_args, **_kwargs): + for event in events_copy: + yield event + + mock_graph_instance.stream.side_effect = _stream + + mock_graph_class.return_value = mock_graph_instance + context.plan_generation_graph = mock_graph_instance + context.plan_generation_graph_class = mock_graph_class + return mock_graph_instance + + +@given('I set the Google provider extra kwargs "{kwargs_string}"') +def step_set_google_provider_kwargs(context, kwargs_string): + context.google_provider_kwargs = _parse_kwargs_string(kwargs_string) + + +@given("the Google provider token estimator returns {token_count:d} tokens") +def step_set_google_token_estimator(context, token_count): + context.google_token_count = token_count + + +@when('I create a Google chat provider with API key "{api_key}" and model "{model_id}"') +def step_create_google_provider(context, api_key, model_id): + patcher = patch("cleveragents.providers.llm.google_provider.ChatGoogleGenerativeAI") + context.chat_google_patcher = patcher + mock_chat_class = patcher.start() + _register_cleanup(context, patcher.stop) + mock_chat_instance = MagicMock(name="ChatGoogleGenerativeAIInstance") + mock_chat_instance.get_num_tokens = MagicMock(return_value=0) + token_override = getattr(context, "google_token_count", None) + if token_override is not None: + mock_chat_instance.get_num_tokens.return_value = token_override + mock_chat_class.return_value = mock_chat_instance + + extra_kwargs = dict(getattr(context, "google_provider_kwargs", {})) + + context.provider = GoogleChatProvider( + api_key=api_key, + model=model_id, + **extra_kwargs, + ) + context.chat_google_class = mock_chat_class + context.chat_google_instance = mock_chat_instance + + +@when("I attempt to create a Google chat provider without an API key") +def step_google_provider_without_api_key(context): + context.google_provider_error = None + try: + GoogleChatProvider(api_key="", model="gemini-2.0-flash") + except Exception as exc: # pragma: no cover - defensive + context.google_provider_error = exc + + +@when("I request plan generation from the Google provider") +def step_request_google_plan_generation(context): + _setup_plan_generation_graph(context) + context.response = context.provider.generate_changes( + context.project, + context.plan, + context.contexts, + ) + context.chat_google_call = context.chat_google_class.call_args + context.plan_generation_graph_call = context.plan_generation_graph_class.call_args + + +@then( + 'the Google provider should construct ChatGoogleGenerativeAI with api key "{api_key}" and model "{model_id}"' +) +def step_assert_google_constructor(context, api_key, model_id): + call = getattr(context, "chat_google_call", None) + assert call is not None, "ChatGoogleGenerativeAI should have been called" + call_args, call_kwargs = call + assert call_args == (), "ChatGoogleGenerativeAI should use keyword arguments" + assert call_kwargs["api_key"] == api_key + assert call_kwargs["model"] == model_id + + graph_call = getattr(context, "plan_generation_graph_call", None) + assert graph_call is not None, "PlanGenerationGraph should receive the LLM" + _, graph_kwargs = graph_call + assert graph_kwargs["llm"] is context.chat_google_instance + + +@then( + 'the Google provider should include kwargs "{kwargs_string}" in the ChatGoogleGenerativeAI call' +) +def step_assert_google_kwargs(context, kwargs_string): + call = getattr(context, "chat_google_call", None) + assert call is not None, "ChatGoogleGenerativeAI should have been called" + _, call_kwargs = call + expected = _parse_kwargs_string(kwargs_string) + for key, value in expected.items(): + assert key in call_kwargs, f"Expected {key} in ChatGoogleGenerativeAI kwargs" + assert call_kwargs[key] == value, ( + f"Expected ChatGoogleGenerativeAI kwargs[{key!r}] to equal {value!r}" + ) + + +@then( + 'the Google provider metadata should report name "{expected_name}" and model "{expected_model}"' +) +def step_assert_google_metadata(context, expected_name, expected_model): + provider = getattr(context, "provider", None) + assert provider is not None, "Google provider should exist" + assert provider.name == expected_name + assert provider.model_id == expected_model + response = getattr(context, "response", None) + if response is not None: + assert response.model_used == expected_model + + +@then( + 'the Google provider response should include {count:d} generated change for "{file_path}"' +) +def step_assert_google_generated_change(context, count, file_path): + response = getattr(context, "response", None) + assert response is not None, "Provider response should exist" + assert len(response.changes) == count, ( + f"Expected {count} changes, got {len(response.changes)}" + ) + assert any( + getattr(change, "file_path", None) == file_path for change in response.changes + ), f"Expected change for {file_path}" + + +@then("the Google provider response token count should equal {expected:d}") +def step_assert_google_token_count(context, expected): + response = getattr(context, "response", None) + assert response is not None + assert response.token_count == expected + + +@then('the Google provider creation should fail with error "{message}"') +def step_assert_google_creation_error(context, message): + error = getattr(context, "google_provider_error", None) + assert error is not None, "Expected the provider to raise an error" + assert isinstance(error, ValueError) + assert str(error) == message diff --git a/features/steps/openai_provider_steps.py b/features/steps/openai_provider_steps.py index 30d836ec4..050a19d19 100644 --- a/features/steps/openai_provider_steps.py +++ b/features/steps/openai_provider_steps.py @@ -5,14 +5,13 @@ from typing import Any from unittest.mock import MagicMock, patch from behave import given, then, when - from tenacity import RetryError, Retrying, stop_after_attempt from cleveragents.domain.models.core import ( Context, + OperationType, Plan, Project, - OperationType, ) from cleveragents.providers.llm.openai_provider import OpenAIChatProvider diff --git a/features/steps/provider_registry_steps.py b/features/steps/provider_registry_steps.py index f2dbf7f62..d301ed2e6 100644 --- a/features/steps/provider_registry_steps.py +++ b/features/steps/provider_registry_steps.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock from behave import given, then, when # type: ignore[import-untyped] +from cleveragents.providers.llm.google_provider import GoogleChatProvider from cleveragents.providers.llm.langchain_chat_provider import LangChainChatProvider from cleveragents.providers.registry import ( ProviderCapabilities, @@ -262,6 +263,11 @@ def step_impl_set_openrouter_org(context: Any, value: str) -> None: context.registry._settings.openrouter_organization = value +@given("the OpenRouter default headers include numeric entries") +def step_impl_set_openrouter_default_headers(context: Any) -> None: + context.openrouter_default_headers = {123: 456, "X-Debug": 789} + + @given("the Azure endpoint setting is cleared") def step_impl_clear_azure_endpoint(context: Any) -> None: assert hasattr(context, "registry"), "Registry must exist before clearing endpoint" @@ -496,6 +502,21 @@ def step_impl_call_private_llm(context: Any, value: str, model: str) -> None: context.created_llm = context.registry._create_provider_llm(provider_type, model) +@when( + 'I call _create_provider_llm for provider "{value}" with model "{model}" and custom default headers' +) +def step_impl_call_private_llm_with_headers( + context: Any, value: str, model: str +) -> None: + provider_enum = _ensure_provider_type(context) + provider_type = getattr(provider_enum, value.upper()) + default_headers = getattr(context, "openrouter_default_headers", None) + assert default_headers is not None, "OpenRouter default headers were not configured" + context.created_llm = context.registry._create_provider_llm( + provider_type, model, default_headers=default_headers + ) + + @when('I try to call _create_provider_llm for provider "{value}" with model "{model}"') def step_impl_try_private_llm(context: Any, value: str, model: str) -> None: provider_enum = _ensure_provider_type(context) @@ -743,11 +764,27 @@ def step_impl_error_mentions_missing_endpoint(context: Any) -> None: assert "azure" in message and "endpoint" in message +@then( + 'the provider registry error should mention missing "{env_var}" environment variable' +) +def step_impl_error_mentions_missing_env(context: Any, env_var: str) -> None: + assert context.error is not None + message = str(context.error) + assert env_var in message, ( + f"Expected error message to mention {env_var!r}, got {message!r}" + ) + + @then("the provider registry AI provider should be a LangChainChatProvider") def step_impl_ai_provider_is_langchain(context: Any) -> None: assert isinstance(context.ai_provider, LangChainChatProvider) +@then("the provider registry AI provider should be a GoogleChatProvider") +def step_impl_ai_provider_is_google(context: Any) -> None: + assert isinstance(context.ai_provider, GoogleChatProvider) + + @then('the provider registry AI provider name should be "{value}"') def step_impl_ai_provider_name(context: Any, value: str) -> None: assert context.ai_provider.name == value diff --git a/implementation_plan.md b/implementation_plan.md index ba513df36..f151b04aa 100644 --- a/implementation_plan.md +++ b/implementation_plan.md @@ -1693,6 +1693,12 @@ Notes: Record provider-specific nuances, API limitations, and testing fixtures. - Authored `features/anthropic_provider.feature:1-19` with matching steps in `features/steps/anthropic_provider_steps.py:1-129` to cover missing-key errors, constructor overrides, and metadata reporting while asserting `PlanGenerationGraph` receives the mocked `ChatAnthropic` instance. - Executed `behave features/anthropic_provider.feature` locally to confirm both Anthropic scenarios pass alongside the refreshed OpenAI suite. +**2025-12-09: Google provider adapter + registry coverage** +- Added `src/cleveragents/providers/llm/google_provider.py:1-40` implementing `GoogleChatProvider`, including API key validation, overridable kwargs, and a LangChain `ChatGoogleGenerativeAI` factory wired for streaming. +- Updated `src/cleveragents/providers/registry.py:585-614` so `create_ai_provider` now instantiates `GoogleChatProvider` when Google credentials are configured, preserving capability metadata for other providers. +- Authored `features/google_provider.feature:1-39` with steps in `features/steps/google_provider_steps.py:1-209` to cover constructor guards, kwarg forwarding, token accounting, and plan-generation graph handoff; expanded `features/provider_registry_coverage.feature:390-422` plus `features/steps/provider_registry_steps.py:12-555` to assert the registry returns the Google adapter instance. +- Added Robot coverage via `robot/google_provider.robot:1-169` for generate/stream/error/retry paths, and verified everything with `nox -s unit_tests -- features/google_provider.feature features/provider_registry_coverage.feature` followed by `nox -s integration_tests -- robot/google_provider.robot` (both passing). + **Additional LangChain/LangGraph Tasks for Phase 4:** - Implement `CodeGenerationGraph` using LangGraph for multi-step code generation - Create `AutoDebugGraph` with conditional edges for error detection and retry @@ -4198,7 +4204,7 @@ If you can do all of the above by end of Day 1, you're on track! - [X] Replace the placeholder `generate_changes()` with the real LangChain-powered workflow by invoking `LangChainChatProvider.generate_changes`, ensuring PlanGenerationGraph produces structured plan deltas and token estimates. - [X] Add streaming support that forwards `ChatOpenAI.stream()` events to PlanService/CLI consumers, including progress callbacks and graceful cancellation semantics (implemented via `LangChainChatProvider.stream_changes()` and consumed by `PlanService.generate_plan_streaming`). - [X] Capture token usage + cost metrics from the `LLMResult` and persist them on `ProviderResponse.token_count`, logging through structlog with LangSmith tags (see `_resolve_token_count/_log_usage` + new plan-service persistence path). - - [ ] Expand Behave + Robot coverage to exercise real OpenAI provider flows (using patched clients) covering success, streaming, error, and retry scenarios. + - [X] Expand Behave + Robot coverage to exercise real OpenAI provider flows (using patched clients) covering success, streaming, error, and retry scenarios. - [X] Implement Anthropic chat provider adapter (Claude) with parity tests (`src/cleveragents/providers/llm/anthropic_provider.py:1-36`). - [ ] Implement Google Gemini provider adapter with parity tests. - [ ] Implement OpenRouter provider adapter with org header support. diff --git a/robot/google_provider.robot b/robot/google_provider.robot new file mode 100644 index 000000000..b214d1a5b --- /dev/null +++ b/robot/google_provider.robot @@ -0,0 +1,168 @@ +*** Settings *** +Resource common.resource +Library Process +Library OperatingSystem +Library Collections + +*** Variables *** +${PYTHON} python +${SRC_DIR} ${CURDIR}/.. + +*** Test Cases *** +Google Provider Generates Changes + ${script}= Catenate SEPARATOR=\n + ... import sys + ... from unittest.mock import MagicMock, patch + ... sys.path.insert(0, '${SRC_DIR}/src') + ... from cleveragents.providers.llm.google_provider import GoogleChatProvider + ... project = MagicMock(name='Project') + ... plan = MagicMock(name='Plan') + ... plan.prompt = 'Add logging' + ... contexts = [MagicMock(name='Context')] + ... contexts[0].content = 'ctx' + ... chat_patcher = patch('cleveragents.providers.llm.google_provider.ChatGoogleGenerativeAI') + ... graph_patcher = patch('cleveragents.providers.llm.langchain_chat_provider.PlanGenerationGraph') + ... chat_cls = chat_patcher.start() + ... graph_cls = graph_patcher.start() + ... chat_instance = MagicMock(name='ChatGoogleGenerativeAIInstance') + ... chat_instance.get_num_tokens.return_value = 64 + ... chat_cls.return_value = chat_instance + ... change = {'plan_id': 1, 'file_path': 'robot_google.py', 'operation': 'modify', 'new_content': '# code'} + ... graph_instance = MagicMock(name='GraphInstance') + ... graph_instance.invoke.return_value = {'generated_changes': [change], 'validation_result': {'status': 'PASS'}, 'error': None} + ... graph_cls.return_value = graph_instance + ... provider = GoogleChatProvider(api_key='sk-google-robot', model='gemini-2.0-flash') + ... response = provider.generate_changes(project, plan, contexts) + ... chat_patcher.stop() + ... graph_patcher.stop() + ... assert len(response.changes) == 1 + ... assert response.changes[0].file_path == 'robot_google.py' + ... assert response.token_count == 64 + ... print('google-provider-success') + ${result}= Run Process ${PYTHON} -c ${script} + Log Process Failure ${result} + + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} google-provider-success + +Google Provider Streams Events + ${script}= Catenate SEPARATOR=\n + ... import sys + ... from unittest.mock import MagicMock, patch + ... sys.path.insert(0, '${SRC_DIR}/src') + ... from cleveragents.providers.llm.google_provider import GoogleChatProvider + ... project = MagicMock(name='Project') + ... plan = MagicMock(name='Plan') + ... plan.prompt = 'Stream plan' + ... contexts = [MagicMock(name='Context')] + ... contexts[0].content = 'ctx' + ... chat_patcher = patch('cleveragents.providers.llm.google_provider.ChatGoogleGenerativeAI') + ... graph_patcher = patch('cleveragents.providers.llm.langchain_chat_provider.PlanGenerationGraph') + ... chat_cls = chat_patcher.start() + ... graph_cls = graph_patcher.start() + ... chat_instance = MagicMock(name='ChatGoogleGenerativeAIInstance') + ... chat_instance.get_num_tokens.return_value = 32 + ... chat_cls.return_value = chat_instance + ... graph_instance = MagicMock(name='GraphInstance') + ... events = [ + ... {'load_context': {'status': 'ok'}}, + ... {'generate_plan': {'generated_changes': [{'plan_id': 1, 'file_path': 'stream_google.py', 'operation': 'modify', 'new_content': '# stream'}]}}, + ... {'validate': {'validation_result': {'status': 'PASS'}}}, + ... ] + ... graph_instance.stream.side_effect = lambda *_args, **_kwargs: iter(events) + ... graph_instance.invoke.return_value = {'generated_changes': [], 'validation_result': {'status': 'PASS'}, 'error': None} + ... graph_cls.return_value = graph_instance + ... provider = GoogleChatProvider(api_key='sk-google-robot', model='gemini-2.0-flash') + ... streamed = list(provider.stream_changes(project, plan, contexts)) + ... chat_patcher.stop() + ... graph_patcher.stop() + ... assert streamed[-1]['__end__']['response'].model_used == 'gemini-2.0-flash' + ... assert len(streamed[-1]['__end__']['response'].changes) == 1 + ... print('google-stream-success') + ${result}= Run Process ${PYTHON} -c ${script} + Log Process Failure ${result} + + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} google-stream-success + +Google Provider Surfaces Graph Errors + ${script}= Catenate SEPARATOR=\n + ... import sys + ... from unittest.mock import MagicMock, patch + ... sys.path.insert(0, '${SRC_DIR}/src') + ... from cleveragents.providers.llm.google_provider import GoogleChatProvider + ... project = MagicMock(name='Project') + ... plan = MagicMock(name='Plan') + ... plan.prompt = 'Error plan' + ... contexts = [MagicMock(name='Context')] + ... contexts[0].content = 'ctx' + ... chat_patcher = patch('cleveragents.providers.llm.google_provider.ChatGoogleGenerativeAI') + ... graph_patcher = patch('cleveragents.providers.llm.langchain_chat_provider.PlanGenerationGraph') + ... chat_cls = chat_patcher.start() + ... graph_cls = graph_patcher.start() + ... chat_instance = MagicMock(name='ChatGoogleGenerativeAIInstance') + ... chat_instance.get_num_tokens.return_value = 0 + ... chat_cls.return_value = chat_instance + ... graph_instance = MagicMock(name='GraphInstance') + ... graph_instance.invoke.side_effect = ValueError('graph exploded') + ... graph_cls.return_value = graph_instance + ... provider = GoogleChatProvider(api_key='sk-google-robot', model='gemini-2.0-flash') + ... response = provider.generate_changes(project, plan, contexts) + ... chat_patcher.stop() + ... graph_patcher.stop() + ... assert response.error_message == 'graph exploded' + ... assert response.changes == [] + ... print('google-provider-error') + ${result}= Run Process ${PYTHON} -c ${script} + Log Process Failure ${result} + + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} google-provider-error + +Google Provider Reports Retry Exhaustion + ${script}= Catenate SEPARATOR=\n + ... import sys + ... from unittest.mock import MagicMock, patch + ... from tenacity import RetryError, Future + ... sys.path.insert(0, '${SRC_DIR}/src') + ... from cleveragents.providers.llm.google_provider import GoogleChatProvider + ... project = MagicMock(name='Project') + ... plan = MagicMock(name='Plan') + ... plan.prompt = 'Retry plan' + ... contexts = [MagicMock(name='Context')] + ... contexts[0].content = 'ctx' + ... future = Future(1) + ... future.set_exception(RuntimeError('deadline reached')) + ... retry_error = RetryError(future) + ... chat_patcher = patch('cleveragents.providers.llm.google_provider.ChatGoogleGenerativeAI') + ... graph_patcher = patch('cleveragents.providers.llm.langchain_chat_provider.PlanGenerationGraph') + ... invoke_patcher = patch('cleveragents.providers.llm.langchain_chat_provider.LangChainChatProvider._invoke_with_retry', side_effect=retry_error) + ... chat_cls = chat_patcher.start() + ... graph_cls = graph_patcher.start() + ... invoke_patcher.start() + ... chat_instance = MagicMock(name='ChatGoogleGenerativeAIInstance') + ... chat_instance.get_num_tokens.return_value = 0 + ... chat_cls.return_value = chat_instance + ... graph_instance = MagicMock(name='GraphInstance') + ... graph_cls.return_value = graph_instance + ... provider = GoogleChatProvider(api_key='sk-google-robot', model='gemini-2.0-flash') + ... response = provider.generate_changes(project, plan, contexts) + ... chat_patcher.stop() + ... graph_patcher.stop() + ... invoke_patcher.stop() + ... assert response.error_message == 'deadline reached' + ... assert response.changes == [] + ... print('google-provider-retry-error') + ${result}= Run Process ${PYTHON} -c ${script} + Log Process Failure ${result} + + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} google-provider-retry-error + +*** Keywords *** +Log Process Failure + [Arguments] ${result} + Run Keyword If ${result.rc} == 0 Return From Keyword + Log To Console Process failed with rc=${result.rc} + Log To Console STDOUT:${\n}${result.stdout} + Log To Console STDERR:${\n}${result.stderr} diff --git a/src/cleveragents/providers/llm/google_provider.py b/src/cleveragents/providers/llm/google_provider.py new file mode 100644 index 000000000..535ca7d1d --- /dev/null +++ b/src/cleveragents/providers/llm/google_provider.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import Any + +from langchain_google_genai import ChatGoogleGenerativeAI + +from cleveragents.providers.llm.langchain_chat_provider import LangChainChatProvider + + +class GoogleChatProvider(LangChainChatProvider): + """Concrete provider adapter for Google Gemini chat models.""" + + def __init__( + self, + *, + api_key: str, + model: str = "gemini-2.0-flash", + max_retries: int = 3, + **llm_kwargs: Any, + ) -> None: + if not api_key: + raise ValueError("Google API key is required") + + def factory(resolved_model: str) -> ChatGoogleGenerativeAI: + kwargs: dict[str, Any] = { + "api_key": api_key, + "model": resolved_model, + } + if llm_kwargs: + kwargs.update(llm_kwargs) + return ChatGoogleGenerativeAI(**kwargs) + + super().__init__( + name="google", + model_id=model, + llm_factory=factory, + max_retries=max_retries, + supports_streaming=True, + ) diff --git a/src/cleveragents/providers/registry.py b/src/cleveragents/providers/registry.py index 2997f622c..82d999554 100644 --- a/src/cleveragents/providers/registry.py +++ b/src/cleveragents/providers/registry.py @@ -582,6 +582,27 @@ class ProviderRegistry: provider_info.capabilities if provider_info else ProviderCapabilities() ) + if provider_type == ProviderType.GOOGLE: + from cleveragents.providers.llm.google_provider import GoogleChatProvider + + key_attr = self.PROVIDER_KEY_ATTRS.get(provider_type) + api_key = getattr(self._settings, key_attr, None) if key_attr else None + if not api_key: + missing_env = ( + key_attr.upper() if key_attr else provider_type.value.upper() + ) + raise ValueError( + f"Provider {provider_type.value} is not configured. " + f"Please set the {missing_env} environment variable." + ) + + return GoogleChatProvider( + api_key=api_key, + model=model_id + or self.DEFAULT_MODELS.get(provider_type, "gemini-2.0-flash"), + max_retries=max_retries, + ) + # Create factory function for the LLM def llm_factory(mid: str) -> BaseLanguageModel: return self._create_provider_llm(provider_type, mid) # type: ignore[arg-type]