monkeybot 2.1.1__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 (178) hide show
  1. monkeybot/__init__.py +3 -0
  2. monkeybot/cli/__init__.py +3 -0
  3. monkeybot/cli/__main__.py +8 -0
  4. monkeybot/cli/audio_io.py +8 -0
  5. monkeybot/cli/gateway_manager.py +17 -0
  6. monkeybot/cli/main.py +22 -0
  7. monkeybot/cli/push_to_talk.py +12 -0
  8. monkeybot/cli/realtime_client.py +13 -0
  9. monkeybot/core/__init__.py +19 -0
  10. monkeybot/core/attachments/__init__.py +22 -0
  11. monkeybot/core/attachments/catalog.py +62 -0
  12. monkeybot/core/attachments/config.py +52 -0
  13. monkeybot/core/attachments/freeze.py +158 -0
  14. monkeybot/core/attachments/resolve.py +70 -0
  15. monkeybot/core/attachments/store.py +180 -0
  16. monkeybot/core/attachments/text.py +72 -0
  17. monkeybot/core/attachments/tools.py +54 -0
  18. monkeybot/core/bootstrap.py +242 -0
  19. monkeybot/core/config/__init__.py +71 -0
  20. monkeybot/core/config/realtime_config.py +150 -0
  21. monkeybot/core/config/runtime_env.py +262 -0
  22. monkeybot/core/config/settings.py +341 -0
  23. monkeybot/core/config/validation.py +249 -0
  24. monkeybot/core/config/yaml_loader.py +45 -0
  25. monkeybot/core/context/__init__.py +781 -0
  26. monkeybot/core/context/campaign_context.py +8 -0
  27. monkeybot/core/context/common.py +14 -0
  28. monkeybot/core/context/curator.py +255 -0
  29. monkeybot/core/context/epoch.py +226 -0
  30. monkeybot/core/context/memory_prompt.py +222 -0
  31. monkeybot/core/context/tool_output_policy.py +270 -0
  32. monkeybot/core/context/tool_result_ingress.py +290 -0
  33. monkeybot/core/context/tool_shapers.py +361 -0
  34. monkeybot/core/hooks/__init__.py +261 -0
  35. monkeybot/core/llm/__init__.py +4 -0
  36. monkeybot/core/llm/provider.py +296 -0
  37. monkeybot/core/llm/realtime_provider.py +203 -0
  38. monkeybot/core/llm/usage.py +57 -0
  39. monkeybot/core/logging_utils.py +24 -0
  40. monkeybot/core/mcp/__init__.py +1 -0
  41. monkeybot/core/mcp/mcp_client.py +1215 -0
  42. monkeybot/core/mcp/ports_mcp.py +109 -0
  43. monkeybot/core/memory/__init__.py +24 -0
  44. monkeybot/core/memory/hook.py +413 -0
  45. monkeybot/core/memory/index_format.py +104 -0
  46. monkeybot/core/memory/integrity.py +180 -0
  47. monkeybot/core/memory/organizer.py +270 -0
  48. monkeybot/core/memory/storage_ops.py +139 -0
  49. monkeybot/core/memory/subsystem.py +91 -0
  50. monkeybot/core/messages/__init__.py +16 -0
  51. monkeybot/core/messages/convert_provider.py +41 -0
  52. monkeybot/core/messages/tool_integrity.py +262 -0
  53. monkeybot/core/messages/transform_context.py +84 -0
  54. monkeybot/core/path_safety.py +11 -0
  55. monkeybot/core/persistence/__init__.py +17 -0
  56. monkeybot/core/persistence/backends.py +236 -0
  57. monkeybot/core/persistence/db.py +28 -0
  58. monkeybot/core/persistence/durable_runs.py +286 -0
  59. monkeybot/core/persistence/firestore.py +658 -0
  60. monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
  61. monkeybot/core/persistence/history.py +156 -0
  62. monkeybot/core/persistence/postgres.py +895 -0
  63. monkeybot/core/persistence/runs.py +76 -0
  64. monkeybot/core/persistence/scheduled_loops.py +435 -0
  65. monkeybot/core/persistence/session_turn_locks.py +94 -0
  66. monkeybot/core/persistence/sqlite.py +218 -0
  67. monkeybot/core/persistence/sqlite_backend.py +74 -0
  68. monkeybot/core/persistence/thread_summary.py +61 -0
  69. monkeybot/core/persistence/transcript.py +194 -0
  70. monkeybot/core/persistence/usage.py +149 -0
  71. monkeybot/core/prompts/__init__.py +1 -0
  72. monkeybot/core/prompts/harness_prompt.py +197 -0
  73. monkeybot/core/prompts/prompt.py +215 -0
  74. monkeybot/core/runtime/__init__.py +1 -0
  75. monkeybot/core/runtime/context_budget.py +267 -0
  76. monkeybot/core/runtime/events.py +819 -0
  77. monkeybot/core/runtime/input_admission.py +154 -0
  78. monkeybot/core/runtime/loop.py +2374 -0
  79. monkeybot/core/runtime/provider_stream_mapper.py +159 -0
  80. monkeybot/core/runtime/realtime_loop.py +654 -0
  81. monkeybot/core/runtime/utterance_buffer.py +179 -0
  82. monkeybot/core/subagents/__init__.py +1 -0
  83. monkeybot/core/subagents/subagent_proto.py +331 -0
  84. monkeybot/core/subagents/subagent_worker.py +441 -0
  85. monkeybot/core/subagents/worker_pool.py +403 -0
  86. monkeybot/core/testing/__init__.py +1 -0
  87. monkeybot/core/testing/mocks_provider.py +86 -0
  88. monkeybot/core/testing/mocks_realtime_provider.py +137 -0
  89. monkeybot/core/tools/__init__.py +1 -0
  90. monkeybot/core/tools/core_tool_executor.py +1548 -0
  91. monkeybot/core/tools/inspector.py +226 -0
  92. monkeybot/core/tools/loop_inspector.py +45 -0
  93. monkeybot/core/tools/patch.py +480 -0
  94. monkeybot/core/tools/permission.py +284 -0
  95. monkeybot/core/tools/sandbox_executor.py +255 -0
  96. monkeybot/core/tools/spill_inventory.py +35 -0
  97. monkeybot/core/tools/terminal.py +381 -0
  98. monkeybot/core/tools/text_normalize.py +25 -0
  99. monkeybot/core/tools/types.py +33 -0
  100. monkeybot/core/tools/workspace_service.py +710 -0
  101. monkeybot/core/tools/workspace_tools.py +116 -0
  102. monkeybot/core/types/__init__.py +1 -0
  103. monkeybot/core/types/content_blocks.py +644 -0
  104. monkeybot/core/types/interfaces.py +156 -0
  105. monkeybot/core/types/types_tools.py +29 -0
  106. monkeybot/core/workspace/__init__.py +8 -0
  107. monkeybot/core/workspace/factory.py +45 -0
  108. monkeybot/core/workspace/gcs.py +130 -0
  109. monkeybot/core/workspace/local.py +162 -0
  110. monkeybot/core/workspace/protocol.py +45 -0
  111. monkeybot/core/workspace/s3.py +151 -0
  112. monkeybot/core/workspace_layout.py +27 -0
  113. monkeybot/gateway/__init__.py +1 -0
  114. monkeybot/gateway/bootstrap.py +18 -0
  115. monkeybot/gateway/main.py +47 -0
  116. monkeybot/gateway/realtime/__init__.py +31 -0
  117. monkeybot/gateway/realtime/app.py +321 -0
  118. monkeybot/gateway/realtime/deps.py +52 -0
  119. monkeybot/gateway/realtime/errors.py +81 -0
  120. monkeybot/gateway/realtime/guardrails.py +88 -0
  121. monkeybot/gateway/realtime/manager.py +77 -0
  122. monkeybot/gateway/realtime/metrics.py +144 -0
  123. monkeybot/gateway/realtime/routes.py +864 -0
  124. monkeybot/gateway/realtime/session.py +232 -0
  125. monkeybot/gateway/realtime/wire.py +412 -0
  126. monkeybot/gateway/realtime_main.py +49 -0
  127. monkeybot/gateway/sse/__init__.py +1 -0
  128. monkeybot/gateway/sse/app.py +733 -0
  129. monkeybot/gateway/sse/loop_port.py +31 -0
  130. monkeybot/gateway/sse/models.py +177 -0
  131. monkeybot/gateway/sse/reply_body.py +91 -0
  132. monkeybot/gateway/sse/routes.py +1101 -0
  133. monkeybot/gateway/sse/scheduler_routes.py +200 -0
  134. monkeybot/gateway/sse/scheduler_wiring.py +96 -0
  135. monkeybot/gateway/sse/session_bus.py +226 -0
  136. monkeybot/gateway/sse/sse.py +46 -0
  137. monkeybot/gateway/sse/workspace_layout.py +7 -0
  138. monkeybot/observability/__init__.py +220 -0
  139. monkeybot/observability/_state.py +10 -0
  140. monkeybot/observability/instrumentation.py +153 -0
  141. monkeybot/observability/propagation.py +65 -0
  142. monkeybot/observability/spans.py +455 -0
  143. monkeybot/providers/__init__.py +19 -0
  144. monkeybot/providers/_openai_compat.py +450 -0
  145. monkeybot/providers/_utils.py +473 -0
  146. monkeybot/providers/bedrock.py +145 -0
  147. monkeybot/providers/claude.py +125 -0
  148. monkeybot/providers/gemini.py +677 -0
  149. monkeybot/providers/gemini_live.py +398 -0
  150. monkeybot/providers/huggingface.py +129 -0
  151. monkeybot/providers/nvidia.py +104 -0
  152. monkeybot/providers/ollama.py +152 -0
  153. monkeybot/providers/openai.py +127 -0
  154. monkeybot/providers/pricing.py +60 -0
  155. monkeybot/providers/sampling.py +44 -0
  156. monkeybot/providers/vertex_claude.py +148 -0
  157. monkeybot/scaffold/__init__.py +33 -0
  158. monkeybot/scheduler/__init__.py +13 -0
  159. monkeybot/scheduler/__main__.py +4 -0
  160. monkeybot/scheduler/engine.py +333 -0
  161. monkeybot/scheduler/http_invoker.py +61 -0
  162. monkeybot/scheduler/interval.py +77 -0
  163. monkeybot/scheduler/tick_result.py +34 -0
  164. monkeybot/scheduler/worker.py +87 -0
  165. monkeybot/subagents/__init__.py +1 -0
  166. monkeybot/subagents/worker/__init__.py +1 -0
  167. monkeybot/subagents/worker/__main__.py +22 -0
  168. monkeybot/web_search/__init__.py +82 -0
  169. monkeybot/web_search/backends/__init__.py +5 -0
  170. monkeybot/web_search/backends/duckduckgo.py +32 -0
  171. monkeybot/web_search/backends/firecrawl.py +43 -0
  172. monkeybot/web_search/backends/tavily.py +45 -0
  173. monkeybot/web_search/protocol.py +25 -0
  174. monkeybot/web_search/tool.py +56 -0
  175. monkeybot-2.1.1.dist-info/METADATA +318 -0
  176. monkeybot-2.1.1.dist-info/RECORD +178 -0
  177. monkeybot-2.1.1.dist-info/WHEEL +4 -0
  178. monkeybot-2.1.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,398 @@
1
+ """Gemini Live realtime provider (Vertex AI Agent Engine / google-genai).
2
+
3
+ Install with the ``realtime-gemini`` extra:
4
+
5
+ uv sync --extra realtime-gemini
6
+
7
+ This adapter maps the google-genai live duplex API to the harness's
8
+ :class:`~monkeybot.core.llm.realtime_provider.RealtimeProvider` protocol.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ from collections.abc import AsyncIterator, Sequence
16
+ from typing import Any
17
+
18
+ from monkeybot.core.llm.realtime_provider import (
19
+ AudioFormat,
20
+ RealtimeAudioDelta,
21
+ RealtimeDone,
22
+ RealtimeError,
23
+ RealtimeEvent,
24
+ RealtimeInterrupted,
25
+ RealtimePartialTranscript,
26
+ RealtimeProvider,
27
+ RealtimeSession,
28
+ RealtimeSessionConfig,
29
+ RealtimeTextDelta,
30
+ RealtimeToolCall,
31
+ RealtimeTurnBoundary,
32
+ RealtimeUsage,
33
+ )
34
+ from monkeybot.core.types.interfaces import LLMError
35
+ from monkeybot.core.types.types_tools import ToolDef
36
+ from monkeybot.providers.gemini import (
37
+ _normalize_vertex_model,
38
+ _tool_defs_to_declarations,
39
+ _vertex_project_and_location,
40
+ )
41
+
42
+ _log = logging.getLogger(__name__)
43
+
44
+
45
+ # Gemini Live currently requires 24kHz PCM s16le mono for output.
46
+ # Input is resampled by the service; we send 24kHz to match the gateway contract.
47
+ _GEMINI_LIVE_INPUT_FORMAT = AudioFormat(
48
+ encoding="pcm_s16le", sample_rate_hz=24000, channels=1, frame_ms=200
49
+ )
50
+ _GEMINI_LIVE_OUTPUT_FORMAT = AudioFormat(
51
+ encoding="pcm_s16le", sample_rate_hz=24000, channels=1, frame_ms=200
52
+ )
53
+
54
+
55
+ def _require_genai() -> tuple[Any, Any]:
56
+ """Import google-genai and return (genai, types)."""
57
+ try:
58
+ from google import genai
59
+ from google.genai import types
60
+ except ImportError as exc:
61
+ raise LLMError(
62
+ "google-genai is required for GeminiLiveProvider. "
63
+ "Install with: uv sync --extra realtime-gemini"
64
+ ) from exc
65
+ return genai, types
66
+
67
+
68
+ def _build_live_config(
69
+ config: RealtimeSessionConfig,
70
+ types: Any,
71
+ ) -> Any:
72
+ """Build a ``LiveConnectConfig`` from the harness realtime config."""
73
+ # Gemini Live preview models (e.g. gemini-3.1-flash-live-preview) do not support the
74
+ # combined AUDIO+TEXT modality. Use AUDIO by default so the agent responds through
75
+ # voice; text transcripts are still available via output_audio_transcription.
76
+ voice_name = config.voice or "Puck"
77
+ cfg_kwargs: dict[str, Any] = {
78
+ "response_modalities": ["AUDIO"],
79
+ # Keep post-tool turns on the audio path. Some Live SDK versions only honor
80
+ # modalities via generation_config after send_tool_response.
81
+ "generation_config": types.GenerationConfig(response_modalities=["AUDIO"]),
82
+ # Explicit speech_config keeps post-tool turns on the audio path. Without it,
83
+ # some Live models fall back to text-only after send_tool_response.
84
+ "speech_config": types.SpeechConfig(
85
+ voice_config=types.VoiceConfig(
86
+ prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice_name)
87
+ )
88
+ ),
89
+ }
90
+
91
+ if config.system_prompt:
92
+ cfg_kwargs["system_instruction"] = types.Content(
93
+ parts=[types.Part(text=config.system_prompt)]
94
+ )
95
+
96
+ if config.tools:
97
+ decls = _tool_defs_to_declarations(config.tools)
98
+ cfg_kwargs["tools"] = [types.Tool(function_declarations=decls)]
99
+
100
+ # Transcriptions are useful for both text and audio modes.
101
+ try:
102
+ cfg_kwargs["input_audio_transcription"] = types.AudioTranscriptionConfig()
103
+ cfg_kwargs["output_audio_transcription"] = types.AudioTranscriptionConfig()
104
+ except Exception:
105
+ _log.debug("google-genai version does not support audio transcription config")
106
+
107
+ if config.max_output_tokens is not None:
108
+ try:
109
+ cfg_kwargs["max_output_tokens"] = config.max_output_tokens
110
+ except Exception:
111
+ _log.warning("ignoring unsupported max_output_tokens config")
112
+
113
+ return types.LiveConnectConfig(**cfg_kwargs)
114
+
115
+
116
+ class GeminiLiveSession(RealtimeSession):
117
+ """One open Gemini Live duplex session."""
118
+
119
+ def __init__(
120
+ self,
121
+ *,
122
+ config: RealtimeSessionConfig,
123
+ api_key: str | None = None,
124
+ ) -> None:
125
+ self._config = config
126
+ self._model = config.model
127
+ self._system_prompt = config.system_prompt
128
+ self._tools = list(config.tools)
129
+ self._api_key = api_key
130
+ # Gemini Live currently requires 24kHz PCM s16le mono for I/O.
131
+ self._input_format = _GEMINI_LIVE_INPUT_FORMAT
132
+ self._output_format = _GEMINI_LIVE_OUTPUT_FORMAT
133
+ self._session: Any | None = None
134
+ self._session_cm: Any | None = None
135
+
136
+ @property
137
+ def input_format(self) -> AudioFormat:
138
+ return self._input_format
139
+
140
+ @property
141
+ def output_format(self) -> AudioFormat:
142
+ return self._output_format
143
+
144
+ async def _connect(self) -> Any:
145
+ """Enter the Gemini Live session context manager and return the SDK session."""
146
+ if self._session is not None:
147
+ return self._session
148
+
149
+ genai, types = _require_genai()
150
+ model_param = _normalize_vertex_model(self._model)
151
+
152
+ if self._api_key:
153
+ client = genai.Client(api_key=self._api_key)
154
+ else:
155
+ project, location = _vertex_project_and_location(model_param)
156
+ client = genai.Client(vertexai=True, project=project, location=location)
157
+
158
+ # Pass the full harness config so voice / max_output_tokens are honored.
159
+ live_config = _build_live_config(self._config, types)
160
+
161
+ cm = client.aio.live.connect(model=model_param, config=live_config)
162
+ self._session_cm = cm
163
+ try:
164
+ self._session = await cm.__aenter__()
165
+ except Exception as exc:
166
+ self._session_cm = None
167
+ raise LLMError(f"Failed to connect to Gemini Live: {exc}") from exc
168
+ return self._session
169
+
170
+ async def send_audio(self, chunk: bytes) -> None:
171
+ session = await self._connect()
172
+ from google.genai import types
173
+
174
+ await session.send_realtime_input(
175
+ audio=types.Blob(
176
+ data=chunk,
177
+ mime_type=f"audio/pcm;rate={self._input_format.sample_rate_hz}",
178
+ )
179
+ )
180
+
181
+ async def end_audio_turn(self) -> None:
182
+ """Tell Gemini Live the user stopped speaking so it can respond immediately.
183
+
184
+ Without this, Gemini waits on its own VAD silence timeout after the last
185
+ audio chunk — which is why typed turns feel fast and spoken turns feel slow.
186
+ """
187
+ session = await self._connect()
188
+ await session.send_realtime_input(audio_stream_end=True)
189
+
190
+ async def send_text(self, text: str) -> None:
191
+ session = await self._connect()
192
+ from google.genai import types
193
+
194
+ await session.send_client_content(
195
+ turns=types.Content(role="user", parts=[types.Part(text=text)]),
196
+ turn_complete=True,
197
+ )
198
+
199
+ async def send_context(self, text: str) -> None:
200
+ """Inject tool result/context into the live session as a user turn.
201
+
202
+ Gemini Live does not have a dedicated "context" role; the closest equivalent is a
203
+ turn-complete client content message. This is semantically a user/system message,
204
+ which is acceptable because the harness already committed the actual tool result to
205
+ HistoryStore. The live session only needs the compact summary to continue.
206
+ """
207
+ session = await self._connect()
208
+ from google.genai import types
209
+
210
+ await session.send_client_content(
211
+ turns=types.Content(role="user", parts=[types.Part(text=text)]),
212
+ turn_complete=True,
213
+ )
214
+
215
+ async def send_tool_results(
216
+ self,
217
+ results: Sequence[tuple[str, str, dict[str, object], bool]],
218
+ ) -> None:
219
+ """Return tool results via Gemini Live's dedicated ``send_tool_response`` RPC.
220
+
221
+ Gemini Live will not continue the conversation until function responses are
222
+ returned for outstanding tool calls. Sending them as plain text via
223
+ ``send_context`` leaves the session hung waiting for tool responses.
224
+ """
225
+ if not results:
226
+ return
227
+ session = await self._connect()
228
+ from google.genai import types
229
+
230
+ # INTERRUPT tells Gemini to speak the follow-up immediately in the configured
231
+ # AUDIO modality. Default/WHEN_IDLE scheduling often yields text-only turns
232
+ # after tools, which is why the CLI showed transcripts with no audio.
233
+ function_responses = [
234
+ types.FunctionResponse(
235
+ id=call_id or None,
236
+ name=name,
237
+ response=payload if not is_error else {"error": payload.get("error", payload)},
238
+ scheduling=types.FunctionResponseScheduling.INTERRUPT,
239
+ )
240
+ for call_id, name, payload, is_error in results
241
+ ]
242
+ await session.send_tool_response(function_responses=function_responses)
243
+
244
+ async def interrupt(self) -> None:
245
+ """Gemini Live has no explicit interrupt RPC; interruption is implicit when the
246
+ client sends new audio/text. We send an empty client content turn-complete so the
247
+ server cancels any in-flight generation and emits ``interrupted=True``.
248
+ """
249
+ session = await self._connect()
250
+ try:
251
+ await session.send_client_content(
252
+ turns=[],
253
+ turn_complete=True,
254
+ )
255
+ except Exception as exc:
256
+ _log.warning("Gemini Live interrupt signal failed: %s", exc)
257
+
258
+ def events(self) -> AsyncIterator[RealtimeEvent]:
259
+ async def _gen() -> AsyncIterator[RealtimeEvent]:
260
+ session = await self._connect()
261
+ try:
262
+ # Gemini Live's receive() yields one assistant turn. Loop it so the session
263
+ # stays open across multiple user/assistant turns.
264
+ while True:
265
+ async for msg in session.receive():
266
+ async for event in self._map_message(msg):
267
+ yield event
268
+ except Exception as exc:
269
+ _log.exception("Gemini Live receive loop failed")
270
+ yield RealtimeError(error=f"Gemini Live receive loop failed: {exc}")
271
+
272
+ return _gen()
273
+
274
+ async def _map_message(self, msg: Any) -> AsyncIterator[RealtimeEvent]:
275
+ """Map one ``LiveServerMessage`` to zero or more harness ``RealtimeEvent``s."""
276
+ if getattr(msg, "error", None):
277
+ yield RealtimeError(error=str(msg.error))
278
+ return
279
+
280
+ turn_complete = False
281
+ server_content = getattr(msg, "server_content", None)
282
+ if server_content is not None:
283
+ interrupted = bool(getattr(server_content, "interrupted", False))
284
+ if interrupted:
285
+ yield RealtimeInterrupted()
286
+
287
+ input_transcription = getattr(server_content, "input_transcription", None)
288
+ if input_transcription:
289
+ text = getattr(input_transcription, "text", "")
290
+ if text:
291
+ yield RealtimePartialTranscript(text=text, is_final=True)
292
+
293
+ output_transcription = getattr(server_content, "output_transcription", None)
294
+ if output_transcription:
295
+ text = getattr(output_transcription, "text", "")
296
+ if text:
297
+ yield RealtimeTextDelta(text=text)
298
+
299
+ model_turn = getattr(server_content, "model_turn", None)
300
+ if model_turn:
301
+ parts = getattr(model_turn, "parts", []) or []
302
+ for part in parts:
303
+ if part is None:
304
+ continue
305
+ text = getattr(part, "text", None)
306
+ if text:
307
+ yield RealtimeTextDelta(text=text)
308
+ inline_data = getattr(part, "inline_data", None)
309
+ if inline_data is not None:
310
+ data = getattr(inline_data, "data", b"")
311
+ if data:
312
+ yield RealtimeAudioDelta(chunk=data)
313
+
314
+ turn_complete = bool(getattr(server_content, "turn_complete", False))
315
+
316
+ usage_metadata = getattr(msg, "usage_metadata", None)
317
+ if usage_metadata is not None:
318
+ yield RealtimeUsage(
319
+ input_tokens=int(
320
+ getattr(usage_metadata, "prompt_token_count", 0)
321
+ or getattr(usage_metadata, "input_token_count", 0)
322
+ or 0
323
+ ),
324
+ output_tokens=int(
325
+ getattr(usage_metadata, "candidates_token_count", 0)
326
+ or getattr(usage_metadata, "response_token_count", 0)
327
+ or getattr(usage_metadata, "output_token_count", 0)
328
+ or 0
329
+ ),
330
+ )
331
+
332
+ # Emit tool calls before any turn boundary so the gateway buffers them first.
333
+ had_tool_calls = False
334
+ tool_call = getattr(msg, "tool_call", None)
335
+ if tool_call is not None:
336
+ function_calls = getattr(tool_call, "function_calls", []) or []
337
+ for call in function_calls:
338
+ had_tool_calls = True
339
+ call_id = str(getattr(call, "id", "") or "")
340
+ name = str(getattr(call, "name", "") or "")
341
+ args_text = getattr(call, "args", None)
342
+ if args_text is None:
343
+ args_text = getattr(call, "function_call", None)
344
+ if isinstance(args_text, str):
345
+ try:
346
+ args = json.loads(args_text)
347
+ except json.JSONDecodeError:
348
+ yield RealtimeToolCall(
349
+ call_id=call_id,
350
+ name=name,
351
+ args={},
352
+ parse_error=f"Failed to parse tool args: {args_text}",
353
+ )
354
+ continue
355
+ elif isinstance(args_text, dict):
356
+ args = dict(args_text)
357
+ else:
358
+ args = {}
359
+ yield RealtimeToolCall(call_id=call_id, name=name, args=args)
360
+
361
+ # Gemini Live often emits tool calls without turn_complete. Synthesize an
362
+ # assistant turn boundary so the gateway dispatches tools immediately.
363
+ if turn_complete or had_tool_calls:
364
+ yield RealtimeTurnBoundary(role="assistant")
365
+ if turn_complete:
366
+ yield RealtimeDone()
367
+
368
+ async def close(self, *, reason: str = "session_end") -> None:
369
+ _ = reason
370
+ if self._session is None:
371
+ return
372
+ try:
373
+ if self._session_cm is not None:
374
+ await self._session_cm.__aexit__(None, None, None)
375
+ except Exception:
376
+ _log.exception("Gemini Live session close failed")
377
+ finally:
378
+ self._session = None
379
+ self._session_cm = None
380
+
381
+
382
+ class GeminiLiveProvider(RealtimeProvider):
383
+ """Gemini Live realtime provider factory."""
384
+
385
+ def __init__(self, api_key: str | None = None) -> None:
386
+ self._api_key = api_key
387
+
388
+ @property
389
+ def name(self) -> str:
390
+ return "gemini-live"
391
+
392
+ async def connect(self, *, config: RealtimeSessionConfig) -> RealtimeSession:
393
+ session = GeminiLiveSession(
394
+ config=config,
395
+ api_key=self._api_key,
396
+ )
397
+ await session._connect()
398
+ return session
@@ -0,0 +1,129 @@
1
+ """Hugging Face provider via the OpenAI-compatible Chat Completions API.
2
+
3
+ Default host is the HF inference router (``router.huggingface.co/hf-inference``).
4
+ Override with ``HF_BASE_URL`` for provider-specific routers (e.g. Cerebras) or
5
+ ``HF_ENDPOINT_URL`` for a dedicated Inference Endpoint.
6
+
7
+ Configuration (environment variables or ``monkeybot.yaml``):
8
+
9
+ - ``HF_TOKEN`` (**required**) — Hugging Face API token
10
+ - ``HF_ENDPOINT_URL`` — full base URL of a dedicated Inference Endpoint
11
+ (takes precedence over ``HF_BASE_URL`` and the default)
12
+ - ``HF_BASE_URL`` — OpenAI-compat base host (default:
13
+ ``https://router.huggingface.co/hf-inference``; append ``/v1`` when missing)
14
+ - ``MODEL_NAME`` — model id passed to the API (e.g. ``meta-llama/Llama-3.1-8B-Instruct``)
15
+ - ``MODEL_TEMPERATURE`` — sampling temperature (default: ``0.7``; set via ``monkeybot.yaml`` / constructor)
16
+ - ``MODEL_MAX_TOKENS`` — max output tokens (default: ``60000``; set via ``monkeybot.yaml`` / constructor)
17
+
18
+ Install the required extra::
19
+
20
+ uv sync --extra huggingface
21
+ # or: pip install "monkeybot[huggingface]"
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ from collections.abc import AsyncIterator, Sequence
28
+
29
+ from monkeybot.core.llm.provider import (
30
+ Message,
31
+ ProviderEvent,
32
+ )
33
+ from monkeybot.core.types.types_tools import ToolDef
34
+ from monkeybot.providers._openai_compat import (
35
+ count_input_tokens_tiktoken,
36
+ is_tool_unsupported_error,
37
+ stream_chat_completions_with_tool_fallback,
38
+ )
39
+ from monkeybot.providers.sampling import resolve_model_sampling
40
+
41
+ _DEFAULT_HOST = "https://router.huggingface.co/hf-inference"
42
+
43
+ # Re-exported for tests that historically imported this private name from here.
44
+ _is_tool_unsupported_error = is_tool_unsupported_error
45
+
46
+
47
+ class HuggingFaceProvider:
48
+ """Hugging Face models using the OpenAI-compatible Chat Completions endpoint.
49
+
50
+ Requires ``monkeybot[huggingface]`` (``openai`` + ``tiktoken``) and ``HF_TOKEN``.
51
+ """
52
+
53
+ @property
54
+ def name(self) -> str:
55
+ return "huggingface"
56
+
57
+ @property
58
+ def supports_streaming(self) -> bool:
59
+ return True
60
+
61
+ def __init__(
62
+ self,
63
+ *,
64
+ temperature: float | None = None,
65
+ max_tokens: int | None = None,
66
+ ) -> None:
67
+ token = os.environ.get("HF_TOKEN", "")
68
+ if not token:
69
+ raise ValueError(
70
+ "HF_TOKEN is not set. Create a token at https://huggingface.co/settings/tokens "
71
+ "and add it to your .env or environment."
72
+ )
73
+ self._token = token
74
+ self._endpoint_url = (os.environ.get("HF_ENDPOINT_URL") or "").rstrip("/")
75
+ self._host = (os.environ.get("HF_BASE_URL") or _DEFAULT_HOST).rstrip("/")
76
+ sampling = resolve_model_sampling(temperature=temperature, max_tokens=max_tokens)
77
+ self._temperature = sampling.temperature
78
+ self._max_tokens = sampling.max_tokens
79
+
80
+ def _resolve_base_url(self, model: str) -> str:
81
+ """Return the OpenAI-compat base URL for ``model``.
82
+
83
+ URL resolution priority:
84
+ 1. ``HF_ENDPOINT_URL`` — dedicated Inference Endpoint, used as-is
85
+ 2. ``HF_BASE_URL`` — when it already ends in ``/v1``, used as-is; else ``/v1`` is appended
86
+ 3. Default — ``router.huggingface.co/hf-inference/v1``
87
+
88
+ Router examples: ``HF_BASE_URL=https://router.huggingface.co/cerebras/v1``.
89
+ """
90
+ del model # model id is passed per request; endpoint URL is env-driven
91
+ if self._endpoint_url:
92
+ return self._endpoint_url
93
+ host = self._host
94
+ if host.endswith("/v1"):
95
+ return host
96
+ return f"{host}/v1"
97
+
98
+ async def count_input_tokens(
99
+ self,
100
+ messages: Sequence[Message],
101
+ tools: Sequence[ToolDef],
102
+ *,
103
+ model: str,
104
+ thinking_budget: int | None = None,
105
+ ) -> int:
106
+ return await count_input_tokens_tiktoken(
107
+ messages, tools, model=model, thinking_budget=thinking_budget
108
+ )
109
+
110
+ async def stream(
111
+ self,
112
+ messages: Sequence[Message],
113
+ tools: Sequence[ToolDef],
114
+ *,
115
+ model: str,
116
+ thinking_budget: int | None = None,
117
+ ) -> AsyncIterator[ProviderEvent]:
118
+ del thinking_budget
119
+ async for event in stream_chat_completions_with_tool_fallback(
120
+ base_url=self._resolve_base_url(model),
121
+ api_key=self._token,
122
+ provider="huggingface",
123
+ messages=messages,
124
+ tools=tools,
125
+ model=model,
126
+ temperature=self._temperature,
127
+ max_tokens=self._max_tokens,
128
+ ):
129
+ yield event
@@ -0,0 +1,104 @@
1
+ """NVIDIA NIM provider via the OpenAI-compatible Chat Completions API.
2
+
3
+ Hosted catalog at https://build.nvidia.com/models — free personal API key,
4
+ ~40 requests/minute. Same OpenAI-compat wire format as HuggingFace/Ollama.
5
+
6
+ Configuration (environment variables or ``monkeybot.yaml``):
7
+
8
+ - ``NVIDIA_API_KEY`` (**required**) — get one free at https://build.nvidia.com
9
+ (open any model card → "Get API Key"); starts with ``nvapi-``
10
+ - ``NVIDIA_BASE_URL`` — OpenAI-compat base host (default:
11
+ ``https://integrate.api.nvidia.com/v1``)
12
+ - ``MODEL_NAME`` — model id passed to the API (e.g. ``meta/llama-3.3-70b-instruct``)
13
+ - ``MODEL_TEMPERATURE`` — sampling temperature (default: ``0.7``; set via ``monkeybot.yaml`` / constructor)
14
+ - ``MODEL_MAX_TOKENS`` — max output tokens (default: ``60000``; set via ``monkeybot.yaml`` / constructor)
15
+
16
+ Install the required extra::
17
+
18
+ uv sync --extra nvidia
19
+ # or: pip install "monkeybot[nvidia]"
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ from collections.abc import AsyncIterator, Sequence
26
+
27
+ from monkeybot.core.llm.provider import (
28
+ Message,
29
+ ProviderEvent,
30
+ )
31
+ from monkeybot.core.types.types_tools import ToolDef
32
+ from monkeybot.providers._openai_compat import (
33
+ count_input_tokens_tiktoken,
34
+ stream_chat_completions_with_tool_fallback,
35
+ )
36
+ from monkeybot.providers.sampling import resolve_model_sampling
37
+
38
+ _DEFAULT_BASE_URL = "https://integrate.api.nvidia.com/v1"
39
+
40
+
41
+ class NvidiaProvider:
42
+ """NVIDIA-hosted models (build.nvidia.com) via the OpenAI-compatible endpoint.
43
+
44
+ Requires ``monkeybot[nvidia]`` (``openai`` + ``tiktoken``) and ``NVIDIA_API_KEY``.
45
+ """
46
+
47
+ @property
48
+ def name(self) -> str:
49
+ return "nvidia"
50
+
51
+ @property
52
+ def supports_streaming(self) -> bool:
53
+ return True
54
+
55
+ def __init__(
56
+ self,
57
+ *,
58
+ temperature: float | None = None,
59
+ max_tokens: int | None = None,
60
+ ) -> None:
61
+ api_key = os.environ.get("NVIDIA_API_KEY", "")
62
+ if not api_key:
63
+ raise ValueError(
64
+ "NVIDIA_API_KEY is not set. Get a free key at https://build.nvidia.com "
65
+ "(open any model card and click 'Get API Key') and add it to your .env."
66
+ )
67
+ self._api_key = api_key
68
+ self._base_url = (os.environ.get("NVIDIA_BASE_URL") or _DEFAULT_BASE_URL).rstrip("/")
69
+ sampling = resolve_model_sampling(temperature=temperature, max_tokens=max_tokens)
70
+ self._temperature = sampling.temperature
71
+ self._max_tokens = sampling.max_tokens
72
+
73
+ async def count_input_tokens(
74
+ self,
75
+ messages: Sequence[Message],
76
+ tools: Sequence[ToolDef],
77
+ *,
78
+ model: str,
79
+ thinking_budget: int | None = None,
80
+ ) -> int:
81
+ return await count_input_tokens_tiktoken(
82
+ messages, tools, model=model, thinking_budget=thinking_budget
83
+ )
84
+
85
+ async def stream(
86
+ self,
87
+ messages: Sequence[Message],
88
+ tools: Sequence[ToolDef],
89
+ *,
90
+ model: str,
91
+ thinking_budget: int | None = None,
92
+ ) -> AsyncIterator[ProviderEvent]:
93
+ del thinking_budget
94
+ async for event in stream_chat_completions_with_tool_fallback(
95
+ base_url=self._base_url,
96
+ api_key=self._api_key,
97
+ provider="nvidia",
98
+ messages=messages,
99
+ tools=tools,
100
+ model=model,
101
+ temperature=self._temperature,
102
+ max_tokens=self._max_tokens,
103
+ ):
104
+ yield event