indoxrouter 0.1.25__py3-none-any.whl → 0.1.27__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.
indoxrouter/client.py CHANGED
@@ -1,1160 +1,1212 @@
1
- """
2
- IndoxRouter Client Module
3
-
4
- This module provides a client for interacting with the IndoxRouter API, which serves as a unified
5
- interface to multiple AI providers and models. The client handles authentication, rate limiting,
6
- error handling, and provides a standardized response format across different AI services.
7
-
8
- IMPORTANT: The IndoxRouter server now supports only cookie-based authentication. This client
9
- automatically handles authentication by exchanging your API key for a JWT token through the login endpoint.
10
-
11
- The Client class offers methods for:
12
- - Authentication and session management
13
- - Making API requests with automatic token refresh
14
- - Accessing AI capabilities: chat completions, text completions, embeddings, image generation, and text-to-speech
15
- - Retrieving information about available providers and models
16
- - Monitoring usage statistics and credit consumption
17
-
18
- Usage example:
19
- ```python
20
- from indoxRouter import Client
21
-
22
- # Initialize client with API key
23
- client = Client(api_key="your_api_key")
24
-
25
- # Get available models
26
- models = client.models()
27
-
28
- # Generate a chat completion
29
- response = client.chat([
30
- {"role": "system", "content": "You are a helpful assistant."},
31
- {"role": "user", "content": "Tell me a joke."}
32
- ], model="openai/gpt-4o-mini")
33
-
34
- # Generate text embeddings
35
- embeddings = client.embeddings("This is a sample text", model="openai/text-embedding-ada-002")
36
-
37
- # Generate text-to-speech audio
38
- audio = client.text_to_speech("Hello, welcome to IndoxRouter!", model="openai/tts-1", voice="alloy")
39
-
40
- # Clean up resources when done
41
- client.close()
42
- ```
43
-
44
- The client can also be used as a context manager:
45
- ```python
46
- with Client(api_key="your_api_key") as client:
47
- response = client.chat([{"role": "user", "content": "Hello!"}], model="openai/gpt-4o-mini")
48
- ```
49
- """
50
-
51
- import os
52
- import logging
53
- from datetime import datetime, timedelta
54
- from typing import Dict, List, Any, Optional, Union
55
- import requests
56
- import json
57
-
58
- from .exceptions import (
59
- AuthenticationError,
60
- NetworkError,
61
- ProviderNotFoundError,
62
- ModelNotFoundError,
63
- ModelNotAvailableError,
64
- InvalidParametersError,
65
- RateLimitError,
66
- ProviderError,
67
- RequestError,
68
- InsufficientCreditsError,
69
- ValidationError,
70
- APIError,
71
- )
72
- from .constants import (
73
- DEFAULT_BASE_URL,
74
- DEFAULT_TIMEOUT,
75
- DEFAULT_MODEL,
76
- DEFAULT_EMBEDDING_MODEL,
77
- DEFAULT_IMAGE_MODEL,
78
- DEFAULT_TTS_MODEL,
79
- CHAT_ENDPOINT,
80
- COMPLETION_ENDPOINT,
81
- EMBEDDING_ENDPOINT,
82
- IMAGE_ENDPOINT,
83
- TTS_ENDPOINT,
84
- MODEL_ENDPOINT,
85
- USAGE_ENDPOINT,
86
- USE_COOKIES,
87
- )
88
-
89
- logger = logging.getLogger(__name__)
90
-
91
-
92
- class Client:
93
- """
94
- Client for interacting with the IndoxRouter API.
95
- """
96
-
97
- def __init__(
98
- self,
99
- api_key: Optional[str] = None,
100
- timeout: int = DEFAULT_TIMEOUT,
101
- ):
102
- """
103
- Initialize the client.
104
-
105
- Args:
106
- api_key: API key for authentication. If not provided, the client will look for the
107
- INDOX_ROUTER_API_KEY environment variable.
108
- timeout: Request timeout in seconds.
109
- """
110
-
111
- use_cookies = USE_COOKIES
112
- self.api_key = api_key or os.environ.get("INDOX_ROUTER_API_KEY")
113
- if not self.api_key:
114
- raise ValueError(
115
- "API key must be provided either as an argument or as the INDOX_ROUTER_API_KEY environment variable."
116
- )
117
-
118
- self.base_url = DEFAULT_BASE_URL
119
- self.timeout = timeout
120
- self.use_cookies = use_cookies
121
- self.session = requests.Session()
122
-
123
- # Authenticate and get JWT tokens
124
- self._authenticate()
125
-
126
- def _authenticate(self):
127
- """
128
- Authenticate with the server and get JWT tokens.
129
- This uses the /auth/token endpoint to get JWT tokens using the API key.
130
- """
131
- try:
132
- # First try with the dedicated API key endpoint
133
- logger.debug("Authenticating with dedicated API key endpoint")
134
- response = self.session.post(
135
- f"{self.base_url}/api/v1/auth/api-key",
136
- headers={"X-API-Key": self.api_key},
137
- timeout=self.timeout,
138
- )
139
-
140
- if response.status_code != 200:
141
- # If dedicated endpoint fails, try using the API key as a username
142
- logger.debug("API key endpoint failed, trying with API key as username")
143
- response = self.session.post(
144
- f"{self.base_url}/api/v1/auth/token",
145
- data={
146
- "username": self.api_key,
147
- "password": self.api_key, # Try using API key as both username and password
148
- },
149
- timeout=self.timeout,
150
- )
151
-
152
- if response.status_code != 200:
153
- # Try one more method - the token endpoint with different format
154
- logger.debug("Trying with API key as token parameter")
155
- response = self.session.post(
156
- f"{self.base_url}/api/v1/auth/token",
157
- data={
158
- "username": "pip_client",
159
- "password": self.api_key,
160
- },
161
- timeout=self.timeout,
162
- )
163
-
164
- if response.status_code != 200:
165
- error_data = {}
166
- try:
167
- error_data = response.json()
168
- except:
169
- error_data = {"detail": response.text}
170
-
171
- raise AuthenticationError(
172
- f"Authentication failed: {error_data.get('detail', 'Unknown error')}"
173
- )
174
-
175
- # Check if we have a token in the response body
176
- try:
177
- response_data = response.json()
178
- if "access_token" in response_data:
179
- # Store token in the session object for later use
180
- self.access_token = response_data["access_token"]
181
- logger.debug("Retrieved access token from response body")
182
- except:
183
- # If we couldn't parse JSON, that's fine - we'll rely on cookies
184
- logger.debug("No token found in response body, will rely on cookies")
185
-
186
- # At this point, the cookies should be set in the session
187
- logger.debug("Authentication successful")
188
-
189
- # Check if we have the cookies we need
190
- if "access_token" not in self.session.cookies:
191
- logger.warning(
192
- "Authentication succeeded but no access_token cookie was set"
193
- )
194
-
195
- except requests.RequestException as e:
196
- logger.error(f"Authentication request failed: {str(e)}")
197
- raise NetworkError(f"Network error during authentication: {str(e)}")
198
-
199
- def _get_domain(self):
200
- """
201
- Extract domain from the base URL for cookie setting.
202
- """
203
- try:
204
- from urllib.parse import urlparse
205
-
206
- parsed_url = urlparse(self.base_url)
207
- return parsed_url.netloc
208
- except Exception:
209
- # If parsing fails, return a default value
210
- return ""
211
-
212
- def enable_debug(self, level=logging.DEBUG):
213
- """
214
- Enable debug logging for the client.
215
-
216
- Args:
217
- level: Logging level (default: logging.DEBUG)
218
- """
219
- handler = logging.StreamHandler()
220
- handler.setFormatter(
221
- logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
222
- )
223
- logger.addHandler(handler)
224
- logger.setLevel(level)
225
- logger.debug("Debug logging enabled")
226
-
227
- def _request(
228
- self,
229
- method: str,
230
- endpoint: str,
231
- data: Optional[Dict[str, Any]] = None,
232
- stream: bool = False,
233
- ) -> Any:
234
- """
235
- Make a request to the API.
236
-
237
- Args:
238
- method: HTTP method (GET, POST, etc.)
239
- endpoint: API endpoint
240
- data: Request data
241
- stream: Whether to stream the response
242
-
243
- Returns:
244
- Response data
245
- """
246
- # Add API version prefix if not already present
247
- if not endpoint.startswith("api/v1/") and not endpoint.startswith("/api/v1/"):
248
- endpoint = f"api/v1/{endpoint}"
249
-
250
- # Remove any leading slash for consistent URL construction
251
- if endpoint.startswith("/"):
252
- endpoint = endpoint[1:]
253
-
254
- url = f"{self.base_url}/{endpoint}"
255
- headers = {"Content-Type": "application/json"}
256
-
257
- # Add Authorization header if we have an access token
258
- if hasattr(self, "access_token") and self.access_token:
259
- headers["Authorization"] = f"Bearer {self.access_token}"
260
-
261
- # logger.debug(f"Making {method} request to {url}")
262
- # if data:
263
- # logger.debug(f"Request data: {json.dumps(data, indent=2)}")
264
-
265
- # Diagnose potential issues with the request
266
- if method == "POST" and data:
267
- diagnosis = self.diagnose_request(endpoint, data)
268
- if not diagnosis["is_valid"]:
269
- issues_str = "\n".join([f"- {issue}" for issue in diagnosis["issues"]])
270
- logger.warning(f"Request validation issues:\n{issues_str}")
271
- # We'll still send the request, but log the issues
272
-
273
- try:
274
- response = self.session.request(
275
- method,
276
- url,
277
- headers=headers,
278
- json=data,
279
- timeout=self.timeout,
280
- stream=stream,
281
- )
282
-
283
- if stream:
284
- return response
285
-
286
- # Check if we need to reauthenticate (401 Unauthorized)
287
- if response.status_code == 401:
288
- logger.debug("Received 401, attempting to reauthenticate")
289
- self._authenticate()
290
-
291
- # Update Authorization header with new token if available
292
- if hasattr(self, "access_token") and self.access_token:
293
- headers["Authorization"] = f"Bearer {self.access_token}"
294
-
295
- # Retry the request after reauthentication
296
- response = self.session.request(
297
- method,
298
- url,
299
- headers=headers,
300
- json=data,
301
- timeout=self.timeout,
302
- stream=stream,
303
- )
304
-
305
- if stream:
306
- return response
307
-
308
- response.raise_for_status()
309
- return response.json()
310
- except requests.HTTPError as e:
311
- error_data = {}
312
- try:
313
- error_data = e.response.json()
314
- logger.error(f"HTTP error response: {json.dumps(error_data, indent=2)}")
315
- except (ValueError, AttributeError):
316
- error_data = {"detail": str(e)}
317
- logger.error(f"HTTP error (no JSON response): {str(e)}")
318
-
319
- status_code = getattr(e.response, "status_code", 500)
320
- error_message = error_data.get("detail", str(e))
321
-
322
- if status_code == 401:
323
- raise AuthenticationError(f"Authentication failed: {error_message}")
324
- elif status_code == 404:
325
- if "provider" in error_message.lower():
326
- raise ProviderNotFoundError(error_message)
327
- elif "model" in error_message.lower():
328
- # Check if it's a model not found vs model not available
329
- if (
330
- "not supported" in error_message.lower()
331
- or "disabled" in error_message.lower()
332
- or "unavailable" in error_message.lower()
333
- ):
334
- raise ModelNotAvailableError(error_message)
335
- else:
336
- raise ModelNotFoundError(error_message)
337
- else:
338
- raise APIError(f"Resource not found: {error_message} (URL: {url})")
339
- elif status_code == 429:
340
- raise RateLimitError(f"Rate limit exceeded: {error_message}")
341
- elif status_code == 400:
342
- # Check if it's a validation error or invalid parameters
343
- if (
344
- "validation" in error_message.lower()
345
- or "invalid format" in error_message.lower()
346
- ):
347
- raise ValidationError(f"Request validation failed: {error_message}")
348
- else:
349
- raise InvalidParametersError(f"Invalid parameters: {error_message}")
350
- elif status_code == 402:
351
- raise InsufficientCreditsError(f"Insufficient credits: {error_message}")
352
- elif status_code == 422:
353
- # Unprocessable Entity - typically validation errors
354
- raise ValidationError(f"Request validation failed: {error_message}")
355
- elif status_code == 503:
356
- # Service Unavailable - model might be temporarily unavailable
357
- if "model" in error_message.lower():
358
- raise ModelNotAvailableError(
359
- f"Model temporarily unavailable: {error_message}"
360
- )
361
- else:
362
- raise APIError(f"Service unavailable: {error_message}")
363
- elif status_code == 500:
364
- # Provide more detailed information for server errors
365
- error_detail = error_data.get("detail", "No details provided")
366
- # Include the request data in the error message for better debugging
367
- request_data_str = json.dumps(data, indent=2) if data else "None"
368
- raise RequestError(
369
- f"Server error (500): {error_detail}. URL: {url}.\n"
370
- f"Request data: {request_data_str}\n"
371
- f"This may indicate an issue with the server configuration or a problem with the provider service."
372
- )
373
- elif status_code >= 400 and status_code < 500:
374
- # Client errors
375
- raise APIError(f"Client error ({status_code}): {error_message}")
376
- else:
377
- # Server errors
378
- raise RequestError(f"Server error ({status_code}): {error_message}")
379
- except requests.RequestException as e:
380
- logger.error(f"Request exception: {str(e)}")
381
- raise NetworkError(f"Network error: {str(e)}")
382
-
383
- def _format_model_string(self, model: str) -> str:
384
- """
385
- Format the model string in a way that the server expects.
386
-
387
- The server might be expecting a different format than "provider/model".
388
- This method handles different formatting requirements.
389
-
390
- Args:
391
- model: Model string in the format "provider/model"
392
-
393
- Returns:
394
- Formatted model string
395
- """
396
- if not model or "/" not in model:
397
- return model
398
-
399
- # The standard format is "provider/model"
400
- # But the server might be expecting something different
401
- provider, model_name = model.split("/", 1)
402
-
403
- # For now, return the original format as it seems the server
404
- # is having issues with JSON formatted model strings
405
- return model
406
-
407
- def _format_image_size_for_provider(
408
- self, size: str, provider: str, model: str
409
- ) -> str:
410
- """
411
- Format the image size parameter based on the provider's requirements.
412
-
413
- Google requires aspect ratios like "1:1", "4:3", etc. while OpenAI uses pixel dimensions
414
- like "1024x1024", "512x512", etc.
415
-
416
- Args:
417
- size: The size parameter (e.g., "1024x1024")
418
- provider: The provider name (e.g., "google", "openai")
419
- model: The model name
420
-
421
- Returns:
422
- Formatted size parameter appropriate for the provider
423
- """
424
- if provider.lower() == "google":
425
- # Google uses aspect ratios instead of pixel dimensions
426
- # Convert common pixel dimensions to aspect ratios
427
- size_to_aspect_ratio = {
428
- "1024x1024": "1:1",
429
- "512x512": "1:1",
430
- "256x256": "1:1",
431
- "1024x768": "4:3",
432
- "768x1024": "3:4",
433
- "1024x1536": "2:3",
434
- "1536x1024": "3:2",
435
- "1792x1024": "16:9",
436
- "1024x1792": "9:16",
437
- }
438
-
439
- # Check if size is already in aspect ratio format (contains a colon)
440
- if ":" in size:
441
- return size
442
-
443
- # Convert to aspect ratio if we have a mapping, otherwise use default 1:1
444
- return size_to_aspect_ratio.get(size, "1:1")
445
-
446
- # For other providers, return the original size
447
- return size
448
-
449
- def chat(
450
- self,
451
- messages: List[Dict[str, str]],
452
- model: str = DEFAULT_MODEL,
453
- temperature: float = 0.7,
454
- max_tokens: Optional[int] = None,
455
- stream: bool = False,
456
- **kwargs,
457
- ) -> Dict[str, Any]:
458
- """
459
- Generate a chat completion.
460
-
461
- Args:
462
- messages: List of messages in the conversation
463
- model: Model to use in the format "provider/model" (e.g., "openai/gpt-4o-mini")
464
- temperature: Sampling temperature
465
- max_tokens: Maximum number of tokens to generate
466
- stream: Whether to stream the response
467
- **kwargs: Additional parameters to pass to the API
468
-
469
- Returns:
470
- Response data
471
- """
472
- # Format the model string
473
- formatted_model = self._format_model_string(model)
474
-
475
- # Filter out problematic parameters
476
- filtered_kwargs = {}
477
- for key, value in kwargs.items():
478
- if key not in ["return_generator"]: # List of parameters to exclude
479
- filtered_kwargs[key] = value
480
-
481
- data = {
482
- "messages": messages,
483
- "model": formatted_model,
484
- "temperature": temperature,
485
- "max_tokens": max_tokens,
486
- "stream": stream,
487
- "additional_params": filtered_kwargs,
488
- }
489
-
490
- if stream:
491
- response = self._request("POST", CHAT_ENDPOINT, data, stream=True)
492
- return self._handle_streaming_response(response)
493
- else:
494
- return self._request("POST", CHAT_ENDPOINT, data)
495
-
496
- def completion(
497
- self,
498
- prompt: str,
499
- model: str = DEFAULT_MODEL,
500
- temperature: float = 0.7,
501
- max_tokens: Optional[int] = None,
502
- stream: bool = False,
503
- **kwargs,
504
- ) -> Dict[str, Any]:
505
- """
506
- Generate a text completion.
507
-
508
- Args:
509
- prompt: Text prompt
510
- model: Model to use in the format "provider/model" (e.g., "openai/gpt-4o-mini")
511
- temperature: Sampling temperature
512
- max_tokens: Maximum number of tokens to generate
513
- stream: Whether to stream the response
514
- **kwargs: Additional parameters to pass to the API
515
-
516
- Returns:
517
- Response data
518
- """
519
- # Format the model string
520
- formatted_model = self._format_model_string(model)
521
-
522
- # Filter out problematic parameters
523
- filtered_kwargs = {}
524
- for key, value in kwargs.items():
525
- if key not in ["return_generator"]: # List of parameters to exclude
526
- filtered_kwargs[key] = value
527
-
528
- data = {
529
- "prompt": prompt,
530
- "model": formatted_model,
531
- "temperature": temperature,
532
- "max_tokens": max_tokens,
533
- "stream": stream,
534
- "additional_params": filtered_kwargs,
535
- }
536
-
537
- if stream:
538
- response = self._request("POST", COMPLETION_ENDPOINT, data, stream=True)
539
- return self._handle_streaming_response(response)
540
- else:
541
- return self._request("POST", COMPLETION_ENDPOINT, data)
542
-
543
- def embeddings(
544
- self,
545
- text: Union[str, List[str]],
546
- model: str = DEFAULT_EMBEDDING_MODEL,
547
- **kwargs,
548
- ) -> Dict[str, Any]:
549
- """
550
- Generate embeddings for text.
551
-
552
- Args:
553
- text: Text to embed (string or list of strings)
554
- model: Model to use in the format "provider/model" (e.g., "openai/text-embedding-ada-002")
555
- **kwargs: Additional parameters to pass to the API
556
-
557
- Returns:
558
- Response data with embeddings
559
- """
560
- # Format the model string
561
- formatted_model = self._format_model_string(model)
562
-
563
- # Filter out problematic parameters
564
- filtered_kwargs = {}
565
- for key, value in kwargs.items():
566
- if key not in ["return_generator"]: # List of parameters to exclude
567
- filtered_kwargs[key] = value
568
-
569
- data = {
570
- "text": text if isinstance(text, list) else [text],
571
- "model": formatted_model,
572
- "additional_params": filtered_kwargs,
573
- }
574
-
575
- return self._request("POST", EMBEDDING_ENDPOINT, data)
576
-
577
- def images(
578
- self,
579
- prompt: str,
580
- model: str = DEFAULT_IMAGE_MODEL,
581
- size: Optional[str] = None,
582
- n: Optional[int] = None,
583
- quality: Optional[str] = None,
584
- style: Optional[str] = None,
585
- # Standard parameters
586
- response_format: Optional[str] = None,
587
- user: Optional[str] = None,
588
- # OpenAI-specific parameters
589
- background: Optional[str] = None,
590
- moderation: Optional[str] = None,
591
- output_compression: Optional[int] = None,
592
- output_format: Optional[str] = None,
593
- # Google-specific parameters
594
- negative_prompt: Optional[str] = None,
595
- guidance_scale: Optional[float] = None,
596
- seed: Optional[int] = None,
597
- safety_filter_level: Optional[str] = None,
598
- person_generation: Optional[str] = None,
599
- include_safety_attributes: Optional[bool] = None,
600
- include_rai_reason: Optional[bool] = None,
601
- language: Optional[str] = None,
602
- output_mime_type: Optional[str] = None,
603
- add_watermark: Optional[bool] = None,
604
- enhance_prompt: Optional[bool] = None,
605
- # Google-specific direct parameters
606
- aspect_ratio: Optional[str] = None,
607
- **kwargs,
608
- ) -> Dict[str, Any]:
609
- """
610
- Generate images from a prompt.
611
-
612
- Args:
613
- prompt: Text prompt
614
- model: Model to use in the format "provider/model" (e.g., "openai/dall-e-3", "google/imagen-3.0-generate-002")
615
-
616
- # Provider-specific parameters - will only be included if explicitly provided
617
- # Note: Different providers support different parameters
618
- size: Image size - For OpenAI: "1024x1024", "512x512", etc. For Google: use aspect_ratio instead
619
- n: Number of images to generate
620
- quality: Image quality (e.g., "standard", "hd") - supported by some providers
621
- style: Image style (e.g., "vivid", "natural") - supported by some providers
622
-
623
- # Standard parameters
624
- response_format: Format of the response - "url" or "b64_json"
625
- user: A unique identifier for the end-user
626
-
627
- # OpenAI-specific parameters
628
- background: Background style - "transparent", "opaque", or "auto"
629
- moderation: Moderation level - "low" or "auto"
630
- output_compression: Compression quality for output images (0-100)
631
- output_format: Output format - "png", "jpeg", or "webp"
632
-
633
- # Google-specific parameters
634
- negative_prompt: Description of what to discourage in the generated images
635
- guidance_scale: Controls how much the model adheres to the prompt
636
- seed: Random seed for image generation
637
- safety_filter_level: Filter level for safety filtering
638
- person_generation: Controls generation of people ("dont_allow", "allow_adult", "allow_all")
639
- include_safety_attributes: Whether to report safety scores of generated images
640
- include_rai_reason: Whether to include filter reason if the image is filtered
641
- language: Language of the text in the prompt
642
- output_mime_type: MIME type of the generated image
643
- add_watermark: Whether to add a watermark to the generated images
644
- enhance_prompt: Whether to use prompt rewriting logic
645
- aspect_ratio: Aspect ratio for Google models (e.g., "1:1", "16:9") - preferred over size
646
-
647
- **kwargs: Additional parameters to pass to the API
648
-
649
- Returns:
650
- Response data with image URLs
651
- """
652
- # Format the model string
653
- formatted_model = self._format_model_string(model)
654
-
655
- # Extract provider and model name from model string if present
656
- provider = "openai" # Default provider
657
- model_name = model
658
- if "/" in model:
659
- provider, model_name = model.split("/", 1)
660
-
661
- # Filter out problematic parameters
662
- filtered_kwargs = {}
663
- for key, value in kwargs.items():
664
- if key not in ["return_generator"]: # List of parameters to exclude
665
- filtered_kwargs[key] = value
666
-
667
- # Create the base request data with only the required parameters
668
- data = {
669
- "prompt": prompt,
670
- "model": formatted_model,
671
- }
672
-
673
- # Add optional parameters only if they are explicitly provided
674
- if n is not None:
675
- data["n"] = n
676
-
677
- # Handle size/aspect_ratio parameters based on provider
678
- if provider.lower() == "google":
679
- # For Google, use aspect_ratio instead of size
680
- if aspect_ratio is not None:
681
- # Google's imagen-3 has specific supported aspect ratios
682
- if model_name == "imagen-3.0-generate-002" and aspect_ratio not in [
683
- "1:1",
684
- "3:4",
685
- "4:3",
686
- "9:16",
687
- "16:9",
688
- ]:
689
- aspect_ratio = "1:1" # Default to 1:1 if not supported
690
- data["aspect_ratio"] = aspect_ratio
691
- elif size is not None:
692
- # Convert size to aspect_ratio
693
- formatted_size = self._format_image_size_for_provider(
694
- size, provider, model_name
695
- )
696
- data["aspect_ratio"] = formatted_size
697
- else:
698
- # Default aspect_ratio for Google
699
- data["aspect_ratio"] = "1:1"
700
- elif provider.lower() == "xai":
701
- # xAI doesn't support size parameter - do not include it
702
- pass
703
- elif size is not None and provider.lower() != "xai":
704
- # For other providers (like OpenAI), use size as is
705
- data["size"] = size
706
-
707
- if quality is not None:
708
- data["quality"] = quality
709
- if style is not None:
710
- data["style"] = style
711
-
712
- # Add standard parameters if provided
713
- if response_format is not None:
714
- # Only add response_format if explicitly provided by the user
715
- data["response_format"] = response_format
716
-
717
- if user is not None:
718
- data["user"] = user
719
-
720
- # Add OpenAI-specific parameters if provided
721
- if background is not None:
722
- data["background"] = background
723
- if moderation is not None:
724
- data["moderation"] = moderation
725
- if output_compression is not None:
726
- data["output_compression"] = output_compression
727
- if output_format is not None:
728
- data["output_format"] = output_format
729
-
730
- # Add Google-specific parameters if provided
731
- if negative_prompt is not None:
732
- data["negative_prompt"] = negative_prompt
733
- if guidance_scale is not None:
734
- data["guidance_scale"] = guidance_scale
735
- if seed is not None:
736
- data["seed"] = seed
737
- if safety_filter_level is not None:
738
- data["safety_filter_level"] = safety_filter_level
739
- if person_generation is not None:
740
- data["person_generation"] = person_generation
741
- if include_safety_attributes is not None:
742
- data["include_safety_attributes"] = include_safety_attributes
743
- if include_rai_reason is not None:
744
- data["include_rai_reason"] = include_rai_reason
745
- if language is not None:
746
- data["language"] = language
747
- if output_mime_type is not None:
748
- data["output_mime_type"] = output_mime_type
749
- if add_watermark is not None:
750
- data["add_watermark"] = add_watermark
751
- if enhance_prompt is not None:
752
- data["enhance_prompt"] = enhance_prompt
753
-
754
- # Add any remaining parameters
755
- if filtered_kwargs:
756
- data["additional_params"] = filtered_kwargs
757
-
758
- # Special case handling for specific models and providers
759
- # Only include parameters supported by each model based on their JSON definitions
760
- if provider.lower() == "openai" and "gpt-image" in model_name.lower():
761
- # For OpenAI's gpt-image models, don't automatically add response_format
762
- if "response_format" in data and response_format is None:
763
- del data["response_format"]
764
-
765
- if provider.lower() == "xai" and "grok-2-image" in model_name.lower():
766
- # For xAI's grok-2-image models, ensure size is not included
767
- if "size" in data:
768
- del data["size"]
769
-
770
- # Clean up any parameters that shouldn't be sent to specific providers
771
- # This ensures we only send parameters that each provider supports
772
- supported_params = self._get_supported_parameters_for_model(
773
- provider, model_name
774
- )
775
- if supported_params:
776
- for param in list(data.keys()):
777
- if param not in ["prompt", "model"] and param not in supported_params:
778
- del data[param]
779
-
780
- return self._request("POST", IMAGE_ENDPOINT, data)
781
-
782
- def text_to_speech(
783
- self,
784
- input: str,
785
- model: str = DEFAULT_TTS_MODEL,
786
- voice: Optional[str] = None,
787
- response_format: Optional[str] = None,
788
- speed: Optional[float] = None,
789
- instructions: Optional[str] = None,
790
- **kwargs,
791
- ) -> Dict[str, Any]:
792
- """
793
- Generate audio from text using text-to-speech models.
794
-
795
- Args:
796
- input: The text to generate audio for
797
- model: Model to use in the format "provider/model" (e.g., "openai/tts-1")
798
- voice: Voice to use for the audio generation (provider-specific)
799
- response_format: Format of the audio response (e.g., "mp3", "opus", "aac", "flac")
800
- speed: Speed of the generated audio (0.25 to 4.0)
801
- instructions: Optional instructions for the TTS generation
802
- **kwargs: Additional parameters to pass to the API
803
-
804
- Returns:
805
- Response data with audio content
806
-
807
- Examples:
808
- Basic usage:
809
- response = client.text_to_speech("Hello, world!")
810
-
811
- With specific voice and format:
812
- response = client.text_to_speech(
813
- "Hello, world!",
814
- model="openai/tts-1",
815
- voice="alloy",
816
- response_format="mp3",
817
- speed=1.0
818
- )
819
-
820
- For different providers (when available):
821
- response = client.text_to_speech(
822
- "Hello, world!",
823
- model="provider/model-name",
824
- voice="provider-specific-voice"
825
- )
826
- """
827
- # Format the model string
828
- formatted_model = self._format_model_string(model)
829
-
830
- # Filter out problematic parameters
831
- filtered_kwargs = {}
832
- for key, value in kwargs.items():
833
- if key not in ["return_generator"]: # List of parameters to exclude
834
- filtered_kwargs[key] = value
835
-
836
- # Create the base request data with required parameters
837
- data = {
838
- "input": input,
839
- "model": formatted_model,
840
- }
841
-
842
- # Add optional parameters only if they are explicitly provided
843
- if voice is not None:
844
- data["voice"] = voice
845
- if response_format is not None:
846
- data["response_format"] = response_format
847
- if speed is not None:
848
- data["speed"] = speed
849
- if instructions is not None and instructions.strip():
850
- data["instructions"] = instructions
851
-
852
- # Add any additional parameters from kwargs
853
- if filtered_kwargs:
854
- data["additional_params"] = filtered_kwargs
855
-
856
- return self._request("POST", TTS_ENDPOINT, data)
857
-
858
- def _get_supported_parameters_for_model(
859
- self, provider: str, model_name: str
860
- ) -> List[str]:
861
- """
862
- Get the list of supported parameters for a specific model.
863
- This helps avoid sending unsupported parameters to providers.
864
-
865
- Args:
866
- provider: The provider name (e.g., 'openai', 'google', 'xai')
867
- model_name: The model name (e.g., 'gpt-image-1', 'imagen-3.0-generate-002')
868
-
869
- Returns:
870
- List of parameter names supported by the model
871
- """
872
- # Define supported parameters for specific models
873
- if provider.lower() == "openai" and "gpt-image" in model_name.lower():
874
- return [
875
- "prompt",
876
- "size",
877
- "quality",
878
- "n",
879
- "user",
880
- "background",
881
- "moderation",
882
- "output_compression",
883
- "output_format",
884
- "style",
885
- ]
886
-
887
- elif provider.lower() == "google" and "imagen" in model_name.lower():
888
- return [
889
- "prompt",
890
- "n",
891
- "negative_prompt",
892
- "aspect_ratio",
893
- "guidance_scale",
894
- "seed",
895
- "safety_filter_level",
896
- "person_generation",
897
- "include_safety_attributes",
898
- "include_rai_reason",
899
- "language",
900
- "output_mime_type",
901
- "output_compression_quality",
902
- "add_watermark",
903
- "enhance_prompt",
904
- "response_format",
905
- ]
906
-
907
- elif provider.lower() == "xai" and "grok-2-image" in model_name.lower():
908
- return ["prompt", "n", "response_format"]
909
-
910
- # Default case - allow all parameters
911
- return []
912
-
913
- def models(self, provider: Optional[str] = None) -> Dict[str, Any]:
914
- """
915
- Get available models.
916
-
917
- Args:
918
- provider: Provider to filter by
919
-
920
- Returns:
921
- List of available models with pricing information
922
- """
923
- endpoint = MODEL_ENDPOINT
924
- if provider:
925
- endpoint = f"{MODEL_ENDPOINT}/{provider}"
926
-
927
- return self._request("GET", endpoint)
928
-
929
- def get_model_info(self, provider: str, model: str) -> Dict[str, Any]:
930
- """
931
- Get information about a specific model.
932
-
933
- Args:
934
- provider: Provider ID
935
- model: Model ID
936
-
937
- Returns:
938
- Model information including pricing
939
- """
940
- return self._request("GET", f"{MODEL_ENDPOINT}/{provider}/{model}")
941
-
942
- def get_usage(self) -> Dict[str, Any]:
943
- """
944
- Get usage statistics for the current user.
945
-
946
- Returns:
947
- Usage statistics
948
- """
949
- return self._request("GET", USAGE_ENDPOINT)
950
-
951
- def test_connection(self) -> Dict[str, Any]:
952
- """
953
- Test the connection to the server and return server status information.
954
-
955
- This method can be used to diagnose connection issues and verify that
956
- the server is accessible and properly configured.
957
-
958
- Returns:
959
- Dictionary containing server status information
960
- """
961
- try:
962
- # Try to access the base URL
963
- response = self.session.get(self.base_url, timeout=self.timeout)
964
-
965
- # Try to get server info if available
966
- server_info = {}
967
- try:
968
- if response.headers.get("Content-Type", "").startswith(
969
- "application/json"
970
- ):
971
- server_info = response.json()
972
- except:
973
- pass
974
-
975
- return {
976
- "status": "connected",
977
- "url": self.base_url,
978
- "status_code": response.status_code,
979
- "server_info": server_info,
980
- "headers": dict(response.headers),
981
- }
982
- except requests.RequestException as e:
983
- return {
984
- "status": "error",
985
- "url": self.base_url,
986
- "error": str(e),
987
- "error_type": type(e).__name__,
988
- }
989
-
990
- def diagnose_request(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]:
991
- """
992
- Diagnose potential issues with a request before sending it to the server.
993
-
994
- This method checks for common issues like malformed model strings,
995
- invalid message formats, or missing required parameters.
996
-
997
- Args:
998
- endpoint: API endpoint
999
- data: Request data
1000
-
1001
- Returns:
1002
- Dictionary with diagnosis results
1003
- """
1004
- issues = []
1005
- warnings = []
1006
-
1007
- # Check if this is a chat request
1008
- if endpoint == CHAT_ENDPOINT:
1009
- # Check model format
1010
- if "model" in data:
1011
- model = data["model"]
1012
- # Check if the model is already formatted as JSON
1013
- if (
1014
- isinstance(model, str)
1015
- and model.startswith("{")
1016
- and model.endswith("}")
1017
- ):
1018
- try:
1019
- model_json = json.loads(model)
1020
- if (
1021
- not isinstance(model_json, dict)
1022
- or "provider" not in model_json
1023
- or "model" not in model_json
1024
- ):
1025
- issues.append(f"Invalid model JSON format: {model}")
1026
- except json.JSONDecodeError:
1027
- issues.append(f"Invalid model JSON format: {model}")
1028
- elif not isinstance(model, str):
1029
- issues.append(f"Model must be a string, got {type(model).__name__}")
1030
- elif "/" not in model:
1031
- issues.append(
1032
- f"Model '{model}' is missing provider prefix (should be 'provider/model')"
1033
- )
1034
- else:
1035
- provider, model_name = model.split("/", 1)
1036
- if not provider or not model_name:
1037
- issues.append(
1038
- f"Invalid model format: '{model}'. Should be 'provider/model'"
1039
- )
1040
- else:
1041
- warnings.append("No model specified, will use default model")
1042
-
1043
- # Check messages format
1044
- if "messages" in data:
1045
- messages = data["messages"]
1046
- if not isinstance(messages, list):
1047
- issues.append(
1048
- f"Messages must be a list, got {type(messages).__name__}"
1049
- )
1050
- elif not messages:
1051
- issues.append("Messages list is empty")
1052
- else:
1053
- for i, msg in enumerate(messages):
1054
- if not isinstance(msg, dict):
1055
- issues.append(
1056
- f"Message {i} must be a dictionary, got {type(msg).__name__}"
1057
- )
1058
- elif "role" not in msg:
1059
- issues.append(f"Message {i} is missing 'role' field")
1060
- elif "content" not in msg:
1061
- issues.append(f"Message {i} is missing 'content' field")
1062
- else:
1063
- issues.append("No messages specified")
1064
-
1065
- # Check if this is a completion request
1066
- elif endpoint == COMPLETION_ENDPOINT:
1067
- # Check model format (same as chat)
1068
- if "model" in data:
1069
- model = data["model"]
1070
- if not isinstance(model, str):
1071
- issues.append(f"Model must be a string, got {type(model).__name__}")
1072
- elif "/" not in model:
1073
- issues.append(
1074
- f"Model '{model}' is missing provider prefix (should be 'provider/model')"
1075
- )
1076
- else:
1077
- warnings.append("No model specified, will use default model")
1078
-
1079
- # Check prompt
1080
- if "prompt" not in data:
1081
- issues.append("No prompt specified")
1082
- elif not isinstance(data["prompt"], str):
1083
- issues.append(
1084
- f"Prompt must be a string, got {type(data['prompt']).__name__}"
1085
- )
1086
-
1087
- # Return diagnosis results
1088
- return {
1089
- "endpoint": endpoint,
1090
- "issues": issues,
1091
- "warnings": warnings,
1092
- "is_valid": len(issues) == 0,
1093
- "data": data,
1094
- }
1095
-
1096
- def _handle_streaming_response(self, response):
1097
- """
1098
- Handle a streaming response.
1099
-
1100
- Args:
1101
- response: Streaming response
1102
-
1103
- Returns:
1104
- Generator yielding response chunks
1105
- """
1106
- try:
1107
- for line in response.iter_lines():
1108
- if line:
1109
- line = line.decode("utf-8")
1110
- if line.startswith("data: "):
1111
- data = line[6:]
1112
- if data == "[DONE]":
1113
- break
1114
- try:
1115
- # Parse JSON chunk
1116
- chunk = json.loads(data)
1117
-
1118
- # For chat responses, return the processed chunk
1119
- # with data field for backward compatibility
1120
- if "choices" in chunk:
1121
- # For delta responses (streaming)
1122
- choice = chunk["choices"][0]
1123
- if "delta" in choice and "content" in choice["delta"]:
1124
- # Add a data field for backward compatibility
1125
- chunk["data"] = choice["delta"]["content"]
1126
- # For text responses (completion)
1127
- elif "text" in choice:
1128
- chunk["data"] = choice["text"]
1129
-
1130
- yield chunk
1131
- except json.JSONDecodeError:
1132
- # For raw text responses
1133
- yield {"data": data}
1134
- finally:
1135
- response.close()
1136
-
1137
- def close(self):
1138
- """Close the session."""
1139
- self.session.close()
1140
-
1141
- def __enter__(self):
1142
- """Enter context manager."""
1143
- return self
1144
-
1145
- def __exit__(self, exc_type, exc_val, exc_tb):
1146
- """Exit context manager."""
1147
- self.close()
1148
-
1149
- def set_base_url(self, base_url: str) -> None:
1150
- """
1151
- Set a new base URL for the API.
1152
-
1153
- Args:
1154
- base_url: New base URL for the API.
1155
- """
1156
- self.base_url = base_url
1157
- logger.debug(f"Base URL set to {base_url}")
1158
-
1159
-
1160
- IndoxRouter = Client
1
+ """
2
+ IndoxRouter Client Module
3
+
4
+ This module provides a client for interacting with the IndoxRouter API, which serves as a unified
5
+ interface to multiple AI providers and models. The client handles authentication, rate limiting,
6
+ error handling, and provides a standardized response format across different AI services.
7
+
8
+ IMPORTANT: The IndoxRouter server now supports only cookie-based authentication. This client
9
+ automatically handles authentication by exchanging your API key for a JWT token through the login endpoint.
10
+
11
+ The Client class offers methods for:
12
+ - Authentication and session management
13
+ - Making API requests with automatic token refresh
14
+ - Accessing AI capabilities: chat completions, text completions, embeddings, image generation, and text-to-speech
15
+ - Retrieving information about available providers and models
16
+ - Monitoring usage statistics and credit consumption
17
+ - BYOK (Bring Your Own Key) support for using your own provider API keys
18
+
19
+ Usage example:
20
+ ```python
21
+ from indoxRouter import Client
22
+
23
+ # Initialize client with API key
24
+ client = Client(api_key="your_api_key")
25
+
26
+ # Get available models
27
+ models = client.models()
28
+
29
+ # Generate a chat completion
30
+ response = client.chat([
31
+ {"role": "system", "content": "You are a helpful assistant."},
32
+ {"role": "user", "content": "Tell me a joke."}
33
+ ], model="openai/gpt-4o-mini")
34
+
35
+ # Generate text embeddings
36
+ embeddings = client.embeddings("This is a sample text", model="openai/text-embedding-ada-002")
37
+
38
+ # Generate text-to-speech audio
39
+ audio = client.text_to_speech("Hello, welcome to IndoxRouter!", model="openai/tts-1", voice="alloy")
40
+
41
+ # Using BYOK (Bring Your Own Key)
42
+ response = client.chat([
43
+ {"role": "user", "content": "Hello!"}
44
+ ], model="openai/gpt-4", byok_api_key="sk-your-openai-key-here")
45
+
46
+ # Clean up resources when done
47
+ client.close()
48
+ ```
49
+
50
+ The client can also be used as a context manager:
51
+ ```python
52
+ with Client(api_key="your_api_key") as client:
53
+ response = client.chat([{"role": "user", "content": "Hello!"}], model="openai/gpt-4o-mini")
54
+ ```
55
+
56
+ BYOK (Bring Your Own Key) Support:
57
+ The client supports BYOK, allowing you to use your own API keys for AI providers:
58
+
59
+ - No credit deduction from your IndoxRouter account
60
+ - No rate limiting from the platform
61
+ - Direct provider access with your own API keys
62
+ - Cost control - you pay providers directly at their rates
63
+
64
+ Example:
65
+ response = client.chat(
66
+ messages=[{"role": "user", "content": "Hello!"}],
67
+ model="openai/gpt-4",
68
+ byok_api_key="sk-your-openai-key-here"
69
+ )
70
+ """
71
+
72
+ import os
73
+ import logging
74
+ from datetime import datetime, timedelta
75
+ from typing import Dict, List, Any, Optional, Union
76
+ import requests
77
+ import json
78
+
79
+ from .exceptions import (
80
+ AuthenticationError,
81
+ NetworkError,
82
+ ProviderNotFoundError,
83
+ ModelNotFoundError,
84
+ ModelNotAvailableError,
85
+ InvalidParametersError,
86
+ RateLimitError,
87
+ ProviderError,
88
+ RequestError,
89
+ InsufficientCreditsError,
90
+ ValidationError,
91
+ APIError,
92
+ )
93
+ from .constants import (
94
+ DEFAULT_BASE_URL,
95
+ DEFAULT_TIMEOUT,
96
+ DEFAULT_MODEL,
97
+ DEFAULT_EMBEDDING_MODEL,
98
+ DEFAULT_IMAGE_MODEL,
99
+ DEFAULT_TTS_MODEL,
100
+ CHAT_ENDPOINT,
101
+ COMPLETION_ENDPOINT,
102
+ EMBEDDING_ENDPOINT,
103
+ IMAGE_ENDPOINT,
104
+ TTS_ENDPOINT,
105
+ MODEL_ENDPOINT,
106
+ USAGE_ENDPOINT,
107
+ USE_COOKIES,
108
+ )
109
+
110
+ logger = logging.getLogger(__name__)
111
+
112
+
113
+ class Client:
114
+ """
115
+ Client for interacting with the IndoxRouter API.
116
+ """
117
+
118
+ def __init__(
119
+ self,
120
+ api_key: Optional[str] = None,
121
+ timeout: int = DEFAULT_TIMEOUT,
122
+ base_url: Optional[str] = None,
123
+ ):
124
+ """
125
+ Initialize the client.
126
+
127
+ Args:
128
+ api_key: API key for authentication. If not provided, the client will look for the
129
+ INDOX_ROUTER_API_KEY environment variable.
130
+ timeout: Request timeout in seconds.
131
+ base_url: Base URL for the API. If not provided, the client will use the default URL.
132
+ """
133
+
134
+ use_cookies = USE_COOKIES
135
+ self.api_key = api_key or os.environ.get("INDOX_ROUTER_API_KEY")
136
+ if not self.api_key:
137
+ raise ValueError(
138
+ "API key must be provided either as an argument or as the INDOX_ROUTER_API_KEY environment variable."
139
+ )
140
+
141
+ self.base_url = base_url if base_url is not None else DEFAULT_BASE_URL
142
+
143
+ if self.base_url.endswith("/"):
144
+ self.base_url = self.base_url.rstrip("/")
145
+
146
+ self.timeout = timeout
147
+ self.use_cookies = use_cookies
148
+ self.session = requests.Session()
149
+
150
+ # Authenticate and get JWT tokens
151
+ self._authenticate()
152
+
153
+ def _authenticate(self):
154
+ """
155
+ Authenticate with the server and get JWT tokens.
156
+ This uses the /auth/token endpoint to get JWT tokens using the API key.
157
+ """
158
+ try:
159
+ # First try with the dedicated API key endpoint
160
+ logger.debug("Authenticating with dedicated API key endpoint")
161
+ response = self.session.post(
162
+ f"{self.base_url}/api/v1/auth/api-key",
163
+ headers={"X-API-Key": self.api_key},
164
+ timeout=self.timeout,
165
+ )
166
+
167
+ if response.status_code != 200:
168
+ # If dedicated endpoint fails, try using the API key as a username
169
+ logger.debug("API key endpoint failed, trying with API key as username")
170
+ response = self.session.post(
171
+ f"{self.base_url}/api/v1/auth/token",
172
+ data={
173
+ "username": self.api_key,
174
+ "password": self.api_key, # Try using API key as both username and password
175
+ },
176
+ timeout=self.timeout,
177
+ )
178
+
179
+ if response.status_code != 200:
180
+ # Try one more method - the token endpoint with different format
181
+ logger.debug("Trying with API key as token parameter")
182
+ response = self.session.post(
183
+ f"{self.base_url}/api/v1/auth/token",
184
+ data={
185
+ "username": "pip_client",
186
+ "password": self.api_key,
187
+ },
188
+ timeout=self.timeout,
189
+ )
190
+
191
+ if response.status_code != 200:
192
+ error_data = {}
193
+ try:
194
+ error_data = response.json()
195
+ except:
196
+ error_data = {"detail": response.text}
197
+
198
+ raise AuthenticationError(
199
+ f"Authentication failed: {error_data.get('detail', 'Unknown error')}"
200
+ )
201
+
202
+ # Check if we have a token in the response body
203
+ try:
204
+ response_data = response.json()
205
+ if "access_token" in response_data:
206
+ # Store token in the session object for later use
207
+ self.access_token = response_data["access_token"]
208
+ logger.debug("Retrieved access token from response body")
209
+ except:
210
+ # If we couldn't parse JSON, that's fine - we'll rely on cookies
211
+ logger.debug("No token found in response body, will rely on cookies")
212
+
213
+ # At this point, the cookies should be set in the session
214
+ logger.debug("Authentication successful")
215
+
216
+ # Check if we have the cookies we need
217
+ if "access_token" not in self.session.cookies:
218
+ logger.warning(
219
+ "Authentication succeeded but no access_token cookie was set"
220
+ )
221
+
222
+ except requests.RequestException as e:
223
+ logger.error(f"Authentication request failed: {str(e)}")
224
+ raise NetworkError(f"Network error during authentication: {str(e)}")
225
+
226
+ def _get_domain(self):
227
+ """
228
+ Extract domain from the base URL for cookie setting.
229
+ """
230
+ try:
231
+ from urllib.parse import urlparse
232
+
233
+ parsed_url = urlparse(self.base_url)
234
+ return parsed_url.netloc
235
+ except Exception:
236
+ # If parsing fails, return a default value
237
+ return ""
238
+
239
+ def enable_debug(self, level=logging.DEBUG):
240
+ """
241
+ Enable debug logging for the client.
242
+
243
+ Args:
244
+ level: Logging level (default: logging.DEBUG)
245
+ """
246
+ handler = logging.StreamHandler()
247
+ handler.setFormatter(
248
+ logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
249
+ )
250
+ logger.addHandler(handler)
251
+ logger.setLevel(level)
252
+ logger.debug("Debug logging enabled")
253
+
254
+ def _request(
255
+ self,
256
+ method: str,
257
+ endpoint: str,
258
+ data: Optional[Dict[str, Any]] = None,
259
+ stream: bool = False,
260
+ ) -> Any:
261
+ """
262
+ Make a request to the API.
263
+
264
+ Args:
265
+ method: HTTP method (GET, POST, etc.)
266
+ endpoint: API endpoint
267
+ data: Request data
268
+ stream: Whether to stream the response
269
+
270
+ Returns:
271
+ Response data
272
+ """
273
+ # Add API version prefix if not already present
274
+ if not endpoint.startswith("api/v1/") and not endpoint.startswith("/api/v1/"):
275
+ endpoint = f"api/v1/{endpoint}"
276
+
277
+ # Remove any leading slash for consistent URL construction
278
+ if endpoint.startswith("/"):
279
+ endpoint = endpoint[1:]
280
+
281
+ url = f"{self.base_url}/{endpoint}"
282
+ headers = {"Content-Type": "application/json"}
283
+
284
+ # Add Authorization header if we have an access token
285
+ if hasattr(self, "access_token") and self.access_token:
286
+ headers["Authorization"] = f"Bearer {self.access_token}"
287
+
288
+ # logger.debug(f"Making {method} request to {url}")
289
+ # if data:
290
+ # logger.debug(f"Request data: {json.dumps(data, indent=2)}")
291
+
292
+ # Diagnose potential issues with the request
293
+ if method == "POST" and data:
294
+ diagnosis = self.diagnose_request(endpoint, data)
295
+ if not diagnosis["is_valid"]:
296
+ issues_str = "\n".join([f"- {issue}" for issue in diagnosis["issues"]])
297
+ logger.warning(f"Request validation issues:\n{issues_str}")
298
+ # We'll still send the request, but log the issues
299
+
300
+ try:
301
+ response = self.session.request(
302
+ method,
303
+ url,
304
+ headers=headers,
305
+ json=data,
306
+ timeout=self.timeout,
307
+ stream=stream,
308
+ )
309
+
310
+ if stream:
311
+ return response
312
+
313
+ # Check if we need to reauthenticate (401 Unauthorized)
314
+ if response.status_code == 401:
315
+ logger.debug("Received 401, attempting to reauthenticate")
316
+ self._authenticate()
317
+
318
+ # Update Authorization header with new token if available
319
+ if hasattr(self, "access_token") and self.access_token:
320
+ headers["Authorization"] = f"Bearer {self.access_token}"
321
+
322
+ # Retry the request after reauthentication
323
+ response = self.session.request(
324
+ method,
325
+ url,
326
+ headers=headers,
327
+ json=data,
328
+ timeout=self.timeout,
329
+ stream=stream,
330
+ )
331
+
332
+ if stream:
333
+ return response
334
+
335
+ response.raise_for_status()
336
+ return response.json()
337
+ except requests.HTTPError as e:
338
+ error_data = {}
339
+ try:
340
+ error_data = e.response.json()
341
+ logger.error(f"HTTP error response: {json.dumps(error_data, indent=2)}")
342
+ except (ValueError, AttributeError):
343
+ error_data = {"detail": str(e)}
344
+ logger.error(f"HTTP error (no JSON response): {str(e)}")
345
+
346
+ status_code = getattr(e.response, "status_code", 500)
347
+ error_message = error_data.get("detail", str(e))
348
+
349
+ if status_code == 401:
350
+ raise AuthenticationError(f"Authentication failed: {error_message}")
351
+ elif status_code == 404:
352
+ if "provider" in error_message.lower():
353
+ raise ProviderNotFoundError(error_message)
354
+ elif "model" in error_message.lower():
355
+ # Check if it's a model not found vs model not available
356
+ if (
357
+ "not supported" in error_message.lower()
358
+ or "disabled" in error_message.lower()
359
+ or "unavailable" in error_message.lower()
360
+ ):
361
+ raise ModelNotAvailableError(error_message)
362
+ else:
363
+ raise ModelNotFoundError(error_message)
364
+ else:
365
+ raise APIError(f"Resource not found: {error_message} (URL: {url})")
366
+ elif status_code == 429:
367
+ raise RateLimitError(f"Rate limit exceeded: {error_message}")
368
+ elif status_code == 400:
369
+ # Check if it's a validation error or invalid parameters
370
+ if (
371
+ "validation" in error_message.lower()
372
+ or "invalid format" in error_message.lower()
373
+ ):
374
+ raise ValidationError(f"Request validation failed: {error_message}")
375
+ else:
376
+ raise InvalidParametersError(f"Invalid parameters: {error_message}")
377
+ elif status_code == 402:
378
+ raise InsufficientCreditsError(f"Insufficient credits: {error_message}")
379
+ elif status_code == 422:
380
+ # Unprocessable Entity - typically validation errors
381
+ raise ValidationError(f"Request validation failed: {error_message}")
382
+ elif status_code == 503:
383
+ # Service Unavailable - model might be temporarily unavailable
384
+ if "model" in error_message.lower():
385
+ raise ModelNotAvailableError(
386
+ f"Model temporarily unavailable: {error_message}"
387
+ )
388
+ else:
389
+ raise APIError(f"Service unavailable: {error_message}")
390
+ elif status_code == 500:
391
+ # Provide more detailed information for server errors
392
+ error_detail = error_data.get("detail", "No details provided")
393
+ # Include the request data in the error message for better debugging
394
+ request_data_str = json.dumps(data, indent=2) if data else "None"
395
+ raise RequestError(
396
+ f"Server error (500): {error_detail}. URL: {url}.\n"
397
+ f"Request data: {request_data_str}\n"
398
+ f"This may indicate an issue with the server configuration or a problem with the provider service."
399
+ )
400
+ elif status_code >= 400 and status_code < 500:
401
+ # Client errors
402
+ raise APIError(f"Client error ({status_code}): {error_message}")
403
+ else:
404
+ # Server errors
405
+ raise RequestError(f"Server error ({status_code}): {error_message}")
406
+ except requests.RequestException as e:
407
+ logger.error(f"Request exception: {str(e)}")
408
+ raise NetworkError(f"Network error: {str(e)}")
409
+
410
+ def _format_model_string(self, model: str) -> str:
411
+ """
412
+ Format the model string in a way that the server expects.
413
+
414
+ The server might be expecting a different format than "provider/model".
415
+ This method handles different formatting requirements.
416
+
417
+ Args:
418
+ model: Model string in the format "provider/model"
419
+
420
+ Returns:
421
+ Formatted model string
422
+ """
423
+ if not model or "/" not in model:
424
+ return model
425
+
426
+ # The standard format is "provider/model"
427
+ # But the server might be expecting something different
428
+ provider, model_name = model.split("/", 1)
429
+
430
+ # For now, return the original format as it seems the server
431
+ # is having issues with JSON formatted model strings
432
+ return model
433
+
434
+ def _format_image_size_for_provider(
435
+ self, size: str, provider: str, model: str
436
+ ) -> str:
437
+ """
438
+ Format the image size parameter based on the provider's requirements.
439
+
440
+ Google requires aspect ratios like "1:1", "4:3", etc. while OpenAI uses pixel dimensions
441
+ like "1024x1024", "512x512", etc.
442
+
443
+ Args:
444
+ size: The size parameter (e.g., "1024x1024")
445
+ provider: The provider name (e.g., "google", "openai")
446
+ model: The model name
447
+
448
+ Returns:
449
+ Formatted size parameter appropriate for the provider
450
+ """
451
+ if provider.lower() == "google":
452
+ # Google uses aspect ratios instead of pixel dimensions
453
+ # Convert common pixel dimensions to aspect ratios
454
+ size_to_aspect_ratio = {
455
+ "1024x1024": "1:1",
456
+ "512x512": "1:1",
457
+ "256x256": "1:1",
458
+ "1024x768": "4:3",
459
+ "768x1024": "3:4",
460
+ "1024x1536": "2:3",
461
+ "1536x1024": "3:2",
462
+ "1792x1024": "16:9",
463
+ "1024x1792": "9:16",
464
+ }
465
+
466
+ # Check if size is already in aspect ratio format (contains a colon)
467
+ if ":" in size:
468
+ return size
469
+
470
+ # Convert to aspect ratio if we have a mapping, otherwise use default 1:1
471
+ return size_to_aspect_ratio.get(size, "1:1")
472
+
473
+ # For other providers, return the original size
474
+ return size
475
+
476
+ def chat(
477
+ self,
478
+ messages: List[Dict[str, str]],
479
+ model: str = DEFAULT_MODEL,
480
+ temperature: float = 0.7,
481
+ max_tokens: Optional[int] = None,
482
+ stream: bool = False,
483
+ byok_api_key: Optional[str] = None,
484
+ **kwargs,
485
+ ) -> Dict[str, Any]:
486
+ """
487
+ Generate a chat completion.
488
+
489
+ Args:
490
+ messages: List of messages in the conversation
491
+ model: Model to use in the format "provider/model" (e.g., "openai/gpt-4o-mini")
492
+ temperature: Sampling temperature
493
+ max_tokens: Maximum number of tokens to generate
494
+ stream: Whether to stream the response
495
+ byok_api_key: Your own API key for the provider (BYOK - Bring Your Own Key)
496
+ **kwargs: Additional parameters to pass to the API
497
+
498
+ Returns:
499
+ Response data
500
+ """
501
+ # Format the model string
502
+ formatted_model = self._format_model_string(model)
503
+
504
+ # Filter out problematic parameters
505
+ filtered_kwargs = {}
506
+ for key, value in kwargs.items():
507
+ if key not in ["return_generator"]: # List of parameters to exclude
508
+ filtered_kwargs[key] = value
509
+
510
+ data = {
511
+ "messages": messages,
512
+ "model": formatted_model,
513
+ "temperature": temperature,
514
+ "max_tokens": max_tokens,
515
+ "stream": stream,
516
+ "byok_api_key": byok_api_key,
517
+ "additional_params": filtered_kwargs,
518
+ }
519
+
520
+ if stream:
521
+ response = self._request("POST", CHAT_ENDPOINT, data, stream=True)
522
+ return self._handle_streaming_response(response)
523
+ else:
524
+ return self._request("POST", CHAT_ENDPOINT, data)
525
+
526
+ def completion(
527
+ self,
528
+ prompt: str,
529
+ model: str = DEFAULT_MODEL,
530
+ temperature: float = 0.7,
531
+ max_tokens: Optional[int] = None,
532
+ stream: bool = False,
533
+ byok_api_key: Optional[str] = None,
534
+ **kwargs,
535
+ ) -> Dict[str, Any]:
536
+ """
537
+ Generate a text completion.
538
+
539
+ Args:
540
+ prompt: Text prompt
541
+ model: Model to use in the format "provider/model" (e.g., "openai/gpt-4o-mini")
542
+ temperature: Sampling temperature
543
+ max_tokens: Maximum number of tokens to generate
544
+ stream: Whether to stream the response
545
+ byok_api_key: Your own API key for the provider (BYOK - Bring Your Own Key)
546
+ **kwargs: Additional parameters to pass to the API
547
+
548
+ Returns:
549
+ Response data
550
+ """
551
+ # Format the model string
552
+ formatted_model = self._format_model_string(model)
553
+
554
+ # Filter out problematic parameters
555
+ filtered_kwargs = {}
556
+ for key, value in kwargs.items():
557
+ if key not in ["return_generator"]: # List of parameters to exclude
558
+ filtered_kwargs[key] = value
559
+
560
+ data = {
561
+ "prompt": prompt,
562
+ "model": formatted_model,
563
+ "temperature": temperature,
564
+ "max_tokens": max_tokens,
565
+ "stream": stream,
566
+ "byok_api_key": byok_api_key,
567
+ "additional_params": filtered_kwargs,
568
+ }
569
+
570
+ if stream:
571
+ response = self._request("POST", COMPLETION_ENDPOINT, data, stream=True)
572
+ return self._handle_streaming_response(response)
573
+ else:
574
+ return self._request("POST", COMPLETION_ENDPOINT, data)
575
+
576
+ def embeddings(
577
+ self,
578
+ text: Union[str, List[str]],
579
+ model: str = DEFAULT_EMBEDDING_MODEL,
580
+ byok_api_key: Optional[str] = None,
581
+ **kwargs,
582
+ ) -> Dict[str, Any]:
583
+ """
584
+ Generate embeddings for text.
585
+
586
+ Args:
587
+ text: Text to embed (string or list of strings)
588
+ model: Model to use in the format "provider/model" (e.g., "openai/text-embedding-ada-002")
589
+ byok_api_key: Your own API key for the provider (BYOK - Bring Your Own Key)
590
+ **kwargs: Additional parameters to pass to the API
591
+
592
+ Returns:
593
+ Response data with embeddings
594
+ """
595
+ # Format the model string
596
+ formatted_model = self._format_model_string(model)
597
+
598
+ # Filter out problematic parameters
599
+ filtered_kwargs = {}
600
+ for key, value in kwargs.items():
601
+ if key not in ["return_generator"]: # List of parameters to exclude
602
+ filtered_kwargs[key] = value
603
+
604
+ data = {
605
+ "text": text if isinstance(text, list) else [text],
606
+ "model": formatted_model,
607
+ "byok_api_key": byok_api_key,
608
+ "additional_params": filtered_kwargs,
609
+ }
610
+
611
+ return self._request("POST", EMBEDDING_ENDPOINT, data)
612
+
613
+ def images(
614
+ self,
615
+ prompt: str,
616
+ model: str = DEFAULT_IMAGE_MODEL,
617
+ size: Optional[str] = None,
618
+ n: Optional[int] = None,
619
+ quality: Optional[str] = None,
620
+ style: Optional[str] = None,
621
+ # Standard parameters
622
+ response_format: Optional[str] = None,
623
+ user: Optional[str] = None,
624
+ # OpenAI-specific parameters
625
+ background: Optional[str] = None,
626
+ moderation: Optional[str] = None,
627
+ output_compression: Optional[int] = None,
628
+ output_format: Optional[str] = None,
629
+ # Google-specific parameters
630
+ negative_prompt: Optional[str] = None,
631
+ guidance_scale: Optional[float] = None,
632
+ seed: Optional[int] = None,
633
+ safety_filter_level: Optional[str] = None,
634
+ person_generation: Optional[str] = None,
635
+ include_safety_attributes: Optional[bool] = None,
636
+ include_rai_reason: Optional[bool] = None,
637
+ language: Optional[str] = None,
638
+ output_mime_type: Optional[str] = None,
639
+ add_watermark: Optional[bool] = None,
640
+ enhance_prompt: Optional[bool] = None,
641
+ # Google-specific direct parameters
642
+ aspect_ratio: Optional[str] = None,
643
+ byok_api_key: Optional[str] = None,
644
+ **kwargs,
645
+ ) -> Dict[str, Any]:
646
+ """
647
+ Generate images from a prompt.
648
+
649
+ Args:
650
+ prompt: Text prompt
651
+ model: Model to use in the format "provider/model" (e.g., "openai/dall-e-3", "google/imagen-3.0-generate-002")
652
+
653
+ # Provider-specific parameters - will only be included if explicitly provided
654
+ # Note: Different providers support different parameters
655
+ size: Image size - For OpenAI: "1024x1024", "512x512", etc. For Google: use aspect_ratio instead
656
+ n: Number of images to generate
657
+ quality: Image quality (e.g., "standard", "hd") - supported by some providers
658
+ style: Image style (e.g., "vivid", "natural") - supported by some providers
659
+
660
+ # Standard parameters
661
+ response_format: Format of the response - "url" or "b64_json"
662
+ user: A unique identifier for the end-user
663
+
664
+ # OpenAI-specific parameters
665
+ background: Background style - "transparent", "opaque", or "auto"
666
+ moderation: Moderation level - "low" or "auto"
667
+ output_compression: Compression quality for output images (0-100)
668
+ output_format: Output format - "png", "jpeg", or "webp"
669
+
670
+ # Google-specific parameters
671
+ negative_prompt: Description of what to discourage in the generated images
672
+ guidance_scale: Controls how much the model adheres to the prompt
673
+ seed: Random seed for image generation
674
+ safety_filter_level: Filter level for safety filtering
675
+ person_generation: Controls generation of people ("dont_allow", "allow_adult", "allow_all")
676
+ include_safety_attributes: Whether to report safety scores of generated images
677
+ include_rai_reason: Whether to include filter reason if the image is filtered
678
+ language: Language of the text in the prompt
679
+ output_mime_type: MIME type of the generated image
680
+ add_watermark: Whether to add a watermark to the generated images
681
+ enhance_prompt: Whether to use prompt rewriting logic
682
+ aspect_ratio: Aspect ratio for Google models (e.g., "1:1", "16:9") - preferred over size
683
+
684
+ byok_api_key: Your own API key for the provider (BYOK - Bring Your Own Key)
685
+
686
+ **kwargs: Additional parameters to pass to the API
687
+
688
+ Returns:
689
+ Response data with image URLs
690
+ """
691
+ # Format the model string
692
+ formatted_model = self._format_model_string(model)
693
+
694
+ # Extract provider and model name from model string if present
695
+ provider = "openai" # Default provider
696
+ model_name = model
697
+ if "/" in model:
698
+ provider, model_name = model.split("/", 1)
699
+
700
+ # Filter out problematic parameters
701
+ filtered_kwargs = {}
702
+ for key, value in kwargs.items():
703
+ if key not in ["return_generator"]: # List of parameters to exclude
704
+ filtered_kwargs[key] = value
705
+
706
+ # Create the base request data with only the required parameters
707
+ data = {
708
+ "prompt": prompt,
709
+ "model": formatted_model,
710
+ "byok_api_key": byok_api_key,
711
+ }
712
+
713
+ # Add optional parameters only if they are explicitly provided
714
+ if n is not None:
715
+ data["n"] = n
716
+
717
+ # Handle size/aspect_ratio parameters based on provider
718
+ if provider.lower() == "google":
719
+ # For Google, use aspect_ratio instead of size
720
+ if aspect_ratio is not None:
721
+ # Google's imagen-3 has specific supported aspect ratios
722
+ if model_name == "imagen-3.0-generate-002" and aspect_ratio not in [
723
+ "1:1",
724
+ "3:4",
725
+ "4:3",
726
+ "9:16",
727
+ "16:9",
728
+ ]:
729
+ aspect_ratio = "1:1" # Default to 1:1 if not supported
730
+ data["aspect_ratio"] = aspect_ratio
731
+ elif size is not None:
732
+ # Convert size to aspect_ratio
733
+ formatted_size = self._format_image_size_for_provider(
734
+ size, provider, model_name
735
+ )
736
+ data["aspect_ratio"] = formatted_size
737
+ else:
738
+ # Default aspect_ratio for Google
739
+ data["aspect_ratio"] = "1:1"
740
+ elif provider.lower() == "xai":
741
+ # xAI doesn't support size parameter - do not include it
742
+ pass
743
+ elif size is not None and provider.lower() != "xai":
744
+ # For other providers (like OpenAI), use size as is
745
+ data["size"] = size
746
+
747
+ if quality is not None:
748
+ data["quality"] = quality
749
+ if style is not None:
750
+ data["style"] = style
751
+
752
+ # Add standard parameters if provided
753
+ if response_format is not None:
754
+ # Only add response_format if explicitly provided by the user
755
+ data["response_format"] = response_format
756
+
757
+ if user is not None:
758
+ data["user"] = user
759
+
760
+ # Add OpenAI-specific parameters if provided
761
+ if background is not None:
762
+ data["background"] = background
763
+ if moderation is not None:
764
+ data["moderation"] = moderation
765
+ if output_compression is not None:
766
+ data["output_compression"] = output_compression
767
+ if output_format is not None:
768
+ data["output_format"] = output_format
769
+
770
+ # Add Google-specific parameters if provided
771
+ if negative_prompt is not None:
772
+ data["negative_prompt"] = negative_prompt
773
+ if guidance_scale is not None:
774
+ data["guidance_scale"] = guidance_scale
775
+ if seed is not None:
776
+ data["seed"] = seed
777
+ if safety_filter_level is not None:
778
+ data["safety_filter_level"] = safety_filter_level
779
+ if person_generation is not None:
780
+ data["person_generation"] = person_generation
781
+ if include_safety_attributes is not None:
782
+ data["include_safety_attributes"] = include_safety_attributes
783
+ if include_rai_reason is not None:
784
+ data["include_rai_reason"] = include_rai_reason
785
+ if language is not None:
786
+ data["language"] = language
787
+ if output_mime_type is not None:
788
+ data["output_mime_type"] = output_mime_type
789
+ if add_watermark is not None:
790
+ data["add_watermark"] = add_watermark
791
+ if enhance_prompt is not None:
792
+ data["enhance_prompt"] = enhance_prompt
793
+
794
+ # Add any remaining parameters
795
+ if filtered_kwargs:
796
+ data["additional_params"] = filtered_kwargs
797
+
798
+ # Special case handling for specific models and providers
799
+ # Only include parameters supported by each model based on their JSON definitions
800
+ if provider.lower() == "openai" and "gpt-image" in model_name.lower():
801
+ # For OpenAI's gpt-image models, don't automatically add response_format
802
+ if "response_format" in data and response_format is None:
803
+ del data["response_format"]
804
+
805
+ if provider.lower() == "xai" and "grok-2-image" in model_name.lower():
806
+ # For xAI's grok-2-image models, ensure size is not included
807
+ if "size" in data:
808
+ del data["size"]
809
+
810
+ # Clean up any parameters that shouldn't be sent to specific providers
811
+ # This ensures we only send parameters that each provider supports
812
+ supported_params = self._get_supported_parameters_for_model(
813
+ provider, model_name
814
+ )
815
+ if supported_params:
816
+ for param in list(data.keys()):
817
+ if param not in ["prompt", "model"] and param not in supported_params:
818
+ del data[param]
819
+
820
+ return self._request("POST", IMAGE_ENDPOINT, data)
821
+
822
+ def text_to_speech(
823
+ self,
824
+ input: str,
825
+ model: str = DEFAULT_TTS_MODEL,
826
+ voice: Optional[str] = None,
827
+ response_format: Optional[str] = None,
828
+ speed: Optional[float] = None,
829
+ instructions: Optional[str] = None,
830
+ byok_api_key: Optional[str] = None,
831
+ **kwargs,
832
+ ) -> Dict[str, Any]:
833
+ """
834
+ Generate audio from text using text-to-speech models.
835
+
836
+ Args:
837
+ input: The text to generate audio for
838
+ model: Model to use in the format "provider/model" (e.g., "openai/tts-1")
839
+ voice: Voice to use for the audio generation (provider-specific)
840
+ response_format: Format of the audio response (e.g., "mp3", "opus", "aac", "flac")
841
+ speed: Speed of the generated audio (0.25 to 4.0)
842
+ instructions: Optional instructions for the TTS generation
843
+ byok_api_key: Your own API key for the provider (BYOK - Bring Your Own Key)
844
+ **kwargs: Additional parameters to pass to the API
845
+
846
+ Returns:
847
+ Response data with audio content
848
+
849
+ Examples:
850
+ Basic usage:
851
+ response = client.text_to_speech("Hello, world!")
852
+
853
+ With specific voice and format:
854
+ response = client.text_to_speech(
855
+ "Hello, world!",
856
+ model="openai/tts-1",
857
+ voice="alloy",
858
+ response_format="mp3",
859
+ speed=1.0
860
+ )
861
+
862
+ For different providers (when available):
863
+ response = client.text_to_speech(
864
+ "Hello, world!",
865
+ model="provider/model-name",
866
+ voice="provider-specific-voice"
867
+ )
868
+
869
+ Using BYOK (Bring Your Own Key):
870
+ response = client.text_to_speech(
871
+ "Hello, world!",
872
+ model="openai/tts-1",
873
+ byok_api_key="sk-your-openai-key-here"
874
+ )
875
+ """
876
+ # Format the model string
877
+ formatted_model = self._format_model_string(model)
878
+
879
+ # Filter out problematic parameters
880
+ filtered_kwargs = {}
881
+ for key, value in kwargs.items():
882
+ if key not in ["return_generator"]: # List of parameters to exclude
883
+ filtered_kwargs[key] = value
884
+
885
+ # Create the base request data with required parameters
886
+ data = {
887
+ "input": input,
888
+ "model": formatted_model,
889
+ }
890
+
891
+ # Add optional parameters only if they are explicitly provided
892
+ if voice is not None:
893
+ data["voice"] = voice
894
+ if response_format is not None:
895
+ data["response_format"] = response_format
896
+ if speed is not None:
897
+ data["speed"] = speed
898
+ if instructions is not None and instructions.strip():
899
+ data["instructions"] = instructions
900
+
901
+ # Add any additional parameters from kwargs
902
+ if filtered_kwargs:
903
+ data["additional_params"] = filtered_kwargs
904
+
905
+ # Add BYOK API key if provided
906
+ if byok_api_key:
907
+ data["byok_api_key"] = byok_api_key
908
+
909
+ return self._request("POST", TTS_ENDPOINT, data)
910
+
911
+ def _get_supported_parameters_for_model(
912
+ self, provider: str, model_name: str
913
+ ) -> List[str]:
914
+ """
915
+ Get the list of supported parameters for a specific model.
916
+ This helps avoid sending unsupported parameters to providers.
917
+
918
+ Args:
919
+ provider: The provider name (e.g., 'openai', 'google', 'xai')
920
+ model_name: The model name (e.g., 'gpt-image-1', 'imagen-3.0-generate-002')
921
+
922
+ Returns:
923
+ List of parameter names supported by the model
924
+ """
925
+ if provider.lower() == "openai" and "gpt-image" in model_name.lower():
926
+ return [
927
+ "prompt",
928
+ "size",
929
+ "quality",
930
+ "n",
931
+ "user",
932
+ "background",
933
+ "moderation",
934
+ "output_compression",
935
+ "output_format",
936
+ "style",
937
+ ]
938
+
939
+ elif provider.lower() == "google" and "imagen" in model_name.lower():
940
+ return [
941
+ "prompt",
942
+ "n",
943
+ "negative_prompt",
944
+ "aspect_ratio",
945
+ "guidance_scale",
946
+ "seed",
947
+ "safety_filter_level",
948
+ "person_generation",
949
+ "include_safety_attributes",
950
+ "include_rai_reason",
951
+ "language",
952
+ "output_mime_type",
953
+ "output_compression_quality",
954
+ "add_watermark",
955
+ "enhance_prompt",
956
+ "response_format",
957
+ ]
958
+
959
+ elif provider.lower() == "xai" and "grok-2-image" in model_name.lower():
960
+ return ["prompt", "n", "response_format"]
961
+
962
+ # Default case - allow all parameters
963
+ return []
964
+
965
+ def models(self, provider: Optional[str] = None) -> Dict[str, Any]:
966
+ """
967
+ Get available models.
968
+
969
+ Args:
970
+ provider: Provider to filter by
971
+
972
+ Returns:
973
+ List of available models with pricing information
974
+ """
975
+ endpoint = MODEL_ENDPOINT
976
+ if provider:
977
+ endpoint = f"{MODEL_ENDPOINT}/{provider}"
978
+
979
+ return self._request("GET", endpoint)
980
+
981
+ def get_model_info(self, provider: str, model: str) -> Dict[str, Any]:
982
+ """
983
+ Get information about a specific model.
984
+
985
+ Args:
986
+ provider: Provider ID
987
+ model: Model ID
988
+
989
+ Returns:
990
+ Model information including pricing
991
+ """
992
+ return self._request("GET", f"{MODEL_ENDPOINT}/{provider}/{model}")
993
+
994
+ def get_usage(self) -> Dict[str, Any]:
995
+ """
996
+ Get usage statistics for the current user.
997
+
998
+ Returns:
999
+ Usage statistics
1000
+ """
1001
+ return self._request("GET", USAGE_ENDPOINT)
1002
+
1003
+ def test_connection(self) -> Dict[str, Any]:
1004
+ """
1005
+ Test the connection to the server and return server status information.
1006
+
1007
+ This method can be used to diagnose connection issues and verify that
1008
+ the server is accessible and properly configured.
1009
+
1010
+ Returns:
1011
+ Dictionary containing server status information
1012
+ """
1013
+ try:
1014
+ # Try to access the base URL
1015
+ response = self.session.get(self.base_url, timeout=self.timeout)
1016
+
1017
+ # Try to get server info if available
1018
+ server_info = {}
1019
+ try:
1020
+ if response.headers.get("Content-Type", "").startswith(
1021
+ "application/json"
1022
+ ):
1023
+ server_info = response.json()
1024
+ except:
1025
+ pass
1026
+
1027
+ return {
1028
+ "status": "connected",
1029
+ "url": self.base_url,
1030
+ "status_code": response.status_code,
1031
+ "server_info": server_info,
1032
+ "headers": dict(response.headers),
1033
+ }
1034
+ except requests.RequestException as e:
1035
+ return {
1036
+ "status": "error",
1037
+ "url": self.base_url,
1038
+ "error": str(e),
1039
+ "error_type": type(e).__name__,
1040
+ }
1041
+
1042
+ def diagnose_request(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]:
1043
+ """
1044
+ Diagnose potential issues with a request before sending it to the server.
1045
+
1046
+ This method checks for common issues like malformed model strings,
1047
+ invalid message formats, or missing required parameters.
1048
+
1049
+ Args:
1050
+ endpoint: API endpoint
1051
+ data: Request data
1052
+
1053
+ Returns:
1054
+ Dictionary with diagnosis results
1055
+ """
1056
+ issues = []
1057
+ warnings = []
1058
+
1059
+ # Check if this is a chat request
1060
+ if endpoint == CHAT_ENDPOINT:
1061
+ # Check model format
1062
+ if "model" in data:
1063
+ model = data["model"]
1064
+ # Check if the model is already formatted as JSON
1065
+ if (
1066
+ isinstance(model, str)
1067
+ and model.startswith("{")
1068
+ and model.endswith("}")
1069
+ ):
1070
+ try:
1071
+ model_json = json.loads(model)
1072
+ if (
1073
+ not isinstance(model_json, dict)
1074
+ or "provider" not in model_json
1075
+ or "model" not in model_json
1076
+ ):
1077
+ issues.append(f"Invalid model JSON format: {model}")
1078
+ except json.JSONDecodeError:
1079
+ issues.append(f"Invalid model JSON format: {model}")
1080
+ elif not isinstance(model, str):
1081
+ issues.append(f"Model must be a string, got {type(model).__name__}")
1082
+ elif "/" not in model:
1083
+ issues.append(
1084
+ f"Model '{model}' is missing provider prefix (should be 'provider/model')"
1085
+ )
1086
+ else:
1087
+ provider, model_name = model.split("/", 1)
1088
+ if not provider or not model_name:
1089
+ issues.append(
1090
+ f"Invalid model format: '{model}'. Should be 'provider/model'"
1091
+ )
1092
+ else:
1093
+ warnings.append("No model specified, will use default model")
1094
+
1095
+ # Check messages format
1096
+ if "messages" in data:
1097
+ messages = data["messages"]
1098
+ if not isinstance(messages, list):
1099
+ issues.append(
1100
+ f"Messages must be a list, got {type(messages).__name__}"
1101
+ )
1102
+ elif not messages:
1103
+ issues.append("Messages list is empty")
1104
+ else:
1105
+ for i, msg in enumerate(messages):
1106
+ if not isinstance(msg, dict):
1107
+ issues.append(
1108
+ f"Message {i} must be a dictionary, got {type(msg).__name__}"
1109
+ )
1110
+ elif "role" not in msg:
1111
+ issues.append(f"Message {i} is missing 'role' field")
1112
+ elif "content" not in msg:
1113
+ issues.append(f"Message {i} is missing 'content' field")
1114
+ else:
1115
+ issues.append("No messages specified")
1116
+
1117
+ # Check if this is a completion request
1118
+ elif endpoint == COMPLETION_ENDPOINT:
1119
+ # Check model format (same as chat)
1120
+ if "model" in data:
1121
+ model = data["model"]
1122
+ if not isinstance(model, str):
1123
+ issues.append(f"Model must be a string, got {type(model).__name__}")
1124
+ elif "/" not in model:
1125
+ issues.append(
1126
+ f"Model '{model}' is missing provider prefix (should be 'provider/model')"
1127
+ )
1128
+ else:
1129
+ warnings.append("No model specified, will use default model")
1130
+
1131
+ # Check prompt
1132
+ if "prompt" not in data:
1133
+ issues.append("No prompt specified")
1134
+ elif not isinstance(data["prompt"], str):
1135
+ issues.append(
1136
+ f"Prompt must be a string, got {type(data['prompt']).__name__}"
1137
+ )
1138
+
1139
+ # Return diagnosis results
1140
+ return {
1141
+ "endpoint": endpoint,
1142
+ "issues": issues,
1143
+ "warnings": warnings,
1144
+ "is_valid": len(issues) == 0,
1145
+ "data": data,
1146
+ }
1147
+
1148
+ def _handle_streaming_response(self, response):
1149
+ """
1150
+ Handle a streaming response.
1151
+
1152
+ Args:
1153
+ response: Streaming response
1154
+
1155
+ Returns:
1156
+ Generator yielding response chunks
1157
+ """
1158
+ try:
1159
+ for line in response.iter_lines():
1160
+ if line:
1161
+ line = line.decode("utf-8")
1162
+ if line.startswith("data: "):
1163
+ data = line[6:]
1164
+ if data == "[DONE]":
1165
+ break
1166
+ try:
1167
+ # Parse JSON chunk
1168
+ chunk = json.loads(data)
1169
+
1170
+ # For chat responses, return the processed chunk
1171
+ # with data field for backward compatibility
1172
+ if "choices" in chunk:
1173
+ # For delta responses (streaming)
1174
+ choice = chunk["choices"][0]
1175
+ if "delta" in choice and "content" in choice["delta"]:
1176
+ # Add a data field for backward compatibility
1177
+ chunk["data"] = choice["delta"]["content"]
1178
+ # For text responses (completion)
1179
+ elif "text" in choice:
1180
+ chunk["data"] = choice["text"]
1181
+
1182
+ yield chunk
1183
+ except json.JSONDecodeError:
1184
+ # For raw text responses
1185
+ yield {"data": data}
1186
+ finally:
1187
+ response.close()
1188
+
1189
+ def close(self):
1190
+ """Close the session."""
1191
+ self.session.close()
1192
+
1193
+ def __enter__(self):
1194
+ """Enter context manager."""
1195
+ return self
1196
+
1197
+ def __exit__(self, exc_type, exc_val, exc_tb):
1198
+ """Exit context manager."""
1199
+ self.close()
1200
+
1201
+ def set_base_url(self, base_url: str) -> None:
1202
+ """
1203
+ Set a new base URL for the API.
1204
+
1205
+ Args:
1206
+ base_url: New base URL for the API.
1207
+ """
1208
+ self.base_url = base_url
1209
+ logger.debug(f"Base URL set to {base_url}")
1210
+
1211
+
1212
+ IndoxRouter = Client