fusefable 0.3.1__tar.gz → 0.4.0__tar.gz
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.
- {fusefable-0.3.1 → fusefable-0.4.0}/PKG-INFO +15 -1
- {fusefable-0.3.1 → fusefable-0.4.0}/README.md +12 -0
- fusefable-0.4.0/fusefable/__init__.py +1 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable/cli.py +8 -0
- fusefable-0.4.0/fusefable/desktop.py +82 -0
- fusefable-0.4.0/fusefable/web.py +115 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable.egg-info/PKG-INFO +15 -1
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable.egg-info/SOURCES.txt +3 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable.egg-info/requires.txt +3 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/pyproject.toml +2 -1
- fusefable-0.4.0/tests/test_desktop.py +49 -0
- fusefable-0.3.1/fusefable/__init__.py +0 -1
- {fusefable-0.3.1 → fusefable-0.4.0}/LICENSE +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable/cache.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable/client.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable/compressor.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable/config.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable/core.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable/cost.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable/ensemble.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable/fanout.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable/fusion.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable/judge.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable/mcp_server.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable/models.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable/providers/__init__.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable/providers/anthropic.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable/providers/base.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable/providers/factory.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable/providers/google.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable/providers/openai_compat.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable/routing.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable/wizard.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable.egg-info/dependency_links.txt +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable.egg-info/entry_points.txt +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/fusefable.egg-info/top_level.txt +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/setup.cfg +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/tests/test_cache.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/tests/test_cli.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/tests/test_client.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/tests/test_compressor.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/tests/test_config.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/tests/test_core.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/tests/test_cost.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/tests/test_ensemble.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/tests/test_fanout.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/tests/test_fusion.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/tests/test_judge.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/tests/test_mcp_server.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/tests/test_models.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/tests/test_native_providers.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/tests/test_openai_compat.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/tests/test_routing.py +0 -0
- {fusefable-0.3.1 → fusefable-0.4.0}/tests/test_wizard.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fusefable
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Fuse multiple AI models and judge the best answer for coding
|
|
5
5
|
Author: proultrax9
|
|
6
6
|
License: MIT
|
|
@@ -24,6 +24,8 @@ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
|
24
24
|
Requires-Dist: respx>=0.21; extra == "dev"
|
|
25
25
|
Provides-Extra: mcp
|
|
26
26
|
Requires-Dist: mcp>=1.2; extra == "mcp"
|
|
27
|
+
Provides-Extra: app
|
|
28
|
+
Requires-Dist: pywebview>=5; extra == "app"
|
|
27
29
|
Dynamic: license-file
|
|
28
30
|
|
|
29
31
|
# Fuse Fable
|
|
@@ -45,6 +47,7 @@ and as a **subagent / pipe** (callable by other tools and scripts).
|
|
|
45
47
|
```bash
|
|
46
48
|
pip install fusefable # core
|
|
47
49
|
pip install "fusefable[mcp]" # if you want the MCP server
|
|
50
|
+
pip install "fusefable[app]" # if you want the desktop window (fusefable gui)
|
|
48
51
|
```
|
|
49
52
|
From source:
|
|
50
53
|
```bash
|
|
@@ -75,6 +78,17 @@ setx OPENROUTER_API_KEY "sk-..." # Windows (open a new terminal afterwards)
|
|
|
75
78
|
|
|
76
79
|
Config is stored at `~/.fusefable/config.yaml`.
|
|
77
80
|
|
|
81
|
+
## 0) Use as a desktop app
|
|
82
|
+
|
|
83
|
+
A native window (like Cursor/VS Code), no browser tab — chat UI with toggles for
|
|
84
|
+
compress / ensemble / cache and a model filter:
|
|
85
|
+
```bash
|
|
86
|
+
pip install "fusefable[app]"
|
|
87
|
+
fusefable gui
|
|
88
|
+
```
|
|
89
|
+
Built on PyWebView (uses the system webview), so it's lightweight and cross-platform.
|
|
90
|
+
Requires a completed `fusefable config`.
|
|
91
|
+
|
|
78
92
|
## 1) Use as a CLI
|
|
79
93
|
```bash
|
|
80
94
|
fusefable ask "Write a quicksort function in Python"
|
|
@@ -17,6 +17,7 @@ and as a **subagent / pipe** (callable by other tools and scripts).
|
|
|
17
17
|
```bash
|
|
18
18
|
pip install fusefable # core
|
|
19
19
|
pip install "fusefable[mcp]" # if you want the MCP server
|
|
20
|
+
pip install "fusefable[app]" # if you want the desktop window (fusefable gui)
|
|
20
21
|
```
|
|
21
22
|
From source:
|
|
22
23
|
```bash
|
|
@@ -47,6 +48,17 @@ setx OPENROUTER_API_KEY "sk-..." # Windows (open a new terminal afterwards)
|
|
|
47
48
|
|
|
48
49
|
Config is stored at `~/.fusefable/config.yaml`.
|
|
49
50
|
|
|
51
|
+
## 0) Use as a desktop app
|
|
52
|
+
|
|
53
|
+
A native window (like Cursor/VS Code), no browser tab — chat UI with toggles for
|
|
54
|
+
compress / ensemble / cache and a model filter:
|
|
55
|
+
```bash
|
|
56
|
+
pip install "fusefable[app]"
|
|
57
|
+
fusefable gui
|
|
58
|
+
```
|
|
59
|
+
Built on PyWebView (uses the system webview), so it's lightweight and cross-platform.
|
|
60
|
+
Requires a completed `fusefable config`.
|
|
61
|
+
|
|
50
62
|
## 1) Use as a CLI
|
|
51
63
|
```bash
|
|
52
64
|
fusefable ask "Write a quicksort function in Python"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0"
|
|
@@ -131,5 +131,13 @@ def mcp():
|
|
|
131
131
|
run_mcp()
|
|
132
132
|
|
|
133
133
|
|
|
134
|
+
@app.command()
|
|
135
|
+
def gui():
|
|
136
|
+
"""เปิดหน้าต่างโปรแกรม (desktop window) แบบ Cursor/VS Code."""
|
|
137
|
+
cfg = _load_or_die()
|
|
138
|
+
from fusefable.desktop import run_app
|
|
139
|
+
run_app(cfg)
|
|
140
|
+
|
|
141
|
+
|
|
134
142
|
if __name__ == "__main__":
|
|
135
143
|
app()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Desktop window (PyWebView) — หน้าต่างโปรแกรมแบบ Cursor/VS Code.
|
|
2
|
+
|
|
3
|
+
ห่อ web UI ด้วย webview ของระบบ; JS เรียก Python ผ่าน js_api (ไม่ต้องมี web server).
|
|
4
|
+
แยก answer_to_dict / run_query (test ได้) ออกจาก run_app (เปิดหน้าต่างจริง).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import asyncio
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from fusefable.config import Config
|
|
10
|
+
from fusefable.core import fuse
|
|
11
|
+
from fusefable.models import FinalAnswer
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def answer_to_dict(ans: FinalAnswer) -> dict:
|
|
15
|
+
"""แปลง FinalAnswer → dict ส่งให้ JS."""
|
|
16
|
+
d = {
|
|
17
|
+
"answer": ans.text,
|
|
18
|
+
"chosen_model": ans.chosen_model,
|
|
19
|
+
"reason": ans.reason,
|
|
20
|
+
"cost_usd": ans.cost_usd,
|
|
21
|
+
"cached": ans.cached,
|
|
22
|
+
"budget_warning": ans.budget_warning,
|
|
23
|
+
"candidates": [{"model": c.model, "text": c.text}
|
|
24
|
+
for c in ans.all_completions],
|
|
25
|
+
}
|
|
26
|
+
if ans.compression is not None:
|
|
27
|
+
c = ans.compression
|
|
28
|
+
d["compression"] = {
|
|
29
|
+
"original_chars": c.original_chars,
|
|
30
|
+
"final_chars": c.final_chars,
|
|
31
|
+
"saved_pct": round(c.saved_pct, 1),
|
|
32
|
+
"method": c.method,
|
|
33
|
+
}
|
|
34
|
+
return d
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _models_from_payload(payload: dict) -> Optional[list[str]]:
|
|
38
|
+
raw = payload.get("models")
|
|
39
|
+
if not raw:
|
|
40
|
+
return None
|
|
41
|
+
if isinstance(raw, str):
|
|
42
|
+
items = [m.strip() for m in raw.split(",") if m.strip()]
|
|
43
|
+
return items or None
|
|
44
|
+
return list(raw) or None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run_query(cfg: Config, payload: dict) -> dict:
|
|
48
|
+
"""รัน fuse จาก payload ของ UI. คืน dict (มี key 'error' ถ้าพัง) — ไม่โยน."""
|
|
49
|
+
try:
|
|
50
|
+
ans = asyncio.run(fuse(
|
|
51
|
+
cfg, payload["question"],
|
|
52
|
+
models=_models_from_payload(payload),
|
|
53
|
+
compress=payload.get("compress"),
|
|
54
|
+
ensemble=payload.get("ensemble"),
|
|
55
|
+
use_cache=payload.get("cache"),
|
|
56
|
+
))
|
|
57
|
+
return answer_to_dict(ans)
|
|
58
|
+
except Exception as e: # noqa: BLE001 — ส่ง error กลับ UI ไม่ให้หน้าต่างค้าง
|
|
59
|
+
return {"error": str(e)}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Api:
|
|
63
|
+
"""js_api ให้ฝั่ง JS เรียก (pywebview.api.ask(...))."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, cfg: Config):
|
|
66
|
+
self.cfg = cfg
|
|
67
|
+
|
|
68
|
+
def ask(self, payload: dict) -> dict:
|
|
69
|
+
return run_query(self.cfg, payload)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def run_app(cfg: Config) -> None:
|
|
73
|
+
"""เปิดหน้าต่าง desktop. ต้องติดตั้ง: pip install 'fusefable[app]'."""
|
|
74
|
+
try:
|
|
75
|
+
import webview
|
|
76
|
+
except ImportError:
|
|
77
|
+
raise SystemExit("ติดตั้งก่อน: pip install 'fusefable[app]'")
|
|
78
|
+
from fusefable.web import INDEX_HTML
|
|
79
|
+
api = Api(cfg)
|
|
80
|
+
webview.create_window("Fuse Fable", html=INDEX_HTML, js_api=api,
|
|
81
|
+
width=960, height=720, min_size=(640, 480))
|
|
82
|
+
webview.start()
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""UI ของหน้าต่าง desktop (HTML/CSS/JS ฝังในไฟล์เดียว — ไม่พึ่ง network)."""
|
|
2
|
+
|
|
3
|
+
INDEX_HTML = r"""<!DOCTYPE html>
|
|
4
|
+
<html lang="th">
|
|
5
|
+
<head>
|
|
6
|
+
<meta charset="utf-8">
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
8
|
+
<title>Fuse Fable</title>
|
|
9
|
+
<style>
|
|
10
|
+
:root { --bg:#0f1115; --panel:#171a21; --panel2:#1f2430; --line:#2a3040;
|
|
11
|
+
--text:#e6e9ef; --muted:#8b93a7; --accent:#5b9cff; --green:#3fb950; }
|
|
12
|
+
* { box-sizing:border-box; }
|
|
13
|
+
html,body { margin:0; height:100%; }
|
|
14
|
+
body { background:var(--bg); color:var(--text); font:14px/1.55 -apple-system,Segoe UI,Roboto,sans-serif;
|
|
15
|
+
display:flex; flex-direction:column; }
|
|
16
|
+
header { padding:12px 16px; border-bottom:1px solid var(--line); display:flex;
|
|
17
|
+
align-items:center; gap:10px; background:var(--panel); }
|
|
18
|
+
header .dot { width:9px; height:9px; border-radius:50%; background:var(--green); }
|
|
19
|
+
header h1 { font-size:15px; margin:0; font-weight:600; }
|
|
20
|
+
header .sub { color:var(--muted); font-size:12px; margin-left:auto; }
|
|
21
|
+
#opts { display:flex; gap:14px; align-items:center; flex-wrap:wrap;
|
|
22
|
+
padding:8px 16px; border-bottom:1px solid var(--line); background:var(--panel);
|
|
23
|
+
font-size:13px; color:var(--muted); }
|
|
24
|
+
#opts label { display:flex; gap:5px; align-items:center; cursor:pointer; }
|
|
25
|
+
#opts input[type=text] { background:var(--panel2); border:1px solid var(--line);
|
|
26
|
+
color:var(--text); border-radius:6px; padding:4px 8px; font-size:12px; width:200px; }
|
|
27
|
+
#msgs { flex:1; overflow-y:auto; padding:16px; display:flex; flex-direction:column; gap:12px; }
|
|
28
|
+
.bubble { max-width:88%; padding:10px 13px; border-radius:12px; white-space:pre-wrap;
|
|
29
|
+
word-wrap:break-word; }
|
|
30
|
+
.user { align-self:flex-end; background:var(--accent); color:#fff; border-bottom-right-radius:3px; }
|
|
31
|
+
.bot { align-self:flex-start; background:var(--panel2); border:1px solid var(--line);
|
|
32
|
+
border-bottom-left-radius:3px; }
|
|
33
|
+
.meta { color:var(--muted); font-size:12px; margin-top:7px; padding-top:7px;
|
|
34
|
+
border-top:1px solid var(--line); }
|
|
35
|
+
.err { background:#3a1d1d; border-color:#5c2b2b; color:#ffb4b4; }
|
|
36
|
+
details { margin-top:6px; } summary { cursor:pointer; color:var(--accent); font-size:12px; }
|
|
37
|
+
details pre { background:var(--bg); padding:8px; border-radius:6px; overflow:auto;
|
|
38
|
+
font-size:12px; border:1px solid var(--line); }
|
|
39
|
+
#inbar { display:flex; gap:8px; padding:12px 16px; border-top:1px solid var(--line);
|
|
40
|
+
background:var(--panel); }
|
|
41
|
+
#q { flex:1; resize:none; height:44px; max-height:160px; background:var(--panel2);
|
|
42
|
+
border:1px solid var(--line); color:var(--text); border-radius:8px; padding:10px 12px;
|
|
43
|
+
font:14px inherit; }
|
|
44
|
+
#send { background:var(--accent); color:#fff; border:0; border-radius:8px; padding:0 18px;
|
|
45
|
+
font-weight:600; cursor:pointer; }
|
|
46
|
+
#send:disabled { opacity:.5; cursor:default; }
|
|
47
|
+
.spin { color:var(--muted); font-size:13px; }
|
|
48
|
+
</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<header>
|
|
52
|
+
<span class="dot"></span><h1>Fuse Fable</h1>
|
|
53
|
+
<span class="sub">fan-out · judge/ensemble · best answer</span>
|
|
54
|
+
</header>
|
|
55
|
+
<div id="opts">
|
|
56
|
+
<label><input type="checkbox" id="compress"> compress</label>
|
|
57
|
+
<label><input type="checkbox" id="ensemble"> ensemble</label>
|
|
58
|
+
<label><input type="checkbox" id="cache"> cache</label>
|
|
59
|
+
<input type="text" id="models" placeholder="models (comma, ว่าง=ทุกตัว)">
|
|
60
|
+
</div>
|
|
61
|
+
<div id="msgs"></div>
|
|
62
|
+
<div id="inbar">
|
|
63
|
+
<textarea id="q" placeholder="พิมพ์คำถาม… (Enter ส่ง, Shift+Enter ขึ้นบรรทัด)"></textarea>
|
|
64
|
+
<button id="send">Send</button>
|
|
65
|
+
</div>
|
|
66
|
+
<script>
|
|
67
|
+
const msgs = document.getElementById('msgs');
|
|
68
|
+
const q = document.getElementById('q');
|
|
69
|
+
const send = document.getElementById('send');
|
|
70
|
+
|
|
71
|
+
function esc(s){ const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
|
|
72
|
+
function add(cls, html){ const el=document.createElement('div'); el.className='bubble '+cls;
|
|
73
|
+
el.innerHTML=html; msgs.appendChild(el); msgs.scrollTop=msgs.scrollHeight; return el; }
|
|
74
|
+
|
|
75
|
+
function renderBot(r){
|
|
76
|
+
if(r.error){ add('bot err', '⚠️ '+esc(r.error)); return; }
|
|
77
|
+
let meta = [];
|
|
78
|
+
meta.push(r.chosen_model==='ensemble' ? 'ensemble' : 'from '+esc(r.chosen_model));
|
|
79
|
+
meta.push(r.cached ? 'cached, $0' : '$'+(r.cost_usd||0).toFixed(4));
|
|
80
|
+
if(r.compression) meta.push('compressed '+r.compression.original_chars+'→'+
|
|
81
|
+
r.compression.final_chars+' (~'+r.compression.saved_pct+'%)');
|
|
82
|
+
let cand = '';
|
|
83
|
+
if(r.candidates && r.candidates.length){
|
|
84
|
+
cand = '<details><summary>'+r.candidates.length+' candidates</summary>'+
|
|
85
|
+
r.candidates.map(c=>'<div><b>'+esc(c.model)+'</b><pre>'+esc(c.text)+'</pre></div>').join('')+
|
|
86
|
+
'</details>';
|
|
87
|
+
}
|
|
88
|
+
let warn = r.budget_warning ? '<div class="meta">⚠️ '+esc(r.budget_warning)+'</div>' : '';
|
|
89
|
+
add('bot', esc(r.answer) + warn + '<div class="meta">'+meta.join(' · ')+'</div>' + cand);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function ask(){
|
|
93
|
+
const text = q.value.trim(); if(!text) return;
|
|
94
|
+
add('user', esc(text)); q.value='';
|
|
95
|
+
send.disabled=true;
|
|
96
|
+
const spin = add('bot spin', 'กำลังฟิวชั่นหลายโมเดล…');
|
|
97
|
+
const payload = { question:text,
|
|
98
|
+
compress:document.getElementById('compress').checked,
|
|
99
|
+
ensemble:document.getElementById('ensemble').checked,
|
|
100
|
+
cache:document.getElementById('cache').checked,
|
|
101
|
+
models:document.getElementById('models').value };
|
|
102
|
+
try {
|
|
103
|
+
const r = await window.pywebview.api.ask(payload);
|
|
104
|
+
spin.remove(); renderBot(r);
|
|
105
|
+
} catch(e){ spin.remove(); add('bot err','⚠️ '+esc(String(e))); }
|
|
106
|
+
send.disabled=false; q.focus();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
send.addEventListener('click', ask);
|
|
110
|
+
q.addEventListener('keydown', e=>{ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); ask(); }});
|
|
111
|
+
q.focus();
|
|
112
|
+
</script>
|
|
113
|
+
</body>
|
|
114
|
+
</html>
|
|
115
|
+
"""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fusefable
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Fuse multiple AI models and judge the best answer for coding
|
|
5
5
|
Author: proultrax9
|
|
6
6
|
License: MIT
|
|
@@ -24,6 +24,8 @@ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
|
24
24
|
Requires-Dist: respx>=0.21; extra == "dev"
|
|
25
25
|
Provides-Extra: mcp
|
|
26
26
|
Requires-Dist: mcp>=1.2; extra == "mcp"
|
|
27
|
+
Provides-Extra: app
|
|
28
|
+
Requires-Dist: pywebview>=5; extra == "app"
|
|
27
29
|
Dynamic: license-file
|
|
28
30
|
|
|
29
31
|
# Fuse Fable
|
|
@@ -45,6 +47,7 @@ and as a **subagent / pipe** (callable by other tools and scripts).
|
|
|
45
47
|
```bash
|
|
46
48
|
pip install fusefable # core
|
|
47
49
|
pip install "fusefable[mcp]" # if you want the MCP server
|
|
50
|
+
pip install "fusefable[app]" # if you want the desktop window (fusefable gui)
|
|
48
51
|
```
|
|
49
52
|
From source:
|
|
50
53
|
```bash
|
|
@@ -75,6 +78,17 @@ setx OPENROUTER_API_KEY "sk-..." # Windows (open a new terminal afterwards)
|
|
|
75
78
|
|
|
76
79
|
Config is stored at `~/.fusefable/config.yaml`.
|
|
77
80
|
|
|
81
|
+
## 0) Use as a desktop app
|
|
82
|
+
|
|
83
|
+
A native window (like Cursor/VS Code), no browser tab — chat UI with toggles for
|
|
84
|
+
compress / ensemble / cache and a model filter:
|
|
85
|
+
```bash
|
|
86
|
+
pip install "fusefable[app]"
|
|
87
|
+
fusefable gui
|
|
88
|
+
```
|
|
89
|
+
Built on PyWebView (uses the system webview), so it's lightweight and cross-platform.
|
|
90
|
+
Requires a completed `fusefable config`.
|
|
91
|
+
|
|
78
92
|
## 1) Use as a CLI
|
|
79
93
|
```bash
|
|
80
94
|
fusefable ask "Write a quicksort function in Python"
|
|
@@ -9,6 +9,7 @@ fusefable/compressor.py
|
|
|
9
9
|
fusefable/config.py
|
|
10
10
|
fusefable/core.py
|
|
11
11
|
fusefable/cost.py
|
|
12
|
+
fusefable/desktop.py
|
|
12
13
|
fusefable/ensemble.py
|
|
13
14
|
fusefable/fanout.py
|
|
14
15
|
fusefable/fusion.py
|
|
@@ -16,6 +17,7 @@ fusefable/judge.py
|
|
|
16
17
|
fusefable/mcp_server.py
|
|
17
18
|
fusefable/models.py
|
|
18
19
|
fusefable/routing.py
|
|
20
|
+
fusefable/web.py
|
|
19
21
|
fusefable/wizard.py
|
|
20
22
|
fusefable.egg-info/PKG-INFO
|
|
21
23
|
fusefable.egg-info/SOURCES.txt
|
|
@@ -36,6 +38,7 @@ tests/test_compressor.py
|
|
|
36
38
|
tests/test_config.py
|
|
37
39
|
tests/test_core.py
|
|
38
40
|
tests/test_cost.py
|
|
41
|
+
tests/test_desktop.py
|
|
39
42
|
tests/test_ensemble.py
|
|
40
43
|
tests/test_fanout.py
|
|
41
44
|
tests/test_fusion.py
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fusefable"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "Fuse multiple AI models and judge the best answer for coding"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -27,6 +27,7 @@ Repository = "https://github.com/proultrax9/fusefable"
|
|
|
27
27
|
[project.optional-dependencies]
|
|
28
28
|
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "respx>=0.21"]
|
|
29
29
|
mcp = ["mcp>=1.2"]
|
|
30
|
+
app = ["pywebview>=5"]
|
|
30
31
|
|
|
31
32
|
[project.scripts]
|
|
32
33
|
fusefable = "fusefable.cli:app"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from fusefable import desktop
|
|
3
|
+
from fusefable.models import FinalAnswer, Completion
|
|
4
|
+
from fusefable.compressor import CompressionResult
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_answer_to_dict_includes_meta():
|
|
8
|
+
ans = FinalAnswer(text="best", chosen_model="gpt", reason="r", cost_usd=0.02,
|
|
9
|
+
all_completions=[Completion(model="gpt", text="best")],
|
|
10
|
+
compression=CompressionResult("x", 1000, 400, "llm"))
|
|
11
|
+
d = desktop.answer_to_dict(ans)
|
|
12
|
+
assert d["answer"] == "best"
|
|
13
|
+
assert d["chosen_model"] == "gpt"
|
|
14
|
+
assert d["candidates"][0]["model"] == "gpt"
|
|
15
|
+
assert d["compression"]["method"] == "llm"
|
|
16
|
+
assert d["compression"]["saved_pct"] == 60.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_models_from_payload_parses_csv():
|
|
20
|
+
assert desktop._models_from_payload({"models": "a, b ,c"}) == ["a", "b", "c"]
|
|
21
|
+
assert desktop._models_from_payload({"models": ""}) is None
|
|
22
|
+
assert desktop._models_from_payload({}) is None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_run_query_calls_fuse_and_serializes(monkeypatch):
|
|
26
|
+
captured = {}
|
|
27
|
+
|
|
28
|
+
async def fake_fuse(cfg, question, models=None, compress=None,
|
|
29
|
+
ensemble=None, use_cache=None):
|
|
30
|
+
captured.update(question=question, models=models, compress=compress,
|
|
31
|
+
ensemble=ensemble, use_cache=use_cache)
|
|
32
|
+
return FinalAnswer(text="hi", chosen_model="m")
|
|
33
|
+
|
|
34
|
+
monkeypatch.setattr(desktop, "fuse", fake_fuse)
|
|
35
|
+
out = desktop.run_query("CFG", {"question": "Q", "models": "a,b",
|
|
36
|
+
"compress": True, "ensemble": False, "cache": True})
|
|
37
|
+
assert out["answer"] == "hi"
|
|
38
|
+
assert captured["question"] == "Q"
|
|
39
|
+
assert captured["models"] == ["a", "b"]
|
|
40
|
+
assert captured["compress"] is True
|
|
41
|
+
assert captured["use_cache"] is True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_run_query_returns_error_on_failure(monkeypatch):
|
|
45
|
+
async def boom(*a, **k):
|
|
46
|
+
raise RuntimeError("kaboom")
|
|
47
|
+
monkeypatch.setattr(desktop, "fuse", boom)
|
|
48
|
+
out = desktop.run_query("CFG", {"question": "Q"})
|
|
49
|
+
assert out["error"] == "kaboom"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.1"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|