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.
- synapse/__init__.py +13 -0
- synapse/__main__.py +6 -0
- synapse/app/__init__.py +1 -0
- synapse/app/agent.py +492 -0
- synapse/app/agent_md.py +107 -0
- synapse/cli.py +750 -0
- synapse/commands/__init__.py +1 -0
- synapse/commands/compression.py +573 -0
- synapse/commands/helpers.py +22 -0
- synapse/commands/mcp.py +406 -0
- synapse/commands/model.py +173 -0
- synapse/commands/result.py +34 -0
- synapse/commands/sessions.py +443 -0
- synapse/commands/slash_cmds.py +521 -0
- synapse/commands/slash_complete.py +816 -0
- synapse/commands/theme.py +99 -0
- synapse/config.py +27 -0
- synapse/content/__init__.py +1 -0
- synapse/content/input_history.py +122 -0
- synapse/content/multimodal.py +733 -0
- synapse/content/prompts.py +249 -0
- synapse/content/skills_catalog.py +128 -0
- synapse/integrations/__init__.py +1 -0
- synapse/integrations/checkpoint_seed.py +281 -0
- synapse/integrations/codex_history.py +375 -0
- synapse/integrations/codex_import.py +393 -0
- synapse/integrations/codex_sessions.py +629 -0
- synapse/integrations/describe_image.py +370 -0
- synapse/integrations/http_clients.py +199 -0
- synapse/integrations/llm_openai_compat.py +90 -0
- synapse/integrations/llm_openai_websocket.py +187 -0
- synapse/integrations/mcp_client.py +646 -0
- synapse/integrations/vision_middleware.py +62 -0
- synapse/models/__init__.py +5 -0
- synapse/models/config.py +240 -0
- synapse/models/helpers.py +206 -0
- synapse/models/profile.py +59 -0
- synapse/models/registry.py +722 -0
- synapse/models_registry.py +7 -0
- synapse/observability/__init__.py +1 -0
- synapse/observability/startup_trace.py +127 -0
- synapse/runtime/__init__.py +1 -0
- synapse/runtime/async_runtime.py +176 -0
- synapse/runtime/backends.py +458 -0
- synapse/runtime/context_compact.py +249 -0
- synapse/runtime/execute_capture.py +48 -0
- synapse/runtime/fs_permissions.py +79 -0
- synapse/runtime/harness.py +57 -0
- synapse/runtime/hitl.py +197 -0
- synapse/runtime/interaction_ledger.py +82 -0
- synapse/runtime/middleware.py +802 -0
- synapse/runtime/model_request_compression_middleware.py +745 -0
- synapse/runtime/pathing.py +146 -0
- synapse/runtime/safety.py +184 -0
- synapse/runtime/steer.py +240 -0
- synapse/runtime/subagents.py +207 -0
- synapse/runtime/tool_ignore.py +221 -0
- synapse/runtime/tool_output_eval.py +118 -0
- synapse/runtime/tool_output_middleware.py +585 -0
- synapse/runtime/tool_output_usage_middleware.py +60 -0
- synapse/sessions/__init__.py +31 -0
- synapse/sessions/cancel_repair.py +208 -0
- synapse/sessions/session_recap.py +174 -0
- synapse/sessions/store.py +695 -0
- synapse/sessions/transcript.py +754 -0
- synapse/settings/__init__.py +5 -0
- synapse/settings/config_paths.py +184 -0
- synapse/settings/schema.py +464 -0
- synapse/tool_output/__init__.py +59 -0
- synapse/tool_output/detection.py +170 -0
- synapse/tool_output/metrics.py +32 -0
- synapse/tool_output/models.py +173 -0
- synapse/tool_output/pipeline.py +330 -0
- synapse/tool_output/repository.py +721 -0
- synapse/tool_output/transformers.py +648 -0
- synapse/tools/__init__.py +5 -0
- synapse/tools/session_tools.py +204 -0
- synapse/ui/__init__.py +10 -0
- synapse/ui/bottombar/__init__.py +73 -0
- synapse/ui/bottombar/components/__init__.py +143 -0
- synapse/ui/bottombar/components/key_hints.py +30 -0
- synapse/ui/bottombar/components/mcp.py +64 -0
- synapse/ui/bottombar/components/mode.py +24 -0
- synapse/ui/bottombar/components/model.py +28 -0
- synapse/ui/bottombar/components/thread.py +29 -0
- synapse/ui/bottombar/context.py +36 -0
- synapse/ui/bottombar/core.py +74 -0
- synapse/ui/dialogs/__init__.py +25 -0
- synapse/ui/dialogs/base.py +362 -0
- synapse/ui/dialogs/codex_session_list.py +84 -0
- synapse/ui/dialogs/compression_diagnostics.py +210 -0
- synapse/ui/dialogs/git_explore.py +702 -0
- synapse/ui/dialogs/mcp_panel.py +407 -0
- synapse/ui/dialogs/model_picker.py +128 -0
- synapse/ui/dialogs/safety_panel.py +63 -0
- synapse/ui/dialogs/session_list.py +98 -0
- synapse/ui/dialogs/theme_designer.py +863 -0
- synapse/ui/dialogs/theme_picker.py +113 -0
- synapse/ui/git_explore/__init__.py +31 -0
- synapse/ui/git_explore/engine.py +82 -0
- synapse/ui/git_explore/provider.py +242 -0
- synapse/ui/git_explore/unified.py +85 -0
- synapse/ui/rendering.py +350 -0
- synapse/ui/sink.py +70 -0
- synapse/ui/steer_widget.py +367 -0
- synapse/ui/stream.py +1207 -0
- synapse/ui/stream_events.py +421 -0
- synapse/ui/stream_runtime.py +252 -0
- synapse/ui/theme.py +1154 -0
- synapse/ui/timeline.py +621 -0
- synapse/ui/topbar/__init__.py +97 -0
- synapse/ui/topbar/components/__init__.py +150 -0
- synapse/ui/topbar/components/branch.py +41 -0
- synapse/ui/topbar/components/title.py +24 -0
- synapse/ui/topbar/components/tool_output.py +24 -0
- synapse/ui/topbar/components/usage.py +24 -0
- synapse/ui/topbar/components/workspace.py +32 -0
- synapse/ui/topbar/context.py +32 -0
- synapse/ui/topbar/core.py +979 -0
- synapse/ui/topbar/git_changes_popover.py +178 -0
- synapse/ui/topbar/git_chrome.py +475 -0
- synapse/ui/topbar/tool_output_popover.py +84 -0
- synapse/ui/topbar/widget.py +474 -0
- synapse/ui/tui.py +5717 -0
- synapse/ui/turn_rail.py +71 -0
- synapse/ui/user_turn.py +83 -0
- synapse/ui/welcome.py +261 -0
- synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
- synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
- synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
- synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
synapse/models/config.py
ADDED
|
@@ -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
|
+
|