brrragent 0.4.2__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.
brrragent/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """
2
+ brrragent — Reusable agentic react loop with MCP tool calling.
3
+
4
+ Provides:
5
+ - McpToolCaller: Multi-server MCP bridge (OpenAI + Gemini formats)
6
+ - McpServerConfig: Streamable HTTP, SSE, stdio, or legacy JSON configuration
7
+ - run_agent: Smart-routed agentic loop (Gemini direct, OpenAI direct, Codex OAuth, OpenRouter)
8
+ - get_default_caller: Singleton McpToolCaller instance
9
+ - pick_key / pick_env_key: Handle comma-separated API keys
10
+ - KeyPool: Thread-safe key pool with 429 eviction (works for any provider)
11
+ - RateLimitExhausted: Exception when all keys are rate-limited
12
+
13
+ Backends (importable individually):
14
+ - brrragent.openrouter: OpenRouter/OpenAI-compatible backend
15
+ - brrragent.openai_direct: OpenAI native backend
16
+ - brrragent.codex_oauth: ChatGPT/Codex OAuth backend via isolated auth
17
+ - brrragent.gemini: Google Gemini direct backend
18
+ - brrragent.keys: API key utilities
19
+ """
20
+
21
+ from brrragent.agent import run_agent
22
+ from brrragent.codex_oauth import CodexAuthConfig, codex_auth_context
23
+ from brrragent.keys import KeyPool, RateLimitExhausted, pick_env_key, pick_key
24
+ from brrragent.mcp_tools import McpServerConfig, McpToolCaller, get_default_caller
25
+ from brrragent.prompt_cache import AgentUsage, PromptCacheConfig
26
+
27
+ __all__ = [
28
+ "AgentUsage",
29
+ "CodexAuthConfig",
30
+ "KeyPool",
31
+ "McpServerConfig",
32
+ "McpToolCaller",
33
+ "PromptCacheConfig",
34
+ "RateLimitExhausted",
35
+ "get_default_caller",
36
+ "codex_auth_context",
37
+ "pick_env_key",
38
+ "pick_key",
39
+ "run_agent",
40
+ ]
brrragent/_errors.py ADDED
@@ -0,0 +1,7 @@
1
+ def safe_error_summary(error: BaseException) -> str:
2
+ """Describe an error without copying provider-controlled response text."""
3
+ name = type(error).__name__
4
+ status = getattr(error, "status_code", None)
5
+ if isinstance(status, int):
6
+ return f"{name} (HTTP {status})"
7
+ return name
brrragent/agent.py ADDED
@@ -0,0 +1,385 @@
1
+ """
2
+ Agentic React Loop — Smart Router.
3
+
4
+ Automatically chooses between Google Gemini direct, OpenAI direct,
5
+ Codex OAuth, native provider APIs, and OpenRouter based on the model name and
6
+ available keys.
7
+
8
+ Routing logic:
9
+ - model starts with "google/" or "gemini-" + Gemini keys available → Gemini direct
10
+ - model starts with "fireworks/" + FIREWORKS_API_KEY → Fireworks native
11
+ - model starts with "minimax/" + MINIMAX_API_KEY → MiniMax native
12
+ - model starts with "yunwu/" + YUNWU_API_KEY → Yunwu native
13
+ - model starts with "codex/" → Codex OAuth
14
+ - model starts with "openai/" + BRRRAGENT_OPENAI_BACKEND=codex_oauth → Codex OAuth
15
+ - model starts with "openai/" + OpenAI keys available → OpenAI direct
16
+ - Otherwise → OpenRouter with OPENROUTER_API_KEY
17
+
18
+ Supports KeyPool for both backends: keys are acquired per-call and evicted
19
+ on 429 errors. If no pool is provided, falls back to env-var key picking.
20
+
21
+ Usage:
22
+ import os
23
+
24
+ from brrragent import McpToolCaller, run_agent, KeyPool
25
+
26
+ gemini_pool = KeyPool.from_csv("key1,key2,key3", name="gemini")
27
+ or_pool = KeyPool.from_csv("sk-or-1,sk-or-2", name="openrouter")
28
+
29
+ result = run_agent(
30
+ system_prompt="You are a helpful assistant.",
31
+ user_prompt="Complete the requested task.",
32
+ model="google/gemini-3.1-pro-preview",
33
+ gemini_key_pool=gemini_pool,
34
+ openrouter_key_pool=or_pool,
35
+ mcp=McpToolCaller(endpoint=os.environ["BRRRAGENT_MCP_URL"]),
36
+ )
37
+ """
38
+
39
+ import logging
40
+ import os
41
+ from collections.abc import Callable
42
+ from typing import TYPE_CHECKING
43
+
44
+ from brrragent.keys import KeyPool
45
+ from brrragent.mcp_tools import McpToolCaller
46
+ from brrragent.prompt_cache import AgentUsage, PromptCacheConfig
47
+
48
+ if TYPE_CHECKING:
49
+ from brrragent.codex_oauth import CodexAuthConfig
50
+
51
+ logger = logging.getLogger(__name__)
52
+
53
+ MAX_TOOL_TURNS = 12
54
+
55
+ # Native provider registry: prefix → (base_url, env_var_name)
56
+ # Models with these prefixes are routed directly to the provider's
57
+ # OpenAI-compatible API instead of going through OpenRouter.
58
+ NATIVE_PROVIDERS: dict[str, tuple[str, str]] = {
59
+ "fireworks/": ("https://api.fireworks.ai/inference/v1", "FIREWORKS_API_KEY"),
60
+ "minimax/": ("https://api.minimax.io/v1", "MINIMAX_API_KEY"),
61
+ "yunwu/": ("https://yunwu.ai/v1", "YUNWU_API_KEY"),
62
+ "z-ai/": (
63
+ os.environ.get("ZAI_BASE_URL", "https://api.z.ai/api/coding/paas/v4"),
64
+ "ZAI_API_KEY",
65
+ ),
66
+ }
67
+
68
+
69
+ def _is_google_model(model: str) -> bool:
70
+ """Check if a model identifier is a Google model."""
71
+ return model.startswith(("google/", "gemini-"))
72
+
73
+
74
+ def _is_openai_model(model: str) -> bool:
75
+ """Check if a model identifier should use OpenAI direct when keys exist."""
76
+ return model.startswith("openai/")
77
+
78
+
79
+ def _is_codex_model(model: str) -> bool:
80
+ return model.startswith("codex/")
81
+
82
+
83
+ def _use_codex_oauth_backend(model: str, api_key: str | None) -> bool:
84
+ if api_key is not None:
85
+ return False
86
+ if _is_codex_model(model):
87
+ return True
88
+ if not _is_openai_model(model):
89
+ return False
90
+ backend = os.environ.get("BRRRAGENT_OPENAI_BACKEND", "").strip().lower()
91
+ enabled = os.environ.get("BRRRAGENT_CODEX_OAUTH", "").strip().lower()
92
+ return backend in {"codex", "codex_oauth"} or enabled in {"1", "true", "yes", "on"}
93
+
94
+
95
+ def _get_gemini_model_name(model: str) -> str:
96
+ """Strip 'google/' prefix to get the bare Gemini model name."""
97
+ if model.startswith("google/"):
98
+ return model[len("google/") :]
99
+ return model
100
+
101
+
102
+ def _resolve_native_provider(model: str) -> tuple[str, str, str, str] | None:
103
+ """Check if the model matches a native provider prefix.
104
+
105
+ Returns (base_url, api_key, provider_name, bare_model) if a native route
106
+ is available, or None to fall through to OpenRouter.
107
+
108
+ The bare_model has the provider prefix stripped — native APIs expect the
109
+ model ID without it (e.g. "accounts/fireworks/routers/kimi-k2p5-turbo"
110
+ not "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo").
111
+ """
112
+ for prefix, (base_url, env_var) in NATIVE_PROVIDERS.items():
113
+ if model.startswith(prefix):
114
+ api_key = os.environ.get(env_var, "").strip()
115
+ if api_key:
116
+ provider_name = prefix.rstrip("/")
117
+ bare_model = model[len(prefix) :]
118
+ return base_url, api_key, provider_name, bare_model
119
+ logger.debug(
120
+ "[agent] Model %s matches %s but %s is not set — falling through to OpenRouter",
121
+ model,
122
+ prefix,
123
+ env_var,
124
+ )
125
+ return None
126
+
127
+
128
+ def _keys_from_envs(*env_names: str) -> list[str]:
129
+ keys: list[str] = []
130
+ seen: set[str] = set()
131
+ for env_name in env_names:
132
+ raw = os.environ.get(env_name, "")
133
+ for key in raw.split(","):
134
+ key = key.strip()
135
+ if key and key not in seen:
136
+ keys.append(key)
137
+ seen.add(key)
138
+ return keys
139
+
140
+
141
+ def run_agent(
142
+ *,
143
+ system_prompt: str,
144
+ user_prompt: str,
145
+ model: str = "google/gemini-3.1-pro-preview",
146
+ api_key: str | None = None,
147
+ gemini_key_pool: KeyPool | None = None,
148
+ openai_key_pool: KeyPool | None = None,
149
+ openrouter_key_pool: KeyPool | None = None,
150
+ base_url: str = "https://openrouter.ai/api/v1",
151
+ mcp: McpToolCaller | None = None,
152
+ extra_tools: list[dict] | None = None,
153
+ max_turns: int = MAX_TOOL_TURNS,
154
+ temperature: float = 0.7,
155
+ max_tokens: int = 4096,
156
+ max_retries: int = 3,
157
+ on_tool_call: Callable | None = None,
158
+ response_schema: dict | None = None,
159
+ prompt_cache: PromptCacheConfig | None = None,
160
+ on_usage: Callable[[AgentUsage], None] | None = None,
161
+ codex_auth: "CodexAuthConfig | None" = None,
162
+ ) -> str:
163
+ """
164
+ Run an agentic react loop with tool calling.
165
+
166
+ Smart routing:
167
+ - If model starts with "google/" and Gemini keys are available → Gemini direct
168
+ - If model starts with "codex/" → Codex OAuth
169
+ - If model starts with "openai/" and BRRRAGENT_OPENAI_BACKEND=codex_oauth → Codex OAuth
170
+ - If model starts with "openai/" and OpenAI keys are available → OpenAI direct
171
+ - If model starts with a native provider prefix (fireworks/, minimax/, yunwu/) and
172
+ the corresponding env var is set → provider's native API
173
+ - Otherwise → OpenRouter with OPENROUTER_API_KEY
174
+
175
+ Key pools (preferred) or env vars can provide the keys.
176
+ On 429, pools evict the bad key and rotate to a fresh one.
177
+ RateLimitExhausted propagates up if ALL keys are exhausted.
178
+
179
+ Args:
180
+ system_prompt: System instructions for the agent.
181
+ user_prompt: The user's request / task.
182
+ model: Model identifier (e.g. "google/gemini-3.1-pro-preview").
183
+ api_key: Override API key (skips smart routing, legacy).
184
+ gemini_key_pool: KeyPool for Gemini keys (preferred over env var).
185
+ openai_key_pool: KeyPool for direct OpenAI keys (preferred over env var).
186
+ openrouter_key_pool: KeyPool for OpenRouter keys (preferred over env var).
187
+ base_url: API base URL (only used for OpenRouter path).
188
+ mcp: McpToolCaller instance. Defaults to the shared singleton.
189
+ extra_tools: Additional tool definitions (OpenAI format).
190
+ max_turns: Maximum number of tool-calling rounds.
191
+ temperature: Sampling temperature.
192
+ max_tokens: Max tokens per response.
193
+ max_retries: Maximum attempts per API call on transient errors. Provider
194
+ SDK retries are disabled so this budget is authoritative.
195
+ on_tool_call: Optional callback(tool_name, tool_args) for logging/UI.
196
+ response_schema: JSON Schema dict for structured output (optional).
197
+ prompt_cache: Stable cache key and requested TTL for this prompt version.
198
+ on_usage: Optional callback invoked with provider token and cache usage.
199
+ codex_auth: Per-call Codex credential profile or explicit file paths.
200
+
201
+ Returns:
202
+ The model's final text response.
203
+
204
+ Raises:
205
+ ValueError: If all retries are exhausted without a response.
206
+ RateLimitExhausted: If all keys in a pool hit 429.
207
+ """
208
+ if mcp is None:
209
+ from brrragent.mcp_tools import get_default_caller
210
+
211
+ mcp = get_default_caller()
212
+
213
+ # Build pools from env vars if not explicitly provided
214
+ if gemini_key_pool is None:
215
+ gemini_csv = os.environ.get("GEMINI_API_KEY", "")
216
+ if gemini_csv.strip():
217
+ gemini_key_pool = KeyPool.from_csv(gemini_csv, name="gemini")
218
+
219
+ if openrouter_key_pool is None:
220
+ or_csv = os.environ.get("OPENROUTER_API_KEY", "")
221
+ if or_csv.strip():
222
+ openrouter_key_pool = KeyPool.from_csv(or_csv, name="openrouter")
223
+
224
+ if openai_key_pool is None:
225
+ openai_keys = _keys_from_envs(
226
+ "OPENAI_API_KEY_PERSONAL",
227
+ "OPENAI_API_KEY",
228
+ "OPENAI_API_KEY_CORP",
229
+ )
230
+ if openai_keys:
231
+ openai_key_pool = KeyPool(openai_keys, name="openai")
232
+
233
+ # Smart routing: decide which backend to use
234
+ use_gemini_direct = (
235
+ _is_google_model(model) and gemini_key_pool is not None and api_key is None
236
+ )
237
+ use_openai_direct = (
238
+ _is_openai_model(model) and openai_key_pool is not None and api_key is None
239
+ )
240
+ use_codex_oauth = _use_codex_oauth_backend(model, api_key)
241
+
242
+ if use_gemini_direct:
243
+ from brrragent.gemini import run_gemini_agent
244
+
245
+ bare_model = _get_gemini_model_name(model)
246
+ logger.info(
247
+ "[agent] Using Gemini direct (model=%s, pool=%s)",
248
+ bare_model,
249
+ gemini_key_pool.status(),
250
+ )
251
+ return run_gemini_agent(
252
+ system_prompt=system_prompt,
253
+ user_prompt=user_prompt,
254
+ model=bare_model,
255
+ key_pool=gemini_key_pool,
256
+ mcp=mcp,
257
+ max_turns=max_turns,
258
+ temperature=temperature,
259
+ max_tokens=max_tokens,
260
+ max_retries=max_retries,
261
+ on_tool_call=on_tool_call,
262
+ response_schema=response_schema,
263
+ prompt_cache=prompt_cache,
264
+ on_usage=on_usage,
265
+ )
266
+
267
+ if use_codex_oauth:
268
+ from brrragent.codex_oauth import codex_auth_context, run_codex_oauth_agent
269
+
270
+ logger.info("[agent] Using Codex OAuth (model=%s)", model)
271
+ kwargs = {
272
+ "system_prompt": system_prompt,
273
+ "user_prompt": user_prompt,
274
+ "model": model,
275
+ "mcp": mcp,
276
+ "extra_tools": extra_tools,
277
+ "max_turns": max_turns,
278
+ "temperature": temperature,
279
+ "max_tokens": max_tokens,
280
+ "max_retries": max_retries,
281
+ "on_tool_call": on_tool_call,
282
+ "response_schema": response_schema,
283
+ "prompt_cache": prompt_cache,
284
+ "on_usage": on_usage,
285
+ }
286
+ if codex_auth is None:
287
+ return run_codex_oauth_agent(**kwargs)
288
+ with codex_auth_context(codex_auth):
289
+ return run_codex_oauth_agent(**kwargs)
290
+
291
+ if use_openai_direct:
292
+ from brrragent.openai_direct import run_openai_agent
293
+
294
+ logger.info(
295
+ "[agent] Using OpenAI direct (model=%s, pool=%s)",
296
+ model,
297
+ openai_key_pool.status(),
298
+ )
299
+ return run_openai_agent(
300
+ system_prompt=system_prompt,
301
+ user_prompt=user_prompt,
302
+ model=model,
303
+ key_pool=openai_key_pool,
304
+ mcp=mcp,
305
+ extra_tools=extra_tools,
306
+ max_turns=max_turns,
307
+ temperature=temperature,
308
+ max_tokens=max_tokens,
309
+ max_retries=max_retries,
310
+ on_tool_call=on_tool_call,
311
+ response_schema=response_schema,
312
+ prompt_cache=prompt_cache,
313
+ on_usage=on_usage,
314
+ )
315
+
316
+ # Check for native provider routing (fireworks/, minimax/, etc.)
317
+ native = _resolve_native_provider(model) if api_key is None else None
318
+
319
+ if native is not None:
320
+ from brrragent.openai_direct import parse_openai_model
321
+ from brrragent.openrouter import run_openrouter_agent
322
+
323
+ native_base_url, native_api_key, provider_name, bare_model = native
324
+ reasoning_effort = None
325
+ if provider_name in {"yunwu", "z-ai"}:
326
+ bare_model, reasoning_effort = parse_openai_model(bare_model)
327
+ if provider_name == "z-ai" and not reasoning_effort:
328
+ reasoning_effort = (
329
+ os.environ.get("ZAI_REASONING_EFFORT", "xhigh").strip() or "xhigh"
330
+ )
331
+ logger.info("[agent] Using %s native (model=%s)", provider_name, bare_model)
332
+ return run_openrouter_agent(
333
+ system_prompt=system_prompt,
334
+ user_prompt=user_prompt,
335
+ model=bare_model,
336
+ api_key=native_api_key,
337
+ key_pool=None,
338
+ base_url=native_base_url,
339
+ mcp=mcp,
340
+ extra_tools=extra_tools,
341
+ max_turns=max_turns,
342
+ temperature=temperature,
343
+ max_tokens=max_tokens,
344
+ max_retries=max_retries,
345
+ on_tool_call=on_tool_call,
346
+ response_schema=response_schema,
347
+ reasoning_effort=reasoning_effort,
348
+ prompt_cache=None,
349
+ on_usage=on_usage,
350
+ )
351
+
352
+ # Fallback: OpenRouter
353
+ from brrragent.openrouter import run_openrouter_agent
354
+
355
+ effective_key = api_key
356
+ effective_pool = None if api_key else openrouter_key_pool
357
+
358
+ if effective_key is None and effective_pool is None:
359
+ raise ValueError(
360
+ "No API key: set GEMINI_API_KEY or OPENROUTER_API_KEY, or provide a key pool"
361
+ )
362
+
363
+ logger.info(
364
+ "[agent] Using OpenRouter (model=%s, pool=%s)",
365
+ model,
366
+ effective_pool.status() if effective_pool else "explicit-key",
367
+ )
368
+ return run_openrouter_agent(
369
+ system_prompt=system_prompt,
370
+ user_prompt=user_prompt,
371
+ model=model,
372
+ api_key=effective_key,
373
+ key_pool=effective_pool,
374
+ base_url=base_url,
375
+ mcp=mcp,
376
+ extra_tools=extra_tools,
377
+ max_turns=max_turns,
378
+ temperature=temperature,
379
+ max_tokens=max_tokens,
380
+ max_retries=max_retries,
381
+ on_tool_call=on_tool_call,
382
+ response_schema=response_schema,
383
+ prompt_cache=prompt_cache,
384
+ on_usage=on_usage,
385
+ )