tamar-model-client 0.1.27__py3-none-any.whl → 0.1.30__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.
@@ -7,7 +7,7 @@ connections fail, supporting both synchronous and asynchronous clients.
7
7
 
8
8
  import json
9
9
  import logging
10
- from typing import Optional, Iterator, AsyncIterator, Dict, Any
10
+ from typing import Optional, Iterator, AsyncIterator, Dict, Any, List
11
11
 
12
12
  from . import generate_request_id, get_protected_logger
13
13
  from ..schemas import ModelRequest, ModelResponse
@@ -15,8 +15,70 @@ from ..schemas import ModelRequest, ModelResponse
15
15
  logger = get_protected_logger(__name__)
16
16
 
17
17
 
18
+ def safe_serialize(obj: Any) -> Any:
19
+ """
20
+ 安全地序列化对象,避免 Pydantic ValidatorIterator 序列化问题
21
+ """
22
+ if obj is None:
23
+ return None
24
+
25
+ # 处理基本类型
26
+ if isinstance(obj, (str, int, float, bool)):
27
+ return obj
28
+
29
+ # 处理列表
30
+ if isinstance(obj, (list, tuple)):
31
+ return [safe_serialize(item) for item in obj]
32
+
33
+ # 处理字典
34
+ if isinstance(obj, dict):
35
+ return {key: safe_serialize(value) for key, value in obj.items()}
36
+
37
+ # 处理 Pydantic 模型
38
+ if hasattr(obj, 'model_dump'):
39
+ try:
40
+ return obj.model_dump(exclude_unset=True)
41
+ except Exception:
42
+ # 如果 model_dump 失败,尝试手动提取字段
43
+ try:
44
+ if hasattr(obj, '__dict__'):
45
+ return {k: safe_serialize(v) for k, v in obj.__dict__.items()
46
+ if not k.startswith('_') and not callable(v)}
47
+ elif hasattr(obj, '__slots__'):
48
+ return {slot: safe_serialize(getattr(obj, slot, None))
49
+ for slot in obj.__slots__ if hasattr(obj, slot)}
50
+ except Exception:
51
+ pass
52
+
53
+ # 处理 Pydantic v1 模型
54
+ if hasattr(obj, 'dict'):
55
+ try:
56
+ return obj.dict(exclude_unset=True)
57
+ except Exception:
58
+ pass
59
+
60
+ # 处理枚举
61
+ if hasattr(obj, 'value'):
62
+ return obj.value
63
+
64
+ # 最后的尝试:转换为字符串
65
+ try:
66
+ return str(obj)
67
+ except Exception:
68
+ return None
69
+
70
+
18
71
  class HttpFallbackMixin:
19
- """HTTP fallback functionality for synchronous clients"""
72
+ """HTTP fallback functionality for synchronous clients
73
+
74
+ This mixin requires the following attributes from the host class:
75
+ - http_fallback_url: str - The HTTP fallback URL (from BaseClient._init_resilient_features)
76
+ - jwt_token: Optional[str] - JWT token for authentication (from BaseClient)
77
+
78
+ Usage:
79
+ This mixin should be used with BaseClient or its subclasses that have
80
+ initialized the resilient features with _init_resilient_features().
81
+ """
20
82
 
21
83
  def _ensure_http_client(self) -> None:
22
84
  """Ensure HTTP client is initialized"""
@@ -29,36 +91,42 @@ class HttpFallbackMixin:
29
91
 
30
92
  # Set default headers
31
93
  self._http_client.headers.update({
32
- 'Content-Type': 'application/json',
33
94
  'User-Agent': 'TamarModelClient/1.0'
34
95
  })
35
96
 
36
97
  def _convert_to_http_format(self, model_request: ModelRequest) -> Dict[str, Any]:
37
98
  """Convert ModelRequest to HTTP payload format"""
99
+ # Use safe serialization to avoid Pydantic ValidatorIterator issues
38
100
  payload = {
39
- "provider": model_request.provider.value,
40
- "model": model_request.model,
41
- "user_context": model_request.user_context.model_dump(),
42
- "stream": model_request.stream
101
+ "provider": safe_serialize(model_request.provider),
102
+ "model": safe_serialize(model_request.model),
103
+ "user_context": safe_serialize(model_request.user_context),
104
+ "stream": safe_serialize(model_request.stream)
43
105
  }
44
106
 
45
107
  # Add provider-specific fields
46
108
  if hasattr(model_request, 'messages') and model_request.messages:
47
- payload['messages'] = model_request.messages
109
+ payload['messages'] = safe_serialize(model_request.messages)
48
110
  if hasattr(model_request, 'contents') and model_request.contents:
49
- payload['contents'] = model_request.contents
111
+ payload['contents'] = safe_serialize(model_request.contents)
50
112
 
51
113
  # Add optional fields
52
114
  if model_request.channel:
53
- payload['channel'] = model_request.channel.value
115
+ payload['channel'] = safe_serialize(model_request.channel)
54
116
  if model_request.invoke_type:
55
- payload['invoke_type'] = model_request.invoke_type.value
117
+ payload['invoke_type'] = safe_serialize(model_request.invoke_type)
56
118
 
57
- # Add extra parameters
119
+ # Add config parameters safely
120
+ if hasattr(model_request, 'config') and model_request.config:
121
+ payload['config'] = safe_serialize(model_request.config)
122
+
123
+ # Add extra parameters safely
58
124
  if hasattr(model_request, 'model_extra') and model_request.model_extra:
59
- for key, value in model_request.model_extra.items():
60
- if key not in payload:
61
- payload[key] = value
125
+ serialized_extra = safe_serialize(model_request.model_extra)
126
+ if isinstance(serialized_extra, dict):
127
+ for key, value in serialized_extra.items():
128
+ if key not in payload:
129
+ payload[key] = value
62
130
 
63
131
  return payload
64
132
 
@@ -88,12 +156,17 @@ class HttpFallbackMixin:
88
156
  data = json.loads(data_str)
89
157
  yield ModelResponse(**data)
90
158
  except json.JSONDecodeError:
91
- logger.warning(f"Failed to parse streaming response: {data_str}")
159
+ logger.warning(f"⚠️ Failed to parse streaming response: {data_str}")
92
160
 
93
161
  def _invoke_http_fallback(self, model_request: ModelRequest,
94
162
  timeout: Optional[float] = None,
95
- request_id: Optional[str] = None) -> Any:
163
+ request_id: Optional[str] = None,
164
+ origin_request_id: Optional[str] = None) -> Any:
96
165
  """HTTP fallback implementation"""
166
+ # Check if http_fallback_url is available
167
+ if not hasattr(self, 'http_fallback_url') or not self.http_fallback_url:
168
+ raise RuntimeError("HTTP fallback URL not configured. Please set MODEL_CLIENT_HTTP_FALLBACK_URL environment variable.")
169
+
97
170
  self._ensure_http_client()
98
171
 
99
172
  # Generate request ID if not provided
@@ -104,10 +177,14 @@ class HttpFallbackMixin:
104
177
  logger.warning(
105
178
  f"🔻 Using HTTP fallback for request",
106
179
  extra={
180
+ "log_type": "info",
107
181
  "request_id": request_id,
108
- "provider": model_request.provider.value,
109
- "model": model_request.model,
110
- "fallback_url": self.http_fallback_url
182
+ "data": {
183
+ "origin_request_id": origin_request_id,
184
+ "provider": model_request.provider.value,
185
+ "model": model_request.model,
186
+ "fallback_url": self.http_fallback_url
187
+ }
111
188
  }
112
189
  )
113
190
 
@@ -117,8 +194,18 @@ class HttpFallbackMixin:
117
194
  # Construct URL
118
195
  url = f"{self.http_fallback_url}/v1/invoke"
119
196
 
120
- # Build headers with authentication
121
- headers = {'X-Request-ID': request_id}
197
+ # Build headers with authentication and request tracking
198
+ headers = {
199
+ 'X-Request-ID': request_id,
200
+ 'Content-Type': 'application/json',
201
+ 'User-Agent': 'TamarModelClient/1.0'
202
+ }
203
+
204
+ # Add origin request ID if provided
205
+ if origin_request_id:
206
+ headers['X-Origin-Request-ID'] = origin_request_id
207
+
208
+ # Add JWT authentication if available
122
209
  if hasattr(self, 'jwt_token') and self.jwt_token:
123
210
  headers['Authorization'] = f'Bearer {self.jwt_token}'
124
211
 
@@ -138,10 +225,101 @@ class HttpFallbackMixin:
138
225
  # Parse response
139
226
  data = response.json()
140
227
  return ModelResponse(**data)
228
+
229
+ def _invoke_batch_http_fallback(self, batch_request: 'BatchModelRequest',
230
+ timeout: Optional[float] = None,
231
+ request_id: Optional[str] = None,
232
+ origin_request_id: Optional[str] = None) -> List['BatchModelResponse']:
233
+ """HTTP batch fallback implementation"""
234
+ # Import here to avoid circular import
235
+ from ..schemas import BatchModelRequest, BatchModelResponse
236
+
237
+ # Check if http_fallback_url is available
238
+ if not hasattr(self, 'http_fallback_url') or not self.http_fallback_url:
239
+ raise RuntimeError("HTTP fallback URL not configured. Please set MODEL_CLIENT_HTTP_FALLBACK_URL environment variable.")
240
+
241
+ self._ensure_http_client()
242
+
243
+ # Generate request ID if not provided
244
+ if not request_id:
245
+ request_id = generate_request_id()
246
+
247
+ # Log fallback usage
248
+ logger.warning(
249
+ f"🔻 Using HTTP fallback for batch request",
250
+ extra={
251
+ "log_type": "info",
252
+ "request_id": request_id,
253
+ "data": {
254
+ "origin_request_id": origin_request_id,
255
+ "batch_size": len(batch_request.items),
256
+ "fallback_url": self.http_fallback_url
257
+ }
258
+ }
259
+ )
260
+
261
+ # Convert to HTTP format
262
+ http_payload = {
263
+ "user_context": batch_request.user_context.model_dump(),
264
+ "items": []
265
+ }
266
+
267
+ # Convert each item
268
+ for item in batch_request.items:
269
+ item_payload = self._convert_to_http_format(item)
270
+ if hasattr(item, 'custom_id') and item.custom_id:
271
+ item_payload['custom_id'] = item.custom_id
272
+ if hasattr(item, 'priority') and item.priority is not None:
273
+ item_payload['priority'] = item.priority
274
+ http_payload['items'].append(item_payload)
275
+
276
+ # Construct URL
277
+ url = f"{self.http_fallback_url}/v1/batch-invoke"
278
+
279
+ # Build headers with authentication and request tracking
280
+ headers = {
281
+ 'X-Request-ID': request_id,
282
+ 'Content-Type': 'application/json',
283
+ 'User-Agent': 'TamarModelClient/1.0'
284
+ }
285
+
286
+ # Add origin request ID if provided
287
+ if origin_request_id:
288
+ headers['X-Origin-Request-ID'] = origin_request_id
289
+
290
+ # Add JWT authentication if available
291
+ if hasattr(self, 'jwt_token') and self.jwt_token:
292
+ headers['Authorization'] = f'Bearer {self.jwt_token}'
293
+
294
+ # Send batch request
295
+ response = self._http_client.post(
296
+ url,
297
+ json=http_payload,
298
+ timeout=timeout or 120, # Longer timeout for batch requests
299
+ headers=headers
300
+ )
301
+ response.raise_for_status()
302
+
303
+ # Parse response
304
+ data = response.json()
305
+ results = []
306
+ for item_data in data.get('results', []):
307
+ results.append(BatchModelResponse(**item_data))
308
+
309
+ return results
141
310
 
142
311
 
143
312
  class AsyncHttpFallbackMixin:
144
- """HTTP fallback functionality for asynchronous clients"""
313
+ """HTTP fallback functionality for asynchronous clients
314
+
315
+ This mixin requires the following attributes from the host class:
316
+ - http_fallback_url: str - The HTTP fallback URL (from BaseClient._init_resilient_features)
317
+ - jwt_token: Optional[str] - JWT token for authentication (from BaseClient)
318
+
319
+ Usage:
320
+ This mixin should be used with BaseClient or its subclasses that have
321
+ initialized the resilient features with _init_resilient_features().
322
+ """
145
323
 
146
324
  async def _ensure_http_client(self) -> None:
147
325
  """Ensure async HTTP client is initialized"""
@@ -149,7 +327,6 @@ class AsyncHttpFallbackMixin:
149
327
  import aiohttp
150
328
  self._http_session = aiohttp.ClientSession(
151
329
  headers={
152
- 'Content-Type': 'application/json',
153
330
  'User-Agent': 'AsyncTamarModelClient/1.0'
154
331
  }
155
332
  )
@@ -188,12 +365,17 @@ class AsyncHttpFallbackMixin:
188
365
  data = json.loads(data_str)
189
366
  yield ModelResponse(**data)
190
367
  except json.JSONDecodeError:
191
- logger.warning(f"Failed to parse streaming response: {data_str}")
368
+ logger.warning(f"⚠️ Failed to parse streaming response: {data_str}")
192
369
 
193
370
  async def _invoke_http_fallback(self, model_request: ModelRequest,
194
371
  timeout: Optional[float] = None,
195
- request_id: Optional[str] = None) -> Any:
372
+ request_id: Optional[str] = None,
373
+ origin_request_id: Optional[str] = None) -> Any:
196
374
  """Async HTTP fallback implementation"""
375
+ # Check if http_fallback_url is available
376
+ if not hasattr(self, 'http_fallback_url') or not self.http_fallback_url:
377
+ raise RuntimeError("HTTP fallback URL not configured. Please set MODEL_CLIENT_HTTP_FALLBACK_URL environment variable.")
378
+
197
379
  await self._ensure_http_client()
198
380
 
199
381
  # Generate request ID if not provided
@@ -204,21 +386,36 @@ class AsyncHttpFallbackMixin:
204
386
  logger.warning(
205
387
  f"🔻 Using HTTP fallback for request",
206
388
  extra={
389
+ "log_type": "info",
207
390
  "request_id": request_id,
208
- "provider": model_request.provider.value,
209
- "model": model_request.model,
210
- "fallback_url": self.http_fallback_url
391
+ "data": {
392
+ "origin_request_id": origin_request_id,
393
+ "provider": model_request.provider.value,
394
+ "model": model_request.model,
395
+ "fallback_url": self.http_fallback_url
396
+ }
211
397
  }
212
398
  )
213
399
 
214
400
  # Convert to HTTP format
215
401
  http_payload = self._convert_to_http_format(model_request)
402
+ print(http_payload)
216
403
 
217
404
  # Construct URL
218
405
  url = f"{self.http_fallback_url}/v1/invoke"
219
406
 
220
- # Build headers with authentication
221
- headers = {'X-Request-ID': request_id}
407
+ # Build headers with authentication and request tracking
408
+ headers = {
409
+ 'X-Request-ID': request_id,
410
+ 'Content-Type': 'application/json',
411
+ 'User-Agent': 'AsyncTamarModelClient/1.0'
412
+ }
413
+
414
+ # Add origin request ID if provided
415
+ if origin_request_id:
416
+ headers['X-Origin-Request-ID'] = origin_request_id
417
+
418
+ # Add JWT authentication if available
222
419
  if hasattr(self, 'jwt_token') and self.jwt_token:
223
420
  headers['Authorization'] = f'Bearer {self.jwt_token}'
224
421
 
@@ -242,6 +439,91 @@ class AsyncHttpFallbackMixin:
242
439
  data = await response.json()
243
440
  return ModelResponse(**data)
244
441
 
442
+ async def _invoke_batch_http_fallback(self, batch_request: 'BatchModelRequest',
443
+ timeout: Optional[float] = None,
444
+ request_id: Optional[str] = None,
445
+ origin_request_id: Optional[str] = None) -> List['BatchModelResponse']:
446
+ """Async HTTP batch fallback implementation"""
447
+ # Import here to avoid circular import
448
+ from ..schemas import BatchModelRequest, BatchModelResponse
449
+
450
+ # Check if http_fallback_url is available
451
+ if not hasattr(self, 'http_fallback_url') or not self.http_fallback_url:
452
+ raise RuntimeError("HTTP fallback URL not configured. Please set MODEL_CLIENT_HTTP_FALLBACK_URL environment variable.")
453
+
454
+ await self._ensure_http_client()
455
+
456
+ # Generate request ID if not provided
457
+ if not request_id:
458
+ request_id = generate_request_id()
459
+
460
+ # Log fallback usage
461
+ logger.warning(
462
+ f"🔻 Using HTTP fallback for batch request",
463
+ extra={
464
+ "log_type": "info",
465
+ "request_id": request_id,
466
+ "data": {
467
+ "origin_request_id": origin_request_id,
468
+ "batch_size": len(batch_request.items),
469
+ "fallback_url": self.http_fallback_url
470
+ }
471
+ }
472
+ )
473
+
474
+ # Convert to HTTP format
475
+ http_payload = {
476
+ "user_context": batch_request.user_context.model_dump(),
477
+ "items": []
478
+ }
479
+
480
+ # Convert each item
481
+ for item in batch_request.items:
482
+ item_payload = self._convert_to_http_format(item)
483
+ if hasattr(item, 'custom_id') and item.custom_id:
484
+ item_payload['custom_id'] = item.custom_id
485
+ if hasattr(item, 'priority') and item.priority is not None:
486
+ item_payload['priority'] = item.priority
487
+ http_payload['items'].append(item_payload)
488
+
489
+ # Construct URL
490
+ url = f"{self.http_fallback_url}/v1/batch-invoke"
491
+
492
+ # Build headers with authentication and request tracking
493
+ headers = {
494
+ 'X-Request-ID': request_id,
495
+ 'Content-Type': 'application/json',
496
+ 'User-Agent': 'AsyncTamarModelClient/1.0'
497
+ }
498
+
499
+ # Add origin request ID if provided
500
+ if origin_request_id:
501
+ headers['X-Origin-Request-ID'] = origin_request_id
502
+
503
+ # Add JWT authentication if available
504
+ if hasattr(self, 'jwt_token') and self.jwt_token:
505
+ headers['Authorization'] = f'Bearer {self.jwt_token}'
506
+
507
+ # Send batch request
508
+ import aiohttp
509
+ timeout_obj = aiohttp.ClientTimeout(total=timeout or 120) if timeout else None
510
+
511
+ async with self._http_session.post(
512
+ url,
513
+ json=http_payload,
514
+ timeout=timeout_obj,
515
+ headers=headers
516
+ ) as response:
517
+ response.raise_for_status()
518
+
519
+ # Parse response
520
+ data = await response.json()
521
+ results = []
522
+ for item_data in data.get('results', []):
523
+ results.append(BatchModelResponse(**item_data))
524
+
525
+ return results
526
+
245
527
  async def _cleanup_http_session(self) -> None:
246
528
  """Clean up HTTP session"""
247
529
  if hasattr(self, '_http_session') and self._http_session:
@@ -10,7 +10,7 @@ import threading
10
10
  from typing import Optional, Dict
11
11
 
12
12
  from ..json_formatter import JSONFormatter
13
- from .utils import get_request_id
13
+ from .utils import get_request_id, get_origin_request_id
14
14
 
15
15
  # gRPC 消息长度限制(32位系统兼容)
16
16
  MAX_MESSAGE_LENGTH = 2 ** 31 - 1
@@ -45,6 +45,20 @@ class RequestIdFilter(logging.Filter):
45
45
  """
46
46
  # 从 ContextVar 中获取当前的 request_id
47
47
  record.request_id = get_request_id()
48
+
49
+ # 添加 origin_request_id 到 data 字段
50
+ origin_request_id = get_origin_request_id()
51
+ if origin_request_id:
52
+ # 确保 data 字段存在且是字典类型
53
+ if not hasattr(record, 'data'):
54
+ record.data = {}
55
+ elif record.data is None:
56
+ record.data = {}
57
+ elif isinstance(record.data, dict):
58
+ # 只有在 data 是字典且没有 origin_request_id 时才添加
59
+ if 'origin_request_id' not in record.data:
60
+ record.data['origin_request_id'] = origin_request_id
61
+
48
62
  return True
49
63
 
50
64
 
@@ -15,6 +15,7 @@ from pydantic import BaseModel
15
15
 
16
16
  # 使用 contextvars 管理请求ID,支持异步和同步上下文中的请求追踪
17
17
  _request_id: ContextVar[str] = ContextVar('request_id', default='-')
18
+ _origin_request_id: ContextVar[str] = ContextVar('origin_request_id', default=None)
18
19
 
19
20
 
20
21
  def is_effective_value(value) -> bool:
@@ -168,4 +169,29 @@ def get_request_id() -> str:
168
169
  Returns:
169
170
  str: 当前的请求ID或默认值
170
171
  """
171
- return _request_id.get()
172
+ return _request_id.get()
173
+
174
+
175
+ def set_origin_request_id(origin_request_id: str):
176
+ """
177
+ 设置当前上下文的原始请求ID
178
+
179
+ 在 ContextVar 中设置原始请求ID,使得在整个异步调用链中
180
+ 都能访问到同一个原始请求ID,便于追踪请求来源。
181
+
182
+ Args:
183
+ origin_request_id: 要设置的原始请求ID字符串
184
+ """
185
+ _origin_request_id.set(origin_request_id)
186
+
187
+
188
+ def get_origin_request_id() -> str:
189
+ """
190
+ 获取当前上下文的原始请求ID
191
+
192
+ 从 ContextVar 中获取当前的原始请求ID,如果没有设置则返回 None
193
+
194
+ Returns:
195
+ str: 当前的原始请求ID或 None
196
+ """
197
+ return _origin_request_id.get()