tsugite-claude-code 0.23.1__tar.gz → 0.23.2__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.
- {tsugite_claude_code-0.23.1 → tsugite_claude_code-0.23.2}/PKG-INFO +2 -2
- {tsugite_claude_code-0.23.1 → tsugite_claude_code-0.23.2}/pyproject.toml +2 -2
- {tsugite_claude_code-0.23.1 → tsugite_claude_code-0.23.2}/tsugite_claude_code/process.py +35 -2
- tsugite_claude_code-0.23.2/tsugite_claude_code/usage.py +106 -0
- tsugite_claude_code-0.23.1/tsugite_claude_code/usage.py +0 -70
- {tsugite_claude_code-0.23.1 → tsugite_claude_code-0.23.2}/.gitignore +0 -0
- {tsugite_claude_code-0.23.1 → tsugite_claude_code-0.23.2}/tsugite_claude_code/__init__.py +0 -0
- {tsugite_claude_code-0.23.1 → tsugite_claude_code-0.23.2}/tsugite_claude_code/provider.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tsugite-claude-code
|
|
3
|
-
Version: 0.23.
|
|
3
|
+
Version: 0.23.2
|
|
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.
|
|
7
|
+
Requires-Dist: tsugite-cli==0.23.2
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "tsugite-claude-code"
|
|
3
|
-
version = "0.23.
|
|
3
|
+
version = "0.23.2"
|
|
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.
|
|
8
|
+
"tsugite-cli==0.23.2",
|
|
9
9
|
]
|
|
10
10
|
|
|
11
11
|
[project.entry-points."tsugite.providers"]
|
|
@@ -10,11 +10,13 @@ import asyncio
|
|
|
10
10
|
import json
|
|
11
11
|
import logging
|
|
12
12
|
import os
|
|
13
|
+
import re
|
|
13
14
|
import shutil
|
|
14
15
|
import tempfile
|
|
15
16
|
import time
|
|
16
17
|
import uuid
|
|
17
18
|
from collections.abc import AsyncIterator
|
|
19
|
+
from pathlib import Path
|
|
18
20
|
|
|
19
21
|
from tsugite.cli.helpers import get_workspace_dir
|
|
20
22
|
|
|
@@ -38,6 +40,34 @@ _STREAM_READ_LIMIT = 16 * 1024 * 1024
|
|
|
38
40
|
_STALL_WARN_SECONDS = 300.0
|
|
39
41
|
_STALL_WARN_ENV = "TSUGITE_CLAUDE_CODE_STALL_WARN_SECONDS"
|
|
40
42
|
|
|
43
|
+
# The CLI reads `@path` in a user turn as its own file-mention syntax and injects that
|
|
44
|
+
# file itself, below tsugite's tool layer and outside `allowed_secrets`. A backslash
|
|
45
|
+
# blocks it. Escaping every `@` would mangle decorators, e-mail addresses and npm scopes.
|
|
46
|
+
_AT_TOKEN = re.compile(r"@(\S+)")
|
|
47
|
+
_MENTION_TRAILERS = ",.;:!?)]}>\"'"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolves_to_path(candidate: str, cwd: Path) -> bool:
|
|
51
|
+
try:
|
|
52
|
+
return (cwd / candidate).exists()
|
|
53
|
+
except OSError:
|
|
54
|
+
return False # exists() raises ENAMETOOLONG rather than returning False.
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _escape_at_mentions(content: str | list, cwd: Path) -> str | list:
|
|
58
|
+
def escape(match: re.Match) -> str:
|
|
59
|
+
candidate = match.group(1).rstrip(_MENTION_TRAILERS)
|
|
60
|
+
if candidate and _resolves_to_path(candidate, cwd):
|
|
61
|
+
return "\\" + match.group(0)
|
|
62
|
+
return match.group(0)
|
|
63
|
+
|
|
64
|
+
if isinstance(content, str):
|
|
65
|
+
return _AT_TOKEN.sub(escape, content)
|
|
66
|
+
return [
|
|
67
|
+
{**b, "text": _AT_TOKEN.sub(escape, b["text"])} if isinstance(b, dict) and b.get("type") == "text" else b
|
|
68
|
+
for b in content
|
|
69
|
+
]
|
|
70
|
+
|
|
41
71
|
|
|
42
72
|
class ClaudeCodeProcess:
|
|
43
73
|
"""Manages a persistent claude CLI subprocess for LLM completions.
|
|
@@ -61,6 +91,7 @@ class ClaudeCodeProcess:
|
|
|
61
91
|
else float(os.environ.get(_STALL_WARN_ENV, _STALL_WARN_SECONDS))
|
|
62
92
|
)
|
|
63
93
|
self._last_output_at: float = 0.0
|
|
94
|
+
self._cwd: Path | None = None
|
|
64
95
|
|
|
65
96
|
@property
|
|
66
97
|
def session_id(self) -> str | None:
|
|
@@ -181,6 +212,7 @@ class ClaudeCodeProcess:
|
|
|
181
212
|
env["CLAUDE_CODE_DISABLE_CLAUDE_MDS"] = "1"
|
|
182
213
|
|
|
183
214
|
workspace = get_workspace_dir()
|
|
215
|
+
self._cwd = workspace
|
|
184
216
|
self._process = await asyncio.create_subprocess_exec(
|
|
185
217
|
*cmd,
|
|
186
218
|
stdin=asyncio.subprocess.PIPE,
|
|
@@ -207,8 +239,8 @@ class ClaudeCodeProcess:
|
|
|
207
239
|
|
|
208
240
|
Args:
|
|
209
241
|
content: User message text, or an Anthropic content-block list
|
|
210
|
-
(text + image blocks) for a multimodal turn.
|
|
211
|
-
|
|
242
|
+
(text + image blocks) for a multimodal turn. The CLI relays
|
|
243
|
+
blocks to the API.
|
|
212
244
|
|
|
213
245
|
Yields:
|
|
214
246
|
Dicts with type "text_delta" (streaming chunk) or "result" (final)
|
|
@@ -216,6 +248,7 @@ class ClaudeCodeProcess:
|
|
|
216
248
|
Raises:
|
|
217
249
|
RuntimeError: If subprocess has crashed
|
|
218
250
|
"""
|
|
251
|
+
content = _escape_at_mentions(content, self._cwd or Path.cwd())
|
|
219
252
|
msg = {
|
|
220
253
|
"type": "user",
|
|
221
254
|
"message": {"role": "user", "content": content},
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Claude Code subscription limits as a tsugite quota source.
|
|
2
|
+
|
|
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.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from tsugite.usage.quota import QuotaReport, QuotaWindow, register_quota_source
|
|
20
|
+
from tsugite.user_agent import set_user_agent_header
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
PROVIDER = "claude_code"
|
|
25
|
+
LABEL = "Claude Code"
|
|
26
|
+
USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
|
|
27
|
+
CACHE_MAX_AGE_MS = 60 * 60 * 1000
|
|
28
|
+
LIVE_TTL_S = 60
|
|
29
|
+
|
|
30
|
+
_last_live: tuple[float, list] | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _config_path() -> Path:
|
|
34
|
+
base = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~")
|
|
35
|
+
return Path(base) / ".claude.json"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _credentials_path() -> Path:
|
|
39
|
+
base = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude")
|
|
40
|
+
return Path(base) / ".credentials.json"
|
|
41
|
+
|
|
42
|
+
|
|
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]
|
|
47
|
+
token = json.loads(_credentials_path().read_text())["claudeAiOauth"]["accessToken"]
|
|
48
|
+
headers = {"Authorization": f"Bearer {token}", "anthropic-beta": "oauth-2025-04-20"}
|
|
49
|
+
set_user_agent_header(headers)
|
|
50
|
+
response = httpx.get(USAGE_URL, headers=headers, timeout=10)
|
|
51
|
+
response.raise_for_status()
|
|
52
|
+
limits = response.json()["limits"]
|
|
53
|
+
_last_live = (time.monotonic(), limits)
|
|
54
|
+
return limits
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _cached_limits() -> list:
|
|
58
|
+
cache = json.loads(_config_path().read_text())["cachedUsageUtilization"]
|
|
59
|
+
if time.time() * 1000 - cache["fetchedAtMs"] > CACHE_MAX_AGE_MS:
|
|
60
|
+
raise ValueError("cached usage is over an hour old")
|
|
61
|
+
return cache["utilization"]["limits"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _label(limit: dict) -> str:
|
|
65
|
+
kind = limit["kind"]
|
|
66
|
+
if kind == "weekly_all":
|
|
67
|
+
return "week"
|
|
68
|
+
if kind == "weekly_scoped":
|
|
69
|
+
model = (limit.get("scope") or {}).get("model") or {}
|
|
70
|
+
name = model.get("display_name")
|
|
71
|
+
return f"week · {name}" if name else kind
|
|
72
|
+
return kind
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _error(message: str) -> QuotaReport:
|
|
76
|
+
return QuotaReport(provider=PROVIDER, label=LABEL, error=message)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def fetch() -> QuotaReport:
|
|
80
|
+
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)
|
|
87
|
+
return _error("Claude Code's usage limits could not be fetched, and no recent cached copy exists")
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
windows = tuple(
|
|
91
|
+
QuotaWindow(
|
|
92
|
+
key=limit["kind"],
|
|
93
|
+
label=_label(limit),
|
|
94
|
+
used_pct=float(limit["percent"]),
|
|
95
|
+
resets_at=limit.get("resets_at"),
|
|
96
|
+
)
|
|
97
|
+
for limit in limits
|
|
98
|
+
if limit.get("percent") is not None
|
|
99
|
+
)
|
|
100
|
+
except Exception:
|
|
101
|
+
return _error("Claude Code's usage limits are not in a shape tsugite understands")
|
|
102
|
+
|
|
103
|
+
return QuotaReport(provider=PROVIDER, label=LABEL, windows=windows)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
register_quota_source(PROVIDER, LABEL, fetch)
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
"""Claude Code subscription limits as a tsugite quota source.
|
|
2
|
-
|
|
3
|
-
Read from ``utilization.limits[]`` in the usage cache Claude Code writes to its
|
|
4
|
-
own config. That list is self-describing, so a limit kind tsugite has never seen
|
|
5
|
-
still shows up. Its sibling keys hold an account id, and a spend figure that
|
|
6
|
-
reads 100% while disabled; neither belongs in a report.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import json
|
|
12
|
-
import os
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
|
|
15
|
-
from tsugite.usage.quota import QuotaReport, QuotaWindow, register_quota_source
|
|
16
|
-
|
|
17
|
-
PROVIDER = "claude_code"
|
|
18
|
-
LABEL = "Claude Code"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def _config_path() -> Path:
|
|
22
|
-
base = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~")
|
|
23
|
-
return Path(base) / ".claude.json"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def _label(limit: dict) -> str:
|
|
27
|
-
kind = limit["kind"]
|
|
28
|
-
if kind == "weekly_all":
|
|
29
|
-
return "week"
|
|
30
|
-
if kind == "weekly_scoped":
|
|
31
|
-
model = (limit.get("scope") or {}).get("model") or {}
|
|
32
|
-
name = model.get("display_name")
|
|
33
|
-
return f"week · {name}" if name else kind
|
|
34
|
-
return kind
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def _error(message: str) -> QuotaReport:
|
|
38
|
-
return QuotaReport(provider=PROVIDER, label=LABEL, error=message)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def fetch() -> QuotaReport:
|
|
42
|
-
try:
|
|
43
|
-
data = json.loads(_config_path().read_text())
|
|
44
|
-
except Exception:
|
|
45
|
-
return _error("Claude Code's config could not be read")
|
|
46
|
-
|
|
47
|
-
cache = data.get("cachedUsageUtilization")
|
|
48
|
-
if not cache:
|
|
49
|
-
return _error("Claude Code has not cached its usage limits yet")
|
|
50
|
-
|
|
51
|
-
try:
|
|
52
|
-
# A window the cache has not filled in yet carries no percent; skip it
|
|
53
|
-
# rather than discard the ones it has.
|
|
54
|
-
windows = tuple(
|
|
55
|
-
QuotaWindow(
|
|
56
|
-
key=limit["kind"],
|
|
57
|
-
label=_label(limit),
|
|
58
|
-
used_pct=float(limit["percent"]),
|
|
59
|
-
resets_at=limit.get("resets_at"),
|
|
60
|
-
)
|
|
61
|
-
for limit in cache["utilization"]["limits"]
|
|
62
|
-
if limit.get("percent") is not None
|
|
63
|
-
)
|
|
64
|
-
except Exception:
|
|
65
|
-
return _error("Claude Code's cached usage limits are not in a shape tsugite understands")
|
|
66
|
-
|
|
67
|
-
return QuotaReport(provider=PROVIDER, label=LABEL, windows=windows)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
register_quota_source(PROVIDER, LABEL, fetch)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|