deepseek-harness-usage 0.1.0__py3-none-any.whl
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.
- deepseek_harness_usage/__init__.py +936 -0
- deepseek_harness_usage/__main__.py +3 -0
- deepseek_harness_usage-0.1.0.dist-info/METADATA +189 -0
- deepseek_harness_usage-0.1.0.dist-info/RECORD +6 -0
- deepseek_harness_usage-0.1.0.dist-info/WHEEL +4 -0
- deepseek_harness_usage-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,936 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""DeepSeek Harness account balance monitor."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import errno
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import signal
|
|
13
|
+
import stat
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import time
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from decimal import Decimal, InvalidOperation
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from urllib.parse import urlsplit
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
|
|
29
|
+
BALANCE_URL = "https://api.deepseek.com/user/balance"
|
|
30
|
+
CACHE_MAX_AGE = 300
|
|
31
|
+
FAILURE_BACKOFF = 30
|
|
32
|
+
DAEMON_INTERVAL = 300
|
|
33
|
+
MAX_RESPONSE_BYTES = 1_048_576
|
|
34
|
+
SCHEMA_VERSION = 1
|
|
35
|
+
PROVIDER = "deepseek"
|
|
36
|
+
CLIENT = "deepseek_harness"
|
|
37
|
+
SOURCE = "deepseek_user_balance_api"
|
|
38
|
+
CREDENTIAL_REF = "DEEPSEEK_API_KEY"
|
|
39
|
+
EXPLICIT_KEY_ENV = "DEEPSEEK_HARNESS_USAGE_API_KEY"
|
|
40
|
+
USAGE_FILE_ENV = "DEEPSEEK_HARNESS_USAGE_FILE"
|
|
41
|
+
|
|
42
|
+
_CREDENTIAL_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
43
|
+
_LEGAL_API_KEY = re.compile(r"^[\x21-\x7e]+$")
|
|
44
|
+
_CURRENCY = re.compile(r"^[A-Z]{3}$")
|
|
45
|
+
_DECIMAL = re.compile(r"^(?:0|[1-9]\d*)(?:\.\d+)?$")
|
|
46
|
+
_FINGERPRINT = re.compile(r"^[0-9a-f]{64}$")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _DuplicateYamlKey(yaml.YAMLError):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _UniqueSafeLoader(yaml.SafeLoader):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_UniqueSafeLoader.yaml_implicit_resolvers = {
|
|
58
|
+
key: list(resolvers)
|
|
59
|
+
for key, resolvers in yaml.SafeLoader.yaml_implicit_resolvers.items()
|
|
60
|
+
}
|
|
61
|
+
for _resolver_key, _resolvers in _UniqueSafeLoader.yaml_implicit_resolvers.items():
|
|
62
|
+
_UniqueSafeLoader.yaml_implicit_resolvers[_resolver_key] = [
|
|
63
|
+
resolver for resolver in _resolvers if resolver[0] != "tag:yaml.org,2002:bool"
|
|
64
|
+
]
|
|
65
|
+
_UniqueSafeLoader.add_implicit_resolver(
|
|
66
|
+
"tag:yaml.org,2002:bool",
|
|
67
|
+
re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$"),
|
|
68
|
+
list("tTfF"),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _construct_unique_mapping(loader, node, deep=False):
|
|
73
|
+
if not isinstance(node, yaml.MappingNode):
|
|
74
|
+
raise _DuplicateYamlKey
|
|
75
|
+
mapping = {}
|
|
76
|
+
for key_node, value_node in node.value:
|
|
77
|
+
key = loader.construct_object(key_node, deep=deep)
|
|
78
|
+
try:
|
|
79
|
+
duplicate = key in mapping
|
|
80
|
+
except TypeError as exc:
|
|
81
|
+
raise _DuplicateYamlKey from exc
|
|
82
|
+
if duplicate:
|
|
83
|
+
raise _DuplicateYamlKey
|
|
84
|
+
mapping[key] = loader.construct_object(value_node, deep=deep)
|
|
85
|
+
return mapping
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_UniqueSafeLoader.add_constructor(
|
|
89
|
+
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_unique_mapping
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class UsageError(RuntimeError):
|
|
94
|
+
"""A safe, user-facing balance collection error."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class _RejectRedirects(urllib.request.HTTPRedirectHandler):
|
|
98
|
+
"""Keep the authorization header pinned to the configured origin."""
|
|
99
|
+
|
|
100
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _iso_now() -> str:
|
|
105
|
+
return datetime.now(UTC).isoformat()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse_iso(value: object) -> datetime | None:
|
|
109
|
+
if not isinstance(value, str) or not value:
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
113
|
+
except ValueError:
|
|
114
|
+
return None
|
|
115
|
+
return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _expand_harness_home(path: str) -> str:
|
|
119
|
+
"""Mirror Harness's deliberately narrow tilde expansion."""
|
|
120
|
+
if path == "~":
|
|
121
|
+
return str(Path.home())
|
|
122
|
+
if path.startswith(("~/", "~\\")):
|
|
123
|
+
return os.path.join(Path.home(), path[2:])
|
|
124
|
+
return path
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _absolute_lexical(path: str) -> Path:
|
|
128
|
+
"""Make a path absolute without following its final symlink."""
|
|
129
|
+
return Path(os.path.abspath(_expand_harness_home(path)))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_dsh_home() -> Path:
|
|
133
|
+
"""Resolve the default Harness home from the launching environment."""
|
|
134
|
+
configured = os.environ.get("DSH_HOME", "")
|
|
135
|
+
return _absolute_lexical(configured) if configured.strip() else Path.home() / ".dsh"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def get_usage_file() -> Path:
|
|
139
|
+
"""Return the cache path, honoring the package-specific override."""
|
|
140
|
+
configured = os.environ.get(USAGE_FILE_ENV, "")
|
|
141
|
+
return (
|
|
142
|
+
_absolute_lexical(configured)
|
|
143
|
+
if configured.strip()
|
|
144
|
+
else get_dsh_home() / "usage-limits.json"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _read_text_if_present(path: Path, label: str) -> str | None:
|
|
149
|
+
try:
|
|
150
|
+
return path.read_text(encoding="utf-8")
|
|
151
|
+
except FileNotFoundError:
|
|
152
|
+
return None
|
|
153
|
+
except (OSError, UnicodeError) as exc:
|
|
154
|
+
raise UsageError(f"Could not read {label} at {path}") from exc
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _trim_node_env(value: str) -> str:
|
|
158
|
+
return value.strip(" \t\n")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _parse_node_env(text: str) -> dict[str, str]:
|
|
162
|
+
"""Mirror the Node 22 parser used by Harness's launch environment."""
|
|
163
|
+
content = _trim_node_env(text.replace("\r", ""))
|
|
164
|
+
values: dict[str, str] = {}
|
|
165
|
+
while content:
|
|
166
|
+
if content[0] in "\n#":
|
|
167
|
+
newline = content.find("\n")
|
|
168
|
+
content = content[newline + 1 :] if newline >= 0 else ""
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
equals = content.find("=")
|
|
172
|
+
newline = content.find("\n")
|
|
173
|
+
delimiter = (
|
|
174
|
+
min(index for index in (equals, newline) if index >= 0)
|
|
175
|
+
if (equals >= 0 or newline >= 0)
|
|
176
|
+
else -1
|
|
177
|
+
)
|
|
178
|
+
if delimiter < 0 or (newline >= 0 and delimiter == newline):
|
|
179
|
+
content = _trim_node_env(content[delimiter + 1 :]) if delimiter >= 0 else ""
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
key = _trim_node_env(content[:delimiter])
|
|
183
|
+
content = content[delimiter + 1 :]
|
|
184
|
+
if not content or content[0] == "\n":
|
|
185
|
+
values[key] = ""
|
|
186
|
+
continue
|
|
187
|
+
content = _trim_node_env(content)
|
|
188
|
+
if not key:
|
|
189
|
+
continue
|
|
190
|
+
if key.startswith("export "):
|
|
191
|
+
key = _trim_node_env(key[7:])
|
|
192
|
+
if not content:
|
|
193
|
+
values[key] = ""
|
|
194
|
+
break
|
|
195
|
+
|
|
196
|
+
if content[0] == '"':
|
|
197
|
+
closing = content.find('"', 1)
|
|
198
|
+
if closing >= 0:
|
|
199
|
+
values[key] = content[1:closing].replace("\\n", "\n")
|
|
200
|
+
newline = content.find("\n", closing + 1)
|
|
201
|
+
content = content[newline + 1 :] if newline >= 0 else ""
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
if content[0] in "'\"`":
|
|
205
|
+
closing = content.find(content[0], 1)
|
|
206
|
+
if closing < 0:
|
|
207
|
+
newline = content.find("\n")
|
|
208
|
+
if newline >= 0:
|
|
209
|
+
values[key] = content[:newline]
|
|
210
|
+
content = content[newline + 1 :]
|
|
211
|
+
else:
|
|
212
|
+
values[key] = content
|
|
213
|
+
break
|
|
214
|
+
else:
|
|
215
|
+
values[key] = content[1:closing]
|
|
216
|
+
newline = content.find("\n", closing + 1)
|
|
217
|
+
content = content[newline + 1 :] if newline >= 0 else ""
|
|
218
|
+
continue
|
|
219
|
+
else:
|
|
220
|
+
newline = content.find("\n")
|
|
221
|
+
if newline >= 0:
|
|
222
|
+
value = content[:newline]
|
|
223
|
+
comment = value.find("#")
|
|
224
|
+
if comment >= 0:
|
|
225
|
+
value = value[:comment]
|
|
226
|
+
values[key] = _trim_node_env(value)
|
|
227
|
+
content = content[newline + 1 :]
|
|
228
|
+
else:
|
|
229
|
+
comment = content.find("#")
|
|
230
|
+
value = content[:comment] if comment >= 0 else content
|
|
231
|
+
values[key] = _trim_node_env(value)
|
|
232
|
+
content = ""
|
|
233
|
+
content = _trim_node_env(content)
|
|
234
|
+
return values
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _dotenv_lookup(path: Path, name: str) -> tuple[bool, str | None]:
|
|
238
|
+
text = _read_text_if_present(path, ".env credential source")
|
|
239
|
+
if text is None:
|
|
240
|
+
return False, None
|
|
241
|
+
values = _parse_node_env(text)
|
|
242
|
+
return name in values, values.get(name)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _dotenv_value(path: Path, name: str) -> str | None:
|
|
246
|
+
found, value = _dotenv_lookup(path, name)
|
|
247
|
+
return value if found and value else None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _managed_credential(path: Path, name: str) -> str | None:
|
|
251
|
+
try:
|
|
252
|
+
mode = path.stat().st_mode
|
|
253
|
+
except FileNotFoundError:
|
|
254
|
+
return None
|
|
255
|
+
except OSError as exc:
|
|
256
|
+
raise UsageError(
|
|
257
|
+
f"Could not inspect Harness credential store at {path}"
|
|
258
|
+
) from exc
|
|
259
|
+
if os.name != "nt" and stat.S_IMODE(mode) & 0o077:
|
|
260
|
+
raise UsageError(
|
|
261
|
+
f"Harness credential store {path} is readable beyond its owner; "
|
|
262
|
+
f'run "chmod 600 {path}" before using it'
|
|
263
|
+
)
|
|
264
|
+
text = _read_text_if_present(path, "Harness credential store")
|
|
265
|
+
if text is None: # The file may disappear between stat and read.
|
|
266
|
+
return None
|
|
267
|
+
try:
|
|
268
|
+
document = yaml.load(text, Loader=_UniqueSafeLoader)
|
|
269
|
+
except yaml.YAMLError as exc:
|
|
270
|
+
mark = getattr(exc, "problem_mark", None)
|
|
271
|
+
location = (
|
|
272
|
+
f" at line {mark.line + 1}, column {mark.column + 1}"
|
|
273
|
+
if mark is not None
|
|
274
|
+
else ""
|
|
275
|
+
)
|
|
276
|
+
raise UsageError(
|
|
277
|
+
f"Harness credential store {path} is invalid{location}"
|
|
278
|
+
) from exc
|
|
279
|
+
if document is None:
|
|
280
|
+
document = {}
|
|
281
|
+
if not isinstance(document, dict):
|
|
282
|
+
raise UsageError(f"Harness credential store {path} must be a mapping")
|
|
283
|
+
for key, value in document.items():
|
|
284
|
+
if not isinstance(key, str) or _CREDENTIAL_NAME.fullmatch(key) is None:
|
|
285
|
+
raise UsageError(
|
|
286
|
+
f"Harness credential store {path} has an invalid credential reference"
|
|
287
|
+
)
|
|
288
|
+
if not isinstance(value, str):
|
|
289
|
+
raise UsageError(f"The value for {key} in {path} is not a string")
|
|
290
|
+
if not value:
|
|
291
|
+
raise UsageError(
|
|
292
|
+
f"The value for {key} in {path} is empty; remove it instead"
|
|
293
|
+
)
|
|
294
|
+
return document.get(name)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _usable_api_key(value: str) -> str:
|
|
298
|
+
normalized = value.strip()
|
|
299
|
+
if not normalized:
|
|
300
|
+
raise UsageError(
|
|
301
|
+
"The configured DEEPSEEK_API_KEY is blank; set it to the raw key"
|
|
302
|
+
)
|
|
303
|
+
if _LEGAL_API_KEY.fullmatch(normalized) is None:
|
|
304
|
+
raise UsageError(
|
|
305
|
+
"The configured DEEPSEEK_API_KEY contains characters an HTTP header cannot carry"
|
|
306
|
+
)
|
|
307
|
+
return normalized
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _is_official_base_url(value: str) -> bool:
|
|
311
|
+
try:
|
|
312
|
+
parsed = urlsplit(value)
|
|
313
|
+
port = parsed.port
|
|
314
|
+
except ValueError:
|
|
315
|
+
return False
|
|
316
|
+
return (
|
|
317
|
+
parsed.scheme == "https"
|
|
318
|
+
and parsed.hostname == "api.deepseek.com"
|
|
319
|
+
and port in (None, 443)
|
|
320
|
+
and parsed.username is None
|
|
321
|
+
and parsed.password is None
|
|
322
|
+
and parsed.path.rstrip("/") in ("", "/v1")
|
|
323
|
+
and not parsed.query
|
|
324
|
+
and not parsed.fragment
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _assert_compatible_harness_route(cwd: Path) -> None:
|
|
329
|
+
"""Refuse known custom routes before reading their possible credential."""
|
|
330
|
+
dsh_home = get_dsh_home()
|
|
331
|
+
bases: list[tuple[bool, str | None]] = [
|
|
332
|
+
(
|
|
333
|
+
"DEEPSEEK_BASE_URL" in os.environ,
|
|
334
|
+
os.environ.get("DEEPSEEK_BASE_URL"),
|
|
335
|
+
)
|
|
336
|
+
]
|
|
337
|
+
bases.append(_dotenv_lookup(cwd / ".env", "DEEPSEEK_BASE_URL"))
|
|
338
|
+
if cwd != dsh_home:
|
|
339
|
+
bases.append(_dotenv_lookup(dsh_home / ".env", "DEEPSEEK_BASE_URL"))
|
|
340
|
+
|
|
341
|
+
settings_text = _read_text_if_present(
|
|
342
|
+
dsh_home / "settings.yaml", "Harness settings"
|
|
343
|
+
)
|
|
344
|
+
if settings_text is not None:
|
|
345
|
+
try:
|
|
346
|
+
settings = yaml.load(settings_text, Loader=_UniqueSafeLoader)
|
|
347
|
+
except yaml.YAMLError as exc:
|
|
348
|
+
raise UsageError(
|
|
349
|
+
"Could not verify the DeepSeek route because Harness settings are invalid"
|
|
350
|
+
) from exc
|
|
351
|
+
if settings is not None and not isinstance(settings, dict):
|
|
352
|
+
raise UsageError(
|
|
353
|
+
"Could not verify the DeepSeek route because Harness settings are invalid"
|
|
354
|
+
)
|
|
355
|
+
section = (settings or {}).get("llm-deepseek")
|
|
356
|
+
if section is not None and not isinstance(section, dict):
|
|
357
|
+
raise UsageError(
|
|
358
|
+
"Could not verify the DeepSeek route because llm-deepseek settings are invalid"
|
|
359
|
+
)
|
|
360
|
+
if isinstance(section, dict):
|
|
361
|
+
has_base_url = "baseURL" in section
|
|
362
|
+
base_url = section.get("baseURL")
|
|
363
|
+
if base_url is not None and not isinstance(base_url, str):
|
|
364
|
+
raise UsageError(
|
|
365
|
+
"Could not verify the DeepSeek route because its baseURL is invalid"
|
|
366
|
+
)
|
|
367
|
+
bases.append((has_base_url, base_url))
|
|
368
|
+
credential_ref = section.get("apiKeyEnv")
|
|
369
|
+
if credential_ref not in (None, CREDENTIAL_REF):
|
|
370
|
+
raise UsageError(
|
|
371
|
+
"Harness uses a custom DeepSeek credential reference; set "
|
|
372
|
+
f"{EXPLICIT_KEY_ENV} to the official DeepSeek account key for this tool"
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
if any(
|
|
376
|
+
present and (not isinstance(base, str) or not _is_official_base_url(base))
|
|
377
|
+
for present, base in bases
|
|
378
|
+
):
|
|
379
|
+
raise UsageError(
|
|
380
|
+
"Harness uses a custom DeepSeek endpoint; set "
|
|
381
|
+
f"{EXPLICIT_KEY_ENV} to the official DeepSeek account key for this tool"
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def get_api_key(cwd: Path | None = None) -> str:
|
|
386
|
+
"""Resolve the default DeepSeek key using Harness credential precedence."""
|
|
387
|
+
explicit = os.environ.get(EXPLICIT_KEY_ENV)
|
|
388
|
+
if explicit is not None:
|
|
389
|
+
return _usable_api_key(explicit)
|
|
390
|
+
|
|
391
|
+
project_root = _absolute_lexical(str(cwd or Path.cwd()))
|
|
392
|
+
_assert_compatible_harness_route(project_root)
|
|
393
|
+
|
|
394
|
+
inherited = os.environ.get(CREDENTIAL_REF)
|
|
395
|
+
if inherited:
|
|
396
|
+
return _usable_api_key(inherited)
|
|
397
|
+
|
|
398
|
+
dsh_home = get_dsh_home()
|
|
399
|
+
stored = _managed_credential(dsh_home / ".credentials.yaml", CREDENTIAL_REF)
|
|
400
|
+
if stored:
|
|
401
|
+
return _usable_api_key(stored)
|
|
402
|
+
|
|
403
|
+
project = _dotenv_value(project_root / ".env", CREDENTIAL_REF)
|
|
404
|
+
if project:
|
|
405
|
+
return _usable_api_key(project)
|
|
406
|
+
if project_root != dsh_home:
|
|
407
|
+
user = _dotenv_value(dsh_home / ".env", CREDENTIAL_REF)
|
|
408
|
+
if user:
|
|
409
|
+
return _usable_api_key(user)
|
|
410
|
+
raise UsageError(
|
|
411
|
+
"DeepSeek Harness has no DEEPSEEK_API_KEY; configure it in the Models page, "
|
|
412
|
+
"export it, or add it to a Harness credential source"
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def fetch_balance(api_key: str | None = None) -> dict:
|
|
417
|
+
"""Fetch the supported account-balance response with the current key."""
|
|
418
|
+
api_key = api_key or get_api_key()
|
|
419
|
+
request = urllib.request.Request(
|
|
420
|
+
BALANCE_URL,
|
|
421
|
+
headers={
|
|
422
|
+
"Authorization": f"Bearer {api_key}",
|
|
423
|
+
"Accept": "application/json",
|
|
424
|
+
"User-Agent": f"deepseek-harness-usage/{__version__}",
|
|
425
|
+
},
|
|
426
|
+
)
|
|
427
|
+
try:
|
|
428
|
+
opener = urllib.request.build_opener(_RejectRedirects())
|
|
429
|
+
with opener.open(request, timeout=15) as response:
|
|
430
|
+
payload_bytes = response.read(MAX_RESPONSE_BYTES + 1)
|
|
431
|
+
except urllib.error.HTTPError as exc:
|
|
432
|
+
if exc.code in (401, 403):
|
|
433
|
+
raise UsageError(
|
|
434
|
+
"DeepSeek no longer accepts the configured API key; update it in DeepSeek Harness"
|
|
435
|
+
) from exc
|
|
436
|
+
raise UsageError(f"DeepSeek balance API returned HTTP {exc.code}") from exc
|
|
437
|
+
except (urllib.error.URLError, TimeoutError) as exc:
|
|
438
|
+
raise UsageError("DeepSeek balance API is unavailable") from exc
|
|
439
|
+
if len(payload_bytes) > MAX_RESPONSE_BYTES:
|
|
440
|
+
raise UsageError("DeepSeek balance API returned an oversized response")
|
|
441
|
+
try:
|
|
442
|
+
payload = json.loads(payload_bytes)
|
|
443
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
444
|
+
raise UsageError("DeepSeek balance API returned invalid JSON") from exc
|
|
445
|
+
if not isinstance(payload, dict):
|
|
446
|
+
raise UsageError("DeepSeek balance API returned an unexpected response")
|
|
447
|
+
return payload
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _money(value: object, field: str) -> str:
|
|
451
|
+
if not isinstance(value, str) or not value:
|
|
452
|
+
raise UsageError(f"DeepSeek balance response omitted {field}")
|
|
453
|
+
if _DECIMAL.fullmatch(value) is None:
|
|
454
|
+
raise UsageError(f"DeepSeek balance response has invalid {field}")
|
|
455
|
+
try:
|
|
456
|
+
parsed = Decimal(value)
|
|
457
|
+
except InvalidOperation as exc:
|
|
458
|
+
raise UsageError(f"DeepSeek balance response has invalid {field}") from exc
|
|
459
|
+
if not parsed.is_finite():
|
|
460
|
+
raise UsageError(f"DeepSeek balance response has invalid {field}")
|
|
461
|
+
return value
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def normalize_balance(payload: dict) -> dict:
|
|
465
|
+
"""Validate and normalize DeepSeek's balance response."""
|
|
466
|
+
available = payload.get("is_available")
|
|
467
|
+
raw_balances = payload.get("balance_infos")
|
|
468
|
+
if not isinstance(available, bool) or not isinstance(raw_balances, list):
|
|
469
|
+
raise UsageError("DeepSeek balance API returned an unexpected response")
|
|
470
|
+
balances: list[dict[str, str]] = []
|
|
471
|
+
currencies: set[str] = set()
|
|
472
|
+
for raw in raw_balances:
|
|
473
|
+
if not isinstance(raw, dict):
|
|
474
|
+
raise UsageError(
|
|
475
|
+
"DeepSeek balance API returned an unexpected balance entry"
|
|
476
|
+
)
|
|
477
|
+
currency = raw.get("currency")
|
|
478
|
+
if not isinstance(currency, str) or _CURRENCY.fullmatch(currency) is None:
|
|
479
|
+
raise UsageError("DeepSeek balance response omitted currency")
|
|
480
|
+
if currency in currencies:
|
|
481
|
+
raise UsageError(f"DeepSeek balance response repeats currency {currency}")
|
|
482
|
+
currencies.add(currency)
|
|
483
|
+
balances.append(
|
|
484
|
+
{
|
|
485
|
+
"currency": currency,
|
|
486
|
+
"total": _money(raw.get("total_balance"), "total_balance"),
|
|
487
|
+
"granted": _money(raw.get("granted_balance"), "granted_balance"),
|
|
488
|
+
"topped_up": _money(raw.get("topped_up_balance"), "topped_up_balance"),
|
|
489
|
+
}
|
|
490
|
+
)
|
|
491
|
+
balances.sort(key=lambda item: item["currency"])
|
|
492
|
+
return {"available": available, "balances": balances}
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _unavailable_snapshot(error: str, retrieved_at: str | None = None) -> dict:
|
|
496
|
+
return {
|
|
497
|
+
"schema_version": SCHEMA_VERSION,
|
|
498
|
+
"provider": PROVIDER,
|
|
499
|
+
"client": CLIENT,
|
|
500
|
+
"source": SOURCE,
|
|
501
|
+
"retrieved_at": retrieved_at or _iso_now(),
|
|
502
|
+
"status": "unavailable",
|
|
503
|
+
"error": error,
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def build_usage_json(api_key: str | None = None) -> dict:
|
|
508
|
+
"""Build one live or unavailable normalized snapshot."""
|
|
509
|
+
retrieved_at = _iso_now()
|
|
510
|
+
try:
|
|
511
|
+
account_balance = normalize_balance(fetch_balance(api_key))
|
|
512
|
+
except UsageError as exc:
|
|
513
|
+
return _unavailable_snapshot(str(exc), retrieved_at)
|
|
514
|
+
except Exception:
|
|
515
|
+
return _unavailable_snapshot(
|
|
516
|
+
"Unexpected error while checking DeepSeek balance", retrieved_at
|
|
517
|
+
)
|
|
518
|
+
return {
|
|
519
|
+
"schema_version": SCHEMA_VERSION,
|
|
520
|
+
"provider": PROVIDER,
|
|
521
|
+
"client": CLIENT,
|
|
522
|
+
"source": SOURCE,
|
|
523
|
+
"retrieved_at": retrieved_at,
|
|
524
|
+
"status": "live",
|
|
525
|
+
"account_balance": account_balance,
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _cache_is_valid(data: object) -> bool:
|
|
530
|
+
if not isinstance(data, dict):
|
|
531
|
+
return False
|
|
532
|
+
if (
|
|
533
|
+
data.get("schema_version") != SCHEMA_VERSION
|
|
534
|
+
or data.get("provider") != PROVIDER
|
|
535
|
+
or data.get("client") != CLIENT
|
|
536
|
+
or data.get("source") != SOURCE
|
|
537
|
+
or _parse_iso(data.get("retrieved_at")) is None
|
|
538
|
+
or not isinstance(data.get("credential_fingerprint"), str)
|
|
539
|
+
or _FINGERPRINT.fullmatch(data["credential_fingerprint"]) is None
|
|
540
|
+
or _parse_iso(data.get("last_attempt_at")) is None
|
|
541
|
+
or data.get("status") not in {"live", "stale", "unavailable"}
|
|
542
|
+
):
|
|
543
|
+
return False
|
|
544
|
+
if data["status"] == "unavailable":
|
|
545
|
+
return isinstance(data.get("error"), str) and bool(data["error"])
|
|
546
|
+
balance = data.get("account_balance")
|
|
547
|
+
if not isinstance(balance, dict) or not isinstance(balance.get("available"), bool):
|
|
548
|
+
return False
|
|
549
|
+
balances = balance.get("balances")
|
|
550
|
+
if not isinstance(balances, list):
|
|
551
|
+
return False
|
|
552
|
+
currencies: set[str] = set()
|
|
553
|
+
for item in balances:
|
|
554
|
+
if not isinstance(item, dict):
|
|
555
|
+
return False
|
|
556
|
+
currency = item.get("currency")
|
|
557
|
+
if (
|
|
558
|
+
not isinstance(currency, str)
|
|
559
|
+
or _CURRENCY.fullmatch(currency) is None
|
|
560
|
+
or currency in currencies
|
|
561
|
+
):
|
|
562
|
+
return False
|
|
563
|
+
currencies.add(currency)
|
|
564
|
+
if any(
|
|
565
|
+
not isinstance(item.get(field), str)
|
|
566
|
+
or _DECIMAL.fullmatch(item[field]) is None
|
|
567
|
+
for field in ("total", "granted", "topped_up")
|
|
568
|
+
):
|
|
569
|
+
return False
|
|
570
|
+
return True
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _mkdir_private_parents(path: Path) -> None:
|
|
574
|
+
missing: list[Path] = []
|
|
575
|
+
current = path
|
|
576
|
+
while not current.exists():
|
|
577
|
+
missing.append(current)
|
|
578
|
+
parent = current.parent
|
|
579
|
+
if parent == current:
|
|
580
|
+
break
|
|
581
|
+
current = parent
|
|
582
|
+
for directory in reversed(missing):
|
|
583
|
+
try:
|
|
584
|
+
directory.mkdir(mode=0o700)
|
|
585
|
+
except FileExistsError:
|
|
586
|
+
if not directory.is_dir():
|
|
587
|
+
raise
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
@contextmanager
|
|
591
|
+
def _cache_lock():
|
|
592
|
+
"""Claim the cache refresh/write role without waiting on another process."""
|
|
593
|
+
path = get_usage_file()
|
|
594
|
+
try:
|
|
595
|
+
_mkdir_private_parents(path.parent)
|
|
596
|
+
lock_path = path.with_name(f"{path.name}.lock")
|
|
597
|
+
descriptor = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
598
|
+
except OSError as exc:
|
|
599
|
+
raise UsageError(f"Could not lock balance cache at {path}") from exc
|
|
600
|
+
try:
|
|
601
|
+
if os.name == "nt":
|
|
602
|
+
import msvcrt
|
|
603
|
+
|
|
604
|
+
if os.fstat(descriptor).st_size == 0:
|
|
605
|
+
os.write(descriptor, b"0")
|
|
606
|
+
os.lseek(descriptor, 0, os.SEEK_SET)
|
|
607
|
+
try:
|
|
608
|
+
msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1)
|
|
609
|
+
except OSError as exc:
|
|
610
|
+
if exc.errno in {errno.EACCES, errno.EAGAIN, errno.EDEADLK}:
|
|
611
|
+
os.close(descriptor)
|
|
612
|
+
descriptor = -1
|
|
613
|
+
yield False
|
|
614
|
+
return
|
|
615
|
+
raise
|
|
616
|
+
else:
|
|
617
|
+
import fcntl
|
|
618
|
+
|
|
619
|
+
try:
|
|
620
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
621
|
+
except BlockingIOError:
|
|
622
|
+
os.close(descriptor)
|
|
623
|
+
descriptor = -1
|
|
624
|
+
yield False
|
|
625
|
+
return
|
|
626
|
+
os.ftruncate(descriptor, 0)
|
|
627
|
+
os.write(descriptor, f"{os.getpid()}\n".encode("ascii"))
|
|
628
|
+
yield True
|
|
629
|
+
finally:
|
|
630
|
+
if descriptor >= 0:
|
|
631
|
+
os.close(descriptor)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _write_usage_file_unlocked(data: dict) -> None:
|
|
635
|
+
path = get_usage_file()
|
|
636
|
+
try:
|
|
637
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
638
|
+
prefix=f".{path.name}.", dir=path.parent
|
|
639
|
+
)
|
|
640
|
+
except OSError as exc:
|
|
641
|
+
raise UsageError(f"Could not prepare balance cache at {path}") from exc
|
|
642
|
+
temporary = Path(temporary_name)
|
|
643
|
+
try:
|
|
644
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
|
|
645
|
+
json.dump(data, handle, indent=2)
|
|
646
|
+
handle.write("\n")
|
|
647
|
+
handle.flush()
|
|
648
|
+
os.fsync(handle.fileno())
|
|
649
|
+
os.replace(temporary, path)
|
|
650
|
+
except OSError as exc:
|
|
651
|
+
temporary.unlink(missing_ok=True)
|
|
652
|
+
raise UsageError(f"Could not update balance cache at {path}") from exc
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def write_usage_file(data: dict) -> None:
|
|
656
|
+
"""Atomically replace the credential-free private balance cache."""
|
|
657
|
+
with _cache_lock() as acquired:
|
|
658
|
+
if not acquired:
|
|
659
|
+
raise UsageError(f"Balance cache at {get_usage_file()} is busy")
|
|
660
|
+
_write_usage_file_unlocked(data)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _read_cache() -> dict | None:
|
|
664
|
+
try:
|
|
665
|
+
with get_usage_file().open("rb") as handle:
|
|
666
|
+
payload = handle.read(MAX_RESPONSE_BYTES + 1)
|
|
667
|
+
if len(payload) > MAX_RESPONSE_BYTES:
|
|
668
|
+
return None
|
|
669
|
+
data = json.loads(payload)
|
|
670
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
671
|
+
return None
|
|
672
|
+
return data if _cache_is_valid(data) else None
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _credential_fingerprint(api_key: str) -> str:
|
|
676
|
+
return hashlib.sha256(api_key.encode("ascii")).hexdigest()
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _bound_cache_record(data: dict, fingerprint: str, attempt_at: str) -> dict:
|
|
680
|
+
return {
|
|
681
|
+
**data,
|
|
682
|
+
"credential_fingerprint": fingerprint,
|
|
683
|
+
"last_attempt_at": attempt_at,
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _without_cache_binding(data: dict) -> dict:
|
|
688
|
+
return {
|
|
689
|
+
key: value
|
|
690
|
+
for key, value in data.items()
|
|
691
|
+
if key not in {"credential_fingerprint", "last_attempt_at"}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _write_unlocked_or_annotate(data: dict) -> dict:
|
|
696
|
+
try:
|
|
697
|
+
_write_usage_file_unlocked(data)
|
|
698
|
+
except UsageError as exc:
|
|
699
|
+
return {**data, "cache_error": str(exc)}
|
|
700
|
+
return data
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _matching_cache(fingerprint: str) -> dict | None:
|
|
704
|
+
cached = _read_cache()
|
|
705
|
+
if cached is None or cached["credential_fingerprint"] != fingerprint:
|
|
706
|
+
return None
|
|
707
|
+
return cached
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _ready_cached_result(
|
|
711
|
+
cached: dict | None, now: datetime, max_age: int
|
|
712
|
+
) -> dict | None:
|
|
713
|
+
if cached is None:
|
|
714
|
+
return None
|
|
715
|
+
last_attempt = _parse_iso(cached["last_attempt_at"])
|
|
716
|
+
attempt_age = (
|
|
717
|
+
(now - last_attempt).total_seconds() if last_attempt is not None else -1
|
|
718
|
+
)
|
|
719
|
+
if cached["status"] in {"stale", "unavailable"} and (
|
|
720
|
+
0 <= attempt_age < FAILURE_BACKOFF
|
|
721
|
+
):
|
|
722
|
+
return _without_cache_binding(cached)
|
|
723
|
+
retrieved = _parse_iso(cached["retrieved_at"])
|
|
724
|
+
age = (now - retrieved).total_seconds() if retrieved is not None else -1
|
|
725
|
+
if cached["status"] == "live" and 0 <= age < max_age:
|
|
726
|
+
return _without_cache_binding({**cached, "status": "cached"})
|
|
727
|
+
return None
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _refresh_in_progress_result(
|
|
731
|
+
cached: dict | None, now: datetime, max_age: int
|
|
732
|
+
) -> dict:
|
|
733
|
+
if cached is None:
|
|
734
|
+
return _unavailable_snapshot("Another balance refresh is already in progress")
|
|
735
|
+
if "account_balance" not in cached:
|
|
736
|
+
return _without_cache_binding(cached)
|
|
737
|
+
retrieved = _parse_iso(cached["retrieved_at"])
|
|
738
|
+
age = (now - retrieved).total_seconds() if retrieved is not None else -1
|
|
739
|
+
status = "cached" if cached["status"] == "live" and 0 <= age < max_age else "stale"
|
|
740
|
+
result = {**cached, "status": status}
|
|
741
|
+
if status == "stale":
|
|
742
|
+
result["refresh_error"] = "Another balance refresh is already in progress"
|
|
743
|
+
return _without_cache_binding(result)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def get_cached_usage(max_age: int = CACHE_MAX_AGE, force_refresh: bool = False) -> dict:
|
|
747
|
+
"""Read a fresh cache, refresh it, or return an explicitly stale snapshot."""
|
|
748
|
+
try:
|
|
749
|
+
api_key = get_api_key()
|
|
750
|
+
except UsageError as exc:
|
|
751
|
+
return _unavailable_snapshot(str(exc))
|
|
752
|
+
fingerprint = _credential_fingerprint(api_key)
|
|
753
|
+
cached = _matching_cache(fingerprint)
|
|
754
|
+
now = datetime.now(UTC)
|
|
755
|
+
if not force_refresh:
|
|
756
|
+
ready = _ready_cached_result(cached, now, max_age)
|
|
757
|
+
if ready is not None:
|
|
758
|
+
return ready
|
|
759
|
+
|
|
760
|
+
with _cache_lock() as acquired:
|
|
761
|
+
if not acquired:
|
|
762
|
+
return _refresh_in_progress_result(
|
|
763
|
+
_matching_cache(fingerprint), datetime.now(UTC), max_age
|
|
764
|
+
)
|
|
765
|
+
cached = _matching_cache(fingerprint)
|
|
766
|
+
if not force_refresh:
|
|
767
|
+
ready = _ready_cached_result(cached, datetime.now(UTC), max_age)
|
|
768
|
+
if ready is not None:
|
|
769
|
+
return ready
|
|
770
|
+
|
|
771
|
+
fresh = build_usage_json(api_key)
|
|
772
|
+
attempt_at = fresh["retrieved_at"]
|
|
773
|
+
if fresh["status"] == "live":
|
|
774
|
+
stored = _write_unlocked_or_annotate(
|
|
775
|
+
_bound_cache_record(fresh, fingerprint, attempt_at)
|
|
776
|
+
)
|
|
777
|
+
return _without_cache_binding(stored)
|
|
778
|
+
if cached is not None and "account_balance" in cached:
|
|
779
|
+
stale = {
|
|
780
|
+
**cached,
|
|
781
|
+
"status": "stale",
|
|
782
|
+
"last_attempt_at": attempt_at,
|
|
783
|
+
"refresh_error": fresh.get("error", "Refresh failed"),
|
|
784
|
+
}
|
|
785
|
+
return _without_cache_binding(_write_unlocked_or_annotate(stale))
|
|
786
|
+
unavailable = _bound_cache_record(fresh, fingerprint, attempt_at)
|
|
787
|
+
return _without_cache_binding(_write_unlocked_or_annotate(unavailable))
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _statusline_text(data: dict) -> str:
|
|
791
|
+
account = data.get("account_balance")
|
|
792
|
+
if not isinstance(account, dict):
|
|
793
|
+
return "dsh:bal:unavailable"
|
|
794
|
+
balances = account.get("balances")
|
|
795
|
+
if not isinstance(balances, list) or not balances:
|
|
796
|
+
summary = "none"
|
|
797
|
+
else:
|
|
798
|
+
summary = (
|
|
799
|
+
",".join(
|
|
800
|
+
f"{item['currency']}{item['total']}"
|
|
801
|
+
for item in balances
|
|
802
|
+
if isinstance(item, dict)
|
|
803
|
+
and isinstance(item.get("currency"), str)
|
|
804
|
+
and isinstance(item.get("total"), str)
|
|
805
|
+
)
|
|
806
|
+
or "none"
|
|
807
|
+
)
|
|
808
|
+
parts = [f"dsh:bal:{summary}"]
|
|
809
|
+
if account.get("available") is False:
|
|
810
|
+
parts.append("api-unavailable")
|
|
811
|
+
if data.get("status") in {"cached", "stale"}:
|
|
812
|
+
parts.append(str(data["status"]))
|
|
813
|
+
return " ".join(parts)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _print_status(data: dict) -> None:
|
|
817
|
+
print("DeepSeek Harness balance")
|
|
818
|
+
print(f"Status: {data['status']}")
|
|
819
|
+
account = data.get("account_balance")
|
|
820
|
+
if isinstance(account, dict):
|
|
821
|
+
print(
|
|
822
|
+
f"API access: {'available' if account.get('available') else 'unavailable'}"
|
|
823
|
+
)
|
|
824
|
+
balances = account.get("balances")
|
|
825
|
+
if isinstance(balances, list) and balances:
|
|
826
|
+
for item in balances:
|
|
827
|
+
print(
|
|
828
|
+
f"{item['currency']}: {item['total']} total "
|
|
829
|
+
f"({item['granted']} granted + {item['topped_up']} topped up)"
|
|
830
|
+
)
|
|
831
|
+
else:
|
|
832
|
+
print("Balances: none reported")
|
|
833
|
+
if data.get("error"):
|
|
834
|
+
print(f"Error: {data['error']}")
|
|
835
|
+
if data.get("refresh_error"):
|
|
836
|
+
print(f"Refresh error: {data['refresh_error']}")
|
|
837
|
+
if data.get("cache_error"):
|
|
838
|
+
print(f"Cache error: {data['cache_error']}")
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _refresh_and_cache() -> dict:
|
|
842
|
+
return get_cached_usage(max_age=0, force_refresh=True)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def cmd_status(_args: argparse.Namespace) -> int:
|
|
846
|
+
data = build_usage_json()
|
|
847
|
+
_print_status(data)
|
|
848
|
+
return 0 if data["status"] == "live" else 1
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def cmd_json(_args: argparse.Namespace) -> int:
|
|
852
|
+
data = build_usage_json()
|
|
853
|
+
print(json.dumps(data, indent=2))
|
|
854
|
+
return 0 if data["status"] == "live" else 1
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def cmd_statusline(args: argparse.Namespace) -> int:
|
|
858
|
+
print(_statusline_text(get_cached_usage(args.max_age, args.refresh)))
|
|
859
|
+
return 0
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def cmd_refresh(_args: argparse.Namespace) -> int:
|
|
863
|
+
data = _refresh_and_cache()
|
|
864
|
+
_print_status(data)
|
|
865
|
+
return 0 if data["status"] == "live" else 1
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def cmd_daemon(args: argparse.Namespace) -> int:
|
|
869
|
+
signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
|
|
870
|
+
if hasattr(signal, "SIGTERM"):
|
|
871
|
+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
|
872
|
+
print(f"deepseek-harness-usage daemon started (refreshing every {args.interval}s)")
|
|
873
|
+
print(f"Writing to {get_usage_file()}")
|
|
874
|
+
while True:
|
|
875
|
+
data = _refresh_and_cache()
|
|
876
|
+
print(f"[{datetime.now().strftime('%H:%M:%S')}] {_statusline_text(data)}")
|
|
877
|
+
if data.get("error"):
|
|
878
|
+
print(f"Error: {data['error']}", file=sys.stderr)
|
|
879
|
+
if data.get("cache_error"):
|
|
880
|
+
print(f"Cache error: {data['cache_error']}", file=sys.stderr)
|
|
881
|
+
time.sleep(args.interval)
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def cmd_install(_args: argparse.Namespace) -> int:
|
|
885
|
+
print(
|
|
886
|
+
"Install with:\n uv tool install deepseek-harness-usage\n\n"
|
|
887
|
+
"Then run:\n deepseek-harness-usage"
|
|
888
|
+
)
|
|
889
|
+
return 0
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _positive_int(value: str) -> int:
|
|
893
|
+
parsed = int(value)
|
|
894
|
+
if parsed <= 0:
|
|
895
|
+
raise argparse.ArgumentTypeError("must be greater than zero")
|
|
896
|
+
return parsed
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def _nonnegative_int(value: str) -> int:
|
|
900
|
+
parsed = int(value)
|
|
901
|
+
if parsed < 0:
|
|
902
|
+
raise argparse.ArgumentTypeError("must be zero or greater")
|
|
903
|
+
return parsed
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
907
|
+
parser = argparse.ArgumentParser(
|
|
908
|
+
description="DeepSeek Harness account balance monitor"
|
|
909
|
+
)
|
|
910
|
+
parser.add_argument(
|
|
911
|
+
"command",
|
|
912
|
+
nargs="?",
|
|
913
|
+
default="status",
|
|
914
|
+
choices=["status", "json", "daemon", "statusline", "refresh", "install"],
|
|
915
|
+
)
|
|
916
|
+
parser.add_argument("-i", "--interval", type=_positive_int, default=DAEMON_INTERVAL)
|
|
917
|
+
parser.add_argument("--max-age", type=_nonnegative_int, default=CACHE_MAX_AGE)
|
|
918
|
+
parser.add_argument("--refresh", action="store_true")
|
|
919
|
+
return parser
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def main() -> int:
|
|
923
|
+
args = _build_parser().parse_args()
|
|
924
|
+
commands = {
|
|
925
|
+
"status": cmd_status,
|
|
926
|
+
"json": cmd_json,
|
|
927
|
+
"daemon": cmd_daemon,
|
|
928
|
+
"statusline": cmd_statusline,
|
|
929
|
+
"refresh": cmd_refresh,
|
|
930
|
+
"install": cmd_install,
|
|
931
|
+
}
|
|
932
|
+
return commands[args.command](args)
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
if __name__ == "__main__":
|
|
936
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: deepseek-harness-usage
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: DeepSeek Harness account balance monitor for official DeepSeek API accounts
|
|
5
|
+
Project-URL: Homepage, https://github.com/wakamex/deepseek-harness-usage
|
|
6
|
+
Project-URL: Source, https://github.com/wakamex/deepseek-harness-usage
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# deepseek-harness-usage
|
|
12
|
+
|
|
13
|
+
DeepSeek Harness account balance monitor. It follows the same usage-tool
|
|
14
|
+
interface as the other `/code/*usage` packages, but reports the allowance
|
|
15
|
+
DeepSeek actually exposes for API accounts: current prepaid balance and API
|
|
16
|
+
availability.
|
|
17
|
+
|
|
18
|
+
It does not estimate account usage from local Harness sessions.
|
|
19
|
+
|
|
20
|
+
## Example output
|
|
21
|
+
|
|
22
|
+
These values are synthetic.
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
DeepSeek Harness balance
|
|
26
|
+
Status: live
|
|
27
|
+
API access: available
|
|
28
|
+
USD: 123.45 total (23.45 granted + 100.00 topped up)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Statusline:
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
dsh:bal:USD123.45
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uv tool install deepseek-harness-usage
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
For local development:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv tool install .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Commands
|
|
50
|
+
|
|
51
|
+
| Command | Description |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `deepseek-harness-usage` | Show a live account balance |
|
|
54
|
+
| `deepseek-harness-usage status` | Same as above |
|
|
55
|
+
| `deepseek-harness-usage json` | Print normalized live JSON |
|
|
56
|
+
| `deepseek-harness-usage statusline` | Print compact cache-first output |
|
|
57
|
+
| `deepseek-harness-usage refresh` | Refresh the cache and print the balance |
|
|
58
|
+
| `deepseek-harness-usage daemon [-i SECS]` | Keep the cache fresh |
|
|
59
|
+
| `deepseek-harness-usage install` | Print installation instructions |
|
|
60
|
+
|
|
61
|
+
Options:
|
|
62
|
+
|
|
63
|
+
- `--max-age SECS` sets the statusline cache lifetime. The default is 300.
|
|
64
|
+
- `--refresh` bypasses a fresh statusline cache.
|
|
65
|
+
- `-i SECS` sets the daemon refresh interval. The default is 300.
|
|
66
|
+
|
|
67
|
+
## Data source
|
|
68
|
+
|
|
69
|
+
The tool calls DeepSeek's supported
|
|
70
|
+
[`GET /user/balance`](https://api-docs.deepseek.com/api/get-user-balance)
|
|
71
|
+
endpoint. The response supplies API availability and total, granted, and
|
|
72
|
+
topped-up balances per currency. Monetary values stay decimal strings from the
|
|
73
|
+
provider and are never converted through binary floating point.
|
|
74
|
+
|
|
75
|
+
DeepSeek does not document an API for aggregate token history, amount spent,
|
|
76
|
+
per-key usage, quota percentages, or reset windows. Its API is billed by token
|
|
77
|
+
usage against account balance, and concurrency limits are not a consumable
|
|
78
|
+
quota. The package therefore reports balance without inventing a percentage or
|
|
79
|
+
reset time.
|
|
80
|
+
|
|
81
|
+
## Authentication
|
|
82
|
+
|
|
83
|
+
Every live request rereads the key. A dedicated
|
|
84
|
+
`DEEPSEEK_HARNESS_USAGE_API_KEY` is the highest-priority source and explicitly
|
|
85
|
+
identifies a key for the official DeepSeek account. Without it, the tool uses
|
|
86
|
+
the default DeepSeek Harness credential order:
|
|
87
|
+
|
|
88
|
+
1. The inherited process environment.
|
|
89
|
+
2. `$DSH_HOME/.credentials.yaml`.
|
|
90
|
+
3. The invoking directory's `.env`.
|
|
91
|
+
4. `$DSH_HOME/.env`.
|
|
92
|
+
|
|
93
|
+
`DSH_HOME` defaults to `~/.dsh`. The tool never refreshes, rotates, rewrites,
|
|
94
|
+
prints, or caches the key. It sends the key only to the fixed HTTPS balance
|
|
95
|
+
endpoint at `api.deepseek.com`, and rejects redirects before the authorization
|
|
96
|
+
header can be forwarded. Like Harness, it refuses a managed credential file
|
|
97
|
+
that is readable by group or other users. It parses and validates the complete
|
|
98
|
+
YAML document, so an invalid sibling entry cannot be silently ignored.
|
|
99
|
+
|
|
100
|
+
A Harness deployment that configures a different credential reference or a
|
|
101
|
+
custom credential-provider path cannot be discovered through the current CLI.
|
|
102
|
+
Set `DEEPSEEK_HARNESS_USAGE_API_KEY` to the official DeepSeek account key when
|
|
103
|
+
running this tool.
|
|
104
|
+
|
|
105
|
+
The balance endpoint accepts DeepSeek-issued API keys. If the Harness route
|
|
106
|
+
uses a gateway or another compatible provider, do not send that provider's key
|
|
107
|
+
to this tool. The package refuses custom endpoints and credential references
|
|
108
|
+
it can identify in the environment or Harness settings before reading the
|
|
109
|
+
credential. Profile-specific plugin patches are not discoverable from a
|
|
110
|
+
standalone invocation, so use the dedicated variable for those deployments.
|
|
111
|
+
|
|
112
|
+
## Cache
|
|
113
|
+
|
|
114
|
+
The credential-free normalized snapshot is written atomically to
|
|
115
|
+
`$DSH_HOME/usage-limits.json`, or `~/.dsh/usage-limits.json` when `DSH_HOME` is
|
|
116
|
+
unset. `DEEPSEEK_HARNESS_USAGE_FILE` overrides that path.
|
|
117
|
+
|
|
118
|
+
The cache contains sensitive account metadata even though it contains no raw
|
|
119
|
+
credential. New cache files use mode `0600`, newly created parent directories
|
|
120
|
+
use mode `0700`, and replacement is atomic and does not follow a final cache
|
|
121
|
+
symlink. Do not publish or share the cache.
|
|
122
|
+
|
|
123
|
+
Freshness states are explicit:
|
|
124
|
+
|
|
125
|
+
| Status | Meaning |
|
|
126
|
+
| --- | --- |
|
|
127
|
+
| `live` | Returned by the balance API now |
|
|
128
|
+
| `cached` | Read from a cache younger than `--max-age` |
|
|
129
|
+
| `stale` | A live refresh failed, so an older valid cache was returned |
|
|
130
|
+
| `unavailable` | No live result or valid cache is available |
|
|
131
|
+
|
|
132
|
+
Cache entries are bound to a one-way SHA-256 fingerprint of the key that
|
|
133
|
+
produced them. A key rotation or per-run override cannot reuse another
|
|
134
|
+
account's cached balance. A failed statusline refresh records a 30-second
|
|
135
|
+
backoff so repeated prompts do not repeatedly wait on an unavailable service.
|
|
136
|
+
Refreshes use a non-blocking OS advisory lock, which is released automatically
|
|
137
|
+
if a writer exits or crashes.
|
|
138
|
+
|
|
139
|
+
Schema version 1 has this form:
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{
|
|
143
|
+
"schema_version": 1,
|
|
144
|
+
"provider": "deepseek",
|
|
145
|
+
"client": "deepseek_harness",
|
|
146
|
+
"source": "deepseek_user_balance_api",
|
|
147
|
+
"retrieved_at": "2026-08-13T20:00:00+00:00",
|
|
148
|
+
"last_attempt_at": "2026-08-13T20:00:00+00:00",
|
|
149
|
+
"credential_fingerprint": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
150
|
+
"status": "live",
|
|
151
|
+
"account_balance": {
|
|
152
|
+
"available": true,
|
|
153
|
+
"balances": [
|
|
154
|
+
{
|
|
155
|
+
"currency": "USD",
|
|
156
|
+
"total": "123.45",
|
|
157
|
+
"granted": "23.45",
|
|
158
|
+
"topped_up": "100.00"
|
|
159
|
+
}
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Reliability boundary
|
|
166
|
+
|
|
167
|
+
Balance is account-wide and authoritative for the account behind the API key.
|
|
168
|
+
It can change because of other clients, other keys, top-ups, or grant expiry.
|
|
169
|
+
Those changes make balance deltas unsuitable as exact spend history.
|
|
170
|
+
|
|
171
|
+
Harness does persist provider-reported tokens for ordinary assistant calls, but
|
|
172
|
+
that is not a complete billing ledger. Existing projections can omit retried
|
|
173
|
+
attempts, compaction, session-title calls, disconnected calls, and every use of
|
|
174
|
+
the key outside that Harness installation. Summing those records would produce
|
|
175
|
+
a precise-looking but incomplete account total.
|
|
176
|
+
|
|
177
|
+
A future local breakdown should be a separate Harness plugin wrapping the
|
|
178
|
+
documented `llm/stream` waterfall and recording each returned usage chunk
|
|
179
|
+
before forwarding it. That would reliably cover instrumented Harness calls
|
|
180
|
+
going forward, but it would still be local accounting rather than account-wide
|
|
181
|
+
usage.
|
|
182
|
+
|
|
183
|
+
## Development
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
uv --no-config lock --check
|
|
187
|
+
uv --no-config run --locked python -m unittest discover -s tests
|
|
188
|
+
uv --no-config build --no-sources
|
|
189
|
+
```
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
deepseek_harness_usage/__init__.py,sha256=ITF6NTF7gGgI0emHhZDIU-LbwSTRKVFS5R13hsc7UFw,32293
|
|
2
|
+
deepseek_harness_usage/__main__.py,sha256=zh6VznPR2mRIv9Y2fsUm4V5Zk_HA6OVth-Rz0vWMjt0,66
|
|
3
|
+
deepseek_harness_usage-0.1.0.dist-info/METADATA,sha256=sM4wQErMwLAXf572SEnyAFQvNUyX2LwmL15u0NPDuQU,6886
|
|
4
|
+
deepseek_harness_usage-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
5
|
+
deepseek_harness_usage-0.1.0.dist-info/entry_points.txt,sha256=Gqu61NkGSejs8p3S76zYmSNWChoomffFa6TY0pK5hds,71
|
|
6
|
+
deepseek_harness_usage-0.1.0.dist-info/RECORD,,
|