ifs-rag-assistant-cli 2.2.2__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.
- ifs_rag_assistant_cli/__init__.py +5 -0
- ifs_rag_assistant_cli/auth_client.py +182 -0
- ifs_rag_assistant_cli/backend_client.py +298 -0
- ifs_rag_assistant_cli/cli.py +632 -0
- ifs_rag_assistant_cli/config.py +135 -0
- ifs_rag_assistant_cli/models.py +45 -0
- ifs_rag_assistant_cli/oauth.py +542 -0
- ifs_rag_assistant_cli/session.py +100 -0
- ifs_rag_assistant_cli-2.2.2.dist-info/METADATA +536 -0
- ifs_rag_assistant_cli-2.2.2.dist-info/RECORD +13 -0
- ifs_rag_assistant_cli-2.2.2.dist-info/WHEEL +5 -0
- ifs_rag_assistant_cli-2.2.2.dist-info/entry_points.txt +2 -0
- ifs_rag_assistant_cli-2.2.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unified authentication and backend client wrapper.
|
|
3
|
+
|
|
4
|
+
Handles the complete flow of device code authentication,
|
|
5
|
+
session creation, and backend API access.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ipaddress import ip_address
|
|
9
|
+
from typing import Optional
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
from ifs_rag_assistant_cli.backend_client import BackendClient
|
|
13
|
+
from ifs_rag_assistant_cli.config import Config, load_config
|
|
14
|
+
from ifs_rag_assistant_cli.oauth import create_device_authenticator
|
|
15
|
+
from ifs_rag_assistant_cli.session import SessionManager
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AuthenticatedClient:
|
|
19
|
+
"""
|
|
20
|
+
Unified client for authenticated backend API access.
|
|
21
|
+
|
|
22
|
+
Manages the complete authentication flow:
|
|
23
|
+
1. Load cached session if available
|
|
24
|
+
2. If not, perform Device Code OAuth authentication
|
|
25
|
+
3. Create backend session with OAuth token
|
|
26
|
+
4. Cache session for future use
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
backend_url: Optional[str] = None,
|
|
32
|
+
keycloak_url: Optional[str] = None,
|
|
33
|
+
realm: Optional[str] = None,
|
|
34
|
+
client_id: Optional[str] = None,
|
|
35
|
+
config: Optional[Config] = None
|
|
36
|
+
):
|
|
37
|
+
"""
|
|
38
|
+
Initialize authenticated client.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
backend_url: Backend API URL (overrides config)
|
|
42
|
+
keycloak_url: Keycloak server URL (overrides config)
|
|
43
|
+
realm: Keycloak realm (overrides config)
|
|
44
|
+
client_id: OAuth client ID (overrides config)
|
|
45
|
+
config: Config instance. If None, loads from file/env
|
|
46
|
+
"""
|
|
47
|
+
# Load config if not provided
|
|
48
|
+
if config is None:
|
|
49
|
+
config = load_config()
|
|
50
|
+
|
|
51
|
+
self.config = config
|
|
52
|
+
|
|
53
|
+
# Initialize session manager
|
|
54
|
+
cache_dir = getattr(config, 'cache_dir', '~/.ifs-rag-assistant-cli')
|
|
55
|
+
self.session_mgr = SessionManager(cache_dir)
|
|
56
|
+
|
|
57
|
+
# Initialize backend client
|
|
58
|
+
api_root = backend_url or getattr(config, 'api_root', 'http://localhost:8000')
|
|
59
|
+
timeout = getattr(config, 'timeout', 300)
|
|
60
|
+
self.backend = BackendClient(str(api_root), int(timeout))
|
|
61
|
+
|
|
62
|
+
# Initialize OAuth authenticator using factory
|
|
63
|
+
keycloak_url = keycloak_url or getattr(
|
|
64
|
+
config, 'keycloak_server_url', 'http://localhost:8080'
|
|
65
|
+
)
|
|
66
|
+
realm = realm or getattr(config, 'keycloak_realm', 'master')
|
|
67
|
+
client_id = client_id or getattr(config, 'keycloak_client_id', 'ifs-rag')
|
|
68
|
+
|
|
69
|
+
# Get OAuth mode and custom wrapper URL from config
|
|
70
|
+
oauth_mode = getattr(config, 'oauth_device_flow_mode', 'standard')
|
|
71
|
+
custom_wrapper_url = getattr(config, 'oauth_custom_wrapper_url', None)
|
|
72
|
+
|
|
73
|
+
self.oauth = create_device_authenticator(
|
|
74
|
+
mode=oauth_mode,
|
|
75
|
+
client_id=str(client_id),
|
|
76
|
+
keycloak_server_url=keycloak_url,
|
|
77
|
+
realm=realm,
|
|
78
|
+
custom_wrapper_url=custom_wrapper_url
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _is_local_api_root(api_root: str) -> bool:
|
|
83
|
+
"""Return True when API root points to a local development host."""
|
|
84
|
+
parsed = urlparse(str(api_root))
|
|
85
|
+
host = (parsed.hostname or "").strip().lower()
|
|
86
|
+
|
|
87
|
+
if not host:
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
if host == "localhost":
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
addr = ip_address(host)
|
|
95
|
+
return addr.is_loopback
|
|
96
|
+
except ValueError:
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
def _is_auth_disabled(self) -> bool:
|
|
100
|
+
"""Return True when auth_mode is explicitly disabled."""
|
|
101
|
+
auth_mode = str(getattr(self.config, "auth_mode", "oauth")).strip().lower()
|
|
102
|
+
if auth_mode not in ("oauth", "disabled"):
|
|
103
|
+
raise RuntimeError(
|
|
104
|
+
f"Invalid auth_mode '{auth_mode}'. Supported values: 'oauth', 'disabled'."
|
|
105
|
+
)
|
|
106
|
+
return auth_mode == "disabled"
|
|
107
|
+
|
|
108
|
+
def ensure_authenticated(self) -> None:
|
|
109
|
+
"""
|
|
110
|
+
Ensure user is authenticated.
|
|
111
|
+
|
|
112
|
+
Attempts to load cached session if available.
|
|
113
|
+
If not, performs Device Code OAuth flow and creates new session.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
RuntimeError: If authentication fails
|
|
117
|
+
"""
|
|
118
|
+
if self._is_auth_disabled():
|
|
119
|
+
if not self._is_local_api_root(self.backend.backend_url):
|
|
120
|
+
raise RuntimeError(
|
|
121
|
+
"Authentication is disabled, but api_root is not local. "
|
|
122
|
+
"Set auth_mode='oauth' or use a localhost/loopback api_root for development."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
print("ℹ️ Authentication disabled for local development (auth_mode=disabled)")
|
|
126
|
+
print("ℹ️ Proceeding without OAuth and backend login session")
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
# Try to load cached session
|
|
130
|
+
session_id = self.session_mgr.load_session()
|
|
131
|
+
|
|
132
|
+
if session_id:
|
|
133
|
+
print("✅ Using cached session")
|
|
134
|
+
# Set the session in the backend client
|
|
135
|
+
self.backend.set_session(session_id)
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
print("🔐 Authentication required")
|
|
139
|
+
|
|
140
|
+
# Perform Device Code OAuth flow
|
|
141
|
+
token, _token_type = self.oauth.authenticate()
|
|
142
|
+
|
|
143
|
+
# Exchange OAuth token for backend session
|
|
144
|
+
print("🔄 Creating backend session...")
|
|
145
|
+
try:
|
|
146
|
+
if self.oauth.uses_refresh_session_login():
|
|
147
|
+
session_id = self.backend.login_with_refresh_token(token)
|
|
148
|
+
else:
|
|
149
|
+
session_id = self.backend.login_with_token(token)
|
|
150
|
+
self.session_mgr.save_session(session_id)
|
|
151
|
+
print("✅ Authenticated successfully!")
|
|
152
|
+
except RuntimeError as e:
|
|
153
|
+
raise RuntimeError(f"Failed to create session: {e}") from e
|
|
154
|
+
|
|
155
|
+
def logout(self) -> None:
|
|
156
|
+
"""
|
|
157
|
+
Logout and clear cached session.
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
RuntimeError: If logout fails
|
|
161
|
+
"""
|
|
162
|
+
if self._is_auth_disabled():
|
|
163
|
+
self.session_mgr.clear_all()
|
|
164
|
+
print("ℹ️ Authentication is disabled (auth_mode=disabled): no backend logout required")
|
|
165
|
+
print("✅ Local session cache cleared")
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
self.backend.logout()
|
|
169
|
+
self.session_mgr.clear_all()
|
|
170
|
+
print("✅ Logged out successfully")
|
|
171
|
+
|
|
172
|
+
def close(self) -> None:
|
|
173
|
+
"""Close backend connection."""
|
|
174
|
+
self.backend.close()
|
|
175
|
+
|
|
176
|
+
def __enter__(self):
|
|
177
|
+
"""Context manager entry."""
|
|
178
|
+
return self
|
|
179
|
+
|
|
180
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
181
|
+
"""Context manager exit."""
|
|
182
|
+
self.close()
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backend API client for IFS RAG Assistant service.
|
|
3
|
+
|
|
4
|
+
Handles communication with the FastAPI backend,
|
|
5
|
+
including authentication and request/response handling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Optional, cast
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
from requests.exceptions import RequestException
|
|
12
|
+
|
|
13
|
+
SESSION_EXPIRED_MESSAGE = (
|
|
14
|
+
"Authentication failed: your session may have expired or is no longer valid. "
|
|
15
|
+
"Please run 'ifs-rag-assistant-cli logout' and try again. "
|
|
16
|
+
"If the problem persists, contact your service administrator."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SessionExpiredError(RuntimeError):
|
|
21
|
+
"""Raised when backend returns 401 due to expired or invalid session."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BackendClient:
|
|
25
|
+
"""
|
|
26
|
+
Client for communicating with IFS RAG backend API.
|
|
27
|
+
|
|
28
|
+
Handles authentication via OAuth tokens and session cookies,
|
|
29
|
+
and provides methods for RAG operations (ask, search, etc.).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, backend_url: str, timeout: int = 300):
|
|
33
|
+
"""
|
|
34
|
+
Initialize backend client.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
backend_url: Base URL of backend API
|
|
38
|
+
timeout: Request timeout in seconds
|
|
39
|
+
"""
|
|
40
|
+
self.backend_url = backend_url.rstrip('/')
|
|
41
|
+
self.timeout = timeout
|
|
42
|
+
self.session = requests.Session()
|
|
43
|
+
|
|
44
|
+
def set_session(self, session_id: str) -> None:
|
|
45
|
+
"""
|
|
46
|
+
Set session ID in client cookies.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
session_id: Session ID to use for authentication
|
|
50
|
+
"""
|
|
51
|
+
self.session.cookies.set('session_id', session_id)
|
|
52
|
+
|
|
53
|
+
def _raise_request_error(self, operation: str, error: RequestException) -> None:
|
|
54
|
+
"""Raise a user-friendly error message for request failures."""
|
|
55
|
+
response = getattr(error, "response", None)
|
|
56
|
+
if response is not None and response.status_code == 401:
|
|
57
|
+
raise SessionExpiredError(SESSION_EXPIRED_MESSAGE) from error
|
|
58
|
+
raise RuntimeError(f"{operation} failed: {error}") from error
|
|
59
|
+
|
|
60
|
+
def login_with_token(self, access_token: str) -> str:
|
|
61
|
+
"""
|
|
62
|
+
Authenticate with OAuth access token and create session.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
access_token: OAuth access token from Keycloak
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Session ID (extracted from response cookies)
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
RuntimeError: If login fails
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
resp = self.session.post(
|
|
75
|
+
f"{self.backend_url}/login",
|
|
76
|
+
json={"access_token": access_token},
|
|
77
|
+
timeout=self.timeout
|
|
78
|
+
)
|
|
79
|
+
resp.raise_for_status()
|
|
80
|
+
except RequestException as e:
|
|
81
|
+
raise RuntimeError(f"Login failed: {e}") from e
|
|
82
|
+
|
|
83
|
+
# Session ID is returned in Set-Cookie header
|
|
84
|
+
if 'session_id' not in self.session.cookies:
|
|
85
|
+
raise RuntimeError("No session_id in login response")
|
|
86
|
+
|
|
87
|
+
return str(self.session.cookies.get('session_id'))
|
|
88
|
+
|
|
89
|
+
def login_with_refresh_token(self, refresh_token: str) -> str:
|
|
90
|
+
"""
|
|
91
|
+
Authenticate with OAuth refresh token (offline token) and create session.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
refresh_token: OAuth refresh token (offline token) from authentication
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Session ID (extracted from response cookies)
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
RuntimeError: If login fails
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
resp = self.session.post(
|
|
104
|
+
f"{self.backend_url}/login/refresh",
|
|
105
|
+
json={"refresh_token": refresh_token},
|
|
106
|
+
timeout=self.timeout
|
|
107
|
+
)
|
|
108
|
+
resp.raise_for_status()
|
|
109
|
+
except RequestException as e:
|
|
110
|
+
raise RuntimeError(f"Login with refresh token failed: {e}") from e
|
|
111
|
+
|
|
112
|
+
# Session ID is returned in Set-Cookie header
|
|
113
|
+
if 'session_id' not in self.session.cookies:
|
|
114
|
+
raise RuntimeError("No session_id in login response")
|
|
115
|
+
|
|
116
|
+
return str(self.session.cookies.get('session_id'))
|
|
117
|
+
|
|
118
|
+
def ask(self, query: str, conversation_id: Optional[str] = None, client_id: Optional[str] = None) -> dict[str, Any]:
|
|
119
|
+
"""
|
|
120
|
+
Ask a question to the RAG system.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
query: The question to ask
|
|
124
|
+
conversation_id: Optional conversation identifier to continue a thread
|
|
125
|
+
client_id: Optional client identifier for tracking
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Response dictionary with answer, citations, conversation_id, turn_id, question_id, answer_id, created_at
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
RuntimeError: If request fails
|
|
132
|
+
"""
|
|
133
|
+
payload = {"query": query}
|
|
134
|
+
if conversation_id:
|
|
135
|
+
payload["conversation_id"] = conversation_id
|
|
136
|
+
if client_id:
|
|
137
|
+
payload["client_id"] = client_id
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
resp = self.session.post(
|
|
141
|
+
f"{self.backend_url}/ask",
|
|
142
|
+
json=payload,
|
|
143
|
+
timeout=self.timeout
|
|
144
|
+
)
|
|
145
|
+
resp.raise_for_status()
|
|
146
|
+
except RequestException as e:
|
|
147
|
+
self._raise_request_error("Ask request", e)
|
|
148
|
+
|
|
149
|
+
return cast(dict[str, Any], resp.json())
|
|
150
|
+
|
|
151
|
+
def search(self, query: str) -> dict[str, Any]:
|
|
152
|
+
"""
|
|
153
|
+
Search the RAG index.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
query: Search query
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Response dictionary with search results (context, citations)
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
RuntimeError: If request fails
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
resp = self.session.get(
|
|
166
|
+
f"{self.backend_url}/search",
|
|
167
|
+
params={"query": query},
|
|
168
|
+
timeout=self.timeout
|
|
169
|
+
)
|
|
170
|
+
resp.raise_for_status()
|
|
171
|
+
except RequestException as e:
|
|
172
|
+
self._raise_request_error("Search request", e)
|
|
173
|
+
|
|
174
|
+
return cast(dict[str, Any], resp.json())
|
|
175
|
+
|
|
176
|
+
def logout(self) -> None:
|
|
177
|
+
"""
|
|
178
|
+
Logout from backend (invalidate session).
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
RuntimeError: If logout request fails
|
|
182
|
+
"""
|
|
183
|
+
try:
|
|
184
|
+
self.session.post(
|
|
185
|
+
f"{self.backend_url}/logout",
|
|
186
|
+
timeout=self.timeout
|
|
187
|
+
)
|
|
188
|
+
except RequestException as e:
|
|
189
|
+
raise RuntimeError(f"Logout failed: {e}") from e
|
|
190
|
+
|
|
191
|
+
def health(self) -> dict[str, Any]:
|
|
192
|
+
"""
|
|
193
|
+
Check backend health status.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Response dictionary with health information
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
RuntimeError: If health check fails
|
|
200
|
+
"""
|
|
201
|
+
try:
|
|
202
|
+
resp = self.session.get(
|
|
203
|
+
f"{self.backend_url}/health",
|
|
204
|
+
timeout=self.timeout
|
|
205
|
+
)
|
|
206
|
+
resp.raise_for_status()
|
|
207
|
+
except RequestException as e:
|
|
208
|
+
self._raise_request_error("Health check", e)
|
|
209
|
+
|
|
210
|
+
return cast(dict[str, Any], resp.json())
|
|
211
|
+
|
|
212
|
+
def submit_feedback(self, conversation_id: str, answer_id: str,
|
|
213
|
+
vote: Optional[str] = None, reason: Optional[str] = None,
|
|
214
|
+
client_id: Optional[str] = None) -> dict[str, Any]:
|
|
215
|
+
"""
|
|
216
|
+
Submit thumbs up/down feedback for a previously returned answer.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
conversation_id: Conversation identifier
|
|
220
|
+
answer_id: Answer identifier to provide feedback for
|
|
221
|
+
vote: 'up', 'down', or None to clear vote
|
|
222
|
+
reason: Optional feedback reason text
|
|
223
|
+
client_id: Optional client identifier for tracking
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Response dictionary with feedback status
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
RuntimeError: If request fails
|
|
230
|
+
"""
|
|
231
|
+
payload = {
|
|
232
|
+
"conversation_id": conversation_id,
|
|
233
|
+
"answer_id": answer_id,
|
|
234
|
+
}
|
|
235
|
+
if vote:
|
|
236
|
+
payload["vote"] = vote
|
|
237
|
+
if reason:
|
|
238
|
+
payload["reason"] = reason
|
|
239
|
+
if client_id:
|
|
240
|
+
payload["client_id"] = client_id
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
resp = self.session.post(
|
|
244
|
+
f"{self.backend_url}/feedback",
|
|
245
|
+
json=payload,
|
|
246
|
+
timeout=self.timeout
|
|
247
|
+
)
|
|
248
|
+
resp.raise_for_status()
|
|
249
|
+
except RequestException as e:
|
|
250
|
+
self._raise_request_error("Submit feedback request", e)
|
|
251
|
+
|
|
252
|
+
return cast(dict[str, Any], resp.json())
|
|
253
|
+
|
|
254
|
+
def get_models(self) -> dict[str, Any]:
|
|
255
|
+
"""
|
|
256
|
+
Get list of available models from the backend.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
Response dictionary with list of models
|
|
260
|
+
|
|
261
|
+
Raises:
|
|
262
|
+
RuntimeError: If request fails
|
|
263
|
+
"""
|
|
264
|
+
try:
|
|
265
|
+
resp = self.session.get(
|
|
266
|
+
f"{self.backend_url}/models",
|
|
267
|
+
timeout=self.timeout
|
|
268
|
+
)
|
|
269
|
+
resp.raise_for_status()
|
|
270
|
+
except RequestException as e:
|
|
271
|
+
self._raise_request_error("Get models request", e)
|
|
272
|
+
|
|
273
|
+
return cast(dict[str, Any], resp.json())
|
|
274
|
+
|
|
275
|
+
def get_usage_guide(self) -> dict[str, Any]:
|
|
276
|
+
"""
|
|
277
|
+
Get usage guide from the backend.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Response dictionary with usage guide content
|
|
281
|
+
|
|
282
|
+
Raises:
|
|
283
|
+
RuntimeError: If request fails
|
|
284
|
+
"""
|
|
285
|
+
try:
|
|
286
|
+
resp = self.session.get(
|
|
287
|
+
f"{self.backend_url}/usage",
|
|
288
|
+
timeout=self.timeout
|
|
289
|
+
)
|
|
290
|
+
resp.raise_for_status()
|
|
291
|
+
except RequestException as e:
|
|
292
|
+
self._raise_request_error("Get usage guide request", e)
|
|
293
|
+
|
|
294
|
+
return cast(dict[str, Any], resp.json())
|
|
295
|
+
|
|
296
|
+
def close(self) -> None:
|
|
297
|
+
"""Close the HTTP session."""
|
|
298
|
+
self.session.close()
|