synapse-cli-agent 0.1.13__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 (131) hide show
  1. synapse/__init__.py +13 -0
  2. synapse/__main__.py +6 -0
  3. synapse/app/__init__.py +1 -0
  4. synapse/app/agent.py +492 -0
  5. synapse/app/agent_md.py +107 -0
  6. synapse/cli.py +750 -0
  7. synapse/commands/__init__.py +1 -0
  8. synapse/commands/compression.py +573 -0
  9. synapse/commands/helpers.py +22 -0
  10. synapse/commands/mcp.py +406 -0
  11. synapse/commands/model.py +173 -0
  12. synapse/commands/result.py +34 -0
  13. synapse/commands/sessions.py +443 -0
  14. synapse/commands/slash_cmds.py +521 -0
  15. synapse/commands/slash_complete.py +816 -0
  16. synapse/commands/theme.py +99 -0
  17. synapse/config.py +27 -0
  18. synapse/content/__init__.py +1 -0
  19. synapse/content/input_history.py +122 -0
  20. synapse/content/multimodal.py +733 -0
  21. synapse/content/prompts.py +249 -0
  22. synapse/content/skills_catalog.py +128 -0
  23. synapse/integrations/__init__.py +1 -0
  24. synapse/integrations/checkpoint_seed.py +281 -0
  25. synapse/integrations/codex_history.py +375 -0
  26. synapse/integrations/codex_import.py +393 -0
  27. synapse/integrations/codex_sessions.py +629 -0
  28. synapse/integrations/describe_image.py +370 -0
  29. synapse/integrations/http_clients.py +199 -0
  30. synapse/integrations/llm_openai_compat.py +90 -0
  31. synapse/integrations/llm_openai_websocket.py +187 -0
  32. synapse/integrations/mcp_client.py +646 -0
  33. synapse/integrations/vision_middleware.py +62 -0
  34. synapse/models/__init__.py +5 -0
  35. synapse/models/config.py +240 -0
  36. synapse/models/helpers.py +206 -0
  37. synapse/models/profile.py +59 -0
  38. synapse/models/registry.py +722 -0
  39. synapse/models_registry.py +7 -0
  40. synapse/observability/__init__.py +1 -0
  41. synapse/observability/startup_trace.py +127 -0
  42. synapse/runtime/__init__.py +1 -0
  43. synapse/runtime/async_runtime.py +176 -0
  44. synapse/runtime/backends.py +458 -0
  45. synapse/runtime/context_compact.py +249 -0
  46. synapse/runtime/execute_capture.py +48 -0
  47. synapse/runtime/fs_permissions.py +79 -0
  48. synapse/runtime/harness.py +57 -0
  49. synapse/runtime/hitl.py +197 -0
  50. synapse/runtime/interaction_ledger.py +82 -0
  51. synapse/runtime/middleware.py +802 -0
  52. synapse/runtime/model_request_compression_middleware.py +745 -0
  53. synapse/runtime/pathing.py +146 -0
  54. synapse/runtime/safety.py +184 -0
  55. synapse/runtime/steer.py +240 -0
  56. synapse/runtime/subagents.py +207 -0
  57. synapse/runtime/tool_ignore.py +221 -0
  58. synapse/runtime/tool_output_eval.py +118 -0
  59. synapse/runtime/tool_output_middleware.py +585 -0
  60. synapse/runtime/tool_output_usage_middleware.py +60 -0
  61. synapse/sessions/__init__.py +31 -0
  62. synapse/sessions/cancel_repair.py +208 -0
  63. synapse/sessions/session_recap.py +174 -0
  64. synapse/sessions/store.py +695 -0
  65. synapse/sessions/transcript.py +754 -0
  66. synapse/settings/__init__.py +5 -0
  67. synapse/settings/config_paths.py +184 -0
  68. synapse/settings/schema.py +464 -0
  69. synapse/tool_output/__init__.py +59 -0
  70. synapse/tool_output/detection.py +170 -0
  71. synapse/tool_output/metrics.py +32 -0
  72. synapse/tool_output/models.py +173 -0
  73. synapse/tool_output/pipeline.py +330 -0
  74. synapse/tool_output/repository.py +721 -0
  75. synapse/tool_output/transformers.py +648 -0
  76. synapse/tools/__init__.py +5 -0
  77. synapse/tools/session_tools.py +204 -0
  78. synapse/ui/__init__.py +10 -0
  79. synapse/ui/bottombar/__init__.py +73 -0
  80. synapse/ui/bottombar/components/__init__.py +143 -0
  81. synapse/ui/bottombar/components/key_hints.py +30 -0
  82. synapse/ui/bottombar/components/mcp.py +64 -0
  83. synapse/ui/bottombar/components/mode.py +24 -0
  84. synapse/ui/bottombar/components/model.py +28 -0
  85. synapse/ui/bottombar/components/thread.py +29 -0
  86. synapse/ui/bottombar/context.py +36 -0
  87. synapse/ui/bottombar/core.py +74 -0
  88. synapse/ui/dialogs/__init__.py +25 -0
  89. synapse/ui/dialogs/base.py +362 -0
  90. synapse/ui/dialogs/codex_session_list.py +84 -0
  91. synapse/ui/dialogs/compression_diagnostics.py +210 -0
  92. synapse/ui/dialogs/git_explore.py +702 -0
  93. synapse/ui/dialogs/mcp_panel.py +407 -0
  94. synapse/ui/dialogs/model_picker.py +128 -0
  95. synapse/ui/dialogs/safety_panel.py +63 -0
  96. synapse/ui/dialogs/session_list.py +98 -0
  97. synapse/ui/dialogs/theme_designer.py +863 -0
  98. synapse/ui/dialogs/theme_picker.py +113 -0
  99. synapse/ui/git_explore/__init__.py +31 -0
  100. synapse/ui/git_explore/engine.py +82 -0
  101. synapse/ui/git_explore/provider.py +242 -0
  102. synapse/ui/git_explore/unified.py +85 -0
  103. synapse/ui/rendering.py +350 -0
  104. synapse/ui/sink.py +70 -0
  105. synapse/ui/steer_widget.py +367 -0
  106. synapse/ui/stream.py +1207 -0
  107. synapse/ui/stream_events.py +421 -0
  108. synapse/ui/stream_runtime.py +252 -0
  109. synapse/ui/theme.py +1154 -0
  110. synapse/ui/timeline.py +621 -0
  111. synapse/ui/topbar/__init__.py +97 -0
  112. synapse/ui/topbar/components/__init__.py +150 -0
  113. synapse/ui/topbar/components/branch.py +41 -0
  114. synapse/ui/topbar/components/title.py +24 -0
  115. synapse/ui/topbar/components/tool_output.py +24 -0
  116. synapse/ui/topbar/components/usage.py +24 -0
  117. synapse/ui/topbar/components/workspace.py +32 -0
  118. synapse/ui/topbar/context.py +32 -0
  119. synapse/ui/topbar/core.py +979 -0
  120. synapse/ui/topbar/git_changes_popover.py +178 -0
  121. synapse/ui/topbar/git_chrome.py +475 -0
  122. synapse/ui/topbar/tool_output_popover.py +84 -0
  123. synapse/ui/topbar/widget.py +474 -0
  124. synapse/ui/tui.py +5717 -0
  125. synapse/ui/turn_rail.py +71 -0
  126. synapse/ui/user_turn.py +83 -0
  127. synapse/ui/welcome.py +261 -0
  128. synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
  129. synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
  130. synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
  131. synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,5 @@
1
+ """Model profile registry and provider assembly."""
2
+
3
+ from synapse.models.registry import ModelProfile, ModelRegistry, build_model_from_settings
4
+
5
+ __all__ = ["ModelProfile", "ModelRegistry", "build_model_from_settings"]
@@ -0,0 +1,240 @@
1
+ """Model configuration parsing and normalization helpers."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ _ENV_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)")
10
+
11
+ DEFAULT_MODELS_CONFIG_REL = Path(".coding-agent") / "models.json"
12
+
13
+ THINKING_LEVELS = ("minimal", "low", "medium", "high", "max")
14
+ DEFAULT_THINKING_LEVELS: tuple[str, ...] = ("off", *THINKING_LEVELS)
15
+
16
+ _PROFILE_META_KEYS = {
17
+ "model", "api_key", "api_key_env", "base_url", "websocket",
18
+ "context_window", "contextwindow", "max_input_tokens", "enable_thinking",
19
+ "thinking", "thinking_level", "reasoning_effort", "thinking_levels",
20
+ "parallel_tool_calls", "image_input", "capabilities", "extra",
21
+ "model_kwargs", "extra_body", "params",
22
+ }
23
+
24
+ def expand_env_string(value: Any) -> Any:
25
+ """Expand ${VAR} / $VAR in string config values."""
26
+ if not isinstance(value, str):
27
+ return value
28
+
29
+ def repl(match: re.Match[str]) -> str:
30
+ key = match.group(1) or match.group(2)
31
+ return os.environ.get(key, "")
32
+
33
+ return _ENV_PATTERN.sub(repl, value)
34
+
35
+
36
+ def parse_context_window(cfg: dict[str, Any] | None) -> int | None:
37
+ """Read context window (tokens) from a models.json profile object.
38
+
39
+ Accepts ``context_window``, ``contextwindow``, or ``max_input_tokens``.
40
+ Returns a positive int, or None when unset/invalid.
41
+ """
42
+ if not isinstance(cfg, dict):
43
+ return None
44
+ raw = cfg.get("context_window")
45
+ if raw is None:
46
+ raw = cfg.get("contextwindow")
47
+ if raw is None:
48
+ raw = cfg.get("max_input_tokens")
49
+ if raw is None or raw is False:
50
+ return None
51
+ try:
52
+ value = int(raw)
53
+ except (TypeError, ValueError):
54
+ return None
55
+ if value <= 0:
56
+ return None
57
+ return value
58
+
59
+
60
+ def apply_context_window_to_model(model: Any, context_window: int | None) -> Any:
61
+ """Stamp ``max_input_tokens`` onto a chat model profile for summarization."""
62
+ if model is None or context_window is None:
63
+ return model
64
+ try:
65
+ size = int(context_window)
66
+ except (TypeError, ValueError):
67
+ return model
68
+ if size <= 0:
69
+ return model
70
+ existing = getattr(model, "profile", None)
71
+ profile: dict[str, Any]
72
+ if isinstance(existing, dict):
73
+ profile = dict(existing)
74
+ else:
75
+ profile = {}
76
+ profile["max_input_tokens"] = size
77
+ try:
78
+ model.profile = profile
79
+ except Exception: # noqa: BLE001
80
+ # Some wrappers expose read-only profile; best-effort only.
81
+ try:
82
+ object.__setattr__(model, "profile", profile)
83
+ except Exception: # noqa: BLE001
84
+ pass
85
+ return model
86
+
87
+
88
+ def parse_optional_bool(value: Any, *, field_name: str = "image_input") -> bool | None:
89
+ """Parse JSON and common string boolean values without truthiness surprises."""
90
+ if value is None:
91
+ return None
92
+ if isinstance(value, bool):
93
+ return value
94
+ if isinstance(value, str):
95
+ normalized = value.strip().casefold()
96
+ if normalized in {"true", "1", "yes", "on", "enabled"}:
97
+ return True
98
+ if normalized in {"false", "0", "no", "off", "disabled"}:
99
+ return False
100
+ raise ValueError(f"{field_name} must be a boolean")
101
+
102
+
103
+ def normalize_thinking_level(value: Any) -> str | None:
104
+ """Normalize thinking level / reasoning_effort to a canonical string.
105
+
106
+ Accepts: off|minimal|low|medium|high|max, plus common aliases
107
+ (min, med, xhigh, ultra).
108
+ """
109
+ if value is None:
110
+ return None
111
+ if isinstance(value, bool):
112
+ return "high" if value else "off"
113
+ text = str(value).strip().casefold()
114
+ if not text:
115
+ return None
116
+ if text in {"off", "false", "0", "disabled", "none", "no"}:
117
+ return "off"
118
+ aliases = {
119
+ "min": "minimal",
120
+ "minimal": "minimal",
121
+ "low": "low",
122
+ "medium": "medium",
123
+ "med": "medium",
124
+ "mid": "medium",
125
+ "high": "high",
126
+ "max": "max",
127
+ "maximum": "max",
128
+ "xhigh": "max",
129
+ "ultra": "max",
130
+ "highest": "max",
131
+ }
132
+ if text in aliases:
133
+ return aliases[text]
134
+ # pass through unknown provider-specific values (e.g. "xhigh" already mapped)
135
+ return str(value).strip()
136
+
137
+
138
+ def parse_thinking_levels(raw: Any) -> list[str] | None:
139
+ """Parse a thinking_levels array into canonical labels.
140
+
141
+ Returns None when unset. Accepts strings like off|low|medium|high|max.
142
+ """
143
+ if raw is None:
144
+ return None
145
+ if not isinstance(raw, list):
146
+ raise ValueError("thinking_levels must be an array of strings")
147
+ out: list[str] = []
148
+ seen: set[str] = set()
149
+ for item in raw:
150
+ level = normalize_thinking_level(item)
151
+ if not level:
152
+ raise ValueError(f"invalid thinking level in thinking_levels: {item!r}")
153
+ if level in seen:
154
+ continue
155
+ seen.add(level)
156
+ out.append(level)
157
+ if not out:
158
+ raise ValueError("thinking_levels must not be empty")
159
+ return out
160
+
161
+
162
+ def default_thinking_levels() -> list[str]:
163
+ """Built-in allowed thinking levels."""
164
+ return list(DEFAULT_THINKING_LEVELS)
165
+
166
+
167
+ def parse_thinking_config(cfg: dict[str, Any]) -> tuple[bool | None, str | None]:
168
+ """Return (enable_thinking, reasoning_effort) from profile JSON.
169
+
170
+ Priority:
171
+ 1) thinking: false | \"off\" | \"disabled\" => disabled
172
+ 2) thinking / thinking_level / reasoning_effort string => level
173
+ 3) enable_thinking bool
174
+ """
175
+ enable: bool | None = None
176
+ level: str | None = None
177
+
178
+ raw_thinking = cfg.get("thinking", None)
179
+ if raw_thinking is not None:
180
+ if isinstance(raw_thinking, bool):
181
+ enable = raw_thinking
182
+ if raw_thinking:
183
+ level = normalize_thinking_level(
184
+ cfg.get("thinking_level")
185
+ or cfg.get("reasoning_effort")
186
+ or "high"
187
+ )
188
+ else:
189
+ level = None
190
+ else:
191
+ text = str(raw_thinking).strip().casefold()
192
+ if text in {"off", "false", "0", "disabled", "none", "no"}:
193
+ enable = False
194
+ level = None
195
+ elif text in {"on", "true", "1", "enabled", "yes"}:
196
+ enable = True
197
+ level = normalize_thinking_level(
198
+ cfg.get("thinking_level") or cfg.get("reasoning_effort") or "high"
199
+ )
200
+ else:
201
+ enable = True
202
+ level = normalize_thinking_level(raw_thinking)
203
+
204
+ if "thinking_level" in cfg and cfg.get("thinking_level") is not None:
205
+ enable = True if enable is None else enable
206
+ if enable is not False:
207
+ level = normalize_thinking_level(cfg.get("thinking_level")) or level
208
+
209
+ if "reasoning_effort" in cfg and cfg.get("reasoning_effort") is not None:
210
+ enable = True if enable is None else enable
211
+ if enable is not False:
212
+ level = normalize_thinking_level(cfg.get("reasoning_effort")) or level
213
+
214
+ if "enable_thinking" in cfg and cfg.get("enable_thinking") is not None:
215
+ enable = bool(cfg.get("enable_thinking"))
216
+ if enable and level is None:
217
+ level = normalize_thinking_level(
218
+ cfg.get("thinking_level")
219
+ or cfg.get("reasoning_effort")
220
+ or "high"
221
+ )
222
+ if not enable:
223
+ level = None
224
+
225
+ return enable, level
226
+
227
+
228
+ def _coerce_params(cfg: dict[str, Any]) -> dict[str, Any]:
229
+ """Collect custom ChatModel / request parameters from profile config."""
230
+ params: dict[str, Any] = {}
231
+ for key in ("params", "extra"):
232
+ raw = cfg.get(key)
233
+ if isinstance(raw, dict):
234
+ params.update(raw)
235
+ # Common top-level ChatModel kwargs
236
+ for key, value in cfg.items():
237
+ if key in _PROFILE_META_KEYS:
238
+ continue
239
+ params[key] = value
240
+ return params
@@ -0,0 +1,206 @@
1
+ """Settings, capability, and presentation helpers for model profiles."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ from synapse.models.config import (
7
+ THINKING_LEVELS,
8
+ default_thinking_levels,
9
+ normalize_thinking_level,
10
+ )
11
+ from synapse.models.profile import ModelProfile
12
+
13
+
14
+ def model_provider(model: str | None) -> str:
15
+ """Return provider prefix (openai/anthropic/...) or empty string."""
16
+ text = (model or "").strip()
17
+ if ":" not in text:
18
+ return ""
19
+ return text.split(":", 1)[0].strip().casefold()
20
+
21
+
22
+ def model_supports_image_input(
23
+ model: str | None,
24
+ explicit: bool | None = None,
25
+ base_url: str | None = None,
26
+ ) -> bool:
27
+ """Resolve native image support with explicit and endpoint-aware defaults.
28
+
29
+ Official model names are not enough to prove that a custom OpenAI-compatible
30
+ gateway accepts image blocks. Non-official OpenAI endpoints therefore default
31
+ to text-only; set ``image_input: true`` to opt in explicitly.
32
+ """
33
+ if explicit is not None:
34
+ return bool(explicit)
35
+ raw = (model or "").strip().casefold()
36
+ provider, _, model_id = raw.partition(":")
37
+ name = model_id or raw
38
+ if provider in {"anthropic", "claude"}:
39
+ return "claude-3" in name or "claude-4" in name or "sonnet-4" in name or "opus-4" in name
40
+ if provider in {"google", "google_genai", "google_vertexai", "gemini", "vertexai"}:
41
+ return "gemini" in name
42
+ if provider in {"openai", "azure_openai", "azure"} or not provider:
43
+ if base_url and not _is_official_openai_endpoint(base_url):
44
+ return False
45
+ return any(
46
+ marker in name
47
+ for marker in (
48
+ "gpt-4o",
49
+ "gpt-4.1",
50
+ "gpt-4-turbo",
51
+ "gpt-5",
52
+ "gpt5",
53
+ "o3",
54
+ "o4",
55
+ )
56
+ )
57
+ return False
58
+
59
+
60
+ def _is_official_openai_endpoint(base_url: str) -> bool:
61
+ normalized = base_url.strip().casefold().rstrip("/")
62
+ return normalized in {
63
+ "https://api.openai.com",
64
+ "https://api.openai.com/v1",
65
+ "https://chatgpt.com/backend-api/codex",
66
+ }
67
+
68
+
69
+ def settings_fallback_api_key(settings: Any, model: str | None = None) -> str | None:
70
+ """Pick settings-level API key for a model provider.
71
+
72
+ Profile plaintext / api_key_env always wins earlier via resolved_api_key().
73
+ This only supplies the fallback when the profile has no key.
74
+ """
75
+ provider = model_provider(model or getattr(settings, "model", None))
76
+ openai_key = getattr(settings, "openai_api_key", None)
77
+ anthropic_key = getattr(settings, "anthropic_api_key", None)
78
+ if provider == "anthropic":
79
+ return anthropic_key
80
+ if provider == "openai":
81
+ return openai_key
82
+ return openai_key or anthropic_key
83
+
84
+
85
+ def apply_profile_to_settings(
86
+ settings: Any,
87
+ profile: ModelProfile,
88
+ *,
89
+ seed_thinking: bool = True,
90
+ ) -> Any:
91
+ """Apply a model profile's identity + credentials onto Settings.
92
+
93
+ Thinking defaults are seeded only when ``seed_thinking`` is True (model switch).
94
+ Credentials always follow the selected profile so switching models cannot keep
95
+ the previous provider's key as the active credential source.
96
+ """
97
+ settings.active_model = profile.name
98
+ settings.model = profile.model
99
+ if profile.base_url:
100
+ # Shared transport field used as OpenAI-compatible / Anthropic base_url source.
101
+ settings.openai_base_url = profile.base_url
102
+
103
+ key = profile.resolved_api_key()
104
+ provider = model_provider(profile.model)
105
+ if key:
106
+ if provider == "anthropic":
107
+ settings.anthropic_api_key = key
108
+ settings.openai_api_key = None
109
+ else:
110
+ # openai + generic OpenAI-compatible providers
111
+ settings.openai_api_key = key
112
+ settings.anthropic_api_key = None
113
+ else:
114
+ # Clear both so stale keys from a previous profile cannot leak.
115
+ settings.openai_api_key = None
116
+ settings.anthropic_api_key = None
117
+
118
+ if seed_thinking:
119
+ if profile.enable_thinking is not None:
120
+ settings.enable_thinking = bool(profile.enable_thinking)
121
+ if profile.reasoning_effort:
122
+ settings.reasoning_effort = profile.reasoning_effort
123
+ if profile.parallel_tool_calls is not None:
124
+ settings.parallel_tool_calls = bool(profile.parallel_tool_calls)
125
+ return settings
126
+
127
+ def short_model_id(model: str | None) -> str:
128
+ """Strip provider prefix: ``openai:deepseek-v4-pro`` -> ``deepseek-v4-pro``."""
129
+ text = (model or "").strip()
130
+ if ":" in text:
131
+ text = text.split(":", 1)[1]
132
+ return text or "model"
133
+
134
+
135
+ def settings_thinking_label(settings: Any) -> str:
136
+ """Current effective thinking label from Settings (runtime override aware)."""
137
+ if getattr(settings, "enable_thinking", True) is False:
138
+ return "off"
139
+ effort = getattr(settings, "reasoning_effort", None)
140
+ if effort:
141
+ return str(effort)
142
+ return "on"
143
+
144
+
145
+ def format_model_status(settings: Any) -> str:
146
+ """Status-bar label: ``deepseek-v4-pro · high``."""
147
+ model = short_model_id(str(getattr(settings, "model", "") or ""))
148
+ return f"{model} · {settings_thinking_label(settings)}"
149
+
150
+
151
+ def apply_thinking_to_settings(
152
+ settings: Any,
153
+ raw: str,
154
+ *,
155
+ allowed: list[str] | None = None,
156
+ ) -> str:
157
+ """Apply a thinking level token onto settings. Returns canonical label.
158
+
159
+ Accepts off|minimal|low|medium|high|max (and aliases).
160
+ When ``allowed`` is provided, the level must be in that catalog.
161
+ """
162
+ text = (raw or "").strip()
163
+ if not text:
164
+ raise ValueError("empty thinking level")
165
+ level = normalize_thinking_level(text)
166
+ if not level:
167
+ raise ValueError(f"unknown thinking level: {raw}")
168
+ catalog = list(allowed) if allowed is not None else default_thinking_levels()
169
+ if level not in catalog:
170
+ raise ValueError(
171
+ f"thinking level {level!r} not allowed; choose one of: {', '.join(catalog)}"
172
+ )
173
+ if level == "off":
174
+ settings.enable_thinking = False
175
+ return "off"
176
+ settings.enable_thinking = True
177
+ settings.reasoning_effort = level
178
+ return level
179
+
180
+
181
+ def is_thinking_token(raw: str) -> bool:
182
+ """True if token looks like a thinking level (for /model parsing)."""
183
+ text = (raw or "").strip().casefold()
184
+ if text in {
185
+ "off",
186
+ "false",
187
+ "0",
188
+ "disabled",
189
+ "none",
190
+ "no",
191
+ "on",
192
+ "true",
193
+ "1",
194
+ "enabled",
195
+ "yes",
196
+ *THINKING_LEVELS,
197
+ "min",
198
+ "med",
199
+ "mid",
200
+ "maximum",
201
+ "xhigh",
202
+ "ultra",
203
+ "highest",
204
+ }:
205
+ return True
206
+ return normalize_thinking_level(text) in THINKING_LEVELS
@@ -0,0 +1,59 @@
1
+ """Model endpoint profile domain object."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from synapse.models.config import expand_env_string
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ModelProfile:
13
+ """One named model endpoint.
14
+
15
+ Thinking fields here are **defaults only**. Session thinking lives on Settings
16
+ and is applied at build time via explicit enable_thinking/reasoning_effort.
17
+ """
18
+
19
+ name: str
20
+ model: str
21
+ api_key: str | None = None
22
+ api_key_env: str | None = None
23
+ base_url: str | None = None
24
+ # Model input context size (tokens). Used by compact/summarization thresholds.
25
+ context_window: int | None = None
26
+ enable_thinking: bool | None = None
27
+ reasoning_effort: str | None = None
28
+ # Optional allowed levels for this model (subset of registry.thinking_levels)
29
+ thinking_levels: tuple[str, ...] | None = None
30
+ parallel_tool_calls: bool | None = None
31
+ # Use the ordinary Responses API WebSocket instead of HTTP/SSE.
32
+ websocket: bool | None = None
33
+ # Free-form kwargs for init_chat_model (temperature, max_tokens, timeout, ...)
34
+ extra: dict[str, Any] = field(default_factory=dict)
35
+ # Request body kwargs merged into model_kwargs
36
+ model_kwargs: dict[str, Any] = field(default_factory=dict)
37
+ # Provider-specific body (merged into extra_body)
38
+ extra_body: dict[str, Any] = field(default_factory=dict)
39
+ # Whether the selected primary model accepts native image content.
40
+ # None means infer from provider/model name.
41
+ image_input: bool | None = None
42
+
43
+ def resolved_api_key(self) -> str | None:
44
+ if self.api_key:
45
+ return expand_env_string(self.api_key) or None
46
+ if self.api_key_env:
47
+ env_name = str(expand_env_string(self.api_key_env) or "")
48
+ return os.environ.get(env_name) or None
49
+ return None
50
+
51
+ def thinking_label(self) -> str:
52
+ if self.enable_thinking is False:
53
+ return "off"
54
+ if self.reasoning_effort:
55
+ return str(self.reasoning_effort)
56
+ if self.enable_thinking is True:
57
+ return "on"
58
+ return "default"
59
+