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,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
+ )