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.
Files changed (50) hide show
  1. synth_ai/__init__.py +1 -1
  2. synth_ai/cli/balance.py +3 -15
  3. synth_ai/config/base_url.py +47 -0
  4. synth_ai/http.py +102 -0
  5. synth_ai/inference/__init__.py +7 -0
  6. synth_ai/inference/client.py +20 -0
  7. synth_ai/jobs/client.py +246 -0
  8. synth_ai/learning/__init__.py +24 -0
  9. synth_ai/learning/client.py +149 -0
  10. synth_ai/learning/config.py +43 -0
  11. synth_ai/learning/constants.py +29 -0
  12. synth_ai/learning/ft_client.py +59 -0
  13. synth_ai/learning/health.py +43 -0
  14. synth_ai/learning/jobs.py +205 -0
  15. synth_ai/learning/rl_client.py +256 -0
  16. synth_ai/learning/sse.py +58 -0
  17. synth_ai/learning/validators.py +48 -0
  18. synth_ai/lm/core/main_v3.py +13 -0
  19. synth_ai/lm/core/synth_models.py +48 -0
  20. synth_ai/lm/core/vendor_clients.py +9 -6
  21. synth_ai/lm/vendors/core/openai_api.py +31 -3
  22. synth_ai/lm/vendors/openai_standard.py +45 -14
  23. synth_ai/lm/vendors/supported/custom_endpoint.py +12 -2
  24. synth_ai/lm/vendors/synth_client.py +372 -28
  25. synth_ai/rl/__init__.py +30 -0
  26. synth_ai/rl/contracts.py +32 -0
  27. synth_ai/rl/env_keys.py +137 -0
  28. synth_ai/rl/secrets.py +19 -0
  29. synth_ai/scripts/verify_rewards.py +100 -0
  30. synth_ai/task/__init__.py +10 -0
  31. synth_ai/task/contracts.py +120 -0
  32. synth_ai/task/health.py +28 -0
  33. synth_ai/task/validators.py +12 -0
  34. synth_ai/tracing_v3/hooks.py +3 -1
  35. synth_ai/tracing_v3/session_tracer.py +123 -2
  36. synth_ai/tracing_v3/turso/manager.py +218 -0
  37. synth_ai/tracing_v3/turso/models.py +53 -0
  38. synth_ai-0.2.4.dev8.dist-info/METADATA +635 -0
  39. {synth_ai-0.2.4.dev7.dist-info → synth_ai-0.2.4.dev8.dist-info}/RECORD +43 -25
  40. synth_ai/tui/__init__.py +0 -1
  41. synth_ai/tui/__main__.py +0 -13
  42. synth_ai/tui/cli/__init__.py +0 -1
  43. synth_ai/tui/cli/query_experiments.py +0 -164
  44. synth_ai/tui/cli/query_experiments_v3.py +0 -164
  45. synth_ai/tui/dashboard.py +0 -340
  46. synth_ai-0.2.4.dev7.dist-info/METADATA +0 -193
  47. {synth_ai-0.2.4.dev7.dist-info → synth_ai-0.2.4.dev8.dist-info}/WHEEL +0 -0
  48. {synth_ai-0.2.4.dev7.dist-info → synth_ai-0.2.4.dev8.dist-info}/entry_points.txt +0 -0
  49. {synth_ai-0.2.4.dev7.dist-info → synth_ai-0.2.4.dev8.dist-info}/licenses/LICENSE +0 -0
  50. {synth_ai-0.2.4.dev7.dist-info → synth_ai-0.2.4.dev8.dist-info}/top_level.txt +0 -0
@@ -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")
@@ -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
- together_naming_regexes: list[Pattern] = [
43
- re.compile(r"^.*\/.*$"),
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 together_naming_regexes):
183
- return TogetherClient()
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 synth_logging:
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=OpenAI(),
56
- async_client=AsyncOpenAI(),
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
- # Forward Qwen3 chat template kwargs via extra_body when requested
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
- # Forward Qwen3 chat template kwargs via extra_body when requested
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
- tool_calls = [
394
- {
395
- "id": tc.id,
396
- "type": tc.type,
397
- "function": {
398
- "name": tc.function.name,
399
- "arguments": tc.function.arguments,
400
- },
401
- }
402
- for tc in message.tool_calls
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
- self.chat_completions_url = f"https://{endpoint_url}/chat/completions"
42
- self.health_url = f"https://{endpoint_url}/health"
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()