venice-cli 0.41.0__tar.gz → 0.43.0__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.
- {venice_cli-0.41.0 → venice_cli-0.43.0}/PKG-INFO +18 -2
- {venice_cli-0.41.0 → venice_cli-0.43.0}/README.md +17 -1
- venice_cli-0.43.0/src/venice/__init__.py +1 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_agent.py +184 -29
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_code.py +6 -3
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_mcp.py +105 -1
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_models.py +25 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_repl.py +25 -2
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/chat.py +9 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/code.py +14 -4
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/userconfig.py +2 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_agent.py +119 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_chat.py +43 -4
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_code.py +3 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_code_command.py +1 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_config.py +16 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_mcp_tools.py +157 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_repl.py +26 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_shared_openai.py +31 -0
- venice_cli-0.41.0/src/venice/__init__.py +0 -1
- {venice_cli-0.41.0 → venice_cli-0.43.0}/.gitignore +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/CONTRIBUTING.md +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/LICENSE +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/Makefile +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/SECURITY.md +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/bin/venice +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/install.sh +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/pyproject.toml +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/__main__.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/audio_player.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/audio_post.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/auth.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/billing.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/cli.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/client.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/__init__.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_audio.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_compact.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_index.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_mcp_client.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_openai.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_queue.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_shared.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/balance.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/bg_remove.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/config.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/contact_sheet.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/embed.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/image.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/image_edit.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/index.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/login.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/master.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/mcp_serve.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/models.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/music.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/search.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/sfx.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/tts.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/upscale.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/video.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/config.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/image_montage.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/mcp_server.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/__init__.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/_mcp_fake_server.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_auth.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_balance.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_bg_remove.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_client.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_compact.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_contact_sheet.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_embed.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_image.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_image_edit.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_index.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_master.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_mcp_client.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_mcp_serve.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_models.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_music.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_search.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_sfx.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_shared.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_tts.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_upscale.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_video.py +0 -0
- {venice_cli-0.41.0 → venice_cli-0.43.0}/uninstall.sh +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: venice-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.43.0
|
|
4
4
|
Summary: A stdlib-only Python CLI for the Venice.ai API: audio, video, images, chat, and embeddings.
|
|
5
5
|
Project-URL: Homepage, https://github.com/gobha-me/venice-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/gobha-me/venice-cli
|
|
@@ -647,7 +647,13 @@ guessing, and `venice_model_details` (single `model` arg) which returns one
|
|
|
647
647
|
model's pricing (cost), `capabilities` (text models — supportsVision etc.),
|
|
648
648
|
`constraints` (image/media — aspect ratios, resolutions, qualities, prompt-length
|
|
649
649
|
limit), and the full `model_spec`, so the agent can budget input and confirm a
|
|
650
|
-
model fits
|
|
650
|
+
model fits, and `venice_vision`, which sends a local image (`input_path`, as a
|
|
651
|
+
base64 data-URL) or an `image_url` to a vision-capable text model and returns
|
|
652
|
+
what it sees — the agent's eyes, so it can inspect its own generations
|
|
653
|
+
(watermarks, character consistency, glitches) instead of working blind; an
|
|
654
|
+
optional `prompt` directs the question, and when `model` is omitted a
|
|
655
|
+
`supportsVision` model is auto-picked from the catalog. Not spend-gated.
|
|
656
|
+
(`venice code` gets all three too.)
|
|
651
657
|
|
|
652
658
|
```sh
|
|
653
659
|
# One command, multiple steps: the model generates an image, then critiques it.
|
|
@@ -671,6 +677,14 @@ Details and safety:
|
|
|
671
677
|
the block is handed back to the model, which adapts. `--yes` auto-approves every
|
|
672
678
|
paid call (this bypasses the per-call cap — `--max-tool-calls` still bounds the
|
|
673
679
|
count). The model itself can never raise its spending authority.
|
|
680
|
+
- **Session spend cap** (`--session-max-spend`, #66) meters the *chat completions
|
|
681
|
+
themselves* — not just paid tools. Each turn's server-reported `usage` is
|
|
682
|
+
priced against the session model's per-1M-token catalog rate; once the running
|
|
683
|
+
total reaches the cap the loop stops starting new turns and forces a final
|
|
684
|
+
answer (chat has no pre-call quote, so it bounds *further* spend, not a turn
|
|
685
|
+
already in flight). Config-backable via `defaults.chat.session_max_spend` /
|
|
686
|
+
`defaults.code.session_max_spend`. Distinct from `--max-spend` (the per-call
|
|
687
|
+
tool cap). A model with unknown pricing is counted (tokens) but not charged.
|
|
674
688
|
- `--output DIR` sets where generated files are written (default: cwd).
|
|
675
689
|
- **Non-streamed in v1.** The tool path buffers each turn, so `--stream` is ignored
|
|
676
690
|
when `--tools` is on; `--json` prints the final completion object.
|
|
@@ -681,6 +695,7 @@ Details and safety:
|
|
|
681
695
|
| `--tool NAME` | restrict to this tool (repeatable; default: all of them) |
|
|
682
696
|
| `--max-tool-calls N` | cap tool invocations before forcing an answer (default 8) |
|
|
683
697
|
| `--max-spend USD` | per-call auto-approve cap for paid tools |
|
|
698
|
+
| `--session-max-spend USD` | cap total chat-completion spend for the session |
|
|
684
699
|
| `--yes` / `-y` | auto-approve every paid tool call and side-effecting MCP tool |
|
|
685
700
|
| `--output DIR` / `-o` | directory for generated files |
|
|
686
701
|
| `--mcp NAME` | attach a registered external MCP server's tools (repeatable) |
|
|
@@ -913,6 +928,7 @@ complete — a loud stderr warning is printed).
|
|
|
913
928
|
| `read_file` / `list_dir` / `grep` | read a file, list a dir, regex-search the tree | no |
|
|
914
929
|
| `git` | read-only git (`status`/`diff`/`log`/`show`/…) | no |
|
|
915
930
|
| `project_search` | semantic search over the `.venice` index (if built) | no |
|
|
931
|
+
| `venice_vision` | describe/inspect a local image or image URL via a vision-capable model | no |
|
|
916
932
|
| `write_file` | create/overwrite a file (atomic) | yes |
|
|
917
933
|
| `edit_file` | replace an exact, unique string in a file | yes |
|
|
918
934
|
| `apply_patch` | apply a batch of edits grouped per file, atomically per file (use `occurrence=N` for non-unique strings) | yes |
|
|
@@ -609,7 +609,13 @@ guessing, and `venice_model_details` (single `model` arg) which returns one
|
|
|
609
609
|
model's pricing (cost), `capabilities` (text models — supportsVision etc.),
|
|
610
610
|
`constraints` (image/media — aspect ratios, resolutions, qualities, prompt-length
|
|
611
611
|
limit), and the full `model_spec`, so the agent can budget input and confirm a
|
|
612
|
-
model fits
|
|
612
|
+
model fits, and `venice_vision`, which sends a local image (`input_path`, as a
|
|
613
|
+
base64 data-URL) or an `image_url` to a vision-capable text model and returns
|
|
614
|
+
what it sees — the agent's eyes, so it can inspect its own generations
|
|
615
|
+
(watermarks, character consistency, glitches) instead of working blind; an
|
|
616
|
+
optional `prompt` directs the question, and when `model` is omitted a
|
|
617
|
+
`supportsVision` model is auto-picked from the catalog. Not spend-gated.
|
|
618
|
+
(`venice code` gets all three too.)
|
|
613
619
|
|
|
614
620
|
```sh
|
|
615
621
|
# One command, multiple steps: the model generates an image, then critiques it.
|
|
@@ -633,6 +639,14 @@ Details and safety:
|
|
|
633
639
|
the block is handed back to the model, which adapts. `--yes` auto-approves every
|
|
634
640
|
paid call (this bypasses the per-call cap — `--max-tool-calls` still bounds the
|
|
635
641
|
count). The model itself can never raise its spending authority.
|
|
642
|
+
- **Session spend cap** (`--session-max-spend`, #66) meters the *chat completions
|
|
643
|
+
themselves* — not just paid tools. Each turn's server-reported `usage` is
|
|
644
|
+
priced against the session model's per-1M-token catalog rate; once the running
|
|
645
|
+
total reaches the cap the loop stops starting new turns and forces a final
|
|
646
|
+
answer (chat has no pre-call quote, so it bounds *further* spend, not a turn
|
|
647
|
+
already in flight). Config-backable via `defaults.chat.session_max_spend` /
|
|
648
|
+
`defaults.code.session_max_spend`. Distinct from `--max-spend` (the per-call
|
|
649
|
+
tool cap). A model with unknown pricing is counted (tokens) but not charged.
|
|
636
650
|
- `--output DIR` sets where generated files are written (default: cwd).
|
|
637
651
|
- **Non-streamed in v1.** The tool path buffers each turn, so `--stream` is ignored
|
|
638
652
|
when `--tools` is on; `--json` prints the final completion object.
|
|
@@ -643,6 +657,7 @@ Details and safety:
|
|
|
643
657
|
| `--tool NAME` | restrict to this tool (repeatable; default: all of them) |
|
|
644
658
|
| `--max-tool-calls N` | cap tool invocations before forcing an answer (default 8) |
|
|
645
659
|
| `--max-spend USD` | per-call auto-approve cap for paid tools |
|
|
660
|
+
| `--session-max-spend USD` | cap total chat-completion spend for the session |
|
|
646
661
|
| `--yes` / `-y` | auto-approve every paid tool call and side-effecting MCP tool |
|
|
647
662
|
| `--output DIR` / `-o` | directory for generated files |
|
|
648
663
|
| `--mcp NAME` | attach a registered external MCP server's tools (repeatable) |
|
|
@@ -875,6 +890,7 @@ complete — a loud stderr warning is printed).
|
|
|
875
890
|
| `read_file` / `list_dir` / `grep` | read a file, list a dir, regex-search the tree | no |
|
|
876
891
|
| `git` | read-only git (`status`/`diff`/`log`/`show`/…) | no |
|
|
877
892
|
| `project_search` | semantic search over the `.venice` index (if built) | no |
|
|
893
|
+
| `venice_vision` | describe/inspect a local image or image URL via a vision-capable model | no |
|
|
878
894
|
| `write_file` | create/overwrite a file (atomic) | yes |
|
|
879
895
|
| `edit_file` | replace an exact, unique string in a file | yes |
|
|
880
896
|
| `apply_patch` | apply a batch of edits grouped per file, atomically per file (use `occurrence=N` for non-unique strings) | yes |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.43.0"
|
|
@@ -34,6 +34,7 @@ from typing import Callable, Dict, List, Optional
|
|
|
34
34
|
|
|
35
35
|
from .. import userconfig
|
|
36
36
|
from . import _mcp
|
|
37
|
+
from . import _models
|
|
37
38
|
from . import _compact
|
|
38
39
|
from .models import MODEL_TYPES
|
|
39
40
|
|
|
@@ -72,6 +73,126 @@ def to_openai_tools(tools: List[Tool]) -> List[dict]:
|
|
|
72
73
|
]
|
|
73
74
|
|
|
74
75
|
|
|
76
|
+
# --------------------------------------------------------------------------- #
|
|
77
|
+
# Cost ledger (#66): meter chat-completion spend across an agent run.
|
|
78
|
+
#
|
|
79
|
+
# Paid *tools* are already spend-gated (`check_spend` in `_mcp`); the model
|
|
80
|
+
# calls themselves were not. This ledger accumulates per-turn cost from the
|
|
81
|
+
# server-reported `usage` block and the catalog's per-1M-token pricing, so a
|
|
82
|
+
# session `--max-spend` can stop a runaway loop. Accounting is post-response
|
|
83
|
+
# (chat pricing is dynamic; there is no pre-call quote), so the gate fires
|
|
84
|
+
# *between* turns: once accumulated cost crosses the cap, no new paid turn
|
|
85
|
+
# starts and the loop forces a final answer (mirroring --max-tool-calls).
|
|
86
|
+
# --------------------------------------------------------------------------- #
|
|
87
|
+
def _usd_per_token(pricing, key) -> Optional[float]:
|
|
88
|
+
"""`pricing.<key>.usd` as a per-token rate (catalog prices are per 1M)."""
|
|
89
|
+
if not isinstance(pricing, dict):
|
|
90
|
+
return None
|
|
91
|
+
node = pricing.get(key)
|
|
92
|
+
if isinstance(node, dict) and isinstance(node.get("usd"), (int, float)):
|
|
93
|
+
return float(node["usd"]) / 1_000_000.0
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class CostLedger:
|
|
98
|
+
"""Accumulates estimated USD spend for one agent run.
|
|
99
|
+
|
|
100
|
+
`max_spend` is the session cap (USD-equivalent; None = unmetered). The
|
|
101
|
+
ledger is bound to a model's pricing on first use via :meth:`bind_pricing`;
|
|
102
|
+
an unknown price means the turn's tokens are counted but not charged
|
|
103
|
+
(degrade gracefully rather than hard-block on a missing price).
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self, max_spend: Optional[float] = None):
|
|
107
|
+
# A non-positive cap means "uncapped" (mirrors --max-tool-calls 0).
|
|
108
|
+
cap = float(max_spend) if max_spend is not None else None
|
|
109
|
+
self.max_spend = cap if (cap is not None and cap > 0) else None
|
|
110
|
+
self.total = 0.0
|
|
111
|
+
self.prompt_tokens = 0
|
|
112
|
+
self.completion_tokens = 0
|
|
113
|
+
self.unpriced = False # saw a turn whose model price was unknown
|
|
114
|
+
self._in = None # per-token input rate (USD)
|
|
115
|
+
self._out = None # per-token output rate (USD)
|
|
116
|
+
|
|
117
|
+
def bind_pricing(self, pricing) -> None:
|
|
118
|
+
"""Set the per-token rates from a catalog `model_spec.pricing` block."""
|
|
119
|
+
self._in = _usd_per_token(pricing, "input")
|
|
120
|
+
self._out = _usd_per_token(pricing, "output")
|
|
121
|
+
|
|
122
|
+
def record(self, usage) -> float:
|
|
123
|
+
"""Add one turn's `usage` (dict or SDK obj); return this turn's cost."""
|
|
124
|
+
if usage is None:
|
|
125
|
+
return 0.0
|
|
126
|
+
if hasattr(usage, "model_dump"):
|
|
127
|
+
usage = usage.model_dump()
|
|
128
|
+
if not isinstance(usage, dict):
|
|
129
|
+
return 0.0
|
|
130
|
+
pt = usage.get("prompt_tokens") or 0
|
|
131
|
+
ct = usage.get("completion_tokens") or 0
|
|
132
|
+
if not isinstance(pt, (int, float)):
|
|
133
|
+
pt = 0
|
|
134
|
+
if not isinstance(ct, (int, float)):
|
|
135
|
+
ct = 0
|
|
136
|
+
self.prompt_tokens += int(pt)
|
|
137
|
+
self.completion_tokens += int(ct)
|
|
138
|
+
cost = 0.0
|
|
139
|
+
if self._in is not None or self._out is not None:
|
|
140
|
+
cost = float(pt) * (self._in or 0.0) + float(ct) * (self._out or 0.0)
|
|
141
|
+
else:
|
|
142
|
+
self.unpriced = True
|
|
143
|
+
self.total += cost
|
|
144
|
+
return cost
|
|
145
|
+
|
|
146
|
+
def over(self) -> bool:
|
|
147
|
+
"""True when accumulated spend has reached/exceeded the cap."""
|
|
148
|
+
return self.max_spend is not None and self.total >= self.max_spend
|
|
149
|
+
|
|
150
|
+
def summary(self) -> str:
|
|
151
|
+
"""A one-line human-readable total (for stderr / --json)."""
|
|
152
|
+
if self.unpriced and self.total == 0.0:
|
|
153
|
+
return (
|
|
154
|
+
f"cost: (unpriced — model rate unknown) "
|
|
155
|
+
f"tokens prompt={self.prompt_tokens} completion={self.completion_tokens}"
|
|
156
|
+
)
|
|
157
|
+
s = f"cost: ${self.total:.4f}"
|
|
158
|
+
if self.max_spend is not None:
|
|
159
|
+
s += f" / cap ${self.max_spend:.2f}"
|
|
160
|
+
s += f" (tokens prompt={self.prompt_tokens} completion={self.completion_tokens})"
|
|
161
|
+
if self.unpriced:
|
|
162
|
+
s += " [partially unpriced]"
|
|
163
|
+
return s
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _pricing_for(models, model_id):
|
|
167
|
+
"""The catalog `model_spec.pricing` block for `model_id`, or None."""
|
|
168
|
+
for m in models or []:
|
|
169
|
+
if isinstance(m, dict) and m.get("id") == model_id:
|
|
170
|
+
spec = m.get("model_spec")
|
|
171
|
+
if isinstance(spec, dict):
|
|
172
|
+
return spec.get("pricing")
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def ledger_from_args(args, models, model_id) -> Optional[CostLedger]:
|
|
177
|
+
"""The session CostLedger for a parsed-args namespace, or None when the run
|
|
178
|
+
isn't spend-capped (#66).
|
|
179
|
+
|
|
180
|
+
Enabled by ``--session-max-spend`` (or ``defaults.<cmd>.session_max_spend``)
|
|
181
|
+
-- DISTINCT from ``--max-spend``, which is the *per-call* auto-approve cap
|
|
182
|
+
for paid tools. Bound to the session model's catalog pricing; an unknown
|
|
183
|
+
price degrades to token-counting without charging (the ledger still reports
|
|
184
|
+
usage). `models` is the text catalog the command already fetched.
|
|
185
|
+
"""
|
|
186
|
+
cap = getattr(args, "session_max_spend", None)
|
|
187
|
+
if cap is None:
|
|
188
|
+
return None
|
|
189
|
+
ledger = CostLedger(max_spend=cap)
|
|
190
|
+
pricing = _pricing_for(models, model_id)
|
|
191
|
+
if pricing is not None:
|
|
192
|
+
ledger.bind_pricing(pricing)
|
|
193
|
+
return ledger
|
|
194
|
+
|
|
195
|
+
|
|
75
196
|
def dispatch_map(tools: List[Tool]) -> Dict[str, Tool]:
|
|
76
197
|
return {t.name: t for t in tools}
|
|
77
198
|
|
|
@@ -208,6 +329,22 @@ _MODEL_DETAILS_SCHEMA = _obj(
|
|
|
208
329
|
required=["model"],
|
|
209
330
|
)
|
|
210
331
|
|
|
332
|
+
_VISION_SCHEMA = _obj(
|
|
333
|
+
{
|
|
334
|
+
"input_path": _p("string", "Path to a local image file to look at."),
|
|
335
|
+
"image_url": _p("string", "URL of an image (instead of input_path)."),
|
|
336
|
+
"prompt": _p(
|
|
337
|
+
"string",
|
|
338
|
+
"What to ask about the image (default: describe it in detail).",
|
|
339
|
+
),
|
|
340
|
+
"model": _p(
|
|
341
|
+
"string",
|
|
342
|
+
"A vision-capable text model id (default: auto-picked from the catalog).",
|
|
343
|
+
),
|
|
344
|
+
"max_tokens": _p("integer"),
|
|
345
|
+
},
|
|
346
|
+
)
|
|
347
|
+
|
|
211
348
|
# Schema for a tool folded in ONLY via `only=` (e.g. `venice code --assets`), so it
|
|
212
349
|
# is not part of chat's default advertised set. Curated subset of
|
|
213
350
|
# `_mcp.image_edit_tool`; `confirm`/`max_spend`/`output_dir` omitted (loop-injected).
|
|
@@ -330,6 +467,16 @@ _BUILTINS = [
|
|
|
330
467
|
_MODEL_DETAILS_SCHEMA,
|
|
331
468
|
False,
|
|
332
469
|
),
|
|
470
|
+
(
|
|
471
|
+
"venice_vision",
|
|
472
|
+
"vision_tool",
|
|
473
|
+
"Look at an image (a local input_path OR an image_url) with a vision-capable "
|
|
474
|
+
"Venice text model and return what it sees as text. Optional prompt directs "
|
|
475
|
+
"the question (default: a detailed description). Auto-picks a supportsVision "
|
|
476
|
+
"model when model is omitted (see venice_model_details). Not spend-gated.",
|
|
477
|
+
_VISION_SCHEMA,
|
|
478
|
+
False,
|
|
479
|
+
),
|
|
333
480
|
(
|
|
334
481
|
"project_search",
|
|
335
482
|
"search_tool",
|
|
@@ -493,19 +640,7 @@ def supports_function_calling(models, model_id) -> Optional[bool]:
|
|
|
493
640
|
None when it can't be determined (no catalog, model absent, or the field is
|
|
494
641
|
missing) -- the caller then attempts the loop with a soft note.
|
|
495
642
|
"""
|
|
496
|
-
|
|
497
|
-
return None
|
|
498
|
-
for m in models:
|
|
499
|
-
if not isinstance(m, dict) or m.get("id") != model_id:
|
|
500
|
-
continue
|
|
501
|
-
spec = m.get("model_spec") or {}
|
|
502
|
-
caps = spec.get("capabilities")
|
|
503
|
-
if not isinstance(caps, dict):
|
|
504
|
-
return None
|
|
505
|
-
norm = {str(k).lower().replace("_", ""): v for k, v in caps.items()}
|
|
506
|
-
val = norm.get("supportsfunctioncalling")
|
|
507
|
-
return bool(val) if val is not None else None
|
|
508
|
-
return None
|
|
643
|
+
return _models.supports_capability(models, model_id, "supportsFunctionCalling")
|
|
509
644
|
|
|
510
645
|
|
|
511
646
|
# --------------------------------------------------------------------------- #
|
|
@@ -684,6 +819,7 @@ def run_loop(
|
|
|
684
819
|
yes: bool,
|
|
685
820
|
json_out: bool,
|
|
686
821
|
budget: Optional[_compact.Budget] = None,
|
|
822
|
+
ledger: Optional[CostLedger] = None,
|
|
687
823
|
) -> int:
|
|
688
824
|
"""Drive the function-calling loop until the model stops (or the cap is hit).
|
|
689
825
|
|
|
@@ -700,6 +836,13 @@ def run_loop(
|
|
|
700
836
|
system message (keeping the system prompt + last `budget.keep_turns` turns
|
|
701
837
|
verbatim). Compaction mutates `messages` in place and is best-effort: a
|
|
702
838
|
failed summary call leaves the history alone.
|
|
839
|
+
|
|
840
|
+
`ledger` (issue #66) meters chat-completion spend: each turn's `usage` is
|
|
841
|
+
recorded against the session model's per-token rate, and once accumulated
|
|
842
|
+
cost reaches `ledger.max_spend` the loop stops starting new paid turns and
|
|
843
|
+
forces a final answer (the model wraps up with the history it has). The
|
|
844
|
+
gate is post-response (chat has no pre-call quote), so it bounds *further*
|
|
845
|
+
spend rather than preempting a turn already in flight.
|
|
703
846
|
"""
|
|
704
847
|
oai_tools = to_openai_tools(tools)
|
|
705
848
|
dispatch = dispatch_map(tools)
|
|
@@ -710,7 +853,29 @@ def run_loop(
|
|
|
710
853
|
unlimited = max_tool_calls is None or max_tool_calls <= 0
|
|
711
854
|
show = not json_out # progress feedback (further TTY-gated inside the helpers)
|
|
712
855
|
|
|
856
|
+
def _force_final(reason: str) -> int:
|
|
857
|
+
print(reason, file=sys.stderr)
|
|
858
|
+
with _Spinner("finishing", enabled=show):
|
|
859
|
+
resp = oai.chat.completions.create(
|
|
860
|
+
model=model,
|
|
861
|
+
messages=messages,
|
|
862
|
+
tools=oai_tools,
|
|
863
|
+
tool_choice="none",
|
|
864
|
+
**base_kwargs,
|
|
865
|
+
)
|
|
866
|
+
if ledger is not None:
|
|
867
|
+
ledger.record(getattr(resp, "usage", None))
|
|
868
|
+
msg = resp.choices[0].message if getattr(resp, "choices", None) else None
|
|
869
|
+
messages.append(_assistant_dict(msg))
|
|
870
|
+
return _emit_final(resp, json_out)
|
|
871
|
+
|
|
713
872
|
while True:
|
|
873
|
+
# Spend gate (#66): don't start a new paid turn once the cap is hit.
|
|
874
|
+
if ledger is not None and ledger.over():
|
|
875
|
+
return _force_final(
|
|
876
|
+
f"chat: reached --max-spend ({ledger.summary()}); "
|
|
877
|
+
"requesting a final answer"
|
|
878
|
+
)
|
|
714
879
|
_compact.maybe_compact(
|
|
715
880
|
oai, model, messages, budget, base_kwargs,
|
|
716
881
|
on_compact=lambda b, a: _progress(
|
|
@@ -727,6 +892,8 @@ def run_loop(
|
|
|
727
892
|
)
|
|
728
893
|
if budget is not None:
|
|
729
894
|
budget.observe(getattr(resp, "usage", None))
|
|
895
|
+
if ledger is not None:
|
|
896
|
+
ledger.record(getattr(resp, "usage", None))
|
|
730
897
|
msg = resp.choices[0].message if getattr(resp, "choices", None) else None
|
|
731
898
|
messages.append(_assistant_dict(msg))
|
|
732
899
|
tool_calls = getattr(msg, "tool_calls", None) if msg is not None else None
|
|
@@ -759,11 +926,6 @@ def run_loop(
|
|
|
759
926
|
)
|
|
760
927
|
|
|
761
928
|
if not unlimited and calls_made >= max_tool_calls:
|
|
762
|
-
print(
|
|
763
|
-
f"chat: reached --max-tool-calls ({max_tool_calls}); "
|
|
764
|
-
"requesting a final answer",
|
|
765
|
-
file=sys.stderr,
|
|
766
|
-
)
|
|
767
929
|
# The forced-final is the turn a long, over-budget run most needs
|
|
768
930
|
# compacted -- it returns without re-entering the loop, so compact
|
|
769
931
|
# here too or it ships the full history (#48).
|
|
@@ -773,14 +935,7 @@ def run_loop(
|
|
|
773
935
|
f"(auto-compacted history: {b} -> {a} messages)", enabled=show,
|
|
774
936
|
),
|
|
775
937
|
)
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
tools=oai_tools,
|
|
781
|
-
tool_choice="none",
|
|
782
|
-
**base_kwargs,
|
|
783
|
-
)
|
|
784
|
-
msg = resp.choices[0].message if getattr(resp, "choices", None) else None
|
|
785
|
-
messages.append(_assistant_dict(msg))
|
|
786
|
-
return _emit_final(resp, json_out)
|
|
938
|
+
return _force_final(
|
|
939
|
+
f"chat: reached --max-tool-calls ({max_tool_calls}); "
|
|
940
|
+
"requesting a final answer"
|
|
941
|
+
)
|
|
@@ -662,11 +662,14 @@ def code_tools(
|
|
|
662
662
|
))
|
|
663
663
|
|
|
664
664
|
# Free model-catalog lookups so the agent can pick a valid `model` for the
|
|
665
|
-
# asset tools (and see cost/context limits) instead of guessing
|
|
666
|
-
#
|
|
665
|
+
# asset tools (and see cost/context limits) instead of guessing, plus
|
|
666
|
+
# venice_vision so it can see images (screenshots/mockups/its own
|
|
667
|
+
# generations) instead of working blind (need a client for the API calls).
|
|
667
668
|
if client is not None:
|
|
668
669
|
tools.extend(_agent.builtin_tools(
|
|
669
|
-
client,
|
|
670
|
+
client,
|
|
671
|
+
only={"venice_models", "venice_model_details", "venice_vision"},
|
|
672
|
+
config=config))
|
|
670
673
|
|
|
671
674
|
if assets and client is not None:
|
|
672
675
|
asset_dir = os.environ.get("VENICE_MCP_OUTPUT_DIR") or root
|
|
@@ -4,7 +4,7 @@ Import-clean by design: this module must NOT import `mcp` (or `openai`) at modul
|
|
|
4
4
|
scope, so `venice --help` and the base, stdlib-only install keep working, and the
|
|
5
5
|
tests here run on Python 3.9 where the `mcp` SDK cannot even be installed. The thin
|
|
6
6
|
FastMCP wiring lives in `venice.mcp_server`; everything with real logic lives here:
|
|
7
|
-
the lazy `import_mcp` probe, the spend gate, the output-dir resolver, and the
|
|
7
|
+
the lazy `import_mcp` probe, the spend gate, the output-dir resolver, and the
|
|
8
8
|
print-free `*_tool` functions the server delegates to.
|
|
9
9
|
|
|
10
10
|
CRITICAL invariant -- an MCP stdio server owns **stdout** for JSON-RPC framing, so
|
|
@@ -569,6 +569,110 @@ def chat_tool(
|
|
|
569
569
|
return out
|
|
570
570
|
|
|
571
571
|
|
|
572
|
+
DEFAULT_VISION_PROMPT = "Describe this image in detail."
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _vision_default(models: List[dict]) -> Optional[str]:
|
|
576
|
+
"""The default-trait text model if vision-capable, else the first
|
|
577
|
+
catalog model advertising supportsVision, else None."""
|
|
578
|
+
default = _models.default_model(models)
|
|
579
|
+
if default and _models.supports_capability(models, default, "supportsVision"):
|
|
580
|
+
return default
|
|
581
|
+
for m in models:
|
|
582
|
+
mid = m.get("id") if isinstance(m, dict) else None
|
|
583
|
+
if mid and _models.supports_capability(models, mid, "supportsVision"):
|
|
584
|
+
return mid
|
|
585
|
+
return None
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def vision_tool(
|
|
589
|
+
client,
|
|
590
|
+
input_path: Optional[str] = None,
|
|
591
|
+
*,
|
|
592
|
+
image_url: Optional[str] = None,
|
|
593
|
+
prompt: Optional[str] = None,
|
|
594
|
+
model: Optional[str] = None,
|
|
595
|
+
max_tokens: Optional[int] = None,
|
|
596
|
+
) -> dict:
|
|
597
|
+
"""Describe/inspect an image via a multimodal /chat/completions call.
|
|
598
|
+
|
|
599
|
+
The image is a local `input_path` (sent as a base64 data: URL) or an
|
|
600
|
+
`image_url` passed through verbatim -- exactly one of the two. The model
|
|
601
|
+
must be vision-capable: an explicit non-vision `model` is rejected
|
|
602
|
+
client-side (the API would reject the image content anyway); when `model`
|
|
603
|
+
is omitted the default-trait text model is used if it advertises
|
|
604
|
+
supportsVision, else the first catalog model that does. Cheap relative to
|
|
605
|
+
media generation, so it is not spend-gated. Needs the `[openai]` extra.
|
|
606
|
+
"""
|
|
607
|
+
if bool(input_path) == bool(image_url):
|
|
608
|
+
return _err("vision: exactly one of input_path or image_url is required")
|
|
609
|
+
|
|
610
|
+
openai = _openai.import_openai("vision") # stderr hint if missing
|
|
611
|
+
if openai is None:
|
|
612
|
+
return _err('vision: needs the openai package: pip install "venice-cli[openai]"')
|
|
613
|
+
|
|
614
|
+
if input_path:
|
|
615
|
+
p = Path(input_path)
|
|
616
|
+
rc = _shared.check_image_file(p, label="vision") # stderr detail
|
|
617
|
+
if rc is not None:
|
|
618
|
+
return _err(f"vision: invalid input file (exit {rc})")
|
|
619
|
+
url = _shared.encode_data_url(p, default_mime="image/png")
|
|
620
|
+
else:
|
|
621
|
+
url = image_url
|
|
622
|
+
|
|
623
|
+
models = _models.catalog(client, "text")
|
|
624
|
+
if model:
|
|
625
|
+
resolved, rc = _models.resolve_model(
|
|
626
|
+
model, models, label="vision", noun="text model"
|
|
627
|
+
)
|
|
628
|
+
if rc is not None:
|
|
629
|
+
return _err(f"vision: could not resolve model (exit {rc})")
|
|
630
|
+
if _models.supports_capability(models, resolved, "supportsVision") is False:
|
|
631
|
+
return _err(
|
|
632
|
+
f"vision: model {resolved!r} does not advertise supportsVision; "
|
|
633
|
+
"pick a vision-capable model (see venice_model_details) or omit model"
|
|
634
|
+
)
|
|
635
|
+
else:
|
|
636
|
+
if models is None:
|
|
637
|
+
return _err("vision: could not fetch the model catalog; pass model explicitly")
|
|
638
|
+
resolved = _vision_default(models)
|
|
639
|
+
if resolved is None:
|
|
640
|
+
return _err(
|
|
641
|
+
"vision: no vision-capable text model found in the catalog; "
|
|
642
|
+
"pass model (see venice_models / venice_model_details)"
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
kwargs: dict = {
|
|
646
|
+
"model": resolved,
|
|
647
|
+
"messages": [
|
|
648
|
+
{
|
|
649
|
+
"role": "user",
|
|
650
|
+
"content": [
|
|
651
|
+
{"type": "text", "text": (prompt or DEFAULT_VISION_PROMPT).strip()},
|
|
652
|
+
{"type": "image_url", "image_url": {"url": url}},
|
|
653
|
+
],
|
|
654
|
+
}
|
|
655
|
+
],
|
|
656
|
+
}
|
|
657
|
+
if max_tokens is not None:
|
|
658
|
+
kwargs["max_tokens"] = int(max_tokens)
|
|
659
|
+
|
|
660
|
+
oai = _openai.build_openai(openai, client)
|
|
661
|
+
try:
|
|
662
|
+
resp = oai.chat.completions.create(**kwargs)
|
|
663
|
+
except openai.OpenAIError as e:
|
|
664
|
+
return _err(f"vision: API error: {e}")
|
|
665
|
+
|
|
666
|
+
content = ""
|
|
667
|
+
if getattr(resp, "choices", None):
|
|
668
|
+
content = resp.choices[0].message.content or ""
|
|
669
|
+
out = {"status": "ok", "content": content, "model": resolved}
|
|
670
|
+
usage = _chat._as_dict(getattr(resp, "usage", None))
|
|
671
|
+
if usage:
|
|
672
|
+
out["usage"] = usage
|
|
673
|
+
return out
|
|
674
|
+
|
|
675
|
+
|
|
572
676
|
def video_tool(
|
|
573
677
|
client,
|
|
574
678
|
prompt: str,
|
|
@@ -41,6 +41,31 @@ def default_model(models: List[dict]) -> Optional[str]:
|
|
|
41
41
|
return None
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
def supports_capability(models, model_id, key) -> Optional[bool]:
|
|
45
|
+
"""Whether `model_id` advertises the boolean capability `key` in the catalog.
|
|
46
|
+
|
|
47
|
+
`key` is matched case/underscore-insensitively against
|
|
48
|
+
`model_spec.capabilities` (e.g. "supportsVision"). True/False when the model
|
|
49
|
+
is found and carries the field; None when it can't be determined (no
|
|
50
|
+
catalog, model absent, or the field missing) -- callers treat None as
|
|
51
|
+
"unknown, attempt anyway".
|
|
52
|
+
"""
|
|
53
|
+
if not models:
|
|
54
|
+
return None
|
|
55
|
+
want = str(key).lower().replace("_", "")
|
|
56
|
+
for m in models:
|
|
57
|
+
if not isinstance(m, dict) or m.get("id") != model_id:
|
|
58
|
+
continue
|
|
59
|
+
spec = m.get("model_spec") or {}
|
|
60
|
+
caps = spec.get("capabilities")
|
|
61
|
+
if not isinstance(caps, dict):
|
|
62
|
+
return None
|
|
63
|
+
norm = {str(k).lower().replace("_", ""): v for k, v in caps.items()}
|
|
64
|
+
val = norm.get(want)
|
|
65
|
+
return bool(val) if val is not None else None
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
44
69
|
def resolve_model(
|
|
45
70
|
requested: Optional[str],
|
|
46
71
|
models: Optional[List[dict]],
|
|
@@ -42,6 +42,7 @@ Commands:
|
|
|
42
42
|
/auto auto-accept paid/side-effecting tool calls for following turns
|
|
43
43
|
/manual confirm each paid/side-effecting tool call (undo /auto)
|
|
44
44
|
/compact [N] summarize older history to shrink the context (keeps last N turns)
|
|
45
|
+
/cost show this session's estimated spend (needs --session-max-spend to cap)
|
|
45
46
|
/reset clear the conversation (keeps the system prompt)
|
|
46
47
|
/save [file] write the transcript JSON (default: the --resume file)
|
|
47
48
|
/help show this help
|
|
@@ -51,8 +52,8 @@ Anything else is sent to the model as your next message."""
|
|
|
51
52
|
# Slash-commands, in help order -- the single source of truth for tab-completion
|
|
52
53
|
# (#40). Keep in sync with `_dispatch_slash` and `_HELP`.
|
|
53
54
|
_COMMANDS = (
|
|
54
|
-
"/system", "/model", "/models", "/auto", "/manual", "/compact", "/
|
|
55
|
-
"/save", "/help", "/exit", "/quit",
|
|
55
|
+
"/system", "/model", "/models", "/auto", "/manual", "/compact", "/cost",
|
|
56
|
+
"/reset", "/save", "/help", "/exit", "/quit",
|
|
56
57
|
)
|
|
57
58
|
|
|
58
59
|
|
|
@@ -236,12 +237,19 @@ def _do_turn(oai, openai, chat, text, messages, gen_kwargs, state, args) -> None
|
|
|
236
237
|
the stream's final chunk.
|
|
237
238
|
"""
|
|
238
239
|
budget = state.get("budget")
|
|
240
|
+
ledger = state.get("ledger")
|
|
239
241
|
_compact.maybe_compact(
|
|
240
242
|
oai, state["model"], messages, budget, gen_kwargs,
|
|
241
243
|
on_compact=lambda b, a: print(
|
|
242
244
|
f"(auto-compacted history: {b} -> {a} messages)", file=sys.stderr,
|
|
243
245
|
),
|
|
244
246
|
)
|
|
247
|
+
# Spend gate (#66): refuse a new turn once the session cap is hit (the
|
|
248
|
+
# tool-loop gates mid-run; a streamed turn gates here).
|
|
249
|
+
if ledger is not None and ledger.over():
|
|
250
|
+
print(f"(max-spend reached: {ledger.summary()}; turn skipped)",
|
|
251
|
+
file=sys.stderr)
|
|
252
|
+
return
|
|
245
253
|
mark = len(messages)
|
|
246
254
|
messages.append({"role": "user", "content": text})
|
|
247
255
|
try:
|
|
@@ -252,11 +260,14 @@ def _do_turn(oai, openai, chat, text, messages, gen_kwargs, state, args) -> None
|
|
|
252
260
|
yes=state["yes"],
|
|
253
261
|
json_out=False,
|
|
254
262
|
budget=budget,
|
|
263
|
+
ledger=ledger,
|
|
255
264
|
)
|
|
256
265
|
else:
|
|
257
266
|
reply, usage = _stream_turn(oai, chat, state["model"], messages, gen_kwargs)
|
|
258
267
|
if budget is not None:
|
|
259
268
|
budget.observe(usage)
|
|
269
|
+
if ledger is not None:
|
|
270
|
+
ledger.record(usage)
|
|
260
271
|
messages.append({"role": "assistant", "content": reply})
|
|
261
272
|
except KeyboardInterrupt:
|
|
262
273
|
# Ctrl-C aborts just this turn -- roll it back and keep the session.
|
|
@@ -346,6 +357,15 @@ def _dispatch_slash(line, messages, state, args, models, oai=None, gen_kwargs=No
|
|
|
346
357
|
)
|
|
347
358
|
else:
|
|
348
359
|
print("(nothing to compact)", file=sys.stderr)
|
|
360
|
+
elif cmd == "cost":
|
|
361
|
+
# Session spend so far (#66). The ledger exists only when the session is
|
|
362
|
+
# spend-capped; otherwise there's nothing to report.
|
|
363
|
+
led = state.get("ledger")
|
|
364
|
+
if led is None:
|
|
365
|
+
print("(no session cost tracking; start with --session-max-spend)",
|
|
366
|
+
file=sys.stderr)
|
|
367
|
+
else:
|
|
368
|
+
print(led.summary(), file=sys.stderr)
|
|
349
369
|
elif cmd == "save":
|
|
350
370
|
target = rest or getattr(args, "resume", None)
|
|
351
371
|
if not target:
|
|
@@ -432,6 +452,9 @@ def run(args, oai, openai, client, models, model, initial=None, *,
|
|
|
432
452
|
# Auto-compaction (#48) is opt-in: `--auto-compact` or
|
|
433
453
|
# `defaults.<cmd>.auto_compact` (it costs a summarization call).
|
|
434
454
|
state["budget"] = _compact.budget_from_args(args)
|
|
455
|
+
# Spend cap (#66): `--max-spend` / `defaults.*.max_spend` meters the
|
|
456
|
+
# session's model calls (asset tools are gated separately).
|
|
457
|
+
state["ledger"] = _agent.ledger_from_args(args, models, model)
|
|
435
458
|
|
|
436
459
|
if _rl is not None:
|
|
437
460
|
_install_completer(_rl, models, stack)
|
|
@@ -153,6 +153,14 @@ def register(subparsers) -> None:
|
|
|
153
153
|
help="Per-call auto-approve cap for paid tools (default: $0.10 / "
|
|
154
154
|
"$VENICE_MCP_MAX_SPEND). Over-cap calls prompt on a TTY.",
|
|
155
155
|
)
|
|
156
|
+
ag.add_argument(
|
|
157
|
+
"--session-max-spend", type=float, default=None, metavar="USD",
|
|
158
|
+
dest="session_max_spend",
|
|
159
|
+
help="Cap total chat-completion spend for this session (#66). Meters the "
|
|
160
|
+
"model's own calls (not just paid tools) from server token usage; at the "
|
|
161
|
+
"cap the agent stops starting new turns and wraps up. Distinct from "
|
|
162
|
+
"--max-spend (the per-call tool cap).",
|
|
163
|
+
)
|
|
156
164
|
ag.add_argument("--yes", "-y", action="store_true", default=None,
|
|
157
165
|
help="Auto-approve every paid tool call and every side-effecting "
|
|
158
166
|
"external MCP tool (skips the confirm gate).")
|
|
@@ -443,6 +451,7 @@ def _run_agent(args, oai, openai, client, models, model, kwargs) -> Optional[int
|
|
|
443
451
|
yes=bool(args.yes),
|
|
444
452
|
json_out=args.json,
|
|
445
453
|
budget=_compact.budget_from_args(args), # #48 auto-compact parity
|
|
454
|
+
ledger=_agent.ledger_from_args(args, models, model), # #66 spend cap
|
|
446
455
|
)
|
|
447
456
|
except openai.OpenAIError as e:
|
|
448
457
|
return _openai.status_to_exit(openai, e, "chat")
|