tsugite-claude-code 0.23.2__tar.gz → 0.24.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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tsugite-claude-code
3
- Version: 0.23.2
3
+ Version: 0.24.0
4
4
  Summary: Tsugite plugin: Claude Code provider (routes LLM calls through the claude CLI)
5
5
  Author: Justyn Shull
6
6
  Requires-Python: >=3.11
7
- Requires-Dist: tsugite-cli==0.23.2
7
+ Requires-Dist: tsugite-cli==0.24.0
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "tsugite-claude-code"
3
- version = "0.23.2"
3
+ version = "0.24.0"
4
4
  description = "Tsugite plugin: Claude Code provider (routes LLM calls through the claude CLI)"
5
5
  authors = [{ name = "Justyn Shull" }]
6
6
  requires-python = ">=3.11"
7
7
  dependencies = [
8
- "tsugite-cli==0.23.2",
8
+ "tsugite-cli==0.24.0",
9
9
  ]
10
10
 
11
11
  [project.entry-points."tsugite.providers"]
@@ -19,6 +19,7 @@ from collections.abc import AsyncIterator
19
19
  from pathlib import Path
20
20
 
21
21
  from tsugite.cli.helpers import get_workspace_dir
22
+ from tsugite_claude_code.usage import record_rate_limit
22
23
 
23
24
  logger = logging.getLogger(__name__)
24
25
 
@@ -368,7 +369,8 @@ class ClaudeCodeProcess:
368
369
  }
369
370
  return
370
371
 
371
- # Skip: rate_limit_event, system, etc.
372
+ elif event_type == "rate_limit_event":
373
+ record_rate_limit(event.get("rate_limit_info") or {})
372
374
 
373
375
  async def stop(self) -> None:
374
376
  """Terminate subprocess and clean up temp files."""
@@ -14,62 +14,43 @@ logger = logging.getLogger(__name__)
14
14
 
15
15
  _CLAUDE_CODE_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"]
16
16
 
17
+ # --- BEGIN GENERATED MODELS (claude_code) --- #
18
+ # Auto-generated by scripts/update_model_registry.py, do not edit manually
19
+ # fmt: off
17
20
  _CLAUDE_CODE_MODELS: dict[str, ModelInfo] = {
18
- "claude_code/claude-fable-5": ModelInfo(
19
- max_input_tokens=1_000_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS
20
- ),
21
- "claude_code/claude-fable-5-1": ModelInfo(
22
- max_input_tokens=1_000_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS
23
- ),
24
- "claude_code/claude-haiku-4-5": ModelInfo(
25
- max_input_tokens=200_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS
26
- ),
27
- "claude_code/claude-haiku-4-5-20251001": ModelInfo(
28
- max_input_tokens=200_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS
29
- ),
30
- "claude_code/claude-opus-4-5": ModelInfo(
31
- max_input_tokens=200_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS
32
- ),
33
- "claude_code/claude-opus-4-5-20251101": ModelInfo(
34
- max_input_tokens=200_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS
35
- ),
36
- "claude_code/claude-opus-4-6": ModelInfo(
37
- max_input_tokens=1_000_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS
38
- ),
39
- "claude_code/claude-opus-4-7": ModelInfo(
40
- max_input_tokens=1_000_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS
41
- ),
42
- "claude_code/claude-opus-4-8": ModelInfo(
43
- max_input_tokens=1_000_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS
44
- ),
45
- "claude_code/claude-opus-5": ModelInfo(
46
- max_input_tokens=1_000_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS
47
- ),
48
- "claude_code/claude-sonnet-4-5": ModelInfo(
49
- max_input_tokens=1_000_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS
50
- ),
51
- "claude_code/claude-sonnet-4-5-20250929": ModelInfo(
52
- max_input_tokens=1_000_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS
53
- ),
54
- "claude_code/claude-sonnet-4-6": ModelInfo(
55
- max_input_tokens=1_000_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS
56
- ),
57
- "claude_code/claude-sonnet-5": ModelInfo(
58
- max_input_tokens=1_000_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS
59
- ),
21
+ "claude_code/claude-fable-5": ModelInfo(max_input_tokens=1_000_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS),
22
+ "claude_code/claude-fable-5-1": ModelInfo(max_input_tokens=1_000_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS),
23
+ "claude_code/claude-haiku-4-5": ModelInfo(max_input_tokens=200_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS),
24
+ "claude_code/claude-haiku-4-5-20251001": ModelInfo(max_input_tokens=200_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS),
25
+ "claude_code/claude-haiku-5-5": ModelInfo(max_input_tokens=1_000_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS),
26
+ "claude_code/claude-opus-4-5": ModelInfo(max_input_tokens=200_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS),
27
+ "claude_code/claude-opus-4-5-20251101": ModelInfo(max_input_tokens=200_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS),
28
+ "claude_code/claude-opus-4-6": ModelInfo(max_input_tokens=1_000_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS),
29
+ "claude_code/claude-opus-4-7": ModelInfo(max_input_tokens=1_000_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS),
30
+ "claude_code/claude-opus-4-8": ModelInfo(max_input_tokens=1_000_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS),
31
+ "claude_code/claude-opus-5": ModelInfo(max_input_tokens=1_000_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS),
32
+ "claude_code/claude-opus-5-5": ModelInfo(max_input_tokens=1_000_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS),
33
+ "claude_code/claude-sonnet-4-5": ModelInfo(max_input_tokens=200_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS),
34
+ "claude_code/claude-sonnet-4-5-20250929": ModelInfo(max_input_tokens=200_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS),
35
+ "claude_code/claude-sonnet-4-6": ModelInfo(max_input_tokens=1_000_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS),
36
+ "claude_code/claude-sonnet-5": ModelInfo(max_input_tokens=1_000_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS),
37
+ "claude_code/claude-sonnet-5-5": ModelInfo(max_input_tokens=1_000_000, supports_vision=True, supported_effort_levels=_CLAUDE_CODE_EFFORT_LEVELS),
60
38
  }
39
+ # fmt: on
40
+ # --- END GENERATED MODELS (claude_code) --- #
61
41
 
62
42
  # Bare tier aliases track the newest model in that tier; version-pinned aliases
63
43
  # stay for reproducible configs. Exception: opus stays on 4-8. Opus 5's replies
64
44
  # through Claude Code's resume path can leave an empty assistant block that wedges
65
45
  # the sidecar transcript (400 "text content blocks must be non-empty"), and the
66
- # fresh-session fallback only covers the first send, so opus-5 is opt-in via its
67
- # pinned alias until a mid-conversation poison recovers cleanly.
46
+ # fresh-session fallback only covers the first send, so opus-5 and opus-5-5 are
47
+ # opt-in via their pinned aliases until a mid-conversation poison recovers cleanly.
68
48
  _ALIASES = {
69
49
  "fable": "claude-fable-5-1",
70
50
  "fable-5-1": "claude-fable-5-1",
71
51
  "fable-5": "claude-fable-5",
72
52
  "opus": "claude-opus-4-8",
53
+ "opus-5-5": "claude-opus-5-5",
73
54
  "opus-5": "claude-opus-5",
74
55
  "opus-4-8": "claude-opus-4-8",
75
56
  "opus-4-7": "claude-opus-4-7",
@@ -77,11 +58,13 @@ _ALIASES = {
77
58
  "opus-4-5": "claude-opus-4-5",
78
59
  "opus-4-5-20251101": "claude-opus-4-5-20251101",
79
60
  "sonnet": "claude-sonnet-5",
61
+ "sonnet-5-5": "claude-sonnet-5-5",
80
62
  "sonnet-5": "claude-sonnet-5",
81
63
  "sonnet-4-6": "claude-sonnet-4-6",
82
64
  "sonnet-4-5": "claude-sonnet-4-5",
83
65
  "sonnet-4-5-20250929": "claude-sonnet-4-5-20250929",
84
- "haiku": "claude-haiku-4-5-20251001",
66
+ "haiku": "claude-haiku-5-5",
67
+ "haiku-5-5": "claude-haiku-5-5",
85
68
  "haiku-4-5": "claude-haiku-4-5",
86
69
  "haiku-4-5-20251001": "claude-haiku-4-5-20251001",
87
70
  }
@@ -150,6 +133,8 @@ class ClaudeCodeProvider:
150
133
  self._resume_session = kwargs.get("resume_session")
151
134
  self._resume_after_compaction = kwargs.get("resume_after_compaction", False)
152
135
  self._previous_messages = kwargs.get("previous_messages", [])
136
+ # total_cost_usd is cumulative for the whole --resume'd session, not this process.
137
+ self._cumulative_cost = kwargs.get("resume_cost_baseline") or 0.0
153
138
 
154
139
  def get_state(self) -> dict | None:
155
140
  return {
@@ -158,6 +143,7 @@ class ClaudeCodeProvider:
158
143
  "context_window": self._context_window,
159
144
  "cache_creation_tokens": self._cache_creation_tokens,
160
145
  "cache_read_tokens": self._cache_read_tokens,
146
+ "cumulative_cost": self._cumulative_cost,
161
147
  }
162
148
 
163
149
  async def acompletion(
@@ -236,6 +222,7 @@ class ClaudeCodeProvider:
236
222
  await self._process.stop()
237
223
  self._resume_session = None
238
224
  self._resume_after_compaction = False
225
+ self._cumulative_cost = 0.0
239
226
  self._process = ClaudeCodeProcess()
240
227
  await self._process.start(
241
228
  model=self._resolved_model,
@@ -350,6 +337,9 @@ class ClaudeCodeProvider:
350
337
  header += ">"
351
338
  parts.append(header + "\n" + "\n\n".join(history_lines) + "\n</conversation_history>\n")
352
339
 
340
+ if self._resume_session:
341
+ parts.extend(self._unseen_delivery_cards())
342
+
353
343
  # Add the task (last user message — earlier user messages are context/history)
354
344
  for msg in reversed(messages):
355
345
  if msg.get("role") == "user":
@@ -379,6 +369,22 @@ class ClaudeCodeProvider:
379
369
  blocks.insert(0, {"type": "text", "text": text})
380
370
  return blocks
381
371
 
372
+ def _unseen_delivery_cards(self) -> list[str]:
373
+ """FYI delivery cards recorded after the last assistant reply.
374
+
375
+ A resumed session skips stored history and the CLI transcript never saw
376
+ these cards. needs_ack cards already render in <pending_deliveries>.
377
+ """
378
+ last_reply = max((i for i, m in enumerate(self._previous_messages) if m.get("role") == "assistant"), default=-1)
379
+ cards = []
380
+ for msg in self._previous_messages[last_reply + 1 :]:
381
+ content = msg.get("content")
382
+ if not isinstance(content, str) or not content.startswith("<tsugite_delivery "):
383
+ continue
384
+ if 'kind="needs_ack"' not in content.split("\n", 1)[0]:
385
+ cards.append(content + "\n")
386
+ return cards
387
+
382
388
  def _image_blocks(self) -> list[dict]:
383
389
  """base64 image `source` payloads for the API-supported image attachments.
384
390
 
@@ -1,9 +1,11 @@
1
1
  """Claude Code subscription limits as a tsugite quota source.
2
2
 
3
3
  Read from ``limits[]`` in the usage endpoint Claude Code calls, with its OAuth
4
- token. When that fails, the usage copy cached in Claude Code's config is used
5
- while it is under an hour old. The list is self-describing, so a limit kind
6
- tsugite has never seen still shows up.
4
+ token. The endpoint rate-limits hard, so it is called at most hourly and backs
5
+ off after a failure, and each turn's ``rate_limit_event`` keeps the session and
6
+ weekly windows current in between. With no endpoint reading, the usage copy
7
+ cached in Claude Code's config is used while it is under an hour old. The list
8
+ is self-describing, so a limit kind tsugite has never seen still shows up.
7
9
  """
8
10
 
9
11
  from __future__ import annotations
@@ -12,6 +14,7 @@ import json
12
14
  import logging
13
15
  import os
14
16
  import time
17
+ from datetime import datetime, timezone
15
18
  from pathlib import Path
16
19
 
17
20
  import httpx
@@ -25,9 +28,14 @@ PROVIDER = "claude_code"
25
28
  LABEL = "Claude Code"
26
29
  USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
27
30
  CACHE_MAX_AGE_MS = 60 * 60 * 1000
28
- LIVE_TTL_S = 60
31
+ LIVE_TTL_S = 60 * 60
32
+ BACKOFF_S = (5 * 60, 10 * 60, 20 * 60, 60 * 60)
33
+ TURN_WINDOWS = {"five_hour": "session", "seven_day": "weekly_all"}
29
34
 
30
35
  _last_live: tuple[float, list] | None = None
36
+ _failures = 0
37
+ _retry_at = 0.0
38
+ _turn: dict[str, tuple[float, QuotaWindow]] = {}
31
39
 
32
40
 
33
41
  def _config_path() -> Path:
@@ -40,25 +48,35 @@ def _credentials_path() -> Path:
40
48
  return Path(base) / ".credentials.json"
41
49
 
42
50
 
43
- def _live_limits() -> list:
44
- global _last_live
45
- if _last_live and time.monotonic() - _last_live[0] < LIVE_TTL_S:
46
- return _last_live[1]
51
+ def _request_limits() -> list:
47
52
  token = json.loads(_credentials_path().read_text())["claudeAiOauth"]["accessToken"]
48
53
  headers = {"Authorization": f"Bearer {token}", "anthropic-beta": "oauth-2025-04-20"}
49
54
  set_user_agent_header(headers)
50
55
  response = httpx.get(USAGE_URL, headers=headers, timeout=10)
51
56
  response.raise_for_status()
52
- limits = response.json()["limits"]
53
- _last_live = (time.monotonic(), limits)
54
- return limits
57
+ return response.json()["limits"]
55
58
 
56
59
 
57
- def _cached_limits() -> list:
60
+ def _refresh_live() -> None:
61
+ global _last_live, _failures, _retry_at
62
+ now = time.time()
63
+ if now < _retry_at:
64
+ return
65
+ try:
66
+ _last_live = (now, _request_limits())
67
+ except Exception as e:
68
+ logger.warning("Claude Code usage endpoint failed: %r", e)
69
+ _retry_at = now + BACKOFF_S[min(_failures, len(BACKOFF_S) - 1)]
70
+ _failures += 1
71
+ return
72
+ _failures, _retry_at = 0, now + LIVE_TTL_S
73
+
74
+
75
+ def _cached_limits() -> tuple[float, list]:
58
76
  cache = json.loads(_config_path().read_text())["cachedUsageUtilization"]
59
77
  if time.time() * 1000 - cache["fetchedAtMs"] > CACHE_MAX_AGE_MS:
60
78
  raise ValueError("cached usage is over an hour old")
61
- return cache["utilization"]["limits"]
79
+ return cache["fetchedAtMs"] / 1000, cache["utilization"]["limits"]
62
80
 
63
81
 
64
82
  def _label(limit: dict) -> str:
@@ -72,22 +90,41 @@ def _label(limit: dict) -> str:
72
90
  return kind
73
91
 
74
92
 
93
+ def record_rate_limit(info: dict) -> None:
94
+ """Keep the windows from a turn's ``rate_limit_event`` (``rate_limit_info``)."""
95
+ windows = info.get("unifiedWindows") or {}
96
+ for name, key in TURN_WINDOWS.items():
97
+ window = windows.get(name)
98
+ if not window or window.get("utilization") is None:
99
+ continue
100
+ resets_at = window.get("resetsAt")
101
+ _turn[key] = (
102
+ time.time(),
103
+ QuotaWindow(
104
+ key=key,
105
+ label=_label({"kind": key}),
106
+ used_pct=round(window["utilization"] * 100, 1),
107
+ resets_at=datetime.fromtimestamp(resets_at, timezone.utc).isoformat() if resets_at else None,
108
+ ),
109
+ )
110
+
111
+
75
112
  def _error(message: str) -> QuotaReport:
76
113
  return QuotaReport(provider=PROVIDER, label=LABEL, error=message)
77
114
 
78
115
 
79
116
  def fetch() -> QuotaReport:
80
117
  try:
81
- limits = _live_limits()
82
- except Exception as live_error:
83
- try:
84
- limits = _cached_limits()
85
- except Exception as cache_error:
86
- logger.warning("Claude Code usage unavailable: live %r, cache %r", live_error, cache_error)
118
+ _refresh_live()
119
+ fetched_at, limits = _last_live or _cached_limits()
120
+ except Exception as e:
121
+ if not _turn:
122
+ logger.warning("Claude Code usage unavailable: %r", e)
87
123
  return _error("Claude Code's usage limits could not be fetched, and no recent cached copy exists")
124
+ fetched_at, limits = 0.0, []
88
125
 
89
126
  try:
90
- windows = tuple(
127
+ windows = [
91
128
  QuotaWindow(
92
129
  key=limit["kind"],
93
130
  label=_label(limit),
@@ -96,11 +133,13 @@ def fetch() -> QuotaReport:
96
133
  )
97
134
  for limit in limits
98
135
  if limit.get("percent") is not None
99
- )
136
+ ]
100
137
  except Exception:
101
138
  return _error("Claude Code's usage limits are not in a shape tsugite understands")
102
139
 
103
- return QuotaReport(provider=PROVIDER, label=LABEL, windows=windows)
140
+ turn = {key: window for key, (read_at, window) in _turn.items() if read_at > fetched_at}
141
+ windows = [*turn.values(), *(w for w in windows if w.key not in turn)]
142
+ return QuotaReport(provider=PROVIDER, label=LABEL, windows=tuple(windows))
104
143
 
105
144
 
106
145
  register_quota_source(PROVIDER, LABEL, fetch)