favlo 0.3.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.
favlo/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Process entry for Favlo. Model math lives in the pipeline file."""
2
+
3
+ __version__ = "0.3.0"
favlo/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from favlo.cli import main
2
+
3
+ raise SystemExit(main())
favlo/chat_client.py ADDED
@@ -0,0 +1,117 @@
1
+ """Agent Framework client for the listener's chat completions.
2
+
3
+ Every chat caller uses this client. Reasoning from a completion is sent back
4
+ on ``reasoning_content``. A reply whose text already contains the tool-call
5
+ markup is sent as that text.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+
13
+ def reasoning_text(message: Any) -> str:
14
+ """``reasoning_content`` on a completion message or a stream delta."""
15
+ if message is None:
16
+ return ""
17
+ value = getattr(message, "reasoning_content", None)
18
+ if isinstance(value, str) and value:
19
+ return value
20
+ extra = getattr(message, "model_extra", None)
21
+ if isinstance(extra, dict):
22
+ raw = extra.get("reasoning_content")
23
+ if isinstance(raw, str) and raw:
24
+ return raw
25
+ return ""
26
+
27
+
28
+ def read_reasoning(message: Any, contents: list[Any]) -> list[Any]:
29
+ """Keep ``reasoning_content`` as reasoning, beside the reply text."""
30
+ text = reasoning_text(message)
31
+ if not text:
32
+ return contents
33
+ from agent_framework import Content
34
+
35
+ return [Content.from_text_reasoning(text=text), *contents]
36
+
37
+
38
+ def prepare_messages(message: object, built: list[dict[str, object]]) -> list[dict[str, object]]:
39
+ """Put reasoning on ``reasoning_content`` for the chat template.
40
+
41
+ The listener reads that field when it renders the next prompt. A reply
42
+ whose text already contains the tool-call markup is sent as that text.
43
+ """
44
+ if getattr(message, "role", None) != "assistant":
45
+ return built
46
+ reasoning = "".join(
47
+ part.text or ""
48
+ for part in getattr(message, "contents", ())
49
+ if getattr(part, "type", None) == "text_reasoning"
50
+ and not getattr(part, "protected_data", None)
51
+ )
52
+ if not reasoning:
53
+ return built
54
+ kept: list[dict[str, object]] = []
55
+ placed = False
56
+ for item in built:
57
+ if item.get("role") != "assistant":
58
+ kept.append(item)
59
+ continue
60
+ text = _content_text(item.get("content"))
61
+ if text == reasoning and "tool_calls" not in item:
62
+ continue
63
+ copied = dict(item)
64
+ if not placed:
65
+ copied["reasoning_content"] = reasoning
66
+ placed = True
67
+ if "<tool_call>" in text:
68
+ copied.pop("tool_calls", None)
69
+ kept.append(copied)
70
+ if not placed:
71
+ kept.append({"role": "assistant", "reasoning_content": reasoning, "content": ""})
72
+ return kept
73
+
74
+
75
+ def history_message(role: str, text: str, reasoning: str = "") -> Any:
76
+ """One stored turn. Reasoning stays a reasoning part, not reply text."""
77
+ from agent_framework import Content, Message
78
+
79
+ parts: list[Any] = []
80
+ if reasoning:
81
+ parts.append(Content.from_text_reasoning(text=reasoning))
82
+ parts.append(text)
83
+ return Message(role, parts)
84
+
85
+
86
+ def open_chat_client(
87
+ model: str,
88
+ base_url: str,
89
+ async_client: Any | None = None,
90
+ ) -> Any:
91
+ """Chat client that sends reasoning back on the next request."""
92
+ from agent_framework.openai import OpenAIChatCompletionClient
93
+
94
+ kwargs: dict[str, Any] = {
95
+ "model": model,
96
+ "api_key": "favlo",
97
+ "base_url": base_url,
98
+ "response_parser": read_reasoning,
99
+ "message_preparer": prepare_messages,
100
+ }
101
+ if async_client is not None:
102
+ kwargs["async_client"] = async_client
103
+ return OpenAIChatCompletionClient(**kwargs)
104
+
105
+
106
+ def _content_text(content: object) -> str:
107
+ if isinstance(content, str):
108
+ return content
109
+ if not isinstance(content, list):
110
+ return ""
111
+ parts: list[str] = []
112
+ for item in content:
113
+ if isinstance(item, str):
114
+ parts.append(item)
115
+ elif isinstance(item, dict) and item.get("type") == "text":
116
+ parts.append(str(item.get("text") or ""))
117
+ return "".join(parts)
favlo/cli.py ADDED
@@ -0,0 +1,225 @@
1
+ """Select one pipeline file and run it.
2
+
3
+ A model flag is the filename without ``.py``. ``--<pipeline>`` loads
4
+ ``<pipeline>.py`` by path, and that file loads the model's package.
5
+ The hyphenated file is not an import.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import importlib.util
12
+ import os
13
+ import sys
14
+ import threading
15
+ from collections.abc import Callable, Sequence
16
+ from pathlib import Path
17
+ from types import ModuleType
18
+
19
+ RUNTIMES = ("metal41", "cuda")
20
+ _IMPORTS = threading.Lock()
21
+
22
+
23
+ def repo_root() -> Path:
24
+ override = os.environ.get("FAVLO_ROOT")
25
+ if override:
26
+ return Path(override)
27
+ for parent in Path(__file__).resolve().parents:
28
+ marker = parent / "src" / "favlo" / "cli.py"
29
+ if (parent / "pyproject.toml").is_file() and marker.is_file():
30
+ return parent
31
+ message = "favlo package is not inside a checkout"
32
+ raise FileNotFoundError(message)
33
+
34
+
35
+ def pipeline_tree(runtime: str, *, custom: bool = False, root: Path | None = None) -> Path:
36
+ """The directory that holds one runtime's entry files."""
37
+ if runtime not in RUNTIMES:
38
+ message = f"unknown runtime: {runtime}"
39
+ raise ValueError(message)
40
+ base = "custom-pipelines" if custom else "pipelines"
41
+ return (root or repo_root()) / base / runtime
42
+
43
+
44
+ def pipeline_names(root: Path | None = None) -> tuple[str, ...]:
45
+ """Entry stems under the four pipeline trees.
46
+
47
+ A new runtime is a new file. Discovering it does not edit this module.
48
+ Two files with the same ``PIPELINE`` stem are an error.
49
+ """
50
+ return tuple(sorted(pipeline_entries(root)))
51
+
52
+
53
+ def pipeline_entries(root: Path | None = None) -> dict[str, Path]:
54
+ """Stem to entry file. A duplicate stem raises ``ValueError``."""
55
+ found: dict[str, Path] = {}
56
+ for custom in (False, True):
57
+ for runtime in RUNTIMES:
58
+ directory = pipeline_tree(runtime, custom=custom, root=root)
59
+ if not directory.is_dir():
60
+ continue
61
+ for path in sorted(directory.glob("*.py")):
62
+ stem = _pipeline_stem(path)
63
+ if stem is None:
64
+ continue
65
+ previous = found.get(stem)
66
+ if previous is not None:
67
+ message = f"duplicate pipeline {stem}: {previous} and {path}"
68
+ raise ValueError(message)
69
+ found[stem] = path
70
+ return found
71
+
72
+
73
+ def _pipeline_stem(path: Path) -> str | None:
74
+ try:
75
+ tree = ast.parse(path.read_text(encoding="utf-8"))
76
+ except (OSError, SyntaxError):
77
+ return None
78
+ for node in tree.body:
79
+ if not isinstance(node, ast.Assign) or len(node.targets) != 1:
80
+ continue
81
+ target = node.targets[0]
82
+ if not isinstance(target, ast.Name) or target.id != "PIPELINE":
83
+ continue
84
+ if isinstance(node.value, ast.Constant) and node.value.value == path.stem:
85
+ return path.stem
86
+ return None
87
+
88
+
89
+ def _help(names: Sequence[str]) -> str:
90
+ lines = ["usage: favlo serve [--non-interactive]"]
91
+ lines.append(" favlo web")
92
+ lines.extend(f" favlo --{name} [args]" for name in names)
93
+ lines.append("")
94
+ lines.append(" serve listen on this terminal")
95
+ lines.append(" serve --non-interactive")
96
+ lines.append(" start the server daemon and exit")
97
+ lines.append(" web start the API and the Expo app")
98
+ width = max((len(name) for name in names), default=0)
99
+ for name in names:
100
+ lines.append(f" --{name.ljust(width)} {name}")
101
+ return "\n".join(lines) + "\n"
102
+
103
+
104
+ def load_pipeline(
105
+ name: str,
106
+ root: Path | None = None,
107
+ *,
108
+ runtime: str | None = None,
109
+ custom: bool = False,
110
+ ) -> ModuleType:
111
+ if runtime is None:
112
+ path = pipeline_entries(root).get(name)
113
+ if path is None:
114
+ message = f"pipeline file is missing: {name}"
115
+ raise FileNotFoundError(message)
116
+ else:
117
+ path = pipeline_tree(runtime, custom=custom, root=root) / f"{name}.py"
118
+ if not path.is_file():
119
+ message = f"pipeline file is missing: {path}"
120
+ raise FileNotFoundError(message)
121
+ with _IMPORTS:
122
+ spec = importlib.util.spec_from_file_location(f"{name.replace('-', '_')}_pipeline", path)
123
+ if spec is None or spec.loader is None:
124
+ message = f"cannot load pipeline file: {path}"
125
+ raise ImportError(message)
126
+ module = importlib.util.module_from_spec(spec)
127
+ spec.loader.exec_module(module)
128
+ return module
129
+
130
+
131
+ def pipeline_roots(root: Path | None = None) -> tuple[Path, ...]:
132
+ """Pipeline trees that exist in this checkout."""
133
+ found: list[Path] = []
134
+ for custom in (False, True):
135
+ for runtime in RUNTIMES:
136
+ directory = pipeline_tree(runtime, custom=custom, root=root)
137
+ if directory.is_dir():
138
+ found.append(directory.resolve())
139
+ return tuple(found)
140
+
141
+
142
+ def drop_pipeline_modules(root: Path | None = None) -> int:
143
+ """Drop imported pipeline packages so the next load reads the files again.
144
+
145
+ The lock is the same one ``load_pipeline`` holds. A module under ``src/favlo`` stays.
146
+ """
147
+ with _IMPORTS:
148
+ roots = pipeline_roots(root)
149
+ names = [
150
+ name
151
+ for name, module in list(sys.modules.items())
152
+ if module is not None and _module_under(module, roots)
153
+ ]
154
+ for name in names:
155
+ sys.modules.pop(name, None)
156
+ return len(names)
157
+
158
+
159
+ def _module_under(module: ModuleType, roots: tuple[Path, ...]) -> bool:
160
+ file = getattr(module, "__file__", None)
161
+ if isinstance(file, str) and file and _path_under(Path(file), roots):
162
+ return True
163
+ paths = getattr(module, "__path__", None)
164
+ if paths is None:
165
+ return False
166
+ return any(_path_under(Path(str(item)), roots) for item in paths)
167
+
168
+
169
+ def _path_under(path: Path, roots: tuple[Path, ...]) -> bool:
170
+ try:
171
+ resolved = path.resolve()
172
+ except OSError:
173
+ return False
174
+ return any(resolved.is_relative_to(root) for root in roots)
175
+
176
+
177
+ def pipeline_main(module: ModuleType) -> Callable[[Sequence[str]], int]:
178
+ entry = getattr(module, "main", None)
179
+ if not callable(entry):
180
+ message = f"{module.__file__} does not define main()"
181
+ raise TypeError(message)
182
+ return entry
183
+
184
+
185
+ def main(argv: Sequence[str] | None = None) -> int:
186
+ from favlo.logs import install_rolling_stdio
187
+
188
+ install_rolling_stdio()
189
+ args = list(sys.argv[1:] if argv is None else argv)
190
+ try:
191
+ names = pipeline_names()
192
+ except ValueError as exc:
193
+ sys.stderr.write(f"{exc}\n")
194
+ return 2
195
+ if not args:
196
+ sys.stderr.write(_help(names))
197
+ return 2
198
+ if args[0] in {"-h", "--help"}:
199
+ sys.stdout.write(_help(names))
200
+ return 0
201
+ if args[0] == "serve":
202
+ from favlo.listener import serve_main
203
+
204
+ return int(serve_main(args[1:]))
205
+ if args[0] == "web":
206
+ from favlo.web_host import main as web_main
207
+
208
+ return int(web_main(args[1:]))
209
+ flag = args[0]
210
+ if not flag.startswith("--") or flag == "--":
211
+ sys.stderr.write(_help(names))
212
+ sys.stderr.write(f"\nunknown argument: {flag}\n")
213
+ return 2
214
+ name = flag[2:]
215
+ if name not in names:
216
+ known = ", ".join(f"--{item}" for item in names)
217
+ sys.stderr.write(f"unknown model flag: {flag}\nknown: {known}\n")
218
+ return 2
219
+ try:
220
+ module = load_pipeline(name)
221
+ entry = pipeline_main(module)
222
+ except (FileNotFoundError, ImportError, TypeError) as exc:
223
+ sys.stderr.write(f"{exc}\n")
224
+ return 2
225
+ return int(entry(args[1:]))
favlo/config_tools.py ADDED
@@ -0,0 +1,234 @@
1
+ """The configuration tool group. Favlo web enables it on the Favlo agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import urllib.error
6
+ import urllib.request
7
+ from pathlib import Path
8
+
9
+ GROUP_ID = "favlo-config"
10
+ GROUP_NAME = "Configuration tools"
11
+
12
+ INSTRUCTIONS = (
13
+ "When someone asks how to connect a client, which address or model id to use, "
14
+ "how to download or quantize a supported model, or how to register one, "
15
+ "call config_read_guide before you answer. "
16
+ "Use clients for the server address, the model field, and reload. "
17
+ "Ctrl+C stops a terminal favlo serve. "
18
+ "Use models for which checkpoints can be downloaded and how registration works. "
19
+ "When someone wants to set up Qwen3.6-35B-A3B, call config_read_setup with qwen36-35b-a3b "
20
+ "before you answer. When someone wants to set up Qwen3.8-27B, call config_read_setup with "
21
+ "qwen38-27b before you answer. When someone wants to set up Z-Image-Turbo, call "
22
+ "config_read_setup with z-image-turbo before you answer. When someone wants to set up "
23
+ "Wan 2.2 I2V, call config_read_setup with wan22-i2v before you answer. When someone wants to "
24
+ "set up ACE-Step, call config_read_setup with ace-step-15-turbo before you answer. For any other "
25
+ "pipeline "
26
+ "they want to set up, download, quantize, or delete, call config_read_setup with that pipeline id. "
27
+ "Answer from that guide. "
28
+ "When they want you to run a step from it, call config_run_setup with that command. "
29
+ "config_run_setup only runs inside this install. Do not run a command the guide does not contain. "
30
+ "When someone asks which models are registered, or which model id to send, "
31
+ "call config_list_models before you answer. Report only the lines it returns. "
32
+ "When someone wants MTP on or off for a model, or a different draft depth, "
33
+ "call config_set_model_mtp. A row that omits MTP stays off. "
34
+ "When someone asks where your name came from, or who Amanda and Celestia are, "
35
+ "call config_read_guide with history and tell that story. Do not add events it does not contain. "
36
+ "Answer from that guide. If the guide does not say it, say you do not know."
37
+ )
38
+
39
+
40
+ def tools(base: str, home: Path | None) -> list[object]:
41
+ """The configuration tools, in the order the group offers them."""
42
+ return [
43
+ _guide_tool(),
44
+ _models_tool(),
45
+ _mtp_tool(),
46
+ _suggestions_tool(home),
47
+ _setup_guide_tool(),
48
+ _setup_run_tool(base),
49
+ ]
50
+
51
+
52
+ def registered_models(root: Path | None = None) -> str:
53
+ """The registry listing. An empty registry says so."""
54
+ from favlo.registry import format_listing
55
+
56
+ listing = format_listing(root).strip()
57
+ if not listing:
58
+ return "no models are registered"
59
+ return listing
60
+
61
+
62
+ def _guide_tool() -> object:
63
+ from typing import Annotated
64
+
65
+ from agent_framework import tool
66
+ from pydantic import Field
67
+
68
+ from favlo.guides import read_guide as load_guide
69
+
70
+ @tool(approval_mode="never_require", name="config_read_guide")
71
+ def read_guide(
72
+ name: Annotated[str, Field(description="clients, models, or history.")],
73
+ ) -> str:
74
+ """Read one guide.
75
+
76
+ clients points a program at this server and covers ``reload``.
77
+ Ctrl+C stops a terminal ``favlo serve``. models covers
78
+ download, quantize, and registration. history is where the name Favlo came from.
79
+ """
80
+ return load_guide(name)
81
+
82
+ return read_guide
83
+
84
+
85
+ def _suggestions_tool(home: Path | None) -> object:
86
+ from typing import Annotated
87
+
88
+ from agent_framework import tool
89
+ from pydantic import Field
90
+
91
+ from favlo.machine import set_suggestions
92
+
93
+ @tool(approval_mode="never_require", name="config_download_suggestions")
94
+ def download_suggestions(
95
+ enabled: Annotated[
96
+ bool,
97
+ Field(description="False stores download reminders as off. True stores them as on."),
98
+ ],
99
+ ) -> str:
100
+ """Store whether download reminders are enabled.
101
+
102
+ The terminal does not start a reminder on its own. This records the preference.
103
+ """
104
+ return set_suggestions(enabled, home)
105
+
106
+ return download_suggestions
107
+
108
+
109
+ def _setup_guide_tool() -> object:
110
+ from typing import Annotated
111
+
112
+ from agent_framework import tool
113
+ from pydantic import Field
114
+
115
+ from favlo.guides import read_setup as load_setup
116
+
117
+ @tool(approval_mode="never_require", name="config_read_setup")
118
+ def read_setup(
119
+ pipeline: Annotated[
120
+ str,
121
+ Field(description="Pipeline id, such as wan22-i2v."),
122
+ ],
123
+ ) -> str:
124
+ """Read one pipeline's setup guide.
125
+
126
+ Call this when someone wants to set up, download, quantize, or delete a model.
127
+ qwen36-35b-a3b is Qwen3.6-35B-A3B. qwen38-27b is Qwen3.8-27B.
128
+ z-image-turbo is Z-Image-Turbo. wan22-i2v is Wan 2.2 I2V.
129
+ ace-step-15-turbo is ACE-Step.
130
+ The result is that pipeline's setup page.
131
+ A pipeline with no setup page names the ones that have one.
132
+ """
133
+ return load_setup(pipeline)
134
+
135
+ return read_setup
136
+
137
+
138
+ def _setup_run_tool(base: str) -> object:
139
+ from typing import Annotated
140
+
141
+ from agent_framework import tool
142
+ from pydantic import Field
143
+
144
+ from favlo.setup_run import accepted_argv, frees_model, run_setup_command
145
+
146
+ @tool(approval_mode="never_require", name="config_run_setup")
147
+ def run_setup(
148
+ command: Annotated[
149
+ str,
150
+ Field(description="One command copied from the setup guide."),
151
+ ],
152
+ ) -> str:
153
+ """Run one setup-guide command in this install.
154
+
155
+ The working directory is the folder where Favlo is installed. A path cannot
156
+ leave that folder. This is not a shell: pipes, cd, and other programs are refused.
157
+ """
158
+ try:
159
+ argv = accepted_argv(command, _install())
160
+ except ValueError as exc:
161
+ return str(exc)
162
+ if frees_model(argv):
163
+ closed = _close_resident(base)
164
+ if closed is not None:
165
+ return closed
166
+ try:
167
+ return run_setup_command(command)
168
+ except ValueError as exc:
169
+ return str(exc)
170
+
171
+ return run_setup
172
+
173
+
174
+ def _install() -> Path:
175
+ from favlo.cli import repo_root
176
+
177
+ return repo_root()
178
+
179
+
180
+ def _close_resident(base: str) -> str | None:
181
+ request = urllib.request.Request(base + "/pipelines/close", data=b"", method="POST")
182
+ try:
183
+ with urllib.request.urlopen(request, timeout=30) as response:
184
+ response.read()
185
+ except (OSError, urllib.error.URLError) as exc:
186
+ return f"could not close the open model: {exc}"
187
+ return None
188
+
189
+
190
+ def _models_tool() -> object:
191
+ from agent_framework import tool
192
+
193
+ @tool(approval_mode="never_require", name="config_list_models")
194
+ def list_models() -> str:
195
+ """List the registered models.
196
+
197
+ Each line is one record: folder, pipeline, alias, key/value mode,
198
+ and whether MTP is on with its draft depth.
199
+ An unregistered complete record is named too. This is the live registry,
200
+ not the download guide.
201
+ """
202
+ return registered_models()
203
+
204
+ return list_models
205
+
206
+
207
+ def _mtp_tool() -> object:
208
+ from typing import Annotated
209
+
210
+ from agent_framework import tool
211
+ from pydantic import Field
212
+
213
+ from favlo.registry import set_model_mtp as store_model_mtp
214
+
215
+ @tool(approval_mode="never_require", name="config_set_model_mtp")
216
+ def set_model_mtp(
217
+ model: Annotated[str, Field(description="Folder, alias, or pipeline id.")],
218
+ enabled: Annotated[bool, Field(description="True drafts. False stays on one token.")],
219
+ depth: Annotated[
220
+ int | None,
221
+ Field(description="Draft length. Omit it to keep the depth already stored."),
222
+ ] = None,
223
+ ) -> str:
224
+ """Store MTP and its draft depth on one registry row.
225
+
226
+ The next completion for that model uses the row when the request omits
227
+ ``mtp`` or ``mtp_depth``. A request field still wins.
228
+ """
229
+ try:
230
+ return store_model_mtp(model, enabled=enabled, depth=depth)
231
+ except (LookupError, ValueError) as exc:
232
+ return str(exc)
233
+
234
+ return set_model_mtp