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.
Files changed (88) hide show
  1. {venice_cli-0.41.0 → venice_cli-0.43.0}/PKG-INFO +18 -2
  2. {venice_cli-0.41.0 → venice_cli-0.43.0}/README.md +17 -1
  3. venice_cli-0.43.0/src/venice/__init__.py +1 -0
  4. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_agent.py +184 -29
  5. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_code.py +6 -3
  6. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_mcp.py +105 -1
  7. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_models.py +25 -0
  8. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_repl.py +25 -2
  9. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/chat.py +9 -0
  10. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/code.py +14 -4
  11. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/userconfig.py +2 -0
  12. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_agent.py +119 -0
  13. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_chat.py +43 -4
  14. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_code.py +3 -0
  15. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_code_command.py +1 -0
  16. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_config.py +16 -0
  17. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_mcp_tools.py +157 -0
  18. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_repl.py +26 -0
  19. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_shared_openai.py +31 -0
  20. venice_cli-0.41.0/src/venice/__init__.py +0 -1
  21. {venice_cli-0.41.0 → venice_cli-0.43.0}/.gitignore +0 -0
  22. {venice_cli-0.41.0 → venice_cli-0.43.0}/CONTRIBUTING.md +0 -0
  23. {venice_cli-0.41.0 → venice_cli-0.43.0}/LICENSE +0 -0
  24. {venice_cli-0.41.0 → venice_cli-0.43.0}/Makefile +0 -0
  25. {venice_cli-0.41.0 → venice_cli-0.43.0}/SECURITY.md +0 -0
  26. {venice_cli-0.41.0 → venice_cli-0.43.0}/bin/venice +0 -0
  27. {venice_cli-0.41.0 → venice_cli-0.43.0}/install.sh +0 -0
  28. {venice_cli-0.41.0 → venice_cli-0.43.0}/pyproject.toml +0 -0
  29. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/__main__.py +0 -0
  30. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/audio_player.py +0 -0
  31. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/audio_post.py +0 -0
  32. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/auth.py +0 -0
  33. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/billing.py +0 -0
  34. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/cli.py +0 -0
  35. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/client.py +0 -0
  36. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/__init__.py +0 -0
  37. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_audio.py +0 -0
  38. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_compact.py +0 -0
  39. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_index.py +0 -0
  40. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_mcp_client.py +0 -0
  41. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_openai.py +0 -0
  42. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_queue.py +0 -0
  43. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/_shared.py +0 -0
  44. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/balance.py +0 -0
  45. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/bg_remove.py +0 -0
  46. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/config.py +0 -0
  47. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/contact_sheet.py +0 -0
  48. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/embed.py +0 -0
  49. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/image.py +0 -0
  50. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/image_edit.py +0 -0
  51. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/index.py +0 -0
  52. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/login.py +0 -0
  53. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/master.py +0 -0
  54. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/mcp_serve.py +0 -0
  55. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/models.py +0 -0
  56. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/music.py +0 -0
  57. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/search.py +0 -0
  58. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/sfx.py +0 -0
  59. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/tts.py +0 -0
  60. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/upscale.py +0 -0
  61. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/commands/video.py +0 -0
  62. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/config.py +0 -0
  63. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/image_montage.py +0 -0
  64. {venice_cli-0.41.0 → venice_cli-0.43.0}/src/venice/mcp_server.py +0 -0
  65. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/__init__.py +0 -0
  66. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/_mcp_fake_server.py +0 -0
  67. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_auth.py +0 -0
  68. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_balance.py +0 -0
  69. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_bg_remove.py +0 -0
  70. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_client.py +0 -0
  71. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_compact.py +0 -0
  72. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_contact_sheet.py +0 -0
  73. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_embed.py +0 -0
  74. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_image.py +0 -0
  75. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_image_edit.py +0 -0
  76. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_index.py +0 -0
  77. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_master.py +0 -0
  78. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_mcp_client.py +0 -0
  79. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_mcp_serve.py +0 -0
  80. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_models.py +0 -0
  81. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_music.py +0 -0
  82. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_search.py +0 -0
  83. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_sfx.py +0 -0
  84. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_shared.py +0 -0
  85. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_tts.py +0 -0
  86. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_upscale.py +0 -0
  87. {venice_cli-0.41.0 → venice_cli-0.43.0}/tests/test_video.py +0 -0
  88. {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.41.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. (`venice code` gets both too.)
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. (`venice code` gets both too.)
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
- if not models:
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
- with _Spinner("finishing", enabled=show):
777
- resp = oai.chat.completions.create(
778
- model=model,
779
- messages=messages,
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 (need a client
666
- # for the /models GET).
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, only={"venice_models", "venice_model_details"}, config=config))
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 seven
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", "/reset",
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")