tamar-model-client 0.1.16__py3-none-any.whl → 0.1.18__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.
@@ -1,6 +1,7 @@
1
1
  from .sync_client import TamarModelClient
2
2
  from .async_client import AsyncTamarModelClient
3
3
  from .exceptions import ModelManagerClientError, ConnectionError, ValidationError
4
+ from .json_formatter import JSONFormatter
4
5
 
5
6
  __all__ = [
6
7
  "TamarModelClient",
@@ -8,4 +9,5 @@ __all__ = [
8
9
  "ModelManagerClientError",
9
10
  "ConnectionError",
10
11
  "ValidationError",
12
+ "JSONFormatter",
11
13
  ]
@@ -4,6 +4,7 @@ import base64
4
4
  import json
5
5
  import logging
6
6
  import os
7
+ import time
7
8
  import uuid
8
9
  from contextvars import ContextVar
9
10
 
@@ -19,7 +20,8 @@ from .exceptions import ConnectionError
19
20
  from .schemas import ModelRequest, ModelResponse, BatchModelRequest, BatchModelResponse
20
21
  from .generated import model_service_pb2, model_service_pb2_grpc
21
22
  from .schemas.inputs import GoogleGenAiInput, OpenAIResponsesInput, OpenAIChatCompletionsInput, \
22
- GoogleVertexAIImagesInput, OpenAIImagesInput
23
+ GoogleVertexAIImagesInput, OpenAIImagesInput, OpenAIImagesEditInput
24
+ from .json_formatter import JSONFormatter
23
25
 
24
26
  logger = logging.getLogger(__name__)
25
27
 
@@ -40,8 +42,8 @@ if not logger.hasHandlers():
40
42
  # 创建日志处理器,输出到控制台
41
43
  console_handler = logging.StreamHandler()
42
44
 
43
- # 设置日志格式
44
- formatter = logging.Formatter('%(asctime)s [%(levelname)s] [%(request_id)s] %(message)s')
45
+ # 使用 JSON 格式化器
46
+ formatter = JSONFormatter()
45
47
  console_handler.setFormatter(formatter)
46
48
 
47
49
  # 为当前记录器添加处理器
@@ -181,26 +183,31 @@ class AsyncTamarModelClient:
181
183
  # 对于取消的情况进行指数退避重试
182
184
  if isinstance(e, grpc.aio.AioRpcError) and e.code() == grpc.StatusCode.CANCELLED:
183
185
  retry_count += 1
184
- logger.warning(f"❌ RPC cancelled, retrying {retry_count}/{self.max_retries}...")
186
+ logger.info(f"❌ RPC cancelled, retrying {retry_count}/{self.max_retries}...",
187
+ extra={"log_type": "info", "data": {"retry_count": retry_count, "max_retries": self.max_retries, "error_code": "CANCELLED"}})
185
188
  if retry_count < self.max_retries:
186
189
  delay = self.retry_delay * (2 ** (retry_count - 1))
187
190
  await asyncio.sleep(delay)
188
191
  else:
189
- logger.error("❌ Max retry reached for CANCELLED")
192
+ logger.error("❌ Max retry reached for CANCELLED",
193
+ extra={"log_type": "info", "data": {"error_code": "CANCELLED", "max_retries_reached": True}})
190
194
  raise
191
195
  # 针对其他 RPC 错误类型,如暂时的连接问题、服务器超时等
192
196
  elif isinstance(e, grpc.RpcError) and e.code() in {grpc.StatusCode.UNAVAILABLE,
193
197
  grpc.StatusCode.DEADLINE_EXCEEDED}:
194
198
  retry_count += 1
195
- logger.warning(f"❌ gRPC error {e.code()}, retrying {retry_count}/{self.max_retries}...")
199
+ logger.info(f"❌ gRPC error {e.code()}, retrying {retry_count}/{self.max_retries}...",
200
+ extra={"log_type": "info", "data": {"retry_count": retry_count, "max_retries": self.max_retries, "error_code": str(e.code())}})
196
201
  if retry_count < self.max_retries:
197
202
  delay = self.retry_delay * (2 ** (retry_count - 1))
198
203
  await asyncio.sleep(delay)
199
204
  else:
200
- logger.error(f"❌ Max retry reached for {e.code()}")
205
+ logger.error(f"❌ Max retry reached for {e.code()}",
206
+ extra={"log_type": "info", "data": {"error_code": str(e.code()), "max_retries_reached": True}})
201
207
  raise
202
208
  else:
203
- logger.error(f"❌ Non-retryable gRPC error: {e}", exc_info=True)
209
+ logger.error(f"❌ Non-retryable gRPC error: {e}", exc_info=True,
210
+ extra={"log_type": "info", "data": {"error_code": str(e.code()) if hasattr(e, 'code') else None, "retryable": False}})
204
211
  raise
205
212
 
206
213
  async def _retry_request_stream(self, func, *args, **kwargs):
@@ -212,26 +219,31 @@ class AsyncTamarModelClient:
212
219
  # 对于取消的情况进行指数退避重试
213
220
  if isinstance(e, grpc.aio.AioRpcError) and e.code() == grpc.StatusCode.CANCELLED:
214
221
  retry_count += 1
215
- logger.warning(f"❌ RPC cancelled, retrying {retry_count}/{self.max_retries}...")
222
+ logger.info(f"❌ RPC cancelled, retrying {retry_count}/{self.max_retries}...",
223
+ extra={"log_type": "info", "data": {"retry_count": retry_count, "max_retries": self.max_retries, "error_code": "CANCELLED"}})
216
224
  if retry_count < self.max_retries:
217
225
  delay = self.retry_delay * (2 ** (retry_count - 1))
218
226
  await asyncio.sleep(delay)
219
227
  else:
220
- logger.error("❌ Max retry reached for CANCELLED")
228
+ logger.error("❌ Max retry reached for CANCELLED",
229
+ extra={"log_type": "info", "data": {"error_code": "CANCELLED", "max_retries_reached": True}})
221
230
  raise
222
231
  # 针对其他 RPC 错误类型,如暂时的连接问题、服务器超时等
223
232
  elif isinstance(e, grpc.RpcError) and e.code() in {grpc.StatusCode.UNAVAILABLE,
224
233
  grpc.StatusCode.DEADLINE_EXCEEDED}:
225
234
  retry_count += 1
226
- logger.warning(f"❌ gRPC error {e.code()}, retrying {retry_count}/{self.max_retries}...")
235
+ logger.info(f"❌ gRPC error {e.code()}, retrying {retry_count}/{self.max_retries}...",
236
+ extra={"log_type": "info", "data": {"retry_count": retry_count, "max_retries": self.max_retries, "error_code": str(e.code())}})
227
237
  if retry_count < self.max_retries:
228
238
  delay = self.retry_delay * (2 ** (retry_count - 1))
229
239
  await asyncio.sleep(delay)
230
240
  else:
231
- logger.error(f"❌ Max retry reached for {e.code()}")
241
+ logger.error(f"❌ Max retry reached for {e.code()}",
242
+ extra={"log_type": "info", "data": {"error_code": str(e.code()), "max_retries_reached": True}})
232
243
  raise
233
244
  else:
234
- logger.error(f"❌ Non-retryable gRPC error: {e}", exc_info=True)
245
+ logger.error(f"❌ Non-retryable gRPC error: {e}", exc_info=True,
246
+ extra={"log_type": "info", "data": {"error_code": str(e.code()) if hasattr(e, 'code') else None, "retryable": False}})
235
247
  raise
236
248
 
237
249
  def _build_auth_metadata(self, request_id: str) -> list:
@@ -266,32 +278,40 @@ class AsyncTamarModelClient:
266
278
  credentials,
267
279
  options=options
268
280
  )
269
- logger.info("🔐 Using secure gRPC channel (TLS enabled)")
281
+ logger.info("🔐 Using secure gRPC channel (TLS enabled)",
282
+ extra={"log_type": "info", "data": {"tls_enabled": True, "server_address": self.server_address}})
270
283
  else:
271
284
  self.channel = grpc.aio.insecure_channel(
272
285
  self.server_address,
273
286
  options=options
274
287
  )
275
- logger.info("🔓 Using insecure gRPC channel (TLS disabled)")
288
+ logger.info("🔓 Using insecure gRPC channel (TLS disabled)",
289
+ extra={"log_type": "info", "data": {"tls_enabled": False, "server_address": self.server_address}})
276
290
  await self.channel.channel_ready()
277
291
  self.stub = model_service_pb2_grpc.ModelServiceStub(self.channel)
278
- logger.info(f"✅ gRPC channel initialized to {self.server_address}")
292
+ logger.info(f"✅ gRPC channel initialized to {self.server_address}",
293
+ extra={"log_type": "info", "data": {"status": "success", "server_address": self.server_address}})
279
294
  return
280
295
  except grpc.FutureTimeoutError as e:
281
- logger.error(f"❌ gRPC channel initialization timed out: {str(e)}", exc_info=True)
296
+ logger.error(f"❌ gRPC channel initialization timed out: {str(e)}", exc_info=True,
297
+ extra={"log_type": "info", "data": {"error_type": "timeout", "server_address": self.server_address}})
282
298
  except grpc.RpcError as e:
283
- logger.error(f"❌ gRPC channel initialization failed: {str(e)}", exc_info=True)
299
+ logger.error(f"❌ gRPC channel initialization failed: {str(e)}", exc_info=True,
300
+ extra={"log_type": "info", "data": {"error_type": "rpc_error", "server_address": self.server_address}})
284
301
  except Exception as e:
285
- logger.error(f"❌ Unexpected error during channel initialization: {str(e)}", exc_info=True)
302
+ logger.error(f"❌ Unexpected error during channel initialization: {str(e)}", exc_info=True,
303
+ extra={"log_type": "info", "data": {"error_type": "unexpected", "server_address": self.server_address}})
286
304
 
287
305
  retry_count += 1
288
306
  if retry_count > self.max_retries:
289
- logger.error(f"❌ Failed to initialize gRPC channel after {self.max_retries} retries.", exc_info=True)
307
+ logger.error(f"❌ Failed to initialize gRPC channel after {self.max_retries} retries.", exc_info=True,
308
+ extra={"log_type": "info", "data": {"max_retries_reached": True, "server_address": self.server_address}})
290
309
  raise ConnectionError(f"❌ Failed to initialize gRPC channel after {self.max_retries} retries.")
291
310
 
292
311
  # 指数退避:延迟时间 = retry_delay * (2 ^ (retry_count - 1))
293
312
  delay = self.retry_delay * (2 ** (retry_count - 1))
294
- logger.info(f"🚀 Retrying connection (attempt {retry_count}/{self.max_retries}) after {delay:.2f}s delay...")
313
+ logger.info(f"🚀 Retrying connection (attempt {retry_count}/{self.max_retries}) after {delay:.2f}s delay...",
314
+ extra={"log_type": "info", "data": {"retry_count": retry_count, "max_retries": self.max_retries, "delay": delay}})
295
315
  await asyncio.sleep(delay)
296
316
 
297
317
  async def _stream(self, request, metadata, invoke_timeout) -> AsyncIterator[ModelResponse]:
@@ -303,6 +323,66 @@ class AsyncTamarModelClient:
303
323
  raw_response=json.loads(response.raw_response) if response.raw_response else None,
304
324
  request_id=response.request_id if response.request_id else None,
305
325
  )
326
+
327
+ async def _stream_with_logging(self, request, metadata, invoke_timeout, start_time, model_request) -> AsyncIterator[ModelResponse]:
328
+ """流式响应的包装器,用于记录完整的响应日志"""
329
+ total_content = ""
330
+ final_usage = None
331
+ error_occurred = None
332
+ chunk_count = 0
333
+
334
+ try:
335
+ async for response in self._stream(request, metadata, invoke_timeout):
336
+ chunk_count += 1
337
+ if response.content:
338
+ total_content += response.content
339
+ if response.usage:
340
+ final_usage = response.usage
341
+ if response.error:
342
+ error_occurred = response.error
343
+ yield response
344
+
345
+ # 流式响应完成,记录成功日志
346
+ duration = time.time() - start_time
347
+ logger.info(
348
+ f"✅ Stream completed successfully | chunks: {chunk_count}",
349
+ extra={
350
+ "log_type": "response",
351
+ "uri": f"/invoke/{model_request.provider.value}/{model_request.invoke_type.value}",
352
+ "duration": duration,
353
+ "data": {
354
+ "provider": model_request.provider.value,
355
+ "invoke_type": model_request.invoke_type.value,
356
+ "model": model_request.model,
357
+ "stream": True,
358
+ "chunks_count": chunk_count,
359
+ "total_length": len(total_content),
360
+ "usage": final_usage
361
+ }
362
+ }
363
+ )
364
+ except Exception as e:
365
+ # 流式响应出错,记录错误日志
366
+ duration = time.time() - start_time
367
+ logger.error(
368
+ f"❌ Stream failed after {chunk_count} chunks: {str(e)}",
369
+ exc_info=True,
370
+ extra={
371
+ "log_type": "response",
372
+ "uri": f"/invoke/{model_request.provider.value}/{model_request.invoke_type.value}",
373
+ "duration": duration,
374
+ "data": {
375
+ "provider": model_request.provider.value,
376
+ "invoke_type": model_request.invoke_type.value,
377
+ "model": model_request.model,
378
+ "stream": True,
379
+ "chunks_count": chunk_count,
380
+ "error_type": type(e).__name__,
381
+ "partial_content_length": len(total_content)
382
+ }
383
+ }
384
+ )
385
+ raise
306
386
 
307
387
  async def _invoke_request(self, request, metadata, invoke_timeout):
308
388
  async for response in self.stub.Invoke(request, metadata=metadata, timeout=invoke_timeout):
@@ -345,8 +425,22 @@ class AsyncTamarModelClient:
345
425
  metadata = self._build_auth_metadata(request_id) # 将 request_id 加入到请求头
346
426
 
347
427
  # 记录开始日志
428
+ start_time = time.time()
348
429
  logger.info(
349
- f"🔵 Request Start | request_id: {request_id} | provider: {model_request.provider} | invoke_type: {model_request.invoke_type} | model_request: {model_request}")
430
+ f"🔵 Request Start | request_id: {request_id} | provider: {model_request.provider} | invoke_type: {model_request.invoke_type}",
431
+ extra={
432
+ "log_type": "request",
433
+ "uri": f"/invoke/{model_request.provider.value}/{model_request.invoke_type.value}",
434
+ "data": {
435
+ "provider": model_request.provider.value,
436
+ "invoke_type": model_request.invoke_type.value,
437
+ "model": model_request.model,
438
+ "stream": model_request.stream,
439
+ "org_id": model_request.user_context.org_id,
440
+ "user_id": model_request.user_context.user_id,
441
+ "client_type": model_request.user_context.client_type
442
+ }
443
+ })
350
444
 
351
445
  # 动态根据 provider/invoke_type 决定使用哪个 input 字段
352
446
  try:
@@ -363,6 +457,8 @@ class AsyncTamarModelClient:
363
457
  allowed_fields = OpenAIChatCompletionsInput.model_fields.keys()
364
458
  case ((ProviderType.OPENAI | ProviderType.AZURE), InvokeType.IMAGE_GENERATION):
365
459
  allowed_fields = OpenAIImagesInput.model_fields.keys()
460
+ case ((ProviderType.OPENAI | ProviderType.AZURE), InvokeType.IMAGE_EDIT_GENERATION):
461
+ allowed_fields = OpenAIImagesEditInput.model_fields.keys()
366
462
  case _:
367
463
  raise ValueError(
368
464
  f"Unsupported provider/invoke_type combination: {model_request.provider} + {model_request.invoke_type}")
@@ -402,16 +498,63 @@ class AsyncTamarModelClient:
402
498
  try:
403
499
  invoke_timeout = timeout or self.default_invoke_timeout
404
500
  if model_request.stream:
405
- return await self._retry_request_stream(self._stream, request, metadata, invoke_timeout)
501
+ # 对于流式响应,使用带日志记录的包装器
502
+ stream_generator = await self._retry_request_stream(self._stream, request, metadata, invoke_timeout)
503
+ return self._stream_with_logging(request, metadata, invoke_timeout, start_time, model_request)
406
504
  else:
407
- return await self._retry_request(self._invoke_request, request, metadata, invoke_timeout)
505
+ result = await self._retry_request(self._invoke_request, request, metadata, invoke_timeout)
506
+
507
+ # 记录非流式响应的成功日志
508
+ duration = time.time() - start_time
509
+ logger.info(
510
+ f"✅ Request completed successfully",
511
+ extra={
512
+ "log_type": "response",
513
+ "uri": f"/invoke/{model_request.provider.value}/{model_request.invoke_type.value}",
514
+ "duration": duration,
515
+ "data": {
516
+ "provider": model_request.provider.value,
517
+ "invoke_type": model_request.invoke_type.value,
518
+ "model": model_request.model,
519
+ "stream": False,
520
+ "content_length": len(result.content) if result.content else 0,
521
+ "usage": result.usage
522
+ }
523
+ }
524
+ )
525
+ return result
408
526
  except grpc.RpcError as e:
527
+ duration = time.time() - start_time
409
528
  error_message = f"❌ Invoke gRPC failed: {str(e)}"
410
- logger.error(error_message, exc_info=True)
529
+ logger.error(error_message, exc_info=True,
530
+ extra={
531
+ "log_type": "response",
532
+ "uri": f"/invoke/{model_request.provider.value}/{model_request.invoke_type.value}",
533
+ "duration": duration,
534
+ "data": {
535
+ "error_type": "grpc_error",
536
+ "error_code": str(e.code()) if hasattr(e, 'code') else None,
537
+ "provider": model_request.provider.value,
538
+ "invoke_type": model_request.invoke_type.value,
539
+ "model": model_request.model
540
+ }
541
+ })
411
542
  raise e
412
543
  except Exception as e:
544
+ duration = time.time() - start_time
413
545
  error_message = f"❌ Invoke other error: {str(e)}"
414
- logger.error(error_message, exc_info=True)
546
+ logger.error(error_message, exc_info=True,
547
+ extra={
548
+ "log_type": "response",
549
+ "uri": f"/invoke/{model_request.provider.value}/{model_request.invoke_type.value}",
550
+ "duration": duration,
551
+ "data": {
552
+ "error_type": "other_error",
553
+ "provider": model_request.provider.value,
554
+ "invoke_type": model_request.invoke_type.value,
555
+ "model": model_request.model
556
+ }
557
+ })
415
558
  raise e
416
559
 
417
560
  async def invoke_batch(self, batch_request_model: BatchModelRequest, timeout: Optional[float] = None,
@@ -442,8 +585,19 @@ class AsyncTamarModelClient:
442
585
  metadata = self._build_auth_metadata(request_id) # 将 request_id 加入到请求头
443
586
 
444
587
  # 记录开始日志
588
+ start_time = time.time()
445
589
  logger.info(
446
- f"🔵 Batch Request Start | request_id: {request_id} | batch_size: {len(batch_request_model.items)} | batch_request_model: {batch_request_model}")
590
+ f"🔵 Batch Request Start | request_id: {request_id} | batch_size: {len(batch_request_model.items)}",
591
+ extra={
592
+ "log_type": "request",
593
+ "uri": "/batch_invoke",
594
+ "data": {
595
+ "batch_size": len(batch_request_model.items),
596
+ "org_id": batch_request_model.user_context.org_id,
597
+ "user_id": batch_request_model.user_context.user_id,
598
+ "client_type": batch_request_model.user_context.client_type
599
+ }
600
+ })
447
601
 
448
602
  # 构造批量请求
449
603
  items = []
@@ -461,6 +615,8 @@ class AsyncTamarModelClient:
461
615
  allowed_fields = OpenAIChatCompletionsInput.model_fields.keys()
462
616
  case ((ProviderType.OPENAI | ProviderType.AZURE), InvokeType.IMAGE_GENERATION):
463
617
  allowed_fields = OpenAIImagesInput.model_fields.keys()
618
+ case ((ProviderType.OPENAI | ProviderType.AZURE), InvokeType.IMAGE_EDIT_GENERATION):
619
+ allowed_fields = OpenAIImagesEditInput.model_fields.keys()
464
620
  case _:
465
621
  raise ValueError(
466
622
  f"Unsupported provider/invoke_type combination: {model_request_item.provider} + {model_request_item.invoke_type}")
@@ -516,17 +672,54 @@ class AsyncTamarModelClient:
516
672
  error=res_item.error or None,
517
673
  custom_id=res_item.custom_id if res_item.custom_id else None
518
674
  ))
519
- return BatchModelResponse(
675
+ batch_response = BatchModelResponse(
520
676
  request_id=response.request_id if response.request_id else None,
521
677
  responses=result
522
678
  )
679
+
680
+ # 记录成功日志
681
+ duration = time.time() - start_time
682
+ logger.info(
683
+ f"✅ Batch request completed successfully",
684
+ extra={
685
+ "log_type": "response",
686
+ "uri": "/batch_invoke",
687
+ "duration": duration,
688
+ "data": {
689
+ "batch_size": len(batch_request_model.items),
690
+ "responses_count": len(result)
691
+ }
692
+ }
693
+ )
694
+ return batch_response
523
695
  except grpc.RpcError as e:
696
+ duration = time.time() - start_time
524
697
  error_message = f"❌ BatchInvoke gRPC failed: {str(e)}"
525
- logger.error(error_message, exc_info=True)
698
+ logger.error(error_message, exc_info=True,
699
+ extra={
700
+ "log_type": "response",
701
+ "uri": "/batch_invoke",
702
+ "duration": duration,
703
+ "data": {
704
+ "error_type": "grpc_error",
705
+ "error_code": str(e.code()) if hasattr(e, 'code') else None,
706
+ "batch_size": len(batch_request_model.items)
707
+ }
708
+ })
526
709
  raise e
527
710
  except Exception as e:
711
+ duration = time.time() - start_time
528
712
  error_message = f"❌ BatchInvoke other error: {str(e)}"
529
- logger.error(error_message, exc_info=True)
713
+ logger.error(error_message, exc_info=True,
714
+ extra={
715
+ "log_type": "response",
716
+ "uri": "/batch_invoke",
717
+ "duration": duration,
718
+ "data": {
719
+ "error_type": "other_error",
720
+ "batch_size": len(batch_request_model.items)
721
+ }
722
+ })
530
723
  raise e
531
724
 
532
725
  async def close(self):
@@ -534,7 +727,8 @@ class AsyncTamarModelClient:
534
727
  if self.channel and not self._closed:
535
728
  await self.channel.close()
536
729
  self._closed = True
537
- logger.info("✅ gRPC channel closed")
730
+ logger.info("✅ gRPC channel closed",
731
+ extra={"log_type": "info", "data": {"status": "success"}})
538
732
 
539
733
  def _safe_sync_close(self):
540
734
  """进程退出时自动关闭 channel(事件循环处理兼容)"""
@@ -546,7 +740,8 @@ class AsyncTamarModelClient:
546
740
  else:
547
741
  loop.run_until_complete(self.close())
548
742
  except Exception as e:
549
- logger.warning(f"❌ gRPC channel close failed at exit: {e}")
743
+ logger.info(f"❌ gRPC channel close failed at exit: {e}",
744
+ extra={"log_type": "info", "data": {"status": "failed", "error": str(e)}})
550
745
 
551
746
  async def __aenter__(self):
552
747
  """支持 async with 自动初始化连接"""
@@ -7,4 +7,5 @@ class InvokeType(str, Enum):
7
7
  CHAT_COMPLETIONS = "chat-completions"
8
8
 
9
9
  GENERATION = "generation" # 生成类,默认的值
10
- IMAGE_GENERATION = "image-generation"
10
+ IMAGE_GENERATION = "image-generation"
11
+ IMAGE_EDIT_GENERATION = "image-edit-generation"
@@ -0,0 +1,26 @@
1
+ import json
2
+ import logging
3
+ from datetime import datetime
4
+
5
+
6
+ class JSONFormatter(logging.Formatter):
7
+ def format(self, record):
8
+ # log_type 只能是 request、response 或 info
9
+ log_type = getattr(record, "log_type", "info")
10
+ if log_type not in ["request", "response", "info"]:
11
+ log_type = "info"
12
+
13
+ log_data = {
14
+ "timestamp": datetime.fromtimestamp(record.created).isoformat(),
15
+ "level": record.levelname,
16
+ "type": log_type,
17
+ "uri": getattr(record, "uri", None),
18
+ "request_id": getattr(record, "request_id", None),
19
+ "data": getattr(record, "data", None),
20
+ "message": record.getMessage(),
21
+ "duration": getattr(record, "duration", None),
22
+ }
23
+ # 增加 trace 支持
24
+ if hasattr(record, "trace"):
25
+ log_data["trace"] = getattr(record, "trace")
26
+ return json.dumps(log_data, ensure_ascii=False)
@@ -1,18 +1,22 @@
1
+ import mimetypes
2
+ import os
3
+
1
4
  import httpx
2
5
  from google.genai import types
3
6
  from openai import NotGiven, NOT_GIVEN
4
- from openai._types import Headers, Query, Body
7
+ from openai._types import Headers, Query, Body, FileTypes
5
8
  from openai.types import ChatModel, Metadata, ReasoningEffort, ResponsesModel, Reasoning, ImageModel
6
9
  from openai.types.chat import ChatCompletionMessageParam, ChatCompletionAudioParam, completion_create_params, \
7
10
  ChatCompletionPredictionContentParam, ChatCompletionStreamOptionsParam, ChatCompletionToolChoiceOptionParam, \
8
11
  ChatCompletionToolParam
9
12
  from openai.types.responses import ResponseInputParam, ResponseIncludable, ResponseTextConfigParam, \
10
13
  response_create_params, ToolParam
11
- from pydantic import BaseModel, model_validator
12
- from typing import List, Optional, Union, Iterable, Dict, Literal
14
+ from pydantic import BaseModel, model_validator, field_validator
15
+ from typing import List, Optional, Union, Iterable, Dict, Literal, IO
13
16
 
14
17
  from tamar_model_client.enums import ProviderType, InvokeType
15
18
  from tamar_model_client.enums.channel import Channel
19
+ from tamar_model_client.utils import convert_file_field, validate_fields_by_provider_and_invoke_type
16
20
 
17
21
 
18
22
  class UserContext(BaseModel):
@@ -149,6 +153,29 @@ class OpenAIImagesInput(BaseModel):
149
153
  }
150
154
 
151
155
 
156
+ class OpenAIImagesEditInput(BaseModel):
157
+ image: Union[FileTypes, List[FileTypes]]
158
+ prompt: str
159
+ background: Optional[Literal["transparent", "opaque", "auto"]] | NotGiven = NOT_GIVEN
160
+ mask: FileTypes | NotGiven = NOT_GIVEN
161
+ model: Union[str, ImageModel, None] | NotGiven = NOT_GIVEN
162
+ n: Optional[int] | NotGiven = NOT_GIVEN
163
+ quality: Optional[Literal["standard", "low", "medium", "high", "auto"]] | NotGiven = NOT_GIVEN
164
+ response_format: Optional[Literal["url", "b64_json"]] | NotGiven = NOT_GIVEN
165
+ size: Optional[Literal["256x256", "512x512", "1024x1024", "1536x1024", "1024x1536", "auto"]] | NotGiven = NOT_GIVEN
166
+ user: str | NotGiven = NOT_GIVEN
167
+ # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
168
+ # The extra values given here take precedence over values defined on the client or passed to this method.
169
+ extra_headers: Headers | None = None
170
+ extra_query: Query | None = None
171
+ extra_body: Body | None = None
172
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
173
+
174
+ model_config = {
175
+ "arbitrary_types_allowed": True
176
+ }
177
+
178
+
152
179
  class BaseRequest(BaseModel):
153
180
  provider: ProviderType # 供应商,如 "openai", "google" 等
154
181
  channel: Channel = Channel.NORMAL # 渠道:不同服务商之前有不同的调用SDK,这里指定是调用哪个SDK
@@ -212,8 +239,11 @@ class ModelRequestInput(BaseRequest):
212
239
  contents: Optional[Union[types.ContentListUnion, types.ContentListUnionDict]] = None
213
240
  config: Optional[types.GenerateContentConfigOrDict] = None
214
241
 
215
- # OpenAIImagesInput + GoogleVertexAIImagesInput 合并字段
242
+ # OpenAIImagesInput + OpenAIImagesEditInput + GoogleVertexAIImagesInput 合并字段
243
+ image: Optional[Union[FileTypes, List[FileTypes]]] = None
216
244
  prompt: Optional[str] = None
245
+ background: Optional[Literal["transparent", "opaque", "auto"]] | NotGiven = NOT_GIVEN
246
+ mask: FileTypes | NotGiven = NOT_GIVEN
217
247
  negative_prompt: Optional[str] = None
218
248
  aspect_ratio: Optional[Literal["1:1", "9:16", "16:9", "4:3", "3:4"]] = None
219
249
  guidance_scale: Optional[float] = None
@@ -223,7 +253,8 @@ class ModelRequestInput(BaseRequest):
223
253
  safety_filter_level: Optional[Literal["block_most", "block_some", "block_few", "block_fewest"]] = None
224
254
  person_generation: Optional[Literal["dont_allow", "allow_adult", "allow_all"]] = None
225
255
  quality: Optional[Literal["standard", "hd"]] | NotGiven = NOT_GIVEN
226
- size: Optional[Literal["256x256", "512x512", "1024x1024", "1792x1024", "1024x1792"]] | NotGiven = NOT_GIVEN
256
+ size: Optional[Literal[
257
+ "auto", "1024x1024", "1536x1024", "1024x1536", "256x256", "512x512", "1792x1024", "1024x1792"]] | NotGiven = NOT_GIVEN
227
258
  style: Optional[Literal["vivid", "natural"]] | NotGiven = NOT_GIVEN
228
259
  number_of_images: Optional[int] = None # Google 用法
229
260
 
@@ -231,71 +262,26 @@ class ModelRequestInput(BaseRequest):
231
262
  "arbitrary_types_allowed": True
232
263
  }
233
264
 
265
+ @field_validator("image", mode="before")
266
+ @classmethod
267
+ def validate_image(cls, v):
268
+ return convert_file_field(v)
269
+
270
+ @field_validator("mask", mode="before")
271
+ @classmethod
272
+ def validate_mask(cls, v):
273
+ return convert_file_field(v)
274
+
234
275
 
235
276
  class ModelRequest(ModelRequestInput):
236
277
  user_context: UserContext # 用户信息
237
278
 
238
279
  @model_validator(mode="after")
239
280
  def validate_by_provider_and_invoke_type(self) -> "ModelRequest":
240
- """根据 provider 和 invoke_type 动态校验具体输入模型字段。"""
241
- # 动态获取 allowed fields
242
- base_allowed = {"provider", "channel", "invoke_type", "user_context"}
243
- google_allowed = base_allowed | set(GoogleGenAiInput.model_fields.keys())
244
- openai_responses_allowed = base_allowed | set(OpenAIResponsesInput.model_fields.keys())
245
- openai_chat_allowed = base_allowed | set(OpenAIChatCompletionsInput.model_fields.keys())
246
- openai_images_allowed = base_allowed | set(OpenAIImagesInput.model_fields.keys())
247
- google_vertexai_images_allowed = base_allowed | set(GoogleVertexAIImagesInput.model_fields.keys())
248
-
249
- # 各模型类型必填字段
250
- google_required_fields = {"model", "contents"}
251
- google_vertexai_image_required_fields = {"model", "prompt"}
252
-
253
- openai_responses_required_fields = {"input", "model"}
254
- openai_chat_required_fields = {"messages", "model"}
255
- openai_image_required_fields = {"prompt"}
256
-
257
- # 选择需要校验的字段集合
258
- # 动态分支逻辑
259
- match (self.provider, self.invoke_type):
260
- case (ProviderType.GOOGLE, InvokeType.GENERATION):
261
- allowed_fields = google_allowed
262
- expected_fields = google_required_fields
263
- case (ProviderType.GOOGLE, InvokeType.IMAGE_GENERATION):
264
- allowed_fields = google_vertexai_images_allowed
265
- expected_fields = google_vertexai_image_required_fields
266
- case ((ProviderType.OPENAI | ProviderType.AZURE), InvokeType.RESPONSES | InvokeType.GENERATION):
267
- allowed_fields = openai_responses_allowed
268
- expected_fields = openai_responses_required_fields
269
- case ((ProviderType.OPENAI | ProviderType.AZURE), InvokeType.CHAT_COMPLETIONS):
270
- allowed_fields = openai_chat_allowed
271
- expected_fields = openai_chat_required_fields
272
- case ((ProviderType.OPENAI | ProviderType.AZURE), InvokeType.IMAGE_GENERATION):
273
- allowed_fields = openai_images_allowed
274
- expected_fields = openai_image_required_fields
275
- case _:
276
- raise ValueError(f"Unsupported provider/invoke_type combination: {self.provider} + {self.invoke_type}")
277
-
278
- # 校验必填字段是否缺失
279
- missing = [field for field in expected_fields if getattr(self, field, None) is None]
280
- if missing:
281
- raise ValueError(
282
- f"Missing required fields for provider={self.provider} and invoke_type={self.invoke_type}: {missing}")
283
-
284
- # 检查是否有非法字段
285
- illegal_fields = []
286
- valid_fields = {"provider", "channel", "invoke_type"} if self.invoke_type == InvokeType.IMAGE_GENERATION else {
287
- "provider", "channel", "invoke_type", "stream"}
288
- for name, value in self.__dict__.items():
289
- if name in valid_fields:
290
- continue
291
- if name not in allowed_fields and value is not None and not isinstance(value, NotGiven):
292
- illegal_fields.append(name)
293
-
294
- if illegal_fields:
295
- raise ValueError(
296
- f"Unsupported fields for provider={self.provider} and invoke_type={self.invoke_type}: {illegal_fields}")
297
-
298
- return self
281
+ return validate_fields_by_provider_and_invoke_type(
282
+ instance=self,
283
+ extra_allowed_fields={"provider", "channel", "invoke_type", "user_context"},
284
+ )
299
285
 
300
286
 
301
287
  class BatchModelRequestItem(ModelRequestInput):
@@ -304,65 +290,10 @@ class BatchModelRequestItem(ModelRequestInput):
304
290
 
305
291
  @model_validator(mode="after")
306
292
  def validate_by_provider_and_invoke_type(self) -> "BatchModelRequestItem":
307
- """根据 provider 和 invoke_type 动态校验具体输入模型字段。"""
308
- # 动态获取 allowed fields
309
- base_allowed = {"provider", "channel", "invoke_type", "user_context", "custom_id"}
310
- google_allowed = base_allowed | set(GoogleGenAiInput.model_fields.keys())
311
- openai_responses_allowed = base_allowed | set(OpenAIResponsesInput.model_fields.keys())
312
- openai_chat_allowed = base_allowed | set(OpenAIChatCompletionsInput.model_fields.keys())
313
- openai_images_allowed = base_allowed | set(OpenAIImagesInput.model_fields.keys())
314
- google_vertexai_images_allowed = base_allowed | set(GoogleVertexAIImagesInput.model_fields.keys())
315
-
316
- # 各模型类型必填字段
317
- google_required_fields = {"model", "contents"}
318
- google_vertexai_image_required_fields = {"model", "prompt"}
319
-
320
- openai_responses_required_fields = {"input", "model"}
321
- openai_chat_required_fields = {"messages", "model"}
322
- openai_image_required_fields = {"prompt"}
323
-
324
- # 选择需要校验的字段集合
325
- # 动态分支逻辑
326
- match (self.provider, self.invoke_type):
327
- case (ProviderType.GOOGLE, InvokeType.GENERATION):
328
- allowed_fields = google_allowed
329
- expected_fields = google_required_fields
330
- case (ProviderType.GOOGLE, InvokeType.IMAGE_GENERATION):
331
- allowed_fields = google_vertexai_images_allowed
332
- expected_fields = google_vertexai_image_required_fields
333
- case ((ProviderType.OPENAI | ProviderType.AZURE), InvokeType.RESPONSES | InvokeType.GENERATION):
334
- allowed_fields = openai_responses_allowed
335
- expected_fields = openai_responses_required_fields
336
- case ((ProviderType.OPENAI | ProviderType.AZURE), InvokeType.CHAT_COMPLETIONS):
337
- allowed_fields = openai_chat_allowed
338
- expected_fields = openai_chat_required_fields
339
- case ((ProviderType.OPENAI | ProviderType.AZURE), InvokeType.IMAGE_GENERATION):
340
- allowed_fields = openai_images_allowed
341
- expected_fields = openai_image_required_fields
342
- case _:
343
- raise ValueError(f"Unsupported provider/invoke_type combination: {self.provider} + {self.invoke_type}")
344
-
345
- # 校验必填字段是否缺失
346
- missing = [field for field in expected_fields if getattr(self, field, None) is None]
347
- if missing:
348
- raise ValueError(
349
- f"Missing required fields for provider={self.provider} and invoke_type={self.invoke_type}: {missing}")
350
-
351
- # 检查是否有非法字段
352
- illegal_fields = []
353
- valid_fields = {"provider", "channel", "invoke_type"} if self.invoke_type == InvokeType.IMAGE_GENERATION else {
354
- "provider", "channel", "invoke_type", "stream"}
355
- for name, value in self.__dict__.items():
356
- if name in valid_fields:
357
- continue
358
- if name not in allowed_fields and value is not None and not isinstance(value, NotGiven):
359
- illegal_fields.append(name)
360
-
361
- if illegal_fields:
362
- raise ValueError(
363
- f"Unsupported fields for provider={self.provider} and invoke_type={self.invoke_type}: {illegal_fields}")
364
-
365
- return self
293
+ return validate_fields_by_provider_and_invoke_type(
294
+ instance=self,
295
+ extra_allowed_fields={"provider", "channel", "invoke_type", "user_context", "custom_id"},
296
+ )
366
297
 
367
298
 
368
299
  class BatchModelRequest(BaseModel):
@@ -17,7 +17,8 @@ from .exceptions import ConnectionError
17
17
  from .generated import model_service_pb2, model_service_pb2_grpc
18
18
  from .schemas import BatchModelResponse, ModelResponse
19
19
  from .schemas.inputs import GoogleGenAiInput, GoogleVertexAIImagesInput, OpenAIResponsesInput, \
20
- OpenAIChatCompletionsInput, OpenAIImagesInput, BatchModelRequest, ModelRequest
20
+ OpenAIChatCompletionsInput, OpenAIImagesInput, OpenAIImagesEditInput, BatchModelRequest, ModelRequest
21
+ from .json_formatter import JSONFormatter
21
22
 
22
23
  logger = logging.getLogger(__name__)
23
24
 
@@ -37,8 +38,8 @@ if not logger.hasHandlers():
37
38
  # 创建日志处理器,输出到控制台
38
39
  console_handler = logging.StreamHandler()
39
40
 
40
- # 设置日志格式
41
- formatter = logging.Formatter('%(asctime)s [%(levelname)s] [%(request_id)s] %(message)s')
41
+ # 使用 JSON 格式化器
42
+ formatter = JSONFormatter()
42
43
  console_handler.setFormatter(formatter)
43
44
 
44
45
  # 为当前记录器添加处理器
@@ -175,15 +176,18 @@ class TamarModelClient:
175
176
  except (grpc.RpcError) as e:
176
177
  if e.code() in {grpc.StatusCode.UNAVAILABLE, grpc.StatusCode.DEADLINE_EXCEEDED}:
177
178
  retry_count += 1
178
- logger.error(f"❌ gRPC error {e.code()}, retrying {retry_count}/{self.max_retries}...")
179
+ logger.info(f"❌ gRPC error {e.code()}, retrying {retry_count}/{self.max_retries}...",
180
+ extra={"log_type": "info", "data": {"retry_count": retry_count, "max_retries": self.max_retries, "error_code": str(e.code())}})
179
181
  if retry_count < self.max_retries:
180
182
  delay = self.retry_delay * (2 ** (retry_count - 1))
181
183
  time.sleep(delay)
182
184
  else:
183
- logger.error(f"❌ Max retry reached for {e.code()}")
185
+ logger.error(f"❌ Max retry reached for {e.code()}",
186
+ extra={"log_type": "info", "data": {"error_code": str(e.code()), "max_retries_reached": True}})
184
187
  raise
185
188
  else:
186
- logger.error(f"❌ Non-retryable gRPC error: {e}", exc_info=True)
189
+ logger.error(f"❌ Non-retryable gRPC error: {e}", exc_info=True,
190
+ extra={"log_type": "info", "data": {"error_code": str(e.code()) if hasattr(e, 'code') else None, "retryable": False}})
187
191
  raise
188
192
 
189
193
  def _build_auth_metadata(self, request_id: str) -> list:
@@ -216,35 +220,43 @@ class TamarModelClient:
216
220
  credentials,
217
221
  options=options
218
222
  )
219
- logger.info("🔐 Using secure gRPC channel (TLS enabled)")
223
+ logger.info("🔐 Using secure gRPC channel (TLS enabled)",
224
+ extra={"log_type": "info", "data": {"tls_enabled": True, "server_address": self.server_address}})
220
225
  else:
221
226
  self.channel = grpc.insecure_channel(
222
227
  self.server_address,
223
228
  options=options
224
229
  )
225
- logger.info("🔓 Using insecure gRPC channel (TLS disabled)")
230
+ logger.info("🔓 Using insecure gRPC channel (TLS disabled)",
231
+ extra={"log_type": "info", "data": {"tls_enabled": False, "server_address": self.server_address}})
226
232
 
227
233
  # Wait for the channel to be ready (synchronously)
228
234
  grpc.channel_ready_future(self.channel).result() # This is blocking in sync mode
229
235
 
230
236
  self.stub = model_service_pb2_grpc.ModelServiceStub(self.channel)
231
- logger.info(f"✅ gRPC channel initialized to {self.server_address}")
237
+ logger.info(f"✅ gRPC channel initialized to {self.server_address}",
238
+ extra={"log_type": "info", "data": {"status": "success", "server_address": self.server_address}})
232
239
  return
233
240
  except grpc.FutureTimeoutError as e:
234
- logger.error(f"❌ gRPC channel initialization timed out: {str(e)}", exc_info=True)
241
+ logger.error(f"❌ gRPC channel initialization timed out: {str(e)}", exc_info=True,
242
+ extra={"log_type": "info", "data": {"error_type": "timeout", "server_address": self.server_address}})
235
243
  except grpc.RpcError as e:
236
- logger.error(f"❌ gRPC channel initialization failed: {str(e)}", exc_info=True)
244
+ logger.error(f"❌ gRPC channel initialization failed: {str(e)}", exc_info=True,
245
+ extra={"log_type": "info", "data": {"error_type": "rpc_error", "server_address": self.server_address}})
237
246
  except Exception as e:
238
- logger.error(f"❌ Unexpected error during channel initialization: {str(e)}", exc_info=True)
247
+ logger.error(f"❌ Unexpected error during channel initialization: {str(e)}", exc_info=True,
248
+ extra={"log_type": "info", "data": {"error_type": "unexpected", "server_address": self.server_address}})
239
249
 
240
250
  retry_count += 1
241
251
  if retry_count > self.max_retries:
242
- logger.error(f"❌ Failed to initialize gRPC channel after {self.max_retries} retries.", exc_info=True)
252
+ logger.error(f"❌ Failed to initialize gRPC channel after {self.max_retries} retries.", exc_info=True,
253
+ extra={"log_type": "info", "data": {"max_retries_reached": True, "server_address": self.server_address}})
243
254
  raise ConnectionError(f"❌ Failed to initialize gRPC channel after {self.max_retries} retries.")
244
255
 
245
256
  # 指数退避:延迟时间 = retry_delay * (2 ^ (retry_count - 1))
246
257
  delay = self.retry_delay * (2 ** (retry_count - 1))
247
- logger.info(f"🚀 Retrying connection (attempt {retry_count}/{self.max_retries}) after {delay:.2f}s delay...")
258
+ logger.info(f"🚀 Retrying connection (attempt {retry_count}/{self.max_retries}) after {delay:.2f}s delay...",
259
+ extra={"log_type": "info", "data": {"retry_count": retry_count, "max_retries": self.max_retries, "delay": delay}})
248
260
  time.sleep(delay) # Blocking sleep in sync version
249
261
 
250
262
  def _stream(self, request, metadata, invoke_timeout) -> Iterator[ModelResponse]:
@@ -256,6 +268,66 @@ class TamarModelClient:
256
268
  raw_response=json.loads(response.raw_response) if response.raw_response else None,
257
269
  request_id=response.request_id if response.request_id else None,
258
270
  )
271
+
272
+ def _stream_with_logging(self, request, metadata, invoke_timeout, start_time, model_request) -> Iterator[ModelResponse]:
273
+ """流式响应的包装器,用于记录完整的响应日志"""
274
+ total_content = ""
275
+ final_usage = None
276
+ error_occurred = None
277
+ chunk_count = 0
278
+
279
+ try:
280
+ for response in self._stream(request, metadata, invoke_timeout):
281
+ chunk_count += 1
282
+ if response.content:
283
+ total_content += response.content
284
+ if response.usage:
285
+ final_usage = response.usage
286
+ if response.error:
287
+ error_occurred = response.error
288
+ yield response
289
+
290
+ # 流式响应完成,记录成功日志
291
+ duration = time.time() - start_time
292
+ logger.info(
293
+ f"✅ Stream completed successfully | chunks: {chunk_count}",
294
+ extra={
295
+ "log_type": "response",
296
+ "uri": f"/invoke/{model_request.provider.value}/{model_request.invoke_type.value}",
297
+ "duration": duration,
298
+ "data": {
299
+ "provider": model_request.provider.value,
300
+ "invoke_type": model_request.invoke_type.value,
301
+ "model": model_request.model,
302
+ "stream": True,
303
+ "chunks_count": chunk_count,
304
+ "total_length": len(total_content),
305
+ "usage": final_usage
306
+ }
307
+ }
308
+ )
309
+ except Exception as e:
310
+ # 流式响应出错,记录错误日志
311
+ duration = time.time() - start_time
312
+ logger.error(
313
+ f"❌ Stream failed after {chunk_count} chunks: {str(e)}",
314
+ exc_info=True,
315
+ extra={
316
+ "log_type": "response",
317
+ "uri": f"/invoke/{model_request.provider.value}/{model_request.invoke_type.value}",
318
+ "duration": duration,
319
+ "data": {
320
+ "provider": model_request.provider.value,
321
+ "invoke_type": model_request.invoke_type.value,
322
+ "model": model_request.model,
323
+ "stream": True,
324
+ "chunks_count": chunk_count,
325
+ "error_type": type(e).__name__,
326
+ "partial_content_length": len(total_content)
327
+ }
328
+ }
329
+ )
330
+ raise
259
331
 
260
332
  def _invoke_request(self, request, metadata, invoke_timeout):
261
333
  response = self.stub.Invoke(request, metadata=metadata, timeout=invoke_timeout)
@@ -298,8 +370,22 @@ class TamarModelClient:
298
370
  metadata = self._build_auth_metadata(request_id) # 将 request_id 加入到请求头
299
371
 
300
372
  # 记录开始日志
373
+ start_time = time.time()
301
374
  logger.info(
302
- f"🔵 Request Start | request_id: {request_id} | provider: {model_request.provider} | invoke_type: {model_request.invoke_type} | model_request: {model_request}")
375
+ f"🔵 Request Start | request_id: {request_id} | provider: {model_request.provider} | invoke_type: {model_request.invoke_type}",
376
+ extra={
377
+ "log_type": "request",
378
+ "uri": f"/invoke/{model_request.provider.value}/{model_request.invoke_type.value}",
379
+ "data": {
380
+ "provider": model_request.provider.value,
381
+ "invoke_type": model_request.invoke_type.value,
382
+ "model": model_request.model,
383
+ "stream": model_request.stream,
384
+ "org_id": model_request.user_context.org_id,
385
+ "user_id": model_request.user_context.user_id,
386
+ "client_type": model_request.user_context.client_type
387
+ }
388
+ })
303
389
 
304
390
  # 动态根据 provider/invoke_type 决定使用哪个 input 字段
305
391
  try:
@@ -316,6 +402,8 @@ class TamarModelClient:
316
402
  allowed_fields = OpenAIChatCompletionsInput.model_fields.keys()
317
403
  case ((ProviderType.OPENAI | ProviderType.AZURE), InvokeType.IMAGE_GENERATION):
318
404
  allowed_fields = OpenAIImagesInput.model_fields.keys()
405
+ case ((ProviderType.OPENAI | ProviderType.AZURE), InvokeType.IMAGE_EDIT_GENERATION):
406
+ allowed_fields = OpenAIImagesEditInput.model_fields.keys()
319
407
  case _:
320
408
  raise ValueError(
321
409
  f"Unsupported provider/invoke_type combination: {model_request.provider} + {model_request.invoke_type}")
@@ -355,16 +443,62 @@ class TamarModelClient:
355
443
  try:
356
444
  invoke_timeout = timeout or self.default_invoke_timeout
357
445
  if model_request.stream:
358
- return self._retry_request(self._stream, request, metadata, invoke_timeout)
446
+ # 对于流式响应,使用带日志记录的包装器
447
+ return self._stream_with_logging(request, metadata, invoke_timeout, start_time, model_request)
359
448
  else:
360
- return self._retry_request(self._invoke_request, request, metadata, invoke_timeout)
449
+ result = self._retry_request(self._invoke_request, request, metadata, invoke_timeout)
450
+
451
+ # 记录非流式响应的成功日志
452
+ duration = time.time() - start_time
453
+ logger.info(
454
+ f"✅ Request completed successfully",
455
+ extra={
456
+ "log_type": "response",
457
+ "uri": f"/invoke/{model_request.provider.value}/{model_request.invoke_type.value}",
458
+ "duration": duration,
459
+ "data": {
460
+ "provider": model_request.provider.value,
461
+ "invoke_type": model_request.invoke_type.value,
462
+ "model": model_request.model,
463
+ "stream": False,
464
+ "content_length": len(result.content) if result.content else 0,
465
+ "usage": result.usage
466
+ }
467
+ }
468
+ )
469
+ return result
361
470
  except grpc.RpcError as e:
471
+ duration = time.time() - start_time
362
472
  error_message = f"❌ Invoke gRPC failed: {str(e)}"
363
- logger.error(error_message, exc_info=True)
473
+ logger.error(error_message, exc_info=True,
474
+ extra={
475
+ "log_type": "response",
476
+ "uri": f"/invoke/{model_request.provider.value}/{model_request.invoke_type.value}",
477
+ "duration": duration,
478
+ "data": {
479
+ "error_type": "grpc_error",
480
+ "error_code": str(e.code()) if hasattr(e, 'code') else None,
481
+ "provider": model_request.provider.value,
482
+ "invoke_type": model_request.invoke_type.value,
483
+ "model": model_request.model
484
+ }
485
+ })
364
486
  raise e
365
487
  except Exception as e:
488
+ duration = time.time() - start_time
366
489
  error_message = f"❌ Invoke other error: {str(e)}"
367
- logger.error(error_message, exc_info=True)
490
+ logger.error(error_message, exc_info=True,
491
+ extra={
492
+ "log_type": "response",
493
+ "uri": f"/invoke/{model_request.provider.value}/{model_request.invoke_type.value}",
494
+ "duration": duration,
495
+ "data": {
496
+ "error_type": "other_error",
497
+ "provider": model_request.provider.value,
498
+ "invoke_type": model_request.invoke_type.value,
499
+ "model": model_request.model
500
+ }
501
+ })
368
502
  raise e
369
503
 
370
504
  def invoke_batch(self, batch_request_model: BatchModelRequest, timeout: Optional[float] = None,
@@ -394,8 +528,19 @@ class TamarModelClient:
394
528
  metadata = self._build_auth_metadata(request_id) # 将 request_id 加入到请求头
395
529
 
396
530
  # 记录开始日志
531
+ start_time = time.time()
397
532
  logger.info(
398
- f"🔵 Batch Request Start | request_id: {request_id} | batch_size: {len(batch_request_model.items)} | batch_request_model: {batch_request_model}")
533
+ f"🔵 Batch Request Start | request_id: {request_id} | batch_size: {len(batch_request_model.items)}",
534
+ extra={
535
+ "log_type": "request",
536
+ "uri": "/batch_invoke",
537
+ "data": {
538
+ "batch_size": len(batch_request_model.items),
539
+ "org_id": batch_request_model.user_context.org_id,
540
+ "user_id": batch_request_model.user_context.user_id,
541
+ "client_type": batch_request_model.user_context.client_type
542
+ }
543
+ })
399
544
 
400
545
  # 构造批量请求
401
546
  items = []
@@ -413,6 +558,8 @@ class TamarModelClient:
413
558
  allowed_fields = OpenAIChatCompletionsInput.model_fields.keys()
414
559
  case ((ProviderType.OPENAI | ProviderType.AZURE), InvokeType.IMAGE_GENERATION):
415
560
  allowed_fields = OpenAIImagesInput.model_fields.keys()
561
+ case ((ProviderType.OPENAI | ProviderType.AZURE), InvokeType.IMAGE_EDIT_GENERATION):
562
+ allowed_fields = OpenAIImagesEditInput.model_fields.keys()
416
563
  case _:
417
564
  raise ValueError(
418
565
  f"Unsupported provider/invoke_type combination: {model_request_item.provider} + {model_request_item.invoke_type}")
@@ -468,17 +615,54 @@ class TamarModelClient:
468
615
  error=res_item.error or None,
469
616
  custom_id=res_item.custom_id if res_item.custom_id else None
470
617
  ))
471
- return BatchModelResponse(
618
+ batch_response = BatchModelResponse(
472
619
  request_id=response.request_id if response.request_id else None,
473
620
  responses=result
474
621
  )
622
+
623
+ # 记录成功日志
624
+ duration = time.time() - start_time
625
+ logger.info(
626
+ f"✅ Batch request completed successfully",
627
+ extra={
628
+ "log_type": "response",
629
+ "uri": "/batch_invoke",
630
+ "duration": duration,
631
+ "data": {
632
+ "batch_size": len(batch_request_model.items),
633
+ "responses_count": len(result)
634
+ }
635
+ }
636
+ )
637
+ return batch_response
475
638
  except grpc.RpcError as e:
639
+ duration = time.time() - start_time
476
640
  error_message = f"❌ BatchInvoke gRPC failed: {str(e)}"
477
- logger.error(error_message, exc_info=True)
641
+ logger.error(error_message, exc_info=True,
642
+ extra={
643
+ "log_type": "response",
644
+ "uri": "/batch_invoke",
645
+ "duration": duration,
646
+ "data": {
647
+ "error_type": "grpc_error",
648
+ "error_code": str(e.code()) if hasattr(e, 'code') else None,
649
+ "batch_size": len(batch_request_model.items)
650
+ }
651
+ })
478
652
  raise e
479
653
  except Exception as e:
654
+ duration = time.time() - start_time
480
655
  error_message = f"❌ BatchInvoke other error: {str(e)}"
481
- logger.error(error_message, exc_info=True)
656
+ logger.error(error_message, exc_info=True,
657
+ extra={
658
+ "log_type": "response",
659
+ "uri": "/batch_invoke",
660
+ "duration": duration,
661
+ "data": {
662
+ "error_type": "other_error",
663
+ "batch_size": len(batch_request_model.items)
664
+ }
665
+ })
482
666
  raise e
483
667
 
484
668
  def close(self):
@@ -486,7 +670,8 @@ class TamarModelClient:
486
670
  if self.channel and not self._closed:
487
671
  self.channel.close()
488
672
  self._closed = True
489
- logger.info("✅ gRPC channel closed")
673
+ logger.info("✅ gRPC channel closed",
674
+ extra={"log_type": "info", "data": {"status": "success"}})
490
675
 
491
676
  def _safe_sync_close(self):
492
677
  """进程退出时自动关闭 channel(事件循环处理兼容)"""
@@ -494,7 +679,8 @@ class TamarModelClient:
494
679
  try:
495
680
  self.close() # 直接调用关闭方法
496
681
  except Exception as e:
497
- logger.error(f"❌ gRPC channel close failed at exit: {e}")
682
+ logger.error(f"❌ gRPC channel close failed at exit: {e}",
683
+ extra={"log_type": "info", "data": {"status": "failed", "error": str(e)}})
498
684
 
499
685
  def __enter__(self):
500
686
  """同步初始化连接"""
@@ -0,0 +1,118 @@
1
+ from openai import NotGiven
2
+ from pydantic import BaseModel
3
+ from typing import Any
4
+ import os, mimetypes
5
+
6
+ def convert_file_field(value: Any) -> Any:
7
+ def is_file_like(obj):
8
+ return hasattr(obj, "read") and callable(obj.read)
9
+
10
+ def infer_mimetype(filename: str) -> str:
11
+ mime, _ = mimetypes.guess_type(filename)
12
+ return mime or "application/octet-stream"
13
+
14
+ def convert_item(item):
15
+ if is_file_like(item):
16
+ filename = os.path.basename(getattr(item, "name", "file.png"))
17
+ content_type = infer_mimetype(filename)
18
+ content = item.read()
19
+ if hasattr(item, "seek"):
20
+ item.seek(0)
21
+ return (filename, content, content_type)
22
+ elif isinstance(item, tuple):
23
+ parts = list(item)
24
+ if len(parts) > 1:
25
+ maybe_file = parts[1]
26
+ if is_file_like(maybe_file):
27
+ content = maybe_file.read()
28
+ if hasattr(maybe_file, "seek"):
29
+ maybe_file.seek(0)
30
+ parts[1] = content
31
+ elif not isinstance(maybe_file, (bytes, bytearray)):
32
+ raise ValueError(f"Unsupported second element in tuple: {type(maybe_file)}")
33
+ if len(parts) == 2:
34
+ parts.append(infer_mimetype(os.path.basename(parts[0] or "file.png")))
35
+ return tuple(parts)
36
+ else:
37
+ return item
38
+
39
+ if value is None:
40
+ return value
41
+ elif isinstance(value, list):
42
+ return [convert_item(v) for v in value]
43
+ else:
44
+ return convert_item(value)
45
+
46
+
47
+ def validate_fields_by_provider_and_invoke_type(
48
+ instance: BaseModel,
49
+ extra_allowed_fields: set[str],
50
+ extra_required_fields: set[str] = set()
51
+ ) -> BaseModel:
52
+ """
53
+ 通用的字段校验逻辑,根据 provider 和 invoke_type 动态检查字段合法性和必填字段。
54
+ 适用于 ModelRequest 和 BatchModelRequestItem。
55
+ """
56
+ from tamar_model_client.enums import ProviderType, InvokeType
57
+ from tamar_model_client.schemas.inputs import GoogleGenAiInput, OpenAIResponsesInput, OpenAIChatCompletionsInput, \
58
+ OpenAIImagesInput, OpenAIImagesEditInput, GoogleVertexAIImagesInput
59
+
60
+ google_allowed = extra_allowed_fields | set(GoogleGenAiInput.model_fields)
61
+ openai_responses_allowed = extra_allowed_fields | set(OpenAIResponsesInput.model_fields)
62
+ openai_chat_allowed = extra_allowed_fields | set(OpenAIChatCompletionsInput.model_fields)
63
+ openai_images_allowed = extra_allowed_fields | set(OpenAIImagesInput.model_fields)
64
+ openai_images_edit_allowed = extra_allowed_fields | set(OpenAIImagesEditInput.model_fields)
65
+ google_vertexai_images_allowed = extra_allowed_fields | set(GoogleVertexAIImagesInput.model_fields)
66
+
67
+ google_required = {"model", "contents"}
68
+ google_vertex_required = {"model", "prompt"}
69
+ openai_resp_required = {"input", "model"}
70
+ openai_chat_required = {"messages", "model"}
71
+ openai_img_required = {"prompt"}
72
+ openai_edit_required = {"image", "prompt"}
73
+
74
+ match (instance.provider, instance.invoke_type):
75
+ case (ProviderType.GOOGLE, InvokeType.GENERATION):
76
+ allowed = google_allowed
77
+ required = google_required
78
+ case (ProviderType.GOOGLE, InvokeType.IMAGE_GENERATION):
79
+ allowed = google_vertexai_images_allowed
80
+ required = google_vertex_required
81
+ case ((ProviderType.OPENAI | ProviderType.AZURE), InvokeType.RESPONSES | InvokeType.GENERATION):
82
+ allowed = openai_responses_allowed
83
+ required = openai_resp_required
84
+ case ((ProviderType.OPENAI | ProviderType.AZURE), InvokeType.CHAT_COMPLETIONS):
85
+ allowed = openai_chat_allowed
86
+ required = openai_chat_required
87
+ case ((ProviderType.OPENAI | ProviderType.AZURE), InvokeType.IMAGE_GENERATION):
88
+ allowed = openai_images_allowed
89
+ required = openai_img_required
90
+ case ((ProviderType.OPENAI | ProviderType.AZURE), InvokeType.IMAGE_EDIT_GENERATION):
91
+ allowed = openai_images_edit_allowed
92
+ required = openai_edit_required
93
+ case _:
94
+ raise ValueError(f"Unsupported provider/invoke_type: {instance.provider} + {instance.invoke_type}")
95
+
96
+ required = required | extra_required_fields
97
+
98
+ missing = [f for f in required if getattr(instance, f, None) is None]
99
+ if missing:
100
+ raise ValueError(
101
+ f"Missing required fields for provider={instance.provider} and invoke_type={instance.invoke_type}: {missing}")
102
+
103
+ illegal = []
104
+ valid_fields = {"provider", "channel", "invoke_type"}
105
+ if getattr(instance, "stream", None) is not None:
106
+ valid_fields.add("stream")
107
+
108
+ for k, v in instance.__dict__.items():
109
+ if k in valid_fields:
110
+ continue
111
+ if k not in allowed and v is not None and not isinstance(v, NotGiven):
112
+ illegal.append(k)
113
+
114
+ if illegal:
115
+ raise ValueError(
116
+ f"Unsupported fields for provider={instance.provider} and invoke_type={instance.invoke_type}: {illegal}")
117
+
118
+ return instance
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tamar-model-client
3
- Version: 0.1.16
3
+ Version: 0.1.18
4
4
  Summary: A Python SDK for interacting with the Model Manager gRPC service
5
5
  Home-page: http://gitlab.tamaredge.top/project-tap/AgentOS/model-manager-client
6
6
  Author: Oscar Ou
@@ -1,19 +1,21 @@
1
- tamar_model_client/__init__.py,sha256=LMECAuDARWHV1XzH3msoDXcyurS2eihRQmBy26_PUE0,328
2
- tamar_model_client/async_client.py,sha256=K14GigYdcsHQg83PP1YH3wxxZEUwvFlIFMWdFfegnhc,25655
1
+ tamar_model_client/__init__.py,sha256=gT2OwD5e4nXAZXIXG9QRn3DwwyDZb-LlICU2vBJX7FU,393
2
+ tamar_model_client/async_client.py,sha256=SVRxIRAgVa7Mfm2krxFDUdjcs1W7uwdjlRa-9msIIDg,36810
3
3
  tamar_model_client/auth.py,sha256=gbwW5Aakeb49PMbmYvrYlVx1mfyn1LEDJ4qQVs-9DA4,438
4
4
  tamar_model_client/exceptions.py,sha256=jYU494OU_NeIa4X393V-Y73mTNm0JZ9yZApnlOM9CJQ,332
5
- tamar_model_client/sync_client.py,sha256=B4itGuFy1T6g2pnC-95RbaaOqtRIYLeW9eah-CRFRM0,22486
5
+ tamar_model_client/json_formatter.py,sha256=9iO4Qn7FiyPTjcn07uHuP4q80upVlmqI_P1UV12YPxI,991
6
+ tamar_model_client/sync_client.py,sha256=bWPkGMcWE73Qtif0thT1lAtF_Kmtvd6j8KV3Jb-N_T4,32493
7
+ tamar_model_client/utils.py,sha256=Kn6pFz9GEC96H4eejEax66AkzvsrXI3WCSDtgDjnVTI,5238
6
8
  tamar_model_client/enums/__init__.py,sha256=3cYYn8ztNGBa_pI_5JGRVYf2QX8fkBVWdjID1PLvoBQ,182
7
9
  tamar_model_client/enums/channel.py,sha256=wCzX579nNpTtwzGeS6S3Ls0UzVAgsOlfy4fXMzQTCAw,199
8
- tamar_model_client/enums/invoke.py,sha256=WufImoN_87ZjGyzYitZkhNNFefWJehKfLtyP-DTBYlA,267
10
+ tamar_model_client/enums/invoke.py,sha256=Up87myAg4-0SDJV5a82ggPDpYHSLEtIco8BF_5Ph1nY,322
9
11
  tamar_model_client/enums/providers.py,sha256=L_bX75K6KnWURoFizoitZ1Ybza7bmYDqXecNzNpgIrI,165
10
12
  tamar_model_client/generated/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
13
  tamar_model_client/generated/model_service_pb2.py,sha256=RI6wNSmgmylzWPedFfPxx938UzS7kcPR58YTzYshcL8,3066
12
14
  tamar_model_client/generated/model_service_pb2_grpc.py,sha256=k4tIbp3XBxdyuOVR18Ung_4SUryONB51UYf_uUEl6V4,5145
13
15
  tamar_model_client/schemas/__init__.py,sha256=AxuI-TcvA4OMTj2FtK4wAItvz9LrK_293pu3cmMLE7k,394
14
- tamar_model_client/schemas/inputs.py,sha256=AlvjTRp_UGnbmqzv4OJ3RjH4UGErzSNfKS8Puj6oEXQ,19088
16
+ tamar_model_client/schemas/inputs.py,sha256=dz1m8NbUIxA99JXZc8WlyzbKpDuz1lEzx3VghC33zYI,14625
15
17
  tamar_model_client/schemas/outputs.py,sha256=M_fcqUtXPJnfiLabHlyA8BorlC5pYkf5KLjXO1ysKIQ,1031
16
- tamar_model_client-0.1.16.dist-info/METADATA,sha256=YaPEPgdIVcJVSZ55rzx-G5TtjHTT0teXJspOz5O3vyE,16562
17
- tamar_model_client-0.1.16.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
18
- tamar_model_client-0.1.16.dist-info/top_level.txt,sha256=_LfDhPv_fvON0PoZgQuo4M7EjoWtxPRoQOBJziJmip8,19
19
- tamar_model_client-0.1.16.dist-info/RECORD,,
18
+ tamar_model_client-0.1.18.dist-info/METADATA,sha256=od6DIz8FluOEDUwfst42_pNvwBO1nZUTjWzTDqZJLwo,16562
19
+ tamar_model_client-0.1.18.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
20
+ tamar_model_client-0.1.18.dist-info/top_level.txt,sha256=_LfDhPv_fvON0PoZgQuo4M7EjoWtxPRoQOBJziJmip8,19
21
+ tamar_model_client-0.1.18.dist-info/RECORD,,