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.
- monkeybot/__init__.py +3 -0
- monkeybot/cli/__init__.py +3 -0
- monkeybot/cli/__main__.py +8 -0
- monkeybot/cli/audio_io.py +8 -0
- monkeybot/cli/gateway_manager.py +17 -0
- monkeybot/cli/main.py +22 -0
- monkeybot/cli/push_to_talk.py +12 -0
- monkeybot/cli/realtime_client.py +13 -0
- monkeybot/core/__init__.py +19 -0
- monkeybot/core/attachments/__init__.py +22 -0
- monkeybot/core/attachments/catalog.py +62 -0
- monkeybot/core/attachments/config.py +52 -0
- monkeybot/core/attachments/freeze.py +158 -0
- monkeybot/core/attachments/resolve.py +70 -0
- monkeybot/core/attachments/store.py +180 -0
- monkeybot/core/attachments/text.py +72 -0
- monkeybot/core/attachments/tools.py +54 -0
- monkeybot/core/bootstrap.py +242 -0
- monkeybot/core/config/__init__.py +71 -0
- monkeybot/core/config/realtime_config.py +150 -0
- monkeybot/core/config/runtime_env.py +262 -0
- monkeybot/core/config/settings.py +341 -0
- monkeybot/core/config/validation.py +249 -0
- monkeybot/core/config/yaml_loader.py +45 -0
- monkeybot/core/context/__init__.py +781 -0
- monkeybot/core/context/campaign_context.py +8 -0
- monkeybot/core/context/common.py +14 -0
- monkeybot/core/context/curator.py +255 -0
- monkeybot/core/context/epoch.py +226 -0
- monkeybot/core/context/memory_prompt.py +222 -0
- monkeybot/core/context/tool_output_policy.py +270 -0
- monkeybot/core/context/tool_result_ingress.py +290 -0
- monkeybot/core/context/tool_shapers.py +361 -0
- monkeybot/core/hooks/__init__.py +261 -0
- monkeybot/core/llm/__init__.py +4 -0
- monkeybot/core/llm/provider.py +296 -0
- monkeybot/core/llm/realtime_provider.py +203 -0
- monkeybot/core/llm/usage.py +57 -0
- monkeybot/core/logging_utils.py +24 -0
- monkeybot/core/mcp/__init__.py +1 -0
- monkeybot/core/mcp/mcp_client.py +1215 -0
- monkeybot/core/mcp/ports_mcp.py +109 -0
- monkeybot/core/memory/__init__.py +24 -0
- monkeybot/core/memory/hook.py +413 -0
- monkeybot/core/memory/index_format.py +104 -0
- monkeybot/core/memory/integrity.py +180 -0
- monkeybot/core/memory/organizer.py +270 -0
- monkeybot/core/memory/storage_ops.py +139 -0
- monkeybot/core/memory/subsystem.py +91 -0
- monkeybot/core/messages/__init__.py +16 -0
- monkeybot/core/messages/convert_provider.py +41 -0
- monkeybot/core/messages/tool_integrity.py +262 -0
- monkeybot/core/messages/transform_context.py +84 -0
- monkeybot/core/path_safety.py +11 -0
- monkeybot/core/persistence/__init__.py +17 -0
- monkeybot/core/persistence/backends.py +236 -0
- monkeybot/core/persistence/db.py +28 -0
- monkeybot/core/persistence/durable_runs.py +286 -0
- monkeybot/core/persistence/firestore.py +658 -0
- monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
- monkeybot/core/persistence/history.py +156 -0
- monkeybot/core/persistence/postgres.py +895 -0
- monkeybot/core/persistence/runs.py +76 -0
- monkeybot/core/persistence/scheduled_loops.py +435 -0
- monkeybot/core/persistence/session_turn_locks.py +94 -0
- monkeybot/core/persistence/sqlite.py +218 -0
- monkeybot/core/persistence/sqlite_backend.py +74 -0
- monkeybot/core/persistence/thread_summary.py +61 -0
- monkeybot/core/persistence/transcript.py +194 -0
- monkeybot/core/persistence/usage.py +149 -0
- monkeybot/core/prompts/__init__.py +1 -0
- monkeybot/core/prompts/harness_prompt.py +197 -0
- monkeybot/core/prompts/prompt.py +215 -0
- monkeybot/core/runtime/__init__.py +1 -0
- monkeybot/core/runtime/context_budget.py +267 -0
- monkeybot/core/runtime/events.py +819 -0
- monkeybot/core/runtime/input_admission.py +154 -0
- monkeybot/core/runtime/loop.py +2374 -0
- monkeybot/core/runtime/provider_stream_mapper.py +159 -0
- monkeybot/core/runtime/realtime_loop.py +654 -0
- monkeybot/core/runtime/utterance_buffer.py +179 -0
- monkeybot/core/subagents/__init__.py +1 -0
- monkeybot/core/subagents/subagent_proto.py +331 -0
- monkeybot/core/subagents/subagent_worker.py +441 -0
- monkeybot/core/subagents/worker_pool.py +403 -0
- monkeybot/core/testing/__init__.py +1 -0
- monkeybot/core/testing/mocks_provider.py +86 -0
- monkeybot/core/testing/mocks_realtime_provider.py +137 -0
- monkeybot/core/tools/__init__.py +1 -0
- monkeybot/core/tools/core_tool_executor.py +1548 -0
- monkeybot/core/tools/inspector.py +226 -0
- monkeybot/core/tools/loop_inspector.py +45 -0
- monkeybot/core/tools/patch.py +480 -0
- monkeybot/core/tools/permission.py +284 -0
- monkeybot/core/tools/sandbox_executor.py +255 -0
- monkeybot/core/tools/spill_inventory.py +35 -0
- monkeybot/core/tools/terminal.py +381 -0
- monkeybot/core/tools/text_normalize.py +25 -0
- monkeybot/core/tools/types.py +33 -0
- monkeybot/core/tools/workspace_service.py +710 -0
- monkeybot/core/tools/workspace_tools.py +116 -0
- monkeybot/core/types/__init__.py +1 -0
- monkeybot/core/types/content_blocks.py +644 -0
- monkeybot/core/types/interfaces.py +156 -0
- monkeybot/core/types/types_tools.py +29 -0
- monkeybot/core/workspace/__init__.py +8 -0
- monkeybot/core/workspace/factory.py +45 -0
- monkeybot/core/workspace/gcs.py +130 -0
- monkeybot/core/workspace/local.py +162 -0
- monkeybot/core/workspace/protocol.py +45 -0
- monkeybot/core/workspace/s3.py +151 -0
- monkeybot/core/workspace_layout.py +27 -0
- monkeybot/gateway/__init__.py +1 -0
- monkeybot/gateway/bootstrap.py +18 -0
- monkeybot/gateway/main.py +47 -0
- monkeybot/gateway/realtime/__init__.py +31 -0
- monkeybot/gateway/realtime/app.py +321 -0
- monkeybot/gateway/realtime/deps.py +52 -0
- monkeybot/gateway/realtime/errors.py +81 -0
- monkeybot/gateway/realtime/guardrails.py +88 -0
- monkeybot/gateway/realtime/manager.py +77 -0
- monkeybot/gateway/realtime/metrics.py +144 -0
- monkeybot/gateway/realtime/routes.py +864 -0
- monkeybot/gateway/realtime/session.py +232 -0
- monkeybot/gateway/realtime/wire.py +412 -0
- monkeybot/gateway/realtime_main.py +49 -0
- monkeybot/gateway/sse/__init__.py +1 -0
- monkeybot/gateway/sse/app.py +733 -0
- monkeybot/gateway/sse/loop_port.py +31 -0
- monkeybot/gateway/sse/models.py +177 -0
- monkeybot/gateway/sse/reply_body.py +91 -0
- monkeybot/gateway/sse/routes.py +1101 -0
- monkeybot/gateway/sse/scheduler_routes.py +200 -0
- monkeybot/gateway/sse/scheduler_wiring.py +96 -0
- monkeybot/gateway/sse/session_bus.py +226 -0
- monkeybot/gateway/sse/sse.py +46 -0
- monkeybot/gateway/sse/workspace_layout.py +7 -0
- monkeybot/observability/__init__.py +220 -0
- monkeybot/observability/_state.py +10 -0
- monkeybot/observability/instrumentation.py +153 -0
- monkeybot/observability/propagation.py +65 -0
- monkeybot/observability/spans.py +455 -0
- monkeybot/providers/__init__.py +19 -0
- monkeybot/providers/_openai_compat.py +450 -0
- monkeybot/providers/_utils.py +473 -0
- monkeybot/providers/bedrock.py +145 -0
- monkeybot/providers/claude.py +125 -0
- monkeybot/providers/gemini.py +677 -0
- monkeybot/providers/gemini_live.py +398 -0
- monkeybot/providers/huggingface.py +129 -0
- monkeybot/providers/nvidia.py +104 -0
- monkeybot/providers/ollama.py +152 -0
- monkeybot/providers/openai.py +127 -0
- monkeybot/providers/pricing.py +60 -0
- monkeybot/providers/sampling.py +44 -0
- monkeybot/providers/vertex_claude.py +148 -0
- monkeybot/scaffold/__init__.py +33 -0
- monkeybot/scheduler/__init__.py +13 -0
- monkeybot/scheduler/__main__.py +4 -0
- monkeybot/scheduler/engine.py +333 -0
- monkeybot/scheduler/http_invoker.py +61 -0
- monkeybot/scheduler/interval.py +77 -0
- monkeybot/scheduler/tick_result.py +34 -0
- monkeybot/scheduler/worker.py +87 -0
- monkeybot/subagents/__init__.py +1 -0
- monkeybot/subagents/worker/__init__.py +1 -0
- monkeybot/subagents/worker/__main__.py +22 -0
- monkeybot/web_search/__init__.py +82 -0
- monkeybot/web_search/backends/__init__.py +5 -0
- monkeybot/web_search/backends/duckduckgo.py +32 -0
- monkeybot/web_search/backends/firecrawl.py +43 -0
- monkeybot/web_search/backends/tavily.py +45 -0
- monkeybot/web_search/protocol.py +25 -0
- monkeybot/web_search/tool.py +56 -0
- monkeybot-2.1.1.dist-info/METADATA +318 -0
- monkeybot-2.1.1.dist-info/RECORD +178 -0
- monkeybot-2.1.1.dist-info/WHEEL +4 -0
- 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
|