venice-cli 0.77.0__tar.gz → 0.78.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 (121) hide show
  1. {venice_cli-0.77.0 → venice_cli-0.78.0}/PKG-INFO +13 -5
  2. {venice_cli-0.77.0 → venice_cli-0.78.0}/README.md +10 -2
  3. {venice_cli-0.77.0 → venice_cli-0.78.0}/pyproject.toml +7 -1
  4. venice_cli-0.78.0/src/venice/__init__.py +1 -0
  5. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/cli.py +11 -1
  6. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/_agent.py +136 -10
  7. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/_repl.py +55 -34
  8. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/config.py +8 -0
  9. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/_drive.py +9 -0
  10. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_agent.py +194 -0
  11. venice_cli-0.78.0/tests/test_cli.py +59 -0
  12. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_code_command.py +20 -0
  13. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_drive_cli.py +65 -0
  14. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_repl.py +128 -0
  15. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_sessions.py +33 -0
  16. venice_cli-0.77.0/src/venice/__init__.py +0 -1
  17. {venice_cli-0.77.0 → venice_cli-0.78.0}/.gitignore +0 -0
  18. {venice_cli-0.77.0 → venice_cli-0.78.0}/CONTRIBUTING.md +0 -0
  19. {venice_cli-0.77.0 → venice_cli-0.78.0}/LICENSE +0 -0
  20. {venice_cli-0.77.0 → venice_cli-0.78.0}/Makefile +0 -0
  21. {venice_cli-0.77.0 → venice_cli-0.78.0}/SECURITY.md +0 -0
  22. {venice_cli-0.77.0 → venice_cli-0.78.0}/bin/venice +0 -0
  23. {venice_cli-0.77.0 → venice_cli-0.78.0}/install.sh +0 -0
  24. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/__main__.py +0 -0
  25. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/audio_player.py +0 -0
  26. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/audio_post.py +0 -0
  27. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/auth.py +0 -0
  28. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/billing.py +0 -0
  29. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/client.py +0 -0
  30. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/__init__.py +0 -0
  31. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/_audio.py +0 -0
  32. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/_browser.py +0 -0
  33. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/_code.py +0 -0
  34. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/_compact.py +0 -0
  35. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/_exec.py +0 -0
  36. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/_index.py +0 -0
  37. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/_mailbox.py +0 -0
  38. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/_mcp.py +0 -0
  39. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/_mcp_client.py +0 -0
  40. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/_memory.py +0 -0
  41. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/_models.py +0 -0
  42. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/_openai.py +0 -0
  43. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/_persona.py +0 -0
  44. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/_queue.py +0 -0
  45. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/_review.py +0 -0
  46. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/_session.py +0 -0
  47. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/_shared.py +0 -0
  48. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/_steer.py +0 -0
  49. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/balance.py +0 -0
  50. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/bg_remove.py +0 -0
  51. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/chat.py +0 -0
  52. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/code.py +0 -0
  53. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/completion.py +0 -0
  54. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/config.py +0 -0
  55. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/contact_sheet.py +0 -0
  56. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/embed.py +0 -0
  57. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/image.py +0 -0
  58. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/image_edit.py +0 -0
  59. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/index.py +0 -0
  60. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/login.py +0 -0
  61. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/master.py +0 -0
  62. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/mcp_serve.py +0 -0
  63. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/memory.py +0 -0
  64. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/models.py +0 -0
  65. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/music.py +0 -0
  66. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/review.py +0 -0
  67. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/search.py +0 -0
  68. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/secret.py +0 -0
  69. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/sessions.py +0 -0
  70. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/sfx.py +0 -0
  71. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/tts.py +0 -0
  72. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/upscale.py +0 -0
  73. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/commands/video.py +0 -0
  74. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/image_montage.py +0 -0
  75. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/mcp_server.py +0 -0
  76. {venice_cli-0.77.0 → venice_cli-0.78.0}/src/venice/userconfig.py +0 -0
  77. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/__init__.py +0 -0
  78. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/_mcp_fake_server.py +0 -0
  79. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/_venice_fake_server.py +0 -0
  80. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_auth.py +0 -0
  81. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_balance.py +0 -0
  82. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_bg_remove.py +0 -0
  83. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_browser.py +0 -0
  84. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_chat.py +0 -0
  85. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_client.py +0 -0
  86. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_code.py +0 -0
  87. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_compact.py +0 -0
  88. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_completion.py +0 -0
  89. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_config.py +0 -0
  90. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_contact_sheet.py +0 -0
  91. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_embed.py +0 -0
  92. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_exec.py +0 -0
  93. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_fake_server.py +0 -0
  94. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_image.py +0 -0
  95. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_image_edit.py +0 -0
  96. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_index.py +0 -0
  97. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_login.py +0 -0
  98. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_mailbox.py +0 -0
  99. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_master.py +0 -0
  100. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_mcp_client.py +0 -0
  101. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_mcp_serve.py +0 -0
  102. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_mcp_tools.py +0 -0
  103. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_memory.py +0 -0
  104. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_models.py +0 -0
  105. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_music.py +0 -0
  106. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_persona.py +0 -0
  107. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_planner.py +0 -0
  108. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_profiles.py +0 -0
  109. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_review.py +0 -0
  110. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_search.py +0 -0
  111. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_secret.py +0 -0
  112. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_sfx.py +0 -0
  113. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_shared.py +0 -0
  114. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_shared_openai.py +0 -0
  115. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_spawn.py +0 -0
  116. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_steer.py +0 -0
  117. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_tts.py +0 -0
  118. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_upscale.py +0 -0
  119. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_video.py +0 -0
  120. {venice_cli-0.77.0 → venice_cli-0.78.0}/tests/test_web_search.py +0 -0
  121. {venice_cli-0.77.0 → venice_cli-0.78.0}/uninstall.sh +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: venice-cli
3
- Version: 0.77.0
3
+ Version: 0.78.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
@@ -28,10 +28,10 @@ Classifier: Topic :: Multimedia :: Sound/Audio
28
28
  Classifier: Topic :: Utilities
29
29
  Requires-Python: >=3.9
30
30
  Provides-Extra: all
31
- Requires-Dist: mcp>=1.2; (python_version >= '3.10') and extra == 'all'
31
+ Requires-Dist: mcp<2,>=1.2; (python_version >= '3.10') and extra == 'all'
32
32
  Requires-Dist: openai>=1.40; extra == 'all'
33
33
  Provides-Extra: mcp
34
- Requires-Dist: mcp>=1.2; (python_version >= '3.10') and extra == 'mcp'
34
+ Requires-Dist: mcp<2,>=1.2; (python_version >= '3.10') and extra == 'mcp'
35
35
  Provides-Extra: openai
36
36
  Requires-Dist: openai>=1.40; extra == 'openai'
37
37
  Provides-Extra: test
@@ -614,7 +614,10 @@ message, keeping the last `N` turns verbatim),
614
614
  `/cost` (this session's estimated spend so far; `--session-max-spend` adds a
615
615
  cap), `/usage` (a token + cost breakdown for the session, keeping the
616
616
  cache-read/cache-write/uncached input split distinct so cache-heavy sessions
617
- cost out correctly, plus a **wall-clock** row — total, turn count and average
617
+ cost out correctly — the split and the hit rate report `n/a` when the model's
618
+ usage block carried no cache fields at all, so a printed `0.0%` always means the
619
+ provider reported a real zero rather than that nobody looked — plus a
620
+ **wall-clock** row — total, turn count and average
618
621
  for the time the CLI kept you waiting, measured from submitting a turn to
619
622
  getting the prompt back, so thinking time at the prompt is never counted; it
620
623
  accumulates across `--resume`), `/reset` (clear history, keep the system prompt),
@@ -624,7 +627,11 @@ accumulates across `--resume`), `/reset` (clear history, keep the system prompt)
624
627
  `git commit`, optionally pre-seeded with `text`),
625
628
  `/help`, and `/exit` (or `/quit`, or Ctrl-D). Ctrl-C aborts the current turn
626
629
  without ending the session (while a tool-loop turn is running it first pauses to
627
- steer — see [Mid-run steering](#mid-run-steering-venice-sessions-send)). Tab completes slash-commands (and model ids after
630
+ steer — see [Mid-run steering](#mid-run-steering-venice-sessions-send)); at the
631
+ prompt it just discards the half-typed line and re-prompts. However you leave —
632
+ `/exit`, Ctrl-D, or a Ctrl-C that lands somewhere unexpected — the session is
633
+ flushed to disk first, so slash-command edits like `/model` and `/system` are
634
+ never lost to the way you quit. Tab completes slash-commands (and model ids after
628
635
  `/model `, persona names after `/persona `). At a per-tool confirmation prompt, `a` accepts that call **and**
629
636
  auto-accepts the rest of the run. `--max-tool-calls 0` runs until the model
630
637
  stops on its own (instead of capping at the default and asking to continue).
@@ -1896,6 +1903,7 @@ The player list (`paplay` -> `aplay` -> `ffplay` -> `mpg123` -> `play`
1896
1903
  | `VENICE_EMBED_CA_BUNDLE` | CA bundle to trust for a self-signed embedding backend (`embed`, `index`, `search`, and the `project_search` agent tool) |
1897
1904
  | `VENICE_MCP_MAX_SPEND` | `mcp-serve` auto-approve cap in USD (default `0.10`) |
1898
1905
  | `VENICE_MCP_OUTPUT_DIR` | where `mcp-serve` tools write files (default: cwd) |
1906
+ | `VENICE_USAGE_RAW` | set to `1`/`true`/`yes`/`on` to echo each API response's raw `usage` block to **stderr** as one `usage-raw: {...}` line (diagnostic; stdout stays machine-readable) |
1899
1907
 
1900
1908
  ## Commands at a glance
1901
1909
 
@@ -574,7 +574,10 @@ message, keeping the last `N` turns verbatim),
574
574
  `/cost` (this session's estimated spend so far; `--session-max-spend` adds a
575
575
  cap), `/usage` (a token + cost breakdown for the session, keeping the
576
576
  cache-read/cache-write/uncached input split distinct so cache-heavy sessions
577
- cost out correctly, plus a **wall-clock** row — total, turn count and average
577
+ cost out correctly — the split and the hit rate report `n/a` when the model's
578
+ usage block carried no cache fields at all, so a printed `0.0%` always means the
579
+ provider reported a real zero rather than that nobody looked — plus a
580
+ **wall-clock** row — total, turn count and average
578
581
  for the time the CLI kept you waiting, measured from submitting a turn to
579
582
  getting the prompt back, so thinking time at the prompt is never counted; it
580
583
  accumulates across `--resume`), `/reset` (clear history, keep the system prompt),
@@ -584,7 +587,11 @@ accumulates across `--resume`), `/reset` (clear history, keep the system prompt)
584
587
  `git commit`, optionally pre-seeded with `text`),
585
588
  `/help`, and `/exit` (or `/quit`, or Ctrl-D). Ctrl-C aborts the current turn
586
589
  without ending the session (while a tool-loop turn is running it first pauses to
587
- steer — see [Mid-run steering](#mid-run-steering-venice-sessions-send)). Tab completes slash-commands (and model ids after
590
+ steer — see [Mid-run steering](#mid-run-steering-venice-sessions-send)); at the
591
+ prompt it just discards the half-typed line and re-prompts. However you leave —
592
+ `/exit`, Ctrl-D, or a Ctrl-C that lands somewhere unexpected — the session is
593
+ flushed to disk first, so slash-command edits like `/model` and `/system` are
594
+ never lost to the way you quit. Tab completes slash-commands (and model ids after
588
595
  `/model `, persona names after `/persona `). At a per-tool confirmation prompt, `a` accepts that call **and**
589
596
  auto-accepts the rest of the run. `--max-tool-calls 0` runs until the model
590
597
  stops on its own (instead of capping at the default and asking to continue).
@@ -1856,6 +1863,7 @@ The player list (`paplay` -> `aplay` -> `ffplay` -> `mpg123` -> `play`
1856
1863
  | `VENICE_EMBED_CA_BUNDLE` | CA bundle to trust for a self-signed embedding backend (`embed`, `index`, `search`, and the `project_search` agent tool) |
1857
1864
  | `VENICE_MCP_MAX_SPEND` | `mcp-serve` auto-approve cap in USD (default `0.10`) |
1858
1865
  | `VENICE_MCP_OUTPUT_DIR` | where `mcp-serve` tools write files (default: cwd) |
1866
+ | `VENICE_USAGE_RAW` | set to `1`/`true`/`yes`/`on` to echo each API response's raw `usage` block to **stderr** as one `usage-raw: {...}` line (diagnostic; stdout stays machine-readable) |
1859
1867
 
1860
1868
  ## Commands at a glance
1861
1869
 
@@ -51,7 +51,13 @@ openai = ["openai>=1.40"]
51
51
  # The SDK requires Python >=3.10, so the dep is gated by an environment marker:
52
52
  # on 3.9 the extra resolves to nothing (the CLI stays 3.9-compatible; only this
53
53
  # one optional command needs 3.10+). Imported lazily, like the openai extra.
54
- mcp = ["mcp>=1.2; python_version>='3.10'"]
54
+ # Upper-bounded as of 2026-07-29 (#95): mcp 2.0.0, published 2026-07-28, deletes
55
+ # `mcp.server.fastmcp` -- `mcp.server` now exposes `mcpserver` instead. The old
56
+ # unbounded `mcp>=1.2` turned that into a red CI on every branch within the hour,
57
+ # on 3.10-3.13 only (2.0 declares requires-python >=3.10, so the 3.9 job kept
58
+ # resolving 1.x and stayed green). 1.29.0 is the current 1.x and still ships
59
+ # FastMCP. Lift this when `mcp_serve.py` is ported to the 2.x server API.
60
+ mcp = ["mcp>=1.2,<2; python_version>='3.10'"]
55
61
  # Test-only: `pexpect` drives the real CLI on a pty in tests/test_drive_cli.py.
56
62
  # Deliberately NOT in `all` -- `all` is a *runtime* install, and the CI `package`
57
63
  # job's "a base install pulls in nothing third-party" proof must stay meaningful.
@@ -0,0 +1 @@
1
+ __version__ = "0.78.0"
@@ -24,7 +24,17 @@ def main(argv=None) -> int:
24
24
  if handler is None:
25
25
  parser.print_help(sys.stderr)
26
26
  return 2
27
- return int(handler(args) or 0)
27
+ try:
28
+ return int(handler(args) or 0)
29
+ except KeyboardInterrupt:
30
+ # The net under the per-command Ctrl-C handlers (#92). Each of those returns 130
31
+ # long before the exception could reach here, so this only ever catches what they
32
+ # miss -- and what they miss used to be a raw traceback. Deliberately silent: the
33
+ # command that owns the interrupt has already printed its own notice, and this
34
+ # must not print a second one over it. 130 is what CPython exited with anyway,
35
+ # so no exit code changes; see the table in the README.
36
+ print(file=sys.stderr) # newline past a half-drawn prompt
37
+ return 130
28
38
 
29
39
 
30
40
  if __name__ == "__main__":
@@ -28,12 +28,16 @@ import contextlib
28
28
  import io
29
29
  import itertools
30
30
  import json
31
+ import os
31
32
  import sys
32
33
  import threading
33
34
  from concurrent.futures import ThreadPoolExecutor, as_completed
34
35
  from dataclasses import dataclass
35
36
  from typing import Callable, Dict, List, NamedTuple, Optional, Tuple
36
37
 
38
+ # Aliased: a bare `config` would be shadowed by the `config=None` keyword argument
39
+ # that `browser_tools` (and its callers) already take further down this module.
40
+ from .. import config as _config
37
41
  from .. import userconfig
38
42
  from . import _exec
39
43
  from . import _mcp
@@ -212,6 +216,70 @@ def _detail(usage: dict, section: str, key: str):
212
216
  return None
213
217
 
214
218
 
219
+ #: The cache sub-fields we recognize inside ``prompt_tokens_details``, in priority
220
+ #: order. `reference/venice-openapi.yaml` documents exactly two (`cached_tokens`,
221
+ #: `cache_creation_input_tokens`); `cache_write_tokens` is OBSERVED on live
222
+ #: kimi/glm/deepseek responses but undocumented, so it is read as a fallback and
223
+ #: must never take precedence over the documented name. Deliberately NOT widened
224
+ #: to the Anthropic/DeepSeek-native top-level aliases (`cache_read_input_tokens`,
225
+ #: `prompt_cache_hit_tokens`): Venice never emits them, and an additive top-level
226
+ #: block would hit the clamp in `record()` -- truncating the write bucket to 0
227
+ #: while `CostLedger`'s "reported" flag swore it was measured, which is a NEW lie.
228
+ #: `_cache_tokens`'s three-state is the honest degradation for a rename (#98).
229
+ _CACHE_READ_KEYS = ("cached_tokens",)
230
+ _CACHE_WRITE_KEYS = ("cache_creation_input_tokens", "cache_write_tokens")
231
+
232
+
233
+ def _cache_tokens(usage: dict, keys) -> Optional[int]:
234
+ """A cache token count from ``prompt_tokens_details``; None when unreported (#98).
235
+
236
+ THREE-STATE ON PURPOSE, and that is the whole point of #98. `None` means "this
237
+ response carried no such field at all", which is NOT the same as a reported zero:
238
+ the spec marks `prompt_tokens_details` ``nullable: true`` and leaves it out of
239
+ `required` (its own glm example ships ``prompt_tokens_details: null``), so
240
+ coercing absence to 0 fabricates a "cache hit rate: 0.0%" that reads as a real
241
+ measurement. A printed 0.0% must mean the provider said zero.
242
+
243
+ Present-but-null and present-but-garbage both count as ABSENT -- a value we cannot
244
+ read is not a measurement (mirrors :func:`_as_int`'s "a bool is never a token
245
+ count" rule). :func:`_detail` is kept as-is for `reasoning_tokens`, which stays
246
+ deliberately two-state: no incident, and no rate hangs off it.
247
+ """
248
+ for key in keys:
249
+ v = _detail(usage, "prompt_tokens_details", key)
250
+ if isinstance(v, (int, float)) and not isinstance(v, bool):
251
+ return _as_int(v)
252
+ return None
253
+
254
+
255
+ def _dump_raw_usage(usage) -> None:
256
+ """Echo one API response's raw `usage` block to stderr when opted in (#98).
257
+
258
+ The ledger aggregates, and an aggregate cannot answer "was the field even there?"
259
+ after the fact -- nothing else persists the raw block, so diagnosing a cache
260
+ regression used to mean re-running the incident live.
261
+
262
+ Off unless $VENICE_USAGE_RAW is explicitly truthy -- a bare truthiness check would
263
+ turn this ON for ``VENICE_USAGE_RAW=0``. STDERR only: `venice code --json` and every
264
+ piped command must keep stdout machine-readable.
265
+
266
+ Exactly ONE `print`, so each record stays an atomic line -- this fires on subagent
267
+ and review ledgers too, which run under a ThreadPoolExecutor, and two writes would
268
+ interleave into garbage under `--parallel`. A diagnostic must never take down the
269
+ turn that produced it, hence the blanket serialization fallback.
270
+ """
271
+ if os.environ.get(_config.ENV_USAGE_RAW, "").strip().lower() not in (
272
+ "1", "true", "yes", "on"
273
+ ):
274
+ return
275
+ try:
276
+ raw = usage.model_dump() if hasattr(usage, "model_dump") else usage
277
+ line = json.dumps(raw, sort_keys=True, default=str)
278
+ except Exception: # noqa: BLE001 - a diagnostic must never break the turn
279
+ line = repr(usage)
280
+ print(f"usage-raw: {line}", file=sys.stderr)
281
+
282
+
215
283
  def format_duration(seconds) -> str:
216
284
  """A compact human duration: ``4.5s``, ``2m 14s``, ``1h 03m`` (#81).
217
285
 
@@ -258,6 +326,16 @@ class CostLedger:
258
326
  self.cache_write_tokens = 0
259
327
  self.reasoning_tokens = 0
260
328
  self.unpriced = False # saw a turn whose model price was unknown
329
+ # #98: sticky "at least one recorded turn carried no recognized cache-read
330
+ # (resp. cache-write) field", mirroring `unpriced`. These exist so a rendered
331
+ # 0 can be told apart from an absent one -- the buckets above cannot say
332
+ # "unknown", and `cache hit rate: 0.0%` was being emitted by byte-identical
333
+ # code for a real miss and for a response with no cache block at all. They
334
+ # drive DIFFERENT render sites (see `usage_report`): read-reported-but-
335
+ # write-absent is the normal state for most Venice models, so folding them
336
+ # into one marker would tag essentially every session.
337
+ self.cache_read_unreported = False
338
+ self.cache_write_unreported = False
261
339
  # #81: the operator's wall-clock. `elapsed_seconds` is BLOCKED time only --
262
340
  # the caller stamps the window and passes the delta, so the ledger never reads
263
341
  # a clock and every ledger test stays a pure-function assertion. `turns` counts
@@ -299,6 +377,8 @@ class CostLedger:
299
377
  "unpriced": self.unpriced,
300
378
  "elapsed_seconds": self.elapsed_seconds, # #81
301
379
  "turns": self.turns,
380
+ "cache_read_unreported": self.cache_read_unreported, # #98
381
+ "cache_write_unreported": self.cache_write_unreported, # #98
302
382
  }
303
383
 
304
384
  def restore(self, d) -> None:
@@ -328,6 +408,14 @@ class CostLedger:
328
408
  self.turns += _as_int(d.get("turns"))
329
409
  if d.get("unpriced"):
330
410
  self.unpriced = True
411
+ # #98: sticky-OR like `unpriced`, and falsy-by-default so a pre-#98 envelope
412
+ # (which has neither key) claims "reported" rather than retroactively marking
413
+ # every resumed session unknown. The inaccuracy has a one-turn lifetime: the
414
+ # flags are sticky, so the resumed session's first `record()` sets the truth.
415
+ if d.get("cache_read_unreported"):
416
+ self.cache_read_unreported = True
417
+ if d.get("cache_write_unreported"):
418
+ self.cache_write_unreported = True
331
419
 
332
420
  def record_turn(self, seconds) -> None:
333
421
  """Add one blocked window's wall-clock and count the turn (#81).
@@ -351,7 +439,19 @@ class CostLedger:
351
439
  buckets are subsets of `prompt_tokens` in Venice's OpenAI-normalized
352
440
  usage shape, so uncached input is the remainder. With no cache tokens and
353
441
  no cache pricing this reduces exactly to the old `pt*in + ct*out`.
442
+
443
+ A cache field the response never carried is recorded as *unreported* rather
444
+ than as zero (#98) -- see :func:`_cache_tokens`. The arithmetic is unchanged
445
+ by that: every shape that used to yield 0 still yields 0.
446
+
447
+ NOT READ: the `/responses` endpoint's second usage shape (`input_tokens` /
448
+ `input_tokens_details.cached_tokens`). The CLI never calls it today, but if it
449
+ ever does this ledger silently zeroes rather than erroring.
354
450
  """
451
+ # FIRST, ahead of every bail-out below: `record(None)` dumping `usage-raw: null`
452
+ # is exactly the diagnostic that was missing -- it separates "the response had
453
+ # no usage block" from "it had one with no cache fields" (#98).
454
+ _dump_raw_usage(usage)
355
455
  if usage is None:
356
456
  return 0.0
357
457
  if hasattr(usage, "model_dump"):
@@ -360,10 +460,17 @@ class CostLedger:
360
460
  return 0.0
361
461
  pt = _as_int(usage.get("prompt_tokens"))
362
462
  ct = _as_int(usage.get("completion_tokens"))
363
- cache_read = _as_int(_detail(usage, "prompt_tokens_details", "cached_tokens"))
364
- cache_write = _as_int(
365
- _detail(usage, "prompt_tokens_details", "cache_creation_input_tokens")
366
- )
463
+ # #98: None means the field was absent, which is not a reported zero. The
464
+ # flags are what `usage_report` needs to refuse to print a fake 0.0%; the
465
+ # counts fall back to 0 so the cost math below is untouched.
466
+ raw_read = _cache_tokens(usage, _CACHE_READ_KEYS)
467
+ raw_write = _cache_tokens(usage, _CACHE_WRITE_KEYS)
468
+ if raw_read is None:
469
+ self.cache_read_unreported = True
470
+ if raw_write is None:
471
+ self.cache_write_unreported = True
472
+ cache_read = 0 if raw_read is None else raw_read
473
+ cache_write = 0 if raw_write is None else raw_write
367
474
  reasoning = _as_int(
368
475
  _detail(usage, "completion_tokens_details", "reasoning_tokens")
369
476
  )
@@ -449,16 +556,35 @@ class CostLedger:
449
556
  if self.prompt_tokens else 0.0
450
557
  )
451
558
  lines = ["session usage:"]
452
- lines.append(
453
- f" input {self.prompt_tokens:>10,} tok "
454
- f"({uncached:,} uncached + {self.cache_read_tokens:,} cache-read "
455
- f"+ {self.cache_write_tokens:,} cache-write)"
456
- )
559
+ # #98: the two flags govern DIFFERENT rows on purpose. Read-reported-but-
560
+ # write-absent is the normal shape for most Venice models, so a shared marker
561
+ # would decorate the hit rate of nearly every session with a warning about a
562
+ # bucket the hit rate does not even use.
563
+ if self.cache_read_unreported and self.cache_write_unreported:
564
+ # Nothing about the split is known -- don't itemize a breakdown that is
565
+ # entirely inferred, same reflex as "(no tokens reported)" above.
566
+ split = "cache breakdown not reported"
567
+ else:
568
+ read_part = ("cache-read n/a" if self.cache_read_unreported
569
+ else f"{self.cache_read_tokens:,} cache-read")
570
+ write_part = ("cache-write n/a" if self.cache_write_unreported
571
+ else f"{self.cache_write_tokens:,} cache-write")
572
+ # NOTE: with a side unreported, `uncached` is an UPPER BOUND and the terms
573
+ # no longer sum to the input total -- the n/a shows where the slack went.
574
+ split = f"{uncached:,} uncached + {read_part} + {write_part}"
575
+ lines.append(f" input {self.prompt_tokens:>10,} tok ({split})")
457
576
  out = f" output {self.completion_tokens:>10,} tok"
458
577
  if self.reasoning_tokens:
459
578
  out += f" (incl. {self.reasoning_tokens:,} reasoning)"
460
579
  lines.append(out)
461
- lines.append(f" cache hit rate: {hit:.1f}%")
580
+ if not self.cache_read_unreported:
581
+ lines.append(f" cache hit rate: {hit:.1f}%")
582
+ elif self.cache_read_tokens:
583
+ # Some turns measured, some didn't: the rate is real but understated
584
+ # against a prompt total that includes turns it could not see.
585
+ lines.append(f" cache hit rate: {hit:.1f}% [partially unreported]")
586
+ else:
587
+ lines.append(" cache hit rate: n/a (no cache fields reported)")
462
588
  if self.unpriced and self.total == 0.0:
463
589
  lines.append(" cost: (model rate unknown)")
464
590
  else:
@@ -700,42 +700,63 @@ def run(args, oai, openai, client, models, model, initial=None, *,
700
700
  label=label, auto=state["yes"], session=active,
701
701
  ephemeral=ephemeral)
702
702
 
703
- # An explicit message (`venice chat -i "hello"`) becomes the first turn.
704
- if initial:
705
- _do_turn(oai, openai, chat, initial, messages, gen_kwargs, state, args)
703
+ # The session flush lives in exactly one place -- the `finally` below -- so that
704
+ # no exit path can skip it (#92). It used to be hand-copied into the ^D handler
705
+ # and the /exit branch, and the ^D copy was skippable: a SIGINT delivered *inside*
706
+ # an `except` block is not caught by a sibling clause of the same `try`, so the
707
+ # `except KeyboardInterrupt` here only ever guarded `input()`, never the recovery
708
+ # code after it. ^D-then-^C (or a lone ^C hitting the readline EOF/SIGINT race)
709
+ # landed between the newline and the save, and the save never ran. Same structural
710
+ # move as #86's ledger `_finish`: one call, in a `finally`, unskippable.
711
+ try:
712
+ # An explicit message (`venice chat -i "hello"`) becomes the first turn.
713
+ if initial:
714
+ _do_turn(oai, openai, chat, initial, messages, gen_kwargs, state, args)
706
715
 
707
- while True:
708
- try:
709
- line = input(_PROMPT)
710
- except EOFError:
711
- print(file=sys.stderr) # newline after ^D
712
- _autosave(state, messages, gen_kwargs) # flush /model,/system-only edits
713
- return 0
714
- except KeyboardInterrupt:
715
- print(file=sys.stderr) # ^C at the prompt: discard the line, re-prompt
716
- continue
717
- line = line.strip()
718
- if not line:
719
- continue
720
- if line.startswith("/"):
721
- # /paste and /edit compose a multi-line turn, then submit it like a
722
- # normal message. They're handled here (not in _dispatch_slash, which
723
- # lacks `openai`/`chat`) so they can call _do_turn directly. (#65)
724
- cmd, _, rest = line[1:].partition(" ")
725
- if cmd.lower() in ("paste", "edit"):
726
- text = (_read_paste_block() if cmd.lower() == "paste"
727
- else _compose_in_editor(rest.strip()))
728
- if text:
729
- _do_turn(oai, openai, chat, text, messages, gen_kwargs,
730
- state, args)
731
- continue
732
- if _dispatch_slash(
733
- line, messages, state, args, models, oai=oai, gen_kwargs=gen_kwargs
734
- ) == "exit":
735
- _autosave(state, messages, gen_kwargs)
716
+ while True:
717
+ try:
718
+ line = input(_PROMPT)
719
+ except EOFError:
720
+ print(file=sys.stderr) # newline after ^D
736
721
  return 0
737
- continue
738
- _do_turn(oai, openai, chat, line, messages, gen_kwargs, state, args)
722
+ except KeyboardInterrupt:
723
+ print(file=sys.stderr) # ^C at the prompt: discard the line, re-prompt
724
+ continue
725
+ line = line.strip()
726
+ if not line:
727
+ continue
728
+ if line.startswith("/"):
729
+ # /paste and /edit compose a multi-line turn, then submit it like a
730
+ # normal message. They're handled here (not in _dispatch_slash, which
731
+ # lacks `openai`/`chat`) so they can call _do_turn directly. (#65)
732
+ cmd, _, rest = line[1:].partition(" ")
733
+ if cmd.lower() in ("paste", "edit"):
734
+ text = (_read_paste_block() if cmd.lower() == "paste"
735
+ else _compose_in_editor(rest.strip()))
736
+ if text:
737
+ _do_turn(oai, openai, chat, text, messages, gen_kwargs,
738
+ state, args)
739
+ continue
740
+ if _dispatch_slash(
741
+ line, messages, state, args, models, oai=oai, gen_kwargs=gen_kwargs
742
+ ) == "exit":
743
+ return 0
744
+ continue
745
+ _do_turn(oai, openai, chat, line, messages, gen_kwargs, state, args)
746
+ except KeyboardInterrupt:
747
+ # Whatever the inner clauses didn't already handle: a ^C inside the ^D
748
+ # teardown, or during a slash-command's own API call. The per-turn abort
749
+ # (`_turn`, above) and the at-the-prompt re-prompt fire first and never reach
750
+ # here, so this is always "something unexpected got interrupted" -- end the
751
+ # session, the way every other command's Ctrl-C handler does. 130 is what
752
+ # CPython already exited with; this just makes it deliberate and quiet.
753
+ print(f"\n{label}: aborted", file=sys.stderr)
754
+ return 130
755
+ finally:
756
+ # Also runs on an unexpected crash, which is new: a session that died mid-run
757
+ # is now persisted rather than lost. `_autosave` no-ops without an active
758
+ # session (--ephemeral) and swallows disk errors, so this can't itself fail.
759
+ _autosave(state, messages, gen_kwargs)
739
760
 
740
761
 
741
762
  def _banner(model, tools_on, resume, messages, *, label="venice chat",
@@ -69,6 +69,14 @@ MEMORY_SUBDIR = "memory" # under INDEX_DIRNAME (.venice) for the project tier
69
69
  MEMORY_FILENAME = "memory.json"
70
70
  TASKS_FILENAME = "tasks.json"
71
71
 
72
+ # Opt-in verbatim dump of each API response's `usage` block to stderr (#98). The
73
+ # ledger aggregates, and an aggregate cannot answer "was the field even there?"
74
+ # after the fact -- nothing persists the raw block anywhere (`venice code --json`
75
+ # and the session envelope both carry only the summed `CostLedger.to_dict()`), so
76
+ # diagnosing a cache regression meant re-running the incident live. Diagnostic
77
+ # only: stderr, so a piped stdout stays machine-readable.
78
+ ENV_USAGE_RAW = "VENICE_USAGE_RAW"
79
+
72
80
  SFX_POLL_INTERVAL_SEC = 2.0
73
81
  SFX_POLL_MAX_WAIT_SEC = 300
74
82
 
@@ -188,6 +188,15 @@ class Driver:
188
188
  def send(self, line: str) -> None:
189
189
  self.child.sendline(line)
190
190
 
191
+ def send_partial(self, text: str) -> None:
192
+ """Type without submitting -- no trailing newline.
193
+
194
+ For testing what happens to a line the user never sent: Ctrl-C at the
195
+ prompt is supposed to discard it (#92). ``send`` would submit it and turn
196
+ the half-typed line into a real turn.
197
+ """
198
+ self.child.send(text)
199
+
191
200
  def send_secret(self, value: str) -> None:
192
201
  """Type a secret, and keep it out of any failure transcript.
193
202