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.
- tamar_model_client/__init__.py +2 -0
- tamar_model_client/async_client.py +227 -32
- tamar_model_client/enums/invoke.py +2 -1
- tamar_model_client/json_formatter.py +26 -0
- tamar_model_client/schemas/inputs.py +54 -123
- tamar_model_client/sync_client.py +211 -25
- tamar_model_client/utils.py +118 -0
- {tamar_model_client-0.1.16.dist-info → tamar_model_client-0.1.18.dist-info}/METADATA +1 -1
- {tamar_model_client-0.1.16.dist-info → tamar_model_client-0.1.18.dist-info}/RECORD +11 -9
- {tamar_model_client-0.1.16.dist-info → tamar_model_client-0.1.18.dist-info}/WHEEL +0 -0
- {tamar_model_client-0.1.16.dist-info → tamar_model_client-0.1.18.dist-info}/top_level.txt +0 -0
tamar_model_client/__init__.py
CHANGED
@@ -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 =
|
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.
|
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.
|
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.
|
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.
|
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}
|
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
|
-
|
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
|
-
|
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)}
|
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
|
-
|
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.
|
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 自动初始化连接"""
|
@@ -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[
|
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
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
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
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
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 =
|
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.
|
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}
|
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
|
-
|
446
|
+
# 对于流式响应,使用带日志记录的包装器
|
447
|
+
return self._stream_with_logging(request, metadata, invoke_timeout, start_time, model_request)
|
359
448
|
else:
|
360
|
-
|
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)}
|
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
|
-
|
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,19 +1,21 @@
|
|
1
|
-
tamar_model_client/__init__.py,sha256=
|
2
|
-
tamar_model_client/async_client.py,sha256=
|
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/
|
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=
|
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=
|
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.
|
17
|
-
tamar_model_client-0.1.
|
18
|
-
tamar_model_client-0.1.
|
19
|
-
tamar_model_client-0.1.
|
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,,
|
File without changes
|
File without changes
|