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
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
"""Multi-model registry built on top of deepagents/LangChain model assembly.
|
|
2
|
+
|
|
3
|
+
Preferred configuration: `.coding-agent/models.json`
|
|
4
|
+
Secrets stay in env / `.env` via `api_key_env`.
|
|
5
|
+
|
|
6
|
+
Top-level fields:
|
|
7
|
+
default, models,
|
|
8
|
+
thinking_levels (array): allowed session thinking levels, shared by models
|
|
9
|
+
default_thinking: optional global default when a profile omits thinking
|
|
10
|
+
|
|
11
|
+
Profile fields:
|
|
12
|
+
model, api_key_env, base_url, websocket,
|
|
13
|
+
context_window / contextwindow / max_input_tokens (int): model context size in
|
|
14
|
+
tokens. Wired into LangChain ``model.profile["max_input_tokens"]`` so
|
|
15
|
+
deepagents summarization can use fraction-based compact triggers
|
|
16
|
+
(default ~85% / keep ~10%). When omitted, compact falls back to fixed
|
|
17
|
+
~170k-token thresholds.
|
|
18
|
+
thinking_levels (optional array): subset of top-level levels for this model
|
|
19
|
+
thinking / thinking_level / reasoning_effort / enable_thinking: profile default only
|
|
20
|
+
temperature, max_tokens, timeout, top_p, ... (ChatModel kwargs),
|
|
21
|
+
model_kwargs (request body kwargs),
|
|
22
|
+
extra_body (provider-specific body merge),
|
|
23
|
+
extra (legacy free-form kwargs for init_chat_model)
|
|
24
|
+
|
|
25
|
+
Runtime thinking is session-scoped (Settings / /model thinking). Profile values
|
|
26
|
+
only seed defaults when a model is selected; they do not lock the effort level.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import hashlib
|
|
32
|
+
import json
|
|
33
|
+
from collections.abc import Callable
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from langchain.chat_models import init_chat_model
|
|
39
|
+
|
|
40
|
+
from synapse.integrations.llm_openai_compat import (
|
|
41
|
+
deepseek_thinking_kwargs,
|
|
42
|
+
enable_openai_compat_reasoning_patch,
|
|
43
|
+
)
|
|
44
|
+
from synapse.models.config import (
|
|
45
|
+
DEFAULT_MODELS_CONFIG_REL,
|
|
46
|
+
DEFAULT_THINKING_LEVELS,
|
|
47
|
+
_coerce_params,
|
|
48
|
+
apply_context_window_to_model,
|
|
49
|
+
default_thinking_levels,
|
|
50
|
+
expand_env_string,
|
|
51
|
+
normalize_thinking_level,
|
|
52
|
+
parse_context_window,
|
|
53
|
+
parse_optional_bool,
|
|
54
|
+
parse_thinking_config,
|
|
55
|
+
parse_thinking_levels,
|
|
56
|
+
)
|
|
57
|
+
from synapse.models.helpers import (
|
|
58
|
+
apply_profile_to_settings as _apply_profile_to_settings,
|
|
59
|
+
)
|
|
60
|
+
from synapse.models.helpers import (
|
|
61
|
+
apply_thinking_to_settings as _apply_thinking_to_settings,
|
|
62
|
+
)
|
|
63
|
+
from synapse.models.helpers import format_model_status as _format_model_status
|
|
64
|
+
from synapse.models.helpers import is_thinking_token as _is_thinking_token
|
|
65
|
+
from synapse.models.helpers import model_provider as _model_provider
|
|
66
|
+
from synapse.models.helpers import model_supports_image_input as _model_supports_image_input
|
|
67
|
+
from synapse.models.helpers import settings_fallback_api_key, short_model_id
|
|
68
|
+
from synapse.models.helpers import settings_thinking_label as _settings_thinking_label
|
|
69
|
+
from synapse.models.profile import ModelProfile
|
|
70
|
+
|
|
71
|
+
apply_profile_to_settings = _apply_profile_to_settings
|
|
72
|
+
apply_thinking_to_settings = _apply_thinking_to_settings
|
|
73
|
+
format_model_status = _format_model_status
|
|
74
|
+
is_thinking_token = _is_thinking_token
|
|
75
|
+
model_provider = _model_provider
|
|
76
|
+
model_supports_image_input = _model_supports_image_input
|
|
77
|
+
settings_thinking_label = _settings_thinking_label
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class ModelRegistry:
|
|
82
|
+
"""Alias catalog for chat models + shared thinking level catalog."""
|
|
83
|
+
|
|
84
|
+
profiles: dict[str, ModelProfile]
|
|
85
|
+
default: str
|
|
86
|
+
# Allowed thinking levels for the session (/model thinking ...).
|
|
87
|
+
thinking_levels: list[str] = field(default_factory=default_thinking_levels)
|
|
88
|
+
# Optional global default when a profile omits thinking config.
|
|
89
|
+
default_thinking: str | None = None
|
|
90
|
+
# Independent image-to-text model configuration from models.json.
|
|
91
|
+
vision_model: dict[str, Any] | None = None
|
|
92
|
+
|
|
93
|
+
def list_names(self) -> list[str]:
|
|
94
|
+
return sorted(self.profiles)
|
|
95
|
+
|
|
96
|
+
def get(self, name: str | None = None) -> ModelProfile:
|
|
97
|
+
key = (name or self.default or "").strip()
|
|
98
|
+
if not key:
|
|
99
|
+
raise KeyError("no model profile selected")
|
|
100
|
+
if key in self.profiles:
|
|
101
|
+
return self.profiles[key]
|
|
102
|
+
# Match concrete provider:model ids stored on profiles.
|
|
103
|
+
for prof in self.profiles.values():
|
|
104
|
+
if prof.model == key:
|
|
105
|
+
return prof
|
|
106
|
+
short = key.split(":", 1)[1] if ":" in key else key
|
|
107
|
+
for prof in self.profiles.values():
|
|
108
|
+
if short_model_id(prof.model) == short:
|
|
109
|
+
return prof
|
|
110
|
+
if ":" in key:
|
|
111
|
+
# Ad-hoc provider:model with no credentials from models.json
|
|
112
|
+
return ModelProfile(name=key, model=key)
|
|
113
|
+
raise KeyError(f"unknown model profile: {key}")
|
|
114
|
+
|
|
115
|
+
def allowed_thinking_levels(self, name: str | None = None) -> list[str]:
|
|
116
|
+
"""Effective thinking levels for a model (registry ∩ optional profile subset)."""
|
|
117
|
+
base = list(self.thinking_levels or default_thinking_levels())
|
|
118
|
+
try:
|
|
119
|
+
profile = self.get(name)
|
|
120
|
+
except KeyError:
|
|
121
|
+
return base
|
|
122
|
+
if not profile.thinking_levels:
|
|
123
|
+
return base
|
|
124
|
+
allowed = set(profile.thinking_levels)
|
|
125
|
+
filtered = [level for level in base if level in allowed]
|
|
126
|
+
return filtered or list(profile.thinking_levels)
|
|
127
|
+
|
|
128
|
+
def build_chat_model(
|
|
129
|
+
self,
|
|
130
|
+
name: str | None = None,
|
|
131
|
+
*,
|
|
132
|
+
fallback_api_key: str | None = None,
|
|
133
|
+
fallback_base_url: str | None = None,
|
|
134
|
+
fallback_enable_thinking: bool = True,
|
|
135
|
+
fallback_reasoning_effort: str = "high",
|
|
136
|
+
fallback_parallel_tool_calls: bool = True,
|
|
137
|
+
fallback_websocket: bool = False,
|
|
138
|
+
fallback_stream_chunk_timeout: float | None = None,
|
|
139
|
+
enable_thinking: bool | None = None,
|
|
140
|
+
reasoning_effort: str | None = None,
|
|
141
|
+
progress: Callable[[str], None] | None = None,
|
|
142
|
+
):
|
|
143
|
+
"""Construct a LangChain chat model for the selected profile.
|
|
144
|
+
|
|
145
|
+
Thinking resolution order:
|
|
146
|
+
1. Explicit ``enable_thinking`` / ``reasoning_effort`` (session Settings)
|
|
147
|
+
2. Profile defaults
|
|
148
|
+
3. ``fallback_*`` kwargs
|
|
149
|
+
|
|
150
|
+
For OpenAI-compatible models, ``stream_chunk_timeout`` defaults to the
|
|
151
|
+
session fallback (usually disabled) so long reasoning pauses do not
|
|
152
|
+
raise langchain-openai's StreamChunkTimeoutError. Profile ``params`` /
|
|
153
|
+
top-level ``stream_chunk_timeout`` still win when present.
|
|
154
|
+
"""
|
|
155
|
+
from synapse.observability.startup_trace import span
|
|
156
|
+
|
|
157
|
+
profile = self.get(name)
|
|
158
|
+
kwargs: dict[str, Any] = dict(profile.extra or {})
|
|
159
|
+
model_name = profile.model
|
|
160
|
+
|
|
161
|
+
api_key = profile.resolved_api_key() or fallback_api_key
|
|
162
|
+
base_url = profile.base_url or fallback_base_url
|
|
163
|
+
|
|
164
|
+
if enable_thinking is None:
|
|
165
|
+
resolved_enable = (
|
|
166
|
+
fallback_enable_thinking
|
|
167
|
+
if profile.enable_thinking is None
|
|
168
|
+
else profile.enable_thinking
|
|
169
|
+
)
|
|
170
|
+
else:
|
|
171
|
+
resolved_enable = bool(enable_thinking)
|
|
172
|
+
|
|
173
|
+
if reasoning_effort is None:
|
|
174
|
+
resolved_effort = profile.reasoning_effort or fallback_reasoning_effort
|
|
175
|
+
else:
|
|
176
|
+
resolved_effort = reasoning_effort
|
|
177
|
+
|
|
178
|
+
if not resolved_enable:
|
|
179
|
+
resolved_effort = None
|
|
180
|
+
|
|
181
|
+
parallel = (
|
|
182
|
+
fallback_parallel_tool_calls
|
|
183
|
+
if profile.parallel_tool_calls is None
|
|
184
|
+
else profile.parallel_tool_calls
|
|
185
|
+
)
|
|
186
|
+
websocket = fallback_websocket if profile.websocket is None else profile.websocket
|
|
187
|
+
|
|
188
|
+
if model_name.startswith("openai:"):
|
|
189
|
+
if progress is not None:
|
|
190
|
+
progress("loading OpenAI SDK")
|
|
191
|
+
with span("model:openai_compat_patch"):
|
|
192
|
+
enable_openai_compat_reasoning_patch()
|
|
193
|
+
if base_url:
|
|
194
|
+
kwargs["base_url"] = str(base_url).rstrip("/")
|
|
195
|
+
if api_key:
|
|
196
|
+
kwargs["api_key"] = api_key
|
|
197
|
+
kwargs.setdefault("use_responses_api", bool(websocket))
|
|
198
|
+
if websocket:
|
|
199
|
+
kwargs["use_responses_api"] = True
|
|
200
|
+
kwargs.setdefault("streaming", True)
|
|
201
|
+
# Override langchain-openai default 120s silence killer unless profile set it.
|
|
202
|
+
if "stream_chunk_timeout" not in kwargs:
|
|
203
|
+
kwargs["stream_chunk_timeout"] = fallback_stream_chunk_timeout
|
|
204
|
+
|
|
205
|
+
model_kwargs = dict(kwargs.get("model_kwargs") or {})
|
|
206
|
+
model_kwargs.update(dict(profile.model_kwargs or {}))
|
|
207
|
+
if parallel:
|
|
208
|
+
model_kwargs.setdefault("parallel_tool_calls", True)
|
|
209
|
+
kwargs["model_kwargs"] = model_kwargs
|
|
210
|
+
|
|
211
|
+
# Thinking / reasoning level + optional user extra_body merge
|
|
212
|
+
think_kwargs = deepseek_thinking_kwargs(
|
|
213
|
+
enabled=bool(resolved_enable),
|
|
214
|
+
reasoning_effort=str(resolved_effort or "high"),
|
|
215
|
+
)
|
|
216
|
+
extra_body = dict(think_kwargs.get("extra_body") or {})
|
|
217
|
+
user_body = dict(profile.extra_body or {})
|
|
218
|
+
if user_body:
|
|
219
|
+
# Deep merge one level for thinking key if both present
|
|
220
|
+
if "thinking" in extra_body and isinstance(user_body.get("thinking"), dict):
|
|
221
|
+
merged_thinking = dict(extra_body["thinking"])
|
|
222
|
+
merged_thinking.update(user_body["thinking"])
|
|
223
|
+
user_body = dict(user_body)
|
|
224
|
+
user_body["thinking"] = merged_thinking
|
|
225
|
+
extra_body.update(user_body)
|
|
226
|
+
if resolved_enable:
|
|
227
|
+
kwargs["reasoning_effort"] = think_kwargs.get(
|
|
228
|
+
"reasoning_effort", resolved_effort
|
|
229
|
+
)
|
|
230
|
+
# Always set extra_body so disable path works
|
|
231
|
+
existing_body = dict(kwargs.get("extra_body") or {})
|
|
232
|
+
existing_body.update(extra_body)
|
|
233
|
+
kwargs["extra_body"] = existing_body
|
|
234
|
+
# Build an async-only OpenAI path. An async API-key callable tells
|
|
235
|
+
# ChatOpenAI not to construct its otherwise eager sync OpenAI client.
|
|
236
|
+
from synapse.integrations.http_clients import build_openai_async_http_client
|
|
237
|
+
|
|
238
|
+
if progress is not None:
|
|
239
|
+
progress("creating async model client")
|
|
240
|
+
async_client = build_openai_async_http_client(
|
|
241
|
+
timeout=kwargs.get("timeout"),
|
|
242
|
+
proxy=kwargs.pop("openai_proxy", None),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
async def _async_api_key() -> str:
|
|
246
|
+
return str(api_key or "")
|
|
247
|
+
|
|
248
|
+
kwargs["api_key"] = _async_api_key
|
|
249
|
+
kwargs["http_async_client"] = async_client
|
|
250
|
+
kwargs.setdefault("http_socket_options", ())
|
|
251
|
+
elif model_name.startswith("anthropic:"):
|
|
252
|
+
if progress is not None:
|
|
253
|
+
progress("loading Anthropic SDK")
|
|
254
|
+
if api_key:
|
|
255
|
+
kwargs["api_key"] = api_key
|
|
256
|
+
kwargs["anthropic_api_key"] = api_key
|
|
257
|
+
if base_url:
|
|
258
|
+
# ChatAnthropic accepts base_url alias for anthropic_api_url
|
|
259
|
+
kwargs["base_url"] = str(base_url).rstrip("/")
|
|
260
|
+
kwargs.setdefault("streaming", True)
|
|
261
|
+
if profile.model_kwargs:
|
|
262
|
+
mk = dict(kwargs.get("model_kwargs") or {})
|
|
263
|
+
mk.update(profile.model_kwargs)
|
|
264
|
+
kwargs["model_kwargs"] = mk
|
|
265
|
+
# ChatAnthropic has a first-class `thinking` field; map from extra_body.
|
|
266
|
+
body = dict(profile.extra_body or {})
|
|
267
|
+
thinking_cfg = body.pop("thinking", None)
|
|
268
|
+
if thinking_cfg is not None and "thinking" not in kwargs:
|
|
269
|
+
kwargs["thinking"] = thinking_cfg
|
|
270
|
+
if body:
|
|
271
|
+
mk = dict(kwargs.get("model_kwargs") or {})
|
|
272
|
+
mk.update(body)
|
|
273
|
+
kwargs["model_kwargs"] = mk
|
|
274
|
+
from synapse.integrations.http_clients import enable_anthropic_long_keepalive_defaults
|
|
275
|
+
|
|
276
|
+
enable_anthropic_long_keepalive_defaults()
|
|
277
|
+
|
|
278
|
+
if progress is not None and not model_name.startswith("openai:"):
|
|
279
|
+
progress("creating async model client")
|
|
280
|
+
try:
|
|
281
|
+
with span("model:init_chat_model"):
|
|
282
|
+
if model_name.startswith("openai:") and websocket:
|
|
283
|
+
from synapse.integrations.llm_openai_websocket import (
|
|
284
|
+
ResponsesWebSocketChatOpenAI,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
chat_model = ResponsesWebSocketChatOpenAI(
|
|
288
|
+
model=short_model_id(model_name),
|
|
289
|
+
**kwargs,
|
|
290
|
+
)
|
|
291
|
+
else:
|
|
292
|
+
chat_model = init_chat_model(model_name, **kwargs)
|
|
293
|
+
except Exception:
|
|
294
|
+
if model_name.startswith("openai:"):
|
|
295
|
+
from synapse.runtime.async_runtime import get_async_runtime
|
|
296
|
+
|
|
297
|
+
get_async_runtime().close_connection(async_client)
|
|
298
|
+
raise
|
|
299
|
+
if model_name.startswith("openai:"):
|
|
300
|
+
chat_model._coding_http_async_client = async_client
|
|
301
|
+
chat_model._coding_async_only = True
|
|
302
|
+
chat_model._coding_websocket = bool(websocket)
|
|
303
|
+
if websocket:
|
|
304
|
+
from synapse.runtime.async_runtime import get_async_runtime
|
|
305
|
+
|
|
306
|
+
get_async_runtime().track_connection(chat_model)
|
|
307
|
+
return apply_context_window_to_model(chat_model, profile.context_window)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _profiles_from_mapping(data: dict[str, Any]) -> ModelRegistry:
|
|
311
|
+
raw_models = data.get("models") or {}
|
|
312
|
+
if not isinstance(raw_models, dict) or not raw_models:
|
|
313
|
+
raise ValueError("models config must contain a non-empty 'models' object")
|
|
314
|
+
|
|
315
|
+
top_levels = parse_thinking_levels(data.get("thinking_levels"))
|
|
316
|
+
thinking_levels = top_levels or default_thinking_levels()
|
|
317
|
+
default_thinking = normalize_thinking_level(data.get("default_thinking"))
|
|
318
|
+
vision_model = data.get("vision_model")
|
|
319
|
+
if vision_model is not None and not isinstance(vision_model, (str, dict)):
|
|
320
|
+
raise ValueError("vision_model must be an object or profile name")
|
|
321
|
+
if vision_model is None and isinstance(raw_models.get("vision_model"), dict):
|
|
322
|
+
# Backward-compatible convenience: allow the vision endpoint to live
|
|
323
|
+
# alongside primary model profiles under models.vision_model.
|
|
324
|
+
vision_model = dict(raw_models["vision_model"])
|
|
325
|
+
|
|
326
|
+
profiles: dict[str, ModelProfile] = {}
|
|
327
|
+
for name, cfg in raw_models.items():
|
|
328
|
+
if not isinstance(cfg, dict):
|
|
329
|
+
raise ValueError(f"model profile {name!r} must be an object")
|
|
330
|
+
model = str(expand_env_string(cfg.get("model") or "")).strip()
|
|
331
|
+
if not model:
|
|
332
|
+
raise ValueError(f"model profile {name!r} missing 'model'")
|
|
333
|
+
|
|
334
|
+
enable_thinking, reasoning_effort = parse_thinking_config(cfg)
|
|
335
|
+
# Profile omits thinking -> optional top-level default_thinking.
|
|
336
|
+
if enable_thinking is None and reasoning_effort is None and default_thinking:
|
|
337
|
+
if default_thinking == "off":
|
|
338
|
+
enable_thinking, reasoning_effort = False, None
|
|
339
|
+
else:
|
|
340
|
+
enable_thinking, reasoning_effort = True, default_thinking
|
|
341
|
+
|
|
342
|
+
profile_levels = parse_thinking_levels(cfg.get("thinking_levels"))
|
|
343
|
+
if profile_levels is not None:
|
|
344
|
+
catalog = set(thinking_levels)
|
|
345
|
+
profile_levels = [
|
|
346
|
+
level for level in profile_levels if level in catalog
|
|
347
|
+
] or profile_levels
|
|
348
|
+
|
|
349
|
+
params = _coerce_params(cfg)
|
|
350
|
+
# Expand env in string params
|
|
351
|
+
expanded_params: dict[str, Any] = {}
|
|
352
|
+
for k, v in params.items():
|
|
353
|
+
expanded_params[k] = expand_env_string(v) if isinstance(v, str) else v
|
|
354
|
+
|
|
355
|
+
base_url = cfg.get("base_url")
|
|
356
|
+
if base_url is not None:
|
|
357
|
+
base_url = str(expand_env_string(base_url)).strip() or None
|
|
358
|
+
|
|
359
|
+
model_kwargs = cfg.get("model_kwargs") or {}
|
|
360
|
+
if not isinstance(model_kwargs, dict):
|
|
361
|
+
raise ValueError(f"model profile {name!r} model_kwargs must be an object")
|
|
362
|
+
extra_body = cfg.get("extra_body") or {}
|
|
363
|
+
if not isinstance(extra_body, dict):
|
|
364
|
+
raise ValueError(f"model profile {name!r} extra_body must be an object")
|
|
365
|
+
|
|
366
|
+
# model_kwargs / extra_body may also appear inside params — peel them out
|
|
367
|
+
if "model_kwargs" in expanded_params and isinstance(expanded_params["model_kwargs"], dict):
|
|
368
|
+
merged_mk = dict(model_kwargs)
|
|
369
|
+
merged_mk.update(expanded_params.pop("model_kwargs"))
|
|
370
|
+
model_kwargs = merged_mk
|
|
371
|
+
if "extra_body" in expanded_params and isinstance(expanded_params["extra_body"], dict):
|
|
372
|
+
merged_eb = dict(extra_body)
|
|
373
|
+
merged_eb.update(expanded_params.pop("extra_body"))
|
|
374
|
+
extra_body = merged_eb
|
|
375
|
+
|
|
376
|
+
parallel = cfg.get("parallel_tool_calls")
|
|
377
|
+
if parallel is None and "parallel_tool_calls" in expanded_params:
|
|
378
|
+
parallel = expanded_params.pop("parallel_tool_calls")
|
|
379
|
+
|
|
380
|
+
image_input = cfg.get("image_input")
|
|
381
|
+
if image_input is None and isinstance(cfg.get("capabilities"), dict):
|
|
382
|
+
image_input = cfg["capabilities"].get("image_input")
|
|
383
|
+
image_input = parse_optional_bool(image_input)
|
|
384
|
+
websocket = parse_optional_bool(cfg.get("websocket"), field_name="websocket")
|
|
385
|
+
|
|
386
|
+
context_window = parse_context_window(cfg)
|
|
387
|
+
# Peel accidental copies from free-form params (meta keys should already
|
|
388
|
+
# exclude these; keep defensive cleanup for nested params/extra).
|
|
389
|
+
for key in ("context_window", "contextwindow", "max_input_tokens"):
|
|
390
|
+
expanded_params.pop(key, None)
|
|
391
|
+
|
|
392
|
+
profiles[str(name)] = ModelProfile(
|
|
393
|
+
name=str(name),
|
|
394
|
+
model=model,
|
|
395
|
+
api_key=cfg.get("api_key"),
|
|
396
|
+
api_key_env=cfg.get("api_key_env"),
|
|
397
|
+
base_url=base_url,
|
|
398
|
+
context_window=context_window,
|
|
399
|
+
enable_thinking=enable_thinking,
|
|
400
|
+
reasoning_effort=reasoning_effort,
|
|
401
|
+
thinking_levels=tuple(profile_levels) if profile_levels else None,
|
|
402
|
+
parallel_tool_calls=None if parallel is None else bool(parallel),
|
|
403
|
+
websocket=websocket,
|
|
404
|
+
image_input=image_input,
|
|
405
|
+
extra=expanded_params,
|
|
406
|
+
model_kwargs=dict(model_kwargs),
|
|
407
|
+
extra_body=dict(extra_body),
|
|
408
|
+
)
|
|
409
|
+
default = str(expand_env_string(data.get("default") or next(iter(profiles))))
|
|
410
|
+
if default not in profiles:
|
|
411
|
+
raise ValueError(f"default model {default!r} not in models")
|
|
412
|
+
return ModelRegistry(
|
|
413
|
+
profiles=profiles,
|
|
414
|
+
default=default,
|
|
415
|
+
vision_model=dict(vision_model) if vision_model else None,
|
|
416
|
+
thinking_levels=thinking_levels,
|
|
417
|
+
default_thinking=default_thinking,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def load_models_config(path: Path | str | None) -> ModelRegistry | None:
|
|
422
|
+
"""Load models JSON if path exists; return None when unset/missing."""
|
|
423
|
+
if path is None:
|
|
424
|
+
return None
|
|
425
|
+
p = Path(path).expanduser()
|
|
426
|
+
if not p.is_file():
|
|
427
|
+
return None
|
|
428
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
429
|
+
if not isinstance(data, dict):
|
|
430
|
+
raise ValueError("models config root must be an object")
|
|
431
|
+
return _profiles_from_mapping(data)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def load_models_json_blob(blob: str | None) -> ModelRegistry | None:
|
|
435
|
+
if not blob or not blob.strip():
|
|
436
|
+
return None
|
|
437
|
+
data = json.loads(blob)
|
|
438
|
+
if not isinstance(data, dict):
|
|
439
|
+
raise ValueError("MODELS_JSON root must be an object")
|
|
440
|
+
return _profiles_from_mapping(data)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def merge_model_profiles(base: ModelProfile, override: ModelProfile) -> ModelProfile:
|
|
444
|
+
"""Merge two profiles with the same name; override wins non-empty fields."""
|
|
445
|
+
return ModelProfile(
|
|
446
|
+
name=override.name or base.name,
|
|
447
|
+
model=override.model or base.model,
|
|
448
|
+
api_key=override.api_key if override.api_key not in (None, "") else base.api_key,
|
|
449
|
+
api_key_env=(
|
|
450
|
+
override.api_key_env
|
|
451
|
+
if override.api_key_env not in (None, "")
|
|
452
|
+
else base.api_key_env
|
|
453
|
+
),
|
|
454
|
+
base_url=override.base_url if override.base_url not in (None, "") else base.base_url,
|
|
455
|
+
context_window=(
|
|
456
|
+
override.context_window
|
|
457
|
+
if override.context_window is not None
|
|
458
|
+
else base.context_window
|
|
459
|
+
),
|
|
460
|
+
enable_thinking=(
|
|
461
|
+
override.enable_thinking
|
|
462
|
+
if override.enable_thinking is not None
|
|
463
|
+
else base.enable_thinking
|
|
464
|
+
),
|
|
465
|
+
reasoning_effort=(
|
|
466
|
+
override.reasoning_effort
|
|
467
|
+
if override.reasoning_effort not in (None, "")
|
|
468
|
+
else base.reasoning_effort
|
|
469
|
+
),
|
|
470
|
+
thinking_levels=(
|
|
471
|
+
override.thinking_levels
|
|
472
|
+
if override.thinking_levels is not None
|
|
473
|
+
else base.thinking_levels
|
|
474
|
+
),
|
|
475
|
+
parallel_tool_calls=(
|
|
476
|
+
override.parallel_tool_calls
|
|
477
|
+
if override.parallel_tool_calls is not None
|
|
478
|
+
else base.parallel_tool_calls
|
|
479
|
+
),
|
|
480
|
+
websocket=(
|
|
481
|
+
override.websocket if override.websocket is not None else base.websocket
|
|
482
|
+
),
|
|
483
|
+
image_input=(
|
|
484
|
+
override.image_input
|
|
485
|
+
if override.image_input is not None
|
|
486
|
+
else base.image_input
|
|
487
|
+
),
|
|
488
|
+
extra={**base.extra, **override.extra},
|
|
489
|
+
model_kwargs={**base.model_kwargs, **override.model_kwargs},
|
|
490
|
+
extra_body={**base.extra_body, **override.extra_body},
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def merge_model_registries(
|
|
495
|
+
base: ModelRegistry | None,
|
|
496
|
+
override: ModelRegistry | None,
|
|
497
|
+
) -> ModelRegistry | None:
|
|
498
|
+
if base is None:
|
|
499
|
+
return override
|
|
500
|
+
if override is None:
|
|
501
|
+
return base
|
|
502
|
+
profiles = dict(base.profiles)
|
|
503
|
+
for name, prof in override.profiles.items():
|
|
504
|
+
if name in profiles:
|
|
505
|
+
profiles[name] = merge_model_profiles(profiles[name], prof)
|
|
506
|
+
else:
|
|
507
|
+
profiles[name] = prof
|
|
508
|
+
default = override.default if override.default in profiles else base.default
|
|
509
|
+
if default not in profiles:
|
|
510
|
+
default = next(iter(profiles))
|
|
511
|
+
# Prefer non-default thinking_levels from override layer when customized.
|
|
512
|
+
if override.thinking_levels and override.thinking_levels != list(DEFAULT_THINKING_LEVELS):
|
|
513
|
+
thinking_levels = list(override.thinking_levels)
|
|
514
|
+
elif base.thinking_levels:
|
|
515
|
+
thinking_levels = list(base.thinking_levels)
|
|
516
|
+
else:
|
|
517
|
+
thinking_levels = default_thinking_levels()
|
|
518
|
+
default_thinking = (
|
|
519
|
+
override.default_thinking
|
|
520
|
+
if override.default_thinking not in (None, "")
|
|
521
|
+
else base.default_thinking
|
|
522
|
+
)
|
|
523
|
+
return ModelRegistry(
|
|
524
|
+
profiles=profiles,
|
|
525
|
+
default=default,
|
|
526
|
+
vision_model=(
|
|
527
|
+
{**base.vision_model, **override.vision_model}
|
|
528
|
+
if base.vision_model and override.vision_model
|
|
529
|
+
else override.vision_model or base.vision_model
|
|
530
|
+
),
|
|
531
|
+
thinking_levels=thinking_levels,
|
|
532
|
+
default_thinking=default_thinking,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def default_models_config_path(workspace: Path | str | None = None) -> Path:
|
|
537
|
+
"""Canonical project path: <workspace>/.coding-agent/models.json."""
|
|
538
|
+
base = Path(workspace).expanduser().resolve() if workspace is not None else Path.cwd()
|
|
539
|
+
return (base / DEFAULT_MODELS_CONFIG_REL).resolve()
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def resolve_models_config_paths(settings: Any) -> list[Path]:
|
|
543
|
+
"""All models.json files that participate in the merge (user → project).
|
|
544
|
+
|
|
545
|
+
If ``settings.models_config_path`` is set, only that explicit file is used.
|
|
546
|
+
"""
|
|
547
|
+
from synapse.settings.config_paths import models_config_paths
|
|
548
|
+
|
|
549
|
+
explicit = getattr(settings, "models_config_path", None)
|
|
550
|
+
workspace = getattr(settings, "workspace", None) or Path.cwd()
|
|
551
|
+
if explicit is not None:
|
|
552
|
+
p = Path(explicit).expanduser()
|
|
553
|
+
if not p.is_absolute():
|
|
554
|
+
p = (Path(workspace).expanduser().resolve() / p).resolve()
|
|
555
|
+
else:
|
|
556
|
+
p = p.resolve()
|
|
557
|
+
return [p] if p.is_file() else [p]
|
|
558
|
+
return models_config_paths(workspace)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def resolve_models_config_path(settings: Any) -> Path | None:
|
|
562
|
+
"""Primary (highest priority) models.json path for display/status."""
|
|
563
|
+
paths = resolve_models_config_paths(settings)
|
|
564
|
+
return paths[-1] if paths else None
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def load_merged_models_registry(settings: Any) -> ModelRegistry | None:
|
|
568
|
+
"""Load and merge layered models.json (+ optional MODELS_JSON blob)."""
|
|
569
|
+
blob = load_models_json_blob(getattr(settings, "models_json", None))
|
|
570
|
+
reg: ModelRegistry | None = None
|
|
571
|
+
for path in resolve_models_config_paths(settings):
|
|
572
|
+
layer = load_models_config(path)
|
|
573
|
+
reg = merge_model_registries(reg, layer)
|
|
574
|
+
reg = merge_model_registries(reg, blob)
|
|
575
|
+
return reg
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def registry_from_settings(settings: Any) -> ModelRegistry:
|
|
579
|
+
"""Build registry from Settings, preferring multi-model config files."""
|
|
580
|
+
reg = load_merged_models_registry(settings)
|
|
581
|
+
if reg is not None:
|
|
582
|
+
selected = (getattr(settings, "active_model", None) or "").strip()
|
|
583
|
+
if not selected:
|
|
584
|
+
candidate = (settings.model or "").strip()
|
|
585
|
+
if candidate in reg.profiles:
|
|
586
|
+
selected = candidate
|
|
587
|
+
if selected:
|
|
588
|
+
if selected in reg.profiles:
|
|
589
|
+
reg.default = selected
|
|
590
|
+
elif ":" in selected:
|
|
591
|
+
reg.profiles[selected] = ModelProfile(name=selected, model=selected)
|
|
592
|
+
reg.default = selected
|
|
593
|
+
return reg
|
|
594
|
+
|
|
595
|
+
name = settings.model
|
|
596
|
+
return ModelRegistry(
|
|
597
|
+
profiles={
|
|
598
|
+
name: ModelProfile(
|
|
599
|
+
name=name,
|
|
600
|
+
model=settings.model,
|
|
601
|
+
api_key=settings.openai_api_key or settings.anthropic_api_key,
|
|
602
|
+
base_url=settings.openai_base_url,
|
|
603
|
+
enable_thinking=settings.enable_thinking,
|
|
604
|
+
reasoning_effort=settings.reasoning_effort,
|
|
605
|
+
parallel_tool_calls=settings.parallel_tool_calls,
|
|
606
|
+
websocket=getattr(settings, "openai_websocket", False),
|
|
607
|
+
image_input=None,
|
|
608
|
+
)
|
|
609
|
+
},
|
|
610
|
+
default=name,
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def apply_models_config_to_settings(settings: Any) -> Any:
|
|
615
|
+
"""Resolve layered models.json and sync selected profile into Settings."""
|
|
616
|
+
reg = load_merged_models_registry(settings)
|
|
617
|
+
if reg is None:
|
|
618
|
+
return settings
|
|
619
|
+
|
|
620
|
+
paths = resolve_models_config_paths(settings)
|
|
621
|
+
primary = paths[-1] if paths else None
|
|
622
|
+
if primary is not None and getattr(settings, "models_config_path", None) != primary:
|
|
623
|
+
settings = settings.model_copy(update={"models_config_path": primary})
|
|
624
|
+
|
|
625
|
+
selected = (getattr(settings, "active_model", None) or "").strip()
|
|
626
|
+
if not selected:
|
|
627
|
+
candidate = (settings.model or "").strip()
|
|
628
|
+
if candidate in reg.profiles:
|
|
629
|
+
selected = candidate
|
|
630
|
+
else:
|
|
631
|
+
selected = reg.default
|
|
632
|
+
if selected not in reg.profiles and ":" not in selected:
|
|
633
|
+
selected = reg.default
|
|
634
|
+
profile = reg.get(selected)
|
|
635
|
+
|
|
636
|
+
updates: dict[str, Any] = {
|
|
637
|
+
"active_model": profile.name,
|
|
638
|
+
"model": profile.model,
|
|
639
|
+
}
|
|
640
|
+
if profile.base_url:
|
|
641
|
+
updates["openai_base_url"] = profile.base_url
|
|
642
|
+
if profile.enable_thinking is not None:
|
|
643
|
+
updates["enable_thinking"] = bool(profile.enable_thinking)
|
|
644
|
+
if profile.reasoning_effort:
|
|
645
|
+
updates["reasoning_effort"] = profile.reasoning_effort
|
|
646
|
+
if profile.parallel_tool_calls is not None:
|
|
647
|
+
updates["parallel_tool_calls"] = bool(profile.parallel_tool_calls)
|
|
648
|
+
# Keep OPENAI_WEBSOCKET as the global/legacy fallback. A profile-local
|
|
649
|
+
# websocket value is resolved directly from ModelProfile when that model is
|
|
650
|
+
# built; copying it into Settings would make later profiles without an
|
|
651
|
+
# explicit value inherit the previous model's transport.
|
|
652
|
+
|
|
653
|
+
# Prefer keys from models.json so the agent can run without .env.
|
|
654
|
+
key = profile.resolved_api_key()
|
|
655
|
+
if key:
|
|
656
|
+
if str(profile.model).startswith("anthropic:"):
|
|
657
|
+
updates["anthropic_api_key"] = key
|
|
658
|
+
updates["openai_api_key"] = None
|
|
659
|
+
else:
|
|
660
|
+
updates["openai_api_key"] = key
|
|
661
|
+
updates["anthropic_api_key"] = None
|
|
662
|
+
else:
|
|
663
|
+
updates["openai_api_key"] = None
|
|
664
|
+
updates["anthropic_api_key"] = None
|
|
665
|
+
|
|
666
|
+
return settings.model_copy(update=updates)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def model_cache_key(settings: Any, *, model_name: str | None = None) -> str:
|
|
670
|
+
"""Stable key for reusing one configured ChatModel within an agent session."""
|
|
671
|
+
reg = registry_from_settings(settings)
|
|
672
|
+
selected = model_name or getattr(settings, "active_model", None) or reg.default
|
|
673
|
+
profile = reg.get(selected)
|
|
674
|
+
api_key = profile.resolved_api_key() or settings_fallback_api_key(settings, profile.model)
|
|
675
|
+
payload = {
|
|
676
|
+
"selected": selected,
|
|
677
|
+
"model": profile.model,
|
|
678
|
+
"base_url": profile.base_url or getattr(settings, "openai_base_url", None),
|
|
679
|
+
"api_key_sha256": hashlib.sha256((api_key or "").encode()).hexdigest(),
|
|
680
|
+
"enable_thinking": bool(getattr(settings, "enable_thinking", True)),
|
|
681
|
+
"reasoning_effort": getattr(settings, "reasoning_effort", None),
|
|
682
|
+
"parallel_tool_calls": getattr(settings, "parallel_tool_calls", True),
|
|
683
|
+
"websocket": (
|
|
684
|
+
profile.websocket
|
|
685
|
+
if profile.websocket is not None
|
|
686
|
+
else getattr(settings, "openai_websocket", False)
|
|
687
|
+
),
|
|
688
|
+
"stream_chunk_timeout": getattr(settings, "stream_chunk_timeout", None),
|
|
689
|
+
"context_window": profile.context_window,
|
|
690
|
+
"extra": profile.extra,
|
|
691
|
+
"model_kwargs": profile.model_kwargs,
|
|
692
|
+
"extra_body": profile.extra_body,
|
|
693
|
+
}
|
|
694
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def build_model_from_settings(
|
|
698
|
+
settings: Any,
|
|
699
|
+
*,
|
|
700
|
+
model_name: str | None = None,
|
|
701
|
+
progress: Callable[[str], None] | None = None,
|
|
702
|
+
):
|
|
703
|
+
"""Convenience: registry + construct selected model."""
|
|
704
|
+
reg = registry_from_settings(settings)
|
|
705
|
+
selected = model_name or getattr(settings, "active_model", None) or reg.default
|
|
706
|
+
profile = reg.get(selected)
|
|
707
|
+
return reg, reg.build_chat_model(
|
|
708
|
+
selected,
|
|
709
|
+
fallback_api_key=settings_fallback_api_key(settings, profile.model),
|
|
710
|
+
fallback_base_url=settings.openai_base_url,
|
|
711
|
+
fallback_enable_thinking=settings.enable_thinking,
|
|
712
|
+
fallback_reasoning_effort=settings.reasoning_effort,
|
|
713
|
+
fallback_parallel_tool_calls=settings.parallel_tool_calls,
|
|
714
|
+
fallback_websocket=getattr(settings, "openai_websocket", False),
|
|
715
|
+
fallback_stream_chunk_timeout=getattr(
|
|
716
|
+
settings, "stream_chunk_timeout", None
|
|
717
|
+
),
|
|
718
|
+
# Session Settings always win over profile defaults.
|
|
719
|
+
enable_thinking=bool(settings.enable_thinking),
|
|
720
|
+
reasoning_effort=settings.reasoning_effort,
|
|
721
|
+
progress=progress,
|
|
722
|
+
)
|