polymath-agent 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. polymath/__init__.py +2 -0
  2. polymath/adapters/__init__.py +7 -0
  3. polymath/adapters/base.py +175 -0
  4. polymath/adapters/claude.py +280 -0
  5. polymath/adapters/gemini.py +186 -0
  6. polymath/adapters/ollama.py +117 -0
  7. polymath/adapters/openai_adapter.py +168 -0
  8. polymath/bootstrap.py +159 -0
  9. polymath/command_registry.py +41 -0
  10. polymath/command_service.py +572 -0
  11. polymath/compressor.py +90 -0
  12. polymath/config.py +293 -0
  13. polymath/context_manager.py +76 -0
  14. polymath/context_store.py +336 -0
  15. polymath/detector.py +442 -0
  16. polymath/domain.py +78 -0
  17. polymath/execution_service.py +325 -0
  18. polymath/main.py +1293 -0
  19. polymath/memory/__init__.py +15 -0
  20. polymath/memory/chunker.py +6 -0
  21. polymath/memory/embedder.py +179 -0
  22. polymath/memory/migrate.py +2 -0
  23. polymath/memory/retriever.py +2 -0
  24. polymath/memory/store.py +9 -0
  25. polymath/memory/sync.py +2 -0
  26. polymath/memory/writer.py +9 -0
  27. polymath/model_policy.py +172 -0
  28. polymath/orchestrator/__init__.py +68 -0
  29. polymath/orchestrator/attempt_ledger.py +34 -0
  30. polymath/orchestrator/ensemble.py +229 -0
  31. polymath/orchestrator/fanout.py +322 -0
  32. polymath/orchestrator/output_policy.py +61 -0
  33. polymath/orchestrator/race.py +311 -0
  34. polymath/orchestrator/run_controller.py +91 -0
  35. polymath/orchestrator/speculative_review.py +120 -0
  36. polymath/orchestrator/state_responder.py +184 -0
  37. polymath/orchestrator/worker_pool.py +37 -0
  38. polymath/permissions.py +82 -0
  39. polymath/pipeline.py +700 -0
  40. polymath/project_config.py +229 -0
  41. polymath/project_runtime.py +109 -0
  42. polymath/router.py +127 -0
  43. polymath/setup_wizard.py +106 -0
  44. polymath/slash_commands.py +566 -0
  45. polymath/subagents.py +486 -0
  46. polymath/tools.py +333 -0
  47. polymath/ui_state.py +84 -0
  48. polymath/workspace.py +66 -0
  49. polymath_agent-0.4.0.dist-info/METADATA +693 -0
  50. polymath_agent-0.4.0.dist-info/RECORD +54 -0
  51. polymath_agent-0.4.0.dist-info/WHEEL +5 -0
  52. polymath_agent-0.4.0.dist-info/entry_points.txt +2 -0
  53. polymath_agent-0.4.0.dist-info/licenses/LICENSE +21 -0
  54. polymath_agent-0.4.0.dist-info/top_level.txt +1 -0
polymath/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """polymath — multi-model AI orchestrator"""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ from polymath.adapters.base import BaseAdapter, Message
2
+ from polymath.adapters.claude import ClaudeAdapter
3
+ from polymath.adapters.gemini import GeminiAdapter
4
+ from polymath.adapters.openai_adapter import OpenAIAdapter
5
+ from polymath.adapters.ollama import OllamaAdapter
6
+
7
+ __all__ = ["BaseAdapter", "Message", "ClaudeAdapter", "GeminiAdapter", "OpenAIAdapter", "OllamaAdapter"]
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, AsyncIterator, Dict, List, Optional
6
+
7
+
8
+ #: Output ceilings. Current models write up to 128K tokens, and 4096 was
9
+ #: cutting plans, reviews and file rewrites off mid-sentence. The
10
+ #: non-streaming figure stays well inside the SDK's HTTP timeout; streaming
11
+ #: has no such constraint, so it gets room.
12
+ DEFAULT_MAX_TOKENS = 16_000
13
+ DEFAULT_STREAM_MAX_TOKENS = 64_000
14
+
15
+
16
+ def truncation_notice(limit: int) -> str:
17
+ """Appended to a reply the provider cut off at the output limit.
18
+
19
+ Without it a truncated answer is indistinguishable from a bad one: it
20
+ simply stops, and every layer downstream treats it as complete.
21
+ """
22
+ return (
23
+ f"\n\n[truncated: the model hit its output limit of {limit:,} tokens. "
24
+ "Ask it to continue, or raise max_tokens.]"
25
+ )
26
+
27
+
28
+ class AuthExpiredError(Exception):
29
+ """Raised when an adapter receives an auth/401 error from the provider."""
30
+ def __init__(self, provider: str, relogin_cmd: str) -> None:
31
+ self.provider = provider
32
+ self.relogin_cmd = relogin_cmd
33
+ super().__init__(f"{provider} auth expired — run: {relogin_cmd}")
34
+
35
+
36
+ def is_auth_failure(
37
+ exc: BaseException,
38
+ auth_types: tuple[type, ...] = (),
39
+ ) -> bool:
40
+ """Whether a provider exception means "your credentials are no longer good".
41
+
42
+ Every SDK raises a distinct class for this, so the class is the answer
43
+ whenever we can resolve it. Matching on the rendered message is the
44
+ fallback for the case where the SDK is too old to expose the class, and
45
+ it is deliberately narrow: an earlier version tested `"401" in text`,
46
+ which reported a rate limit whose body happened to mention 401 as an
47
+ expired login and sent the user off to log in again.
48
+ """
49
+ if auth_types and isinstance(exc, auth_types):
50
+ return True
51
+ status = getattr(exc, "status_code", None)
52
+ if status is None:
53
+ response = getattr(exc, "response", None)
54
+ status = getattr(response, "status_code", None)
55
+ if status in (401, 403):
56
+ return True
57
+ text = str(exc).lower()
58
+ return any(
59
+ marker in text
60
+ for marker in (
61
+ "authentication_error", "authentication error",
62
+ "authentication failed", "unauthorized",
63
+ "invalid api key", "invalid_api_key",
64
+ "unauthenticated", "invalid_grant", "expired token",
65
+ "token has expired", "credentials",
66
+ )
67
+ )
68
+
69
+
70
+ def is_rate_limit(exc: BaseException) -> bool:
71
+ """Whether a provider exception means "slow down or you are out of quota".
72
+
73
+ Status first, then the SDK class name, then unambiguous phrases. Matching
74
+ on `"429" in str(exc)` alone classifies any error whose body happens to
75
+ contain those digits as a rate limit.
76
+ """
77
+ status = getattr(exc, "status_code", None)
78
+ if status is None:
79
+ response = getattr(exc, "response", None)
80
+ status = getattr(response, "status_code", None)
81
+ if status == 429:
82
+ return True
83
+ if type(exc).__name__ in ("RateLimitError", "ResourceExhausted",
84
+ "TooManyRequests"):
85
+ return True
86
+ text = str(exc).lower()
87
+ return any(
88
+ marker in text
89
+ for marker in ("rate limit", "rate_limit", "insufficient_quota",
90
+ "quota exceeded", "too many requests")
91
+ )
92
+
93
+
94
+ def resolve_exception_types(module_name: str, *names: str) -> tuple[type, ...]:
95
+ """Look up exception classes on an SDK without importing it at module
96
+ scope — the SDKs are optional extras, so a missing one must not break
97
+ the import. Names the SDK does not define are skipped."""
98
+ import importlib
99
+ try:
100
+ module = importlib.import_module(module_name)
101
+ except Exception:
102
+ return ()
103
+ found = []
104
+ for name in names:
105
+ cls = getattr(module, name, None)
106
+ if isinstance(cls, type) and issubclass(cls, BaseException):
107
+ found.append(cls)
108
+ return tuple(found)
109
+
110
+
111
+ @dataclass
112
+ class Message:
113
+ role: str # "user" | "assistant" | "system" | "tool"
114
+ content: str
115
+ model: str = ""
116
+ step: str = ""
117
+ tool_calls: List[ToolCall] = field(default_factory=list)
118
+ tool_call_id: Optional[str] = None
119
+
120
+
121
+ @dataclass
122
+ class ToolCall:
123
+ id: str
124
+ name: str
125
+ arguments: Dict[str, Any]
126
+
127
+
128
+ class BaseAdapter(ABC):
129
+ """Every model adapter must implement this interface."""
130
+
131
+ provider: str = ""
132
+
133
+ @abstractmethod
134
+ async def complete(
135
+ self,
136
+ messages: list[Message],
137
+ model_id: str,
138
+ system: str = "",
139
+ tools: list[Any] | None = None,
140
+ temperature: float = 0.7,
141
+ max_tokens: int = DEFAULT_MAX_TOKENS,
142
+ ) -> str | Message:
143
+ """Send messages and return the full response text or Message with tool calls."""
144
+ ...
145
+
146
+ @abstractmethod
147
+ async def stream(
148
+ self,
149
+ messages: list[Message],
150
+ model_id: str,
151
+ system: str = "",
152
+ tools: list[Any] | None = None,
153
+ temperature: float = 0.7,
154
+ max_tokens: int = DEFAULT_STREAM_MAX_TOKENS,
155
+ ) -> AsyncIterator[str | ToolCall]:
156
+ """Stream response tokens or tool call chunks."""
157
+ ...
158
+
159
+ def count_tokens(self, text: str) -> int:
160
+ """Local token estimate. Never a network call, never exact.
161
+
162
+ Used where an async context is not available. Prefer
163
+ count_tokens_async, which asks the provider where the provider can
164
+ answer.
165
+ """
166
+ return len(text) // 4
167
+
168
+ async def count_tokens_async(self, text: str, model_id: str = "") -> int:
169
+ """Token count, exact where the provider offers a counting endpoint.
170
+
171
+ Defaults to the local estimate. Adapters that can do better override
172
+ this; they must fall back to the estimate rather than raise, since a
173
+ count is only ever used to budget context.
174
+ """
175
+ return self.count_tokens(text)
@@ -0,0 +1,280 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from typing import Any, AsyncIterator
5
+
6
+ from polymath.adapters.base import (
7
+ AuthExpiredError, BaseAdapter, DEFAULT_MAX_TOKENS,
8
+ DEFAULT_STREAM_MAX_TOKENS, Message, ToolCall,
9
+ is_auth_failure, resolve_exception_types, truncation_notice,
10
+ )
11
+ from polymath.config import get_model
12
+
13
+
14
+ def _auth_types() -> tuple[type, ...]:
15
+ return resolve_exception_types(
16
+ "anthropic", "AuthenticationError", "PermissionDeniedError",
17
+ )
18
+
19
+
20
+ #: Rough characters-per-token for English prose. Only used when the API
21
+ #: cannot be reached; count_tokens_async is the real answer.
22
+ _CLAUDE_CHARS_PER_TOKEN = 4
23
+
24
+ #: Model used for counting when the caller did not name one. Counting is
25
+ #: model-specific, so this is a fallback, not a default worth relying on.
26
+ _DEFAULT_COUNTING_MODEL = "claude-sonnet-5"
27
+
28
+ #: (model, sha256 of text) -> token count. Cleared wholesale when full;
29
+ #: counts are cheap to recompute and this is only a budget figure.
30
+ _TOKEN_COUNT_CACHE: dict[tuple[str, str], int] = {}
31
+ _TOKEN_COUNT_CACHE_MAX = 512
32
+
33
+
34
+ def _accepts_temperature(model_id: str) -> bool:
35
+ """Whether to send a sampling temperature to this model.
36
+
37
+ Claude removed temperature/top_p/top_k with the 4.7 generation; sending
38
+ one returns a 400. An id we do not recognise is assumed to be a newer
39
+ model and gets no temperature — omitting it is valid on every Claude
40
+ model ever released, so unknown-means-omit is the safe default.
41
+ """
42
+ model = get_model(model_id)
43
+ return bool(model and model.supports_temperature)
44
+
45
+
46
+ class ClaudeAdapter(BaseAdapter):
47
+ provider = "claude"
48
+
49
+ def __init__(self, api_key: str) -> None:
50
+ import anthropic
51
+ self._client = anthropic.AsyncAnthropic(api_key=api_key)
52
+ self._last_input_tokens: int = 0
53
+ self._last_output_tokens: int = 0
54
+
55
+ def _convert_tools(self, tools: list[Any] | None) -> list[dict] | None:
56
+ if not tools:
57
+ return None
58
+ claude_tools = []
59
+ for t in tools:
60
+ schema = t.to_json_schema()
61
+ claude_tools.append({
62
+ "name": schema["name"],
63
+ "description": schema["description"],
64
+ "input_schema": schema["parameters"]
65
+ })
66
+ return claude_tools
67
+
68
+ async def complete(
69
+ self,
70
+ messages: list[Message],
71
+ model_id: str,
72
+ system: str = "",
73
+ tools: list[Any] | None = None,
74
+ temperature: float = 0.7,
75
+ max_tokens: int = DEFAULT_MAX_TOKENS,
76
+ ) -> str | Message:
77
+ claude_msgs = []
78
+ for m in messages:
79
+ if m.role == "system":
80
+ continue
81
+
82
+ content = []
83
+ if m.content:
84
+ content.append({"type": "text", "text": m.content})
85
+
86
+ for tc in m.tool_calls:
87
+ content.append({
88
+ "type": "tool_use",
89
+ "id": tc.id,
90
+ "name": tc.name,
91
+ "input": tc.arguments
92
+ })
93
+
94
+ if m.role == "tool":
95
+ # Special handling for tool results in Claude
96
+ claude_msgs.append({
97
+ "role": "user",
98
+ "content": [{
99
+ "type": "tool_result",
100
+ "tool_use_id": m.tool_call_id,
101
+ "content": m.content
102
+ }]
103
+ })
104
+ continue
105
+
106
+ claude_msgs.append({"role": m.role, "content": content})
107
+
108
+ kwargs = dict(
109
+ model=model_id,
110
+ max_tokens=max_tokens,
111
+ messages=claude_msgs,
112
+ )
113
+ if _accepts_temperature(model_id):
114
+ kwargs["temperature"] = temperature
115
+ if system:
116
+ kwargs["system"] = system
117
+ if tools:
118
+ kwargs["tools"] = self._convert_tools(tools)
119
+
120
+ try:
121
+ resp = await self._client.messages.create(**kwargs)
122
+ except Exception as e:
123
+ if is_auth_failure(e, _auth_types()):
124
+ raise AuthExpiredError("claude", "claude") from e
125
+ raise
126
+
127
+ if hasattr(resp, "usage") and resp.usage:
128
+ self._last_input_tokens = getattr(resp.usage, "input_tokens", 0)
129
+ self._last_output_tokens = getattr(resp.usage, "output_tokens", 0)
130
+
131
+ # Collect all tool_use blocks (regardless of stop_reason)
132
+ tool_calls = []
133
+ content_text = ""
134
+ for block in resp.content:
135
+ if block.type == "text":
136
+ content_text += block.text
137
+ elif block.type == "tool_use":
138
+ tool_calls.append(ToolCall(
139
+ id=block.id,
140
+ name=block.name,
141
+ arguments=block.input,
142
+ ))
143
+
144
+ if getattr(resp, "stop_reason", None) == "max_tokens":
145
+ content_text += truncation_notice(max_tokens)
146
+
147
+ if tool_calls:
148
+ return Message(
149
+ role="assistant",
150
+ content=content_text,
151
+ tool_calls=tool_calls,
152
+ )
153
+
154
+ return content_text
155
+
156
+ async def stream(
157
+ self,
158
+ messages: list[Message],
159
+ model_id: str,
160
+ system: str = "",
161
+ tools: list[Any] | None = None,
162
+ temperature: float = 0.7,
163
+ max_tokens: int = DEFAULT_STREAM_MAX_TOKENS,
164
+ ) -> AsyncIterator[str | ToolCall]:
165
+ # Fall back to complete() when tools are present since streaming + tools is complex.
166
+ if tools:
167
+ result = await self.complete(
168
+ messages=messages,
169
+ model_id=model_id,
170
+ system=system,
171
+ tools=tools,
172
+ temperature=temperature,
173
+ max_tokens=max_tokens,
174
+ )
175
+ if isinstance(result, Message):
176
+ if result.content:
177
+ yield result.content
178
+ for tc in result.tool_calls:
179
+ yield tc
180
+ else:
181
+ yield result
182
+ return
183
+
184
+ # Build properly formatted messages for streaming (no tools)
185
+ claude_msgs = []
186
+ for m in messages:
187
+ if m.role == "system":
188
+ continue
189
+ if m.role == "tool":
190
+ claude_msgs.append({
191
+ "role": "user",
192
+ "content": [{
193
+ "type": "tool_result",
194
+ "tool_use_id": m.tool_call_id,
195
+ "content": m.content,
196
+ }]
197
+ })
198
+ continue
199
+ content = []
200
+ if m.content:
201
+ content.append({"type": "text", "text": m.content})
202
+ for tc in m.tool_calls:
203
+ content.append({
204
+ "type": "tool_use",
205
+ "id": tc.id,
206
+ "name": tc.name,
207
+ "input": tc.arguments,
208
+ })
209
+ claude_msgs.append({"role": m.role, "content": content})
210
+
211
+ kwargs = dict(
212
+ model=model_id,
213
+ max_tokens=max_tokens,
214
+ messages=claude_msgs,
215
+ )
216
+ if _accepts_temperature(model_id):
217
+ kwargs["temperature"] = temperature
218
+ if system:
219
+ kwargs["system"] = system
220
+
221
+ try:
222
+ async with self._client.messages.stream(**kwargs) as stream:
223
+ async for text in stream.text_stream:
224
+ yield text
225
+ # The answer is already in the user's hands by this point.
226
+ # Reporting truncation is a nicety, so it must never be able
227
+ # to fail the run that just succeeded — an SDK without
228
+ # get_final_message would otherwise turn a complete answer
229
+ # into an exception.
230
+ try:
231
+ final = await stream.get_final_message()
232
+ truncated = getattr(final, "stop_reason", None) == "max_tokens"
233
+ except Exception:
234
+ truncated = False
235
+ if truncated:
236
+ yield truncation_notice(max_tokens)
237
+ except Exception as e:
238
+ if is_auth_failure(e, _auth_types()):
239
+ raise AuthExpiredError("claude", "claude") from e
240
+ raise
241
+
242
+ def count_tokens(self, text: str) -> int:
243
+ """Local estimate for Claude.
244
+
245
+ This used to load tiktoken's cl100k_base, which is OpenAI's encoding
246
+ and not Claude's, so the number was wrong by an unknown margin in an
247
+ unknown direction. There is no local Claude tokenizer; a character
248
+ ratio is the honest local answer, and count_tokens_async gets the
249
+ real one from the API.
250
+ """
251
+ return max(1, len(text) // _CLAUDE_CHARS_PER_TOKEN) if text else 0
252
+
253
+ async def count_tokens_async(self, text: str, model_id: str = "") -> int:
254
+ """Exact count from Anthropic's own counting endpoint.
255
+
256
+ Cached per (model, text): the context budget is recomputed on every
257
+ turn over a prompt that is mostly unchanged, and this would otherwise
258
+ be one network round trip each time.
259
+ """
260
+ if not text:
261
+ return 0
262
+ model = model_id or _DEFAULT_COUNTING_MODEL
263
+ key = (model, hashlib.sha256(text.encode("utf-8")).hexdigest())
264
+ cached = _TOKEN_COUNT_CACHE.get(key)
265
+ if cached is not None:
266
+ return cached
267
+ try:
268
+ resp = await self._client.messages.count_tokens(
269
+ model=model,
270
+ messages=[{"role": "user", "content": text}],
271
+ )
272
+ count = int(resp.input_tokens)
273
+ except Exception:
274
+ # Offline, unkeyed, or an unknown model id. A budget figure is
275
+ # not worth failing a run over.
276
+ return self.count_tokens(text)
277
+ if len(_TOKEN_COUNT_CACHE) >= _TOKEN_COUNT_CACHE_MAX:
278
+ _TOKEN_COUNT_CACHE.clear()
279
+ _TOKEN_COUNT_CACHE[key] = count
280
+ return count
@@ -0,0 +1,186 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any, AsyncIterator
5
+
6
+ from polymath.adapters.base import (
7
+ AuthExpiredError, BaseAdapter, DEFAULT_MAX_TOKENS,
8
+ DEFAULT_STREAM_MAX_TOKENS, Message, ToolCall,
9
+ is_auth_failure, resolve_exception_types,
10
+ )
11
+
12
+
13
+ def _auth_types() -> tuple[type, ...]:
14
+ return resolve_exception_types(
15
+ "google.genai.errors", "ClientError", "UnauthenticatedError",
16
+ )
17
+
18
+
19
+ class GeminiAdapter(BaseAdapter):
20
+ provider = "gemini"
21
+
22
+ def __init__(self, api_key: str) -> None:
23
+ from google import genai
24
+ from google.genai import types
25
+ # OAuth tokens (from gemini CLI) start with "ya29." — use http_options
26
+ # to pass them as a bearer token instead of an API key.
27
+ if api_key.startswith("ya29."):
28
+ self._client = genai.Client(
29
+ http_options={"headers": {"Authorization": f"Bearer {api_key}"}},
30
+ )
31
+ else:
32
+ self._client = genai.Client(api_key=api_key)
33
+ self._types = types
34
+ self._last_input_tokens: int = 0
35
+ self._last_output_tokens: int = 0
36
+
37
+ def _build_contents(self, messages: list[Message]) -> list:
38
+ """Convert Message list to Gemini contents format."""
39
+ contents = []
40
+ for m in messages:
41
+ if m.role == "system":
42
+ continue
43
+ role = "user" if m.role in ("user", "tool") else "model"
44
+ parts = []
45
+
46
+ if m.role == "tool":
47
+ # Tool result — send as function_response
48
+ parts.append(self._types.Part(
49
+ function_response=self._types.FunctionResponse(
50
+ name=m.model,
51
+ response={"result": m.content},
52
+ )
53
+ ))
54
+ elif m.tool_calls:
55
+ # Assistant message with tool calls
56
+ if m.content:
57
+ parts.append(self._types.Part(text=m.content))
58
+ for tc in m.tool_calls:
59
+ parts.append(self._types.Part(
60
+ function_call=self._types.FunctionCall(
61
+ name=tc.name,
62
+ args=tc.arguments,
63
+ )
64
+ ))
65
+ else:
66
+ parts.append(self._types.Part(text=m.content or ""))
67
+
68
+ contents.append(self._types.Content(role=role, parts=parts))
69
+ return contents
70
+
71
+ def _build_tools(self, tools: list[Any] | None):
72
+ if not tools:
73
+ return None
74
+ declarations = []
75
+ for t in tools:
76
+ schema = t.parameters.copy() if t.parameters else {}
77
+ declarations.append(self._types.FunctionDeclaration(
78
+ name=t.name,
79
+ description=t.description,
80
+ parameters=schema,
81
+ ))
82
+ return [self._types.Tool(function_declarations=declarations)]
83
+
84
+ def _parse_response(self, resp) -> str | Message:
85
+ try:
86
+ usage = resp.usage_metadata
87
+ if usage:
88
+ self._last_input_tokens = getattr(usage, "prompt_token_count", 0) or 0
89
+ self._last_output_tokens = getattr(usage, "candidates_token_count", 0) or 0
90
+ except Exception:
91
+ pass
92
+
93
+ try:
94
+ tool_calls = []
95
+ content_text = ""
96
+ for part in resp.candidates[0].content.parts:
97
+ if part.function_call and part.function_call.name:
98
+ fc = part.function_call
99
+ tool_calls.append(ToolCall(
100
+ id=fc.name,
101
+ name=fc.name,
102
+ arguments=dict(fc.args) if fc.args else {},
103
+ ))
104
+ elif part.text:
105
+ content_text += part.text
106
+ if tool_calls:
107
+ return Message(role="assistant", content=content_text, tool_calls=tool_calls)
108
+ return content_text
109
+ except Exception:
110
+ pass
111
+
112
+ try:
113
+ return resp.text
114
+ except Exception:
115
+ return ""
116
+
117
+ async def complete(
118
+ self,
119
+ messages: list[Message],
120
+ model_id: str,
121
+ system: str = "",
122
+ tools: list[Any] | None = None,
123
+ temperature: float = 0.7,
124
+ max_tokens: int = DEFAULT_MAX_TOKENS,
125
+ ) -> str | Message:
126
+ contents = self._build_contents(messages)
127
+ config = self._types.GenerateContentConfig(
128
+ system_instruction=system or None,
129
+ temperature=temperature,
130
+ max_output_tokens=max_tokens,
131
+ tools=self._build_tools(tools),
132
+ )
133
+ try:
134
+ resp = await self._client.aio.models.generate_content(
135
+ model=model_id,
136
+ contents=contents,
137
+ config=config,
138
+ )
139
+ except Exception as e:
140
+ if is_auth_failure(e):
141
+ raise AuthExpiredError("gemini", "gemini auth login") from e
142
+ raise
143
+ return self._parse_response(resp)
144
+
145
+ async def stream(
146
+ self,
147
+ messages: list[Message],
148
+ model_id: str,
149
+ system: str = "",
150
+ tools: list[Any] | None = None,
151
+ temperature: float = 0.7,
152
+ max_tokens: int = DEFAULT_STREAM_MAX_TOKENS,
153
+ ) -> AsyncIterator[str | ToolCall]:
154
+ # If tools requested, fall back to complete() — streaming + tools is complex
155
+ if tools:
156
+ result = await self.complete(messages, model_id, system, tools, temperature, max_tokens)
157
+ if isinstance(result, Message):
158
+ if result.content:
159
+ yield result.content
160
+ for tc in result.tool_calls:
161
+ yield tc
162
+ else:
163
+ yield result
164
+ return
165
+
166
+ contents = self._build_contents(messages)
167
+ config = self._types.GenerateContentConfig(
168
+ system_instruction=system or None,
169
+ temperature=temperature,
170
+ max_output_tokens=max_tokens,
171
+ )
172
+ try:
173
+ async for chunk in await self._client.aio.models.generate_content_stream(
174
+ model=model_id,
175
+ contents=contents,
176
+ config=config,
177
+ ):
178
+ try:
179
+ if chunk.text:
180
+ yield chunk.text
181
+ except Exception:
182
+ pass
183
+ except Exception as e:
184
+ if is_auth_failure(e):
185
+ raise AuthExpiredError("gemini", "gemini auth login") from e
186
+ raise