gemcode 0.3.28__tar.gz → 0.3.30__tar.gz
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.
- {gemcode-0.3.28/src/gemcode.egg-info → gemcode-0.3.30}/PKG-INFO +1 -1
- {gemcode-0.3.28 → gemcode-0.3.30}/pyproject.toml +1 -1
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/agent.py +116 -4
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/callbacks.py +68 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/config.py +31 -0
- gemcode-0.3.30/src/gemcode/hooks.py +194 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/modality_tools.py +15 -1
- gemcode-0.3.30/src/gemcode/pricing.py +76 -0
- gemcode-0.3.30/src/gemcode/refine.py +204 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/repl_commands.py +29 -1
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/repl_slash.py +85 -2
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/session_runtime.py +24 -1
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tool_prompt_manifest.py +3 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tui/input_handler.py +20 -1
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tui/scrollback.py +44 -1
- {gemcode-0.3.28 → gemcode-0.3.30/src/gemcode.egg-info}/PKG-INFO +1 -1
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode.egg-info/SOURCES.txt +3 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/LICENSE +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/MANIFEST.in +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/README.md +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/setup.cfg +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/__init__.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/__main__.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/audit.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/autocompact.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/capability_routing.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/cli.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/compaction.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/computer_use/__init__.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/computer_use/browser_computer.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/context_budget.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/context_warning.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/credentials.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/hitl_session.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/interactions.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/invoke.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/kairos_daemon.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/limits.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/live_audio_engine.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/logging_config.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/mcp_loader.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/memory/__init__.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/memory/embedding_memory_service.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/memory/file_memory_service.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/model_errors.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/model_routing.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/paths.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/permissions.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/plugins/__init__.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/plugins/terminal_hooks_plugin.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/plugins/tool_recovery_plugin.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/prompt_suggestions.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/query/__init__.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/query/config.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/query/deps.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/query/engine.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/query/stop_hooks.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/query/token_budget.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/query/transitions.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/slash_commands.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/thinking.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tool_registry.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/__init__.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/bash.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/browser.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/edit.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/filesystem.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/search.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/shell.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/shell_gate.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/subtask.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/think.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/todo.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools/web.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tools_inspector.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/trust.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tui/spinner.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tui/welcome_banner.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/tui/welcome_rich.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/version.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/vertex.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/web/__init__.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/web/claude_sse_adapter.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/web/terminal_repl.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode/workspace_hints.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode.egg-info/dependency_links.txt +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode.egg-info/entry_points.txt +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode.egg-info/requires.txt +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/src/gemcode.egg-info/top_level.txt +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_agent_instruction.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_autocompact.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_capability_routing.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_claude_web_adapter_sse.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_cli_init.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_computer_use_permissions.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_context_budget.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_context_warning.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_credentials.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_interactive_permission_ask.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_kairos_scheduler.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_modality_tools.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_model_error_retry.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_model_errors.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_model_routing.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_paths.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_permissions.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_prompt_suggestions.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_repl_commands.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_repl_slash.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_slash_commands.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_thinking_config.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_token_budget.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_tool_context_circulation.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_tools.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_tools_inspector.py +0 -0
- {gemcode-0.3.28 → gemcode-0.3.30}/tests/test_workspace_hints.py +0 -0
|
@@ -66,6 +66,8 @@ def _build_runtime_facts(cfg: GemCodeConfig) -> str:
|
|
|
66
66
|
|
|
67
67
|
# ── Active capabilities ──────────────────────────────────────────────────
|
|
68
68
|
caps: list[str] = []
|
|
69
|
+
if getattr(cfg, "enable_web_search", False) and not getattr(cfg, "enable_deep_research", False):
|
|
70
|
+
caps.append("web_search ON (tool: google_search — standalone search without full deep_research)")
|
|
69
71
|
if getattr(cfg, "enable_deep_research", False):
|
|
70
72
|
dr_extras = " + google_maps_grounding" if getattr(cfg, "enable_maps_grounding", False) else ""
|
|
71
73
|
caps.append(f"deep_research ON (tools: google_search, url_context{dr_extras})")
|
|
@@ -77,8 +79,12 @@ def _build_runtime_facts(cfg: GemCodeConfig) -> str:
|
|
|
77
79
|
caps.append(f"memory ON ({mem_kind}, stored at {mem_path}; ADK preload_memory injects relevant memories before each turn)")
|
|
78
80
|
if getattr(cfg, "enable_computer_use", False):
|
|
79
81
|
caps.append("computer_use ON (tools: navigate, click_at, type_text_at, browser_screenshot, browser_find_element, etc.)")
|
|
82
|
+
if getattr(cfg, "enable_code_executor", False):
|
|
83
|
+
caps.append("code_executor ON — you can write Python code blocks and they will be executed safely via Gemini's built-in sandboxed executor; results appear as code_execution_result events. Use this for math, data processing, quick tests, and anything that would otherwise require a shell command.")
|
|
84
|
+
if getattr(cfg, "enable_artifacts", True):
|
|
85
|
+
caps.append("artifacts ON — use save_artifact(filename, bytes, mime_type) / load_artifact(filename) to store large/binary outputs (screenshots, PDFs, generated files) outside session history. Artifacts are keyed by filename; prefix 'user:' for cross-session persistence.")
|
|
80
86
|
if not caps:
|
|
81
|
-
caps.append("none enabled (use /research on, /embeddings on, /memory on, /computer on to enable)")
|
|
87
|
+
caps.append("none enabled (use /research on, /embeddings on, /memory on, /computer on, /code on to enable)")
|
|
82
88
|
caps_text = "\n".join(f" - {c}" for c in caps)
|
|
83
89
|
|
|
84
90
|
# ── Limits ───────────────────────────────────────────────────────────────
|
|
@@ -147,6 +153,40 @@ Memory is **ON** ({kind}). Stored at: `{mem_path}`
|
|
|
147
153
|
"""
|
|
148
154
|
|
|
149
155
|
|
|
156
|
+
def _build_plan_mode_section() -> str:
|
|
157
|
+
"""Injected when plan_mode=True — instructs agent to write explicit plans first."""
|
|
158
|
+
return """
|
|
159
|
+
## PLAN MODE IS ACTIVE
|
|
160
|
+
|
|
161
|
+
You are currently in **Plan Mode**. Before executing ANY tools that modify files or run shell commands, you MUST:
|
|
162
|
+
|
|
163
|
+
1. **Write a numbered plan** in your response text — list every step you intend to take.
|
|
164
|
+
Example:
|
|
165
|
+
```
|
|
166
|
+
Plan:
|
|
167
|
+
1. Read src/auth/login.ts to understand the current flow
|
|
168
|
+
2. Read src/types/user.ts for the User interface
|
|
169
|
+
3. Add `lastLogin: Date` field to User interface
|
|
170
|
+
4. Update login handler to set lastLogin on successful auth
|
|
171
|
+
5. Run `npm run build` to verify no TypeScript errors
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
2. **Pause after the plan** — do not immediately execute tools. Present the plan and wait for the user to confirm ("go", "proceed", "looks good") before starting.
|
|
175
|
+
|
|
176
|
+
3. **Stick to the plan** — if you discover the plan needs changing mid-execution, note the update before proceeding.
|
|
177
|
+
|
|
178
|
+
4. **Report completion** against the plan — when done, confirm each step as completed.
|
|
179
|
+
|
|
180
|
+
**Why plan mode?**
|
|
181
|
+
- Prevents unintended side effects from premature tool execution
|
|
182
|
+
- Gives you and the user visibility into the full scope before any changes
|
|
183
|
+
- Makes complex multi-file tasks reviewable and reversible
|
|
184
|
+
- Catches scope creep early
|
|
185
|
+
|
|
186
|
+
**To turn off plan mode**, type `/plan off` at the prompt.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
|
|
150
190
|
def _build_computer_use_section(cfg: GemCodeConfig) -> str:
|
|
151
191
|
"""Rich computer use guidance, only injected when enable_computer_use=True."""
|
|
152
192
|
w = getattr(cfg, "_cfg", None)
|
|
@@ -426,6 +466,41 @@ Use `run_subtask` when the work is better done in an isolated context:
|
|
|
426
466
|
|
|
427
467
|
The sub-agent inherits your permission settings and returns its final text as `result`. Treat it as a trusted colleague returning a written summary.
|
|
428
468
|
|
|
469
|
+
## ADK Special Tools (always available when ADK supports them)
|
|
470
|
+
|
|
471
|
+
### `get_user_choice`
|
|
472
|
+
Present the user with a structured multi-option prompt rather than open-ended questions.
|
|
473
|
+
Use when you need the user to pick from 2–6 specific options (e.g. "Which framework would you like?", "Choose migration strategy: A, B, or C").
|
|
474
|
+
This provides a better UX than asking them to type a free-form answer.
|
|
475
|
+
|
|
476
|
+
### `load_artifacts`
|
|
477
|
+
Load binary/large artifacts that were saved in a previous turn or by a sub-agent.
|
|
478
|
+
Artifacts are keyed by filename (e.g. "report.pdf", "screenshot.png", "output.json").
|
|
479
|
+
Use `user:filename` prefix for user-scoped artifacts that persist across sessions.
|
|
480
|
+
After loading, the artifact bytes are available for further processing (display, analysis, transformation).
|
|
481
|
+
|
|
482
|
+
### `exit_loop`
|
|
483
|
+
Signal the surrounding LoopAgent to stop iterating and return the final result.
|
|
484
|
+
Only meaningful when this agent is running inside an ADK LoopAgent pipeline.
|
|
485
|
+
Call this when the task is complete and no further iterations are needed.
|
|
486
|
+
|
|
487
|
+
## Artifacts — storing large outputs
|
|
488
|
+
When `artifacts ON` (see Runtime facts above):
|
|
489
|
+
- **Save** large generated content as artifacts instead of printing them inline:
|
|
490
|
+
- Screenshots from computer_use: save as "screenshot.png" artifact
|
|
491
|
+
- Generated reports/PDFs: save as "report.pdf" artifact
|
|
492
|
+
- Large JSON data: save as "data.json" artifact
|
|
493
|
+
- **Reference** artifacts in instructions via `{artifact.filename?}` template syntax
|
|
494
|
+
- Artifacts are keyed by filename; `user:` prefix = cross-session persistence
|
|
495
|
+
|
|
496
|
+
## Code Executor (sandboxed Python)
|
|
497
|
+
When `code_executor ON` (see Runtime facts above):
|
|
498
|
+
- You can write Python code blocks in your response and the Gemini API executes them safely
|
|
499
|
+
- The result appears as a `code_execution_result` event with stdout and the outcome
|
|
500
|
+
- Best for: math calculations, data transformation, unit testing logic, quick experiments
|
|
501
|
+
- The sandbox does NOT have internet access or filesystem access — use for pure computation
|
|
502
|
+
- For file I/O or shell commands, use the standard tools (`bash`, `write_file`, etc.)
|
|
503
|
+
|
|
429
504
|
## Evaluator-optimizer loop
|
|
430
505
|
For tasks where quality matters:
|
|
431
506
|
1. Complete the task (execute tools, write code, run commands)
|
|
@@ -461,6 +536,9 @@ All file tools use paths **relative to the project root** (where GemCode was sta
|
|
|
461
536
|
if getattr(cfg, "enable_memory", False):
|
|
462
537
|
base = f"{base}\n\n{_build_memory_section(cfg)}"
|
|
463
538
|
|
|
539
|
+
if getattr(cfg, "plan_mode", False):
|
|
540
|
+
base = f"{base}\n\n{_build_plan_mode_section()}"
|
|
541
|
+
|
|
464
542
|
tool_manifest = build_tool_manifest(cfg)
|
|
465
543
|
if tool_manifest:
|
|
466
544
|
base = f"{base}\n\n{tool_manifest}"
|
|
@@ -470,6 +548,17 @@ All file tools use paths **relative to the project root** (where GemCode was sta
|
|
|
470
548
|
return base
|
|
471
549
|
|
|
472
550
|
|
|
551
|
+
def _build_code_executor(cfg: GemCodeConfig):
|
|
552
|
+
"""Return an ADK BuiltInCodeExecutor when enable_code_executor=True, else None."""
|
|
553
|
+
if not getattr(cfg, "enable_code_executor", False):
|
|
554
|
+
return None
|
|
555
|
+
try:
|
|
556
|
+
from google.adk.code_executors import BuiltInCodeExecutor
|
|
557
|
+
return BuiltInCodeExecutor()
|
|
558
|
+
except Exception:
|
|
559
|
+
return None
|
|
560
|
+
|
|
561
|
+
|
|
473
562
|
def build_root_agent(
|
|
474
563
|
cfg: GemCodeConfig,
|
|
475
564
|
extra_tools: list | None = None,
|
|
@@ -488,12 +577,19 @@ def build_root_agent(
|
|
|
488
577
|
if _tools is not None:
|
|
489
578
|
tools = list(_tools)
|
|
490
579
|
else:
|
|
491
|
-
|
|
580
|
+
tools = build_function_tools(cfg)
|
|
492
581
|
if getattr(cfg, "enable_memory", False):
|
|
493
582
|
# ADK preload_memory injects retrieved memories into the next llm_request.
|
|
494
583
|
from google.adk.tools import preload_memory
|
|
495
|
-
|
|
496
584
|
tools = [preload_memory, *tools]
|
|
585
|
+
|
|
586
|
+
# ADK built-in interactive + artifact tools — always available when ADK supports them.
|
|
587
|
+
try:
|
|
588
|
+
from google.adk.tools import get_user_choice, load_artifacts, exit_loop
|
|
589
|
+
tools = [*tools, get_user_choice, load_artifacts, exit_loop]
|
|
590
|
+
except Exception:
|
|
591
|
+
pass
|
|
592
|
+
|
|
497
593
|
if extra_tools:
|
|
498
594
|
tools = [*tools, *extra_tools]
|
|
499
595
|
|
|
@@ -548,15 +644,31 @@ def build_root_agent(
|
|
|
548
644
|
tool_config=tool_cfg,
|
|
549
645
|
)
|
|
550
646
|
|
|
551
|
-
|
|
647
|
+
# global_instruction applies to the entire agent tree (including sub-agents
|
|
648
|
+
# spawned via run_subtask or multi-agent delegation). Keep it short — it's
|
|
649
|
+
# prepended to every agent's effective instruction.
|
|
650
|
+
global_instr = (
|
|
651
|
+
"You are GemCode, an expert software engineering agent powered by Google Gemini. "
|
|
652
|
+
"Act, don't advise. Complete tasks fully and autonomously. "
|
|
653
|
+
"Think before destructive actions. Use read-only tools before shell/write tools."
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
agent_kwargs: dict = dict(
|
|
552
657
|
model=cfg.model,
|
|
553
658
|
name="gemcode",
|
|
554
659
|
instruction=build_instruction(cfg),
|
|
660
|
+
global_instruction=global_instr,
|
|
555
661
|
tools=tools,
|
|
556
662
|
generate_content_config=gen_cfg,
|
|
557
663
|
**cb_kwargs,
|
|
558
664
|
)
|
|
559
665
|
|
|
666
|
+
code_executor = _build_code_executor(cfg)
|
|
667
|
+
if code_executor is not None:
|
|
668
|
+
agent_kwargs["code_executor"] = code_executor
|
|
669
|
+
|
|
670
|
+
return LlmAgent(**agent_kwargs)
|
|
671
|
+
|
|
560
672
|
|
|
561
673
|
def create_runner(cfg: GemCodeConfig, extra_tools: list | None = None):
|
|
562
674
|
"""Backward-compatible: prefer `gemcode.session_runtime.create_runner`."""
|
|
@@ -141,6 +141,22 @@ def make_before_tool_callback(cfg: GemCodeConfig):
|
|
|
141
141
|
record = {"tool": name, "args": _redact_args(name, args)}
|
|
142
142
|
append_audit(cfg.project_root, record)
|
|
143
143
|
|
|
144
|
+
# ── Shell hooks: pre_tool_use ─────────────────────────────────────────
|
|
145
|
+
# If the project has a .gemcode/hooks/pre_tool_use.sh, run it now.
|
|
146
|
+
# Non-zero exit or {"decision":"deny"} stdout will block the tool call.
|
|
147
|
+
try:
|
|
148
|
+
from gemcode.hooks import run_pre_tool_use_hook
|
|
149
|
+
hook_result = run_pre_tool_use_hook(
|
|
150
|
+
cfg.project_root,
|
|
151
|
+
model=getattr(cfg, "model", "") or "",
|
|
152
|
+
tool_name=name,
|
|
153
|
+
args=args or {},
|
|
154
|
+
)
|
|
155
|
+
if hook_result is not None:
|
|
156
|
+
return hook_result
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
|
|
144
160
|
streak = 0
|
|
145
161
|
if tool_context is not None:
|
|
146
162
|
try:
|
|
@@ -316,6 +332,19 @@ def make_after_tool_callback(cfg: GemCodeConfig):
|
|
|
316
332
|
st[_STATE_FAILURE_KEY] = 0
|
|
317
333
|
else:
|
|
318
334
|
st[_STATE_FAILURE_KEY] = 0
|
|
335
|
+
# ── Shell hooks: post_tool_use ────────────────────────────────────────
|
|
336
|
+
try:
|
|
337
|
+
from gemcode.hooks import run_post_tool_use_hook
|
|
338
|
+
run_post_tool_use_hook(
|
|
339
|
+
cfg.project_root,
|
|
340
|
+
model=getattr(cfg, "model", "") or "",
|
|
341
|
+
tool_name=name,
|
|
342
|
+
args=args or {},
|
|
343
|
+
result=tool_response if isinstance(tool_response, dict) else {},
|
|
344
|
+
)
|
|
345
|
+
except Exception:
|
|
346
|
+
pass
|
|
347
|
+
|
|
319
348
|
if _maybe_tool_summary_enabled():
|
|
320
349
|
summary: dict[str, Any] = {
|
|
321
350
|
"phase": "tool_result",
|
|
@@ -399,6 +428,7 @@ def make_after_model_callback(cfg: GemCodeConfig):
|
|
|
399
428
|
"candidates_token_count",
|
|
400
429
|
"cached_content_token_count",
|
|
401
430
|
"total_token_count",
|
|
431
|
+
"thoughts_token_count",
|
|
402
432
|
):
|
|
403
433
|
if hasattr(um, attr):
|
|
404
434
|
v = getattr(um, attr)
|
|
@@ -407,6 +437,44 @@ def make_after_model_callback(cfg: GemCodeConfig):
|
|
|
407
437
|
if d:
|
|
408
438
|
append_audit(cfg.project_root, {"phase": "model_usage", **d})
|
|
409
439
|
|
|
440
|
+
# ── Expose live token stats to the TUI ───────────────────────────────────
|
|
441
|
+
# The TUI reads cfg._last_turn_stats after each turn to display token counts
|
|
442
|
+
# and estimated cost in the footer (like OpenClaude's spinner token display).
|
|
443
|
+
try:
|
|
444
|
+
in_tok = d.get("prompt_token_count", 0) or 0
|
|
445
|
+
out_tok = d.get("candidates_token_count", 0) or 0
|
|
446
|
+
think_tok = d.get("thoughts_token_count", 0) or 0
|
|
447
|
+
cache_tok = d.get("cached_content_token_count", 0) or 0
|
|
448
|
+
total_tok = d.get("total_token_count", 0) or 0
|
|
449
|
+
|
|
450
|
+
prev_session_tokens = int(st.get(SESSION_TOTAL_TOKENS_KEY, 0) or 0)
|
|
451
|
+
session_total = prev_session_tokens + total_tok
|
|
452
|
+
|
|
453
|
+
from gemcode.pricing import estimate_cost
|
|
454
|
+
turn_cost = estimate_cost(
|
|
455
|
+
getattr(cfg, "model", "") or "",
|
|
456
|
+
input_tokens=in_tok,
|
|
457
|
+
output_tokens=out_tok,
|
|
458
|
+
)
|
|
459
|
+
# Accumulate session cost
|
|
460
|
+
prev_cost = getattr(cfg, "_session_cost_usd", 0.0) or 0.0
|
|
461
|
+
session_cost = prev_cost + (turn_cost or 0.0)
|
|
462
|
+
object.__setattr__(cfg, "_session_cost_usd", session_cost)
|
|
463
|
+
|
|
464
|
+
stats: dict[str, Any] = {
|
|
465
|
+
"in": in_tok,
|
|
466
|
+
"out": out_tok,
|
|
467
|
+
"think": think_tok,
|
|
468
|
+
"cache": cache_tok,
|
|
469
|
+
"total": total_tok,
|
|
470
|
+
"session_total": session_total,
|
|
471
|
+
"turn_cost": turn_cost,
|
|
472
|
+
"session_cost": session_cost,
|
|
473
|
+
}
|
|
474
|
+
object.__setattr__(cfg, "_last_turn_stats", stats)
|
|
475
|
+
except Exception:
|
|
476
|
+
pass
|
|
477
|
+
|
|
410
478
|
pt = d.get("prompt_token_count")
|
|
411
479
|
if isinstance(pt, int) and pt >= 0:
|
|
412
480
|
try:
|
|
@@ -251,6 +251,37 @@ class GemCodeConfig:
|
|
|
251
251
|
default_factory=lambda: _truthy_env("GEMCODE_SHOW_FULL_THINKING", default=False)
|
|
252
252
|
)
|
|
253
253
|
|
|
254
|
+
# Enable ADK BuiltInCodeExecutor for safe sandboxed Python execution via
|
|
255
|
+
# Gemini's code execution API. When on, the agent can write and run Python
|
|
256
|
+
# snippets inline (math, data processing, quick tests) without requiring
|
|
257
|
+
# bash/shell permissions. Requires a Gemini model that supports code execution
|
|
258
|
+
# (gemini-2.5-flash, gemini-2.5-pro, gemini-3.x).
|
|
259
|
+
enable_code_executor: bool = field(
|
|
260
|
+
default_factory=lambda: _truthy_env("GEMCODE_ENABLE_CODE_EXECUTOR", default=False)
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# Enable ADK artifact service for storing large/binary outputs (screenshots,
|
|
264
|
+
# generated files, reports) outside of session history.
|
|
265
|
+
# When on, the agent can save_artifact / load_artifact keyed by filename.
|
|
266
|
+
enable_artifacts: bool = field(
|
|
267
|
+
default_factory=lambda: _truthy_env("GEMCODE_ENABLE_ARTIFACTS", default=True)
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Plan mode: when ON, the agent explicitly writes out a numbered plan
|
|
271
|
+
# BEFORE executing any tools, then checks the plan before reporting done.
|
|
272
|
+
# Like OpenClaude's EnterPlanMode — great for complex, multi-file tasks.
|
|
273
|
+
# Toggle at runtime with /plan on|off.
|
|
274
|
+
plan_mode: bool = field(
|
|
275
|
+
default_factory=lambda: _truthy_env("GEMCODE_PLAN_MODE", default=False)
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Always-on web search (independent of deep_research / research mode).
|
|
279
|
+
# When True, google_search is available as a basic tool without enabling
|
|
280
|
+
# the full deep_research capability suite (url_context, maps, etc.).
|
|
281
|
+
enable_web_search: bool = field(
|
|
282
|
+
default_factory=lambda: _truthy_env("GEMCODE_ENABLE_WEB_SEARCH", default=False)
|
|
283
|
+
)
|
|
284
|
+
|
|
254
285
|
def __post_init__(self) -> None:
|
|
255
286
|
self.project_root = self.project_root.resolve()
|
|
256
287
|
# Default agentic depth when env omits GEMCODE_MAX_LLM_CALLS (was: None → SDK default).
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GemCode shell hooks — scriptable lifecycle events inspired by OpenClaude.
|
|
3
|
+
|
|
4
|
+
Hooks are shell scripts stored under ``<project_root>/.gemcode/hooks/``.
|
|
5
|
+
They receive a JSON payload on stdin and can:
|
|
6
|
+
- pre_tool_use.sh: return non-zero exit to DENY the tool call, or
|
|
7
|
+
print JSON {"decision":"deny","reason":"..."} to stderr.
|
|
8
|
+
- post_tool_use.sh: informational; return value ignored.
|
|
9
|
+
- session_start.sh: runs when a new GemCode session starts.
|
|
10
|
+
- session_stop.sh: runs when the session ends.
|
|
11
|
+
|
|
12
|
+
HOOK ENVIRONMENT
|
|
13
|
+
All hooks receive these env vars:
|
|
14
|
+
GEMCODE_HOOK_TYPE — "pre_tool_use" | "post_tool_use" | "session_start" | "session_stop"
|
|
15
|
+
GEMCODE_PROJECT_ROOT — absolute path to the project root
|
|
16
|
+
GEMCODE_MODEL — active model id
|
|
17
|
+
|
|
18
|
+
HOOK STDIN (pre_tool_use, post_tool_use)
|
|
19
|
+
JSON object with:
|
|
20
|
+
{ "tool": "<tool_name>",
|
|
21
|
+
"args": { ...tool_args... },
|
|
22
|
+
"type": "pre_tool_use" | "post_tool_use",
|
|
23
|
+
"result": { ...tool_result... } // post only
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
PRE_TOOL_USE DECISION
|
|
27
|
+
If exit code is non-zero → the tool is DENIED.
|
|
28
|
+
If exit code is 0 → the tool proceeds normally.
|
|
29
|
+
If stdout starts with '{' and contains "decision": "deny" → the tool is denied.
|
|
30
|
+
|
|
31
|
+
EXAMPLE HOOKS
|
|
32
|
+
# .gemcode/hooks/pre_tool_use.sh
|
|
33
|
+
#!/bin/bash
|
|
34
|
+
# Deny all delete_file calls
|
|
35
|
+
HOOK=$(cat)
|
|
36
|
+
TOOL=$(echo "$HOOK" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['tool'])")
|
|
37
|
+
if [ "$TOOL" = "delete_file" ]; then
|
|
38
|
+
echo "delete_file blocked by hook" >&2
|
|
39
|
+
exit 1
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
# .gemcode/hooks/session_start.sh
|
|
43
|
+
#!/bin/bash
|
|
44
|
+
echo "[gemcode hook] session started in $GEMCODE_PROJECT_ROOT" >> /tmp/gemcode-audit.log
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import json
|
|
50
|
+
import logging
|
|
51
|
+
import os
|
|
52
|
+
import subprocess
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
from typing import Any
|
|
55
|
+
|
|
56
|
+
log = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
_HOOKS_DIR = ".gemcode/hooks"
|
|
59
|
+
_TIMEOUT_PRE = 5.0 # seconds — pre_tool_use must respond quickly
|
|
60
|
+
_TIMEOUT_POST = 10.0 # seconds — post_tool_use has more budget
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _find_hook(project_root: Path, name: str) -> Path | None:
|
|
64
|
+
"""Return the hook file path if it exists and is executable, else None."""
|
|
65
|
+
hooks_dir = project_root / _HOOKS_DIR
|
|
66
|
+
for ext in ("", ".sh", ".py", ".bash"):
|
|
67
|
+
candidate = hooks_dir / f"{name}{ext}"
|
|
68
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
69
|
+
return candidate
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _run_hook(
|
|
74
|
+
hook_path: Path,
|
|
75
|
+
project_root: Path,
|
|
76
|
+
model: str,
|
|
77
|
+
hook_type: str,
|
|
78
|
+
stdin_data: dict | None = None,
|
|
79
|
+
timeout: float = 5.0,
|
|
80
|
+
) -> tuple[int, str, str]:
|
|
81
|
+
"""
|
|
82
|
+
Run a hook script.
|
|
83
|
+
|
|
84
|
+
Returns (exit_code, stdout, stderr).
|
|
85
|
+
Never raises — all exceptions are caught and logged.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
env = {
|
|
89
|
+
**os.environ,
|
|
90
|
+
"GEMCODE_HOOK_TYPE": hook_type,
|
|
91
|
+
"GEMCODE_PROJECT_ROOT": str(project_root),
|
|
92
|
+
"GEMCODE_MODEL": model or "",
|
|
93
|
+
}
|
|
94
|
+
stdin_bytes = (
|
|
95
|
+
json.dumps(stdin_data, ensure_ascii=False).encode() if stdin_data else b""
|
|
96
|
+
)
|
|
97
|
+
result = subprocess.run(
|
|
98
|
+
[str(hook_path)],
|
|
99
|
+
input=stdin_bytes,
|
|
100
|
+
capture_output=True,
|
|
101
|
+
timeout=timeout,
|
|
102
|
+
env=env,
|
|
103
|
+
cwd=str(project_root),
|
|
104
|
+
)
|
|
105
|
+
return result.returncode, result.stdout.decode(errors="replace"), result.stderr.decode(errors="replace")
|
|
106
|
+
except subprocess.TimeoutExpired:
|
|
107
|
+
log.warning("[hooks] %s timed out after %.1fs", hook_path.name, timeout)
|
|
108
|
+
return 1, "", f"hook timed out after {timeout}s"
|
|
109
|
+
except Exception as exc:
|
|
110
|
+
log.debug("[hooks] failed to run %s: %s", hook_path.name, exc)
|
|
111
|
+
return 1, "", str(exc)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def run_pre_tool_use_hook(
|
|
115
|
+
project_root: Path,
|
|
116
|
+
model: str,
|
|
117
|
+
tool_name: str,
|
|
118
|
+
args: dict[str, Any],
|
|
119
|
+
) -> dict | None:
|
|
120
|
+
"""
|
|
121
|
+
Run the pre_tool_use hook (if it exists).
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
None → tool is allowed (default)
|
|
125
|
+
dict → {"error": "...", "error_kind": "hook_denied"} to deny the tool
|
|
126
|
+
"""
|
|
127
|
+
hook = _find_hook(project_root, "pre_tool_use")
|
|
128
|
+
if hook is None:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
payload = {"tool": tool_name, "args": args, "type": "pre_tool_use"}
|
|
132
|
+
rc, stdout, stderr = _run_hook(
|
|
133
|
+
hook, project_root, model, "pre_tool_use", payload, timeout=_TIMEOUT_PRE
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if rc != 0:
|
|
137
|
+
reason = stderr.strip() or stdout.strip() or f"hook exited {rc}"
|
|
138
|
+
log.info("[hooks] pre_tool_use denied %s: %s", tool_name, reason[:200])
|
|
139
|
+
return {
|
|
140
|
+
"error": f"Tool denied by pre_tool_use hook: {reason[:400]}",
|
|
141
|
+
"error_kind": "hook_denied",
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
# Check if stdout contains an explicit deny decision.
|
|
145
|
+
if stdout.strip().startswith("{"):
|
|
146
|
+
try:
|
|
147
|
+
decision = json.loads(stdout.strip())
|
|
148
|
+
if str(decision.get("decision", "")).lower() == "deny":
|
|
149
|
+
reason = decision.get("reason", "hook denied")
|
|
150
|
+
log.info("[hooks] pre_tool_use JSON-denied %s: %s", tool_name, reason)
|
|
151
|
+
return {
|
|
152
|
+
"error": f"Tool denied by hook: {reason}",
|
|
153
|
+
"error_kind": "hook_denied",
|
|
154
|
+
}
|
|
155
|
+
except Exception:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
return None # allowed
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def run_post_tool_use_hook(
|
|
162
|
+
project_root: Path,
|
|
163
|
+
model: str,
|
|
164
|
+
tool_name: str,
|
|
165
|
+
args: dict[str, Any],
|
|
166
|
+
result: dict[str, Any],
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Run the post_tool_use hook if it exists. Return value is ignored."""
|
|
169
|
+
hook = _find_hook(project_root, "post_tool_use")
|
|
170
|
+
if hook is None:
|
|
171
|
+
return
|
|
172
|
+
payload = {
|
|
173
|
+
"tool": tool_name,
|
|
174
|
+
"args": args,
|
|
175
|
+
"result": result,
|
|
176
|
+
"type": "post_tool_use",
|
|
177
|
+
}
|
|
178
|
+
_run_hook(hook, project_root, model, "post_tool_use", payload, timeout=_TIMEOUT_POST)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def run_session_start_hook(project_root: Path, model: str) -> None:
|
|
182
|
+
"""Run the session_start hook if it exists."""
|
|
183
|
+
hook = _find_hook(project_root, "session_start")
|
|
184
|
+
if hook is None:
|
|
185
|
+
return
|
|
186
|
+
_run_hook(hook, project_root, model, "session_start", timeout=10.0)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def run_session_stop_hook(project_root: Path, model: str) -> None:
|
|
190
|
+
"""Run the session_stop hook if it exists."""
|
|
191
|
+
hook = _find_hook(project_root, "session_stop")
|
|
192
|
+
if hook is None:
|
|
193
|
+
return
|
|
194
|
+
_run_hook(hook, project_root, model, "session_stop", timeout=10.0)
|
|
@@ -172,9 +172,23 @@ def build_extra_tools(cfg: GemCodeConfig) -> list[Any]:
|
|
|
172
172
|
"""Return ADK tool unions to expose for enabled modalities."""
|
|
173
173
|
extra: list[Any] = []
|
|
174
174
|
|
|
175
|
+
# ── Web search (standalone, no full deep_research needed) ────────────────
|
|
176
|
+
# enable_web_search=True adds google_search alone.
|
|
177
|
+
# enable_deep_research=True adds google_search + url_context + optional maps.
|
|
178
|
+
# If both are on, avoid adding google_search twice.
|
|
179
|
+
web_search_added = False
|
|
180
|
+
if getattr(cfg, "enable_web_search", False) and not getattr(cfg, "enable_deep_research", False):
|
|
181
|
+
try:
|
|
182
|
+
from google.adk.tools import google_search
|
|
183
|
+
extra.append(google_search)
|
|
184
|
+
web_search_added = True
|
|
185
|
+
except Exception:
|
|
186
|
+
pass
|
|
187
|
+
|
|
175
188
|
if getattr(cfg, "enable_deep_research", False):
|
|
176
189
|
from google.adk.tools import google_search, url_context
|
|
177
|
-
|
|
190
|
+
if not web_search_added:
|
|
191
|
+
extra.append(google_search)
|
|
178
192
|
extra.append(url_context)
|
|
179
193
|
# Google Maps grounding can be incompatible with other built-in tools
|
|
180
194
|
# (e.g., google_search) depending on the request/model tooling layer.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-model token pricing for cost estimation.
|
|
3
|
+
|
|
4
|
+
All prices are USD per 1 million tokens (input / output separately).
|
|
5
|
+
Sources: Google AI Studio / Vertex AI pricing pages as of 2026.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from gemcode.pricing import estimate_cost
|
|
9
|
+
usd = estimate_cost("gemini-2.5-flash", input_tokens=5000, output_tokens=1200)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
# Format: model_prefix → (input_$/M, output_$/M)
|
|
15
|
+
# Matches are done by checking if the model id STARTS WITH any key (longest first).
|
|
16
|
+
_PRICING_TABLE: dict[str, tuple[float, float]] = {
|
|
17
|
+
# ── Gemini 2.5 ──────────────────────────────────────────────────────────
|
|
18
|
+
"gemini-2.5-pro": (3.50, 10.50), # standard context ≤200k
|
|
19
|
+
"gemini-2.5-flash": (0.15, 0.60), # standard context
|
|
20
|
+
"gemini-2.5-flash-8b": (0.037, 0.15), # 8B lite variant
|
|
21
|
+
# ── Gemini 2.0 ──────────────────────────────────────────────────────────
|
|
22
|
+
"gemini-2.0-flash": (0.10, 0.40),
|
|
23
|
+
"gemini-2.0-flash-lite": (0.075, 0.30),
|
|
24
|
+
"gemini-2.0-pro": (3.50, 10.50),
|
|
25
|
+
# ── Gemini 1.5 ──────────────────────────────────────────────────────────
|
|
26
|
+
"gemini-1.5-flash": (0.075, 0.30),
|
|
27
|
+
"gemini-1.5-pro": (1.25, 5.00),
|
|
28
|
+
# ── Gemini experimental / preview ───────────────────────────────────────
|
|
29
|
+
"gemini-exp": (0.00, 0.00), # free during preview
|
|
30
|
+
"gemini-3": (0.30, 1.20), # approximate future pricing
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def estimate_cost(
|
|
35
|
+
model: str,
|
|
36
|
+
*,
|
|
37
|
+
input_tokens: int = 0,
|
|
38
|
+
output_tokens: int = 0,
|
|
39
|
+
) -> float | None:
|
|
40
|
+
"""
|
|
41
|
+
Return estimated USD cost for a model turn, or None if pricing is unknown.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
model: Model id string (e.g. "gemini-2.5-flash").
|
|
45
|
+
input_tokens: Prompt/input token count.
|
|
46
|
+
output_tokens: Candidates/output token count.
|
|
47
|
+
"""
|
|
48
|
+
if not model:
|
|
49
|
+
return None
|
|
50
|
+
model_lower = model.lower().strip()
|
|
51
|
+
# Match longest prefix first for specificity.
|
|
52
|
+
for prefix in sorted(_PRICING_TABLE, key=len, reverse=True):
|
|
53
|
+
if model_lower.startswith(prefix):
|
|
54
|
+
in_rate, out_rate = _PRICING_TABLE[prefix]
|
|
55
|
+
return (input_tokens * in_rate + output_tokens * out_rate) / 1_000_000
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def format_cost(usd: float | None) -> str:
|
|
60
|
+
"""Human-readable cost string, e.g. '$0.0012' or '$1.23'."""
|
|
61
|
+
if usd is None:
|
|
62
|
+
return ""
|
|
63
|
+
if usd < 0.0001:
|
|
64
|
+
return f"<$0.0001"
|
|
65
|
+
if usd < 0.01:
|
|
66
|
+
return f"${usd:.4f}"
|
|
67
|
+
return f"${usd:.3f}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def format_tokens(n: int) -> str:
|
|
71
|
+
"""Format a token count compactly: 1234 → '1.2k', 12345678 → '12.3M'."""
|
|
72
|
+
if n >= 1_000_000:
|
|
73
|
+
return f"{n / 1_000_000:.1f}M"
|
|
74
|
+
if n >= 1_000:
|
|
75
|
+
return f"{n / 1_000:.1f}k"
|
|
76
|
+
return str(n)
|