deepctl-core 0.2.7__tar.gz → 0.2.8__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.
- {deepctl_core-0.2.7 → deepctl_core-0.2.8}/PKG-INFO +1 -1
- {deepctl_core-0.2.7 → deepctl_core-0.2.8}/pyproject.toml +1 -1
- {deepctl_core-0.2.7 → deepctl_core-0.2.8}/src/deepctl_core/auth.py +210 -34
- {deepctl_core-0.2.7 → deepctl_core-0.2.8}/src/deepctl_core/config.py +4 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.8}/src/deepctl_core.egg-info/PKG-INFO +1 -1
- {deepctl_core-0.2.7 → deepctl_core-0.2.8}/README.md +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.8}/setup.cfg +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.8}/src/deepctl_core/__init__.py +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.8}/src/deepctl_core/base_command.py +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.8}/src/deepctl_core/base_group_command.py +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.8}/src/deepctl_core/client.py +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.8}/src/deepctl_core/models.py +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.8}/src/deepctl_core/output.py +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.8}/src/deepctl_core/plugin_env.py +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.8}/src/deepctl_core/plugin_manager.py +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.8}/src/deepctl_core/py.typed +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.8}/src/deepctl_core/skill_generator.py +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.8}/src/deepctl_core/timing.py +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.8}/src/deepctl_core.egg-info/SOURCES.txt +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.8}/src/deepctl_core.egg-info/dependency_links.txt +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.8}/src/deepctl_core.egg-info/requires.txt +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.8}/src/deepctl_core.egg-info/top_level.txt +0 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import os
|
|
4
4
|
import time
|
|
5
5
|
import webbrowser
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
6
7
|
from urllib.parse import urlencode
|
|
7
8
|
|
|
8
9
|
import httpx
|
|
@@ -20,6 +21,8 @@ console = Console()
|
|
|
20
21
|
AUTH_BASE_URL = os.getenv("DEEPGRAM_CLI_BASE_URL", "https://id.dx.deepgram.com")
|
|
21
22
|
DEVICE_CODE_URL = f"{AUTH_BASE_URL}/device/code"
|
|
22
23
|
TOKEN_POLL_URL = f"{AUTH_BASE_URL}/device/token"
|
|
24
|
+
TOKEN_REFRESH_URL = f"{AUTH_BASE_URL}/token"
|
|
25
|
+
DG_CREDENTIALS_URL = f"{AUTH_BASE_URL}/credentials/token"
|
|
23
26
|
|
|
24
27
|
# Registered OIDC client ID for the CLI
|
|
25
28
|
CLIENT_ID = "deepgram-cli"
|
|
@@ -43,6 +46,7 @@ class TokenResponse(BaseModel):
|
|
|
43
46
|
|
|
44
47
|
access_token: str
|
|
45
48
|
project_id: str
|
|
49
|
+
refresh_token: str | None = None # Present in new JWT-based device flow
|
|
46
50
|
token_type: str | None = None
|
|
47
51
|
expires_in: int | None = None
|
|
48
52
|
scope: str | None = None
|
|
@@ -105,11 +109,12 @@ class AuthManager:
|
|
|
105
109
|
"""
|
|
106
110
|
profile_name = profile_name or self.config.profile or "default"
|
|
107
111
|
|
|
108
|
-
# Check keyring for API key
|
|
112
|
+
# Check keyring for direct API key or JWT (both indicate logged-in state)
|
|
109
113
|
has_api_key = False
|
|
110
114
|
try:
|
|
111
115
|
api_key = keyring.get_password(KEYRING_SERVICE, f"api-key.{profile_name}")
|
|
112
|
-
|
|
116
|
+
jwt = keyring.get_password(KEYRING_SERVICE, f"jwt.{profile_name}")
|
|
117
|
+
has_api_key = bool(api_key or jwt)
|
|
113
118
|
except Exception:
|
|
114
119
|
pass
|
|
115
120
|
|
|
@@ -150,32 +155,47 @@ class AuthManager:
|
|
|
150
155
|
return bool(os.getenv("DEEPGRAM_API_KEY") and os.getenv("DEEPGRAM_PROJECT_ID"))
|
|
151
156
|
|
|
152
157
|
def get_api_key(self, ignore_env: bool = False) -> str | None:
|
|
153
|
-
"""Get API key following precedence: explicit >
|
|
158
|
+
"""Get API key following precedence: explicit > JWT flow > legacy keyring > env.
|
|
159
|
+
|
|
160
|
+
When the user logged in via device flow with the new JWT-based server,
|
|
161
|
+
this method transparently refreshes the JWT if expired and exchanges it
|
|
162
|
+
for a Deepgram API token (dg_token). All callers receive a usable
|
|
163
|
+
Deepgram token regardless of which auth path was used.
|
|
164
|
+
|
|
165
|
+
Passing --api-key (or DEEPGRAM_API_KEY) bypasses the JWT flow entirely,
|
|
166
|
+
which is the correct behaviour for CI/CD and direct key usage.
|
|
154
167
|
|
|
155
168
|
Args:
|
|
156
169
|
ignore_env: If True, don't check environment variables
|
|
157
170
|
"""
|
|
158
|
-
# Explicit credentials
|
|
171
|
+
# Explicit credentials (--api-key flag, CI/CD) bypass the JWT flow.
|
|
159
172
|
if self.explicit_api_key:
|
|
160
173
|
return self.explicit_api_key
|
|
161
174
|
|
|
162
|
-
# Check profile credentials next
|
|
163
175
|
profile_name = self.config.profile or "default"
|
|
164
176
|
|
|
165
|
-
#
|
|
177
|
+
# JWT-based flow: present when logged in via device flow with new server.
|
|
178
|
+
try:
|
|
179
|
+
jwt = keyring.get_password(KEYRING_SERVICE, f"jwt.{profile_name}")
|
|
180
|
+
if jwt:
|
|
181
|
+
return self._get_dg_token_via_jwt(jwt, profile_name)
|
|
182
|
+
except Exception:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
# Legacy path: direct API key in keyring (--api-key login, old device flow).
|
|
166
186
|
try:
|
|
167
187
|
api_key = keyring.get_password(KEYRING_SERVICE, f"api-key.{profile_name}")
|
|
168
188
|
if api_key:
|
|
169
189
|
return api_key
|
|
170
190
|
except Exception:
|
|
171
|
-
pass
|
|
191
|
+
pass
|
|
172
192
|
|
|
173
|
-
#
|
|
193
|
+
# Config file fallback (keyring unavailable on this machine).
|
|
174
194
|
current_profile = self.config.get_profile(profile_name)
|
|
175
195
|
if current_profile.api_key:
|
|
176
196
|
return current_profile.api_key
|
|
177
197
|
|
|
178
|
-
# Environment variable
|
|
198
|
+
# Environment variable — lowest priority.
|
|
179
199
|
if not ignore_env:
|
|
180
200
|
api_key = os.getenv("DEEPGRAM_API_KEY")
|
|
181
201
|
if api_key:
|
|
@@ -596,29 +616,66 @@ class AuthManager:
|
|
|
596
616
|
raise AuthenticationError("Authentication timed out")
|
|
597
617
|
|
|
598
618
|
def _store_token(self, token_response: TokenResponse) -> None:
|
|
599
|
-
"""Store authentication token.
|
|
600
|
-
# The access_token from dx-id is the Deepgram API key
|
|
601
|
-
api_key = token_response.access_token
|
|
602
|
-
project_id = token_response.project_id
|
|
619
|
+
"""Store authentication token.
|
|
603
620
|
|
|
621
|
+
Handles two server response shapes:
|
|
622
|
+
- New JWT flow: access_token is a short-lived JWT (expires_in=900),
|
|
623
|
+
refresh_token present. Stores JWT + refresh_token in keyring.
|
|
624
|
+
- Legacy flow: access_token is a Deepgram API key directly.
|
|
625
|
+
Stored under api-key.{profile} as before.
|
|
626
|
+
"""
|
|
604
627
|
profile_name = self.config.profile or "default"
|
|
605
|
-
keyring_available = False
|
|
606
628
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
629
|
+
if token_response.refresh_token:
|
|
630
|
+
# New JWT-based device flow.
|
|
631
|
+
jwt = token_response.access_token
|
|
632
|
+
refresh_token = token_response.refresh_token
|
|
633
|
+
expires_in = token_response.expires_in or 900
|
|
634
|
+
expires_at = (
|
|
635
|
+
datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
|
636
|
+
).isoformat()
|
|
615
637
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
638
|
+
try:
|
|
639
|
+
# Clear any stale direct API key so get_api_key() doesn't
|
|
640
|
+
# return a cached dg_token from a previous session.
|
|
641
|
+
keyring.delete_password(KEYRING_SERVICE, f"api-key.{profile_name}")
|
|
642
|
+
except Exception:
|
|
643
|
+
pass
|
|
644
|
+
|
|
645
|
+
try:
|
|
646
|
+
keyring.set_password(KEYRING_SERVICE, f"jwt.{profile_name}", jwt)
|
|
647
|
+
keyring.set_password(
|
|
648
|
+
KEYRING_SERVICE, f"refresh-token.{profile_name}", refresh_token
|
|
649
|
+
)
|
|
650
|
+
console.print("[green]✓[/green] Session stored securely in system keyring")
|
|
651
|
+
except Exception as e:
|
|
652
|
+
console.print(f"[yellow]Warning:[/yellow] Could not store in keyring: {e}")
|
|
653
|
+
|
|
654
|
+
# Store expiry timestamps and project ID in config (non-sensitive).
|
|
655
|
+
profile = self.config.get_profile(profile_name)
|
|
656
|
+
profile.jwt_expires_at = expires_at
|
|
657
|
+
profile.project_id = token_response.project_id
|
|
658
|
+
self.config.save()
|
|
659
|
+
|
|
660
|
+
else:
|
|
661
|
+
# Legacy flow: access_token is the Deepgram API key directly.
|
|
662
|
+
api_key = token_response.access_token
|
|
663
|
+
project_id = token_response.project_id
|
|
664
|
+
keyring_available = False
|
|
665
|
+
|
|
666
|
+
try:
|
|
667
|
+
keyring.set_password(KEYRING_SERVICE, f"api-key.{profile_name}", api_key)
|
|
668
|
+
console.print("[green]✓[/green] API key stored securely in system keyring")
|
|
669
|
+
keyring_available = True
|
|
670
|
+
except Exception as e:
|
|
671
|
+
console.print(f"[yellow]Warning:[/yellow] Could not store in keyring: {e}")
|
|
672
|
+
console.print("API key will be stored in config file instead")
|
|
673
|
+
|
|
674
|
+
self.config.create_profile(
|
|
675
|
+
profile_name,
|
|
676
|
+
api_key=api_key if not keyring_available else None,
|
|
677
|
+
project_id=project_id,
|
|
678
|
+
)
|
|
622
679
|
|
|
623
680
|
def logout(self, keep_config: bool = False) -> None:
|
|
624
681
|
"""Logout user and clear credentials.
|
|
@@ -629,12 +686,17 @@ class AuthManager:
|
|
|
629
686
|
"""
|
|
630
687
|
profile_name = self.config.profile or "default"
|
|
631
688
|
|
|
632
|
-
# Clear
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
689
|
+
# Clear all credential types from keyring.
|
|
690
|
+
for keyring_key in (
|
|
691
|
+
f"api-key.{profile_name}",
|
|
692
|
+
f"jwt.{profile_name}",
|
|
693
|
+
f"refresh-token.{profile_name}",
|
|
694
|
+
):
|
|
695
|
+
try:
|
|
696
|
+
keyring.delete_password(KEYRING_SERVICE, keyring_key)
|
|
697
|
+
except Exception:
|
|
698
|
+
pass
|
|
699
|
+
console.print("[dim]Cleared credentials from system keyring[/dim]")
|
|
638
700
|
|
|
639
701
|
if keep_config:
|
|
640
702
|
# Clear sensitive data from config but keep profile
|
|
@@ -687,6 +749,120 @@ class AuthManager:
|
|
|
687
749
|
current_profile=self.config.profile or self.config._config.default_profile,
|
|
688
750
|
)
|
|
689
751
|
|
|
752
|
+
def _get_dg_token_via_jwt(self, jwt: str, profile_name: str) -> str:
|
|
753
|
+
"""Return a valid Deepgram API token, refreshing as needed.
|
|
754
|
+
|
|
755
|
+
Checks the cached dg_token first; refreshes the JWT if expired; then
|
|
756
|
+
exchanges the JWT for a fresh dg_token when the cached one is stale.
|
|
757
|
+
"""
|
|
758
|
+
profile = self.config.get_profile(profile_name)
|
|
759
|
+
now = datetime.now(timezone.utc)
|
|
760
|
+
|
|
761
|
+
# Return cached dg_token if it is still valid.
|
|
762
|
+
try:
|
|
763
|
+
dg_token = keyring.get_password(KEYRING_SERVICE, f"api-key.{profile_name}")
|
|
764
|
+
if dg_token and profile.dg_token_expires_at:
|
|
765
|
+
expires = datetime.fromisoformat(profile.dg_token_expires_at)
|
|
766
|
+
if now < expires:
|
|
767
|
+
return dg_token
|
|
768
|
+
except Exception:
|
|
769
|
+
pass
|
|
770
|
+
|
|
771
|
+
# Refresh the JWT if it has expired.
|
|
772
|
+
current_jwt = jwt
|
|
773
|
+
if profile.jwt_expires_at:
|
|
774
|
+
jwt_expires = datetime.fromisoformat(profile.jwt_expires_at)
|
|
775
|
+
if now >= jwt_expires:
|
|
776
|
+
current_jwt = self._refresh_jwt(profile_name)
|
|
777
|
+
|
|
778
|
+
return self._exchange_for_dg_token(current_jwt, profile_name)
|
|
779
|
+
|
|
780
|
+
def _refresh_jwt(self, profile_name: str) -> str:
|
|
781
|
+
"""Exchange a refresh token for a new JWT."""
|
|
782
|
+
try:
|
|
783
|
+
refresh_token = keyring.get_password(
|
|
784
|
+
KEYRING_SERVICE, f"refresh-token.{profile_name}"
|
|
785
|
+
)
|
|
786
|
+
except Exception:
|
|
787
|
+
refresh_token = None
|
|
788
|
+
|
|
789
|
+
if not refresh_token:
|
|
790
|
+
raise AuthenticationError(
|
|
791
|
+
"Session expired and no refresh token is available. "
|
|
792
|
+
"Please run 'dg login' again."
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
try:
|
|
796
|
+
response = self.client.post(
|
|
797
|
+
TOKEN_REFRESH_URL,
|
|
798
|
+
json={"grant_type": "refresh_token", "refresh_token": refresh_token},
|
|
799
|
+
)
|
|
800
|
+
except httpx.RequestError as e:
|
|
801
|
+
raise AuthenticationError(f"Network error during token refresh: {e}")
|
|
802
|
+
|
|
803
|
+
if response.status_code not in (200, 201):
|
|
804
|
+
raise AuthenticationError(
|
|
805
|
+
f"Token refresh failed (HTTP {response.status_code}). "
|
|
806
|
+
"Please run 'dg login' again."
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
data = response.json()
|
|
810
|
+
new_jwt: str = data["access_token"]
|
|
811
|
+
new_refresh: str = data.get("refresh_token", refresh_token)
|
|
812
|
+
expires_in: int = data.get("expires_in", 900)
|
|
813
|
+
expires_at = (
|
|
814
|
+
datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
|
815
|
+
).isoformat()
|
|
816
|
+
|
|
817
|
+
try:
|
|
818
|
+
keyring.set_password(KEYRING_SERVICE, f"jwt.{profile_name}", new_jwt)
|
|
819
|
+
if new_refresh != refresh_token:
|
|
820
|
+
keyring.set_password(
|
|
821
|
+
KEYRING_SERVICE, f"refresh-token.{profile_name}", new_refresh
|
|
822
|
+
)
|
|
823
|
+
except Exception:
|
|
824
|
+
pass
|
|
825
|
+
|
|
826
|
+
profile = self.config.get_profile(profile_name)
|
|
827
|
+
profile.jwt_expires_at = expires_at
|
|
828
|
+
self.config.save()
|
|
829
|
+
|
|
830
|
+
return new_jwt
|
|
831
|
+
|
|
832
|
+
def _exchange_for_dg_token(self, jwt: str, profile_name: str) -> str:
|
|
833
|
+
"""Exchange a JWT for a Deepgram API token (dg_token)."""
|
|
834
|
+
try:
|
|
835
|
+
response = self.client.post(
|
|
836
|
+
DG_CREDENTIALS_URL,
|
|
837
|
+
headers={"Authorization": f"Bearer {jwt}"},
|
|
838
|
+
)
|
|
839
|
+
except httpx.RequestError as e:
|
|
840
|
+
raise AuthenticationError(f"Network error during credentials exchange: {e}")
|
|
841
|
+
|
|
842
|
+
if response.status_code not in (200, 201):
|
|
843
|
+
raise AuthenticationError(
|
|
844
|
+
f"Credentials exchange failed (HTTP {response.status_code}). "
|
|
845
|
+
"Please run 'dg login' again."
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
data = response.json()
|
|
849
|
+
dg_token: str = data["dg_token"]
|
|
850
|
+
expires_in: int = data.get("expires_in", 86400)
|
|
851
|
+
expires_at = (
|
|
852
|
+
datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
|
853
|
+
).isoformat()
|
|
854
|
+
|
|
855
|
+
try:
|
|
856
|
+
keyring.set_password(KEYRING_SERVICE, f"api-key.{profile_name}", dg_token)
|
|
857
|
+
except Exception:
|
|
858
|
+
pass # Non-fatal: will re-exchange next time
|
|
859
|
+
|
|
860
|
+
profile = self.config.get_profile(profile_name)
|
|
861
|
+
profile.dg_token_expires_at = expires_at
|
|
862
|
+
self.config.save()
|
|
863
|
+
|
|
864
|
+
return dg_token
|
|
865
|
+
|
|
690
866
|
def __del__(self) -> None:
|
|
691
867
|
"""Cleanup HTTP client."""
|
|
692
868
|
if hasattr(self, "client"):
|
|
@@ -15,6 +15,10 @@ class ProfileConfig(BaseModel):
|
|
|
15
15
|
api_key: str | None = None
|
|
16
16
|
project_id: str | None = None
|
|
17
17
|
base_url: str = "https://api.deepgram.com"
|
|
18
|
+
# JWT-based auth (device flow with new server): expiry timestamps stored
|
|
19
|
+
# here (non-sensitive); the tokens themselves live in the keyring.
|
|
20
|
+
jwt_expires_at: str | None = None
|
|
21
|
+
dg_token_expires_at: str | None = None
|
|
18
22
|
|
|
19
23
|
|
|
20
24
|
class OutputConfig(BaseModel):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|