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.
@@ -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,3 @@
1
+ from deepseek_harness_usage import main
2
+
3
+ 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ deepseek-harness-usage = deepseek_harness_usage:main