diff --git a/CHANGELOG.md b/CHANGELOG.md index 80c0a4f..7e9b3a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to the OpenIntent SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.15.1] - 2026-03-06 + +### Changed + +- **Gemini SDK Migration** — Replaced deprecated `google-generativeai` SDK with `google-genai` (v1.0+). The `GeminiAdapter` now uses the modern `genai.Client` pattern (`client.models.generate_content` / `client.models.generate_content_stream`) instead of the legacy `genai.configure()` + `GenerativeModel()` approach. +- **GeminiAdapter Rewrite** — Full protocol parity with OpenAI/Anthropic adapters: prompt/completion/total token counts from `usage_metadata`, finish reason mapping from candidates, function call tracking with provider-native IDs, streaming usage metadata from final chunks, and multi-turn `GeminiChatSession` with proper history management (including during streaming). +- **LLMEngine Gemini Integration** — Added `_messages_to_gemini_contents()` for proper message conversion (system messages become `system_instruction`, assistant messages use `model` role), `_tools_to_gemini_format()` for tool schema conversion to Gemini function declarations, and raw provider fallback paths for Gemini in both `_call_raw_provider` and `_stream_raw_provider`. +- **Default model names** updated from `gemini-1.5-pro` / `gemini-2.0-flash` to `gemini-3-flash` across SDK, docs, and frontend. +- **Dependency** — `google-generativeai>=0.4.0` replaced with `google-genai>=1.0.0` in `gemini` and `all-adapters` optional extras. + +### Updated + +- All version references updated to 0.15.1 across Python SDK, MCP server package, documentation, and changelog. +- Documentation and examples updated to reflect new SDK patterns and model names. + +--- + ## [0.15.0] - 2026-03-02 ### Added diff --git a/docs/changelog.md b/docs/changelog.md index 4553788..93f90c1 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,6 +5,23 @@ All notable changes to the OpenIntent SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.15.1] - 2026-03-06 + +### Changed + +- **Gemini SDK Migration** — Replaced deprecated `google-generativeai` SDK with `google-genai` (v1.0+). The `GeminiAdapter` now uses the modern `genai.Client` pattern (`client.models.generate_content` / `client.models.generate_content_stream`) instead of the legacy `genai.configure()` + `GenerativeModel()` approach. +- **GeminiAdapter Rewrite** — Full protocol parity with OpenAI/Anthropic adapters: prompt/completion/total token counts from `usage_metadata`, finish reason mapping from candidates, function call tracking with provider-native IDs, streaming usage metadata from final chunks, and multi-turn `GeminiChatSession` with proper history management (including during streaming). +- **LLMEngine Gemini Integration** — Added `_messages_to_gemini_contents()` for proper message conversion (system messages become `system_instruction`, assistant messages use `model` role), `_tools_to_gemini_format()` for tool schema conversion to Gemini function declarations, and raw provider fallback paths for Gemini in both `_call_raw_provider` and `_stream_raw_provider`. +- **Default model names** updated from `gemini-1.5-pro` / `gemini-2.0-flash` to `gemini-3-flash` across SDK, docs, and frontend. +- **Dependency** — `google-generativeai>=0.4.0` replaced with `google-genai>=1.0.0` in `gemini` and `all-adapters` optional extras. + +### Updated + +- All version references updated to 0.15.1 across Python SDK, MCP server package, documentation, and changelog. +- Documentation and examples updated to reflect new SDK patterns and model names. + +--- + ## [0.15.0] - 2026-03-02 ### Added diff --git a/docs/examples/llm-adapters.md b/docs/examples/llm-adapters.md index aae46fa..bdaecfc 100644 --- a/docs/examples/llm-adapters.md +++ b/docs/examples/llm-adapters.md @@ -68,16 +68,18 @@ for chunk in adapter.chat_complete_stream( ## Google Gemini ```python -import google.generativeai as genai +from google import genai from openintent.adapters import GeminiAdapter -genai.configure(api_key="...") -model = genai.GenerativeModel("gemini-pro") +gemini = genai.Client(api_key="...") -adapter = GeminiAdapter(model, client, intent.id) -response = adapter.chat_complete( - messages=[{"role": "user", "content": "Explain machine learning"}] -) +adapter = GeminiAdapter(gemini, client, intent.id, model="gemini-3-flash") +response = adapter.generate_content("Explain machine learning") +print(response.text) + +# Streaming +for chunk in adapter.generate_content("Explain in detail", stream=True): + print(chunk.text, end="", flush=True) ``` ## DeepSeek diff --git a/docs/getting-started/llm-quickstart.md b/docs/getting-started/llm-quickstart.md index 2e462a3..5026342 100644 --- a/docs/getting-started/llm-quickstart.md +++ b/docs/getting-started/llm-quickstart.md @@ -174,7 +174,7 @@ The `model=` parameter accepts any model supported by the [LLM adapters](../../g @Agent("researcher", model="claude-sonnet-4-20250514") # Google Gemini -@Agent("researcher", model="gemini-2.0-flash") +@Agent("researcher", model="gemini-3-flash") # DeepSeek @Agent("researcher", model="deepseek-chat") diff --git a/docs/guide/adapters.md b/docs/guide/adapters.md index 22438c7..c701d30 100644 --- a/docs/guide/adapters.md +++ b/docs/guide/adapters.md @@ -97,14 +97,13 @@ pip install openintent[all-adapters] # All adapters === "Gemini" ```python + from google import genai from openintent.adapters import GeminiAdapter - adapter = GeminiAdapter(gemini_client, oi_client, intent_id) + gemini = genai.Client(api_key="...") + adapter = GeminiAdapter(gemini, oi_client, intent_id, model="gemini-3-flash") - response = adapter.generate_content( - model="gemini-pro", - contents=[{"role": "user", "parts": [{"text": "Hello"}]}] - ) + response = adapter.generate_content("Hello") ``` === "Grok / DeepSeek" diff --git a/docs/overrides/home.html b/docs/overrides/home.html index d06ac3f..ccf1fb0 100644 --- a/docs/overrides/home.html +++ b/docs/overrides/home.html @@ -24,7 +24,7 @@
-
v0.15.0 — Native FastAPI SSE & RFC-0010 Retry MCP Tools
+
v0.15.1 — Gemini Adapter Rebuild for google-genai SDK

Stop Duct-Taping Your Agents Together

OpenIntent is a durable, auditable protocol for multi-agent coordination. Structured intents replace fragile chat chains. Versioned state replaces guesswork. Ship agent systems that actually work in production. @@ -50,7 +50,7 @@

Stop Duct-Taping Your Agents Together

Tests
-
v0.15.0
+
v0.15.1
Latest
diff --git a/mcp-server/package.json b/mcp-server/package.json index ae4cca8..4a882d2 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@openintentai/mcp-server", - "version": "0.15.0", + "version": "0.15.1", "description": "MCP server exposing the OpenIntent Coordination Protocol as MCP tools and resources", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index b35a16f..d97fcc6 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -30,7 +30,7 @@ async function main() { const server = new Server( { name: "openintent-mcp", - version: "0.15.0", + version: "0.15.1", }, { capabilities: { diff --git a/mkdocs.yml b/mkdocs.yml index e3ff6dd..f516a5f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -204,7 +204,7 @@ extra: link: https://pypi.org/project/openintent/ version: provider: mike - announcement: "v0.15.0 is here — Native FastAPI SSE replaces sse-starlette, plus 4 new RFC-0010 retry MCP tools (70 total). Read the changelog →" + announcement: "v0.15.1 is here — Gemini adapter rebuilt for google-genai SDK v1.0+, full LLMEngine Gemini integration. Read the changelog →" meta: - name: description content: "OpenIntent Python SDK — structured multi-agent coordination protocol with decorator-first agents, 23 RFCs, 7 LLM adapters, federation, MCP integration, and built-in FastAPI server." diff --git a/openintent/__init__.py b/openintent/__init__.py index b262df2..e24aed3 100644 --- a/openintent/__init__.py +++ b/openintent/__init__.py @@ -233,7 +233,7 @@ def get_server() -> tuple[Any, Any, Any]: ) -__version__ = "0.15.0" +__version__ = "0.15.1" __all__ = [ "OpenIntentClient", "AsyncOpenIntentClient", diff --git a/openintent/adapters/__init__.py b/openintent/adapters/__init__.py index ff04ef0..5efc441 100644 --- a/openintent/adapters/__init__.py +++ b/openintent/adapters/__init__.py @@ -6,7 +6,7 @@ Supported providers: - OpenAI (GPT-4, GPT-4o, GPT-5.2, codex models like gpt-5.2-codex, etc.) - Anthropic (Claude 3, Claude 4, etc.) -- Google Gemini (Gemini 1.5, Gemini 2, etc.) +- Google Gemini (Gemini 3 Flash, Gemini 3 Pro, Gemini 3.1 Pro, etc.) - xAI Grok (Grok-beta, etc.) - DeepSeek (DeepSeek-chat, DeepSeek-coder, etc.) - Azure OpenAI (GPT-4, GPT-4o via Azure endpoints) @@ -58,15 +58,14 @@ Example usage with Google Gemini: - import google.generativeai as genai + from google import genai from openintent import OpenIntentClient from openintent.adapters import GeminiAdapter - genai.configure(api_key="...") - model = genai.GenerativeModel("gemini-1.5-pro") - client = OpenIntentClient(base_url="...", api_key="...") - adapter = GeminiAdapter(model, client, intent_id="...") + gemini = genai.Client(api_key="...") + + adapter = GeminiAdapter(gemini, client, intent_id="...", model="gemini-3-flash") response = adapter.generate_content("Hello, how are you?") """ diff --git a/openintent/adapters/gemini_adapter.py b/openintent/adapters/gemini_adapter.py index c5ba61e..fb97711 100644 --- a/openintent/adapters/gemini_adapter.py +++ b/openintent/adapters/gemini_adapter.py @@ -1,21 +1,21 @@ """Google Gemini provider adapter for automatic OpenIntent coordination. -This adapter wraps the Google Generative AI client to automatically log intent events -for content generation, tool calls, and streaming responses. +This adapter wraps the Google GenAI client (``google-genai`` SDK) to +automatically log intent events for content generation, tool calls, and +streaming responses. Installation: pip install openintent[gemini] Example: - import google.generativeai as genai + from google import genai from openintent import OpenIntentClient from openintent.adapters import GeminiAdapter openintent = OpenIntentClient(base_url="...", api_key="...") - genai.configure(api_key="...") - model = genai.GenerativeModel("gemini-1.5-pro") + gemini = genai.Client(api_key="...") - adapter = GeminiAdapter(model, openintent, intent_id="...") + adapter = GeminiAdapter(gemini, openintent, intent_id="...", model="gemini-3-flash") # Regular generation - automatically logs request events response = adapter.generate_content("Hello, how are you?") @@ -35,26 +35,26 @@ def _check_gemini_installed() -> None: - """Check if the google-generativeai package is installed.""" + """Check if the google-genai package is installed.""" try: - import google.generativeai # noqa: F401 + from google import genai # noqa: F401 except ImportError: raise ImportError( - "GeminiAdapter requires the 'google-generativeai' package. " + "GeminiAdapter requires the 'google-genai' package. " "Install it with: pip install openintent[gemini]" ) from None class GeminiAdapter(BaseAdapter): - """Adapter for the Google Generative AI (Gemini) Python client. + """Adapter for the Google GenAI (Gemini) Python client. - Wraps a GenerativeModel instance to automatically log OpenIntent events + Wraps a ``genai.Client`` instance to automatically log OpenIntent events for all content generation, tool calls, and streaming responses. - The adapter exposes the same interface as the GenerativeModel, so you can - use it as a drop-in replacement: + The adapter provides the same high-level interface as the underlying SDK + while transparently recording protocol events: - adapter = GeminiAdapter(model, openintent, intent_id) + adapter = GeminiAdapter(client, openintent, intent_id, model="gemini-3-flash") response = adapter.generate_content("Hello") Events logged: @@ -70,31 +70,38 @@ class GeminiAdapter(BaseAdapter): def __init__( self, - gemini_model: Any, + gemini_client: Any, openintent_client: "OpenIntentClient", intent_id: str, + model: str = "gemini-3-flash", config: Optional[AdapterConfig] = None, ): """Initialize the Gemini adapter. Args: - gemini_model: The GenerativeModel instance to wrap. + gemini_client: A ``genai.Client`` instance. openintent_client: The OpenIntent client for logging events. intent_id: The intent ID to associate events with. + model: The Gemini model name to use (e.g. ``"gemini-3-flash"``). config: Optional adapter configuration. Raises: - ImportError: If the google-generativeai package is not installed. + ImportError: If the google-genai package is not installed. """ _check_gemini_installed() super().__init__(openintent_client, intent_id, config) - self._model = gemini_model - self._model_name = getattr(gemini_model, "model_name", "gemini") + self._gemini = gemini_client + self._model_name = model @property - def model(self) -> Any: - """The wrapped GenerativeModel.""" - return self._model + def gemini_client(self) -> Any: + """The wrapped genai.Client.""" + return self._gemini + + @property + def model_name(self) -> str: + """The model name used for generation.""" + return self._model_name def generate_content( self, @@ -106,9 +113,11 @@ def generate_content( """Generate content with automatic event logging. Args: - contents: The prompt or conversation contents. + contents: The prompt string, Content object, or list of contents. stream: Whether to stream the response. - **kwargs: Additional arguments passed to generate_content. + **kwargs: Additional arguments passed to the GenAI client. + Supports ``config`` (generation config dict or object), + ``tools``, and any other ``generate_content`` parameters. Returns: GenerateContentResponse or iterator of chunks if streaming. @@ -116,13 +125,12 @@ def generate_content( request_id = self._generate_id() model = self._model_name tools = kwargs.get("tools", []) - temperature = kwargs.get("generation_config", {}) - if hasattr(temperature, "temperature"): - temperature = temperature.temperature - elif isinstance(temperature, dict): - temperature = temperature.get("temperature") - else: - temperature = None + gen_config = kwargs.get("config", {}) + temperature = None + if isinstance(gen_config, dict): + temperature = gen_config.get("temperature") + elif hasattr(gen_config, "temperature"): + temperature = gen_config.temperature messages_count = ( 1 @@ -136,10 +144,20 @@ def generate_content( try: tool_names = [] if tools: - for tool in tools: - if hasattr(tool, "function_declarations"): - for fd in tool.function_declarations: + for t in tools if isinstance(tools, list) else [tools]: + if hasattr(t, "function_declarations"): + for fd in t.function_declarations: tool_names.append(getattr(fd, "name", "unknown")) + elif isinstance(t, dict) and "function_declarations" in t: + for fd in t["function_declarations"]: + name = ( + fd.get("name", "unknown") + if isinstance(fd, dict) + else getattr(fd, "name", "unknown") + ) + tool_names.append(name) + elif callable(t): + tool_names.append(getattr(t, "__name__", "unknown")) self._client.log_llm_request_started( self._intent_id, @@ -196,23 +214,26 @@ def _handle_completion( start_time: float, ) -> Any: """Handle a non-streaming completion.""" - response = self._model.generate_content(contents, **kwargs) + response = self._gemini.models.generate_content( + model=model, contents=contents, **kwargs + ) duration_ms = int((time.time() - start_time) * 1000) if self._config.log_requests: try: usage = getattr(response, "usage_metadata", None) text_content = "" - if response.candidates: + if hasattr(response, "candidates") and response.candidates: candidate = response.candidates[0] - if hasattr(candidate, "content") and candidate.content.parts: - for part in candidate.content.parts: - if hasattr(part, "text"): + if hasattr(candidate, "content") and candidate.content: + for part in candidate.content.parts or []: + if hasattr(part, "text") and part.text: text_content += part.text finish_reason = None - if response.candidates: - finish_reason = str(response.candidates[0].finish_reason) + if hasattr(response, "candidates") and response.candidates: + fr = response.candidates[0].finish_reason + finish_reason = str(fr) if fr is not None else None self._client.log_llm_request_completed( self._intent_id, @@ -272,7 +293,9 @@ def _handle_stream( self._invoke_stream_start(stream_id, model, "google") - stream = self._model.generate_content(contents, stream=True, **kwargs) + stream = self._gemini.models.generate_content_stream( + model=model, contents=contents, **kwargs + ) return self._stream_wrapper( stream, stream_id, @@ -320,22 +343,25 @@ def _stream_wrapper( if getattr(chunk, "usage_metadata", None) is not None: usage_metadata = chunk.usage_metadata - if chunk.candidates: + if hasattr(chunk, "candidates") and chunk.candidates: candidate = chunk.candidates[0] - if hasattr(candidate, "content") and candidate.content.parts: - for part in candidate.content.parts: + if hasattr(candidate, "content") and candidate.content: + for part in candidate.content.parts or []: if hasattr(part, "text") and part.text: content_parts.append(part.text) self._invoke_on_token(part.text, stream_id) - if hasattr(part, "function_call"): + if hasattr(part, "function_call") and part.function_call: fc = part.function_call function_calls.append( { - "name": fc.name, - "args": dict(fc.args) if fc.args else {}, + "name": getattr(fc, "name", "unknown"), + "args": dict(fc.args) + if getattr(fc, "args", None) + else {}, + "id": getattr(fc, "id", None), } ) - if candidate.finish_reason: + if hasattr(candidate, "finish_reason") and candidate.finish_reason: finish_reason = str(candidate.finish_reason) yield chunk @@ -407,7 +433,7 @@ def _stream_wrapper( self._client.log_tool_call_started( self._intent_id, tool_name=fc["name"], - tool_id=self._generate_id(), + tool_id=fc["id"] or self._generate_id(), arguments=fc["args"], provider="google", model=model, @@ -455,26 +481,28 @@ def _stream_wrapper( def _log_function_calls(self, response: Any, model: str) -> None: """Log function calls from a response.""" - if not response.candidates: + if not hasattr(response, "candidates") or not response.candidates: return candidate = response.candidates[0] - if not hasattr(candidate, "content") or not candidate.content.parts: + if not hasattr(candidate, "content") or not candidate.content: + return + if not candidate.content.parts: return for part in candidate.content.parts: - if not hasattr(part, "function_call"): + fc = getattr(part, "function_call", None) + if not fc: continue - fc = part.function_call - tool_id = self._generate_id() + tool_id = getattr(fc, "id", None) or self._generate_id() try: self._client.log_tool_call_started( self._intent_id, - tool_name=fc.name, + tool_name=getattr(fc, "name", "unknown"), tool_id=tool_id, - arguments=dict(fc.args) if fc.args else {}, + arguments=dict(fc.args) if getattr(fc, "args", None) else {}, provider="google", model=model, ) @@ -488,27 +516,43 @@ def start_chat(self, **kwargs: Any) -> "GeminiChatSession": Returns a wrapped chat session that logs events for each message. """ - chat = self._model.start_chat(**kwargs) - return GeminiChatSession(self, chat) + return GeminiChatSession(self, kwargs.get("history")) class GeminiChatSession: - """Wrapped chat session for event tracking.""" + """Wrapped chat session for event tracking. + + Maintains a conversation history and delegates generation to the + adapter while preserving multi-turn context. + """ - def __init__(self, adapter: GeminiAdapter, chat: Any): + def __init__(self, adapter: GeminiAdapter, history: Any = None): self._adapter = adapter - self._chat = chat + self._history: list[Any] = list(history) if history else [] @property def history(self) -> list[Any]: """The chat history.""" - return self._chat.history + return list(self._history) def send_message(self, content: Any, *, stream: bool = False, **kwargs: Any) -> Any: """Send a message with automatic event logging.""" + from google.genai import types + + user_content = types.Content( + role="user", + parts=[ + types.Part.from_text(text=content) + if isinstance(content, str) + else content + ], + ) + + contents = list(self._history) + [user_content] + request_id = self._adapter._generate_id() model = self._adapter._model_name - messages_count = len(self._chat.history) + 1 + messages_count = len(contents) if self._adapter._config.log_requests: try: @@ -530,24 +574,59 @@ def send_message(self, content: Any, *, stream: bool = False, **kwargs: Any) -> try: if stream: return self._handle_stream( - content, kwargs, request_id, model, messages_count, start_time + contents, + kwargs, + request_id, + model, + messages_count, + start_time, + user_content, ) else: - response = self._chat.send_message(content, **kwargs) + response = self._adapter._gemini.models.generate_content( + model=model, contents=contents, **kwargs + ) duration_ms = int((time.time() - start_time) * 1000) + self._history.append(user_content) + if hasattr(response, "candidates") and response.candidates: + candidate = response.candidates[0] + if hasattr(candidate, "content") and candidate.content: + self._history.append(candidate.content) + if self._adapter._config.log_requests: try: - text_content = ( - response.text if hasattr(response, "text") else "" - ) + text_content = "" + usage = getattr(response, "usage_metadata", None) + if hasattr(response, "candidates") and response.candidates: + cand = response.candidates[0] + if hasattr(cand, "content") and cand.content: + for part in cand.content.parts or []: + if hasattr(part, "text") and part.text: + text_content += part.text + self._adapter._client.log_llm_request_completed( self._adapter._intent_id, request_id=request_id, provider="google", model=model, messages_count=messages_count, - response_content=text_content, + response_content=text_content if text_content else None, + prompt_tokens=( + getattr(usage, "prompt_token_count", None) + if usage + else None + ), + completion_tokens=( + getattr(usage, "candidates_token_count", None) + if usage + else None + ), + total_tokens=( + getattr(usage, "total_token_count", None) + if usage + else None + ), duration_ms=duration_ms, ) except Exception as e: @@ -578,12 +657,13 @@ def send_message(self, content: Any, *, stream: bool = False, **kwargs: Any) -> def _handle_stream( self, - content: Any, + contents: list[Any], kwargs: dict[str, Any], request_id: str, model: str, messages_count: int, start_time: float, + user_content: Any, ) -> Iterator[Any]: """Handle streaming chat response.""" stream_id = self._adapter._generate_id() @@ -601,7 +681,30 @@ def _handle_stream( e, {"phase": "stream_started", "stream_id": stream_id} ) - response = self._chat.send_message(content, stream=True, **kwargs) - return self._adapter._stream_wrapper( - response, stream_id, request_id, model, messages_count, start_time + self._history.append(user_content) + + response = self._adapter._gemini.models.generate_content_stream( + model=model, contents=contents, **kwargs + ) + return self._chat_stream_wrapper( + self._adapter._stream_wrapper( + response, stream_id, request_id, model, messages_count, start_time + ), ) + + def _chat_stream_wrapper(self, inner: Iterator[Any]) -> Iterator[Any]: + """Wrap stream to capture assistant content for chat history.""" + collected_text: list[str] = [] + for chunk in inner: + if hasattr(chunk, "candidates") and chunk.candidates: + cand = chunk.candidates[0] + if hasattr(cand, "content") and cand.content: + for part in cand.content.parts or []: + if hasattr(part, "text") and part.text: + collected_text.append(part.text) + yield chunk + + if collected_text: + self._history.append( + {"role": "model", "parts": [{"text": "".join(collected_text)}]} + ) diff --git a/openintent/llm.py b/openintent/llm.py index d4ed190..25aca47 100644 --- a/openintent/llm.py +++ b/openintent/llm.py @@ -39,6 +39,47 @@ async def work(self, intent): GEMINI_STYLE_PROVIDERS = {"gemini"} +def _messages_to_gemini_contents( + messages: list[dict[str, Any]], +) -> tuple[Optional[str], list[Any]]: + """Convert OpenIntent-style messages to Gemini contents. + + Returns (system_instruction, contents) where system_instruction is + extracted from any ``system`` role messages and contents is a list + of dicts compatible with the ``google-genai`` SDK. + """ + system_parts: list[str] = [] + contents: list[dict[str, Any]] = [] + for msg in messages: + role = msg.get("role", "user") + text = msg.get("content", "") + if role == "system": + system_parts.append(text) + elif role == "assistant": + contents.append({"role": "model", "parts": [{"text": text}]}) + else: + contents.append({"role": "user", "parts": [{"text": text}]}) + system_instruction = "\n".join(system_parts) if system_parts else None + return system_instruction, contents + + +def _tools_to_gemini_format(tools: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Convert OpenIntent-style tool schemas to Gemini function declarations.""" + if not tools: + return [] + declarations: list[dict[str, Any]] = [] + for t in tools: + decl: dict[str, Any] = { + "name": t["name"], + "description": t.get("description", ""), + } + params = t.get("parameters") + if params: + decl["parameters"] = params + declarations.append(decl) + return [{"function_declarations": declarations}] + + def _is_codex_model(model: str) -> bool: """Check if a model name indicates a codex/completions-only model.""" from openintent.adapters.codex_utils import is_codex_model @@ -1071,6 +1112,8 @@ def _format_tools_for_provider(self) -> list[dict]: return [] if self._provider in ANTHROPIC_STYLE_PROVIDERS: return _tools_to_anthropic_format(tools) + if self._provider in GEMINI_STYLE_PROVIDERS: + return _tools_to_gemini_format(tools) return _tools_to_openai_format(tools) async def _call_llm( @@ -1099,8 +1142,30 @@ async def _call_llm( if self._provider in ANTHROPIC_STYLE_PROVIDERS: response = adapter.messages.create(**call_kwargs) elif self._provider in GEMINI_STYLE_PROVIDERS: + system_instruction, gemini_contents = _messages_to_gemini_contents( + messages + ) + gemini_kwargs: dict[str, Any] = {} + gen_config: dict[str, Any] = {} + if call_kwargs.get("temperature") is not None: + gen_config["temperature"] = call_kwargs["temperature"] + if call_kwargs.get("max_tokens") is not None: + gen_config["max_output_tokens"] = call_kwargs["max_tokens"] + if system_instruction: + gen_config["system_instruction"] = system_instruction + if gen_config: + gemini_kwargs["config"] = gen_config + if tools: + gemini_kwargs["tools"] = _tools_to_gemini_format( + tools + if not isinstance(tools[0], dict) or "type" not in tools[0] + else [ + t["function"] for t in tools if t.get("type") == "function" + ] + ) response = adapter.generate_content( - messages[-1]["content"] if messages else "", + gemini_contents if gemini_contents else "", + **gemini_kwargs, ) elif _is_codex_model(self._config.model): call_kwargs.pop("system", None) @@ -1148,6 +1213,43 @@ async def _call_raw_provider(self, call_kwargs: dict) -> Any: "anthropic package required. Install with: pip install anthropic" ) + elif self._provider in GEMINI_STYLE_PROVIDERS: + try: + from google import genai + + client = genai.Client(api_key=self._config.api_key) + system_instruction, gemini_contents = _messages_to_gemini_contents( + call_kwargs.get("messages", []) + ) + gen_config: dict[str, Any] = {} + if call_kwargs.get("temperature") is not None: + gen_config["temperature"] = call_kwargs["temperature"] + if call_kwargs.get("max_tokens") is not None: + gen_config["max_output_tokens"] = call_kwargs["max_tokens"] + if system_instruction: + gen_config["system_instruction"] = system_instruction + gkw: dict[str, Any] = {} + if gen_config: + gkw["config"] = gen_config + tools = call_kwargs.get("tools") + if tools: + gkw["tools"] = _tools_to_gemini_format( + tools + if not isinstance(tools[0], dict) or "type" not in tools[0] + else [ + t["function"] for t in tools if t.get("type") == "function" + ] + ) + return client.models.generate_content( + model=call_kwargs.get("model", "gemini-3-flash"), + contents=gemini_contents if gemini_contents else "", + **gkw, + ) + except ImportError: + raise ImportError( + "google-genai package required. Install with: pip install google-genai" + ) + raise ValueError(f"Unsupported provider: {self._provider}") async def _stream_llm( @@ -1174,13 +1276,33 @@ async def _stream_llm( async for token in self._iter_anthropic_stream(stream): yield token elif self._provider in GEMINI_STYLE_PROVIDERS: + system_instruction, gemini_contents = _messages_to_gemini_contents( + messages + ) + gemini_kwargs: dict[str, Any] = {} + gen_config: dict[str, Any] = {} + if call_kwargs.get("temperature") is not None: + gen_config["temperature"] = call_kwargs["temperature"] + if call_kwargs.get("max_tokens") is not None: + gen_config["max_output_tokens"] = call_kwargs["max_tokens"] + if system_instruction: + gen_config["system_instruction"] = system_instruction + if gen_config: + gemini_kwargs["config"] = gen_config response = adapter.generate_content( - messages[-1]["content"] if messages else "", + gemini_contents if gemini_contents else "", stream=True, + **gemini_kwargs, ) for chunk in response: if hasattr(chunk, "text") and chunk.text: yield chunk.text + elif hasattr(chunk, "candidates") and chunk.candidates: + cand = chunk.candidates[0] + if hasattr(cand, "content") and cand.content: + for part in cand.content.parts or []: + if hasattr(part, "text") and part.text: + yield part.text elif _is_codex_model(self._config.model): call_kwargs.pop("system", None) stream = adapter.completions.create(**call_kwargs) @@ -1238,6 +1360,43 @@ async def _stream_raw_provider(self, call_kwargs: dict) -> AsyncIterator[str]: pass except ImportError: raise ImportError("anthropic package required.") + elif self._provider in GEMINI_STYLE_PROVIDERS: + try: + from google import genai + + client = genai.Client(api_key=self._config.api_key) + system_instruction, gemini_contents = _messages_to_gemini_contents( + call_kwargs.get("messages", []) + ) + gen_config: dict[str, Any] = {} + temp = call_kwargs.get("temperature") + if temp is not None: + gen_config["temperature"] = temp + max_tok = call_kwargs.get("max_tokens") + if max_tok is not None: + gen_config["max_output_tokens"] = max_tok + if system_instruction: + gen_config["system_instruction"] = system_instruction + gkw: dict[str, Any] = {} + if gen_config: + gkw["config"] = gen_config + for chunk in client.models.generate_content_stream( + model=call_kwargs.get("model", "gemini-3-flash"), + contents=gemini_contents if gemini_contents else "", + **gkw, + ): + if hasattr(chunk, "text") and chunk.text: + yield chunk.text + elif hasattr(chunk, "candidates") and chunk.candidates: + cand = chunk.candidates[0] + if hasattr(cand, "content") and cand.content: + for part in cand.content.parts or []: + if hasattr(part, "text") and part.text: + yield part.text + except ImportError: + raise ImportError( + "google-genai package required. Install with: pip install google-genai" + ) async def _iter_openai_stream(self, stream: Any) -> AsyncIterator[str]: """Iterate an OpenAI-style stream (sync iterator) yielding tokens.""" diff --git a/pyproject.toml b/pyproject.toml index 855d3ce..91ec7ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "openintent" -version = "0.15.0" +version = "0.15.1" description = "Python SDK and Server for the OpenIntent Coordination Protocol" readme = "README.md" license = {text = "MIT"} @@ -70,7 +70,7 @@ anthropic = [ "anthropic>=0.18.0", ] gemini = [ - "google-generativeai>=0.4.0", + "google-genai>=1.0.0", ] grok = [ "openai>=1.0.0", # Grok uses OpenAI-compatible API @@ -82,7 +82,7 @@ deepseek = [ all-adapters = [ "openai>=1.0.0", "anthropic>=0.18.0", - "google-generativeai>=0.4.0", + "google-genai>=1.0.0", ] all = [ "openintent[server,dev,docs,all-adapters]", @@ -122,8 +122,8 @@ module = [ "openai", "openai.*", "google.*", - "google.generativeai", - "google.generativeai.*", + "google.genai", + "google.genai.*", "xai_grok", "xai_grok.*", "deepseek", diff --git a/tests/test_llm.py b/tests/test_llm.py index 0c3e540..ee6c3dd 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -26,8 +26,10 @@ ProtocolToolExecutor, Tool, ToolDef, + _messages_to_gemini_contents, _resolve_provider, _tools_to_anthropic_format, + _tools_to_gemini_format, _tools_to_openai_format, define_tool, tool, @@ -51,6 +53,9 @@ def test_anthropic(self): def test_gemini(self): assert _resolve_provider("gemini-pro") == "gemini" assert _resolve_provider("gemini-1.5-flash") == "gemini" + assert _resolve_provider("gemini-3-flash") == "gemini" + assert _resolve_provider("gemini-3-pro") == "gemini" + assert _resolve_provider("gemini-3.1-pro") == "gemini" def test_grok(self): assert _resolve_provider("grok-2") == "grok" @@ -1669,3 +1674,102 @@ async def test_think_uses_local_then_remote(self): "web_search", query="openintent protocol", ) + + +# --------------------------------------------------------------------------- +# Gemini Message & Tool Conversion +# --------------------------------------------------------------------------- + + +class TestGeminiConversions: + def test_messages_to_gemini_system_extracted(self): + messages = [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello"}, + ] + system_instruction, contents = _messages_to_gemini_contents(messages) + assert system_instruction == "You are helpful." + assert len(contents) == 1 + assert contents[0]["role"] == "user" + assert contents[0]["parts"][0]["text"] == "Hello" + + def test_messages_to_gemini_no_system(self): + messages = [ + {"role": "user", "content": "Hi"}, + {"role": "assistant", "content": "Hello!"}, + {"role": "user", "content": "How are you?"}, + ] + system_instruction, contents = _messages_to_gemini_contents(messages) + assert system_instruction is None + assert len(contents) == 3 + assert contents[0]["role"] == "user" + assert contents[1]["role"] == "model" + assert contents[2]["role"] == "user" + + def test_messages_to_gemini_multi_system(self): + messages = [ + {"role": "system", "content": "Rule 1"}, + {"role": "system", "content": "Rule 2"}, + {"role": "user", "content": "Go"}, + ] + system_instruction, contents = _messages_to_gemini_contents(messages) + assert "Rule 1" in system_instruction + assert "Rule 2" in system_instruction + assert len(contents) == 1 + + def test_messages_to_gemini_empty(self): + system_instruction, contents = _messages_to_gemini_contents([]) + assert system_instruction is None + assert contents == [] + + def test_tools_to_gemini_format(self): + tools = [ + { + "name": "search", + "description": "Search the web.", + "parameters": { + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"], + }, + }, + { + "name": "calc", + "description": "Do math.", + }, + ] + result = _tools_to_gemini_format(tools) + assert len(result) == 1 + declarations = result[0]["function_declarations"] + assert len(declarations) == 2 + assert declarations[0]["name"] == "search" + assert declarations[0]["description"] == "Search the web." + assert "parameters" in declarations[0] + assert declarations[1]["name"] == "calc" + assert "parameters" not in declarations[1] + + def test_tools_to_gemini_format_empty(self): + result = _tools_to_gemini_format([]) + assert result == [] + + +class TestGeminiToolFormatConversion: + def test_gemini_format_from_protocol_tools(self): + result = _tools_to_gemini_format(PROTOCOL_TOOLS_AGENT) + assert len(result) == 1 + declarations = result[0]["function_declarations"] + assert len(declarations) == len(PROTOCOL_TOOLS_AGENT) + names = {d["name"] for d in declarations} + assert "remember" in names + assert "recall" in names + + def test_format_tools_for_gemini_provider(self): + agent = MagicMock() + agent._agent_id = "test" + agent._config = AgentConfig() + agent._agents_list = None + config = LLMConfig(model="gemini-3-flash", provider="gemini") + engine = LLMEngine(agent, config) + formatted = engine._format_tools_for_provider() + assert len(formatted) == 1 + assert "function_declarations" in formatted[0]