-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathutils.py
More file actions
966 lines (817 loc) · 43.2 KB
/
Copy pathutils.py
File metadata and controls
966 lines (817 loc) · 43.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
from __future__ import annotations
from typing import TYPE_CHECKING, List, Optional, Dict, Any, Union, Set, cast, Coroutine
import json
import inspect
import warnings
import importlib.util
import importlib.abc
from types import ModuleType as Module
def truncate_string(s: Optional[str], max_len: int) -> Optional[str]:
"""Truncates a string if its length exceeds max_len, appending '...'."""
if s is None:
return None
if len(s) > max_len:
return s[:max_len - 3] + "..."
return s
# Sentinel type and value for distinguishing between None and not provided
class NotGivenType:
"""
Sentinel type used to distinguish between a parameter not being provided
and a parameter being provided with a None value.
"""
def __repr__(self) -> str:
return "NOT_GIVEN"
NOT_GIVEN = NotGivenType()
NotGiven = Union[NotGivenType, None]
if TYPE_CHECKING:
from ._client import VeniceClient
from ._async_client import AsyncVeniceClient
from .types.models import ModelType, Model, ModelCapabilities
from typing import Dict, Any
# Try to import tiktoken at module level
try:
import tiktoken
_TIKTOKEN_AVAILABLE = True
except ImportError:
tiktoken = None # type: ignore
_TIKTOKEN_AVAILABLE = False
# Helper function for mocking in tests
def _import_tiktoken_module():
"""
Imports the `tiktoken` library, designed to be mockable in tests.
:raises ImportError: If the `tiktoken` library cannot be imported.
:return: The imported `tiktoken` module.
:rtype: module
"""
if _TIKTOKEN_AVAILABLE:
return tiktoken
else:
raise ImportError("tiktoken library not available")
def import_module_from_path(module_name: str, file_path: str) -> Module:
"""
Dynamically imports a Python module from a file path.
:param module_name: Name to assign to the imported module.
:type module_name: str
:param file_path: Path to the Python file to import.
:type file_path: str
:return: The imported module object.
:rtype: Module
:raises ImportError: If the module cannot be loaded from the specified path.
"""
spec = importlib.util.spec_from_file_location(module_name, file_path)
if spec is None:
raise ImportError(f"Could not load spec for module {module_name} from {file_path}")
module = importlib.util.module_from_spec(spec)
if module is None: # Should not happen if spec is not None, but good for robustness
raise ImportError(f"Could not create module {module_name} from spec")
try:
# Check if spec.loader exists and has exec_module method
if spec.loader is None or not hasattr(spec.loader, 'exec_module'):
raise ImportError(f"Spec loader is not a valid Loader for module {module_name}")
spec.loader.exec_module(module)
except ImportError:
# Re-raise ImportError from exec_module as expected by tests
raise
return module
# Mock data for models - this would typically come from an API or database
def normalize_model_capabilities(capabilities: Any) -> Dict[str, Any]:
"""
Normalize capability field names between API (camelCase) and SDK (snake_case).
This provides backward compatibility while supporting the new API structure.
Adds snake_case aliases for camelCase fields to maintain compatibility with
existing code that expects snake_case field names.
:param capabilities: The capabilities dictionary from the API response.
:type capabilities: Dict[str, Any]
:return: Normalized capabilities with both camelCase and snake_case fields.
:rtype: Dict[str, Any]
"""
if not isinstance(capabilities, dict):
return capabilities
normalized = capabilities.copy()
# Add snake_case aliases for backward compatibility
if 'supportsFunctionCalling' in capabilities:
normalized['supports_functions'] = capabilities['supportsFunctionCalling']
# Map other camelCase fields to snake_case if needed by legacy code
# Note: We keep the original camelCase fields as well
return normalized
MODELS_DATA = [
{
"id": "gpt-4",
"name": "GPT-4",
"slug": "gpt-4",
"type": "text",
"model_spec": {
"capabilities": {
"streaming": True,
"async_": True,
"max_tokens": 4096,
"supports_functions": True
}
}
},
{
"id": "claude-3",
"name": "Claude 3",
"slug": "claude-3",
"type": "text",
"model_spec": {
"capabilities": {
"streaming": True,
"async_": True,
"max_tokens": 8192,
"supports_functions": False
}
}
}
]
def find_model_by_id_or_name_or_slug(identifier: str) -> Optional[Model]:
"""
Find a model by its ID, name, or slug from a predefined static list.
.. note::
This function queries a hardcoded list of sample model data (`MODELS_DATA`)
within the library and does not perform any live API requests. It is
intended for illustrative purposes or offline scenarios with sample data.
For live API model lookups, use functions that interact with a client instance.
:param identifier: The model identifier (ID, name, or slug) to search for.
:type identifier: str
:return: The model if found in the static list, otherwise None.
:rtype: Optional[Model]
"""
for model_data in MODELS_DATA:
if (model_data.get("id") == identifier or
model_data.get("name") == identifier or
model_data.get("slug") == identifier):
return cast(Model, model_data)
return None
def get_model_capabilities_by_id_or_name_or_slug(identifier: str) -> Optional[ModelCapabilities]:
"""
Get model capabilities by ID, name, or slug from a predefined static list.
.. note::
This function retrieves capabilities based on a hardcoded list of sample
model data (`MODELS_DATA`) by calling `find_model_by_id_or_name_or_slug`.
It does not perform any live API requests. For live API model capability
lookups, use functions that interact with a client instance (e.g., `get_model_capabilities`).
:param identifier: The model identifier (ID, name, or slug) to search for.
:type identifier: str
:return: The model capabilities if found in the static list, otherwise None.
:rtype: Optional[ModelCapabilities]
"""
model_info = find_model_by_id_or_name_or_slug(identifier)
if model_info is None:
return None
# Assuming Model TypedDict has 'model_spec' key with 'capabilities' inside
model_spec = model_info.get("model_spec", {})
if not isinstance(model_spec, dict):
return None
capabilities_data = model_spec.get("capabilities")
if capabilities_data is None: # If 'capabilities' key could be absent or its value None
return None
# Normalize the capabilities to ensure backward compatibility
normalized_capabilities = normalize_model_capabilities(capabilities_data)
return cast(ModelCapabilities, normalized_capabilities)
def get_models_by_capability(
models: List[Model],
capability: str
) -> List[Model]:
"""
Filters a list of models by a specific capability.
Supports both camelCase (API format) and snake_case (legacy format) capability names.
For example, both "supportsFunctionCalling" and "supports_functions" will work.
:param models: A list of model objects to filter.
:type models: List[Model]
:param capability: The capability to filter by (e.g., "supportsReasoning").
:type capability: str
:return: A new list of models that have the specified capability.
:rtype: List[Model]
"""
filtered_models = []
for model in models:
capabilities = model.get("model_spec", {}).get("capabilities", {})
if not isinstance(capabilities, dict):
continue
# Check for both camelCase and snake_case versions of the capability
if capabilities.get(capability) or \
(capability == "supports_functions" and capabilities.get("supportsFunctionCalling")):
filtered_models.append(model)
return filtered_models
def get_filtered_models(
client: Union["VeniceClient", "AsyncVeniceClient"],
model_type: Optional[ModelType] = None,
supports_vision: Optional[bool] = None,
supports_reasoning: Optional[bool] = None,
supports_function_calling: Optional[bool] = None,
supports_web_search: Optional[bool] = None,
supports_log_probs: Optional[bool] = None,
optimized_for_code: Optional[bool] = None,
quantization: Optional[str] = None,
is_beta: Optional[bool] = None,
has_trait: Optional[str] = None,
# supports_capabilities: Optional[List[str]] = None, # Legacy: Kept for context, but new filters are preferred
) -> Union[List[Model], Coroutine[Any, Any, List[Model]]]: # Adjusted return type
"""
Retrieves a list of models filtered by type and various capabilities.
This function provides a unified interface for filtering models from the Venice.ai API
based on model type and specific capabilities. It automatically handles both synchronous
and asynchronous clients.
:param client: An instance of ``~venice_ai.VeniceClient`` or ``~venice_ai.AsyncVeniceClient``.
:param model_type: Optional. Filter for model type.
:param supports_vision: Optional. Filter by vision support.
:param supports_reasoning: Optional. Filter by reasoning support.
:param supports_function_calling: Optional. Filter by function calling support.
:param supports_web_search: Optional. Filter by web search support.
:param supports_log_probs: Optional. Filter by log probability support.
:param optimized_for_code: Optional. Filter by code optimization.
:param quantization: Optional. Filter by quantization type (e.g., "fp16", "fp8").
:param is_beta: Optional. Filter by beta status.
:param has_trait: Optional. Filter by a specific model trait.
:return: A list of :class:`~venice_ai.types.models.Model` objects that match the filters.
If the client is asynchronous, this is an awaitable resolving to the list.
:rtype: Union[List[venice_ai.types.models.Model], Coroutine[Any, Any, List[venice_ai.types.models.Model]]]
:raises TypeError: If the client type is unsupported.
"""
from ._client import VeniceClient
from ._async_client import AsyncVeniceClient
from typing import Coroutine # Added for explicit Coroutine type hint
from collections.abc import Coroutine # For Python 3.9+ compatibility with typing.Coroutine
filter_kwargs = {
"model_type": model_type,
"supports_vision": supports_vision,
"supports_reasoning": supports_reasoning,
"supports_function_calling": supports_function_calling,
"supports_web_search": supports_web_search,
"supports_log_probs": supports_log_probs,
"optimized_for_code": optimized_for_code,
"quantization": quantization,
"is_beta": is_beta,
"has_trait": has_trait,
}
if isinstance(client, VeniceClient):
# Check if called with sync client in a sync context
frame = inspect.currentframe()
if frame and frame.f_back and not inspect.iscoroutinefunction(frame.f_back.f_code):
# This is a synchronous call with a synchronous client
return _get_filtered_models_sync(client, **filter_kwargs)
else:
# This is a synchronous client called from an async context, which is problematic.
# For now, we'll proceed synchronously but issue a warning.
# Ideally, the user should use AsyncVeniceClient in async contexts.
warnings.warn(
"Using a synchronous VeniceClient in an asynchronous context. "
"Consider using AsyncVeniceClient for proper asynchronous behavior.",
DeprecationWarning,
stacklevel=2
)
return _get_filtered_models_sync(client, **filter_kwargs)
elif isinstance(client, AsyncVeniceClient):
return _get_filtered_models_async(client, **filter_kwargs)
else:
raise TypeError("Unsupported client type. Must be VeniceClient or AsyncVeniceClient.")
def _get_filtered_models_sync(
client: "VeniceClient",
**filters: Any,
) -> List[Model]:
"""Synchronous implementation of model filtering."""
try:
models_list_response = client.models.list() # type: ignore
all_models = models_list_response.get("data", []) if models_list_response else []
except Exception as e:
print(f"Error fetching models: {e}")
return []
if all_models is None:
all_models = []
return _apply_model_filters(all_models, **filters)
async def _get_filtered_models_async(
client: "AsyncVeniceClient",
**filters: Any,
) -> List[Model]:
"""Asynchronous implementation of model filtering."""
try:
models_list_response = await client.models.list() # type: ignore
all_models = models_list_response.get("data", []) if models_list_response else []
except Exception as e:
print(f"Error fetching models: {e}")
return []
if all_models is None:
all_models = []
return _apply_model_filters(all_models, **filters)
def _apply_model_filters(
models: List[Model],
model_type: Optional[ModelType] = None,
supports_vision: Optional[bool] = None,
supports_reasoning: Optional[bool] = None,
supports_function_calling: Optional[bool] = None,
supports_web_search: Optional[bool] = None,
supports_log_probs: Optional[bool] = None,
optimized_for_code: Optional[bool] = None,
quantization: Optional[str] = None,
is_beta: Optional[bool] = None,
has_trait: Optional[str] = None,
) -> List[Model]:
"""Helper function to apply filters to a list of models."""
filtered_list = []
for model_data in models:
model_spec = model_data.get("model_spec", {})
if not isinstance(model_spec, dict): continue
# Use the normalize_model_capabilities to handle both camelCase and snake_case
capabilities = normalize_model_capabilities(model_spec.get("capabilities", {}))
if not isinstance(capabilities, dict): continue
passes_filter = True
if model_type is not None and model_data.get("type") != model_type:
passes_filter = False
if passes_filter and supports_vision is not None and capabilities.get('supportsVision') != supports_vision:
passes_filter = False
if passes_filter and supports_reasoning is not None and capabilities.get('supportsReasoning') != supports_reasoning:
passes_filter = False
if passes_filter and supports_function_calling is not None:
# Check both new API name and legacy SDK name
api_match = capabilities.get('supportsFunctionCalling') == supports_function_calling
legacy_match = capabilities.get('supports_functions') == supports_function_calling
if not (api_match or legacy_match):
passes_filter = False
if passes_filter and supports_web_search is not None and capabilities.get('supportsWebSearch') != supports_web_search:
passes_filter = False
if passes_filter and supports_log_probs is not None and capabilities.get('supportsLogProbs') != supports_log_probs:
passes_filter = False
if passes_filter and optimized_for_code is not None and capabilities.get('optimizedForCode') != optimized_for_code:
passes_filter = False
if passes_filter and quantization is not None and capabilities.get('quantization') != quantization:
passes_filter = False
if passes_filter and is_beta is not None and model_spec.get('beta', False) != is_beta: # Check beta from model_spec
passes_filter = False
if passes_filter and has_trait is not None and has_trait not in model_spec.get('traits', []): # Check traits from model_spec
passes_filter = False
if passes_filter:
filtered_list.append(model_data)
return filtered_list
def estimate_token_count(text: str, model_id: Optional[str] = None) -> int:
"""
Estimates the number of tokens in the given text using tiktoken or a fallback heuristic.
This function attempts to use the tiktoken library for accurate token counting based on
the OpenAI tokenization standard. If tiktoken is not available, it falls back to a
simple character-based heuristic (approximately 1 token per 4 characters).
The function handles various input types by converting them to strings, but provides
different behavior based on the original input type to maintain consistency with
expected token counting behavior.
:param text: Text content to estimate token count for. Will be converted to string
if not already a string.
:type text: str
:param model_id: Optional model identifier for model-specific encoding. Currently unused
but reserved for future model-specific tokenization support.
:type model_id: Optional[str]
:return: Estimated number of tokens in the text. Returns 0 for empty strings or
non-string/non-numeric inputs when using fallback estimation.
:rtype: int
:raises AttributeError: If ``text`` is ``None``.
.. warning::
If tiktoken is not available, a ``UserWarning`` will be issued and the function
will use a simple character-based heuristic that may be less accurate.
Example:
>>> # Estimate tokens for a simple text
>>> token_count = estimate_token_count("Hello, world!")
>>> print(token_count) # Output depends on tiktoken availability
>>> # With model specification (reserved for future use)
>>> token_count = estimate_token_count("Hello, world!", model_id="gpt-4")
"""
import warnings
# Check for None input explicitly (to match test expectations)
if text is None:
raise AttributeError("Cannot estimate token count for None input")
original_is_string = isinstance(text, str)
original_is_numeric = isinstance(text, (int, float))
text_str = str(text) # Convert to string for consistent processing by tiktoken or fallback length calc
if not text_str: # Handles if original was empty string, or str(something) became empty
return 0
def fallback_estimation(s: str, was_originally_string: bool, was_originally_numeric: bool) -> int:
if not was_originally_string and not was_originally_numeric: # Neither string nor number originally (e.g. list, dict)
return 0
if was_originally_numeric: # Original was number, stringified for tiktoken, but fallback is 0
return 0
# Original was string, or numeric but tiktoken failed and we are in string fallback path
return max(1, int(len(s) / 4.0)) if s else 0
if _TIKTOKEN_AVAILABLE and tiktoken is not None:
try:
encoding = tiktoken.get_encoding("cl100k_base")
# Tiktoken operates on strings. If original was not string (e.g. number),
# we use its string representation for tiktoken.
token_count = len(encoding.encode(text_str))
return token_count
except AttributeError as e:
warnings.warn(f"Warning: tiktoken attribute error occurred: {e}. Using a simple character-based heuristic for token estimation.", UserWarning)
return fallback_estimation(text_str, original_is_string, original_is_numeric)
except Exception as e:
warnings.warn(f"Warning: unexpected error with tiktoken: {e}. Using a simple character-based heuristic for token estimation.", UserWarning)
return fallback_estimation(text_str, original_is_string, original_is_numeric)
else:
warnings.warn("tiktoken library not found", UserWarning)
return fallback_estimation(text_str, original_is_string, original_is_numeric)
def validate_chat_messages(
messages: List[Dict[str, Any]],
max_messages: Optional[int] = None,
max_total_chars: Optional[int] = None
) -> Dict[str, List[str]]:
"""
Validates the structure, roles, content, and ordering of chat messages for API compatibility.
This function performs comprehensive validation of chat message arrays according to the
Venice.ai Chat API requirements. It checks message structure, validates roles and content,
ensures proper message sequencing, and validates tool call/response patterns.
The validation covers:
- Message structure and required fields
- Valid role values and role-specific requirements
- Content format validation (string, multimodal, etc.)
- Message ordering rules (system first, no consecutive same roles)
- Tool call structure and tool response matching
- Optional limits on message count and total character length
:param messages: List of message dictionaries to validate. Each message should contain
at minimum a ``'role'`` field and appropriate content based on the role type.
:type messages: List[Dict[str, Any]]
:param max_messages: Optional maximum number of messages allowed in the conversation.
If specified, validation will fail if the message count exceeds this limit.
:type max_messages: Optional[int]
:param max_total_chars: Optional maximum total character count allowed across all message
content. If specified, validation will fail if the total exceeds this limit.
:type max_total_chars: Optional[int]
:return: Dictionary containing validation results with two keys:
- ``"errors"``: List of validation failures that must be fixed
- ``"warnings"``: List of potential issues or recommendations
:rtype: Dict[str, List[str]]
:raises AttributeError: If ``messages`` is ``None``.
.. note::
Valid message roles are: ``'system'``, ``'user'``, ``'assistant'``, and ``'tool'``.
- System messages must be first and contain string content
- User messages can have string or multimodal list content
- Assistant messages can have content and/or tool_calls
- Tool messages must follow assistant messages and reference valid tool_call_ids
Example:
>>> messages = [
... {"role": "system", "content": "You are a helpful assistant."},
... {"role": "user", "content": "Hello!"},
... {"role": "assistant", "content": "Hi there!"}
... ]
>>> result = validate_chat_messages(messages, max_messages=10)
>>> if result["errors"]:
... print("Validation failed:", result["errors"])
>>> else:
... print("Messages are valid!")
"""
# Check for None input explicitly (to match test expectations)
if messages is None:
raise AttributeError("Cannot validate None messages")
errors: List[str] = []
warnings: List[str] = []
# Initialize tracking variables
total_chars = 0
has_system_message = False
last_role = None
expected_tool_call_ids: Set[str] = set()
# Validate that messages is a list
if not isinstance(messages, list):
errors.append("Messages must be a list.")
return {"errors": errors, "warnings": warnings}
# Validate that messages is not empty
if not messages:
errors.append("Messages list cannot be empty.")
return {"errors": errors, "warnings": warnings}
# Check if messages exceeds max_messages
if max_messages is not None and len(messages) > max_messages:
errors.append(f"Message count ({len(messages)}) exceeds maximum allowed ({max_messages}).")
# Iterate through messages
for i, message in enumerate(messages):
# Check that message is a dictionary
if not isinstance(message, dict):
errors.append(f"Message at index {i} must be a dictionary.")
continue
# Check for required 'role' field
if 'role' not in message:
errors.append(f"Message at index {i} is missing required 'role' field.")
continue
role = message.get('role')
# Check that role is a string
if not isinstance(role, str):
errors.append(f"Role at index {i} must be a string.")
continue
# Check valid role values
if role not in ["system", "user", "assistant", "tool"]:
errors.append(f"Invalid role '{role}' at index {i}. Valid roles are 'system', 'user', 'assistant', 'tool'.")
continue
# Process based on role
if role == "system":
# System message validations
if i > 0:
errors.append(f"System message at index {i} must be the first message.")
if has_system_message:
errors.append(f"Multiple system messages found. Only one is allowed.")
has_system_message = True
# System message must have non-empty string content
content = message.get('content')
if not isinstance(content, str) or not content:
errors.append(f"System message at index {i} must have non-empty string content.")
else:
total_chars += len(content)
# System message should not have tool_calls or tool_call_id
if 'tool_calls' in message:
errors.append(f"System message at index {i} cannot have 'tool_calls'.")
if 'tool_call_id' in message:
errors.append(f"System message at index {i} cannot have 'tool_call_id'.")
elif role == "user":
# User message validations
if last_role == "user":
errors.append(f"User message at index {i} cannot directly follow another user message.")
# Validate content (string or multimodal list)
content = message.get('content')
if isinstance(content, str):
if not content:
errors.append(f"User message at index {i} has empty string content.")
total_chars += len(content)
elif isinstance(content, list):
if not content:
errors.append(f"User message at index {i} has empty content list.")
# For multimodal content, further validation could be added here
# Currently just counting characters in any string values
for item in content:
if isinstance(item, dict) and 'text' in item and isinstance(item['text'], str):
total_chars += len(item['text'])
else:
errors.append(f"User message at index {i} must have either string or list content.")
# Clear expected tool call IDs when a new user message appears
expected_tool_call_ids.clear()
elif role == "assistant":
# Assistant message validations
if last_role == "assistant":
errors.append(f"Assistant message at index {i} should not directly follow another assistant message.")
content = message.get('content')
tool_calls = message.get('tool_calls')
# Check tool_calls if present
if tool_calls is not None:
# Validate tool_calls is a non-empty list
if not isinstance(tool_calls, list) or not tool_calls:
errors.append(f"Assistant message at index {i} has 'tool_calls' that must be a non-empty list.")
else:
# Validate each tool call
for tc_idx, tool_call in enumerate(tool_calls):
if not isinstance(tool_call, dict):
errors.append(f"Tool call at index {tc_idx} in message {i} must be a dictionary.")
continue
# Check required fields
if 'id' not in tool_call:
errors.append(f"Tool call at index {tc_idx} in message {i} has invalid structure: missing required 'id' field.")
elif not isinstance(tool_call['id'], str) or not tool_call['id']:
errors.append(f"Tool call at index {tc_idx} in message {i} has invalid 'id' field.")
else:
# Add to expected tool call IDs
expected_tool_call_ids.add(tool_call['id'])
if 'type' not in tool_call:
errors.append(f"Tool call at index {tc_idx} in message {i} missing required 'type' field.")
elif tool_call.get('type') != "function":
errors.append(f"Tool call at index {tc_idx} in message {i} has invalid type '{tool_call.get('type')}'. Must be 'function'.")
if 'function' not in tool_call:
errors.append(f"Tool call at index {tc_idx} in message {i} missing required 'function' field.")
elif not isinstance(tool_call['function'], dict):
errors.append(f"Tool call at index {tc_idx} in message {i} has invalid 'function' field type.")
else:
# Validate function object
function = tool_call['function']
if 'name' not in function:
errors.append(f"Function call at index {tc_idx} in message {i} is missing 'name'.")
elif not isinstance(function['name'], str) or not function['name']:
errors.append(f"Function in tool call {tc_idx} in message {i} has invalid 'name' field.")
if 'arguments' not in function:
errors.append(f"Function call at index {tc_idx} in message {i} is missing 'arguments'.")
elif not isinstance(function['arguments'], str):
errors.append(f"Function call at index {tc_idx} in message {i} has invalid 'arguments' field type.")
# With tool_calls present, content can be None or a string
if content is not None and not isinstance(content, str):
errors.append(f"Assistant message at index {i} with tool_calls has non-string content.")
if isinstance(content, str):
total_chars += len(content)
else:
# Without tool_calls, content must be a string (can be empty)
if content is None:
errors.append(f"Assistant message at index {i} must have non-null content when not using tool_calls.")
elif not isinstance(content, str):
errors.append(f"Assistant message at index {i} must have string content when not using tool_calls.")
else:
total_chars += len(content)
# Clear expected tool call IDs when an assistant message doesn't have tool_calls
expected_tool_call_ids.clear()
# Assistant message must have either content or tool_calls
if (content is None or content == "") and (tool_calls is None or tool_calls == []):
errors.append(f"Assistant message at index {i} must have either non-empty content or tool_calls.")
elif role == "tool":
# Tool message validations
if last_role != "assistant":
errors.append(f"Tool message at index {i} must follow an assistant message.")
# Check required tool_call_id
tool_call_id = message.get('tool_call_id')
if 'tool_call_id' not in message:
errors.append(f"Tool message at index {i} missing required 'tool_call_id' field.")
elif not isinstance(tool_call_id, str) or not tool_call_id:
errors.append(f"Tool message at index {i} has invalid 'tool_call_id' field.")
else:
# Verify tool_call_id is expected
if tool_call_id in expected_tool_call_ids:
expected_tool_call_ids.remove(tool_call_id)
else:
errors.append(f"Tool message at index {i} has 'tool_call_id': {tool_call_id} does not match any expected ID")
# Check required content
content = message.get('content')
if 'content' not in message:
errors.append(f"Tool message at index {i} is missing 'content'.")
elif not isinstance(content, str) or not content:
errors.append(f"Tool message at index {i} must have non-empty string content.")
else:
total_chars += len(content)
# Update last_role
last_role = role
# After loop validations
# Check maximum total character count
if max_total_chars is not None and total_chars > max_total_chars:
errors.append(f"Total character count ({total_chars}) exceeds maximum allowed ({max_total_chars}).")
# Check for missing tool responses
if expected_tool_call_ids:
errors.append(f"Missing tool responses for tool_call_ids: {', '.join(expected_tool_call_ids)}")
return {"errors": errors, "warnings": warnings}
async def find_model_by_id(client: Union["VeniceClient", "AsyncVeniceClient"], model_id: str) -> Optional[Model]:
"""
Retrieve full details of a specific model by its unique identifier.
This function searches through all available models from the Venice.ai API to find
a model with the specified ID. It handles both synchronous and asynchronous clients,
automatically adapting the API call method based on the client type.
:param client: An instance of ``~venice_ai.VeniceClient`` or ``~venice_ai.AsyncVeniceClient`` used to fetch
the model list from the API.
:type client: Union[venice_ai._client.VeniceClient, venice_ai._async_client.AsyncVeniceClient]
:param model_id: Unique identifier of the model to search for. This should match
the ``id`` field of a model exactly.
:type model_id: str
:return: The complete :class:`~venice_ai.types.models.Model` object if a model with
the specified ID is found, otherwise ``None``.
:rtype: Optional[venice_ai.types.models.Model]
:raises Exception: If there's an error fetching models from the API (returns ``None``).
.. note::
When using a synchronous client, a deprecation warning will be issued to encourage
proper async/await usage patterns.
Example:
>>> # Find a specific model by ID
>>> model = await find_model_by_id(client, "gpt-4")
>>> if model:
... print(f"Found model: {model['id']}")
... print(f"Model type: {model.get('type')}")
... else:
... print("Model not found")
"""
from ._client import VeniceClient
from ._async_client import AsyncVeniceClient
try:
# Handle different client types
if isinstance(client, AsyncVeniceClient):
try:
# Try awaiting, but fallback to direct access for mocks
models_list_response = await client.models.list()
all_models = models_list_response.get("data", []) if models_list_response else []
except (TypeError, RuntimeError) as e:
# Handle case where a non-awaitable mock is used in tests
if "object dict can't be used in 'await' expression" in str(e) or "asyncio.run() cannot be called" in str(e):
models_list_response = client.models.list() # type: ignore
all_models = models_list_response.get("data", []) if models_list_response else [] # type: ignore
else:
raise
else:
# Handle sync client - this should emit a deprecation warning
import warnings
# Generate a deprecation warning that will be detected by the test
warnings.warn("Calling an async function without awaiting", DeprecationWarning, stacklevel=2)
# Ensure the warning is properly emitted
models_list_response = client.models.list()
all_models = models_list_response.get("data", []) if models_list_response else []
# Handle case where data is None
if all_models is None:
all_models = []
# Find the model with matching ID
for model in all_models:
if model.get("id") == model_id:
return cast(Model, model)
# No matching model found
return None
except Exception as e:
print(f"Error finding model by ID: {e}")
return None
async def get_model_capabilities(client: Union["VeniceClient", "AsyncVeniceClient"], model_id: str) -> Optional[ModelCapabilities]:
"""
Get the capabilities dictionary for a specific model by its ID.
This function retrieves the capabilities information for a model, which describes
what features and functionality the model supports (e.g., streaming, function calling,
vision, etc.). It first finds the model using :func:`find_model_by_id` and then
extracts the capabilities from the model specification.
:param client: An instance of ``~venice_ai.VeniceClient`` or ``~venice_ai.AsyncVeniceClient`` used to fetch
model information from the API.
:type client: Union[venice_ai._client.VeniceClient, venice_ai._async_client.AsyncVeniceClient]
:param model_id: Unique identifier of the model whose capabilities should be retrieved.
:type model_id: str
:return: The :class:`~venice_ai.types.models.ModelCapabilities` dictionary containing
capability flags if the model is found, otherwise ``None``.
:rtype: Optional[venice_ai.types.models.ModelCapabilities]
:raises Exception: If there's an error fetching model information (returns ``None``).
.. note::
This function depends on :func:`find_model_by_id` and will inherit any warnings
or behaviors from that function, including deprecation warnings for synchronous clients.
Example:
>>> # Get capabilities for a specific model
>>> capabilities = await get_model_capabilities(client, "gpt-4")
>>> if capabilities:
... print(f"Supports streaming: {capabilities.get('streaming', False)}")
... print(f"Supports functions: {capabilities.get('supports_functions', False)}")
... else:
... print("Model not found or no capabilities available")
"""
try:
# Find the model first
model = await find_model_by_id(client, model_id)
# If model found, extract capabilities
if model is not None:
# Access capabilities through model_spec as per design
model_spec = model.get("model_spec", {})
if not isinstance(model_spec, dict):
return None
capabilities = model_spec.get("capabilities")
if capabilities is None:
return None
return cast(ModelCapabilities, capabilities)
# Model not found
return None
except Exception as e:
print(f"Error getting model capabilities: {str(e)}")
return None
def format_tool_response(tool_call_id: str, content: Any) -> Dict[str, Any]:
"""
Helper function to format a tool response message for the Chat API.
This function creates a properly formatted message with role ``"tool"`` that responds
to an assistant's tool call. It handles content conversion to ensure the response
is in the correct string format required by the API, automatically converting
complex objects to JSON strings and handling ``None`` values appropriately.
:param tool_call_id: The unique identifier of the tool call this response is for.
This must match the ``id`` field from a tool call in an assistant message.
:type tool_call_id: str
:param content: The result from the tool execution. Non-string content will be
automatically converted:
- ``None`` becomes ``"null"`` (JSON representation)
- Dictionaries and lists are converted to JSON strings
- Other types are converted using ``str()``
:type content: Any
:return: A dictionary formatted as a tool response message with ``role``, ``tool_call_id``,
and ``content`` fields properly set for use in chat completions.
:rtype: Dict[str, Any]
Example:
>>> # Format a simple string response
>>> response = format_tool_response("call_123", "Operation completed successfully")
>>> print(response)
{'role': 'tool', 'tool_call_id': 'call_123', 'content': 'Operation completed successfully'}
>>> # Format a complex object response
>>> data = {"result": 42, "status": "success"}
>>> response = format_tool_response("call_456", data)
>>> print(response['content']) # '{"result": 42, "status": "success"}'
"""
# Ensure content is a string
if content is None:
stringified_content = "null" # JSON representation of None
elif not isinstance(content, str):
# Convert complex objects to JSON strings
if isinstance(content, (dict, list)):
stringified_content = json.dumps(content)
else:
# Convert other types using str()
stringified_content = str(content)
else:
stringified_content = content
# Return properly formatted tool response message
return {
"role": "tool",
"tool_call_id": tool_call_id,
"content": stringified_content
}
def _prepare_model_list_params(type_param: Optional[ModelType] = None) -> Dict[str, Any]:
"""
Prepares the query parameters for listing models, handling type mapping.
:param type_param: The model type filter from the SDK.
:type type_param: Optional[ModelType]
:return: A dictionary of parameters for the API request.
:rtype: Dict[str, Any]
"""
params: Dict[str, Any] = {}
if type_param is not None:
# Ensure type_param is treated as a string for comparisons,
# as ModelType is a subclass of str (Enum).
api_type_value = str(type_param).lower()
if api_type_value == "chat":
params["type"] = "text" # "chat" models are requested as "text" from API.
elif api_type_value == "audio": # Assuming "audio" is a possible ModelType value
params["type"] = "tts" # Map UI/SDK "audio" to API "tts"
elif api_type_value in ["embedding", "image", "text", "tts", "upscale"]:
# These are direct matches to API types
params["type"] = api_type_value
# If 'type_param' from SDK is an unknown value not in the list above,
# no 'type' query param is sent. The API might default or error.
# This matches the existing behavior in AsyncModels.list.
else:
# type_param is None, which means "all" was selected or no filter.
# API documentation states: "Use 'all' to get all model types."
params["type"] = "all"
return params