fast-mode-kit 0.2.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.
fastkit/evals/run.py ADDED
@@ -0,0 +1,96 @@
1
+ """Run a gold set against an agent and print a report.
2
+
3
+ python -m fastkit.evals.run --gold fastkit/evals/shop_gold.jsonl # offline demo model, free
4
+ python -m fastkit.evals.run --gold fastkit/evals/shop_gold.jsonl \
5
+ --base-url http://localhost:8080/v1 --model my-model # your model
6
+ ... --price-in 0.25 --price-out 1.25 --spend-cap 0.50 # hosted APIs: cap the spend
7
+
8
+ Run one request per question. Read cost from the response's own usage.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import asyncio
14
+ import os
15
+ from typing import Any, Awaitable, Callable
16
+
17
+ from fastkit.checks import ungrounded_figures
18
+
19
+ from .report import markdown
20
+ from .schema import GoldItem, Result, grade
21
+
22
+ Agent = Callable[[str], Awaitable[dict[str, Any]]]
23
+
24
+
25
+ def load_gold(path: str) -> list[GoldItem]:
26
+ with open(path, encoding="utf-8") as f:
27
+ return [GoldItem.model_validate_json(line) for line in f if line.strip()]
28
+
29
+
30
+ async def run_eval(items: list[GoldItem], agent_fn: Agent, *, spend_cap_usd: float,
31
+ price_per_mtok: tuple[float, float] | None) -> list[Result]:
32
+ results: list[Result] = []
33
+ spent = 0.0
34
+ for item in items:
35
+ if price_per_mtok and spend_cap_usd > 0 and spent >= spend_cap_usd:
36
+ break
37
+ out = await agent_fn(item.question)
38
+ u = out.get("usage") or {}
39
+ cost = 0.0
40
+ if price_per_mtok:
41
+ cost = (u.get("prompt_tokens", 0) * price_per_mtok[0]
42
+ + u.get("completion_tokens", 0) * price_per_mtok[1]) / 1e6
43
+ spent += cost
44
+ results.append(Result(
45
+ id=item.id, language=item.language, correct=grade(item, out),
46
+ first_token_s=out.get("first_token_s"), total_s=out.get("total_s", 0.0), cost_usd=cost,
47
+ fell_back=out.get("fell_back"),
48
+ ungrounded=ungrounded_figures(out.get("text") or "", out.get("rows") or [], item.question)))
49
+ return results
50
+
51
+
52
+ def mock_agent() -> Agent:
53
+ from fastkit.examples.shop.agent import make_registry, run
54
+ from fastkit.examples.shop.demo_model import DemoModel, demo_vocab
55
+
56
+ async def agent(q: str) -> dict[str, Any]:
57
+ reg = make_registry()
58
+ return await run(q, "fast", DemoModel(reg, vocab=demo_vocab()), registry=reg)
59
+ return agent
60
+
61
+
62
+ def client_agent(base_url: str, model: str, key_env: str, mode: str) -> Agent:
63
+ from fastkit.examples.shop.agent import make_registry, run
64
+ from fastkit.client import ChatClient
65
+
66
+ async def agent(q: str) -> dict[str, Any]:
67
+ reg = make_registry()
68
+ return await run(q, mode, ChatClient(base_url, os.environ.get(key_env), model), registry=reg)
69
+ return agent
70
+
71
+
72
+ def main() -> None:
73
+ ap = argparse.ArgumentParser()
74
+ ap.add_argument("--gold", default="fastkit/evals/shop_gold.jsonl")
75
+ ap.add_argument("--mock", action="store_true")
76
+ ap.add_argument("--base-url")
77
+ ap.add_argument("--model", default="")
78
+ ap.add_argument("--api-key-env", default="FASTKIT_API_KEY")
79
+ ap.add_argument("--mode", default="fast")
80
+ ap.add_argument("--price-in", type=float, help="USD per million input tokens")
81
+ ap.add_argument("--price-out", type=float, help="USD per million output tokens")
82
+ ap.add_argument("--spend-cap", type=float, default=0.50)
83
+ a = ap.parse_args()
84
+ items = load_gold(a.gold)
85
+ if a.base_url and not a.mock:
86
+ agent = client_agent(a.base_url, a.model, a.api_key_env, a.mode)
87
+ else:
88
+ agent = mock_agent()
89
+ price = (a.price_in, a.price_out) if a.price_in is not None and a.price_out is not None else None
90
+ if a.base_url and price is None:
91
+ print("note: no --price-in/--price-out given, so cost is reported as $0 and the cap cannot stop the run")
92
+ print(markdown(asyncio.run(run_eval(items, agent, spend_cap_usd=a.spend_cap, price_per_mtok=price))))
93
+
94
+
95
+ if __name__ == "__main__":
96
+ main()
@@ -0,0 +1,50 @@
1
+ """Gold items, results and grading."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ _NUM = re.compile(r"-?\d[\d,]*(?:\.\d+)?")
11
+
12
+
13
+ class GoldItem(BaseModel):
14
+ id: str
15
+ question: str
16
+ language: str
17
+ expected_tools: list[str] = Field(default_factory=list)
18
+ expected_figures: list[str] = Field(default_factory=list)
19
+ expects_confirm: bool = False
20
+
21
+
22
+ class Result(BaseModel):
23
+ id: str
24
+ language: str
25
+ correct: bool
26
+ first_token_s: float | None
27
+ total_s: float
28
+ cost_usd: float
29
+ fell_back: str | None
30
+ ungrounded: list[str]
31
+
32
+
33
+ def _numbers(text: str) -> list[float]:
34
+ out = []
35
+ for m in _NUM.finditer(text):
36
+ try:
37
+ out.append(float(m.group(0).replace(",", "")))
38
+ except ValueError:
39
+ pass
40
+ return out
41
+
42
+
43
+ def grade(item: GoldItem, out: dict[str, Any]) -> bool:
44
+ """Correct when every expected figure appears (numerically) and a write
45
+ gets its confirm card."""
46
+ if item.expects_confirm and not out.get("confirm"):
47
+ return False
48
+ haystack = (out.get("text") or "") + " " + json.dumps(out.get("confirm") or {})
49
+ have = _numbers(haystack)
50
+ return all(any(abs(h - float(f)) < 1e-6 for h in have) for f in item.expected_figures)
@@ -0,0 +1,24 @@
1
+ {"id":"en-01","question":"Which product made the most revenue?","language":"en","expected_tools":["rank_products"],"expected_figures":["84.0"]}
2
+ {"id":"en-02","question":"Top 3 products by units sold","language":"en","expected_tools":["rank_products"],"expected_figures":["15","8","7"]}
3
+ {"id":"en-03","question":"How many notebooks are in stock?","language":"en","expected_tools":["product_stock"],"expected_figures":["120"]}
4
+ {"id":"en-04","question":"Stock of caps?","language":"en","expected_tools":["product_stock"],"expected_figures":["0"]}
5
+ {"id":"en-05","question":"Revenue on 2026-11-05","language":"en","expected_tools":["orders_by_day"],"expected_figures":["-7.5"]}
6
+ {"id":"en-06","question":"What did Chen buy and how much did he spend in total?","language":"en","expected_tools":["customer_orders"],"expected_figures":["35.0","48.0"]}
7
+ {"id":"en-07","question":"Lowest selling product by revenue, and the lamp's stock","language":"en","expected_tools":["rank_products","product_stock"],"expected_figures":["22.5","3"]}
8
+ {"id":"en-08","question":"I sold 3 mugs to Ana","language":"en","expected_tools":["record_sale"],"expected_figures":["3","36.0"],"expects_confirm":true}
9
+ {"id":"es-01","question":"¿Qué producto generó más ingresos?","language":"es","expected_tools":["rank_products"],"expected_figures":["84.0"]}
10
+ {"id":"es-02","question":"Los 3 productos más vendidos por unidades","language":"es","expected_tools":["rank_products"],"expected_figures":["15","8","7"]}
11
+ {"id":"es-03","question":"¿Cuántas velas hay en stock?","language":"es","expected_tools":["product_stock"],"expected_figures":["60"]}
12
+ {"id":"es-04","question":"¿Cuántos pedidos hubo el 2026-11-01?","language":"es","expected_tools":["orders_by_day"],"expected_figures":["2"]}
13
+ {"id":"es-05","question":"Ingresos del 2026-11-03","language":"es","expected_tools":["orders_by_day"],"expected_figures":["81.0"]}
14
+ {"id":"es-06","question":"¿Qué compró Dara?","language":"es","expected_tools":["customer_orders"],"expected_figures":["45.0","22.5"]}
15
+ {"id":"es-07","question":"Precio y stock de la tetera (Teapot)","language":"es","expected_tools":["product_stock"],"expected_figures":["35.0","6"]}
16
+ {"id":"es-08","question":"Vendí 2 lámparas (Lamp)","language":"es","expected_tools":["record_sale"],"expected_figures":["2","96.0"],"expects_confirm":true}
17
+ {"id":"ty-01","question":"wich prodct sold the most unitz","language":"typo","expected_tools":["rank_products"],"expected_figures":["15"]}
18
+ {"id":"ty-02","question":"stok of the scraf","language":"typo","expected_tools":["product_stock"],"expected_figures":["15"]}
19
+ {"id":"ty-03","question":"how much revnue on nov 2 2026","language":"typo","expected_tools":["orders_by_day"],"expected_figures":["80.0"]}
20
+ {"id":"ty-04","question":"bens orders pls","language":"typo","expected_tools":["customer_orders"],"expected_figures":["45.0","36.0","15.0"]}
21
+ {"id":"ty-05","question":"lowest revenue product?? ignore the ones with no sales","language":"typo","expected_tools":["rank_products"],"expected_figures":["22.5"]}
22
+ {"id":"ty-06","question":"top seller y su stock","language":"mixed","expected_tools":["rank_products","product_stock"],"expected_figures":["84.0","40"]}
23
+ {"id":"ty-07","question":"pen set stock porfa","language":"mixed","expected_tools":["product_stock"],"expected_figures":["0"]}
24
+ {"id":"ty-08","question":"just sold 1 tea pot","language":"typo","expected_tools":["record_sale"],"expected_figures":["1","35.0"],"expects_confirm":true}
File without changes
File without changes
@@ -0,0 +1,104 @@
1
+ """Example shop agent with a Fast mode.
2
+
3
+ python -m fastkit.examples.shop.agent "top 3 products by revenue" --mock
4
+ python -m fastkit.examples.shop.agent "lowest selling product" \
5
+ --base-url http://localhost:8080/v1 --model my-model
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import asyncio
11
+ import os
12
+ import time
13
+ from typing import Any
14
+
15
+ from fastkit.checks import safe_filter
16
+ from fastkit.client import ChatClient
17
+ from fastkit.loop import FastConfig, fast_answer
18
+ from fastkit.modes import resolve_mode
19
+ from fastkit.pipeline import pipeline_answer
20
+ from fastkit.prompts import render
21
+ from fastkit.tools import ToolRegistry
22
+ from fastkit.types import Model
23
+
24
+ from .data import connect
25
+ from .tools import build_registry
26
+
27
+ DOMAIN = "a small online shop's products, stock, customers and orders"
28
+
29
+
30
+ def make_registry() -> ToolRegistry:
31
+ return build_registry(connect())
32
+
33
+
34
+ async def run(question: str, mode: str, model: Model, reader: Model | None = None,
35
+ writer: Model | None = None, registry: ToolRegistry | None = None,
36
+ config: FastConfig | None = None) -> dict[str, Any]:
37
+ reg = registry or make_registry()
38
+ tool_list = "\n".join(f"- {t.name}: {t.description}" for t in reg.tools.values())
39
+ trace: dict[str, Any] = {}
40
+ reader, writer = reader or model, writer or model
41
+
42
+ def fallback():
43
+ return pipeline_answer(reader, writer, reg, question,
44
+ reader_prompt=render("reader", domain=DOMAIN),
45
+ writer_prompt=render("writer", domain=DOMAIN), trace=trace)
46
+
47
+ if config is None:
48
+ # Only give up on a silent model if the backup uses a different one.
49
+ config = FastConfig(silence_fallback=reader is not model)
50
+ if resolve_mode(mode) == "fast":
51
+ # Small registry: offer every tool. Large one: offer a likely bundle and
52
+ # let the model reach the rest through find_tools.
53
+ offered = list(reg.tools) if len(reg.tools) <= 8 else reg.find(question, k=5)
54
+ stream = fast_answer(model, reg, question, [], offered,
55
+ system_prompt=render("fast_system", domain=DOMAIN, tool_list=tool_list),
56
+ fallback=fallback, config=config, trace=trace)
57
+ else:
58
+ trace["fell_back"] = None
59
+ stream = fallback()
60
+
61
+ t0 = time.monotonic()
62
+ first = None
63
+ parts: list[str] = []
64
+ usage = None
65
+ async for ev in stream:
66
+ if ev.kind == "token":
67
+ first = first or time.monotonic() - t0
68
+ parts.append(ev.text)
69
+ elif ev.kind == "done" and ev.usage:
70
+ usage = ev.usage
71
+ if first is None and (parts or trace.get("confirm")):
72
+ first = time.monotonic() - t0
73
+ rows = trace.get("rows", [])
74
+ tools_called = trace.get("tools_called", [])
75
+ text = "".join(parts)
76
+ return {"text": safe_filter(text, rows, question) if text else text, "rows": rows,
77
+ "confirm": trace.get("confirm"), "fell_back": trace.get("fell_back"),
78
+ "note": trace.get("note"), "tools_called": tools_called, "first_token_s": first, "total_s": time.monotonic() - t0,
79
+ "usage": usage or trace.get("usage")}
80
+
81
+
82
+ def main() -> None:
83
+ ap = argparse.ArgumentParser()
84
+ ap.add_argument("question")
85
+ ap.add_argument("--mode", default="fast")
86
+ ap.add_argument("--mock", action="store_true", help="offline fake model (default if no --base-url)")
87
+ ap.add_argument("--base-url")
88
+ ap.add_argument("--model", default="")
89
+ ap.add_argument("--api-key-env", default="FASTKIT_API_KEY")
90
+ a = ap.parse_args()
91
+ reg = make_registry()
92
+ if a.base_url and not a.mock:
93
+ model: Model = ChatClient(a.base_url, os.environ.get(a.api_key_env), a.model)
94
+ else:
95
+ from .demo_model import DemoModel, demo_vocab
96
+ model = DemoModel(reg, vocab=demo_vocab())
97
+ out = asyncio.run(run(a.question, a.mode, model, registry=reg))
98
+ print(out["text"] or out["confirm"])
99
+ print(f"\n[first token {out['first_token_s'] or 0:.2f}s, total {out['total_s']:.2f}s, "
100
+ f"fallback={out['fell_back']}]")
101
+
102
+
103
+ if __name__ == "__main__":
104
+ main()
@@ -0,0 +1,32 @@
1
+ """A tiny in-memory shop: products, customers, orders. Toy data only."""
2
+ import sqlite3
3
+
4
+ PRODUCTS = [
5
+ ("SKU-1001", "Mug", "kitchen", 12.0, 40), ("SKU-1002", "Teapot", "kitchen", 35.0, 6),
6
+ ("SKU-1003", "Cap", "apparel", 18.0, 0), ("SKU-1004", "Scarf", "apparel", 22.5, 15),
7
+ ("SKU-1005", "Notebook", "stationery", 4.5, 120), ("SKU-1006", "Pen set", "stationery", 9.0, 0),
8
+ ("SKU-1007", "Lamp", "home", 48.0, 3), ("SKU-1008", "Candle", "home", 7.5, 60),
9
+ ]
10
+ CUSTOMERS = [(1, "Ana"), (2, "Ben"), (3, "Chen"), (4, "Dara")]
11
+ # (order_id, day, customer_id, sku, qty); negative qty = a return
12
+ ORDERS = [
13
+ (1, "2026-11-01", 1, "SKU-1001", 4), (2, "2026-11-01", 2, "SKU-1005", 10),
14
+ (3, "2026-11-02", 3, "SKU-1002", 1), (4, "2026-11-02", 1, "SKU-1008", 6),
15
+ (5, "2026-11-03", 4, "SKU-1004", 2), (6, "2026-11-03", 2, "SKU-1001", 3),
16
+ (7, "2026-11-04", 3, "SKU-1007", 1), (8, "2026-11-04", 4, "SKU-1005", 5),
17
+ (9, "2026-11-05", 1, "SKU-1004", -1), (10, "2026-11-05", 2, "SKU-1008", 2),
18
+ ]
19
+
20
+
21
+ def connect() -> sqlite3.Connection:
22
+ conn = sqlite3.connect(":memory:")
23
+ conn.row_factory = sqlite3.Row
24
+ conn.executescript("""
25
+ create table products(sku text primary key, name text, category text, price real, stock int);
26
+ create table customers(id int primary key, name text);
27
+ create table orders(id int primary key, day text, customer_id int, sku text, qty int);
28
+ """)
29
+ conn.executemany("insert into products values (?,?,?,?,?)", PRODUCTS)
30
+ conn.executemany("insert into customers values (?,?)", CUSTOMERS)
31
+ conn.executemany("insert into orders values (?,?,?,?,?)", ORDERS)
32
+ return conn
@@ -0,0 +1,69 @@
1
+ """A fake model so the example runs offline with no key. It is NOT smart:
2
+ it picks the best-matching tool and prints the first rows. Swap in a real
3
+ model with --base-url to see real answers."""
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import re
8
+ from typing import Any, AsyncIterator
9
+
10
+ from fastkit.tools import ToolRegistry
11
+ from fastkit.types import Message, StreamEvent, ToolCall
12
+
13
+
14
+ class DemoModel:
15
+ """vocab: names the fake model may spot in a question (products, customers)."""
16
+
17
+ def __init__(self, registry: ToolRegistry, vocab: list[str] | None = None):
18
+ self.registry = registry
19
+ self.vocab = vocab or []
20
+
21
+ def _args(self, tool_name: str, q: str) -> dict:
22
+ tool = self.registry.get(tool_name)
23
+ props = (tool.parameters or {}).get("properties", {}) if tool else {}
24
+ ql = q.lower().replace(" ", "")
25
+ names = [v for v in self.vocab if v.lower().replace(" ", "") in ql]
26
+ nums = [int(n) for n in re.findall(r"(?<![\d-])\d{1,3}(?![\d-])", q)]
27
+ date = re.search(r"\d{4}-\d{2}-\d{2}", q)
28
+ args: dict = {}
29
+ for p in props:
30
+ if p in ("name", "product", "customer") and names:
31
+ args[p] = names[0]
32
+ elif p in ("qty", "n") and nums:
33
+ args[p] = nums[0]
34
+ elif p == "day" and date:
35
+ args[p] = date.group(0)
36
+ return args
37
+
38
+ async def stream(self, messages: list[Message], tools: list[dict] | None = None,
39
+ **kw: Any) -> AsyncIterator[StreamEvent]:
40
+ last = messages[-1]
41
+ if last.role == "user" and tools:
42
+ q = last.content or ""
43
+ found = self.registry.find(q) or ["rank_products"]
44
+ yield StreamEvent(kind="tool_call",
45
+ tool_call=ToolCall(id="demo", name=found[0], arguments=self._args(found[0], q)))
46
+ elif last.role == "user" and (last.content or "").startswith("Question:"):
47
+ rows = json.loads(last.content.split("Rows:\n", 1)[1] or "[]")
48
+ yield StreamEvent(kind="token", text=self._lines(rows))
49
+ elif last.role == "user":
50
+ found = self.registry.find(last.content or "") or ["rank_products"]
51
+ yield StreamEvent(kind="token", text=json.dumps({"parts": [{"tool": found[0], "args": {}}]}))
52
+ else:
53
+ result = json.loads(last.content or "[]")
54
+ if any("error" in r for r in result if isinstance(r, dict)):
55
+ yield StreamEvent(kind="token", text="I need a bit more detail for that one.")
56
+ else:
57
+ yield StreamEvent(kind="token", text=self._lines(result))
58
+ yield StreamEvent(kind="done", usage={"prompt_tokens": 0, "completion_tokens": 0})
59
+
60
+ @staticmethod
61
+ def _lines(rows: list[dict]) -> str:
62
+ if not rows:
63
+ return "No data for that."
64
+ return "\n".join(", ".join(f"{k}: {v}" for k, v in r.items()) for r in rows[:3])
65
+
66
+
67
+ def demo_vocab() -> list[str]:
68
+ from .data import CUSTOMERS, PRODUCTS
69
+ return [p[1] for p in PRODUCTS] + [c[1] for c in CUSTOMERS]
@@ -0,0 +1,73 @@
1
+ """Shop tools. Each returns rows with every derived figure computed server-side,
2
+ so the model never has to do arithmetic."""
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+
7
+ from fastkit.tools import Tool, ToolRegistry
8
+
9
+ OBJ = lambda props, req=(): {"type": "object", "properties": props, "required": list(req)}
10
+
11
+
12
+ def build_registry(conn: sqlite3.Connection) -> ToolRegistry:
13
+ q = lambda sql, *a: [dict(r) for r in conn.execute(sql, a).fetchall()]
14
+
15
+ async def rank_products(n: int = 3, by: str = "revenue", order: str = "desc"):
16
+ col = {"revenue": "revenue", "units": "units"}.get(by, "revenue")
17
+ rows = q(f"""select p.sku, p.name, coalesce(sum(o.qty),0) units,
18
+ round(coalesce(sum(o.qty*p.price),0),2) revenue
19
+ from products p left join orders o on o.sku=p.sku group by p.sku""")
20
+ active = [r for r in rows if r["units"] != 0]
21
+ inactive = len(rows) - len(active)
22
+ active.sort(key=lambda r: r[col], reverse=(order != "asc"))
23
+ out = [dict(r, rank=i + 1, measure=col) for i, r in enumerate(active[:n])]
24
+ if order == "asc" and inactive:
25
+ out.append({"inactive_count": inactive, "note": "products with no sales, left out of the ranking"})
26
+ return out
27
+
28
+ def find_product(name: str) -> list[dict]:
29
+ # Forgiving lookup: case, spaces and a plural "s" never hide a product.
30
+ key = name.strip().lower()
31
+ rows = q("select sku, name, stock, price from products")
32
+ for cand in (key, key[:-1] if key.endswith("s") else key):
33
+ hit = [r for r in rows if r["name"].lower() == cand or r["sku"].lower() == cand
34
+ or r["name"].lower().replace(" ", "") == cand.replace(" ", "")]
35
+ if hit:
36
+ return hit
37
+ return [r for r in rows if key.rstrip("s") in r["name"].lower()]
38
+
39
+ async def product_stock(name: str):
40
+ return find_product(name) or [{"missing": name, "known_products": [r["name"] for r in q("select name from products")]}]
41
+
42
+ async def orders_by_day(day: str | None = None):
43
+ sql = """select o.day, count(*) orders, sum(o.qty) units, round(sum(o.qty*p.price),2) revenue
44
+ from orders o join products p on p.sku=o.sku {} group by o.day order by o.day"""
45
+ return q(sql.format("where o.day=?"), day) if day else q(sql.format(""))
46
+
47
+ async def customer_orders(customer: str):
48
+ return q("""select c.name customer, o.day, p.name product, o.qty, round(o.qty*p.price,2) amount
49
+ from orders o join customers c on c.id=o.customer_id join products p on p.sku=o.sku
50
+ where lower(c.name)=lower(?) order by o.day""", customer) or [{"missing": customer}]
51
+
52
+ async def record_sale(product: str, qty: int, customer: str | None = None):
53
+ found = find_product(product)
54
+ if not found:
55
+ return [{"missing": product}]
56
+ p = found[0]
57
+ return [{"confirm": {"action": "record_sale", "sku": p["sku"], "product": p["name"], "qty": qty,
58
+ "customer": customer, "amount": round(qty * p["price"], 2)}}]
59
+
60
+ r = ToolRegistry()
61
+ r.register(Tool("rank_products", "rank products by revenue or units sold, highest or lowest first",
62
+ OBJ({"n": {"type": "integer"}, "by": {"type": "string", "enum": ["revenue", "units"]},
63
+ "order": {"type": "string", "enum": ["desc", "asc"]}}), rank_products))
64
+ r.register(Tool("product_stock", "current stock level and price of one product",
65
+ OBJ({"name": {"type": "string"}}, ["name"]), product_stock))
66
+ r.register(Tool("orders_by_day", "order count, units and revenue per day",
67
+ OBJ({"day": {"type": "string", "description": "YYYY-MM-DD"}}), orders_by_day))
68
+ r.register(Tool("customer_orders", "every order a customer placed with amounts",
69
+ OBJ({"customer": {"type": "string"}}, ["customer"]), customer_orders))
70
+ r.register(Tool("record_sale", "record a sale the user reports (sold, selling); shows a confirm card first",
71
+ OBJ({"product": {"type": "string"}, "qty": {"type": "integer"},
72
+ "customer": {"type": "string"}}, ["product", "qty"]), record_sale, kind="propose"))
73
+ return r
fastkit/hedge.py ADDED
@@ -0,0 +1,84 @@
1
+ """Hedged streaming across routes.
2
+
3
+ Lesson: when the primary route is silent, start the SAME model on another
4
+ route rather than falling to a weaker model. The first leg to speak wins.
5
+ A leg that has already started streaming is never cut by a timer.
6
+
7
+ ``silence_drop_s`` is a TOTAL budget from the start of the call, shared by
8
+ all legs: with hedge 5 s and drop 15 s, a third leg gets 5 s. Keep
9
+ ``hedge_after_s * (len(legs) - 1) < silence_drop_s`` or later legs never start.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ from typing import Any, AsyncIterator
15
+
16
+ from .types import Message, Model, StreamEvent
17
+
18
+
19
+ class AllLegsSilent(RuntimeError):
20
+ pass
21
+
22
+
23
+ async def hedged_stream(legs: list[Model], messages: list[Message], *, hedge_after_s: float,
24
+ silence_drop_s: float, tools: list[dict] | None = None,
25
+ **kw: Any) -> AsyncIterator[StreamEvent]:
26
+ if not legs:
27
+ raise ValueError("no legs")
28
+ queue: asyncio.Queue = asyncio.Queue()
29
+ tasks: list[asyncio.Task] = []
30
+
31
+ async def pump(i: int, leg: Model) -> None:
32
+ try:
33
+ async for ev in leg.stream(messages, tools=tools, **kw):
34
+ await queue.put((i, ev))
35
+ except Exception as exc: # a failed leg is just a silent leg
36
+ await queue.put((i, StreamEvent(kind="error", text=str(exc))))
37
+ await queue.put((i, None))
38
+
39
+ def start(i: int) -> None:
40
+ tasks.append(asyncio.create_task(pump(i, legs[i])))
41
+
42
+ loop = asyncio.get_running_loop()
43
+ t0 = loop.time()
44
+ start(0)
45
+ winner: int | None = None
46
+ finished: set[int] = set()
47
+ try:
48
+ while True:
49
+ if winner is None:
50
+ now = loop.time()
51
+ if now - t0 >= silence_drop_s:
52
+ raise AllLegsSilent(f"no leg spoke within {silence_drop_s}s")
53
+ next_hedge = t0 + hedge_after_s * len(tasks)
54
+ deadline = min(t0 + silence_drop_s,
55
+ next_hedge if len(tasks) < len(legs) else float("inf"))
56
+ try:
57
+ i, ev = await asyncio.wait_for(queue.get(), timeout=max(0.0, deadline - now))
58
+ except asyncio.TimeoutError:
59
+ if len(tasks) < len(legs) and loop.time() >= next_hedge:
60
+ start(len(tasks))
61
+ continue
62
+ else:
63
+ i, ev = await queue.get()
64
+ if ev is None:
65
+ finished.add(i)
66
+ if winner == i:
67
+ return
68
+ if winner is None and len(finished) == len(legs):
69
+ raise AllLegsSilent("every leg ended without output")
70
+ if winner is None and len(tasks) < len(legs):
71
+ start(len(tasks))
72
+ continue
73
+ if winner is None:
74
+ if ev.kind == "error":
75
+ continue
76
+ winner = i
77
+ for j, t in enumerate(tasks):
78
+ if j != i:
79
+ t.cancel()
80
+ if i == winner:
81
+ yield ev
82
+ finally:
83
+ for t in tasks:
84
+ t.cancel()