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]