devin-cli-usage 0.1.1__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- devin_cli_usage-0.4.0/.gitignore +4 -0
- devin_cli_usage-0.4.0/LICENSE +21 -0
- devin_cli_usage-0.4.0/PKG-INFO +25 -0
- devin_cli_usage-0.4.0/README.md +12 -0
- devin_cli_usage-0.4.0/devin_cli_usage/__init__.py +7 -0
- devin_cli_usage-0.4.0/devin_cli_usage/__main__.py +3 -0
- devin_cli_usage-0.4.0/pyproject.toml +24 -0
- devin_cli_usage-0.1.1/.github/workflows/publish.yml +0 -29
- devin_cli_usage-0.1.1/.gitignore +0 -6
- devin_cli_usage-0.1.1/PKG-INFO +0 -109
- devin_cli_usage-0.1.1/README.md +0 -100
- devin_cli_usage-0.1.1/dist/.gitignore +0 -2
- devin_cli_usage-0.1.1/pyproject.toml +0 -24
- devin_cli_usage-0.1.1/src/devin_cli_usage/__init__.py +0 -456
- devin_cli_usage-0.1.1/src/devin_cli_usage/__main__.py +0 -3
- devin_cli_usage-0.1.1/tests/test_devin_cli_usage.py +0 -193
- devin_cli_usage-0.1.1/uv.lock +0 -8
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mihai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: devin-cli-usage
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Devin quota and credits monitor
|
|
5
|
+
Project-URL: Homepage, https://github.com/wakamex/clanker-usage
|
|
6
|
+
Project-URL: Source, https://github.com/wakamex/clanker-usage
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: cli,devin,quota,statusline,usage,windsurf
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Requires-Dist: clanker-usage==0.4.0
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# devin-cli-usage
|
|
15
|
+
|
|
16
|
+
Monitor your Devin quota from the terminal: current usage, a one-line statusline, and a daemon that keeps a cache fresh.
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
uv tool install devin-cli-usage
|
|
20
|
+
devin-cli-usage # current quotas
|
|
21
|
+
devin-cli-usage statusline # one line, from a cache less than five minutes old
|
|
22
|
+
devin-cli-usage json # the usage-v1 document
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`devin-cli-usage` is the Devin command of [clanker-usage](https://github.com/wakamex/clanker-usage), so it installs clanker-usage at the same version. The documentation lives there.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# devin-cli-usage
|
|
2
|
+
|
|
3
|
+
Monitor your Devin quota from the terminal: current usage, a one-line statusline, and a daemon that keeps a cache fresh.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
uv tool install devin-cli-usage
|
|
7
|
+
devin-cli-usage # current quotas
|
|
8
|
+
devin-cli-usage statusline # one line, from a cache less than five minutes old
|
|
9
|
+
devin-cli-usage json # the usage-v1 document
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`devin-cli-usage` is the Devin command of [clanker-usage](https://github.com/wakamex/clanker-usage), so it installs clanker-usage at the same version. The documentation lives there.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling==1.32.4"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "devin-cli-usage"
|
|
7
|
+
version = "0.4.0"
|
|
8
|
+
description = "Devin quota and credits monitor"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
keywords = ["devin", "windsurf", "usage", "quota", "statusline", "cli"]
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
dependencies = ["clanker-usage==0.4.0"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://github.com/wakamex/clanker-usage"
|
|
18
|
+
Source = "https://github.com/wakamex/clanker-usage"
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
devin-cli-usage = "devin_cli_usage:main"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["devin_cli_usage"]
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
name: Publish to PyPI
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
tags:
|
|
6
|
-
- "v*"
|
|
7
|
-
|
|
8
|
-
jobs:
|
|
9
|
-
publish:
|
|
10
|
-
runs-on: ubuntu-latest
|
|
11
|
-
environment: pypi
|
|
12
|
-
permissions:
|
|
13
|
-
contents: read
|
|
14
|
-
id-token: write
|
|
15
|
-
steps:
|
|
16
|
-
- name: Checkout
|
|
17
|
-
uses: actions/checkout@v7.0.1
|
|
18
|
-
- name: Install uv
|
|
19
|
-
uses: astral-sh/setup-uv@v9.0.0
|
|
20
|
-
with:
|
|
21
|
-
enable-cache: false
|
|
22
|
-
- name: Verify version tag
|
|
23
|
-
run: test "$GITHUB_REF_NAME" = "v$(uv --no-config version --short)"
|
|
24
|
-
- name: Test
|
|
25
|
-
run: uv --no-config run --locked python -m unittest discover -s tests
|
|
26
|
-
- name: Build
|
|
27
|
-
run: uv --no-config build --no-sources
|
|
28
|
-
- name: Publish
|
|
29
|
-
run: uv publish --trusted-publishing always
|
devin_cli_usage-0.1.1/.gitignore
DELETED
devin_cli_usage-0.1.1/PKG-INFO
DELETED
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: devin-cli-usage
|
|
3
|
-
Version: 0.1.1
|
|
4
|
-
Summary: Devin CLI account quota monitor using local Devin authentication
|
|
5
|
-
Project-URL: Homepage, https://github.com/wakamex/devin-cli-usage
|
|
6
|
-
Project-URL: Source, https://github.com/wakamex/devin-cli-usage
|
|
7
|
-
Requires-Python: >=3.12
|
|
8
|
-
Description-Content-Type: text/markdown
|
|
9
|
-
|
|
10
|
-
# devin-cli-usage
|
|
11
|
-
|
|
12
|
-
Devin account usage and quota monitor. It follows the same dependency-free
|
|
13
|
-
interface as the other `/code/*usage` tools.
|
|
14
|
-
|
|
15
|
-
The tool targets the local Devin CLI harness, but reports account-wide Devin
|
|
16
|
-
quota. It does not estimate usage from local CLI sessions.
|
|
17
|
-
|
|
18
|
-
## Example output
|
|
19
|
-
|
|
20
|
-
```text
|
|
21
|
-
Devin usage
|
|
22
|
-
Status: live
|
|
23
|
-
Plan: Pro
|
|
24
|
-
Daily: 100% remaining, resets in 11h
|
|
25
|
-
Weekly: 100% remaining, resets in 6d11h
|
|
26
|
-
Prompt credits available: -1
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
Statusline:
|
|
30
|
-
|
|
31
|
-
```text
|
|
32
|
-
devin:daily:100%left reset:11h
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
## Install
|
|
36
|
-
|
|
37
|
-
```bash
|
|
38
|
-
uv tool install devin-cli-usage
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
For local development:
|
|
42
|
-
|
|
43
|
-
```bash
|
|
44
|
-
uv tool install .
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
## Commands
|
|
48
|
-
|
|
49
|
-
| Command | Description |
|
|
50
|
-
| --- | --- |
|
|
51
|
-
| `devin-cli-usage` | Show current account usage |
|
|
52
|
-
| `devin-cli-usage status` | Same as above |
|
|
53
|
-
| `devin-cli-usage json` | Print normalized JSON |
|
|
54
|
-
| `devin-cli-usage statusline` | Print compact cached output |
|
|
55
|
-
| `devin-cli-usage refresh` | Refresh the cache and print status |
|
|
56
|
-
| `devin-cli-usage daemon [-i SECS]` | Keep the cache fresh |
|
|
57
|
-
| `devin-cli-usage install` | Print installation instructions |
|
|
58
|
-
|
|
59
|
-
## Authentication and data
|
|
60
|
-
|
|
61
|
-
The tool reads `windsurf_api_key` and `api_server_url` from Devin's local
|
|
62
|
-
`~/.local/share/devin/credentials.toml`, or the corresponding path below
|
|
63
|
-
`XDG_DATA_HOME` when it is set, then calls the read-only `GetUserStatus`
|
|
64
|
-
service used by the installed client. It rereads credentials for every live
|
|
65
|
-
request. It never refreshes credentials or modifies Devin-owned files.
|
|
66
|
-
|
|
67
|
-
The normalized JSON includes:
|
|
68
|
-
|
|
69
|
-
- `provider`, `source`, `retrieved_at`, and `status`
|
|
70
|
-
- plan name, billing strategy, and plan period
|
|
71
|
-
- daily and weekly quota percentages and reset times
|
|
72
|
-
- available and consumed credits, ACU limits, and overage balance when supplied
|
|
73
|
-
|
|
74
|
-
`status` is `live`, `cached`, `stale`, or `unavailable`. Errors and caches do
|
|
75
|
-
not include the API key or Devin account identity. A credit value of `-1` is a
|
|
76
|
-
vendor sentinel for an unlimited or non-credit-based allowance and is
|
|
77
|
-
preserved verbatim.
|
|
78
|
-
|
|
79
|
-
The tool writes only its own non-secret cache at
|
|
80
|
-
`~/.local/share/devin/usage-limits.json`, also following `XDG_DATA_HOME` when
|
|
81
|
-
set. JSON results report whether they are `live`, `cached`, `stale`, or
|
|
82
|
-
`unavailable`.
|
|
83
|
-
|
|
84
|
-
Environment overrides:
|
|
85
|
-
|
|
86
|
-
- `DEVIN_USAGE_CREDENTIALS_FILE`: alternate credentials file
|
|
87
|
-
- `DEVIN_USAGE_FILE`: alternate cache path
|
|
88
|
-
|
|
89
|
-
The user-status service is undocumented and may change with Devin client
|
|
90
|
-
releases.
|
|
91
|
-
|
|
92
|
-
## Options
|
|
93
|
-
|
|
94
|
-
```text
|
|
95
|
-
usage: devin-cli-usage [-h] [-i INTERVAL] [--max-age MAX_AGE] [--refresh]
|
|
96
|
-
{status,json,daemon,statusline,refresh,install}
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
- `--max-age SECS`: maximum cache age used by `statusline`
|
|
100
|
-
- `--refresh`: bypass the cache for `statusline`
|
|
101
|
-
- `-i SECS`: daemon refresh interval
|
|
102
|
-
|
|
103
|
-
## Development
|
|
104
|
-
|
|
105
|
-
```bash
|
|
106
|
-
uv --no-config lock --check
|
|
107
|
-
uv --no-config run --locked python -m unittest discover -s tests
|
|
108
|
-
uv --no-config build --no-sources
|
|
109
|
-
```
|
devin_cli_usage-0.1.1/README.md
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
# devin-cli-usage
|
|
2
|
-
|
|
3
|
-
Devin account usage and quota monitor. It follows the same dependency-free
|
|
4
|
-
interface as the other `/code/*usage` tools.
|
|
5
|
-
|
|
6
|
-
The tool targets the local Devin CLI harness, but reports account-wide Devin
|
|
7
|
-
quota. It does not estimate usage from local CLI sessions.
|
|
8
|
-
|
|
9
|
-
## Example output
|
|
10
|
-
|
|
11
|
-
```text
|
|
12
|
-
Devin usage
|
|
13
|
-
Status: live
|
|
14
|
-
Plan: Pro
|
|
15
|
-
Daily: 100% remaining, resets in 11h
|
|
16
|
-
Weekly: 100% remaining, resets in 6d11h
|
|
17
|
-
Prompt credits available: -1
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
Statusline:
|
|
21
|
-
|
|
22
|
-
```text
|
|
23
|
-
devin:daily:100%left reset:11h
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
## Install
|
|
27
|
-
|
|
28
|
-
```bash
|
|
29
|
-
uv tool install devin-cli-usage
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
For local development:
|
|
33
|
-
|
|
34
|
-
```bash
|
|
35
|
-
uv tool install .
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
## Commands
|
|
39
|
-
|
|
40
|
-
| Command | Description |
|
|
41
|
-
| --- | --- |
|
|
42
|
-
| `devin-cli-usage` | Show current account usage |
|
|
43
|
-
| `devin-cli-usage status` | Same as above |
|
|
44
|
-
| `devin-cli-usage json` | Print normalized JSON |
|
|
45
|
-
| `devin-cli-usage statusline` | Print compact cached output |
|
|
46
|
-
| `devin-cli-usage refresh` | Refresh the cache and print status |
|
|
47
|
-
| `devin-cli-usage daemon [-i SECS]` | Keep the cache fresh |
|
|
48
|
-
| `devin-cli-usage install` | Print installation instructions |
|
|
49
|
-
|
|
50
|
-
## Authentication and data
|
|
51
|
-
|
|
52
|
-
The tool reads `windsurf_api_key` and `api_server_url` from Devin's local
|
|
53
|
-
`~/.local/share/devin/credentials.toml`, or the corresponding path below
|
|
54
|
-
`XDG_DATA_HOME` when it is set, then calls the read-only `GetUserStatus`
|
|
55
|
-
service used by the installed client. It rereads credentials for every live
|
|
56
|
-
request. It never refreshes credentials or modifies Devin-owned files.
|
|
57
|
-
|
|
58
|
-
The normalized JSON includes:
|
|
59
|
-
|
|
60
|
-
- `provider`, `source`, `retrieved_at`, and `status`
|
|
61
|
-
- plan name, billing strategy, and plan period
|
|
62
|
-
- daily and weekly quota percentages and reset times
|
|
63
|
-
- available and consumed credits, ACU limits, and overage balance when supplied
|
|
64
|
-
|
|
65
|
-
`status` is `live`, `cached`, `stale`, or `unavailable`. Errors and caches do
|
|
66
|
-
not include the API key or Devin account identity. A credit value of `-1` is a
|
|
67
|
-
vendor sentinel for an unlimited or non-credit-based allowance and is
|
|
68
|
-
preserved verbatim.
|
|
69
|
-
|
|
70
|
-
The tool writes only its own non-secret cache at
|
|
71
|
-
`~/.local/share/devin/usage-limits.json`, also following `XDG_DATA_HOME` when
|
|
72
|
-
set. JSON results report whether they are `live`, `cached`, `stale`, or
|
|
73
|
-
`unavailable`.
|
|
74
|
-
|
|
75
|
-
Environment overrides:
|
|
76
|
-
|
|
77
|
-
- `DEVIN_USAGE_CREDENTIALS_FILE`: alternate credentials file
|
|
78
|
-
- `DEVIN_USAGE_FILE`: alternate cache path
|
|
79
|
-
|
|
80
|
-
The user-status service is undocumented and may change with Devin client
|
|
81
|
-
releases.
|
|
82
|
-
|
|
83
|
-
## Options
|
|
84
|
-
|
|
85
|
-
```text
|
|
86
|
-
usage: devin-cli-usage [-h] [-i INTERVAL] [--max-age MAX_AGE] [--refresh]
|
|
87
|
-
{status,json,daemon,statusline,refresh,install}
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
- `--max-age SECS`: maximum cache age used by `statusline`
|
|
91
|
-
- `--refresh`: bypass the cache for `statusline`
|
|
92
|
-
- `-i SECS`: daemon refresh interval
|
|
93
|
-
|
|
94
|
-
## Development
|
|
95
|
-
|
|
96
|
-
```bash
|
|
97
|
-
uv --no-config lock --check
|
|
98
|
-
uv --no-config run --locked python -m unittest discover -s tests
|
|
99
|
-
uv --no-config build --no-sources
|
|
100
|
-
```
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
[project]
|
|
2
|
-
name = "devin-cli-usage"
|
|
3
|
-
version = "0.1.1"
|
|
4
|
-
description = "Devin CLI account quota monitor using local Devin authentication"
|
|
5
|
-
readme = "README.md"
|
|
6
|
-
requires-python = ">=3.12"
|
|
7
|
-
dependencies = []
|
|
8
|
-
|
|
9
|
-
[tool.uv]
|
|
10
|
-
package = true
|
|
11
|
-
|
|
12
|
-
[build-system]
|
|
13
|
-
requires = ["hatchling"]
|
|
14
|
-
build-backend = "hatchling.build"
|
|
15
|
-
|
|
16
|
-
[tool.hatch.build.targets.wheel]
|
|
17
|
-
packages = ["src/devin_cli_usage"]
|
|
18
|
-
|
|
19
|
-
[project.urls]
|
|
20
|
-
Homepage = "https://github.com/wakamex/devin-cli-usage"
|
|
21
|
-
Source = "https://github.com/wakamex/devin-cli-usage"
|
|
22
|
-
|
|
23
|
-
[project.scripts]
|
|
24
|
-
devin-cli-usage = "devin_cli_usage:main"
|
|
@@ -1,456 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Devin account usage monitor using Devin-owned local authentication."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import argparse
|
|
7
|
-
import json
|
|
8
|
-
import math
|
|
9
|
-
import os
|
|
10
|
-
import re
|
|
11
|
-
import shutil
|
|
12
|
-
import signal
|
|
13
|
-
import subprocess
|
|
14
|
-
import sys
|
|
15
|
-
import tempfile
|
|
16
|
-
import time
|
|
17
|
-
import tomllib
|
|
18
|
-
import urllib.error
|
|
19
|
-
import urllib.parse
|
|
20
|
-
import urllib.request
|
|
21
|
-
from datetime import UTC, datetime
|
|
22
|
-
from pathlib import Path
|
|
23
|
-
|
|
24
|
-
DEFAULT_CREDENTIALS_FILE = (
|
|
25
|
-
Path.home() / ".local" / "share" / "devin" / "credentials.toml"
|
|
26
|
-
)
|
|
27
|
-
DEFAULT_USAGE_FILE = Path.home() / ".local" / "share" / "devin" / "usage-limits.json"
|
|
28
|
-
USER_STATUS_PATH = "/exa.seat_management_pb.SeatManagementService/GetUserStatus"
|
|
29
|
-
CACHE_MAX_AGE = 300
|
|
30
|
-
DAEMON_INTERVAL = 300
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _iso_now() -> str:
|
|
34
|
-
return datetime.now(UTC).isoformat()
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def _parse_iso(value: object) -> datetime | None:
|
|
38
|
-
if not isinstance(value, str) or not value:
|
|
39
|
-
return None
|
|
40
|
-
try:
|
|
41
|
-
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
42
|
-
except ValueError:
|
|
43
|
-
return None
|
|
44
|
-
return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _number(value: object) -> int | float | None:
|
|
48
|
-
if isinstance(value, bool):
|
|
49
|
-
return None
|
|
50
|
-
if isinstance(value, int):
|
|
51
|
-
return value
|
|
52
|
-
if isinstance(value, float):
|
|
53
|
-
return value if math.isfinite(value) else None
|
|
54
|
-
if isinstance(value, str):
|
|
55
|
-
try:
|
|
56
|
-
parsed = float(value)
|
|
57
|
-
except ValueError:
|
|
58
|
-
return None
|
|
59
|
-
if not math.isfinite(parsed):
|
|
60
|
-
return None
|
|
61
|
-
return int(parsed) if parsed.is_integer() else parsed
|
|
62
|
-
return None
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def _optional_number(data: dict, key: str) -> int | float | None:
|
|
66
|
-
raw = data.get(key)
|
|
67
|
-
if raw is None:
|
|
68
|
-
return None
|
|
69
|
-
value = _number(raw)
|
|
70
|
-
if value is None:
|
|
71
|
-
raise RuntimeError(f"Devin user-status response contained invalid {key}")
|
|
72
|
-
return value
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def _format_reset(value: object) -> str:
|
|
76
|
-
seconds = _number(value)
|
|
77
|
-
if seconds is None:
|
|
78
|
-
return ""
|
|
79
|
-
try:
|
|
80
|
-
reset = datetime.fromtimestamp(float(seconds), UTC)
|
|
81
|
-
except (ValueError, OSError):
|
|
82
|
-
return ""
|
|
83
|
-
minutes = max(0, int((reset - datetime.now(UTC)).total_seconds()) // 60)
|
|
84
|
-
if minutes >= 1440:
|
|
85
|
-
return f"{minutes // 1440}d{(minutes % 1440) // 60}h"
|
|
86
|
-
if minutes >= 60:
|
|
87
|
-
return f"{minutes // 60}h{minutes % 60}m"
|
|
88
|
-
return f"{minutes}m"
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def get_credentials_file() -> Path:
|
|
92
|
-
override = os.environ.get("DEVIN_USAGE_CREDENTIALS_FILE")
|
|
93
|
-
if override:
|
|
94
|
-
return Path(override).expanduser()
|
|
95
|
-
data_home = os.environ.get("XDG_DATA_HOME")
|
|
96
|
-
return (
|
|
97
|
-
Path(data_home).expanduser() / "devin" / "credentials.toml"
|
|
98
|
-
if data_home
|
|
99
|
-
else DEFAULT_CREDENTIALS_FILE
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def get_usage_file() -> Path:
|
|
104
|
-
override = os.environ.get("DEVIN_USAGE_FILE")
|
|
105
|
-
if override:
|
|
106
|
-
return Path(override).expanduser()
|
|
107
|
-
data_home = os.environ.get("XDG_DATA_HOME")
|
|
108
|
-
return (
|
|
109
|
-
Path(data_home).expanduser() / "devin" / "usage-limits.json"
|
|
110
|
-
if data_home
|
|
111
|
-
else DEFAULT_USAGE_FILE
|
|
112
|
-
)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def get_credentials() -> tuple[str, str]:
|
|
116
|
-
"""Read the current API key and server without changing Devin credentials."""
|
|
117
|
-
path = get_credentials_file()
|
|
118
|
-
try:
|
|
119
|
-
data = tomllib.loads(path.read_text())
|
|
120
|
-
except FileNotFoundError as exc:
|
|
121
|
-
raise RuntimeError(f"Devin credentials not found at {path}") from exc
|
|
122
|
-
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
123
|
-
raise RuntimeError(f"Could not read Devin credentials from {path}") from exc
|
|
124
|
-
api_key = data.get("windsurf_api_key")
|
|
125
|
-
server = data.get("api_server_url")
|
|
126
|
-
if not isinstance(api_key, str) or not api_key:
|
|
127
|
-
raise RuntimeError("Devin credentials do not contain a local API key")
|
|
128
|
-
if not isinstance(server, str) or not server:
|
|
129
|
-
raise RuntimeError("Devin credentials do not contain an API server")
|
|
130
|
-
parsed = urllib.parse.urlparse(server)
|
|
131
|
-
if parsed.scheme != "https" or not parsed.hostname:
|
|
132
|
-
raise RuntimeError("Devin credential API server is not a valid HTTPS URL")
|
|
133
|
-
return api_key, server.rstrip("/")
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def _devin_version() -> str:
|
|
137
|
-
executable = shutil.which("devin")
|
|
138
|
-
if not executable:
|
|
139
|
-
return "unknown"
|
|
140
|
-
try:
|
|
141
|
-
result = subprocess.run(
|
|
142
|
-
[executable, "version"],
|
|
143
|
-
check=False,
|
|
144
|
-
capture_output=True,
|
|
145
|
-
text=True,
|
|
146
|
-
timeout=5,
|
|
147
|
-
)
|
|
148
|
-
except (OSError, subprocess.SubprocessError):
|
|
149
|
-
return "unknown"
|
|
150
|
-
match = re.search(r"\b(\d+(?:\.\d+)+)\b", result.stdout)
|
|
151
|
-
return match.group(1) if match else "unknown"
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def fetch_usage() -> dict:
|
|
155
|
-
api_key, server = get_credentials()
|
|
156
|
-
version = _devin_version()
|
|
157
|
-
payload = {
|
|
158
|
-
"metadata": {
|
|
159
|
-
"apiKey": api_key,
|
|
160
|
-
"ideName": "devin",
|
|
161
|
-
"ideVersion": version,
|
|
162
|
-
"extensionVersion": version,
|
|
163
|
-
"locale": "en",
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
request = urllib.request.Request(
|
|
167
|
-
server + USER_STATUS_PATH,
|
|
168
|
-
data=json.dumps(payload, separators=(",", ":")).encode(),
|
|
169
|
-
method="POST",
|
|
170
|
-
headers={
|
|
171
|
-
"Content-Type": "application/json",
|
|
172
|
-
"Connect-Protocol-Version": "1",
|
|
173
|
-
"User-Agent": "devin-cli-usage/0.1",
|
|
174
|
-
},
|
|
175
|
-
)
|
|
176
|
-
try:
|
|
177
|
-
with urllib.request.urlopen(request, timeout=15) as response:
|
|
178
|
-
result = json.loads(response.read())
|
|
179
|
-
except urllib.error.HTTPError as exc:
|
|
180
|
-
if exc.code in (401, 403):
|
|
181
|
-
raise RuntimeError(
|
|
182
|
-
"Devin local API key is no longer accepted; sign in with Devin again"
|
|
183
|
-
) from exc
|
|
184
|
-
raise RuntimeError(f"Devin user-status API returned HTTP {exc.code}") from exc
|
|
185
|
-
except (urllib.error.URLError, TimeoutError) as exc:
|
|
186
|
-
raise RuntimeError("Devin user-status API is unavailable") from exc
|
|
187
|
-
except json.JSONDecodeError as exc:
|
|
188
|
-
raise RuntimeError("Devin user-status API returned invalid JSON") from exc
|
|
189
|
-
if not isinstance(result, dict):
|
|
190
|
-
raise RuntimeError("Devin user-status API returned an unexpected response")
|
|
191
|
-
return result
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def _normalized_plan_info(payload: dict, plan_status: dict) -> dict:
|
|
195
|
-
info = payload.get("planInfo")
|
|
196
|
-
if not isinstance(info, dict):
|
|
197
|
-
info = plan_status.get("planInfo")
|
|
198
|
-
if not isinstance(info, dict):
|
|
199
|
-
info = {}
|
|
200
|
-
normalized = {
|
|
201
|
-
key: value
|
|
202
|
-
for key, source in (
|
|
203
|
-
("name", "planName"),
|
|
204
|
-
("billing_strategy", "billingStrategy"),
|
|
205
|
-
)
|
|
206
|
-
if (value := info.get(source)) is not None
|
|
207
|
-
}
|
|
208
|
-
for key, source in (
|
|
209
|
-
("monthly_prompt_credits", "monthlyPromptCredits"),
|
|
210
|
-
("monthly_flow_credits", "monthlyFlowCredits"),
|
|
211
|
-
("monthly_flex_credits", "monthlyFlexCreditPurchaseAmount"),
|
|
212
|
-
):
|
|
213
|
-
if (value := _optional_number(info, source)) is not None:
|
|
214
|
-
normalized[key] = value
|
|
215
|
-
return normalized
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
def _normalize(payload: dict) -> dict:
|
|
219
|
-
user_status = payload.get("userStatus")
|
|
220
|
-
if not isinstance(user_status, dict):
|
|
221
|
-
raise RuntimeError("Devin user-status response did not contain userStatus")
|
|
222
|
-
plan_status = user_status.get("planStatus")
|
|
223
|
-
if not isinstance(plan_status, dict):
|
|
224
|
-
raise RuntimeError("Devin user-status response did not contain planStatus")
|
|
225
|
-
quotas = {
|
|
226
|
-
"daily": {
|
|
227
|
-
"remaining_pct": _optional_number(
|
|
228
|
-
plan_status, "dailyQuotaRemainingPercent"
|
|
229
|
-
),
|
|
230
|
-
"reset_at": _optional_number(plan_status, "dailyQuotaResetAtUnix"),
|
|
231
|
-
},
|
|
232
|
-
"weekly": {
|
|
233
|
-
"remaining_pct": _optional_number(
|
|
234
|
-
plan_status, "weeklyQuotaRemainingPercent"
|
|
235
|
-
),
|
|
236
|
-
"reset_at": _optional_number(plan_status, "weeklyQuotaResetAtUnix"),
|
|
237
|
-
},
|
|
238
|
-
}
|
|
239
|
-
credits = {
|
|
240
|
-
key: value
|
|
241
|
-
for key, source in (
|
|
242
|
-
("available_prompt", "availablePromptCredits"),
|
|
243
|
-
("used_prompt", "usedPromptCredits"),
|
|
244
|
-
("available_flow", "availableFlowCredits"),
|
|
245
|
-
("used_flow", "usedFlowCredits"),
|
|
246
|
-
("available_flex", "availableFlexCredits"),
|
|
247
|
-
("used_flex", "usedFlexCredits"),
|
|
248
|
-
("overage_balance_micros", "overageBalanceMicros"),
|
|
249
|
-
("acu_consumed", "acuConsumed"),
|
|
250
|
-
("acu_limit", "acuLimit"),
|
|
251
|
-
)
|
|
252
|
-
if (value := _optional_number(plan_status, source)) is not None
|
|
253
|
-
}
|
|
254
|
-
if not credits and not any(
|
|
255
|
-
quota["remaining_pct"] is not None for quota in quotas.values()
|
|
256
|
-
):
|
|
257
|
-
raise RuntimeError("Devin user-status response did not contain usage data")
|
|
258
|
-
return {
|
|
259
|
-
"plan": _normalized_plan_info(payload, plan_status),
|
|
260
|
-
"plan_period": {
|
|
261
|
-
"start": plan_status.get("planStart"),
|
|
262
|
-
"end": plan_status.get("planEnd"),
|
|
263
|
-
},
|
|
264
|
-
"quotas": quotas,
|
|
265
|
-
"credits": credits,
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
def build_usage_json() -> dict:
|
|
270
|
-
updated_at = _iso_now()
|
|
271
|
-
try:
|
|
272
|
-
usage = _normalize(fetch_usage())
|
|
273
|
-
except Exception as exc:
|
|
274
|
-
return {
|
|
275
|
-
"provider": "devin",
|
|
276
|
-
"status": "unavailable",
|
|
277
|
-
"source": "devin_user_status_api",
|
|
278
|
-
"retrieved_at": updated_at,
|
|
279
|
-
"error": str(exc),
|
|
280
|
-
}
|
|
281
|
-
return {
|
|
282
|
-
"provider": "devin",
|
|
283
|
-
"status": "live",
|
|
284
|
-
"source": "devin_user_status_api",
|
|
285
|
-
"retrieved_at": updated_at,
|
|
286
|
-
**usage,
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def write_usage_file(data: dict) -> None:
|
|
291
|
-
path = get_usage_file()
|
|
292
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
293
|
-
fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
294
|
-
temporary = Path(temporary_name)
|
|
295
|
-
try:
|
|
296
|
-
with os.fdopen(fd, "w") as handle:
|
|
297
|
-
json.dump(data, handle, indent=2)
|
|
298
|
-
handle.write("\n")
|
|
299
|
-
os.replace(temporary, path)
|
|
300
|
-
except Exception:
|
|
301
|
-
temporary.unlink(missing_ok=True)
|
|
302
|
-
raise
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
def _read_cache() -> dict | None:
|
|
306
|
-
try:
|
|
307
|
-
data = json.loads(get_usage_file().read_text())
|
|
308
|
-
except (OSError, json.JSONDecodeError):
|
|
309
|
-
return None
|
|
310
|
-
return data if isinstance(data, dict) else None
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
def get_cached_usage(max_age: int = CACHE_MAX_AGE, force_refresh: bool = False) -> dict:
|
|
314
|
-
cached = _read_cache()
|
|
315
|
-
if not force_refresh and cached:
|
|
316
|
-
retrieved = _parse_iso(cached.get("retrieved_at"))
|
|
317
|
-
if retrieved and (datetime.now(UTC) - retrieved).total_seconds() < max_age:
|
|
318
|
-
return {**cached, "status": "cached"}
|
|
319
|
-
fresh = build_usage_json()
|
|
320
|
-
if fresh["status"] == "live":
|
|
321
|
-
write_usage_file(fresh)
|
|
322
|
-
return fresh
|
|
323
|
-
if cached:
|
|
324
|
-
return {**cached, "status": "stale", "refresh_error": fresh.get("error")}
|
|
325
|
-
return fresh
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
def _quota_value(data: dict) -> tuple[str, dict] | None:
|
|
329
|
-
quotas = data.get("quotas")
|
|
330
|
-
if not isinstance(quotas, dict):
|
|
331
|
-
return None
|
|
332
|
-
candidates = [
|
|
333
|
-
(name, quota)
|
|
334
|
-
for name, quota in quotas.items()
|
|
335
|
-
if isinstance(quota, dict) and quota.get("remaining_pct") is not None
|
|
336
|
-
]
|
|
337
|
-
return (
|
|
338
|
-
min(candidates, key=lambda item: float(item[1]["remaining_pct"]))
|
|
339
|
-
if candidates
|
|
340
|
-
else None
|
|
341
|
-
)
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
def _statusline_text(data: dict) -> str:
|
|
345
|
-
selected = _quota_value(data)
|
|
346
|
-
if not selected:
|
|
347
|
-
return "devin:q:unavailable"
|
|
348
|
-
name, quota = selected
|
|
349
|
-
remaining = float(quota["remaining_pct"])
|
|
350
|
-
parts = [f"devin:{name}:{remaining:g}%left"]
|
|
351
|
-
reset = _format_reset(quota.get("reset_at"))
|
|
352
|
-
if reset:
|
|
353
|
-
parts.append(f"reset:{reset}")
|
|
354
|
-
if data.get("status") in {"cached", "stale"}:
|
|
355
|
-
parts.append(str(data["status"]))
|
|
356
|
-
return " ".join(parts)
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
def _print_status(data: dict) -> None:
|
|
360
|
-
print("Devin usage")
|
|
361
|
-
print(f"Status: {data['status']}")
|
|
362
|
-
plan = data.get("plan")
|
|
363
|
-
if isinstance(plan, dict) and plan.get("name"):
|
|
364
|
-
print(f"Plan: {plan['name']}")
|
|
365
|
-
quotas = data.get("quotas")
|
|
366
|
-
if isinstance(quotas, dict):
|
|
367
|
-
for name in ("daily", "weekly"):
|
|
368
|
-
quota = quotas.get(name)
|
|
369
|
-
if isinstance(quota, dict) and quota.get("remaining_pct") is not None:
|
|
370
|
-
suffix = _format_reset(quota.get("reset_at"))
|
|
371
|
-
reset = f", resets in {suffix}" if suffix else ""
|
|
372
|
-
print(
|
|
373
|
-
f"{name.title()}: {float(quota['remaining_pct']):g}% remaining{reset}"
|
|
374
|
-
)
|
|
375
|
-
credits = data.get("credits")
|
|
376
|
-
if isinstance(credits, dict):
|
|
377
|
-
if credits.get("available_prompt") is not None:
|
|
378
|
-
print(f"Prompt credits available: {credits['available_prompt']}")
|
|
379
|
-
if credits.get("available_flow") is not None:
|
|
380
|
-
print(f"Flow credits available: {credits['available_flow']}")
|
|
381
|
-
if credits.get("available_flex") is not None:
|
|
382
|
-
print(f"Flex credits available: {credits['available_flex']}")
|
|
383
|
-
if data.get("error"):
|
|
384
|
-
print(f"Error: {data['error']}")
|
|
385
|
-
if data.get("refresh_error"):
|
|
386
|
-
print(f"Refresh error: {data['refresh_error']}")
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
def cmd_status(_args: argparse.Namespace) -> None:
|
|
390
|
-
_print_status(build_usage_json())
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
def cmd_json(_args: argparse.Namespace) -> None:
|
|
394
|
-
print(json.dumps(build_usage_json(), indent=2))
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
def cmd_statusline(args: argparse.Namespace) -> None:
|
|
398
|
-
print(_statusline_text(get_cached_usage(args.max_age, args.refresh)))
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
def cmd_refresh(_args: argparse.Namespace) -> None:
|
|
402
|
-
data = build_usage_json()
|
|
403
|
-
if data["status"] == "live":
|
|
404
|
-
write_usage_file(data)
|
|
405
|
-
_print_status(data)
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
def cmd_daemon(args: argparse.Namespace) -> None:
|
|
409
|
-
signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
|
|
410
|
-
if hasattr(signal, "SIGTERM"):
|
|
411
|
-
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
|
412
|
-
print(f"devin-cli-usage daemon started (refreshing every {args.interval}s)")
|
|
413
|
-
print(f"Writing to {get_usage_file()}")
|
|
414
|
-
while True:
|
|
415
|
-
data = build_usage_json()
|
|
416
|
-
if data["status"] == "live":
|
|
417
|
-
write_usage_file(data)
|
|
418
|
-
print(f"[{datetime.now().strftime('%H:%M:%S')}] {_statusline_text(data)}")
|
|
419
|
-
time.sleep(args.interval)
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
def cmd_install(_args: argparse.Namespace) -> None:
|
|
423
|
-
print(
|
|
424
|
-
"Install with:\n uv tool install devin-cli-usage\n\nThen run:\n devin-cli-usage"
|
|
425
|
-
)
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
def _build_parser() -> argparse.ArgumentParser:
|
|
429
|
-
parser = argparse.ArgumentParser(description="Devin account usage monitor")
|
|
430
|
-
parser.add_argument(
|
|
431
|
-
"command",
|
|
432
|
-
nargs="?",
|
|
433
|
-
default="status",
|
|
434
|
-
choices=["status", "json", "daemon", "statusline", "refresh", "install"],
|
|
435
|
-
)
|
|
436
|
-
parser.add_argument("-i", "--interval", type=int, default=DAEMON_INTERVAL)
|
|
437
|
-
parser.add_argument("--max-age", type=int, default=CACHE_MAX_AGE)
|
|
438
|
-
parser.add_argument("--refresh", action="store_true")
|
|
439
|
-
return parser
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
def main() -> None:
|
|
443
|
-
args = _build_parser().parse_args()
|
|
444
|
-
commands = {
|
|
445
|
-
"status": cmd_status,
|
|
446
|
-
"json": cmd_json,
|
|
447
|
-
"daemon": cmd_daemon,
|
|
448
|
-
"statusline": cmd_statusline,
|
|
449
|
-
"refresh": cmd_refresh,
|
|
450
|
-
"install": cmd_install,
|
|
451
|
-
}
|
|
452
|
-
commands[args.command](args)
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
if __name__ == "__main__":
|
|
456
|
-
main()
|
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import tempfile
|
|
5
|
-
import unittest
|
|
6
|
-
import urllib.error
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
from unittest import mock
|
|
9
|
-
|
|
10
|
-
import devin_cli_usage as devin_usage
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class FakeResponse:
|
|
14
|
-
def __init__(self, payload: dict):
|
|
15
|
-
self.payload = payload
|
|
16
|
-
|
|
17
|
-
def __enter__(self):
|
|
18
|
-
return self
|
|
19
|
-
|
|
20
|
-
def __exit__(self, *_args):
|
|
21
|
-
return False
|
|
22
|
-
|
|
23
|
-
def read(self):
|
|
24
|
-
return json.dumps(self.payload).encode()
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class DevinUsageTests(unittest.TestCase):
|
|
28
|
-
def test_default_files_follow_xdg_data_home(self):
|
|
29
|
-
with (
|
|
30
|
-
tempfile.TemporaryDirectory() as temporary,
|
|
31
|
-
mock.patch.dict(
|
|
32
|
-
devin_usage.os.environ,
|
|
33
|
-
{"XDG_DATA_HOME": temporary},
|
|
34
|
-
clear=True,
|
|
35
|
-
),
|
|
36
|
-
):
|
|
37
|
-
data_dir = Path(temporary) / "devin"
|
|
38
|
-
self.assertEqual(
|
|
39
|
-
devin_usage.get_credentials_file(), data_dir / "credentials.toml"
|
|
40
|
-
)
|
|
41
|
-
self.assertEqual(
|
|
42
|
-
devin_usage.get_usage_file(), data_dir / "usage-limits.json"
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
def test_credentials_are_reread_without_writes(self):
|
|
46
|
-
with tempfile.TemporaryDirectory() as temporary:
|
|
47
|
-
credentials = Path(temporary) / "credentials.toml"
|
|
48
|
-
credentials.write_text(
|
|
49
|
-
'windsurf_api_key = "first"\napi_server_url = "https://server.codeium.com"\n'
|
|
50
|
-
)
|
|
51
|
-
with mock.patch.object(
|
|
52
|
-
devin_usage, "DEFAULT_CREDENTIALS_FILE", credentials
|
|
53
|
-
):
|
|
54
|
-
self.assertEqual(devin_usage.get_credentials()[0], "first")
|
|
55
|
-
credentials.write_text(
|
|
56
|
-
'windsurf_api_key = "second"\napi_server_url = "https://server.codeium.com"\n'
|
|
57
|
-
)
|
|
58
|
-
self.assertEqual(devin_usage.get_credentials()[0], "second")
|
|
59
|
-
|
|
60
|
-
def test_fetch_puts_api_key_in_request_metadata(self):
|
|
61
|
-
payload = {"userStatus": {"planStatus": {}}}
|
|
62
|
-
|
|
63
|
-
def fake_urlopen(request, timeout):
|
|
64
|
-
body = json.loads(request.data)
|
|
65
|
-
metadata = body["metadata"]
|
|
66
|
-
self.assertEqual(metadata["apiKey"], "local-key")
|
|
67
|
-
self.assertEqual(metadata["ideName"], "devin")
|
|
68
|
-
self.assertEqual(metadata["ideVersion"], "3000.3.27")
|
|
69
|
-
self.assertNotIn("Authorization", request.headers)
|
|
70
|
-
self.assertEqual(timeout, 15)
|
|
71
|
-
return FakeResponse(payload)
|
|
72
|
-
|
|
73
|
-
with (
|
|
74
|
-
mock.patch.object(
|
|
75
|
-
devin_usage,
|
|
76
|
-
"get_credentials",
|
|
77
|
-
return_value=("local-key", "https://server.codeium.com"),
|
|
78
|
-
),
|
|
79
|
-
mock.patch.object(devin_usage, "_devin_version", return_value="3000.3.27"),
|
|
80
|
-
mock.patch.object(
|
|
81
|
-
devin_usage.urllib.request, "urlopen", side_effect=fake_urlopen
|
|
82
|
-
),
|
|
83
|
-
):
|
|
84
|
-
self.assertEqual(devin_usage.fetch_usage(), payload)
|
|
85
|
-
|
|
86
|
-
def test_build_normalizes_quota_and_credit_fields(self):
|
|
87
|
-
payload = {
|
|
88
|
-
"planInfo": {"planName": "Pro", "monthlyPromptCredits": 1000},
|
|
89
|
-
"userStatus": {
|
|
90
|
-
"planStatus": {
|
|
91
|
-
"dailyQuotaRemainingPercent": 75,
|
|
92
|
-
"dailyQuotaResetAtUnix": 2_000_000_000,
|
|
93
|
-
"weeklyQuotaRemainingPercent": "50",
|
|
94
|
-
"availablePromptCredits": "900",
|
|
95
|
-
"usedPromptCredits": 100,
|
|
96
|
-
"overageBalanceMicros": "0",
|
|
97
|
-
}
|
|
98
|
-
},
|
|
99
|
-
}
|
|
100
|
-
with mock.patch.object(devin_usage, "fetch_usage", return_value=payload):
|
|
101
|
-
data = devin_usage.build_usage_json()
|
|
102
|
-
self.assertEqual(data["status"], "live")
|
|
103
|
-
self.assertEqual(data["plan"]["name"], "Pro")
|
|
104
|
-
self.assertEqual(data["quotas"]["daily"]["remaining_pct"], 75)
|
|
105
|
-
self.assertEqual(data["quotas"]["weekly"]["remaining_pct"], 50)
|
|
106
|
-
self.assertEqual(data["credits"]["available_prompt"], 900)
|
|
107
|
-
|
|
108
|
-
def test_incomplete_plan_status_is_unavailable(self):
|
|
109
|
-
for plan_status in (None, [], {}):
|
|
110
|
-
with self.subTest(plan_status=plan_status):
|
|
111
|
-
payload = {"userStatus": {"planStatus": plan_status}}
|
|
112
|
-
with mock.patch.object(
|
|
113
|
-
devin_usage, "fetch_usage", return_value=payload
|
|
114
|
-
):
|
|
115
|
-
data = devin_usage.build_usage_json()
|
|
116
|
-
self.assertEqual(data["status"], "unavailable")
|
|
117
|
-
|
|
118
|
-
def test_nonfinite_usage_is_unavailable(self):
|
|
119
|
-
payloads = (
|
|
120
|
-
{
|
|
121
|
-
"userStatus": {
|
|
122
|
-
"planStatus": {"dailyQuotaRemainingPercent": float("inf")}
|
|
123
|
-
}
|
|
124
|
-
},
|
|
125
|
-
{
|
|
126
|
-
"planInfo": {"monthlyPromptCredits": "NaN"},
|
|
127
|
-
"userStatus": {
|
|
128
|
-
"planStatus": {"dailyQuotaRemainingPercent": 75}
|
|
129
|
-
},
|
|
130
|
-
},
|
|
131
|
-
)
|
|
132
|
-
for payload in payloads:
|
|
133
|
-
with self.subTest(payload=payload):
|
|
134
|
-
with mock.patch.object(
|
|
135
|
-
devin_usage, "fetch_usage", return_value=payload
|
|
136
|
-
):
|
|
137
|
-
data = devin_usage.build_usage_json()
|
|
138
|
-
self.assertEqual(data["status"], "unavailable")
|
|
139
|
-
json.dumps(data, allow_nan=False)
|
|
140
|
-
|
|
141
|
-
def test_non_https_server_is_rejected(self):
|
|
142
|
-
with tempfile.TemporaryDirectory() as temporary:
|
|
143
|
-
credentials = Path(temporary) / "credentials.toml"
|
|
144
|
-
credentials.write_text(
|
|
145
|
-
'windsurf_api_key = "key"\napi_server_url = "http://server.codeium.com"\n'
|
|
146
|
-
)
|
|
147
|
-
with mock.patch.object(
|
|
148
|
-
devin_usage, "DEFAULT_CREDENTIALS_FILE", credentials
|
|
149
|
-
):
|
|
150
|
-
with self.assertRaises(RuntimeError):
|
|
151
|
-
devin_usage.get_credentials()
|
|
152
|
-
|
|
153
|
-
def test_unauthorized_never_attempts_refresh(self):
|
|
154
|
-
error = urllib.error.HTTPError("https://example.test", 401, "", {}, None)
|
|
155
|
-
with (
|
|
156
|
-
mock.patch.object(
|
|
157
|
-
devin_usage,
|
|
158
|
-
"get_credentials",
|
|
159
|
-
return_value=("expired", "https://server.codeium.com"),
|
|
160
|
-
),
|
|
161
|
-
mock.patch.object(devin_usage, "_devin_version", return_value="1.0.0"),
|
|
162
|
-
mock.patch.object(devin_usage.urllib.request, "urlopen", side_effect=error),
|
|
163
|
-
):
|
|
164
|
-
data = devin_usage.build_usage_json()
|
|
165
|
-
self.assertEqual(data["status"], "unavailable")
|
|
166
|
-
self.assertIn("sign in", data["error"])
|
|
167
|
-
|
|
168
|
-
def test_stale_cache_is_returned_when_refresh_fails(self):
|
|
169
|
-
cached = {
|
|
170
|
-
"provider": "devin",
|
|
171
|
-
"status": "live",
|
|
172
|
-
"source": "devin_user_status_api",
|
|
173
|
-
"retrieved_at": "2020-01-01T00:00:00+00:00",
|
|
174
|
-
"quotas": {"daily": {"remaining_pct": 50}},
|
|
175
|
-
}
|
|
176
|
-
with tempfile.TemporaryDirectory() as temporary:
|
|
177
|
-
cache = Path(temporary) / "usage.json"
|
|
178
|
-
cache.write_text(json.dumps(cached))
|
|
179
|
-
with (
|
|
180
|
-
mock.patch.object(devin_usage, "DEFAULT_USAGE_FILE", cache),
|
|
181
|
-
mock.patch.object(
|
|
182
|
-
devin_usage,
|
|
183
|
-
"build_usage_json",
|
|
184
|
-
return_value={"status": "unavailable", "error": "offline"},
|
|
185
|
-
),
|
|
186
|
-
):
|
|
187
|
-
result = devin_usage.get_cached_usage()
|
|
188
|
-
self.assertEqual(result["status"], "stale")
|
|
189
|
-
self.assertEqual(result["refresh_error"], "offline")
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if __name__ == "__main__":
|
|
193
|
-
unittest.main()
|