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.
- api/__init__.py +5 -0
- api/anthropic_compat.py +1518 -0
- api/artifact_viewer.py +366 -0
- api/caudate_middleware.py +618 -0
- api/forge_bootstrapper_routes.py +377 -0
- api/forge_routes.py +630 -0
- api/forge_system_routes.py +294 -0
- api/openai_compat.py +1993 -0
- api/server.py +667 -0
- api/storyboard_page.py +677 -0
- caudate_cli-0.1.0.dist-info/METADATA +354 -0
- caudate_cli-0.1.0.dist-info/RECORD +153 -0
- caudate_cli-0.1.0.dist-info/WHEEL +5 -0
- caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
- caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
- cognos_mcp/__init__.py +4 -0
- cognos_mcp/bridge.py +41 -0
- cognos_mcp/client.py +70 -0
- cognos_mcp/config.py +49 -0
- cognos_mcp/server.py +66 -0
- config.py +82 -0
- core/__init__.py +0 -0
- core/agent.py +468 -0
- core/agentic_loop.py +731 -0
- core/anthropic_auth.py +91 -0
- core/background.py +113 -0
- core/banner.py +134 -0
- core/bootstrap.py +292 -0
- core/citations.py +131 -0
- core/compaction.py +109 -0
- core/constitution.py +198 -0
- core/diff_viewer.py +87 -0
- core/export.py +85 -0
- core/file_refs.py +119 -0
- core/files.py +199 -0
- core/hooks.py +209 -0
- core/image.py +599 -0
- core/input.py +91 -0
- core/loop.py +238 -0
- core/memory_md.py +147 -0
- core/notifications.py +99 -0
- core/ownership.py +181 -0
- core/paste.py +81 -0
- core/permissions.py +210 -0
- core/plan_mode.py +215 -0
- core/sandbox_prompt.py +185 -0
- core/scheduler.py +195 -0
- core/schemas.py +202 -0
- core/session.py +90 -0
- core/settings.py +132 -0
- core/skills.py +398 -0
- core/slash_commands.py +977 -0
- core/statusline.py +61 -0
- core/subagent.py +300 -0
- core/thinking.py +50 -0
- core/updater.py +122 -0
- core/usage.py +109 -0
- core/worktree.py +93 -0
- execution/__init__.py +0 -0
- execution/executor.py +329 -0
- execution/plugins.py +108 -0
- execution/tools/__init__.py +0 -0
- execution/tools/agent_tool.py +107 -0
- execution/tools/agentic_tool.py +297 -0
- execution/tools/artifact_tool.py +191 -0
- execution/tools/ask_user_question_tool.py +137 -0
- execution/tools/base.py +81 -0
- execution/tools/calculator_tool.py +137 -0
- execution/tools/cognos_card_tool.py +124 -0
- execution/tools/cron_tool.py +215 -0
- execution/tools/datetime_tool.py +215 -0
- execution/tools/describe_image_tool.py +161 -0
- execution/tools/draw_tool.py +164 -0
- execution/tools/edit_image_tool.py +262 -0
- execution/tools/edit_tool.py +245 -0
- execution/tools/file_tool.py +90 -0
- execution/tools/find_anywhere_tool.py +255 -0
- execution/tools/forge_feature_tools.py +377 -0
- execution/tools/glob_tool.py +59 -0
- execution/tools/grep_tool.py +89 -0
- execution/tools/http_request_tool.py +224 -0
- execution/tools/load_skill_tool.py +104 -0
- execution/tools/longcat_avatar_tool.py +384 -0
- execution/tools/mcp_tool.py +100 -0
- execution/tools/notebook_tool.py +279 -0
- execution/tools/openapi_tool.py +440 -0
- execution/tools/plan_mode_tool.py +95 -0
- execution/tools/push_notification_tool.py +157 -0
- execution/tools/python_tool.py +61 -0
- execution/tools/respond_tool.py +40 -0
- execution/tools/sandbox_tool.py +378 -0
- execution/tools/search_tool.py +153 -0
- execution/tools/semantic_search_tool.py +106 -0
- execution/tools/shell_tool.py +283 -0
- execution/tools/speak_tool.py +134 -0
- execution/tools/storyboard_tool.py +727 -0
- execution/tools/system_info_tool.py +212 -0
- execution/tools/task_tool.py +323 -0
- execution/tools/think_tool.py +49 -0
- execution/tools/transcribe_audio_tool.py +86 -0
- execution/tools/update_memory_tool.py +92 -0
- execution/tools/web_fetch_tool.py +82 -0
- execution/tools/worktree_tool.py +174 -0
- llm/__init__.py +0 -0
- llm/fallback.py +116 -0
- llm/models.py +320 -0
- llm/provider.py +1356 -0
- llm/router.py +373 -0
- main.py +1889 -0
- memory/__init__.py +0 -0
- memory/episodic.py +99 -0
- memory/procedural.py +145 -0
- memory/semantic.py +71 -0
- memory/working.py +64 -0
- nn/__init__.py +43 -0
- nn/auto_evolve.py +245 -0
- nn/caudate.py +136 -0
- nn/config.py +141 -0
- nn/consolidator.py +81 -0
- nn/data.py +1635 -0
- nn/encoder.py +258 -0
- nn/forge_advisor.py +303 -0
- nn/format.py +235 -0
- nn/heads.py +432 -0
- nn/observer.py +994 -0
- nn/policy.py +214 -0
- nn/runtime.py +343 -0
- nn/scorer.py +175 -0
- nn/trainer.py +515 -0
- nn/vision.py +352 -0
- personality/__init__.py +23 -0
- personality/engine.py +129 -0
- personality/identity.py +144 -0
- personality/inner_voice.py +100 -0
- personality/mood.py +205 -0
- planning/__init__.py +0 -0
- planning/dev_server.py +221 -0
- planning/forge_models.py +718 -0
- planning/orchestrator.py +1363 -0
- planning/planner.py +451 -0
- planning/task_graph.py +61 -0
- reflection/__init__.py +0 -0
- reflection/meta_learner.py +156 -0
- reflection/reflector.py +127 -0
- ui/__init__.py +5 -0
- ui/display.py +88 -0
- voice/__init__.py +0 -0
- voice/conversation.py +125 -0
- voice/listener.py +111 -0
- voice/speaker.py +59 -0
- voice/stt.py +126 -0
- 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)
|