pyindus 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.
- pyindus/__init__.py +37 -0
- pyindus/auth.py +405 -0
- pyindus/chat.py +213 -0
- pyindus/cli.py +96 -0
- pyindus/client.py +300 -0
- pyindus/exceptions.py +28 -0
- pyindus/models.py +171 -0
- pyindus-0.1.0.dist-info/METADATA +108 -0
- pyindus-0.1.0.dist-info/RECORD +11 -0
- pyindus-0.1.0.dist-info/WHEEL +4 -0
- pyindus-0.1.0.dist-info/entry_points.txt +2 -0
pyindus/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""PyIndus - Python package for Indus Chat API by Sarvam AI."""
|
|
2
|
+
|
|
3
|
+
from pyindus.client import IndusClient
|
|
4
|
+
from pyindus.auth import IndusAuth
|
|
5
|
+
from pyindus.models import (
|
|
6
|
+
UserInfo,
|
|
7
|
+
ChatAccount,
|
|
8
|
+
TaskGraph,
|
|
9
|
+
ChatSession,
|
|
10
|
+
PromptResponse,
|
|
11
|
+
Step,
|
|
12
|
+
Config,
|
|
13
|
+
)
|
|
14
|
+
from pyindus.exceptions import (
|
|
15
|
+
IndusError,
|
|
16
|
+
AuthenticationError,
|
|
17
|
+
SessionError,
|
|
18
|
+
APIError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"IndusClient",
|
|
25
|
+
"IndusAuth",
|
|
26
|
+
"UserInfo",
|
|
27
|
+
"ChatAccount",
|
|
28
|
+
"TaskGraph",
|
|
29
|
+
"ChatSession",
|
|
30
|
+
"PromptResponse",
|
|
31
|
+
"Step",
|
|
32
|
+
"Config",
|
|
33
|
+
"IndusError",
|
|
34
|
+
"AuthenticationError",
|
|
35
|
+
"SessionError",
|
|
36
|
+
"APIError",
|
|
37
|
+
]
|
pyindus/auth.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""Authentication module for Indus API.
|
|
2
|
+
|
|
3
|
+
Handles the Ory/Kratos-based login flow:
|
|
4
|
+
1. Initiate login flow → get flow ID and CSRF token
|
|
5
|
+
2. Submit phone number → triggers OTP via SMS
|
|
6
|
+
3. Submit OTP code → completes login, receives session cookies
|
|
7
|
+
4. Refresh Indus auth token using session cookies
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from urllib.parse import urlencode, urlparse, parse_qs
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from pyindus.exceptions import AuthenticationError, APIError
|
|
20
|
+
from pyindus.models import UserInfo, RefreshResponse
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
INDUS_BASE_URL = "https://indus.sarvam.ai"
|
|
25
|
+
LOGIN_BASE_URL = "https://login.sarvam.ai"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class IndusAuth:
|
|
29
|
+
"""Handles authentication with the Indus platform.
|
|
30
|
+
|
|
31
|
+
The Indus platform uses Ory/Kratos for identity management with
|
|
32
|
+
phone number + OTP authentication. Session state is maintained
|
|
33
|
+
via cookies.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, http_client: httpx.Client | None = None):
|
|
37
|
+
self._client = http_client or httpx.Client(
|
|
38
|
+
follow_redirects=False,
|
|
39
|
+
timeout=30.0,
|
|
40
|
+
headers={
|
|
41
|
+
"user-agent": (
|
|
42
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
43
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
44
|
+
"Chrome/144.0.0.0 Safari/537.36"
|
|
45
|
+
),
|
|
46
|
+
"accept": "*/*",
|
|
47
|
+
"accept-language": "en-US,en;q=0.9",
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
self._owns_client = http_client is None
|
|
51
|
+
self._flow_id: str | None = None
|
|
52
|
+
self._csrf_token: str | None = None
|
|
53
|
+
self._phone: str | None = None
|
|
54
|
+
self._authenticated = False
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def client(self) -> httpx.Client:
|
|
58
|
+
"""The underlying HTTP client (shares cookies across auth and chat)."""
|
|
59
|
+
return self._client
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def is_authenticated(self) -> bool:
|
|
63
|
+
return self._authenticated
|
|
64
|
+
|
|
65
|
+
def login(self, phone: str) -> str:
|
|
66
|
+
"""Initiate login flow with phone number.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
phone: Phone number with country code (e.g., "+918874163264")
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Flow ID for the login flow.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
AuthenticationError: If the login flow fails.
|
|
76
|
+
"""
|
|
77
|
+
self._phone = phone
|
|
78
|
+
|
|
79
|
+
# Step 1: Create a new login flow
|
|
80
|
+
logger.info("Creating login flow...")
|
|
81
|
+
resp = self._client.post(
|
|
82
|
+
f"{LOGIN_BASE_URL}/api/flow/login",
|
|
83
|
+
json={"returnTo": INDUS_BASE_URL},
|
|
84
|
+
)
|
|
85
|
+
if resp.status_code != 200:
|
|
86
|
+
raise AuthenticationError(
|
|
87
|
+
f"Failed to create login flow: {resp.status_code} {resp.text}"
|
|
88
|
+
)
|
|
89
|
+
flow_data = resp.json()
|
|
90
|
+
self._flow_id = flow_data.get("id")
|
|
91
|
+
if not self._flow_id:
|
|
92
|
+
raise AuthenticationError(
|
|
93
|
+
f"No flow ID in login response: {flow_data}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Now fetch the actual flow data to get the CSRF token
|
|
97
|
+
self._refresh_csrf_token()
|
|
98
|
+
if not self._csrf_token:
|
|
99
|
+
raise AuthenticationError("Could not extract CSRF token from flow.")
|
|
100
|
+
|
|
101
|
+
logger.info("Login flow created: %s", self._flow_id)
|
|
102
|
+
|
|
103
|
+
# Step 2: Submit phone number to trigger OTP
|
|
104
|
+
logger.info("Submitting phone number to trigger OTP...")
|
|
105
|
+
form_data = {
|
|
106
|
+
"csrf_token": self._csrf_token,
|
|
107
|
+
"method": "code",
|
|
108
|
+
"identifier": phone,
|
|
109
|
+
}
|
|
110
|
+
resp = self._client.post(
|
|
111
|
+
f"{LOGIN_BASE_URL}/identity/self-service/login",
|
|
112
|
+
params={"flow": self._flow_id},
|
|
113
|
+
content=urlencode(form_data),
|
|
114
|
+
headers={"content-type": "application/x-www-form-urlencoded"},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Expect a 303 redirect (OTP sent) or 422 (validation error) or 200
|
|
118
|
+
if resp.status_code == 303:
|
|
119
|
+
location = resp.headers.get("location", "")
|
|
120
|
+
if "error" in location:
|
|
121
|
+
raise AuthenticationError(f"Login failed: Redirected to error page ({location})")
|
|
122
|
+
logger.info("OTP sent successfully. Waiting for code...")
|
|
123
|
+
elif resp.status_code in (200, 422):
|
|
124
|
+
# Check if there's an error in the response
|
|
125
|
+
try:
|
|
126
|
+
body = resp.json()
|
|
127
|
+
if "error" in body:
|
|
128
|
+
raise AuthenticationError(
|
|
129
|
+
f"Login error: {body['error']}"
|
|
130
|
+
)
|
|
131
|
+
except (json.JSONDecodeError, KeyError):
|
|
132
|
+
pass
|
|
133
|
+
logger.info("OTP sent (status %d). Waiting for code...", resp.status_code)
|
|
134
|
+
else:
|
|
135
|
+
raise AuthenticationError(
|
|
136
|
+
f"Failed to submit phone: {resp.status_code} {resp.text}"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Step 3: Refresh CSRF token from updated flow
|
|
140
|
+
self._refresh_csrf_token()
|
|
141
|
+
|
|
142
|
+
return self._flow_id
|
|
143
|
+
|
|
144
|
+
def verify_otp(self, code: str) -> UserInfo:
|
|
145
|
+
"""Submit OTP code to complete login.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
code: The OTP code received via SMS.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
UserInfo for the authenticated user.
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
AuthenticationError: If OTP verification fails.
|
|
155
|
+
"""
|
|
156
|
+
if not self._flow_id or not self._csrf_token or not self._phone:
|
|
157
|
+
raise AuthenticationError(
|
|
158
|
+
"Must call login() before verify_otp()"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
logger.info("Submitting OTP code...")
|
|
162
|
+
form_data = {
|
|
163
|
+
"csrf_token": self._csrf_token,
|
|
164
|
+
"method": "code",
|
|
165
|
+
"identifier": self._phone,
|
|
166
|
+
"code": code,
|
|
167
|
+
}
|
|
168
|
+
resp = self._client.post(
|
|
169
|
+
f"{LOGIN_BASE_URL}/identity/self-service/login",
|
|
170
|
+
params={"flow": self._flow_id},
|
|
171
|
+
content=urlencode(form_data),
|
|
172
|
+
headers={"content-type": "application/x-www-form-urlencoded"},
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if resp.status_code == 303:
|
|
176
|
+
# Follow the redirect to indus.sarvam.ai to set cookies
|
|
177
|
+
location = resp.headers.get("location", "")
|
|
178
|
+
if "error" in location:
|
|
179
|
+
raise AuthenticationError(f"OTP verification failed: Redirected to error page ({location})")
|
|
180
|
+
logger.info("Login successful, following redirect to: %s", location)
|
|
181
|
+
if location:
|
|
182
|
+
# Follow redirect — this sets the session cookies on indus.sarvam.ai
|
|
183
|
+
self._client.get(location)
|
|
184
|
+
elif resp.status_code == 200:
|
|
185
|
+
# Might have the session inline
|
|
186
|
+
try:
|
|
187
|
+
body = resp.json()
|
|
188
|
+
if "error" in body:
|
|
189
|
+
raise AuthenticationError(
|
|
190
|
+
f"OTP verification failed: {body['error']}"
|
|
191
|
+
)
|
|
192
|
+
except (json.JSONDecodeError, KeyError):
|
|
193
|
+
pass
|
|
194
|
+
elif resp.status_code == 422:
|
|
195
|
+
try:
|
|
196
|
+
body = resp.json()
|
|
197
|
+
error_msg = self._extract_error_message(body)
|
|
198
|
+
raise AuthenticationError(
|
|
199
|
+
f"OTP verification failed: {error_msg}"
|
|
200
|
+
)
|
|
201
|
+
except (json.JSONDecodeError, KeyError):
|
|
202
|
+
raise AuthenticationError(
|
|
203
|
+
f"OTP verification failed: {resp.status_code} {resp.text}"
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
raise AuthenticationError(
|
|
207
|
+
f"OTP verification failed: {resp.status_code} {resp.text}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Step 4: Refresh the Indus auth token
|
|
211
|
+
self.refresh()
|
|
212
|
+
|
|
213
|
+
# Step 5: Get user info
|
|
214
|
+
user_info = self.get_me()
|
|
215
|
+
self._authenticated = True
|
|
216
|
+
logger.info("Authenticated as: %s", user_info.name)
|
|
217
|
+
return user_info
|
|
218
|
+
|
|
219
|
+
def refresh(self) -> RefreshResponse:
|
|
220
|
+
"""Refresh the Indus auth token.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
RefreshResponse with token expiry info.
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
AuthenticationError: If token refresh fails.
|
|
227
|
+
"""
|
|
228
|
+
logger.debug("Refreshing auth token...")
|
|
229
|
+
resp = self._client.post(
|
|
230
|
+
f"{INDUS_BASE_URL}/api/auth/refresh",
|
|
231
|
+
headers={"origin": INDUS_BASE_URL, "referer": f"{INDUS_BASE_URL}/"},
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if resp.status_code == 401:
|
|
235
|
+
self._authenticated = False
|
|
236
|
+
raise AuthenticationError("Session expired. Please login again.")
|
|
237
|
+
|
|
238
|
+
if resp.status_code != 200:
|
|
239
|
+
raise APIError(
|
|
240
|
+
f"Token refresh failed: {resp.status_code}",
|
|
241
|
+
status_code=resp.status_code,
|
|
242
|
+
response_body=resp.text,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
data = resp.json()
|
|
246
|
+
result = RefreshResponse.model_validate(data)
|
|
247
|
+
self._authenticated = True
|
|
248
|
+
logger.debug("Token refreshed, expires in %ds", result.expires_in)
|
|
249
|
+
return result
|
|
250
|
+
|
|
251
|
+
def get_me(self) -> UserInfo:
|
|
252
|
+
"""Get current user info.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
UserInfo for the authenticated user.
|
|
256
|
+
|
|
257
|
+
Raises:
|
|
258
|
+
AuthenticationError: If not authenticated.
|
|
259
|
+
"""
|
|
260
|
+
resp = self._client.get(
|
|
261
|
+
f"{INDUS_BASE_URL}/api/auth/me",
|
|
262
|
+
headers={"referer": f"{INDUS_BASE_URL}/"},
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if resp.status_code == 401:
|
|
266
|
+
self._authenticated = False
|
|
267
|
+
raise AuthenticationError("Not authenticated")
|
|
268
|
+
|
|
269
|
+
if resp.status_code != 200:
|
|
270
|
+
raise APIError(
|
|
271
|
+
f"Failed to get user info: {resp.status_code}",
|
|
272
|
+
status_code=resp.status_code,
|
|
273
|
+
response_body=resp.text,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
data = resp.json()
|
|
277
|
+
return UserInfo.model_validate(data)
|
|
278
|
+
|
|
279
|
+
def save_session(self, path: str | Path) -> None:
|
|
280
|
+
"""Save session cookies to a file for later reuse.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
path: File path to save the session data.
|
|
284
|
+
"""
|
|
285
|
+
path = Path(path)
|
|
286
|
+
cookies_data = []
|
|
287
|
+
for cookie in self._client.cookies.jar:
|
|
288
|
+
cookies_data.append({
|
|
289
|
+
"name": cookie.name,
|
|
290
|
+
"value": cookie.value,
|
|
291
|
+
"domain": cookie.domain,
|
|
292
|
+
"path": cookie.path,
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
session_data = {
|
|
296
|
+
"cookies": cookies_data,
|
|
297
|
+
"authenticated": self._authenticated,
|
|
298
|
+
}
|
|
299
|
+
path.write_text(json.dumps(session_data, indent=2))
|
|
300
|
+
logger.info("Session saved to %s", path)
|
|
301
|
+
|
|
302
|
+
def load_session(self, path: str | Path) -> bool:
|
|
303
|
+
"""Load session cookies from a file.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
path: File path to load the session data from.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
True if session was loaded and is valid.
|
|
310
|
+
|
|
311
|
+
Raises:
|
|
312
|
+
AuthenticationError: If session file doesn't exist or is invalid.
|
|
313
|
+
"""
|
|
314
|
+
path = Path(path)
|
|
315
|
+
if not path.exists():
|
|
316
|
+
raise AuthenticationError(f"Session file not found: {path}")
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
session_data = json.loads(path.read_text())
|
|
320
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
321
|
+
raise AuthenticationError(f"Failed to load session: {e}")
|
|
322
|
+
|
|
323
|
+
# Restore cookies
|
|
324
|
+
for cookie_data in session_data.get("cookies", []):
|
|
325
|
+
self._client.cookies.set(
|
|
326
|
+
cookie_data["name"],
|
|
327
|
+
cookie_data["value"],
|
|
328
|
+
domain=cookie_data.get("domain", ""),
|
|
329
|
+
path=cookie_data.get("path", "/"),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
# Verify the session is still valid
|
|
333
|
+
try:
|
|
334
|
+
self.refresh()
|
|
335
|
+
self._authenticated = True
|
|
336
|
+
logger.info("Session loaded and validated from %s", path)
|
|
337
|
+
return True
|
|
338
|
+
except (AuthenticationError, APIError):
|
|
339
|
+
self._authenticated = False
|
|
340
|
+
logger.warning("Loaded session is expired")
|
|
341
|
+
return False
|
|
342
|
+
|
|
343
|
+
def close(self) -> None:
|
|
344
|
+
"""Close the HTTP client if we own it."""
|
|
345
|
+
if self._owns_client:
|
|
346
|
+
self._client.close()
|
|
347
|
+
|
|
348
|
+
def _refresh_csrf_token(self) -> None:
|
|
349
|
+
"""Fetch the latest CSRF token from the login flow."""
|
|
350
|
+
if not self._flow_id:
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
resp = self._client.get(
|
|
354
|
+
f"{LOGIN_BASE_URL}/api/flow/login",
|
|
355
|
+
params={"id": self._flow_id},
|
|
356
|
+
)
|
|
357
|
+
if resp.status_code == 200:
|
|
358
|
+
flow_data = resp.json()
|
|
359
|
+
token = self._extract_csrf_token(flow_data)
|
|
360
|
+
if token:
|
|
361
|
+
self._csrf_token = token
|
|
362
|
+
logger.debug("CSRF token refreshed")
|
|
363
|
+
|
|
364
|
+
@staticmethod
|
|
365
|
+
def _extract_csrf_token(flow_data: dict) -> str | None:
|
|
366
|
+
"""Extract CSRF token from Ory/Kratos flow data.
|
|
367
|
+
|
|
368
|
+
The CSRF token is nested in the UI nodes of the flow response.
|
|
369
|
+
"""
|
|
370
|
+
# Try direct csrf_token field
|
|
371
|
+
if "csrf_token" in flow_data:
|
|
372
|
+
return flow_data["csrf_token"]
|
|
373
|
+
|
|
374
|
+
# Try nested in UI nodes (Ory/Kratos format)
|
|
375
|
+
ui = flow_data.get("ui", {})
|
|
376
|
+
nodes = ui.get("nodes", [])
|
|
377
|
+
for node in nodes:
|
|
378
|
+
attrs = node.get("attributes", {})
|
|
379
|
+
if attrs.get("name") == "csrf_token":
|
|
380
|
+
return attrs.get("value")
|
|
381
|
+
|
|
382
|
+
# Try in the flow's action URL
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
@staticmethod
|
|
386
|
+
def _extract_error_message(body: dict) -> str:
|
|
387
|
+
"""Extract a human-readable error from an Ory/Kratos error response."""
|
|
388
|
+
# Check UI messages
|
|
389
|
+
ui = body.get("ui", {})
|
|
390
|
+
messages = ui.get("messages", [])
|
|
391
|
+
if messages:
|
|
392
|
+
return "; ".join(m.get("text", str(m)) for m in messages)
|
|
393
|
+
|
|
394
|
+
# Check node-level messages
|
|
395
|
+
nodes = ui.get("nodes", [])
|
|
396
|
+
for node in nodes:
|
|
397
|
+
node_messages = node.get("messages", [])
|
|
398
|
+
if node_messages:
|
|
399
|
+
return "; ".join(m.get("text", str(m)) for m in node_messages)
|
|
400
|
+
|
|
401
|
+
# Check top-level error
|
|
402
|
+
error = body.get("error", {})
|
|
403
|
+
if isinstance(error, dict):
|
|
404
|
+
return error.get("message", str(error))
|
|
405
|
+
return str(error)
|
pyindus/chat.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Chat operations module for Indus API.
|
|
2
|
+
|
|
3
|
+
Handles chat sessions, prompts, task graphs, and account operations.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from pyindus.exceptions import APIError, AuthenticationError, SessionError
|
|
14
|
+
from pyindus.models import (
|
|
15
|
+
ChatAccount,
|
|
16
|
+
ChatSession,
|
|
17
|
+
Config,
|
|
18
|
+
PromptResponse,
|
|
19
|
+
TaskGraph,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
INDUS_BASE_URL = "https://indus.sarvam.ai"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class IndusChat:
|
|
28
|
+
"""Handles chat operations with the Indus API.
|
|
29
|
+
|
|
30
|
+
This class requires an authenticated httpx.Client with valid
|
|
31
|
+
session cookies. Use IndusAuth to authenticate first.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, http_client: httpx.Client):
|
|
35
|
+
self._client = http_client
|
|
36
|
+
|
|
37
|
+
def get_task_graphs(self, online_only: bool = True) -> list[TaskGraph]:
|
|
38
|
+
"""Get available AI models (task graphs).
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
online_only: If True, only return online/available models.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
List of available TaskGraph models.
|
|
45
|
+
"""
|
|
46
|
+
params = {}
|
|
47
|
+
if online_only:
|
|
48
|
+
params["online"] = "true"
|
|
49
|
+
|
|
50
|
+
resp = self._request("GET", "/api/chat/task-graphs", params=params)
|
|
51
|
+
return [TaskGraph.model_validate(tg) for tg in resp.json()]
|
|
52
|
+
|
|
53
|
+
def get_task_graph(self, uid: str) -> TaskGraph:
|
|
54
|
+
"""Get details of a specific task graph.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
uid: The task graph UID.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
TaskGraph details.
|
|
61
|
+
"""
|
|
62
|
+
resp = self._request("GET", f"/api/chat/task-graphs/{uid}")
|
|
63
|
+
return TaskGraph.model_validate(resp.json())
|
|
64
|
+
|
|
65
|
+
def create_session(self, task_graph_uid: str) -> str:
|
|
66
|
+
"""Create a new chat session.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
task_graph_uid: UID of the task graph (model) to use.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The session UID.
|
|
73
|
+
"""
|
|
74
|
+
resp = self._request(
|
|
75
|
+
"POST",
|
|
76
|
+
"/api/chat/session",
|
|
77
|
+
json={"task_graph_uid": task_graph_uid},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if resp.status_code != 201:
|
|
81
|
+
raise SessionError(
|
|
82
|
+
f"Failed to create session: {resp.status_code} {resp.text}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
session_uid = resp.json()
|
|
86
|
+
if isinstance(session_uid, str):
|
|
87
|
+
logger.info("Created session: %s", session_uid)
|
|
88
|
+
return session_uid
|
|
89
|
+
else:
|
|
90
|
+
# The response might be a dict with a uid field
|
|
91
|
+
uid = session_uid.get("uid", session_uid.get("id", str(session_uid)))
|
|
92
|
+
logger.info("Created session: %s", uid)
|
|
93
|
+
return uid
|
|
94
|
+
|
|
95
|
+
def send_prompt(self, session_uid: str, prompt: str) -> PromptResponse:
|
|
96
|
+
"""Send a prompt to a chat session.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
session_uid: The session UID.
|
|
100
|
+
prompt: The user's message.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
PromptResponse containing the AI's response steps.
|
|
104
|
+
"""
|
|
105
|
+
# Generate trace ID for the request
|
|
106
|
+
trace_id = uuid.uuid4().hex[:32]
|
|
107
|
+
|
|
108
|
+
resp = self._request(
|
|
109
|
+
"POST",
|
|
110
|
+
"/api/chat/prompt/prompt",
|
|
111
|
+
json={
|
|
112
|
+
"sessionUid": session_uid,
|
|
113
|
+
"prompt": prompt,
|
|
114
|
+
},
|
|
115
|
+
extra_headers={
|
|
116
|
+
"x-amzn-trace-id": f"Root=1-{trace_id[:8]}-{trace_id[8:32]};eru={session_uid}",
|
|
117
|
+
},
|
|
118
|
+
timeout=120.0, # Prompts can take longer
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return PromptResponse.model_validate(resp.json())
|
|
122
|
+
|
|
123
|
+
def list_sessions(self, include_shares: bool = True) -> list[ChatSession]:
|
|
124
|
+
"""List all chat sessions.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
include_shares: If True, include shared sessions.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
List of ChatSession objects.
|
|
131
|
+
"""
|
|
132
|
+
params = {}
|
|
133
|
+
if include_shares:
|
|
134
|
+
params["include_shares"] = "true"
|
|
135
|
+
|
|
136
|
+
resp = self._request("GET", "/api/chat/session", params=params)
|
|
137
|
+
return [ChatSession.model_validate(s) for s in resp.json()]
|
|
138
|
+
|
|
139
|
+
def get_account_me(self) -> ChatAccount:
|
|
140
|
+
"""Get chat account info.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
ChatAccount with user details.
|
|
144
|
+
"""
|
|
145
|
+
resp = self._request("GET", "/api/chat/account/me")
|
|
146
|
+
return ChatAccount.model_validate(resp.json())
|
|
147
|
+
|
|
148
|
+
def get_config(self) -> Config:
|
|
149
|
+
"""Get platform configuration.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Config with platform settings.
|
|
153
|
+
"""
|
|
154
|
+
resp = self._request("GET", "/api/config")
|
|
155
|
+
return Config.model_validate(resp.json())
|
|
156
|
+
|
|
157
|
+
def _request(
|
|
158
|
+
self,
|
|
159
|
+
method: str,
|
|
160
|
+
path: str,
|
|
161
|
+
*,
|
|
162
|
+
params: dict | None = None,
|
|
163
|
+
json: dict | None = None,
|
|
164
|
+
extra_headers: dict | None = None,
|
|
165
|
+
timeout: float | None = None,
|
|
166
|
+
) -> httpx.Response:
|
|
167
|
+
"""Make an authenticated request to the Indus API.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
method: HTTP method.
|
|
171
|
+
path: API path (relative to INDUS_BASE_URL).
|
|
172
|
+
params: Query parameters.
|
|
173
|
+
json: JSON request body.
|
|
174
|
+
extra_headers: Additional headers.
|
|
175
|
+
timeout: Request timeout.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
httpx.Response
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
AuthenticationError: If the request returns 401.
|
|
182
|
+
APIError: If the request fails.
|
|
183
|
+
"""
|
|
184
|
+
url = f"{INDUS_BASE_URL}{path}"
|
|
185
|
+
headers = {
|
|
186
|
+
"accept": "application/json, text/plain, */*",
|
|
187
|
+
"origin": INDUS_BASE_URL,
|
|
188
|
+
"referer": f"{INDUS_BASE_URL}/",
|
|
189
|
+
}
|
|
190
|
+
if extra_headers:
|
|
191
|
+
headers.update(extra_headers)
|
|
192
|
+
|
|
193
|
+
kwargs: dict = {"headers": headers}
|
|
194
|
+
if params:
|
|
195
|
+
kwargs["params"] = params
|
|
196
|
+
if json is not None:
|
|
197
|
+
kwargs["json"] = json
|
|
198
|
+
if timeout:
|
|
199
|
+
kwargs["timeout"] = timeout
|
|
200
|
+
|
|
201
|
+
resp = self._client.request(method, url, **kwargs)
|
|
202
|
+
|
|
203
|
+
if resp.status_code == 401:
|
|
204
|
+
raise AuthenticationError("Not authenticated. Please login first.")
|
|
205
|
+
|
|
206
|
+
if resp.status_code >= 400:
|
|
207
|
+
raise APIError(
|
|
208
|
+
f"API error {resp.status_code}: {resp.text}",
|
|
209
|
+
status_code=resp.status_code,
|
|
210
|
+
response_body=resp.text,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
return resp
|
pyindus/cli.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""CLI entry point for PyIndus.
|
|
2
|
+
|
|
3
|
+
Provides an interactive login flow.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main():
|
|
12
|
+
"""Interactive login and chat."""
|
|
13
|
+
from pyindus.client import IndusClient
|
|
14
|
+
|
|
15
|
+
client = IndusClient()
|
|
16
|
+
|
|
17
|
+
print("╔══════════════════════════════════════╗")
|
|
18
|
+
print("║ PyIndus - Indus Chat ║")
|
|
19
|
+
print("║ Powered by Sarvam AI ║")
|
|
20
|
+
print("╚══════════════════════════════════════╝")
|
|
21
|
+
print()
|
|
22
|
+
|
|
23
|
+
# Try loading existing session
|
|
24
|
+
try:
|
|
25
|
+
if client.load_session():
|
|
26
|
+
user = client.get_user_info()
|
|
27
|
+
print(f"✓ Session loaded. Welcome back, {user.name}!")
|
|
28
|
+
_chat_loop(client)
|
|
29
|
+
return
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
# Interactive login
|
|
34
|
+
phone = input("Enter your phone number (with country code, e.g., +91...): ").strip()
|
|
35
|
+
if not phone:
|
|
36
|
+
print("Phone number is required.")
|
|
37
|
+
sys.exit(1)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
client.login(phone)
|
|
41
|
+
print(f"✓ OTP sent to {phone}")
|
|
42
|
+
except Exception as e:
|
|
43
|
+
print(f"✗ Login failed: {e}")
|
|
44
|
+
sys.exit(1)
|
|
45
|
+
|
|
46
|
+
code = input("Enter the OTP code: ").strip()
|
|
47
|
+
if not code:
|
|
48
|
+
print("OTP code is required.")
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
user = client.verify_otp(code)
|
|
53
|
+
print(f"✓ Welcome, {user.name}!")
|
|
54
|
+
client.save_session()
|
|
55
|
+
print("✓ Session saved for next time.")
|
|
56
|
+
except Exception as e:
|
|
57
|
+
print(f"✗ OTP verification failed: {e}")
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
|
|
60
|
+
_chat_loop(client)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _chat_loop(client):
|
|
64
|
+
"""Simple interactive chat loop."""
|
|
65
|
+
print()
|
|
66
|
+
print("Type your message (or 'quit' to exit, 'new' for new session):")
|
|
67
|
+
print()
|
|
68
|
+
|
|
69
|
+
while True:
|
|
70
|
+
try:
|
|
71
|
+
prompt = input("You: ").strip()
|
|
72
|
+
except (EOFError, KeyboardInterrupt):
|
|
73
|
+
print("\nGoodbye!")
|
|
74
|
+
break
|
|
75
|
+
|
|
76
|
+
if not prompt:
|
|
77
|
+
continue
|
|
78
|
+
if prompt.lower() in ("quit", "exit", "q"):
|
|
79
|
+
print("Goodbye!")
|
|
80
|
+
break
|
|
81
|
+
if prompt.lower() == "new":
|
|
82
|
+
client.new_session()
|
|
83
|
+
print("✓ New session started.")
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
response = client.chat(prompt)
|
|
88
|
+
print(f"\nIndus: {response.answer}\n")
|
|
89
|
+
except Exception as e:
|
|
90
|
+
print(f"\n✗ Error: {e}\n")
|
|
91
|
+
|
|
92
|
+
client.close()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
main()
|
pyindus/client.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""High-level client for the Indus Chat API.
|
|
2
|
+
|
|
3
|
+
Combines auth and chat modules into a simple, user-friendly interface.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from pyindus.auth import IndusAuth
|
|
14
|
+
from pyindus.chat import IndusChat
|
|
15
|
+
from pyindus.exceptions import AuthenticationError, SessionError
|
|
16
|
+
from pyindus.models import (
|
|
17
|
+
ChatAccount,
|
|
18
|
+
ChatSession,
|
|
19
|
+
Config,
|
|
20
|
+
PromptResponse,
|
|
21
|
+
TaskGraph,
|
|
22
|
+
UserInfo,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class IndusClient:
|
|
29
|
+
"""High-level client for interacting with the Indus Chat API.
|
|
30
|
+
|
|
31
|
+
Usage::
|
|
32
|
+
|
|
33
|
+
# Interactive login
|
|
34
|
+
client = IndusClient()
|
|
35
|
+
client.login("+91XXXXXXXXXX")
|
|
36
|
+
client.verify_otp("123456")
|
|
37
|
+
|
|
38
|
+
# Chat
|
|
39
|
+
response = client.chat("What is AI?")
|
|
40
|
+
print(response.answer)
|
|
41
|
+
|
|
42
|
+
# Save session for later
|
|
43
|
+
client.save_session("session.json")
|
|
44
|
+
|
|
45
|
+
Usage with context manager::
|
|
46
|
+
|
|
47
|
+
with IndusClient() as client:
|
|
48
|
+
client.load_session("session.json")
|
|
49
|
+
response = client.chat("Hello!")
|
|
50
|
+
print(response.answer)
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, session_file: str | Path = "indus_session.json"):
|
|
54
|
+
self.session_file = session_file
|
|
55
|
+
self._http_client = httpx.Client(
|
|
56
|
+
follow_redirects=False,
|
|
57
|
+
timeout=30.0,
|
|
58
|
+
headers={
|
|
59
|
+
"user-agent": (
|
|
60
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
61
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
62
|
+
"Chrome/144.0.0.0 Safari/537.36"
|
|
63
|
+
),
|
|
64
|
+
"accept": "*/*",
|
|
65
|
+
"accept-language": "en-US,en;q=0.9",
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
self._auth = IndusAuth(http_client=self._http_client)
|
|
69
|
+
self._chat = IndusChat(http_client=self._http_client)
|
|
70
|
+
self._default_task_graph_uid: str | None = None
|
|
71
|
+
self._current_session_uid: str | None = None
|
|
72
|
+
|
|
73
|
+
# Auto-load session if it exists to make it feel like an SDK
|
|
74
|
+
try:
|
|
75
|
+
self.load_session(self.session_file, quiet=True)
|
|
76
|
+
except AuthenticationError:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
# ── Context Manager ──────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
def __enter__(self) -> IndusClient:
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def __exit__(self, *args) -> None:
|
|
85
|
+
self.close()
|
|
86
|
+
|
|
87
|
+
# ── Authentication ───────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
def login(self, phone: str) -> str:
|
|
90
|
+
"""Start login flow with phone number.
|
|
91
|
+
|
|
92
|
+
An OTP will be sent to the provided phone number via SMS.
|
|
93
|
+
Call verify_otp() with the received code to complete login.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
phone: Phone number with country code (e.g., "+918874163264").
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Flow ID for the login flow.
|
|
100
|
+
"""
|
|
101
|
+
return self._auth.login(phone)
|
|
102
|
+
|
|
103
|
+
def verify_otp(self, code: str) -> UserInfo:
|
|
104
|
+
"""Complete login with OTP code.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
code: The OTP code received via SMS.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
UserInfo for the authenticated user.
|
|
111
|
+
"""
|
|
112
|
+
user_info = self._auth.verify_otp(code)
|
|
113
|
+
|
|
114
|
+
# Cache the default task graph
|
|
115
|
+
try:
|
|
116
|
+
models = self.get_models()
|
|
117
|
+
if models:
|
|
118
|
+
self._default_task_graph_uid = models[0].uid
|
|
119
|
+
logger.info("Default model: %s", models[0].name)
|
|
120
|
+
except Exception as e:
|
|
121
|
+
logger.warning("Could not fetch models: %s", e)
|
|
122
|
+
|
|
123
|
+
# Auto-save session to provide a seamless SDK experience
|
|
124
|
+
self.save_session(self.session_file)
|
|
125
|
+
return user_info
|
|
126
|
+
|
|
127
|
+
def save_session(self, path: str | Path | None = None) -> None:
|
|
128
|
+
"""Save session cookies to disk for later reuse.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
path: File path to save the session data. Uses the default if not provided.
|
|
132
|
+
"""
|
|
133
|
+
self._auth.save_session(path or self.session_file)
|
|
134
|
+
|
|
135
|
+
def load_session(self, path: str | Path | None = None, quiet: bool = False) -> bool:
|
|
136
|
+
"""Load a previously saved session.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
path: File path to load the session data from. Uses the default if not provided.
|
|
140
|
+
quiet: If True, suppress errors if file doesn't exist.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
True if session loaded and is still valid.
|
|
144
|
+
"""
|
|
145
|
+
try:
|
|
146
|
+
result = self._auth.load_session(path or self.session_file)
|
|
147
|
+
except AuthenticationError:
|
|
148
|
+
if quiet:
|
|
149
|
+
return False
|
|
150
|
+
raise
|
|
151
|
+
|
|
152
|
+
if result:
|
|
153
|
+
# Cache the default task graph
|
|
154
|
+
try:
|
|
155
|
+
models = self.get_models()
|
|
156
|
+
if models:
|
|
157
|
+
self._default_task_graph_uid = models[0].uid
|
|
158
|
+
except Exception as e:
|
|
159
|
+
if not quiet:
|
|
160
|
+
logger.warning("Could not fetch models: %s", e)
|
|
161
|
+
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def is_authenticated(self) -> bool:
|
|
166
|
+
"""Whether the client is currently authenticated."""
|
|
167
|
+
return self._auth.is_authenticated
|
|
168
|
+
|
|
169
|
+
def get_user_info(self) -> UserInfo:
|
|
170
|
+
"""Get current user info.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
UserInfo for the authenticated user.
|
|
174
|
+
"""
|
|
175
|
+
return self._auth.get_me()
|
|
176
|
+
|
|
177
|
+
def refresh_auth(self):
|
|
178
|
+
"""Manually refresh the auth token."""
|
|
179
|
+
return self._auth.refresh()
|
|
180
|
+
|
|
181
|
+
# ── Chat ─────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
def chat(
|
|
184
|
+
self,
|
|
185
|
+
prompt: str,
|
|
186
|
+
*,
|
|
187
|
+
session_uid: str | None = None,
|
|
188
|
+
task_graph_uid: str | None = None,
|
|
189
|
+
) -> PromptResponse:
|
|
190
|
+
"""Send a message and get a response.
|
|
191
|
+
|
|
192
|
+
If no session_uid is provided, a new session will be created.
|
|
193
|
+
If no task_graph_uid is provided, the default model is used.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
prompt: The user message.
|
|
197
|
+
session_uid: Optional existing session UID for conversation continuity.
|
|
198
|
+
task_graph_uid: Optional task graph UID to specify which model to use.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
PromptResponse with the AI's response.
|
|
202
|
+
"""
|
|
203
|
+
if not self._auth.is_authenticated:
|
|
204
|
+
# Try to auto-refresh if we have cookies but token expired
|
|
205
|
+
try:
|
|
206
|
+
self._auth.refresh()
|
|
207
|
+
except AuthenticationError:
|
|
208
|
+
raise AuthenticationError(
|
|
209
|
+
"Not authenticated. Call login() and verify_otp() first."
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Auto-refresh proactively wrapped around the chat to handle expiration
|
|
213
|
+
# IndusChat already handles throwing APIErrors, but we can catch 401s
|
|
214
|
+
# and retry once if the token expired mid-session.
|
|
215
|
+
|
|
216
|
+
# Determine task graph
|
|
217
|
+
tg_uid = task_graph_uid or self._default_task_graph_uid
|
|
218
|
+
if not tg_uid:
|
|
219
|
+
models = self.get_models()
|
|
220
|
+
if not models:
|
|
221
|
+
raise SessionError("No models available")
|
|
222
|
+
tg_uid = models[0].uid
|
|
223
|
+
self._default_task_graph_uid = tg_uid
|
|
224
|
+
|
|
225
|
+
# Use existing session or create a new one
|
|
226
|
+
if session_uid:
|
|
227
|
+
sid = session_uid
|
|
228
|
+
elif self._current_session_uid:
|
|
229
|
+
sid = self._current_session_uid
|
|
230
|
+
else:
|
|
231
|
+
sid = self._chat.create_session(tg_uid)
|
|
232
|
+
self._current_session_uid = sid
|
|
233
|
+
|
|
234
|
+
# Send prompt with auto-retry on 401
|
|
235
|
+
try:
|
|
236
|
+
return self._chat.send_prompt(sid, prompt)
|
|
237
|
+
except AuthenticationError:
|
|
238
|
+
# Token might have expired just now, try to refresh and retry
|
|
239
|
+
logger.info("Session expired during chat. Attempting auto-refresh...")
|
|
240
|
+
self._auth.refresh()
|
|
241
|
+
# Save the newly refreshed session automatically
|
|
242
|
+
self.save_session()
|
|
243
|
+
return self._chat.send_prompt(sid, prompt)
|
|
244
|
+
|
|
245
|
+
def new_session(self, task_graph_uid: str | None = None) -> str:
|
|
246
|
+
"""Create a new chat session explicitly.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
task_graph_uid: Optional task graph UID. Uses default if not provided.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
The new session UID.
|
|
253
|
+
"""
|
|
254
|
+
tg_uid = task_graph_uid or self._default_task_graph_uid
|
|
255
|
+
if not tg_uid:
|
|
256
|
+
models = self.get_models()
|
|
257
|
+
if not models:
|
|
258
|
+
raise SessionError("No models available")
|
|
259
|
+
tg_uid = models[0].uid
|
|
260
|
+
|
|
261
|
+
self._current_session_uid = self._chat.create_session(tg_uid)
|
|
262
|
+
return self._current_session_uid
|
|
263
|
+
|
|
264
|
+
def get_models(self) -> list[TaskGraph]:
|
|
265
|
+
"""Get available AI models.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
List of available TaskGraph models.
|
|
269
|
+
"""
|
|
270
|
+
return self._chat.get_task_graphs(online_only=True)
|
|
271
|
+
|
|
272
|
+
def list_sessions(self) -> list[ChatSession]:
|
|
273
|
+
"""List all chat sessions.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
List of ChatSession objects.
|
|
277
|
+
"""
|
|
278
|
+
return self._chat.list_sessions()
|
|
279
|
+
|
|
280
|
+
def get_account(self) -> ChatAccount:
|
|
281
|
+
"""Get chat account info.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
ChatAccount with user details.
|
|
285
|
+
"""
|
|
286
|
+
return self._chat.get_account_me()
|
|
287
|
+
|
|
288
|
+
def get_config(self) -> Config:
|
|
289
|
+
"""Get platform configuration.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
Config with platform settings.
|
|
293
|
+
"""
|
|
294
|
+
return self._chat.get_config()
|
|
295
|
+
|
|
296
|
+
# ── Cleanup ──────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
def close(self) -> None:
|
|
299
|
+
"""Close the client and release resources."""
|
|
300
|
+
self._auth.close()
|
pyindus/exceptions.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Custom exceptions for PyIndus."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class IndusError(Exception):
|
|
5
|
+
"""Base exception for all PyIndus errors."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthenticationError(IndusError):
|
|
11
|
+
"""Raised when authentication fails."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SessionError(IndusError):
|
|
17
|
+
"""Raised when session operations fail."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class APIError(IndusError):
|
|
23
|
+
"""Raised when an API call returns an unexpected error."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str, status_code: int | None = None, response_body: str | None = None):
|
|
26
|
+
self.status_code = status_code
|
|
27
|
+
self.response_body = response_body
|
|
28
|
+
super().__init__(message)
|
pyindus/models.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Pydantic models for Indus API responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UserInfo(BaseModel):
|
|
9
|
+
"""User info from /api/auth/me."""
|
|
10
|
+
|
|
11
|
+
sub: str
|
|
12
|
+
email: str = ""
|
|
13
|
+
name: str = ""
|
|
14
|
+
token_expires_at: int = Field(alias="tokenExpiresAt")
|
|
15
|
+
token_expires_in: int = Field(alias="tokenExpiresIn")
|
|
16
|
+
|
|
17
|
+
model_config = {"populate_by_name": True}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ChatAccount(BaseModel):
|
|
21
|
+
"""Chat account info from /api/chat/account/me."""
|
|
22
|
+
|
|
23
|
+
uid: str
|
|
24
|
+
first_name: str | None = None
|
|
25
|
+
last_name: str | None = None
|
|
26
|
+
full_name: str | None = None
|
|
27
|
+
email: str = ""
|
|
28
|
+
dp: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TaskGraph(BaseModel):
|
|
32
|
+
"""Available AI model/task graph."""
|
|
33
|
+
|
|
34
|
+
uid: str
|
|
35
|
+
name: str
|
|
36
|
+
description: str = ""
|
|
37
|
+
online: bool = False
|
|
38
|
+
online_sort_order: int = 0
|
|
39
|
+
favicon_light: str | None = None
|
|
40
|
+
favicon_dark: str | None = None
|
|
41
|
+
nodes: dict | None = None
|
|
42
|
+
attachment_mime: list[str] = Field(default_factory=list, alias="attachmentMime")
|
|
43
|
+
max_attachment_count: int | None = Field(default=None, alias="maxAttachmentCount")
|
|
44
|
+
max_attachment_size: int | None = Field(default=None, alias="maxAttachmentSize")
|
|
45
|
+
prompt_improvement_agent_uid: str | None = Field(default=None, alias="prompt_improvement_agent_uid")
|
|
46
|
+
can_edit_artefact: bool = Field(default=False, alias="canEditArtefact")
|
|
47
|
+
init_json_schema: dict | None = Field(default=None, alias="initJsonSchema")
|
|
48
|
+
|
|
49
|
+
model_config = {"populate_by_name": True}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ChatSession(BaseModel):
|
|
53
|
+
"""Chat session metadata."""
|
|
54
|
+
|
|
55
|
+
uid: str
|
|
56
|
+
title: str = ""
|
|
57
|
+
task_graph_uid: str = ""
|
|
58
|
+
task_graph_version: int | None = None
|
|
59
|
+
created_at: str = ""
|
|
60
|
+
role: str = ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Step(BaseModel):
|
|
64
|
+
"""Individual step in a prompt response.
|
|
65
|
+
|
|
66
|
+
Step types (determined by 't' field):
|
|
67
|
+
0 - Thinking/reasoning content
|
|
68
|
+
20 - Separator/newline
|
|
69
|
+
17 - Tool call (search, etc.)
|
|
70
|
+
15 - Tool result
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
node_uid: str | None = None
|
|
74
|
+
t: int = 0
|
|
75
|
+
content: str | None = None
|
|
76
|
+
# Tool call fields
|
|
77
|
+
id: str | None = None
|
|
78
|
+
name: str | None = None
|
|
79
|
+
arg: str | None = None
|
|
80
|
+
mcp_uid: str | None = None
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def is_thinking(self) -> bool:
|
|
84
|
+
return self.t == 0
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def is_tool_call(self) -> bool:
|
|
88
|
+
return self.t == 17
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def is_tool_result(self) -> bool:
|
|
92
|
+
return self.t == 15
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def is_separator(self) -> bool:
|
|
96
|
+
return self.t == 20
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class PromptResponse(BaseModel):
|
|
100
|
+
"""Full response from /api/chat/prompt/prompt."""
|
|
101
|
+
|
|
102
|
+
human_turn_uid: str = Field(alias="humanTurnUid")
|
|
103
|
+
agent_turn_uid: str = Field(alias="agentTurnUid")
|
|
104
|
+
steps: list[Step] = Field(default_factory=list)
|
|
105
|
+
|
|
106
|
+
model_config = {"populate_by_name": True}
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def answer(self) -> str:
|
|
110
|
+
"""Extract the final text answer from the steps.
|
|
111
|
+
|
|
112
|
+
The final answer is typically the last thinking step (t=0) that
|
|
113
|
+
contains the synthesized response after all tool calls.
|
|
114
|
+
"""
|
|
115
|
+
answer_parts: list[str] = []
|
|
116
|
+
# Find the last group of thinking steps after tool results
|
|
117
|
+
last_tool_result_idx = -1
|
|
118
|
+
for i, step in enumerate(self.steps):
|
|
119
|
+
if step.is_tool_result:
|
|
120
|
+
last_tool_result_idx = i
|
|
121
|
+
|
|
122
|
+
# Collect all thinking content after the last tool result
|
|
123
|
+
start_idx = last_tool_result_idx + 1 if last_tool_result_idx >= 0 else 0
|
|
124
|
+
for step in self.steps[start_idx:]:
|
|
125
|
+
if step.is_thinking and step.content:
|
|
126
|
+
answer_parts.append(step.content)
|
|
127
|
+
elif step.is_separator and step.content:
|
|
128
|
+
answer_parts.append(step.content)
|
|
129
|
+
|
|
130
|
+
return "".join(answer_parts).strip()
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def thinking(self) -> str:
|
|
134
|
+
"""Extract thinking/reasoning content (before the final answer)."""
|
|
135
|
+
parts: list[str] = []
|
|
136
|
+
for step in self.steps:
|
|
137
|
+
if step.is_thinking and step.content:
|
|
138
|
+
parts.append(step.content)
|
|
139
|
+
elif step.is_tool_call:
|
|
140
|
+
break # Stop at first tool call
|
|
141
|
+
return "".join(parts).strip()
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def tool_calls(self) -> list[Step]:
|
|
145
|
+
"""Get all tool call steps."""
|
|
146
|
+
return [s for s in self.steps if s.is_tool_call]
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def tool_results(self) -> list[Step]:
|
|
150
|
+
"""Get all tool result steps."""
|
|
151
|
+
return [s for s in self.steps if s.is_tool_result]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class RefreshResponse(BaseModel):
|
|
155
|
+
"""Response from /api/auth/refresh."""
|
|
156
|
+
|
|
157
|
+
success: bool
|
|
158
|
+
expires_in: int = Field(alias="expiresIn")
|
|
159
|
+
token_expires_at: int = Field(alias="tokenExpiresAt")
|
|
160
|
+
|
|
161
|
+
model_config = {"populate_by_name": True}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class Config(BaseModel):
|
|
165
|
+
"""Platform configuration from /api/config."""
|
|
166
|
+
|
|
167
|
+
voice_mode_enabled: bool = Field(default=False, alias="voiceModeEnabled")
|
|
168
|
+
voice_mode_environment: str = Field(default="prod", alias="voiceModeEnvironment")
|
|
169
|
+
hidden_task_graphs_mobile: list[str] = Field(default_factory=list, alias="hiddenTaskGraphsMobile")
|
|
170
|
+
|
|
171
|
+
model_config = {"populate_by_name": True}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyindus
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python package for Indus Chat API by Sarvam AI
|
|
5
|
+
Author: Abhishek Verma
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: ai,api,chatbot,indus,sarvam
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: httpx>=0.27.0
|
|
18
|
+
Requires-Dist: pydantic>=2.0.0
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# PyIndus
|
|
22
|
+
|
|
23
|
+
A Python package for interacting with [Indus](https://indus.sarvam.ai), a ChatGPT alternative by Sarvam AI.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install pyindus
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or with uv:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uv add pyindus
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
`IndusClient` acts as a fully-featured, seamless SDK. It **automatically saves, loads, and refreshes sessions** for you.
|
|
40
|
+
|
|
41
|
+
### 1. Initial Login
|
|
42
|
+
Run this once to authenticate. The client will automatically save your session to `indus_session.json` by default.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from pyindus import IndusClient
|
|
46
|
+
|
|
47
|
+
# Login with phone number
|
|
48
|
+
client = IndusClient()
|
|
49
|
+
client.login("+91XXXXXXXXXX")
|
|
50
|
+
|
|
51
|
+
# Enter the OTP received via SMS
|
|
52
|
+
client.verify_otp("123456")
|
|
53
|
+
|
|
54
|
+
# The session is now authenticated and saved automatically!
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 2. Immediate Re-use (Like an SDK)
|
|
58
|
+
Run this anywhere else in your project. Because the session was saved, the client automatically loads it on `__init__`. *If the token expires, the client will dynamically refresh it in the background.*
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from pyindus import IndusClient
|
|
62
|
+
|
|
63
|
+
# Automatically loads the previous session from 'indus_session.json'
|
|
64
|
+
client = IndusClient()
|
|
65
|
+
|
|
66
|
+
# Chat directly! No need to login again.
|
|
67
|
+
response = client.chat("What is quantum computing?")
|
|
68
|
+
print(response.answer)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Integration Guide: Custom Paths
|
|
72
|
+
|
|
73
|
+
If you're building a web app or managing multiple users, you can specify individual session files.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from pyindus import IndusClient
|
|
77
|
+
|
|
78
|
+
# Supply a unique path for the user's session
|
|
79
|
+
def handle_user_request(user_id, message):
|
|
80
|
+
session_path = f"sessions/user_{user_id}.json"
|
|
81
|
+
|
|
82
|
+
# Auto-loads and manages session in this specific file
|
|
83
|
+
with IndusClient(session_file=session_path) as client:
|
|
84
|
+
return client.chat(message)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Advanced Usage
|
|
88
|
+
|
|
89
|
+
### Working with Specific Models
|
|
90
|
+
Indus supports different "Task Graphs" (models like Sarvam Think, Bulbul, etc.). By default, `IndusClient` selects the first available chat model automatically.
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from pyindus import IndusClient
|
|
94
|
+
|
|
95
|
+
with IndusClient() as client:
|
|
96
|
+
# List available models
|
|
97
|
+
models = client.get_models()
|
|
98
|
+
for model in models:
|
|
99
|
+
print(f"{model.name}: {model.description}")
|
|
100
|
+
|
|
101
|
+
# Use a specific model
|
|
102
|
+
response = client.chat("Explain gravity", task_graph_uid=models[-1].uid)
|
|
103
|
+
print(response.answer)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pyindus/__init__.py,sha256=GmRsrPH2wLM5gM000lydNQhq3Xk-dGJwIthhrmGIvto,694
|
|
2
|
+
pyindus/auth.py,sha256=RkB3N7U0Uyng1agq_VWFERhrORnPTeP8xYW19Dm6lBg,14132
|
|
3
|
+
pyindus/chat.py,sha256=rcCMa1Y-h_QyVE_vQ8ssZtDy3jyVx0pTzLbXCcdLZVQ,6328
|
|
4
|
+
pyindus/cli.py,sha256=cysMBcH0LdqmxyyEJWD2cvVdE4lpxssmdBmqltSnumU,2651
|
|
5
|
+
pyindus/client.py,sha256=I3dKCnNaBJXlUQ_7_OQ0BZFiXqKxCdrAP0jmdiYLwzQ,10012
|
|
6
|
+
pyindus/exceptions.py,sha256=EWxkUA6hvpHX6fu6yySIsMFcPtWgpdHR16Ld_NpWmQk,655
|
|
7
|
+
pyindus/models.py,sha256=7teFzsxoblF9BzKFJD8lOmGXpp1DsKgCGEjmnFYU8hs,5272
|
|
8
|
+
pyindus-0.1.0.dist-info/METADATA,sha256=uYFr9jjv83-B0m5CXUTzGnAww-lBw1EHilvuYaMKbxo,2993
|
|
9
|
+
pyindus-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
10
|
+
pyindus-0.1.0.dist-info/entry_points.txt,sha256=lqWkiMrp6xX8TqBBlKtYVFNoPeQMPYh8Pz_as0D0Gos,45
|
|
11
|
+
pyindus-0.1.0.dist-info/RECORD,,
|