From 59324933b142ecc28d1ce724063295cebe19114e Mon Sep 17 00:00:00 2001 From: Vaishnavi Desai Date: Fri, 19 Jun 2026 17:00:45 +0530 Subject: [PATCH 1/4] refactor: reduce provider sync/async duplication --- src/selectools/embeddings/anthropic.py | 9 - src/selectools/embeddings/cohere.py | 8 - src/selectools/embeddings/gemini.py | 8 - src/selectools/embeddings/openai.py | 9 - src/selectools/embeddings/provider.py | 4 +- .../providers/anthropic_provider.py | 237 +++++++++++------- src/selectools/providers/gemini_provider.py | 197 ++++++--------- 7 files changed, 221 insertions(+), 251 deletions(-) diff --git a/src/selectools/embeddings/anthropic.py b/src/selectools/embeddings/anthropic.py index 1002f2d..1af8c31 100644 --- a/src/selectools/embeddings/anthropic.py +++ b/src/selectools/embeddings/anthropic.py @@ -139,15 +139,6 @@ def embed_query(self, query: str) -> List[float]: ) return cast(List[float], response.embeddings[0]) - @property - def dimension(self) -> int: - """ - Get the embedding vector dimension. - - Returns: - Dimension of embedding vectors - """ - return self._dimension __stability__ = "beta" diff --git a/src/selectools/embeddings/cohere.py b/src/selectools/embeddings/cohere.py index ad7d936..4217eb9 100644 --- a/src/selectools/embeddings/cohere.py +++ b/src/selectools/embeddings/cohere.py @@ -154,15 +154,7 @@ def embed_query(self, query: str) -> List[float]: ) return cast(List[float], response.embeddings[0]) - @property - def dimension(self) -> int: - """ - Get the embedding vector dimension. - Returns: - Dimension of embedding vectors - """ - return self._dimension __stability__ = "beta" diff --git a/src/selectools/embeddings/gemini.py b/src/selectools/embeddings/gemini.py index fa87e5d..f62dfc6 100644 --- a/src/selectools/embeddings/gemini.py +++ b/src/selectools/embeddings/gemini.py @@ -172,15 +172,7 @@ def embed_query(self, query: str) -> List[float]: raise ValueError("No embedding returned") return response.embeddings[0].values - @property - def dimension(self) -> int: - """ - Get the embedding vector dimension. - Returns: - Dimension of embedding vectors - """ - return self._dimension __stability__ = "beta" diff --git a/src/selectools/embeddings/openai.py b/src/selectools/embeddings/openai.py index 5a8967c..a16c57d 100644 --- a/src/selectools/embeddings/openai.py +++ b/src/selectools/embeddings/openai.py @@ -146,15 +146,6 @@ def embed_query(self, query: str) -> List[float]: """ return self.embed_text(query) - @property - def dimension(self) -> int: - """ - Get the embedding vector dimension. - - Returns: - Dimension of embedding vectors - """ - return self._dimension __stability__ = "beta" diff --git a/src/selectools/embeddings/provider.py b/src/selectools/embeddings/provider.py index 265463e..8c1858e 100644 --- a/src/selectools/embeddings/provider.py +++ b/src/selectools/embeddings/provider.py @@ -63,7 +63,6 @@ def embed_query(self, query: str) -> List[float]: pass @property - @abstractmethod def dimension(self) -> int: """ Get the embedding vector dimension for this model. @@ -71,8 +70,7 @@ def dimension(self) -> int: Returns: Integer dimension of embedding vectors """ - pass - + return self._dimension __stability__ = "beta" diff --git a/src/selectools/providers/anthropic_provider.py b/src/selectools/providers/anthropic_provider.py index 471c6f7..f4cfe98 100644 --- a/src/selectools/providers/anthropic_provider.py +++ b/src/selectools/providers/anthropic_provider.py @@ -113,6 +113,8 @@ class AnthropicProvider(Provider): cache_system: bool = False cache_tools: bool = False + + def __init__( self, api_key: str | None = None, @@ -155,6 +157,96 @@ def __init__( self.cache_system = cache_system self.cache_tools = cache_tools + + + def _build_request_args( + self, + *, + model_name: str, + system_prompt: str, + payload: List[dict], + temperature: float, + max_tokens: int, + tools: List[Tool] | None, + timeout: float | None, + stream: bool = False, + ) -> Dict[str, Any]: + request_args: Dict[str, Any] = { + "model": model_name, + "system": self._system_param(system_prompt), + "messages": payload, + "temperature": temperature, + "max_tokens": max_tokens, + } + + if stream: + request_args["stream"] = True + + if tools: + request_args["tools"] = self._tools_param(tools) + + if timeout is not None: + request_args["timeout"] = timeout + + return request_args + + def _message_from_content_blocks( + self, + blocks: Any, + ) -> tuple[str, List[ToolCall]]: + content_text = "" + tool_calls: List[ToolCall] = [] + + for block in blocks: + if block.type == "text": + content_text += block.text + elif block.type == "tool_use": + tool_calls.append( + ToolCall( + tool_name=block.name, + parameters=cast(Dict[str, Any], block.input), + id=block.id, + ) + ) + + return _strip_reasoning_tags(content_text), tool_calls + + + def _usage_from_response( + self, + response: Any, + model_name: str, + ) -> UsageStats: + usage = response.usage + + cache_creation_tokens = self._cache_usage_token( + usage, + "cache_creation_input_tokens", + ) + + cache_read_tokens = self._cache_usage_token( + usage, + "cache_read_input_tokens", + ) + + return UsageStats( + prompt_tokens=usage.input_tokens if usage else 0, + completion_tokens=usage.output_tokens if usage else 0, + total_tokens=(usage.input_tokens + usage.output_tokens) if usage else 0, + cost_usd=calculate_cost( + model_name, + usage.input_tokens if usage else 0, + usage.output_tokens if usage else 0, + cache_read_input_tokens=cache_read_tokens or 0, + cache_creation_input_tokens=cache_creation_tokens or 0, + ), + model=model_name, + provider="anthropic", + cache_creation_input_tokens=cache_creation_tokens, + cache_read_input_tokens=cache_read_tokens, + ) + + def complete( self, *, @@ -185,13 +277,15 @@ def complete( """ payload = self._format_messages(messages) model_name = model or self.default_model - request_args = { - "model": model_name, - "system": self._system_param(system_prompt), - "messages": payload, - "temperature": temperature, - "max_tokens": max_tokens, - } + request_args = self._build_request_args( + model_name=model_name, + system_prompt=system_prompt, + payload=payload, + temperature=temperature, + max_tokens=max_tokens, + tools=tools, + timeout=timeout, + ) if tools: request_args["tools"] = self._tools_param(tools) @@ -202,44 +296,18 @@ def complete( except Exception as exc: raise ProviderError(f"Anthropic completion failed: {exc}") from exc - content_text = "" - tool_calls: List[ToolCall] = [] - - for block in response.content: - if block.type == "text": - content_text += block.text - elif block.type == "tool_use": - tool_calls.append( - ToolCall( - tool_name=block.name, - parameters=cast(Dict[str, Any], block.input), - id=block.id, - ) - ) + content_text, tool_calls = self._message_from_content_blocks( + response.content + ) content_text = _strip_reasoning_tags(content_text) # Extract usage stats. Cache tokens are billed separately from # input_tokens (reads at 0.1x, 5-min-TTL writes at 1.25x the prompt # rate), so they feed into calculate_cost as well. - usage = response.usage - cache_creation_tokens = self._cache_usage_token(usage, "cache_creation_input_tokens") - cache_read_tokens = self._cache_usage_token(usage, "cache_read_input_tokens") - usage_stats = UsageStats( - prompt_tokens=usage.input_tokens if usage else 0, - completion_tokens=usage.output_tokens if usage else 0, - total_tokens=(usage.input_tokens + usage.output_tokens) if usage else 0, - cost_usd=calculate_cost( - model_name, - usage.input_tokens if usage else 0, - usage.output_tokens if usage else 0, - cache_read_input_tokens=cache_read_tokens or 0, - cache_creation_input_tokens=cache_creation_tokens or 0, - ), - model=model_name, - provider="anthropic", - cache_creation_input_tokens=cache_creation_tokens, - cache_read_input_tokens=cache_read_tokens, + usage_stats = self._usage_from_response( + response, + model_name, ) return ( @@ -271,14 +339,16 @@ def stream( """ payload = self._format_messages(messages) model_name = model or self.default_model - request_args = { - "model": model_name, - "system": self._system_param(system_prompt), - "messages": payload, - "temperature": temperature, - "max_tokens": max_tokens, - "stream": True, - } + request_args = self._build_request_args( + model_name=model_name, + system_prompt=system_prompt, + payload=payload, + temperature=temperature, + max_tokens=max_tokens, + tools=tools, + timeout=timeout, + stream=True, + ) if tools: request_args["tools"] = self._tools_param(tools) if timeout is not None: @@ -561,13 +631,15 @@ async def acomplete( """ payload = self._format_messages(messages) model_name = model or self.default_model - request_args = { - "model": model_name, - "system": self._system_param(system_prompt), - "messages": payload, - "temperature": temperature, - "max_tokens": max_tokens, - } + request_args = self._build_request_args( + model_name=model_name, + system_prompt=system_prompt, + payload=payload, + temperature=temperature, + max_tokens=max_tokens, + tools=tools, + timeout=timeout, + ) if tools: request_args["tools"] = self._tools_param(tools) @@ -578,46 +650,19 @@ async def acomplete( except Exception as exc: raise ProviderError(f"Anthropic async completion failed: {exc}") from exc - content_text = "" - tool_calls: List[ToolCall] = [] - - for block in response.content: - if block.type == "text": - content_text += block.text - elif block.type == "tool_use": - tool_calls.append( - ToolCall( - tool_name=block.name, - parameters=cast(Dict[str, Any], block.input), - id=block.id, - ) - ) + content_text, tool_calls = self._message_from_content_blocks( + response.content + ) content_text = _strip_reasoning_tags(content_text) # Extract usage stats. Cache tokens are billed separately from # input_tokens (reads at 0.1x, 5-min-TTL writes at 1.25x the prompt # rate), so they feed into calculate_cost as well. - usage = response.usage - cache_creation_tokens = self._cache_usage_token(usage, "cache_creation_input_tokens") - cache_read_tokens = self._cache_usage_token(usage, "cache_read_input_tokens") - usage_stats = UsageStats( - prompt_tokens=usage.input_tokens if usage else 0, - completion_tokens=usage.output_tokens if usage else 0, - total_tokens=(usage.input_tokens + usage.output_tokens) if usage else 0, - cost_usd=calculate_cost( - model_name, - usage.input_tokens if usage else 0, - usage.output_tokens if usage else 0, - cache_read_input_tokens=cache_read_tokens or 0, - cache_creation_input_tokens=cache_creation_tokens or 0, - ), - model=model_name, - provider="anthropic", - cache_creation_input_tokens=cache_creation_tokens, - cache_read_input_tokens=cache_read_tokens, + usage_stats = self._usage_from_response( + response, + model_name, ) - return ( Message( role=Role.ASSISTANT, @@ -647,14 +692,16 @@ async def astream( """ payload = self._format_messages(messages) model_name = model or self.default_model - request_args = { - "model": model_name, - "system": self._system_param(system_prompt), - "messages": payload, - "temperature": temperature, - "max_tokens": max_tokens, - "stream": True, - } + request_args = self._build_request_args( + model_name=model_name, + system_prompt=system_prompt, + payload=payload, + temperature=temperature, + max_tokens=max_tokens, + tools=tools, + timeout=timeout, + stream=True, + ) if tools: request_args["tools"] = self._tools_param(tools) if timeout is not None: diff --git a/src/selectools/providers/gemini_provider.py b/src/selectools/providers/gemini_provider.py index 51a8c1f..97f6750 100644 --- a/src/selectools/providers/gemini_provider.py +++ b/src/selectools/providers/gemini_provider.py @@ -141,6 +141,60 @@ def __init__( self._genai = genai self.default_model = default_model + + def _build_config( + self, + *, + system_prompt: str, + tools, + temperature: float, + max_tokens: int, + timeout: float | None,): + from google.genai import types + + http_options = ( + types.HttpOptions(timeout=int(timeout * 1000)) + if timeout is not None + else None + ) + + config = types.GenerateContentConfig( + temperature=temperature, + max_output_tokens=max_tokens, + system_instruction=system_prompt if system_prompt else None, + http_options=http_options, + ) + + if tools: + config.tools = [self._map_tool_to_gemini(t) for t in tools] + + return config + + + + def _toolcall_from_part(self, part) -> ToolCall: + tc_id = f"call_{uuid.uuid4().hex}" + + raw_sig = getattr(part, "thought_signature", None) + + sig_str = ( + ( + base64.b64encode(raw_sig).decode("ascii") + if isinstance(raw_sig, bytes) + else str(raw_sig) + ) + if raw_sig + else None + ) + + return ToolCall( + tool_name=str(part.function_call.name or ""), + parameters=part.function_call.args if part.function_call.args else {}, + id=tc_id, + thought_signature=sig_str, + ) + + def complete( self, *, @@ -174,19 +228,14 @@ def complete( model_name = model or self.default_model contents = self._format_contents(system_prompt, messages) - http_options = ( - types.HttpOptions(timeout=int(timeout * 1000)) if timeout is not None else None - ) - config = types.GenerateContentConfig( + config = self._build_config( + system_prompt=system_prompt, + tools=tools, temperature=temperature, - max_output_tokens=max_tokens, - system_instruction=system_prompt if system_prompt else None, - http_options=http_options, + max_tokens=max_tokens, + timeout=timeout, ) - if tools: - config.tools = [self._map_tool_to_gemini(t) for t in tools] - try: response = self._client.models.generate_content( model=model_name, @@ -210,24 +259,8 @@ def complete( if candidate_content and candidate_content.parts: for part in candidate_content.parts: if part.function_call: - tc_id = f"call_{uuid.uuid4().hex}" - raw_sig = getattr(part, "thought_signature", None) - sig_str = ( - ( - base64.b64encode(raw_sig).decode("ascii") - if isinstance(raw_sig, bytes) - else str(raw_sig) - ) - if raw_sig - else None - ) tool_calls.append( - ToolCall( - tool_name=str(part.function_call.name or ""), - parameters=part.function_call.args if part.function_call.args else {}, - id=tc_id, - thought_signature=sig_str, - ) + self._toolcall_from_part(part) ) # Issue #66: a tool-equipped response with neither text nor tool calls @@ -297,19 +330,14 @@ def stream( model_name = model or self.default_model contents = self._format_contents(system_prompt, messages) - http_options = ( - types.HttpOptions(timeout=int(timeout * 1000)) if timeout is not None else None - ) - config = types.GenerateContentConfig( + config = self._build_config( + system_prompt=system_prompt, + tools=tools, temperature=temperature, - max_output_tokens=max_tokens, - system_instruction=system_prompt if system_prompt else None, - http_options=http_options, + max_tokens=max_tokens, + timeout=timeout, ) - if tools: - config.tools = [self._map_tool_to_gemini(t) for t in tools] - try: stream = self._client.models.generate_content_stream( model=model_name, @@ -358,28 +386,7 @@ def stream( continue _seen_tool_calls.add(dedup_key) - tc_id = f"call_{uuid.uuid4().hex}" - raw_sig = getattr(part, "thought_signature", None) - sig_str = ( - ( - base64.b64encode(raw_sig).decode("ascii") - if isinstance(raw_sig, bytes) - else str(raw_sig) - ) - if raw_sig - else None - ) - _yielded_any = True - yield ToolCall( - tool_name=call_name, - parameters=( - part.function_call.args - if part.function_call.args - else {} - ), - id=tc_id, - thought_signature=sig_str, - ) + yield self._toolcall_from_part(part) except ProviderError: raise except Exception as exc: @@ -570,19 +577,14 @@ async def acomplete( model_name = model or self.default_model contents = self._format_contents(system_prompt, messages) - http_options = ( - types.HttpOptions(timeout=int(timeout * 1000)) if timeout is not None else None - ) - config = types.GenerateContentConfig( + config = self._build_config( + system_prompt=system_prompt, + tools=tools, temperature=temperature, - max_output_tokens=max_tokens, - system_instruction=system_prompt if system_prompt else None, - http_options=http_options, + max_tokens=max_tokens, + timeout=timeout, ) - if tools: - config.tools = [self._map_tool_to_gemini(t) for t in tools] - try: response = await self._client.aio.models.generate_content( model=model_name, @@ -606,24 +608,8 @@ async def acomplete( if candidate_content and candidate_content.parts: for part in candidate_content.parts: if part.function_call: - tc_id = f"call_{uuid.uuid4().hex}" - raw_sig = getattr(part, "thought_signature", None) - sig_str = ( - ( - base64.b64encode(raw_sig).decode("ascii") - if isinstance(raw_sig, bytes) - else str(raw_sig) - ) - if raw_sig - else None - ) tool_calls.append( - ToolCall( - tool_name=str(part.function_call.name or ""), - parameters=part.function_call.args if part.function_call.args else {}, - id=tc_id, - thought_signature=sig_str, - ) + self._toolcall_from_part(part) ) # Issue #66: see complete() — surface empty tool-equipped responses. @@ -689,19 +675,14 @@ async def astream( model_name = model or self.default_model contents = self._format_contents(system_prompt, messages) - http_options = ( - types.HttpOptions(timeout=int(timeout * 1000)) if timeout is not None else None - ) - config = types.GenerateContentConfig( + config = self._build_config( + system_prompt=system_prompt, + tools=tools, temperature=temperature, - max_output_tokens=max_tokens, - system_instruction=system_prompt if system_prompt else None, - http_options=http_options, + max_tokens=max_tokens, + timeout=timeout, ) - if tools: - config.tools = [self._map_tool_to_gemini(t) for t in tools] - try: stream = await self._client.aio.models.generate_content_stream( model=model_name, @@ -744,29 +725,7 @@ async def astream( if dedup_key in _seen_tool_calls: continue _seen_tool_calls.add(dedup_key) - - tc_id = f"call_{uuid.uuid4().hex}" - raw_sig = getattr(part, "thought_signature", None) - sig_str = ( - ( - base64.b64encode(raw_sig).decode("ascii") - if isinstance(raw_sig, bytes) - else str(raw_sig) - ) - if raw_sig - else None - ) - _yielded_any = True - yield ToolCall( - tool_name=call_name, - parameters=( - part.function_call.args - if part.function_call.args - else {} - ), - id=tc_id, - thought_signature=sig_str, - ) + yield self._toolcall_from_part(part) except ProviderError: raise except Exception as exc: From 427df3c10987729b696a21af6f551b3c934018bb Mon Sep 17 00:00:00 2001 From: Vaishnavi Desai Date: Sat, 20 Jun 2026 11:35:41 +0530 Subject: [PATCH 2/4] fix: address review feedback for provider refactor --- .../providers/anthropic_provider.py | 34 +++------------ src/selectools/providers/gemini_provider.py | 43 ++++++++----------- 2 files changed, 24 insertions(+), 53 deletions(-) diff --git a/src/selectools/providers/anthropic_provider.py b/src/selectools/providers/anthropic_provider.py index f4cfe98..2b8ba38 100644 --- a/src/selectools/providers/anthropic_provider.py +++ b/src/selectools/providers/anthropic_provider.py @@ -113,8 +113,6 @@ class AnthropicProvider(Provider): cache_system: bool = False cache_tools: bool = False - - def __init__( self, api_key: str | None = None, @@ -157,8 +155,6 @@ def __init__( self.cache_system = cache_system self.cache_tools = cache_tools - - def _build_request_args( self, *, @@ -209,8 +205,7 @@ def _message_from_content_blocks( ) ) - return _strip_reasoning_tags(content_text), tool_calls - + return _strip_reasoning_tags(content_text), tool_calls def _usage_from_response( self, @@ -246,7 +241,6 @@ def _usage_from_response( cache_read_input_tokens=cache_read_tokens, ) - def complete( self, *, @@ -286,19 +280,13 @@ def complete( tools=tools, timeout=timeout, ) - if tools: - request_args["tools"] = self._tools_param(tools) - if timeout is not None: - request_args["timeout"] = timeout try: response = self._client.messages.create(**request_args) # type: ignore[call-overload] except Exception as exc: raise ProviderError(f"Anthropic completion failed: {exc}") from exc - content_text, tool_calls = self._message_from_content_blocks( - response.content - ) + content_text, tool_calls = self._message_from_content_blocks(response.content) content_text = _strip_reasoning_tags(content_text) @@ -349,10 +337,7 @@ def stream( timeout=timeout, stream=True, ) - if tools: - request_args["tools"] = self._tools_param(tools) - if timeout is not None: - request_args["timeout"] = timeout + try: stream = self._client.messages.create(**request_args) # type: ignore[call-overload] except Exception as exc: @@ -640,19 +625,13 @@ async def acomplete( tools=tools, timeout=timeout, ) - if tools: - request_args["tools"] = self._tools_param(tools) - if timeout is not None: - request_args["timeout"] = timeout try: response = await self._async_client.messages.create(**request_args) # type: ignore[call-overload] except Exception as exc: raise ProviderError(f"Anthropic async completion failed: {exc}") from exc - content_text, tool_calls = self._message_from_content_blocks( - response.content - ) + content_text, tool_calls = self._message_from_content_blocks(response.content) content_text = _strip_reasoning_tags(content_text) @@ -702,10 +681,7 @@ async def astream( timeout=timeout, stream=True, ) - if tools: - request_args["tools"] = self._tools_param(tools) - if timeout is not None: - request_args["timeout"] = timeout + try: stream = await self._async_client.messages.create(**request_args) # type: ignore[call-overload] except Exception as exc: diff --git a/src/selectools/providers/gemini_provider.py b/src/selectools/providers/gemini_provider.py index 97f6750..8289981 100644 --- a/src/selectools/providers/gemini_provider.py +++ b/src/selectools/providers/gemini_provider.py @@ -16,6 +16,8 @@ if TYPE_CHECKING: from ..tools.base import Tool +from google.genai import types + from ..env import load_default_env from ..exceptions import ProviderConfigurationError from ..models import Gemini as GeminiModels @@ -141,21 +143,18 @@ def __init__( self._genai = genai self.default_model = default_model - def _build_config( self, *, system_prompt: str, - tools, + tools: List[Tool] | None, temperature: float, max_tokens: int, - timeout: float | None,): - from google.genai import types + timeout: float | None, + ) -> types.GenerateContentConfig: http_options = ( - types.HttpOptions(timeout=int(timeout * 1000)) - if timeout is not None - else None + types.HttpOptions(timeout=int(timeout * 1000)) if timeout is not None else None ) config = types.GenerateContentConfig( @@ -168,11 +167,12 @@ def _build_config( if tools: config.tools = [self._map_tool_to_gemini(t) for t in tools] - return config + return config - - - def _toolcall_from_part(self, part) -> ToolCall: + def _toolcall_from_part( + self, + part: types.Part, + ) -> ToolCall: tc_id = f"call_{uuid.uuid4().hex}" raw_sig = getattr(part, "thought_signature", None) @@ -194,7 +194,6 @@ def _toolcall_from_part(self, part) -> ToolCall: thought_signature=sig_str, ) - def complete( self, *, @@ -223,7 +222,6 @@ def complete( Raises: ProviderError: If the API call fails """ - from google.genai import types model_name = model or self.default_model contents = self._format_contents(system_prompt, messages) @@ -259,9 +257,7 @@ def complete( if candidate_content and candidate_content.parts: for part in candidate_content.parts: if part.function_call: - tool_calls.append( - self._toolcall_from_part(part) - ) + tool_calls.append(self._toolcall_from_part(part)) # Issue #66: a tool-equipped response with neither text nor tool calls # would silently no-op the agent loop. Surface it loudly. @@ -325,7 +321,6 @@ def stream( str: Text content deltas. ToolCall: Complete tool call objects when a function_call part arrives. """ - from google.genai import types model_name = model or self.default_model contents = self._format_contents(system_prompt, messages) @@ -386,7 +381,9 @@ def stream( continue _seen_tool_calls.add(dedup_key) - yield self._toolcall_from_part(part) + if part.function_call: + _yielded_any = True + yield self._toolcall_from_part(part) except ProviderError: raise except Exception as exc: @@ -572,7 +569,6 @@ async def acomplete( Returns: Tuple of (response_text, usage_stats) """ - from google.genai import types model_name = model or self.default_model contents = self._format_contents(system_prompt, messages) @@ -608,9 +604,7 @@ async def acomplete( if candidate_content and candidate_content.parts: for part in candidate_content.parts: if part.function_call: - tool_calls.append( - self._toolcall_from_part(part) - ) + tool_calls.append(self._toolcall_from_part(part)) # Issue #66: see complete() — surface empty tool-equipped responses. if tools and not content_text and not tool_calls: @@ -670,7 +664,6 @@ async def astream( str: Text content deltas ToolCall: Complete tool call objects when a function_call part arrives """ - from google.genai import types model_name = model or self.default_model contents = self._format_contents(system_prompt, messages) @@ -725,7 +718,9 @@ async def astream( if dedup_key in _seen_tool_calls: continue _seen_tool_calls.add(dedup_key) - yield self._toolcall_from_part(part) + if part.function_call: + _yielded_any = True + yield self._toolcall_from_part(part) except ProviderError: raise except Exception as exc: From 7ce6dabf41c6f0ce25795a055b7cb678e976efd3 Mon Sep 17 00:00:00 2001 From: John Niche Date: Sat, 20 Jun 2026 17:51:59 -0300 Subject: [PATCH 3/4] style/types: clean up ruff format + mypy gaps from the refactor CI runs neither ruff nor mypy, so three issues slipped through on this branch: - ruff format: removing the duplicated `dimension` properties left stray consecutive blank lines before `__stability__` in 4 embedding modules. Reformatted (whitespace only). - mypy [attr-defined]: the new shared `EmbeddingProvider.dimension` returns `self._dimension`, but the base class never declared it. Added a `_dimension: int` class annotation that documents the contract subclasses must satisfy. - mypy [union-attr]: `_toolcall_from_part` reads `part.function_call.name` outside the callers' `if part.function_call:` guards. Narrowed with a local + explicit guard. No behavior change. ruff format/check + mypy all clean on the 7 files. Claude-Session: https://claude.ai/code/session_011xRdXi1rCM9jbpHQJkNzof --- src/selectools/embeddings/anthropic.py | 1 - src/selectools/embeddings/cohere.py | 2 -- src/selectools/embeddings/gemini.py | 2 -- src/selectools/embeddings/openai.py | 1 - src/selectools/embeddings/provider.py | 6 ++++++ src/selectools/providers/gemini_provider.py | 8 ++++++-- 6 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/selectools/embeddings/anthropic.py b/src/selectools/embeddings/anthropic.py index 1af8c31..f430f25 100644 --- a/src/selectools/embeddings/anthropic.py +++ b/src/selectools/embeddings/anthropic.py @@ -140,7 +140,6 @@ def embed_query(self, query: str) -> List[float]: return cast(List[float], response.embeddings[0]) - __stability__ = "beta" __all__ = ["AnthropicEmbeddingProvider"] diff --git a/src/selectools/embeddings/cohere.py b/src/selectools/embeddings/cohere.py index 4217eb9..969cc2c 100644 --- a/src/selectools/embeddings/cohere.py +++ b/src/selectools/embeddings/cohere.py @@ -155,8 +155,6 @@ def embed_query(self, query: str) -> List[float]: return cast(List[float], response.embeddings[0]) - - __stability__ = "beta" __all__ = ["CohereEmbeddingProvider"] diff --git a/src/selectools/embeddings/gemini.py b/src/selectools/embeddings/gemini.py index f62dfc6..cd292de 100644 --- a/src/selectools/embeddings/gemini.py +++ b/src/selectools/embeddings/gemini.py @@ -173,8 +173,6 @@ def embed_query(self, query: str) -> List[float]: return response.embeddings[0].values - - __stability__ = "beta" __all__ = ["GeminiEmbeddingProvider"] diff --git a/src/selectools/embeddings/openai.py b/src/selectools/embeddings/openai.py index a16c57d..c5df0fb 100644 --- a/src/selectools/embeddings/openai.py +++ b/src/selectools/embeddings/openai.py @@ -147,7 +147,6 @@ def embed_query(self, query: str) -> List[float]: return self.embed_text(query) - __stability__ = "beta" __all__ = ["OpenAIEmbeddingProvider"] diff --git a/src/selectools/embeddings/provider.py b/src/selectools/embeddings/provider.py index 8c1858e..349b10d 100644 --- a/src/selectools/embeddings/provider.py +++ b/src/selectools/embeddings/provider.py @@ -19,6 +19,11 @@ class EmbeddingProvider(ABC): across different backend implementations (OpenAI, Anthropic/Voyage, Gemini, Cohere). """ + # Each concrete provider sets this in __init__ (typically via + # _get_model_dimension()). Declared here so the shared `dimension` + # property below is type-checked against the base class. + _dimension: int + @abstractmethod def embed_text(self, text: str) -> List[float]: """ @@ -72,6 +77,7 @@ def dimension(self) -> int: """ return self._dimension + __stability__ = "beta" __all__ = ["EmbeddingProvider"] diff --git a/src/selectools/providers/gemini_provider.py b/src/selectools/providers/gemini_provider.py index 8289981..0ee7974 100644 --- a/src/selectools/providers/gemini_provider.py +++ b/src/selectools/providers/gemini_provider.py @@ -187,9 +187,13 @@ def _toolcall_from_part( else None ) + function_call = part.function_call + if function_call is None: # pragma: no cover - callers guard on part.function_call + raise ProviderError("Gemini part has no function_call to convert") + return ToolCall( - tool_name=str(part.function_call.name or ""), - parameters=part.function_call.args if part.function_call.args else {}, + tool_name=str(function_call.name or ""), + parameters=function_call.args if function_call.args else {}, id=tc_id, thought_signature=sig_str, ) From 7d7efb95aa5618f804eeccd9f4a2631b5c3693d7 Mon Sep 17 00:00:00 2001 From: John Niche Date: Sat, 20 Jun 2026 18:07:44 -0300 Subject: [PATCH 4/4] ci: re-trigger checks Empty commit to re-fire the pull_request workflow (squashed on merge). Claude-Session: https://claude.ai/code/session_011xRdXi1rCM9jbpHQJkNzof