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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pyindus = pyindus.cli:main