synth-ai 0.2.4.dev7__py3-none-any.whl → 0.2.4.dev8__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.
- synth_ai/__init__.py +1 -1
- synth_ai/cli/balance.py +3 -15
- synth_ai/config/base_url.py +47 -0
- synth_ai/http.py +102 -0
- synth_ai/inference/__init__.py +7 -0
- synth_ai/inference/client.py +20 -0
- synth_ai/jobs/client.py +246 -0
- synth_ai/learning/__init__.py +24 -0
- synth_ai/learning/client.py +149 -0
- synth_ai/learning/config.py +43 -0
- synth_ai/learning/constants.py +29 -0
- synth_ai/learning/ft_client.py +59 -0
- synth_ai/learning/health.py +43 -0
- synth_ai/learning/jobs.py +205 -0
- synth_ai/learning/rl_client.py +256 -0
- synth_ai/learning/sse.py +58 -0
- synth_ai/learning/validators.py +48 -0
- synth_ai/lm/core/main_v3.py +13 -0
- synth_ai/lm/core/synth_models.py +48 -0
- synth_ai/lm/core/vendor_clients.py +9 -6
- synth_ai/lm/vendors/core/openai_api.py +31 -3
- synth_ai/lm/vendors/openai_standard.py +45 -14
- synth_ai/lm/vendors/supported/custom_endpoint.py +12 -2
- synth_ai/lm/vendors/synth_client.py +372 -28
- synth_ai/rl/__init__.py +30 -0
- synth_ai/rl/contracts.py +32 -0
- synth_ai/rl/env_keys.py +137 -0
- synth_ai/rl/secrets.py +19 -0
- synth_ai/scripts/verify_rewards.py +100 -0
- synth_ai/task/__init__.py +10 -0
- synth_ai/task/contracts.py +120 -0
- synth_ai/task/health.py +28 -0
- synth_ai/task/validators.py +12 -0
- synth_ai/tracing_v3/hooks.py +3 -1
- synth_ai/tracing_v3/session_tracer.py +123 -2
- synth_ai/tracing_v3/turso/manager.py +218 -0
- synth_ai/tracing_v3/turso/models.py +53 -0
- synth_ai-0.2.4.dev8.dist-info/METADATA +635 -0
- {synth_ai-0.2.4.dev7.dist-info → synth_ai-0.2.4.dev8.dist-info}/RECORD +43 -25
- synth_ai/tui/__init__.py +0 -1
- synth_ai/tui/__main__.py +0 -13
- synth_ai/tui/cli/__init__.py +0 -1
- synth_ai/tui/cli/query_experiments.py +0 -164
- synth_ai/tui/cli/query_experiments_v3.py +0 -164
- synth_ai/tui/dashboard.py +0 -340
- synth_ai-0.2.4.dev7.dist-info/METADATA +0 -193
- {synth_ai-0.2.4.dev7.dist-info → synth_ai-0.2.4.dev8.dist-info}/WHEEL +0 -0
- {synth_ai-0.2.4.dev7.dist-info → synth_ai-0.2.4.dev8.dist-info}/entry_points.txt +0 -0
- {synth_ai-0.2.4.dev7.dist-info → synth_ai-0.2.4.dev8.dist-info}/licenses/LICENSE +0 -0
- {synth_ai-0.2.4.dev7.dist-info → synth_ai-0.2.4.dev8.dist-info}/top_level.txt +0 -0
synth_ai/learning/sse.py
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import json
|
4
|
+
import time
|
5
|
+
from typing import Any, Callable, Optional
|
6
|
+
|
7
|
+
import aiohttp
|
8
|
+
|
9
|
+
|
10
|
+
def _api_base(b: str) -> str:
|
11
|
+
b = (b or "").rstrip("/")
|
12
|
+
return b if b.endswith("/api") else f"{b}/api"
|
13
|
+
|
14
|
+
|
15
|
+
async def stream_events(
|
16
|
+
base_url: str,
|
17
|
+
api_key: str,
|
18
|
+
job_id: str,
|
19
|
+
*,
|
20
|
+
seconds: int = 60,
|
21
|
+
on_event: Optional[Callable[[dict], None]] = None,
|
22
|
+
) -> None:
|
23
|
+
if seconds <= 0:
|
24
|
+
return
|
25
|
+
headers = {"Accept": "text/event-stream", "Authorization": f"Bearer {api_key}"}
|
26
|
+
candidates = [
|
27
|
+
f"{_api_base(base_url)}/rl/jobs/{job_id}/events?since_seq=0",
|
28
|
+
f"{_api_base(base_url)}/learning/jobs/{job_id}/events?since_seq=0",
|
29
|
+
]
|
30
|
+
for url in candidates:
|
31
|
+
try:
|
32
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=None)) as session:
|
33
|
+
async with session.get(url, headers=headers) as resp:
|
34
|
+
if resp.status != 200:
|
35
|
+
continue
|
36
|
+
start_t = time.time()
|
37
|
+
async for raw in resp.content:
|
38
|
+
line = raw.decode(errors="ignore").strip()
|
39
|
+
if not line or line.startswith(":"):
|
40
|
+
continue
|
41
|
+
if not line.startswith("data:"):
|
42
|
+
continue
|
43
|
+
data = line[5:].strip()
|
44
|
+
try:
|
45
|
+
obj = json.loads(data)
|
46
|
+
except Exception:
|
47
|
+
continue
|
48
|
+
if on_event:
|
49
|
+
try:
|
50
|
+
on_event(obj)
|
51
|
+
except Exception:
|
52
|
+
pass
|
53
|
+
if (time.time() - start_t) >= seconds:
|
54
|
+
return
|
55
|
+
except Exception:
|
56
|
+
continue
|
57
|
+
|
58
|
+
|
@@ -0,0 +1,48 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from pathlib import Path
|
4
|
+
import json
|
5
|
+
from typing import Any, Dict
|
6
|
+
from urllib.parse import urlparse
|
7
|
+
|
8
|
+
|
9
|
+
def validate_training_jsonl(path: str | Path, *, sample_lines: int = 50) -> None:
|
10
|
+
p = Path(path)
|
11
|
+
if not p.exists():
|
12
|
+
raise FileNotFoundError(str(p))
|
13
|
+
lines = p.read_text().splitlines()
|
14
|
+
if not lines:
|
15
|
+
raise ValueError("empty JSONL")
|
16
|
+
for i, line in enumerate(lines[: max(1, sample_lines) ], start=1):
|
17
|
+
if not line.strip():
|
18
|
+
continue
|
19
|
+
try:
|
20
|
+
obj = json.loads(line)
|
21
|
+
except Exception as e:
|
22
|
+
raise ValueError(f"invalid json on line {i}: {e}") from e
|
23
|
+
msgs = obj.get("messages")
|
24
|
+
if not isinstance(msgs, list) or len(msgs) < 2:
|
25
|
+
raise ValueError(f"line {i}: missing messages[] with at least 2 turns")
|
26
|
+
roles = [m.get("role") for m in msgs if isinstance(m, dict)]
|
27
|
+
if not roles or not isinstance(roles[0], str):
|
28
|
+
raise ValueError(f"line {i}: missing first role")
|
29
|
+
for m in msgs:
|
30
|
+
if not isinstance(m, dict):
|
31
|
+
raise ValueError(f"line {i}: non-dict message")
|
32
|
+
if not isinstance(m.get("role"), str) or not isinstance(m.get("content"), str) or not m["content"].strip():
|
33
|
+
raise ValueError(f"line {i}: invalid role/content")
|
34
|
+
|
35
|
+
|
36
|
+
def validate_task_app_url(url: str, *, name: str = "TASK_APP_BASE_URL") -> None:
|
37
|
+
from synth_ai.task.validators import validate_task_app_url as _vt
|
38
|
+
|
39
|
+
_vt(url, name=name)
|
40
|
+
|
41
|
+
|
42
|
+
def validate_trainer_cfg_rl(trainer: Dict[str, Any]) -> None:
|
43
|
+
bs = int(trainer.get("batch_size", 1))
|
44
|
+
gs = int(trainer.get("group_size", 2))
|
45
|
+
if bs < 1:
|
46
|
+
raise ValueError("trainer.batch_size must be >= 1")
|
47
|
+
if gs < 2:
|
48
|
+
raise ValueError("trainer.group_size must be >= 2")
|
synth_ai/lm/core/main_v3.py
CHANGED
@@ -117,7 +117,11 @@ class LM:
|
|
117
117
|
if enable_v2_tracing is not None:
|
118
118
|
enable_v3_tracing = enable_v2_tracing
|
119
119
|
|
120
|
+
# Debug logging
|
121
|
+
print(f"🔍 LM __init__: provider={provider}, vendor={vendor}, model={model}")
|
122
|
+
|
120
123
|
# If vendor not provided, infer from model name
|
124
|
+
# But only if no explicit provider was given
|
121
125
|
if vendor is None and model is not None:
|
122
126
|
# Import vendor detection logic
|
123
127
|
from synth_ai.lm.core.vendor_clients import (
|
@@ -156,6 +160,7 @@ class LM:
|
|
156
160
|
|
157
161
|
self.vendor = vendor
|
158
162
|
self.model = model
|
163
|
+
print(f"🔍 LM final: vendor={self.vendor}, model={self.model}")
|
159
164
|
self.is_structured = is_structured
|
160
165
|
self.structured_outputs_vendor = structured_outputs_vendor
|
161
166
|
self.response_format = response_format
|
@@ -337,6 +342,14 @@ class LM:
|
|
337
342
|
if hasattr(vendor_wrapper, "_hit_api_async"):
|
338
343
|
# OpenAIStandard expects lm_config
|
339
344
|
lm_config = {"temperature": self.temperature, **self.additional_params, **kwargs}
|
345
|
+
# Map convenience enable_thinking => thinking_mode unless explicitly set
|
346
|
+
if "enable_thinking" in lm_config and "thinking_mode" not in lm_config:
|
347
|
+
try:
|
348
|
+
et = lm_config.get("enable_thinking")
|
349
|
+
if isinstance(et, bool):
|
350
|
+
lm_config["thinking_mode"] = "think" if et else "no_think"
|
351
|
+
except Exception:
|
352
|
+
pass
|
340
353
|
if self.json_mode:
|
341
354
|
lm_config["response_format"] = {"type": "json_object"}
|
342
355
|
|
@@ -0,0 +1,48 @@
|
|
1
|
+
"""
|
2
|
+
Synth-supported models registry.
|
3
|
+
|
4
|
+
This module defines the specific models that are supported by Synth's infrastructure.
|
5
|
+
Models are organized by family and size for easy maintenance and extension.
|
6
|
+
|
7
|
+
MAINTENANCE GUIDE:
|
8
|
+
1. Add new model families to the appropriate lists (QWEN_MODELS, OTHER_SYNTH_MODELS)
|
9
|
+
2. Fine-tuned models (ft:) are automatically detected by regex
|
10
|
+
3. Update SYNTH_SUPPORTED_MODELS set when adding new models
|
11
|
+
4. Test changes with: pytest tests/lms/test_qwen_chat_completions.py
|
12
|
+
|
13
|
+
WHY THIS EXISTS:
|
14
|
+
- The previous regex (^.*\/.*$) was too broad and caught unintended models
|
15
|
+
- This provides explicit control over which models use Synth infrastructure
|
16
|
+
- Easier to maintain and debug model routing issues
|
17
|
+
"""
|
18
|
+
|
19
|
+
from typing import List, Set
|
20
|
+
|
21
|
+
# Qwen3 model families supported by Synth
|
22
|
+
QWEN3_MODELS: List[str] = [
|
23
|
+
# Qwen3 base models
|
24
|
+
"Qwen/Qwen3-0.6B",
|
25
|
+
"Qwen/Qwen3-1.7B",
|
26
|
+
"Qwen/Qwen3-4B",
|
27
|
+
"Qwen/Qwen3-8B",
|
28
|
+
"Qwen/Qwen3-14B",
|
29
|
+
"Qwen/Qwen3-32B",
|
30
|
+
|
31
|
+
# Qwen3 specialized variants
|
32
|
+
"Qwen/Qwen3-4B-Instruct-2507",
|
33
|
+
"Qwen/Qwen3-4B-Thinking-2507",
|
34
|
+
]
|
35
|
+
|
36
|
+
# Fine-tuned models pattern - any model starting with "ft:" is considered Synth-compatible
|
37
|
+
# These are dynamically detected, but we can add specific known ones here
|
38
|
+
FINE_TUNED_MODELS: List[str] = [
|
39
|
+
# Add specific fine-tuned models that are known to work with Synth
|
40
|
+
# Examples:
|
41
|
+
# "ft:Qwen/Qwen3-4B-Instruct-2507:ftjob-22",
|
42
|
+
]
|
43
|
+
|
44
|
+
# Combine all Synth-supported models
|
45
|
+
SYNTH_SUPPORTED_MODELS: Set[str] = set(QWEN3_MODELS + FINE_TUNED_MODELS)
|
46
|
+
|
47
|
+
# Export the main set for easy import
|
48
|
+
__all__ = ["SYNTH_SUPPORTED_MODELS", "QWEN3_MODELS", "FINE_TUNED_MODELS"]
|
@@ -21,6 +21,7 @@ from synth_ai.lm.core.all import (
|
|
21
21
|
OpenRouterClient,
|
22
22
|
TogetherClient,
|
23
23
|
)
|
24
|
+
from synth_ai.lm.core.synth_models import SYNTH_SUPPORTED_MODELS
|
24
25
|
|
25
26
|
# Regular expressions to match model names to their respective providers
|
26
27
|
openai_naming_regexes: list[Pattern] = [
|
@@ -39,8 +40,10 @@ gemini_naming_regexes: list[Pattern] = [
|
|
39
40
|
deepseek_naming_regexes: list[Pattern] = [
|
40
41
|
re.compile(r"^deepseek-.*$"),
|
41
42
|
]
|
42
|
-
|
43
|
-
|
43
|
+
# Synth-specific model patterns (Qwen3 and fine-tuned models)
|
44
|
+
synth_naming_regexes: list[Pattern] = [
|
45
|
+
re.compile(r"^ft:.*$"), # Fine-tuned models (ft:model-name)
|
46
|
+
re.compile(r"^Qwen/Qwen3.*$"), # Qwen3 models specifically (Qwen/Qwen3-*)
|
44
47
|
]
|
45
48
|
|
46
49
|
groq_naming_regexes: list[Pattern] = [
|
@@ -79,8 +82,6 @@ openrouter_naming_regexes: list[Pattern] = [
|
|
79
82
|
|
80
83
|
# Custom endpoint patterns - check these before generic patterns
|
81
84
|
custom_endpoint_naming_regexes: list[Pattern] = [
|
82
|
-
# Modal endpoints: org--app.modal.run
|
83
|
-
re.compile(r"^[a-zA-Z0-9\-]+--[a-zA-Z0-9\-]+\.modal\.run$"),
|
84
85
|
# Generic domain patterns for custom endpoints
|
85
86
|
re.compile(r"^[a-zA-Z0-9\-]+\.[a-zA-Z0-9\-]+\.[a-zA-Z]+$"), # domain.tld
|
86
87
|
re.compile(r"^[a-zA-Z0-9\-]+\.[a-zA-Z0-9\-]+\.[a-zA-Z]+\/[a-zA-Z0-9\-\/]+$"), # domain.tld/path
|
@@ -179,7 +180,9 @@ def get_client(
|
|
179
180
|
elif any(regex.match(model_name) for regex in custom_endpoint_naming_regexes):
|
180
181
|
# Custom endpoints are passed as the endpoint URL
|
181
182
|
return CustomEndpointClient(endpoint_url=model_name)
|
182
|
-
elif any(regex.match(model_name) for regex in
|
183
|
-
|
183
|
+
elif (any(regex.match(model_name) for regex in synth_naming_regexes) or
|
184
|
+
model_name in SYNTH_SUPPORTED_MODELS):
|
185
|
+
# Synth models use OpenAI-compatible client with custom endpoint
|
186
|
+
return OpenAIStructuredOutputClient(synth_logging=synth_logging)
|
184
187
|
else:
|
185
188
|
raise ValueError(f"Invalid model name: {model_name}")
|
@@ -6,6 +6,7 @@ supporting both standard and structured output modes.
|
|
6
6
|
"""
|
7
7
|
|
8
8
|
import json
|
9
|
+
import os
|
9
10
|
from typing import Any
|
10
11
|
|
11
12
|
import openai
|
@@ -42,18 +43,45 @@ class OpenAIStructuredOutputClient(OpenAIStandard):
|
|
42
43
|
"""
|
43
44
|
|
44
45
|
def __init__(self, synth_logging: bool = True):
|
45
|
-
if
|
46
|
+
# Check if we should use Synth clients instead of OpenAI
|
47
|
+
openai_base = os.getenv("OPENAI_API_BASE", "")
|
48
|
+
use_synth = (openai_base.startswith("https://synth") or
|
49
|
+
openai_base.startswith("https://agent-learning") or
|
50
|
+
os.getenv("SYNTH_BASE_URL") or os.getenv("MODAL_BASE_URL"))
|
51
|
+
|
52
|
+
if use_synth:
|
53
|
+
# Use Synth clients for Synth endpoints
|
54
|
+
from synth_ai.lm.vendors.synth_client import AsyncSynthClient, SyncSynthClient
|
55
|
+
from synth_ai.lm.config import SynthConfig
|
56
|
+
|
57
|
+
# Create config from OPENAI_* environment variables if available
|
58
|
+
openai_base = os.getenv("OPENAI_API_BASE")
|
59
|
+
openai_key = os.getenv("OPENAI_API_KEY")
|
60
|
+
|
61
|
+
if openai_base and openai_key:
|
62
|
+
config = SynthConfig(base_url=openai_base, api_key=openai_key)
|
63
|
+
sync_client = SyncSynthClient(config)
|
64
|
+
async_client = AsyncSynthClient(config)
|
65
|
+
else:
|
66
|
+
# Fall back to default config loading
|
67
|
+
sync_client = SyncSynthClient()
|
68
|
+
async_client = AsyncSynthClient()
|
69
|
+
elif synth_logging:
|
46
70
|
# print("Using synth logging - OpenAIStructuredOutputClient")
|
47
71
|
from synth_ai.lm.provider_support.openai import AsyncOpenAI, OpenAI
|
72
|
+
sync_client = OpenAI()
|
73
|
+
async_client = AsyncOpenAI()
|
48
74
|
else:
|
49
75
|
# print("Not using synth logging - OpenAIStructuredOutputClient")
|
50
76
|
from openai import AsyncOpenAI, OpenAI
|
77
|
+
sync_client = OpenAI()
|
78
|
+
async_client = AsyncOpenAI()
|
51
79
|
|
52
80
|
super().__init__(
|
53
81
|
used_for_structured_outputs=True,
|
54
82
|
exceptions_to_retry=OPENAI_EXCEPTIONS_TO_RETRY,
|
55
|
-
sync_client=
|
56
|
-
async_client=
|
83
|
+
sync_client=sync_client,
|
84
|
+
async_client=async_client,
|
57
85
|
)
|
58
86
|
|
59
87
|
async def _hit_api_async_structured_output(
|
@@ -207,7 +207,22 @@ class OpenAIStandard(VendorBase, OpenAIResponsesAPIMixin):
|
|
207
207
|
api_params = apply_tool_overrides(api_params)
|
208
208
|
api_params = apply_param_overrides(api_params)
|
209
209
|
|
210
|
-
#
|
210
|
+
# Thinking controls: route via extra_body.chat_template_kwargs for compatibility
|
211
|
+
thinking_mode_val = lm_config.get("thinking_mode")
|
212
|
+
thinking_budget_val = lm_config.get("thinking_budget")
|
213
|
+
if thinking_mode_val is not None or thinking_budget_val is not None:
|
214
|
+
api_params["extra_body"] = api_params.get("extra_body", {})
|
215
|
+
ctk = api_params["extra_body"].get("chat_template_kwargs", {})
|
216
|
+
if thinking_mode_val is not None:
|
217
|
+
ctk["thinking_mode"] = thinking_mode_val
|
218
|
+
if thinking_budget_val is not None:
|
219
|
+
try:
|
220
|
+
ctk["thinking_budget"] = int(thinking_budget_val)
|
221
|
+
except Exception:
|
222
|
+
ctk["thinking_budget"] = thinking_budget_val
|
223
|
+
api_params["extra_body"]["chat_template_kwargs"] = ctk
|
224
|
+
|
225
|
+
# Backward-compatible: forward legacy enable_thinking only via extra_body for callers still using it
|
211
226
|
if lm_config.get("enable_thinking") is not None:
|
212
227
|
api_params["extra_body"] = api_params.get("extra_body", {})
|
213
228
|
ctk = api_params["extra_body"].get("chat_template_kwargs", {})
|
@@ -220,7 +235,7 @@ class OpenAIStandard(VendorBase, OpenAIResponsesAPIMixin):
|
|
220
235
|
**api_params.get("extra_body", {}),
|
221
236
|
**(lm_config.get("extra_body") or {}),
|
222
237
|
}
|
223
|
-
#
|
238
|
+
# Ensure legacy extra_body flag remains merged (do not override top-level fields)
|
224
239
|
if lm_config.get("enable_thinking") is not None:
|
225
240
|
api_params["extra_body"] = api_params.get("extra_body", {})
|
226
241
|
ctk = api_params["extra_body"].get("chat_template_kwargs", {})
|
@@ -387,20 +402,36 @@ class OpenAIStandard(VendorBase, OpenAIResponsesAPIMixin):
|
|
387
402
|
# raise
|
388
403
|
message = output.choices[0].message
|
389
404
|
|
390
|
-
# Convert tool calls to dict format
|
405
|
+
# Convert tool calls to dict format, preferring dict-shaped entries first
|
391
406
|
tool_calls = None
|
392
407
|
if message.tool_calls:
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
"
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
|
408
|
+
converted: list[dict] = []
|
409
|
+
for tc in message.tool_calls:
|
410
|
+
if isinstance(tc, dict):
|
411
|
+
fn = tc.get("function") or {}
|
412
|
+
converted.append(
|
413
|
+
{
|
414
|
+
"id": tc.get("id"),
|
415
|
+
"type": tc.get("type", "function"),
|
416
|
+
"function": {
|
417
|
+
"name": fn.get("name") or tc.get("name"),
|
418
|
+
"arguments": fn.get("arguments") or tc.get("arguments"),
|
419
|
+
},
|
420
|
+
}
|
421
|
+
)
|
422
|
+
else:
|
423
|
+
# SDK object path
|
424
|
+
converted.append(
|
425
|
+
{
|
426
|
+
"id": getattr(tc, "id", None),
|
427
|
+
"type": getattr(tc, "type", "function"),
|
428
|
+
"function": {
|
429
|
+
"name": getattr(getattr(tc, "function", None), "name", None),
|
430
|
+
"arguments": getattr(getattr(tc, "function", None), "arguments", None),
|
431
|
+
},
|
432
|
+
}
|
433
|
+
)
|
434
|
+
tool_calls = converted or None
|
404
435
|
|
405
436
|
# Attach basic usage if available
|
406
437
|
usage_dict = None
|
@@ -38,8 +38,18 @@ class CustomEndpointAPI(VendorBase):
|
|
38
38
|
# Construct full chat completions URL
|
39
39
|
if endpoint_url.endswith("/"):
|
40
40
|
endpoint_url = endpoint_url[:-1]
|
41
|
-
|
42
|
-
|
41
|
+
|
42
|
+
# Handle full URLs that already include protocol
|
43
|
+
if endpoint_url.startswith(("http://", "https://")):
|
44
|
+
# Remove protocol and domain part, keep only the base path if any
|
45
|
+
parsed = endpoint_url.replace("https://", "").replace("http://", "")
|
46
|
+
base_url = parsed.split("/")[0] # Get domain only
|
47
|
+
self.chat_completions_url = f"https://{base_url}/chat/completions"
|
48
|
+
self.health_url = f"https://{base_url}/health"
|
49
|
+
else:
|
50
|
+
# Original logic for domain-only URLs
|
51
|
+
self.chat_completions_url = f"https://{endpoint_url}/chat/completions"
|
52
|
+
self.health_url = f"https://{endpoint_url}/health"
|
43
53
|
|
44
54
|
# Setup session with connection pooling and retries
|
45
55
|
self.session = self._create_session()
|