caudate-cli 0.1.0__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 (153) hide show
  1. api/__init__.py +5 -0
  2. api/anthropic_compat.py +1518 -0
  3. api/artifact_viewer.py +366 -0
  4. api/caudate_middleware.py +618 -0
  5. api/forge_bootstrapper_routes.py +377 -0
  6. api/forge_routes.py +630 -0
  7. api/forge_system_routes.py +294 -0
  8. api/openai_compat.py +1993 -0
  9. api/server.py +667 -0
  10. api/storyboard_page.py +677 -0
  11. caudate_cli-0.1.0.dist-info/METADATA +354 -0
  12. caudate_cli-0.1.0.dist-info/RECORD +153 -0
  13. caudate_cli-0.1.0.dist-info/WHEEL +5 -0
  14. caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
  15. caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  16. caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
  17. cognos_mcp/__init__.py +4 -0
  18. cognos_mcp/bridge.py +41 -0
  19. cognos_mcp/client.py +70 -0
  20. cognos_mcp/config.py +49 -0
  21. cognos_mcp/server.py +66 -0
  22. config.py +82 -0
  23. core/__init__.py +0 -0
  24. core/agent.py +468 -0
  25. core/agentic_loop.py +731 -0
  26. core/anthropic_auth.py +91 -0
  27. core/background.py +113 -0
  28. core/banner.py +134 -0
  29. core/bootstrap.py +292 -0
  30. core/citations.py +131 -0
  31. core/compaction.py +109 -0
  32. core/constitution.py +198 -0
  33. core/diff_viewer.py +87 -0
  34. core/export.py +85 -0
  35. core/file_refs.py +119 -0
  36. core/files.py +199 -0
  37. core/hooks.py +209 -0
  38. core/image.py +599 -0
  39. core/input.py +91 -0
  40. core/loop.py +238 -0
  41. core/memory_md.py +147 -0
  42. core/notifications.py +99 -0
  43. core/ownership.py +181 -0
  44. core/paste.py +81 -0
  45. core/permissions.py +210 -0
  46. core/plan_mode.py +215 -0
  47. core/sandbox_prompt.py +185 -0
  48. core/scheduler.py +195 -0
  49. core/schemas.py +202 -0
  50. core/session.py +90 -0
  51. core/settings.py +132 -0
  52. core/skills.py +398 -0
  53. core/slash_commands.py +977 -0
  54. core/statusline.py +61 -0
  55. core/subagent.py +300 -0
  56. core/thinking.py +50 -0
  57. core/updater.py +122 -0
  58. core/usage.py +109 -0
  59. core/worktree.py +93 -0
  60. execution/__init__.py +0 -0
  61. execution/executor.py +329 -0
  62. execution/plugins.py +108 -0
  63. execution/tools/__init__.py +0 -0
  64. execution/tools/agent_tool.py +107 -0
  65. execution/tools/agentic_tool.py +297 -0
  66. execution/tools/artifact_tool.py +191 -0
  67. execution/tools/ask_user_question_tool.py +137 -0
  68. execution/tools/base.py +81 -0
  69. execution/tools/calculator_tool.py +137 -0
  70. execution/tools/cognos_card_tool.py +124 -0
  71. execution/tools/cron_tool.py +215 -0
  72. execution/tools/datetime_tool.py +215 -0
  73. execution/tools/describe_image_tool.py +161 -0
  74. execution/tools/draw_tool.py +164 -0
  75. execution/tools/edit_image_tool.py +262 -0
  76. execution/tools/edit_tool.py +245 -0
  77. execution/tools/file_tool.py +90 -0
  78. execution/tools/find_anywhere_tool.py +255 -0
  79. execution/tools/forge_feature_tools.py +377 -0
  80. execution/tools/glob_tool.py +59 -0
  81. execution/tools/grep_tool.py +89 -0
  82. execution/tools/http_request_tool.py +224 -0
  83. execution/tools/load_skill_tool.py +104 -0
  84. execution/tools/longcat_avatar_tool.py +384 -0
  85. execution/tools/mcp_tool.py +100 -0
  86. execution/tools/notebook_tool.py +279 -0
  87. execution/tools/openapi_tool.py +440 -0
  88. execution/tools/plan_mode_tool.py +95 -0
  89. execution/tools/push_notification_tool.py +157 -0
  90. execution/tools/python_tool.py +61 -0
  91. execution/tools/respond_tool.py +40 -0
  92. execution/tools/sandbox_tool.py +378 -0
  93. execution/tools/search_tool.py +153 -0
  94. execution/tools/semantic_search_tool.py +106 -0
  95. execution/tools/shell_tool.py +283 -0
  96. execution/tools/speak_tool.py +134 -0
  97. execution/tools/storyboard_tool.py +727 -0
  98. execution/tools/system_info_tool.py +212 -0
  99. execution/tools/task_tool.py +323 -0
  100. execution/tools/think_tool.py +49 -0
  101. execution/tools/transcribe_audio_tool.py +86 -0
  102. execution/tools/update_memory_tool.py +92 -0
  103. execution/tools/web_fetch_tool.py +82 -0
  104. execution/tools/worktree_tool.py +174 -0
  105. llm/__init__.py +0 -0
  106. llm/fallback.py +116 -0
  107. llm/models.py +320 -0
  108. llm/provider.py +1356 -0
  109. llm/router.py +373 -0
  110. main.py +1889 -0
  111. memory/__init__.py +0 -0
  112. memory/episodic.py +99 -0
  113. memory/procedural.py +145 -0
  114. memory/semantic.py +71 -0
  115. memory/working.py +64 -0
  116. nn/__init__.py +43 -0
  117. nn/auto_evolve.py +245 -0
  118. nn/caudate.py +136 -0
  119. nn/config.py +141 -0
  120. nn/consolidator.py +81 -0
  121. nn/data.py +1635 -0
  122. nn/encoder.py +258 -0
  123. nn/forge_advisor.py +303 -0
  124. nn/format.py +235 -0
  125. nn/heads.py +432 -0
  126. nn/observer.py +994 -0
  127. nn/policy.py +214 -0
  128. nn/runtime.py +343 -0
  129. nn/scorer.py +175 -0
  130. nn/trainer.py +515 -0
  131. nn/vision.py +352 -0
  132. personality/__init__.py +23 -0
  133. personality/engine.py +129 -0
  134. personality/identity.py +144 -0
  135. personality/inner_voice.py +100 -0
  136. personality/mood.py +205 -0
  137. planning/__init__.py +0 -0
  138. planning/dev_server.py +221 -0
  139. planning/forge_models.py +718 -0
  140. planning/orchestrator.py +1363 -0
  141. planning/planner.py +451 -0
  142. planning/task_graph.py +61 -0
  143. reflection/__init__.py +0 -0
  144. reflection/meta_learner.py +156 -0
  145. reflection/reflector.py +127 -0
  146. ui/__init__.py +5 -0
  147. ui/display.py +88 -0
  148. voice/__init__.py +0 -0
  149. voice/conversation.py +125 -0
  150. voice/listener.py +111 -0
  151. voice/speaker.py +59 -0
  152. voice/stt.py +126 -0
  153. voice/tts.py +214 -0
@@ -0,0 +1,92 @@
1
+ """UpdateMemory — write to the durable cross-session memory.md.
2
+
3
+ Three modes:
4
+ - `append` : add a new entry to the bottom of memory.md
5
+ - `replace` : wholesale rewrite (use sparingly — destructive)
6
+ - `read` : return current contents (the LLM doesn't normally need
7
+ this since memory.md is auto-injected into the system
8
+ prompt every turn, but it's useful when the user asks
9
+ "what do you remember?")
10
+
11
+ Caudate's `memory_write` head also writes to this file automatically
12
+ when she predicts a turn is memory-worthy (above threshold) — that
13
+ path goes through `nn/observer.py` and bypasses this tool. The tool
14
+ exists for *explicit* memory updates the LLM (or user) wants to make.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any
20
+
21
+ from core.memory_md import get_memory
22
+ from core.schemas import ToolResult
23
+ from execution.tools.base import BaseTool
24
+
25
+
26
+ class UpdateMemoryTool(BaseTool):
27
+ mutates = True
28
+ name = "UpdateMemory"
29
+ description = (
30
+ "Manage Cognos's durable cross-session memory file. "
31
+ "Use this when the user says 'remember X', 'note that...', or "
32
+ "you want to record an important fact for future sessions. "
33
+ "Modes: 'append' (add a new entry), 'replace' (rewrite the whole "
34
+ "file — destructive, only use when explicitly asked to clean up), "
35
+ "'read' (return current contents). Memory is automatically "
36
+ "injected into your system prompt every turn — you don't need to "
37
+ "call 'read' to access it under normal flow."
38
+ )
39
+
40
+ @property
41
+ def input_schema(self) -> dict[str, Any]:
42
+ return {
43
+ "type": "object",
44
+ "properties": {
45
+ "mode": {
46
+ "type": "string",
47
+ "enum": ["append", "replace", "read"],
48
+ "description": "What to do with memory.md.",
49
+ },
50
+ "content": {
51
+ "type": "string",
52
+ "description": "For 'append' or 'replace': the text to write.",
53
+ },
54
+ "title": {
55
+ "type": "string",
56
+ "description": "Optional short title for an 'append' entry.",
57
+ },
58
+ },
59
+ "required": ["mode"],
60
+ }
61
+
62
+ async def execute(self, **kwargs: Any) -> ToolResult:
63
+ mode = (kwargs.get("mode") or "").lower()
64
+ memory = get_memory()
65
+ if mode == "read":
66
+ text = memory.read()
67
+ return ToolResult(
68
+ tool_name=self.name, status="success",
69
+ output=text or "(memory.md is empty)",
70
+ )
71
+ content = (kwargs.get("content") or "").strip()
72
+ if not content:
73
+ return ToolResult(
74
+ tool_name=self.name, status="error",
75
+ error="content is required for append/replace mode",
76
+ )
77
+ if mode == "append":
78
+ memory.append(content, source="explicit", title=kwargs.get("title", ""))
79
+ return ToolResult(
80
+ tool_name=self.name, status="success",
81
+ output=f"Appended to memory.md ({len(content)} chars)",
82
+ )
83
+ if mode == "replace":
84
+ memory.replace(content)
85
+ return ToolResult(
86
+ tool_name=self.name, status="success",
87
+ output=f"Replaced memory.md ({len(content)} chars)",
88
+ )
89
+ return ToolResult(
90
+ tool_name=self.name, status="error",
91
+ error=f"unknown mode {mode!r}; expected append/replace/read",
92
+ )
@@ -0,0 +1,82 @@
1
+ """WebFetch tool — fetch a URL and return cleaned text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import html
7
+ import re
8
+ from typing import Any
9
+
10
+ from core.schemas import ToolResult
11
+ from execution.tools.base import BaseTool
12
+
13
+
14
+ class WebFetchTool(BaseTool):
15
+ name = "WebFetch"
16
+ description = (
17
+ "Fetch a web page and return its text content with HTML stripped. "
18
+ "Use for reading documentation, articles, or any web resource."
19
+ )
20
+
21
+ @property
22
+ def input_schema(self) -> dict:
23
+ return {
24
+ "type": "object",
25
+ "properties": {
26
+ "url": {
27
+ "type": "string",
28
+ "description": "The URL to fetch (must start with http:// or https://)",
29
+ },
30
+ "max_chars": {
31
+ "type": "integer",
32
+ "description": "Max characters to return (default 20000)",
33
+ "default": 20000,
34
+ },
35
+ },
36
+ "required": ["url"],
37
+ }
38
+
39
+ async def execute(self, **kwargs: Any) -> ToolResult:
40
+ url = kwargs.get("url", "")
41
+ if not url:
42
+ return self._error("No url provided")
43
+ if not url.startswith(("http://", "https://")):
44
+ return self._error(f"Invalid URL scheme: {url}")
45
+
46
+ max_chars = int(kwargs.get("max_chars", 20000))
47
+
48
+ cmd = f'curl -sSL --max-time 30 -A "cognos-agent/1.0" "{url}"'
49
+ try:
50
+ proc = await asyncio.create_subprocess_shell(
51
+ cmd,
52
+ stdout=asyncio.subprocess.PIPE,
53
+ stderr=asyncio.subprocess.PIPE,
54
+ )
55
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=35)
56
+ except asyncio.TimeoutError:
57
+ return self._error("Fetch timed out")
58
+ except Exception as e:
59
+ return self._error(str(e))
60
+
61
+ if proc.returncode != 0:
62
+ return self._error(f"curl failed: {stderr.decode(errors='ignore')[:200]}")
63
+
64
+ raw = stdout.decode(errors="ignore")
65
+ text = _strip_html(raw)
66
+ if len(text) > max_chars:
67
+ text = text[:max_chars] + f"\n\n... (truncated at {max_chars} chars)"
68
+ return self._success(text)
69
+
70
+
71
+ _SCRIPT_STYLE_RE = re.compile(r"<(script|style)[^>]*>.*?</\1>", re.DOTALL | re.IGNORECASE)
72
+ _TAG_RE = re.compile(r"<[^>]+>")
73
+ _WS_RE = re.compile(r"\n\s*\n+")
74
+
75
+
76
+ def _strip_html(raw: str) -> str:
77
+ """Very basic HTML → text conversion (no external deps)."""
78
+ raw = _SCRIPT_STYLE_RE.sub("", raw)
79
+ raw = _TAG_RE.sub("", raw)
80
+ raw = html.unescape(raw)
81
+ raw = _WS_RE.sub("\n\n", raw)
82
+ return raw.strip()
@@ -0,0 +1,174 @@
1
+ """Worktree tools — git worktree isolation for agent work.
2
+
3
+ `git worktree` creates a separate working copy backed by the same
4
+ `.git/` repo. Useful when you want the agent to try a risky change
5
+ without touching the live working tree, or to work on multiple
6
+ branches in parallel.
7
+
8
+ Two tools (matching Claude Code's surface):
9
+
10
+ - **`EnterWorktree`** — create a fresh worktree off HEAD (or a given
11
+ base branch) and remember it as the "active" worktree. Returns
12
+ its path + branch name. Subsequent file operations should be
13
+ targeted at that path explicitly (these tools are advisory; they
14
+ don't reroute Edit/Write to the worktree automatically).
15
+ - **`ExitWorktree`** — clean up the active worktree. By default
16
+ keeps it if there are uncommitted changes (so work isn't lost);
17
+ pass `force=true` to delete regardless.
18
+
19
+ Process-local: the active worktree handle lives in this module. Not
20
+ persisted; restart wipes it.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from core.schemas import ToolResult
30
+ from core.worktree import Worktree, create_worktree
31
+ from execution.tools.base import BaseTool
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ # Process-wide active worktree. Multiple tools share this so the
37
+ # `ExitWorktree` call doesn't need an id.
38
+ _ACTIVE: Worktree | None = None
39
+
40
+
41
+ class EnterWorktreeTool(BaseTool):
42
+ mutates = True
43
+ name = "EnterWorktree"
44
+ description = (
45
+ "Create a temporary git worktree (separate working copy backed "
46
+ "by the same `.git/` repo) so changes can be tried in "
47
+ "isolation. Optional `base_branch` (default HEAD). Returns the "
48
+ "worktree path and a fresh branch name. Use `ExitWorktree` "
49
+ "afterwards to clean up. Note: file-modifying tools (Edit, "
50
+ "Write, Bash) don't auto-route to the worktree — pass the "
51
+ "returned path explicitly when running them."
52
+ )
53
+
54
+ @property
55
+ def input_schema(self) -> dict[str, Any]:
56
+ return {
57
+ "type": "object",
58
+ "properties": {
59
+ "base_branch": {
60
+ "type": "string",
61
+ "description": "Branch or commit to base the worktree on. Default 'HEAD'.",
62
+ "default": "HEAD",
63
+ },
64
+ "parent_repo": {
65
+ "type": "string",
66
+ "description": "Path to the parent repo. Default = current working dir.",
67
+ },
68
+ },
69
+ }
70
+
71
+ async def execute(self, **kwargs: Any) -> ToolResult:
72
+ global _ACTIVE
73
+ if _ACTIVE is not None:
74
+ return ToolResult(
75
+ tool_name=self.name, status="error",
76
+ error=(
77
+ f"a worktree is already active at {_ACTIVE.path}. "
78
+ "Call ExitWorktree first."
79
+ ),
80
+ )
81
+ base = (kwargs.get("base_branch") or "HEAD").strip()
82
+ parent_str = (kwargs.get("parent_repo") or "").strip()
83
+ parent = Path(parent_str).expanduser().resolve() if parent_str else None
84
+ wt = await create_worktree(parent_repo=parent, base_branch=base)
85
+ if wt is None:
86
+ return ToolResult(
87
+ tool_name=self.name, status="error",
88
+ error=(
89
+ "git worktree creation failed. Check that the cwd "
90
+ "is a git repo, git is installed, and the working "
91
+ "tree isn't blocked by uncommitted submodule state."
92
+ ),
93
+ )
94
+ _ACTIVE = wt
95
+ return ToolResult(
96
+ tool_name=self.name, status="success",
97
+ output=(
98
+ f"Worktree created.\n"
99
+ f" path: {wt.path}\n"
100
+ f" branch: {wt.branch}\n"
101
+ f" parent: {wt.parent_repo}\n"
102
+ "Use this path explicitly when running file-modifying "
103
+ "tools. Call ExitWorktree to clean up."
104
+ ),
105
+ metadata={
106
+ "path": str(wt.path),
107
+ "branch": wt.branch,
108
+ "parent": str(wt.parent_repo),
109
+ },
110
+ )
111
+
112
+
113
+ class ExitWorktreeTool(BaseTool):
114
+ mutates = True
115
+ name = "ExitWorktree"
116
+ description = (
117
+ "Clean up the active worktree created by EnterWorktree. By "
118
+ "default it's KEPT if there are uncommitted changes (work "
119
+ "isn't silently lost). Pass `force: true` to delete anyway. "
120
+ "Returns whether it was removed or kept, plus the path so you "
121
+ "can inspect it manually."
122
+ )
123
+
124
+ @property
125
+ def input_schema(self) -> dict[str, Any]:
126
+ return {
127
+ "type": "object",
128
+ "properties": {
129
+ "force": {
130
+ "type": "boolean",
131
+ "description": (
132
+ "If true, delete even if there are uncommitted "
133
+ "changes (DESTRUCTIVE). Default false."
134
+ ),
135
+ "default": False,
136
+ },
137
+ },
138
+ }
139
+
140
+ async def execute(self, **kwargs: Any) -> ToolResult:
141
+ global _ACTIVE
142
+ if _ACTIVE is None:
143
+ return ToolResult(
144
+ tool_name=self.name, status="error",
145
+ error="no active worktree (call EnterWorktree first)",
146
+ )
147
+ force = bool(kwargs.get("force", False))
148
+ path = _ACTIVE.path
149
+ had_changes = await _ACTIVE.has_changes()
150
+ kept = had_changes and not force
151
+ try:
152
+ await _ACTIVE.cleanup(force=force)
153
+ except Exception as e:
154
+ return ToolResult(
155
+ tool_name=self.name, status="error",
156
+ error=f"cleanup failed: {e}",
157
+ )
158
+ _ACTIVE = None
159
+ if kept:
160
+ return ToolResult(
161
+ tool_name=self.name, status="success",
162
+ output=(
163
+ f"Worktree kept (uncommitted changes present).\n"
164
+ f" path: {path}\n"
165
+ "Pass force=true to delete anyway, or commit/push "
166
+ "the changes before exiting."
167
+ ),
168
+ metadata={"path": str(path), "kept": True},
169
+ )
170
+ return ToolResult(
171
+ tool_name=self.name, status="success",
172
+ output=f"Worktree removed: {path}",
173
+ metadata={"path": str(path), "kept": False},
174
+ )
llm/__init__.py ADDED
File without changes
llm/fallback.py ADDED
@@ -0,0 +1,116 @@
1
+ """FallbackLLMProvider — try a chain of providers in order on failure.
2
+
3
+ Wraps a primary provider plus an ordered list of fallbacks. On a
4
+ *retryable* failure (network blip, timeout, model overloaded, 5xx) it
5
+ moves to the next provider. On a deterministic failure (bad request,
6
+ model not found) it raises immediately — falling back wouldn't help.
7
+
8
+ Maintains the same surface as `LLMProvider` so anywhere that takes one
9
+ will also take this. The router (`DualLLMProvider`) is orthogonal and
10
+ can sit on top of fallbacks (each tier becomes a fallback chain).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from typing import Any, AsyncIterator
17
+
18
+ from pydantic import BaseModel
19
+
20
+ from core.schemas import StreamEvent
21
+ from llm.provider import LLMProvider, LLMResponse, _is_retryable
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class FallbackLLMProvider:
27
+ """LLMProvider with an ordered fallback chain.
28
+
29
+ The first provider's `model` is exposed via `.model` so existing
30
+ code that treats this as an LLMProvider still gets a sensible value
31
+ for logging / status lines.
32
+ """
33
+
34
+ def __init__(self, primary: LLMProvider, fallbacks: list[LLMProvider]):
35
+ if not isinstance(primary, LLMProvider):
36
+ raise TypeError("primary must be an LLMProvider")
37
+ self._chain: list[LLMProvider] = [primary, *fallbacks]
38
+ self._active_idx = 0
39
+
40
+ @property
41
+ def model(self) -> str:
42
+ return self._chain[self._active_idx].model
43
+
44
+ @property
45
+ def temperature(self) -> float:
46
+ return self._chain[self._active_idx].temperature
47
+
48
+ @property
49
+ def max_tokens(self) -> int:
50
+ return self._chain[self._active_idx].max_tokens
51
+
52
+ def switch_model(self, model: str) -> None:
53
+ """Switch the *primary* provider's model. Fallbacks are unchanged."""
54
+ self._chain[0].switch_model(model)
55
+
56
+ # ------------------------------------------------------------------
57
+
58
+ async def chat(self, *args: Any, **kwargs: Any) -> LLMResponse:
59
+ return await self._call("chat", *args, **kwargs)
60
+
61
+ async def complete(self, *args: Any, **kwargs: Any) -> LLMResponse:
62
+ return await self._call("complete", *args, **kwargs)
63
+
64
+ async def structured_output(self, *args: Any, **kwargs: Any) -> Any:
65
+ return await self._call("structured_output", *args, **kwargs)
66
+
67
+ async def stream(
68
+ self, *args: Any, **kwargs: Any,
69
+ ) -> AsyncIterator[StreamEvent]:
70
+ last_err: BaseException | None = None
71
+ for i, provider in enumerate(self._chain):
72
+ try:
73
+ async for evt in provider.stream(*args, **kwargs):
74
+ yield evt
75
+ self._active_idx = i
76
+ return
77
+ except Exception as e:
78
+ last_err = e
79
+ if not _is_retryable(e) or i == len(self._chain) - 1:
80
+ raise
81
+ logger.warning(
82
+ f"Stream via {provider.model!r} failed ({e}); "
83
+ f"falling back to {self._chain[i + 1].model!r}"
84
+ )
85
+ if last_err:
86
+ raise last_err
87
+
88
+ async def _call(self, method: str, *args: Any, **kwargs: Any) -> Any:
89
+ last_err: BaseException | None = None
90
+ for i, provider in enumerate(self._chain):
91
+ try:
92
+ fn = getattr(provider, method)
93
+ result = await fn(*args, **kwargs)
94
+ self._active_idx = i
95
+ return result
96
+ except Exception as e:
97
+ last_err = e
98
+ if not _is_retryable(e) or i == len(self._chain) - 1:
99
+ raise
100
+ logger.warning(
101
+ f"{method} via {provider.model!r} failed ({e}); "
102
+ f"falling back to {self._chain[i + 1].model!r}"
103
+ )
104
+ if last_err:
105
+ raise last_err
106
+
107
+
108
+ def build_fallback(
109
+ primary: LLMProvider,
110
+ fallback_models: list[str],
111
+ ) -> LLMProvider | FallbackLLMProvider:
112
+ """Construct a FallbackLLMProvider, or return primary if no fallbacks."""
113
+ if not fallback_models:
114
+ return primary
115
+ fallbacks = [LLMProvider(model=m) for m in fallback_models]
116
+ return FallbackLLMProvider(primary=primary, fallbacks=fallbacks)