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.
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: fast-mode-kit
3
+ Version: 0.2.0
4
+ Summary: Starter kit for adding a fast, data-only answer mode to any chat agent
5
+ License: MIT
6
+ Keywords: llm,agents,latency,tool-calling,evals
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: httpx>=0.27
14
+ Requires-Dist: pydantic>=2.6
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=8; extra == "dev"
17
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
18
+ Dynamic: license-file
19
+
20
+ # fast-mode-kit
21
+
22
+ A starter kit for adding a **Fast mode** to any chat agent. Fast mode answers exactly what was asked, from your data, right the first time, in a few seconds.
23
+
24
+ It comes from shipping Fast mode in a live product. Every rule here traces back to a measured result or a real failure, and the [failure catalogue](docs/08-failure-catalogue.md) lists them.
25
+
26
+ It works with **frontier APIs** (any OpenAI-compatible endpoint) and with **models you host yourself** (mlx, vLLM, Ollama, llama.cpp).
27
+
28
+ ## Quick start (offline, no key, no cost)
29
+
30
+ ```bash
31
+ pip install fast-mode-kit
32
+
33
+ fastkit demo "rank products by revenue" # the example shop agent, offline
34
+ fastkit eval # accuracy, latency, cost and fallback report
35
+ fastkit init my-agent # your own Fast mode project
36
+ cd my-agent && python agent.py "how many widgets in stock?"
37
+ ```
38
+
39
+ The offline stand-in model picks tools by word overlap and has no understanding, so the example scores about 40% on `fastkit eval`. That is expected: it shows what keyword routing gets you. A real model fills the gap.
40
+
41
+ ## With a real model
42
+
43
+ ```bash
44
+ export FASTKIT_API_KEY=... # leave unset for local servers (Ollama, vLLM, mlx, llama.cpp)
45
+
46
+ # 1. One tiny request: is this model fast enough, and does it call tools?
47
+ fastkit check --base-url https://openrouter.ai/api/v1 --model google/gemini-2.5-flash
48
+
49
+ # 2. Measure your agent on your questions, with a spend cap
50
+ fastkit eval --agent agent.py --gold gold.jsonl \
51
+ --base-url https://openrouter.ai/api/v1 --model google/gemini-2.5-flash \
52
+ --price-in 0.30 --price-out 2.50 --spend-cap 0.50
53
+ ```
54
+
55
+ `fastkit check` prints PASS, SLOW or FAIL against the 4.5 s first-words budget and tells you whether the model called the tool. A model that answers without calling it can't run the one-call loop.
56
+
57
+ `fastkit.checks.safe_filter` runs after the answer has finished. Use it in evals and logs, or buffer the output if you want to filter before the user sees it.
58
+
59
+ ## Develop
60
+
61
+ ```bash
62
+ git clone <this repo> && cd fast-mode-kit
63
+ pip install -e ".[dev]" && pytest -q # every test runs against a mock model
64
+ ```
65
+
66
+ ## What's inside
67
+
68
+ | Path | What it is |
69
+ |---|---|
70
+ | `docs/` | the guide, chapters 1-9: what Fast is, architecture, deadlines, prompts, checks, model choice, self-hosting, failure catalogue, measuring |
71
+ | `fastkit/templates/` | prompt templates: Fast system, reader, writer, server-owned lines |
72
+ | `fastkit/client.py` | streaming client for any OpenAI-compatible server |
73
+ | `fastkit/loop.py` | the one-call loop: read, call tools and write in one conversation, with a fallback |
74
+ | `fastkit/pipeline.py` | the fallback: a reader plans, the server fetches, a writer answers |
75
+ | `fastkit/hedge.py` | hedged routes; a stream that has started is never cut |
76
+ | `fastkit/tools.py` | tool registry, `find_tools`, `note_turn`, propose/confirm writes |
77
+ | `fastkit/checks.py` | do-no-harm figure checks |
78
+ | `fastkit/modes.py` | Fast-only gate |
79
+ | `fastkit/mock.py` | scripted model for offline tests |
80
+ | `fastkit/examples/shop/` | a complete agent over a toy shop database |
81
+ | `fastkit/evals/` | gold-set runner, report and spend cap |
82
+ | `fastkit/cli.py` | the `fastkit` command: demo, init, check, eval |
83
+ | `fastkit/scaffold/` | the starter project `fastkit init` writes |
84
+
85
+ ## Adapt it to your domain in 5 steps
86
+
87
+ 1. **Tools:** write read tools that return rows with every derived figure already computed (totals, ranks, percentages), plus propose tools for writes. See `fastkit/examples/shop/tools.py`.
88
+ 2. **Prompt:** render `fastkit/templates/fast_system.md` with your domain and tool list.
89
+ 3. **Loop:** call `fast_answer(...)` with your model and a pipeline fallback. See `fastkit/examples/shop/agent.py`.
90
+ 4. **Gold set:** write 40-60 real questions with their expected figures, across the languages your users write in.
91
+ 5. **Measure:** run `fastkit eval` on two or three models and pick by tail latency and per-language accuracy, not by the median.
92
+
93
+ ## The rules in one screen
94
+
95
+ - No second chance in Fast, so the first answer must be right.
96
+ - One line per asked part, figure first, data only.
97
+ - The server computes every figure. The model copies.
98
+ - Fall back to another model path, never to keyword guesses.
99
+ - A deadline never cuts an answer that has started streaming.
100
+ - A check that edits a correct line is a bug in the check.
101
+ - Keep Fast changes behind one gate, and prove the other modes unchanged.
102
+ - Never judge a reading model with thinking switched off.
103
+
104
+ ## License
105
+
106
+ MIT
@@ -0,0 +1,39 @@
1
+ fast_mode_kit-0.2.0.dist-info/licenses/LICENSE,sha256=h6zptfibmvmQwYvAXPsLKTwaUHN6kacQq_fZ9xR-zPw,1083
2
+ fastkit/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ fastkit/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
4
+ fastkit/checks.py,sha256=-u6_6EikWLq9XmudtyRmZaz_vmetMg9FSjWbYK8sRyM,4687
5
+ fastkit/cli.py,sha256=brKOrCYLCkqfqPPqeF_uOG-_TOMA91Qp8ItNE32a__c,7760
6
+ fastkit/client.py,sha256=RpVdi01ENdGb2WrQlFMcjxf5T-9ktLL6Ra16ycZmPXM,3604
7
+ fastkit/hedge.py,sha256=0M9JVlV_4POBRNlvVTm87PuHhmaX-uNY_atTy2r-6H8,3139
8
+ fastkit/loop.py,sha256=9a8yKegvKYQj8WMnGoOeFhWw8I94zaLY_LGVPR8Qtyo,5966
9
+ fastkit/mock.py,sha256=s-2HEyb2YUN5Ty8ap-4xEs_ovxBHm8PxHRJVgJPgKzE,1250
10
+ fastkit/modes.py,sha256=c0CDePFgnapWbGuw5cFZAprGBvh5ZcJknNoDU9dZKwQ,651
11
+ fastkit/pipeline.py,sha256=c2ioZItlx__VW_VUbT-44A3EpeFn-MIawIOYSfVO61A,2021
12
+ fastkit/prompts.py,sha256=ZBRpA9Y0ElrjA5uHwPjlerIpjkoBwy8KvMByLYpWHIw,916
13
+ fastkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ fastkit/tools.py,sha256=9zO5uKmJiD5Nnus9cPO9nXDERANIAqV7dpQm1tw5AzY,2453
15
+ fastkit/types.py,sha256=iLOY_IvRHyI_5i7NFSGp5mXIZeWvZsa2jxmkjfuYiJI,1371
16
+ fastkit/evals/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
+ fastkit/evals/report.py,sha256=lleMizxkwfRoFsvVV8NMDoZxB-BnvgKW9hO8mC8jVgU,1904
18
+ fastkit/evals/run.py,sha256=he4IlBJ5BuQCnuyXmMXg2BJ8xO_CdRvXqhW7IjWu7Ho,3863
19
+ fastkit/evals/schema.py,sha256=CUn_qXfDms_qoeoPACKmJ3IGRMZ_sBzQJwepNlJv2Tg,1300
20
+ fastkit/evals/shop_gold.jsonl,sha256=soI_JRtB6mHyQgaxGvx8am4044IkjEi8TQDIwV2Q1hI,3476
21
+ fastkit/examples/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
+ fastkit/examples/shop/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
+ fastkit/examples/shop/agent.py,sha256=r6MeyDCiIMepXe96-m_0McBAVBHz92W-ULiKKRu5PWM,4133
24
+ fastkit/examples/shop/data.py,sha256=yoDLFDiz9-DA86fp0V5BrKgfVglh3HscfZLPtHBwRuw,1642
25
+ fastkit/examples/shop/demo_model.py,sha256=CV-T6zFC2aQoYOu5n8XuxtyIuo7IvSrMu1AGMsAHciI,3069
26
+ fastkit/examples/shop/tools.py,sha256=rbdznsCmOqr1JsZFr0H9qIFROHe7B2StlgrCenTvAgo,4287
27
+ fastkit/scaffold/README.md,sha256=nXtS7JwRHfCKifnSPT263fHJku59x1ZeJk8k6Alsiq0,606
28
+ fastkit/scaffold/agent.py,sha256=Pc1mHo-t2PCAg5B54XCjor7nCn9brmKATO5qENmeaNE,3627
29
+ fastkit/scaffold/gold.jsonl,sha256=R1C-UXPGSLEIv077-AJxG6u8LGOcBwQUusjsy5VESzk,378
30
+ fastkit/scaffold/tools.py,sha256=Un6euayO7NACdiLahRSYK5-3ckyj7JMg7dFz5vsCp-w,1475
31
+ fastkit/templates/fast_system.md,sha256=m9cl84Vk_k9vb9-TSfgplWcWAtukXzzWVP8oOtJGER8,1390
32
+ fastkit/templates/notes.md,sha256=oxLTDpYGuxMktuyuv-xn2HLvHkj-uOwAmEmOnje1g1E,366
33
+ fastkit/templates/reader.md,sha256=tFYlSIqcMoWLErQ9Jh1FgAHMPLa-f56rgWvV_EyV5Lc,414
34
+ fastkit/templates/writer.md,sha256=9pe5XJVD08TsTx62tyNDAAxs7jUcggATN0Ry6K6ia68,309
35
+ fast_mode_kit-0.2.0.dist-info/METADATA,sha256=H3l1hCE2ejFAB5_TTrRECqHKz88LWx59O5XZDq2BgKM,5168
36
+ fast_mode_kit-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
37
+ fast_mode_kit-0.2.0.dist-info/entry_points.txt,sha256=S9R4Ha1DdsZ-FdqTLXELBR4uRboaB-GBxDnj5Jxcenc,45
38
+ fast_mode_kit-0.2.0.dist-info/top_level.txt,sha256=ublpjaGX1NVzyRCpSCqknBi8ZOI0cEcOBGDZmVKRf2s,8
39
+ fast_mode_kit-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fastkit = fastkit.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 fast-mode-kit contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ fastkit
fastkit/__init__.py ADDED
File without changes
fastkit/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
fastkit/checks.py ADDED
@@ -0,0 +1,131 @@
1
+ """Do-no-harm figure checks.
2
+
3
+ Rules learned the hard way:
4
+ - an identifier that appears in the rows is sourced, even if it has digits;
5
+ - a zero or negative that answers the question is said, never trimmed;
6
+ - a sentence is dropped only when it has an unsourced figure AND no sourced
7
+ one; a check never leaves an empty answer or a fragment.
8
+ Every edit a check makes to a correct line is a bug in the check.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import re
14
+ from typing import Any, Iterable
15
+
16
+ log = logging.getLogger(__name__)
17
+
18
+ _NUM = re.compile(r"(?<![\w.])-?\d[\d,]*(?:\.\d+)?%?")
19
+ _RANGE_DASH = re.compile(r"(?<=\d)-(?=\d)")
20
+ _LIST_MARK = re.compile(r"^\s*\d+[.)]\s+")
21
+ _TOKEN = re.compile(r"[A-Za-z0-9][A-Za-z0-9_\-./]*[A-Za-z0-9]|[A-Za-z0-9]")
22
+ _SENT = re.compile(r"(?<=[.!?])\s+")
23
+
24
+
25
+ def _walk(obj: Any) -> Iterable[Any]:
26
+ if isinstance(obj, dict):
27
+ for v in obj.values():
28
+ yield from _walk(v)
29
+ elif isinstance(obj, (list, tuple)):
30
+ for v in obj:
31
+ yield from _walk(v)
32
+ else:
33
+ yield obj
34
+
35
+
36
+ def _row_facts(rows: list[dict], question: str) -> tuple[list[float], set[str]]:
37
+ nums: list[float] = []
38
+ strings: set[str] = set()
39
+ for v in _walk(rows):
40
+ if isinstance(v, bool):
41
+ continue
42
+ if isinstance(v, (int, float)):
43
+ nums.append(float(v))
44
+ elif isinstance(v, str):
45
+ strings.add(v.lower())
46
+ try:
47
+ nums.append(float(v.replace(",", "")))
48
+ except ValueError:
49
+ pass
50
+ for m in _NUM.finditer(question):
51
+ nums.append(float(m.group(0).rstrip("%").replace(",", "")))
52
+ strings.add(question.lower())
53
+ return nums, strings
54
+
55
+
56
+ def _matches(text: str, nums: list[float]) -> bool:
57
+ pct = text.endswith("%")
58
+ raw = text.rstrip("%").replace(",", "")
59
+ try:
60
+ val = float(raw)
61
+ except ValueError:
62
+ return False
63
+ decimals = len(raw.split(".")[1]) if "." in raw else 0
64
+ for n in nums:
65
+ for cand in ((n, n * 100) if pct else (n,)):
66
+ for c in (cand, abs(cand)) if val >= 0 else (cand,):
67
+ # "fell 3.2%" states -3.2 without its sign
68
+ if round(c, decimals) == val or c == val:
69
+ return True
70
+ if decimals == 0 and abs(c) >= 1 and round(c) == val:
71
+ return True
72
+ return False
73
+
74
+
75
+ def _figures(answer: str) -> list[tuple[str, bool]]:
76
+ """(figure, is_identifier) pairs found in the answer."""
77
+ out = []
78
+ spans = []
79
+ for tok in _TOKEN.finditer(answer):
80
+ t = tok.group(0)
81
+ if not any(c.isdigit() for c in t):
82
+ continue
83
+ # codes (SKU-1042), dates (2026-11-01), versions: identifiers, not quantities
84
+ if re.fullmatch(r"\d+(?:\.\d+)?-\d+(?:\.\d+)?", t):
85
+ continue # a range like 10-20: two plain numbers, handled below
86
+ if any(c.isalpha() for c in t) or "-" in t.lstrip("-") or t.count(".") > 1 or "/" in t:
87
+ out.append((t, True))
88
+ spans.append(tok.span())
89
+ for m in _NUM.finditer(_RANGE_DASH.sub(" ", answer)):
90
+ if any(a <= m.start() < b for a, b in spans):
91
+ continue
92
+ out.append((m.group(0).rstrip(".,"), False))
93
+ return out
94
+
95
+
96
+ def ungrounded_figures(answer: str, rows: list[dict], question: str = "") -> list[str]:
97
+ nums, strings = _row_facts(rows, question)
98
+ bad = []
99
+ for fig, ident in _figures(answer):
100
+ if ident:
101
+ if not any(fig.lower() in s for s in strings):
102
+ bad.append(fig)
103
+ elif not _matches(fig, nums):
104
+ bad.append(fig)
105
+ return bad
106
+
107
+
108
+ def safe_filter(answer: str, rows: list[dict], question: str = "") -> str:
109
+ """Drop a sentence only when every figure in it is unsourced. Lines and
110
+ list markers are kept as written; never returns an empty answer."""
111
+ out_lines = []
112
+ dropped = False
113
+ for line in answer.split("\n"):
114
+ mark = _LIST_MARK.match(line)
115
+ prefix, body = (line[:mark.end()], line[mark.end():]) if mark else ("", line)
116
+ kept = []
117
+ for sent in _SENT.split(body):
118
+ figs = [f for f, _ in _figures(sent)]
119
+ bad = ungrounded_figures(sent, rows, question)
120
+ if bad and len(bad) == len(figs):
121
+ log.info("dropped unsourced sentence: %r", sent)
122
+ dropped = True
123
+ continue
124
+ kept.append(sent)
125
+ if kept or not body.strip():
126
+ out_lines.append(prefix + " ".join(kept))
127
+ result = "\n".join(out_lines).strip()
128
+ if not result:
129
+ log.warning("check would empty the answer; keeping original")
130
+ return answer
131
+ return result if dropped else answer
fastkit/cli.py ADDED
@@ -0,0 +1,190 @@
1
+ """fastkit command line.
2
+
3
+ fastkit demo "top 3 products by revenue" answer one question with the example agent
4
+ fastkit init my-agent start a Fast mode project of your own
5
+ fastkit check --base-url URL --model NAME one tiny request: speed and tool calling
6
+ fastkit eval [--agent agent.py] [--gold g] accuracy, latency, cost, fallback report
7
+
8
+ Nothing here calls a paid API unless you pass --base-url.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import asyncio
14
+ import importlib.util
15
+ import os
16
+ import shutil
17
+ import sys
18
+ import time
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from .types import Message, Model
23
+
24
+ HERE = Path(__file__).resolve().parent
25
+ KEY_ENV = "FASTKIT_API_KEY"
26
+
27
+ PROBE_TOOL = {"type": "function", "function": {
28
+ "name": "get_time", "description": "Current time. Call this for any question about the time.",
29
+ "parameters": {"type": "object", "properties": {}}}}
30
+
31
+
32
+ def _client(base_url: str, model: str, key_env: str) -> Model:
33
+ from .client import ChatClient
34
+ return ChatClient(base_url, os.environ.get(key_env), model)
35
+
36
+
37
+ async def probe(model: Model, timeout: float = 30.0) -> dict[str, Any]:
38
+ """Send one tiny tool-call request. Time the first event and see if a tool was called."""
39
+ msgs = [Message(role="system", content="Use tools for facts. Never answer from memory."),
40
+ Message(role="user", content="What time is it?")]
41
+ t0 = time.monotonic()
42
+ first = None
43
+ called = False
44
+ error = None
45
+ text: list[str] = []
46
+ try:
47
+ async def go():
48
+ nonlocal first, called
49
+ async for ev in model.stream(msgs, tools=[PROBE_TOOL]):
50
+ if ev.kind in ("token", "tool_call") and first is None:
51
+ first = time.monotonic() - t0
52
+ if ev.kind == "tool_call":
53
+ called = True
54
+ elif ev.kind == "token":
55
+ text.append(ev.text)
56
+ elif ev.kind == "error":
57
+ raise RuntimeError(ev.text)
58
+ await asyncio.wait_for(go(), timeout)
59
+ except Exception as e: # report, never crash the check
60
+ error = f"{type(e).__name__}: {e}"
61
+ return {"first_event_s": first, "total_s": time.monotonic() - t0, "called_tool": called,
62
+ "text": "".join(text), "error": error}
63
+
64
+
65
+ def verdict(rep: dict[str, Any]) -> str:
66
+ if rep["error"]:
67
+ return f"FAIL: request failed ({rep['error']})"
68
+ lines = []
69
+ if not rep["called_tool"]:
70
+ lines.append("FAIL: answered without calling the tool. A one-call Fast loop needs a model that calls tools; "
71
+ "use the pipeline path or another model.")
72
+ f = rep["first_event_s"]
73
+ if f is None:
74
+ lines.append("FAIL: no output.")
75
+ elif f > 4.5:
76
+ lines.append(f"SLOW: first event after {f:.2f} s, over the 4.5 s first-words budget. Try another provider route or a smaller model.")
77
+ elif f > 2.5:
78
+ lines.append(f"OK but tight: first event {f:.2f} s. Little room for a second round.")
79
+ else:
80
+ lines.append(f"GOOD: first event {f:.2f} s.")
81
+ if rep["called_tool"] and not lines[0].startswith(("FAIL", "SLOW")):
82
+ lines.insert(0, "PASS: called the tool.")
83
+ return "\n".join(lines)
84
+
85
+
86
+ def cmd_check(a) -> int:
87
+ rep = asyncio.run(probe(_client(a.base_url, a.model, a.api_key_env)))
88
+ print(verdict(rep))
89
+ print(f"[first event {rep['first_event_s'] or 0:.2f}s, total {rep['total_s']:.2f}s]")
90
+ return 0 if rep["called_tool"] and not rep["error"] else 1
91
+
92
+
93
+ def cmd_demo(a) -> int:
94
+ from .examples.shop.agent import make_registry, run
95
+ reg = make_registry()
96
+ if a.base_url:
97
+ model = _client(a.base_url, a.model, a.api_key_env)
98
+ else:
99
+ from .examples.shop.demo_model import DemoModel, demo_vocab
100
+ model = DemoModel(reg, vocab=demo_vocab())
101
+ print("(offline stand-in model: it only matches words to tools. Pass --base-url for a real model.)\n")
102
+ out = asyncio.run(run(a.question, a.mode, model, registry=reg))
103
+ print(out["text"] or out["confirm"] or "(no answer)")
104
+ print(f"\n[first token {out['first_token_s'] or 0:.2f}s, total {out['total_s']:.2f}s, "
105
+ f"fallback={out['fell_back']}, tools={out['tools_called']}]")
106
+ return 0
107
+
108
+
109
+ def cmd_init(a) -> int:
110
+ dest = Path(a.path)
111
+ if dest.exists() and any(dest.iterdir()):
112
+ print(f"{dest} is not empty; pick a new folder.", file=sys.stderr)
113
+ return 1
114
+ dest.mkdir(parents=True, exist_ok=True)
115
+ for f in (HERE / "scaffold").iterdir():
116
+ if f.is_file() and f.suffix in (".py", ".md", ".jsonl"):
117
+ shutil.copy(f, dest / f.name)
118
+ shutil.copytree(HERE / "templates", dest / "prompts")
119
+ print(f"Created {dest}. Next:\n cd {dest}\n python agent.py \"how many widgets in stock?\"\n"
120
+ f"Then read {dest / 'README.md'}.")
121
+ return 0
122
+
123
+
124
+ def _load_agent(path: str):
125
+ p = Path(path).resolve()
126
+ sys.path.insert(0, str(p.parent))
127
+ spec = importlib.util.spec_from_file_location("user_agent", p)
128
+ mod = importlib.util.module_from_spec(spec)
129
+ spec.loader.exec_module(mod)
130
+ return mod
131
+
132
+
133
+ def cmd_eval(a) -> int:
134
+ from .evals.report import markdown
135
+ from .evals.run import client_agent, load_gold, mock_agent, run_eval
136
+ gold = a.gold or (None if a.agent else str(HERE / "evals" / "shop_gold.jsonl"))
137
+ if not gold:
138
+ print("--gold is required with --agent", file=sys.stderr)
139
+ return 2
140
+ if a.agent:
141
+ mod = _load_agent(a.agent)
142
+ model = mod.make_model(a.base_url, a.model, a.api_key_env)
143
+ agent = lambda q: mod.answer(q, model) # noqa: E731
144
+ elif a.base_url:
145
+ agent = client_agent(a.base_url, a.model, a.api_key_env, a.mode)
146
+ else:
147
+ agent = mock_agent()
148
+ price = (a.price_in, a.price_out) if a.price_in is not None and a.price_out is not None else None
149
+ if a.base_url and price is None:
150
+ print("note: no --price-in/--price-out, so cost shows $0 and the spend cap cannot stop the run")
151
+ results = asyncio.run(run_eval(load_gold(gold), agent, spend_cap_usd=a.spend_cap, price_per_mtok=price))
152
+ print(markdown(results))
153
+ return 0
154
+
155
+
156
+ def main(argv: list[str] | None = None) -> int:
157
+ ap = argparse.ArgumentParser(prog="fastkit", description="Build and measure a Fast answer mode.")
158
+ sub = ap.add_subparsers(dest="cmd", required=True)
159
+
160
+ def model_args(p, need_url=False):
161
+ p.add_argument("--base-url", required=need_url, help="OpenAI-compatible endpoint, e.g. http://localhost:11434/v1")
162
+ p.add_argument("--model", default="")
163
+ p.add_argument("--api-key-env", default=KEY_ENV, help=f"env var holding the key (default {KEY_ENV})")
164
+
165
+ p = sub.add_parser("demo", help="answer one question with the example shop agent")
166
+ p.add_argument("question")
167
+ p.add_argument("--mode", default="fast")
168
+ model_args(p)
169
+ p.set_defaults(fn=cmd_demo)
170
+
171
+ p = sub.add_parser("init", help="create a starter Fast mode project")
172
+ p.add_argument("path")
173
+ p.set_defaults(fn=cmd_init)
174
+
175
+ p = sub.add_parser("check", help="one tiny request: first-event time and tool calling")
176
+ model_args(p, need_url=True)
177
+ p.set_defaults(fn=cmd_check)
178
+
179
+ p = sub.add_parser("eval", help="run a gold set and print the report")
180
+ p.add_argument("--agent", help="your agent.py (from fastkit init); default: the example shop")
181
+ p.add_argument("--gold")
182
+ p.add_argument("--mode", default="fast")
183
+ p.add_argument("--price-in", type=float, help="USD per million input tokens")
184
+ p.add_argument("--price-out", type=float, help="USD per million output tokens")
185
+ p.add_argument("--spend-cap", type=float, default=0.50)
186
+ model_args(p)
187
+ p.set_defaults(fn=cmd_eval)
188
+
189
+ a = ap.parse_args(argv)
190
+ return a.fn(a)
fastkit/client.py ADDED
@@ -0,0 +1,77 @@
1
+ """Streaming client for any OpenAI-compatible chat endpoint.
2
+
3
+ Works with hosted APIs and local servers (mlx_lm.server, vLLM, Ollama,
4
+ llama.cpp). Use ``extra_body`` for server-specific fields, for example
5
+ ``{"adapters": "/abs/path"}`` so mlx_lm.server applies your LoRA adapter.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any, AsyncIterator
11
+
12
+ import httpx
13
+
14
+ from .types import Message, StreamEvent, ToolCall
15
+
16
+
17
+ class ChatClient:
18
+ def __init__(self, base_url: str, api_key: str | None, model: str,
19
+ extra_body: dict[str, Any] | None = None, timeout: float = 60.0,
20
+ transport: httpx.AsyncBaseTransport | None = None):
21
+ self.base_url = base_url.rstrip("/")
22
+ self.api_key = api_key
23
+ self.model = model
24
+ self.extra_body = extra_body or {}
25
+ self.timeout = timeout
26
+ self._transport = transport
27
+
28
+ async def stream(self, messages: list[Message], tools: list[dict] | None = None,
29
+ **kw: Any) -> AsyncIterator[StreamEvent]:
30
+ body: dict[str, Any] = {
31
+ "model": self.model,
32
+ "messages": [m.to_api() for m in messages],
33
+ "stream": True,
34
+ "stream_options": {"include_usage": True},
35
+ **self.extra_body, **kw,
36
+ }
37
+ if tools:
38
+ body["tools"] = tools
39
+ headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
40
+ partial: dict[int, dict[str, Any]] = {}
41
+ usage = None
42
+ async with httpx.AsyncClient(timeout=self.timeout, transport=self._transport) as http:
43
+ async with http.stream("POST", f"{self.base_url}/chat/completions",
44
+ json=body, headers=headers) as resp:
45
+ if resp.status_code >= 400:
46
+ text = (await resp.aread()).decode(errors="replace")[:300]
47
+ yield StreamEvent(kind="error", text=f"HTTP {resp.status_code}: {text}")
48
+ return
49
+ async for line in resp.aiter_lines():
50
+ if not line.startswith("data:"):
51
+ continue
52
+ data = line[5:].strip()
53
+ if data == "[DONE]":
54
+ break
55
+ chunk = json.loads(data)
56
+ usage = chunk.get("usage") or usage
57
+ for choice in chunk.get("choices") or []:
58
+ delta = choice.get("delta") or {}
59
+ if delta.get("content"):
60
+ yield StreamEvent(kind="token", text=delta["content"])
61
+ for tc in delta.get("tool_calls") or []:
62
+ slot = partial.setdefault(tc.get("index", 0),
63
+ {"id": "", "name": "", "args": ""})
64
+ slot["id"] = tc.get("id") or slot["id"]
65
+ fn = tc.get("function") or {}
66
+ slot["name"] = fn.get("name") or slot["name"]
67
+ slot["args"] += fn.get("arguments") or ""
68
+ for i in sorted(partial):
69
+ s = partial[i]
70
+ try:
71
+ args = json.loads(s["args"]) if s["args"] else {}
72
+ except json.JSONDecodeError:
73
+ yield StreamEvent(kind="error", text=f"bad tool arguments for {s['name']}")
74
+ continue
75
+ yield StreamEvent(kind="tool_call",
76
+ tool_call=ToolCall(id=s["id"] or f"call_{i}", name=s["name"], arguments=args))
77
+ yield StreamEvent(kind="done", usage=usage)
File without changes
@@ -0,0 +1,48 @@
1
+ """Markdown report: accuracy, latency spread, cost, fallback rate."""
2
+ from __future__ import annotations
3
+
4
+ from collections import defaultdict
5
+
6
+ from .schema import Result
7
+
8
+
9
+ def _pct(values: list[float], p: float) -> float:
10
+ s = sorted(values)
11
+ if not s:
12
+ return 0.0
13
+ k = (len(s) - 1) * p
14
+ lo, hi = int(k), min(int(k) + 1, len(s) - 1)
15
+ return s[lo] + (s[hi] - s[lo]) * (k - lo)
16
+
17
+
18
+ def markdown(results: list[Result]) -> str:
19
+ n = len(results) or 1
20
+ ft = [r.first_token_s for r in results if r.first_token_s is not None]
21
+ tot = [r.total_s for r in results]
22
+ rows = [
23
+ ("questions", str(len(results))),
24
+ ("accuracy", f"{round(100 * sum(r.correct for r in results) / n)}%"),
25
+ ("median first token", f"{_pct(ft, .5):.2f} s"),
26
+ ("p95 first token", f"{_pct(ft, .95):.2f} s"),
27
+ ("worst first token", f"{max(ft, default=0):.2f} s"),
28
+ ("median total", f"{_pct(tot, .5):.2f} s"),
29
+ ("total cost", f"${sum(r.cost_usd for r in results):.4f}"),
30
+ ("fallback rate", f"{round(100 * sum(1 for r in results if r.fell_back) / n)}%"),
31
+ ("answers with unsourced figures", str(sum(1 for r in results if r.ungrounded))),
32
+ ]
33
+ by_lang: dict[str, list[Result]] = defaultdict(list)
34
+ for r in results:
35
+ by_lang[r.language].append(r)
36
+ for lang, rs in sorted(by_lang.items()):
37
+ rows.append((f"accuracy ({lang})", f"{sum(r.correct for r in rs)}/{len(rs)}"))
38
+ reasons: dict[str, int] = defaultdict(int)
39
+ for r in results:
40
+ if r.fell_back:
41
+ reasons[r.fell_back] += 1
42
+ for k, v in sorted(reasons.items()):
43
+ rows.append((f"fallback: {k}", str(v)))
44
+ lines = ["| measure | value |", "|---|---|", *[f"| {a} | {b} |" for a, b in rows]]
45
+ misses = [r.id for r in results if not r.correct]
46
+ if misses:
47
+ lines += ["", "Missed: " + ", ".join(misses)]
48
+ return "\n".join(lines)